TypeScript로 읽기 전용 상태 표현하기: Ghost 'sent' 상태 타입 분리 설계

시스템이 자동 설정하는 상태를 타입으로 어떻게 강제할까요? Ghost 포스트의 sent 상태를 예시로, Read 타입과 Write 타입을 분리해 invariant를 컴파일 타임에 보장하는 설계 패턴을 정리합니다.

TypeScript로 읽기 전용 상태 표현하기: Ghost 'sent' 상태 타입 분리 설계

1. 문제 상황

Ghost의 sent 상태는 무엇인가

Ghost 블로그 플랫폼에서 포스트는 네 가지 상태(status) 중 하나를 가집니다.

상태 의미 누가 설정하는가?
draft 초안 사용자
scheduled 예약 발행 사용자
published 발행됨 사용자
sent 이메일로 발송됨 시스템(자동)

앞의 세 가지 상태는 사용자가 자유롭게 전환할 수 있습니다. draft → published, scheduled → draft, published → draft처럼 말이죠. 그런데 sent는 다릅니다.

sent 상태는 뉴스레터 발송이 성공했을 때 Ghost가 자동으로 설정하는 상태입니다.

구독자에게 이메일이 나간 순간, Ghost는 해당 포스트의 상태를 published에서 sent로 바꿉니다. 사용자는 Ghost Admin UI에서도, Admin API에서도 이 상태를 직접 설정할 수 없습니다. 설정하려고 하면 Ghost가 "invalid state transition" 같은 에러를 던지거나, 설정한 값이 무시됩니다.

즉, sent"조회는 가능하지만 쓰기는 불가능한" 상태입니다.

문제가 드러나는 순간: MCP 도구 설계

ghost-mcp에는 Ghost 포스트 목록을 조회하는 ghost_list_posts 도구가 있습니다. 이 도구는 status 파라미터로 포스트를 필터링할 수 있는데, 처음에는 이렇게 정의되어 있었습니다.

// ❌ 초기 버전: sent가 누락됨
server.tool(
  'ghost_list_posts',
  'List Ghost blog posts with optional filters',
  {
    status: z.enum(['draft', 'published', 'scheduled']).optional(),
    // ...
  },
  // ...
);

이 시점에 issue #10이 열렸습니다. "뉴스레터로 발송된 글만 보고 싶은데 sent 필터가 없다"는 것이었습니다.

맞는 말입니다. Ghost 블로그에서 지금까지 발송된 뉴스레터 목록을 확인하는 것은 정당한 사용 사례입니다. 그래서 sent를 추가하기로 했습니다.

그런데 간단히 "전부 허용"하면 안 되는 이유

처음에는 이렇게 생각할 수 있습니다. "그냥 네 가지 상태 모두 허용하는 union 하나 만들어서 재사용하면 되는 거 아닌가?"

// ⚠️ 단순해 보이지만 위험한 접근
type PostStatus = 'draft' | 'published' | 'scheduled' | 'sent';

interface GhostPost { status: PostStatus; }
interface GhostPostCreate { status: PostStatus; }  // ← 여기가 문제
interface GhostPostUpdate { status: PostStatus; }  // ← 여기도 문제

DRY 원칙만 보면 깔끔합니다. 하지만 이 설계는 불변성을 깨뜨립니다. 이제 사용자가 ghost_update_poststatus: 'sent'를 던질 수 있게 되고, 그러면 Ghost API가 런타임에 에러를 내거나 무시합니다. 타입 시스템은 "괜찮다"고 말하는데 런타임에 막히는 전형적인 실패입니다.

이 때 떠오르는 질문이 있습니다.

"쓰기 불가능한 상태를 어떻게 타입으로 표현할까?"


2. 원인 분석

2.1 상태 머신의 세 가지 관점

Ghost 포스트의 상태 머신에는 세 가지 관점이 있습니다.

┌─────────────┐                    ┌─────────────┐
│  사용자가   │   create / update  │ 서버가 받아   │
│  설정 가능  │──────────────────→│ 저장 가능한   │
│   상태      │                    │   상태       │
└─────────────┘                    └─────────────┘
  draft                              draft
  published                          published
  scheduled                          scheduled

┌─────────────────────────────────────────────┐
│  서버 응답에 실제로 존재할 수 있는 상태      │
│  (시스템이 자동 설정한 값 포함)              │
│  draft, published, scheduled, sent          │
└─────────────────────────────────────────────┘
  • 사용자가 설정 가능한 상태(쓰기 가능): draft, published, scheduled
  • 서버 응답에 나타날 수 있는 상태(읽기 가능): draft, published, scheduled, sent

이 두 집합은 다릅니다. 공통 요소는 많지만, sent는 읽기 전용입니다.

2.2 단일 union의 함정

만약 모든 인터페이스가 같은 union을 공유하면, 타입은 **"가능한 상태의 합집합"**을 표현하게 됩니다. 그런데 우리가 원하는 건 **"맥락별 부분집합"**입니다.

  • GhostPost.status: 네 가지 전부 가능 (Ghost 서버가 반환할 수 있는 모든 값)
  • GhostPostCreate.status: 세 가지만 가능 (사용자가 설정할 수 있는 값)
  • GhostPostUpdate.status: 세 가지만 가능 (사용자가 수정할 수 있는 값)

단일 union은 이 세 가지 맥락을 구분하지 못합니다. 결과적으로 "더 자유로운 쪽의 시맨틱"이 전체를 오염시키는 구조가 됩니다.

2.3 TypeScript가 exhaustive check에서 잃는 것

한 가지 더 무서운 것은, 단일 union을 쓰는 순간 exhaustive check가 거짓말을 하게 된다는 것입니다.

type PostStatus = 'draft' | 'published' | 'scheduled' | 'sent';

function describeUpdateAction(status: PostStatus): string {
  switch (status) {
    case 'draft': return '초안 상태로 저장합니다';
    case 'published': return '즉시 발행합니다';
    case 'scheduled': return '예약 발행합니다';
    case 'sent': return '???';  // ← 여기에 뭐라고 쓸 것인가?
  }
}

sent는 update에서 허용되지 않는 값인데, 타입 시스템이 이 케이스를 처리하라고 강요합니다. 결국 throw new Error('invalid') 같은 런타임 방어 코드를 써야 하고, 이는 "타입으로 불변성을 강제한다"는 TypeScript의 장점을 반쯤 포기하는 것입니다.


3. 해결 방법

3.1 타입 분리 원칙: "맥락별 부분집합"

해결책은 타입을 맥락별로 분리하는 것입니다.

// src/ghost/types.ts

// 서버가 반환할 수 있는 모든 상태 (읽기)
export interface GhostPost {
  id: string;
  title: string;
  slug: string;
  status: 'draft' | 'published' | 'scheduled' | 'sent';  // ← 4개
  // ...
}

// 사용자가 새로 만들 때 설정 가능한 상태 (생성)
export interface GhostPostCreate {
  title: string;
  slug?: string;
  status: 'draft' | 'published' | 'scheduled';  // ← 3개
  mobiledoc?: string;
  // ...
}

// 사용자가 수정할 때 설정 가능한 상태 (수정)
export interface GhostPostUpdate {
  id: string;
  updated_at: string;
  title?: string;
  slug?: string;
  status?: 'draft' | 'published' | 'scheduled';  // ← 3개
  // ...
}

핵심은 GhostPost.status에만 'sent'를 포함시키고, GhostPostCreate.statusGhostPostUpdate.status에는 제외한다는 점입니다. 처음엔 DRY 원칙을 위반하는 것처럼 보이지만, 실제로는 각 타입이 서로 다른 제약을 표현하는 올바른 설계입니다.

3.2 효과: 타입 시스템이 잘못된 호출을 미리 막는다

이제 사용자가 이런 코드를 작성하면:

// 잘못된 호출
await ghost.updatePost({
  id: '...',
  updated_at: '...',
  status: 'sent',  // ← 컴파일 에러!
});

TypeScript가 컴파일 타임에 에러를 냅니다.

Type '"sent"' is not assignable to type '"draft" | "published" | "scheduled"'.

런타임 Ghost API 에러를 기다릴 필요가 없습니다. IDE가 자동완성 단계에서 sent를 제안조차 하지 않습니다.

3.3 MCP 도구 파라미터 스키마에도 반영

MCP 도구 레벨에서도 같은 원칙을 적용합니다. Zod 스키마는 세 군데에서 각각 다르게 정의됩니다.

// src/tools/post-tools.ts

// List 도구는 'sent' 포함
server.tool(
  'ghost_list_posts',
  'List Ghost blog posts with optional filters',
  {
    status: z
      .enum(['draft', 'published', 'scheduled', 'sent'])
      .optional()
      .describe(
        'Filter by post status. "sent" returns posts that were emailed ' +
        'as a newsletter (system-set, read-only).'
      ),
    // ...
  },
  // ...
);

// Create/Update 도구는 'sent' 제외
server.tool(
  'ghost_update_post',
  'Update an existing Ghost post',
  {
    id: z.string(),
    status: z
      .enum(['draft', 'published', 'scheduled'])  // ← sent 없음
      .optional(),
    // ...
  },
  // ...
);

스키마 레벨에서 막혀 있으면, MCP 클라이언트(Claude 등)가 잘못된 값을 시도조차 하지 않습니다. 모델은 도구의 스키마를 보고 가능한 값을 추론하기 때문입니다.

describe() 주석에 "system-set, read-only"를 명시한 점이 중요합니다. LLM이 이 설명을 읽고 "아 이건 내가 설정할 수 없는 값이구나"라고 이해하게 됩니다.

3.4 통합 테스트로 실제 동작까지 검증

타입으로 막았다고 해서 런타임이 완벽하다는 보장은 없습니다. Ghost API가 실제로 status=sent 필터를 받는지도 확인해야 합니다.

// src/tools/tools.test.ts

describe('ghost_list_posts: sent status filter', () => {
  it('passes status=sent through to the Ghost API', async () => {
    const mockGhost = {
      getPosts: vi.fn().mockResolvedValue({
        posts: [
          { id: '1', title: 'Newsletter A', status: 'sent' },
          { id: '2', title: 'Newsletter B', status: 'sent' },
        ],
        pagination: { total: 2 },
      }),
    };

    const result = await handleListPosts(
      { status: 'sent' },
      mockGhost
    );

    // Ghost API 호출에 sent가 그대로 전달되었는지
    expect(mockGhost.getPosts).toHaveBeenCalledWith(
      expect.objectContaining({ status: 'sent' })
    );

    // 응답에도 sent 상태가 포함되는지
    expect(result).toContain('Newsletter A');
    expect(result).toContain('sent');
  });
});

이 테스트는 두 가지를 보장합니다.

  1. MCP 스키마가 sent를 허용한다 — 파라미터 검증이 통과됨
  2. 내부 Ghost 클라이언트가 sent를 그대로 전달한다 — 중간에 필터링/변환되지 않음

3.5 주석으로 "왜 비대칭인지"를 기록

미래의 개발자(혹은 미래의 자신)가 "왜 sent가 create에는 없는 걸까?"라고 헷갈리지 않도록, 커밋 메시지와 코드 주석에 의도를 남겼습니다.

// src/ghost/types.ts

/**
 * Ghost post representation returned by the API.
 *
 * NOTE: `status` includes 'sent' because Ghost automatically transitions
 * a post to 'sent' after a successful newsletter send. It is a read-only
 * system state — do NOT add it to GhostPostCreate / GhostPostUpdate,
 * which would allow invalid state transitions from user input.
 */
export interface GhostPost {
  status: 'draft' | 'published' | 'scheduled' | 'sent';
  // ...
}

"왜 이렇게 설계했는가"는 코드에 담기지 않으면 반드시 잊힙니다. 리팩토링 중에 DRY 원칙을 잘못 적용해서 하나로 합치는 실수가 반복되지 않도록, "하지 말 것"과 그 이유를 함께 적어두는 것이 좋습니다.


4. 핵심 개념 정리

타입 분리의 세 가지 맥락

타입 역할 sent 포함? 이유
GhostPost 서버 응답 (읽기) 서버가 반환할 수 있는 모든 값
GhostPostCreate 생성 요청 사용자가 설정할 수 없는 값 제외
GhostPostUpdate 수정 요청 사용자가 설정할 수 없는 값 제외

DRY vs 정확성 — 언제 분리할까?

상황 단일 union 사용 타입 분리
모든 맥락에서 같은 값 허용 ✅ 적절 과도함
읽기와 쓰기에 다른 제약 ❌ 위험 ✅ 적절
역할별 권한 차이 ❌ 위험 ✅ 적절
생애주기의 일부 상태만 허용 ❌ 위험 ✅ 적절
시스템이 관리하는 필드 포함 ❌ 위험 ✅ 적절

대칭 vs 비대칭 상태

대칭 상태 (사용자가 자유롭게 전환)
  draft ↔ published ↔ scheduled

비대칭 상태 (시스템만 설정)
  published ──newsletter_sent──→ sent
                                  │
                                  │ (사용자 수정 불가)
                                  ▼
                                 (final state)

비대칭 상태가 있으면 타입 분리가 필요하다는 신호입니다.


5. 베스트 프랙티스

체크리스트

  • [ ] API 응답에 나타날 수 있는 모든 상태를 먼저 파악하기 (문서 + 실제 운영 로그 확인)
  • [ ] 각 상태를 "누가 설정하는가"로 분류하기 — 사용자 / 시스템 / 외부 이벤트
  • [ ] 시스템이 자동 설정하는 상태는 읽기 전용 타입에만 포함
  • [ ] Create/Update 타입에서 시스템 전용 상태 제외 — 런타임 에러를 컴파일 타임으로 끌어올리기
  • [ ] MCP 도구나 API 스키마의 파라미터 enum도 같은 원칙 적용
  • [ ] "왜 이 타입에는 이 값이 없는가"를 주석으로 기록 — 미래의 DRY 리팩토링 유혹 차단
  • [ ] 통합 테스트로 실제 API 동작 검증 — 타입만 믿지 말고 런타임도 확인

일반화: 다른 도메인에도 적용되는 패턴

이 패턴은 Ghost에만 국한되지 않습니다. 여러 도메인에서 같은 구조가 반복됩니다.

도메인 시스템 전용 상태 예시
결제 refunded, disputed, charged_back 은행/카드사가 설정
주문 delivered, returned 물류 시스템이 설정
사용자 banned, locked, archived 관리자/보안 시스템이 설정
작업 큐 completed, failed, retried 워커가 설정
문서 archived, deleted 시스템 정책이 설정

이런 도메인에서도 Read 타입과 Write 타입을 분리해서 "시스템만 설정 가능한 상태"를 Write에서 제외하면 같은 안전성을 얻을 수 있습니다.


6. FAQ

Q: DRY 원칙을 위반하는 것 같은데 괜찮나요?

A: DRY는 **"지식의 중복"**을 피하는 원칙이지, **"문자열의 중복"**을 피하는 원칙이 아닙니다. GhostPost.statusGhostPostCreate.status는 문자열은 겹쳐 보이지만 다른 지식을 표현합니다. 하나는 "서버가 반환할 수 있는 값", 다른 하나는 "사용자가 설정할 수 있는 값"이죠. 이 두 지식이 실제로 같은 의미라면 나중에 둘 다 바꿔야겠지만, Ghost의 sent처럼 한쪽에만 해당하는 값이 생기면 즉시 갈라집니다. 이런 갈라짐의 가능성이 있다면 분리가 맞습니다.

Q: 공통 부분을 헬퍼 타입으로 빼면 안 되나요?

A: 가능합니다. 예를 들어:

type UserSettableStatus = 'draft' | 'published' | 'scheduled';
type SystemSettableStatus = 'sent';
type AllStatus = UserSettableStatus | SystemSettableStatus;

interface GhostPost { status: AllStatus; }
interface GhostPostCreate { status: UserSettableStatus; }
interface GhostPostUpdate { status: UserSettableStatus; }

이 방식은 의도가 타입 이름에 드러나서 더 좋다고 볼 수 있습니다. 다만 파일 맨 위에 타입이 세 개 생기므로 개인적인 취향 문제입니다. 핵심은 타입 이름이 "누가 설정할 수 있는가"를 담는다는 점입니다.

Q: 런타임 검증(Zod)만으로 충분하지 않나요?

A: 런타임 검증만으로는 IDE 자동완성과 exhaustive check의 혜택을 잃습니다. TypeScript 타입 시스템은 "잘못된 값을 애초에 작성할 수 없게" 만들어주지만, Zod는 "작성할 수는 있으나 실행 시 에러를 낸다"에 그칩니다. 둘을 같이 사용하는 것이 가장 좋습니다. 타입으로 대부분을 막고, Zod로 외부 입력(JSON 파일, HTTP body)을 검증하는 식입니다. ghost-mcp는 실제로 MCP 도구 입력을 Zod로 검증하면서 내부 타입과 일치시키고 있습니다.

Q: sent 상태 필터가 필요한 실제 사용 사례는 뭔가요?

A: 몇 가지 예시:

  • "지금까지 발송한 뉴스레터 목록을 보고 싶다"
  • "이번 달에 이메일로 나간 글만 분석하고 싶다"
  • "발송된 글과 미발송 글의 참여도를 비교하고 싶다"
  • "특정 태그가 붙은 발송 글만 추려내고 싶다"

Ghost의 sent는 단순한 상태가 아니라 "뉴스레터 발송 이벤트의 기록"이기도 합니다. 블로그 + 이메일 뉴스레터를 함께 운영하는 경우 이 필터가 분석에 유용합니다.

Q: 상태 머신 라이브러리(XState 등)를 써야 하나요?

A: Ghost의 상태 머신은 매우 작습니다(4개 상태, 몇 개의 전이). 이 규모에서는 TypeScript union으로 충분하고, 오히려 라이브러리가 overkill입니다. 상태가 10개 이상이거나 가드 조건이 복잡해지면 XState 같은 도구가 가치를 발휘하기 시작합니다. ghost-mcp는 "union + 타입 분리"만으로 필요한 모든 제약을 표현할 수 있었습니다.

Q: 서버 응답이 미래에 새 상태를 추가하면 어떻게 되나요?

A: TypeScript가 런타임에 새 값이 들어오는 걸 막지는 못합니다. 그래서 두 가지 방어책을 권장합니다.

  1. Zod로 응답도 검증 — 예상치 못한 값이 들어오면 즉시 감지
  2. exhaustive check에 default 케이스 추가 — 알 수 없는 상태는 로그만 찍고 넘어가도록
function describeStatus(status: GhostPost['status']): string {
  switch (status) {
    case 'draft': return '초안';
    case 'published': return '발행됨';
    case 'scheduled': return '예약';
    case 'sent': return '이메일 발송됨';
    default: {
      console.warn('Unknown Ghost status:', status);
      return String(status);
    }
  }
}

7. 참고 자료


8. 다음 단계

이 글은 타입 시스템으로 API 경계를 표현하는 이야기였습니다. 다음 편에서는 MCP 서버의 보안을 다룹니다 — Claude 같은 LLM이 생성한 파일 경로를 서버가 받을 때, Path Traversal 공격을 18줄로 막는 방법을 살펴봅니다. 로컬 권한을 가진 MCP 서버에서 이 방어가 왜 필수인지도 함께 정리합니다.