Ghost API 태그 삭제가 안 되는 이유: 빈 배열과 PATCH 시맨틱의 함정
Ghost MCP 개발 중 발견한 태그 삭제 silent failure. spread-if 패턴이 PATCH 요청에서 왜 위험한지, 그리고 빈 배열을 명시적으로 전송해야 하는 이유를 정리합니다.
1. 문제 상황
증상: 마크다운에서 태그를 지웠는데 Ghost엔 그대로 남아있음
ghost-mcp(Ghost 블로그 관리 MCP 서버)에서 로컬 마크다운 파일을 Ghost draft로 push하는 ghost_push_local 도구를 사용하던 중 한 가지 이상한 현상을 발견했습니다.
처음에는 태그가 있는 상태로 push합니다.
# ~/blog-drafts/my-post.md (첫 번째 버전)
---
slug: my-post
tags: [javascript, debugging]
---
# My Post
본문...
이 상태로 push하면 Ghost에 javascript, debugging 태그가 정상적으로 붙습니다. 그런데 나중에 마음을 바꾸어 태그를 모두 지우고 다시 push했습니다.
# ~/blog-drafts/my-post.md (두 번째 버전 - tags 제거)
---
slug: my-post
---
# My Post
본문...
기대: Ghost에서도 태그가 제거될 것이다.
결과: Ghost에는 여전히 javascript, debugging 태그가 남아 있습니다.
아무 에러도 발생하지 않았고, ghost_push_local은 "updated"라고 응답했으며, 로그도 정상이었습니다. 그런데 Ghost에는 구 태그가 고스란히 남아 있었습니다.
영향 범위
이런 유형의 버그는 체감보다 범위가 넓습니다.
- 블로그를 자동화 파이프라인으로 관리할수록 축적 효과가 큽니다. 한 번 잘못된 태그가 붙으면 지워지지 않으니까요
- 태그 분류를 재설계할 때 "구 태그가 사라지지 않음"이라는 문제로 나타납니다
- 66개 포스트를 전수 감사하는 과정에서 **여러 포스트에 "유령 태그"**가 남아 있음을 발견했습니다
- 단위 테스트는 전부 통과합니다. 문제는 통합 레벨에서만 드러나는 전형적인 silent failure입니다
"에러 없음 ≠ 정상 동작"이라는 교훈이 새삼스럽게 느껴지는 순간입니다.
2. 원인 분석
2.1 REST PATCH의 시맨틱
REST API에서 리소스를 부분 수정할 때는 주로 PATCH(또는 Ghost의 경우 PUT /posts/:id/)를 사용합니다. 이때 한 가지 질문이 있습니다.
"요청 body에 필드를 포함하지 않으면 서버는 어떻게 해석할까요?"
대부분의 API는 이렇게 동작합니다.
| 요청 body | 서버 동작 |
|---|---|
{ title: "새 제목" } |
title만 업데이트, 다른 필드는 그대로 유지 |
{ title: "새 제목", tags: [] } |
title 업데이트 + 태그 전부 제거 |
{ title: "새 제목", tags: null } |
벤더마다 다름 (Ghost는 에러) |
핵심은 "필드 부재"와 "빈 값"이 완전히 다른 의미라는 점입니다.
- 필드가 없음 = "건드리지 말라(no-op)"
- 빈 배열
[]= "이 필드를 비워라(clear)" null= 벤더마다 다름 (삭제 의도일 수도, 에러일 수도)
이 규칙은 RFC 7396(JSON Merge Patch)에도 명시되어 있습니다. 즉, Ghost만의 특이점이 아니라 REST PATCH의 보편적 시맨틱입니다. 그런데 이 시맨틱을 잊는 순간, 코드는 사용자의 의도와 완전히 다른 동작을 합니다.
2.2 함정의 시작: spread-if 패턴
TypeScript/JavaScript에서는 조건부로 객체 필드를 포함하는 유명한 패턴이 있습니다.
const payload = {
title: "새 제목",
...(tags.length > 0 && { tags }), // ← spread-if 패턴
};
이 패턴은 tags.length > 0일 때만 tags 필드를 포함하고, 그렇지 않으면 아예 생략합니다. false를 spread 하면 아무 일도 일어나지 않기 때문에 작동합니다.
// 케이스 A: tags.length > 0 === true
{ title: "새 제목", ...{ tags: [{ name: 'a' }] } }
// 결과: { title: "새 제목", tags: [{ name: 'a' }] }
// 케이스 B: tags.length === 0
{ title: "새 제목", ...false }
// 결과: { title: "새 제목" } ← tags 필드 자체가 없음!
POST /posts/(생성)에서는 이 패턴이 잘 작동합니다. "값이 없으면 필드 자체를 안 보내서 서버가 기본값을 쓰게 한다"는 의도는 타당해 보입니다.
하지만 PUT /posts/:id/(부분 수정)에서는 치명적입니다. 태그가 비어 있다는 것은 "건드리지 말아 달라"가 아니라 **"전부 지워 달라"**는 사용자의 의도일 수 있기 때문입니다.
2.3 실제 코드에서 발견된 버그
ghost-mcp의 src/tools/sync-tools.ts에는 다음과 같은 코드가 있었습니다.
// ❌ 버그가 있는 코드
await ghost.updatePost({
id: current.id,
updated_at: current.updated_at,
title: parsed.title,
mobiledoc: toMobiledoc(parsed.body),
slug: parsed.slug || undefined,
meta_title: parsed.metaTitle || undefined,
meta_description: parsed.metaDescription || undefined,
custom_excerpt: parsed.excerpt || undefined,
...(parsed.tags.length > 0 && {
tags: parsed.tags.map(name => ({ name })),
}), // ← 여기가 함정
});
이 코드는 두 가지 상황에서 다르게 동작합니다.
| 파일 상태 | 생성되는 payload | Ghost 동작 |
|---|---|---|
tags: [a, b] |
{ ..., tags: [{name:'a'},{name:'b'}] } |
태그를 [a, b]로 교체 |
tags: [] 또는 필드 없음 |
{ ... } (tags 필드 자체가 없음) |
기존 태그 유지(no-op) |
개발자는 "태그가 없으면 안 보내는 게 자연스러워 보였다"고 말할 수 있습니다. 하지만 사용자 관점에서는 "내가 마크다운에서 태그를 지웠으니 Ghost에서도 지워져야 한다"는 기대입니다. 이 두 관점이 충돌하는 순간 silent failure가 발생합니다.
흥미롭게도, 이 버그는 생성 시점에는 없었습니다. ghost_create_post(POST)에서는 tags 필드를 생략해도 "기본값 = 빈 태그"로 처리되므로 결과가 같습니다. 문제는 이 패턴을 복붙으로 update 쪽에 가져오면서 시맨틱의 차이를 놓친 것입니다.
3. 해결 방법
3.1 1줄 수정: spread-if 제거
고치는 방법은 간단합니다.
// ✅ 수정된 코드
await ghost.updatePost({
id: current.id,
updated_at: current.updated_at,
title: parsed.title,
mobiledoc: toMobiledoc(parsed.body),
slug: parsed.slug || undefined,
meta_title: parsed.metaTitle || undefined,
meta_description: parsed.metaDescription || undefined,
custom_excerpt: parsed.excerpt || undefined,
tags: parsed.tags.map(name => ({ name })), // ← 항상 전송
});
이제 parsed.tags가 빈 배열이면 payload에는 tags: []가 포함되어 전송되고, Ghost는 이를 "태그 제거"로 해석합니다.
중요한 포인트: 다른 필드들(slug, meta_title 등)은 여전히 || undefined 패턴을 사용합니다. 왜 배열만 다르게 취급할까요?
- 문자열 필드: 사용자가 빈 문자열을 의도했는지, 값이 없어서 생략하려는지 구분하기 어렵고, 대부분 "값이 없으면 건드리지 말라"가 자연스러운 기대입니다
- 배열 필드: 빈 배열은 "비우라"는 명확한 의미를 가지며, 사용자의 삭제 의도를 표현하는 방식이기도 합니다
즉, 필드 타입과 사용자의 의도를 매핑해야 합니다. 모든 필드를 일괄적으로 처리하는 것이 깔끔해 보이지만, 실제로는 각 필드의 시맨틱을 개별적으로 이해해야 합니다.
3.2 diff
변경된 부분만 추려내면 다음과 같습니다.
- ...(parsed.tags.length > 0 && { tags: parsed.tags.map(name => ({ name })) }),
+ tags: parsed.tags.map(name => ({ name })),
1줄 수정이지만, 의미론적으로는 **"spread-if 패턴의 위험성"**이라는 큰 주제를 담고 있습니다.
3.3 회귀 테스트 추가
1줄 수정으로 끝냈다면 나중에 누군가 "태그가 없을 때 필드를 안 보내는 게 깔끔하지 않나?"라며 되돌릴 수 있습니다. 그래서 회귀 테스트를 추가했습니다.
// src/tools/tools.test.ts — tag-clearing regression test
describe('ghost_push_local: tag clearing', () => {
it('sends tags: [] when frontmatter has no tags', async () => {
const mockGhost = {
findPostBySlug: vi.fn().mockResolvedValue({
id: 'post-id',
updated_at: '2026-04-01T00:00:00.000Z',
}),
updatePost: vi.fn().mockResolvedValue({ url: 'https://example.com/p' }),
};
// 태그 없는 마크다운
const markdown = [
'---',
'slug: my-post',
'---',
'# Test',
'Body',
].join('\n');
// filename만 던지고 handler 내부에서 파일을 읽는다고 가정
await handlePushLocal({ filename: 'my-post.md' }, mockGhost);
// ✅ updatePost가 tags: [] 를 포함해서 호출되어야 함
expect(mockGhost.updatePost).toHaveBeenCalledWith(
expect.objectContaining({
tags: [], // ← 핵심 검증
})
);
});
});
이 테스트는 두 가지를 검증합니다.
tags필드가 payload에 포함되는가?- 그 값이 정확히
[]인가?
expect.objectContaining으로 tags: []가 있음을 확인하면, 누군가 나중에 ...(tags.length > 0 && ...)로 되돌렸을 때 테스트가 즉시 실패합니다. 필드 자체가 사라지면 objectContaining의 매칭이 실패하기 때문입니다.
3.4 이 버그를 놓친 이유 — 테스트의 빈틈
흥미로운 점은, 원래 코드에도 "태그가 있는 경우"에 대한 테스트는 있었다는 것입니다.
// 기존 테스트 — 긍정 케이스만 있음
it('sends tags when frontmatter has tags', async () => {
const markdown = `---\nslug: p\ntags: [a, b]\n---\n# T\n`;
// ... 파일 생성 ...
await handlePushLocal({ filename: 'p.md' }, mockGhost);
expect(mockGhost.updatePost).toHaveBeenCalledWith(
expect.objectContaining({
tags: [{ name: 'a' }, { name: 'b' }],
})
);
});
하지만 **"태그가 없는 경우"의 음성 테스트(negative test)**는 없었습니다. 즉, 다음 질문들이 검증되지 않았습니다.
- 빈 태그를 전송하면 어떻게 되는가?
- Ghost가 이 때 어떻게 반응하는가?
- Ghost에 기대하는 최종 상태는 무엇인가?
교훈: 긍정 케이스만 테스트하는 습관은 silent failure의 온상이 됩니다. 사용자 관점에서 "반대 경우"가 있다면 반드시 테스트를 추가해야 합니다.
4. 핵심 개념 정리
REST PATCH에서 필드의 상태 의미
| 표현 | 의미 | Ghost에서의 예시 |
|---|---|---|
| 필드 부재 | 건드리지 말라 | { title: "x" } — 나머지 필드 유지 |
빈 배열 [] |
비워라 | { tags: [] } — 태그 전부 제거 |
빈 문자열 "" |
벤더마다 다름 | { slug: "" } — 대부분 에러 |
null |
벤더마다 다름 | { tags: null } — Ghost는 에러 |
| 기존 값 전달 | 변경 없음(멱등) | { title: "기존 제목" } |
spread-if 패턴의 적절한 사용처
| 상황 | 사용 가능? | 이유 |
|---|---|---|
POST /resource/ (생성) |
✅ | 기본값으로 채워짐, 의미 충돌 없음 |
PUT/PATCH — 단일 스칼라 필드 |
⚠️ | 사용자 의도 명확히 한 후 사용 |
PUT/PATCH — 배열/리스트 필드 |
❌ | 빈 값이 "clear"를 의미할 가능성 높음 |
| 사용자 삭제 의도가 있는 필드 | ❌ | 항상 명시적으로 [] 전송 |
5. 베스트 프랙티스
체크리스트
- [ ] 배열 필드는 항상 명시적으로 전송하기 —
[]이든 값이 있든 - [ ] PATCH 요청 payload를 직렬화해서 실제 네트워크로 나가는 모습을 로그로 확인
- [ ] 음성 테스트(negative test) 추가 — "값이 없을 때", "빈 값일 때" 케이스
- [ ] API 벤더 문서에서 "부분 업데이트 시맨틱" 확인하기
- [ ] spread-if 패턴은 생성(
POST)에만 사용하고, 수정(PUT/PATCH)에는 주의 - [ ] 필드의 의미를 사용자 관점에서 명시적으로 정의하기 ("빈 배열 = 지우기", "필드 부재 = 건드리지 않음")
예방 코드 스니펫: 의도 타입
필드별 의도를 분명하게 하고 싶다면, undefined와 []를 구분하는 태그드 유니온을 만들 수 있습니다.
// 의도를 타입으로 명시
type UpdateField<T> =
| { action: 'keep' } // 건드리지 않음
| { action: 'set'; value: T } // 새 값으로 설정
| { action: 'clear' }; // 비우기
interface UpdatePostIntent {
id: string;
updated_at: string;
tags: UpdateField<Tag[]>;
title: UpdateField<string>;
}
// 호출부 — 의도가 읽는 사람에게 명확해짐
const intent: UpdatePostIntent = {
id,
updated_at,
tags: parsed.tags.length > 0
? { action: 'set', value: parsed.tags.map(t => ({ name: t })) }
: { action: 'clear' }, // ← "건드리지 말라"가 아니라 "비우라"
title: { action: 'set', value: parsed.title },
};
// 런타임에 Ghost payload로 변환
function toGhostPayload(intent: UpdatePostIntent) {
const payload: Record<string, unknown> = {
id: intent.id,
updated_at: intent.updated_at,
};
if (intent.tags.action === 'set') payload.tags = intent.tags.value;
if (intent.tags.action === 'clear') payload.tags = [];
// 'keep'은 필드를 생성하지 않음
if (intent.title.action === 'set') payload.title = intent.title.value;
if (intent.title.action === 'clear') payload.title = '';
return payload;
}
이 방식은 조금 verbose 하지만, "필드를 건드리지 않는 것"과 "필드를 비우는 것"을 타입 수준에서 강제하므로 같은 실수를 반복하기 어렵습니다. 코드 리뷰어도 action: 'clear' 같은 키워드를 보면 즉시 의도를 파악할 수 있습니다.
예방 코드 스니펫: 필드별 명시적 변환
더 가볍게 가고 싶다면, 필드마다 빌더 함수를 만드는 방법도 있습니다.
// tags 필드 전용 빌더
function buildTags(input: string[] | undefined): Tag[] {
// undefined면 호출부에서 판단, 여기서는 항상 배열 반환
if (!input) return [];
return input.map(name => ({ name }));
}
// 사용
const payload = {
id,
updated_at,
title: parsed.title,
tags: buildTags(parsed.tags), // ← 의도가 함수명에 드러남
};
함수명이 buildTags라면, 호출자는 "내가 주는 값이 항상 네트워크에 실린다"는 기대를 가지게 됩니다. 조건부 생략의 여지가 사라지니 버그가 덜 생깁니다.
6. FAQ
Q: POST 요청에서도 같은 패턴을 쓰면 안 되나요?
A: POST(생성)에서는 괜찮습니다. 리소스가 아직 존재하지 않으므로 "필드 부재"와 "빈 값"이 같은 의미(기본값 적용)로 처리되기 때문입니다. 문제는 PUT/PATCH 같은 부분 수정에서만 발생합니다. 그래서 "생성 코드를 복붙해서 수정 코드를 만드는" 흐름에서 버그가 자주 숨어듭니다.
Q: Ghost가 아니라 다른 API(WordPress, Notion 등)에서도 같은 함정이 있나요?
A: 네, 보편적인 REST 시맨틱입니다. RFC 7396(JSON Merge Patch)에도 "필드가 없으면 기존 값 유지, null이면 삭제"로 정의되어 있습니다. 다만 벤더마다 null의 해석이 다르므로, 삭제 의도는 빈 배열이나 명시적인 삭제 메서드를 사용하는 것이 안전합니다. WordPress REST API, Notion API, GitHub REST API 모두 이 규칙을 따릅니다.
Q: 그럼 모든 배열 필드를 항상 보내야 하나요? 요청 크기가 커지지 않나요?
A: 배열 필드 중 "사용자가 비우려는 의도가 있을 수 있는 것"만 명시적으로 보내면 됩니다. 예를 들어 뉴스레터 발송 대상 목록 같은 읽기 전용 필드는 그대로 두어도 됩니다. 판단 기준은 **"빈 상태가 사용자에게 의미를 가지는가?"**입니다. 요청 크기가 걱정된다면 일부 필드만 선택적으로 명시하는 하이브리드 접근도 가능합니다.
Q: 회귀 테스트를 어디까지 추가해야 할지 모르겠습니다.
A: "버그가 한 번이라도 발견된 시나리오"는 무조건 추가합니다. 그 외에도 **"사용자 의도가 무엇인지 의문이 드는 경계 조건"**을 테스트로 만드는 것이 좋습니다. 예: 빈 배열, 매우 긴 배열, 중복 요소, null 포함 등. 특히 음성 테스트는 긍정 테스트만큼 중요합니다 — "X가 일어날 때 Y가 발생한다"만큼이나 "X가 일어나지 않을 때 Y도 일어나지 않는다"가 중요합니다.
Q: spread-if 대신 다른 패턴은 없나요?
A: 있습니다. 몇 가지 대안을 정리하면:
// 1. 기본값 사용 (항상 필드 포함)
{ tags: parsed.tags ?? [] }
// 2. 명시적 null 할당 (API가 지원한다면)
{ tags: parsed.tags.length > 0 ? parsed.tags : null }
// 3. 의도 타입 (위에서 소개한 UpdateField 패턴)
{ tags: parsed.tags.length > 0
? { action: 'set', value: parsed.tags }
: { action: 'clear' } }
// 4. 필드별 빌더 함수
{ tags: buildTags(parsed.tags) }
어떤 것을 선택할지는 팀의 코딩 스타일과 API 벤더의 지원 여부에 따라 다릅니다. 중요한 건 **"의도가 코드에 드러나야 한다"**는 점입니다.
Q: 이런 함정을 찾는 린트 규칙이 있나요?
A: 직접적으로는 없습니다. 하지만 eslint-plugin-no-spread-if를 만들거나, CI에서 PATCH payload의 스키마를 검증하는 파이프라인을 추가하는 방법으로 예방할 수 있습니다. 또는 위에서 소개한 UpdateField<T> 같은 명시적 타입을 팀 컨벤션으로 강제하는 것도 효과적입니다. 팀 규모에 따라 적절한 수준을 고르면 됩니다.
7. 참고 자료
- Ghost Admin API 공식 문서
- RFC 7396 — JSON Merge Patch
- Vitest expect.objectContaining 매처
- Ghost MCP 서버 저장소 (GitHub)
8. 다음 단계
이 글은 ghost-mcp 개발 과정에서 발견한 여러 기술적 이야기 중 하나입니다. 다음 편에서는 타입 시스템을 활용해 "읽기 전용 상태"를 어떻게 표현할지를 다룹니다 — Ghost의 sent 상태(뉴스레터 발송 후 시스템이 자동으로 설정하는 상태)를 예시로, Create/Update와 List에서 각각 다른 타입을 쓰는 설계 결정을 살펴봅니다.