Ghost API 태그 삭제가 안 되는 이유: 빈 배열과 PATCH 시맨틱의 함정

Ghost MCP 개발 중 발견한 태그 삭제 silent failure. spread-if 패턴이 PATCH 요청에서 왜 위험한지, 그리고 빈 배열을 명시적으로 전송해야 하는 이유를 정리합니다.

Ghost API 태그 삭제가 안 되는 이유: 빈 배열과 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: [],  // ← 핵심 검증
      })
    );
  });
});

이 테스트는 두 가지를 검증합니다.

  1. tags 필드가 payload에 포함되는가?
  2. 그 값이 정확히 []인가?

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. 참고 자료


8. 다음 단계

이 글은 ghost-mcp 개발 과정에서 발견한 여러 기술적 이야기 중 하나입니다. 다음 편에서는 타입 시스템을 활용해 "읽기 전용 상태"를 어떻게 표현할지를 다룹니다 — Ghost의 sent 상태(뉴스레터 발송 후 시스템이 자동으로 설정하는 상태)를 예시로, Create/Update와 List에서 각각 다른 타입을 쓰는 설계 결정을 살펴봅니다.