DEV_BBAK
← 포스트

닫힌 상태로 도메인주도 설계

·13 min read·로딩중...

TL;DR

  • AI 협업에서 설계가 흔들리는 원인은 열린 상태 모델(status: string)이다.
  • 상태를 닫힌 상태(Discriminated Union) 로 정의하고, 전이는 순수 함수로 단일화하며, API 경계는 zod로 런타임 검증한다.
  • 이 규칙을 LLM Harness 툴에서 사용 가능한 스킬 + 셀프리뷰 루프로 운영하면 품질이 지속된다.

AI랑 같이 만들수록 설계가 흔들리는 이유

AI로 구현 속도는 빨라졌는데, 같은 도메인 기능이 PR마다 다른 규칙으로 만들어졌다. 특히 status: string 같은 열린 모델에서는 “가능한 상태/불가능한 상태” 경계가 매번 달라졌다.

AI는 본질적으로 비결정적 출력을 내는 생성기라 같은 입력에도 확률적으로 다른 결과를 낸다. 이 비결정성이 열린 도메인 모델과 만나면 해석 자유도가 폭발한다. 결국 프롬프트를 더 길게 쓰는 방식으로는 한계가 있었고, 필요했던 건 더 많은 설명이 아니라 더 적은 해석 여지였다.

그래서 방향을 바꿨다. AI에게 “잘 이해해줘”라고 부탁하는 대신, 이해를 덜 해도 정답에 가깝게 만들 구조를 먼저 설계했다.

핵심은 프론트엔드 인터페이스를 상태 기계(state machine)에 가까운 형태로 바꾸는 일이었다. 이번 Order 예시에서는 상태를 다음 세 가지로 고정했다.

  • 가주문
  • 예약된주문
  • 처리완료주문

각 상태를 단일 Order 타입의 optional 필드로 뭉개 버리는 대신, 상태별 인터페이스(Discriminated Union) 로 분리했다. 이렇게 하면 “예약된주문인데 reservedAt이 없음” 같은 모순을 타입 레벨에서 바로 차단할 수 있다. 타입 안정성은 부수 효과에 가깝고, 진짜 이득은 AI가 코드를 생성할 때 따라야 할 문법적 레일이 생긴다는 데 있다. 레일 위에서는 결과가 조금 달라도 시스템 바깥으로 벗어나기 어렵다.

여기에 상태 전이(가주문 -> 예약된주문 -> 처리완료주문)를 순수 함수로 고정하면, 컴포넌트마다 제각각이던 규칙이 한 곳으로 수렴한다. API 응답에는 zod로 런타임 검증을 붙여, 타입 시스템 바깥에서 들어오는 오염까지 막았다.

타입/전이/검증을 팀 컨벤션으로 고정하는 방법

이 구조를 팀의 기본값(default)으로 만들어야 사람이 바뀌거나 AI 출력이 달라져도 품질이 유지된다. 실무에서는 아래 4단계로 고정했다.

상태를 먼저 닫는다 (Closed World)

도메인 상태를 string으로 열어두지 않고, 리터럴 유니온/enum으로 닫는다. 예: 가주문 | 예약된주문 | 처리완료주문

이 단계에서 얻는 건 “타입 안정성” 이전에 용어 안정성이다. 팀 문서, 기획, API, 프론트 코드가 같은 단어를 쓰기 시작한다.

상태별 인터페이스를 분리한다

Order 하나에 optional 필드를 몰아넣지 않고, 상태별 인터페이스를 분리해 Discriminated Union으로 묶는다.

  • 가주문에는 완료/예약 시각이 없다
  • 예약된주문에는 reservedAt이 반드시 있다
  • 처리완료주문에는 completedAt이 반드시 있다

이렇게 “존재해야 할 데이터”와 “존재하면 안 되는 데이터”를 동시에 표현해야 AI가 생성한 코드도 자연스럽게 올바른 모양으로 수렴한다.

전이 함수를 단일 진입점으로 만든다

상태 변경은 컴포넌트 내부에서 직접 하지 않고, transitionOrder(order, action) 같은 도메인 함수로만 수행한다. 이 규칙 하나로 화면마다 전이 규칙이 어긋나고, 예외 케이스가 흩어지고, 테스트 지점을 찾아 헤매던 일이 크게 줄었다. 정책은 한 곳에 두고 화면은 결과만 소비하는 구조가 된다.

런타임 검증을 붙인다

TypeScript는 컴파일 타임 보호막이고, 실제 장애는 대부분 런타임 입력(API 응답)에서 시작된다.

그래서 zod 스키마를 discriminatedUnion("status", ...)로 맞추고, API boundary에서 무조건 parse/safeParse를 거친다.

이렇게 하면 “백엔드 응답이 순간적으로 틀렸을 때도 프론트가 조용히 망가지는” 대신, 초기에 명시적으로 실패하고 로그/모니터링 포인트가 생긴다.

Pattern Matching으로 전이/표현 누락을 컴파일 타임에 차단하기

아래는 ts-pattern을 사용해 상태 분기를 switch 대신 선언적으로 고정한 예시다.

// order.model.ts
export type Order =
  | { status: '가주문'; id: string; createdAt: string }
  | { status: '예약된주문'; id: string; createdAt: string; reservedAt: string }
  | { status: '처리완료주문'; id: string; createdAt: string; reservedAt: string; completedAt: string };
// order.transition.ts
import { match } from 'ts-pattern';
import type { Order } from './order.model';

type Action =
  | { type: 'reserve'; at: string }
  | { type: 'complete'; at: string };

export const transitionOrder = (order: Order, action: Action): Order =>
  match<[Order, Action], Order>([order, action])
    .with([{ status: '가주문' }, { type: 'reserve' }], ([o, a]) => ({
      ...o,
      status: '예약된주문',
      reservedAt: a.at,
    }))
    .with([{ status: '예약된주문' }, { type: 'complete' }], ([o, a]) => ({
      ...o,
      status: '처리완료주문',
      completedAt: a.at,
    }))
    .otherwise(([o]) => o);

예제의 .otherwise는 무효한 전이를 조용히 원본 그대로 돌려보내므로, 실무에서는 이 지점을 에러로 던질지 로그로 남길지 미리 정해둬야 한다.

// order.presenter.ts
import { match } from 'ts-pattern';
import type { Order } from './order.model';

export const toOrderBadge = (order: Order) =>
  match(order)
    .with({ status: '가주문' }, () => '📝 가주문')
    .with({ status: '예약된주문' }, () => '📅 예약됨')
    .with({ status: '처리완료주문' }, () => '✅ 완료')
    .exhaustive();

실무에서 중요한 포인트는 아래 3가지다.

  • 새 상태가 추가되면 .exhaustive()가 누락 분기를 바로 에러로 드러낸다.
  • 분기 로직이 퍼지지 않아, 리뷰 시 “어디를 고쳐야 하는지”가 명확해진다.
  • AI가 코드를 생성해도 패턴 매칭 레일 안에서 움직여 결과 품질이 안정된다.

팀에 정착시키는 운영 팁

  • PR 체크리스트에 열린 status 금지, 전이 함수 사용, zod 검증 포함
  • 코드리뷰 기준에 “optional 남발 여부” 추가
  • 예제 템플릿(복붙 가능한 Order 샘플) 팀 위키에 고정
  • AI 프롬프트에도 “상태는 닫고, 전이는 함수로, 입력은 zod 검증”을 기본 규칙으로 삽입

LLM Harness 툴에서 사용 가능한 스킬 + 셀프리뷰 피드백 루프

설계를 반복 가능한 시스템으로 만들려면, 정한 모델링 규칙을 LLM Harness 툴에서 사용 가능한 스킬로 고정하고 PR 전 단계에 셀프리뷰 루프를 의무화하면 된다. 핵심은 생성과 검증 전 과정에서 질의를 반복해 모델링 품질을 끌어올리는 루프를 만드는 일이다.

  1. 생성 축(Implementation + 질의 루프) — 닫힌 상태, 상태별 인터페이스, 단일 전이 함수 규칙을 지키며 구현하고, 구현 중에도 AI에게 계속 질문을 던진다.
  2. 검증 축(Self-review + 재질의 루프) — 도메인 모델링 위반 여부, 팀 컨벤션 준수 여부, 선언적 패턴 훼손 여부, “동작은 하지만 모델이 열린 코드”를 찾는다. 이때 다시 묻는다. “이 실패 처리 방식이 비즈니스 우선순위와 맞는가?”, “런타임 검증 실패 시 사용자/운영 관점에서 의도된 동작인가?”

운영 흐름은 다음처럼 고정된다.

요구사항 입력 → AI 구현 → AI 셀프리뷰 → 수정 → 재검증 → PR 제출

이때 리뷰 코멘트를 많이 받는 것보다, 수정 가능한 핵심 이슈만 추려서 루프를 짧게 유지하는 편이 낫다. 루프가 짧아야 팀이 피로하지 않고 규칙이 실제 개발 속도 안에서 살아남는다.

설계 규칙을 스킬로 제품화하고 리뷰를 루프로 운영하는 순간부터 AI의 비결정성은 통제 대상이 되고, 팀의 코드베이스는 점점 더 결정적으로 진화한다.

구현 중심에서 설계 중심으로

이 흐름에서 가장 큰 전환은 AI를 사고 확장 장치로 쓰기 시작한 일이었다. 코드 생성을 맡기기 전에 설계를 더 자주 하고, 설계 과정에서 질의응답을 더 촘촘히 돌렸다.

좋은 데이터 모델링은 질문으로 경계를 선명하게 만들어가는 반복 작업이다. 던지는 질문은 대체로 이런 형태다.

  • 이 상태는 정말 독립 상태인가, 아니면 파생 상태인가?
  • 이 전이는 허용해야 하나, 예외로 막아야 하나?
  • 이 필드는 모든 상태에 필요한가, 특정 상태에서만 유효한가?
  • 런타임에서 반드시 검증해야 할 입력 경계는 어디인가?

이 질문들을 AI와 짧은 루프로 자주 돌리면, 모델은 점점 닫히고 전이 규칙은 더 명확해진다. 그 결과 구현 단계에서는 선택지가 줄어들고, 시스템은 더 견고해진다.

“좋은 코드 생성”이 목표가 아니라, “좋은 코드만 통과하는 루프”를 설계하는 게 목표다.