DEV_BBAK
← 포스트

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

·16 min read·로딩중...

TL;DR

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

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

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

이건 AI가 게을러서가 아니다. AI는 본질적으로 비결정적 출력을 내는 생성기다. 같은 입력에도 확률적으로 다른 결과를 낸다. 문제는 이 비결정성이 열린 도메인 모델과 만났을 때, 해석 자유도가 폭발한다는 점이다.

결국 프롬프트를 더 길게 쓰는 것으로는 한계가 있었다. 필요했던 건 더 많은 설명이 아니라, 더 적은 해석 여지였다.

“설명” 대신 “제약”을 인터페이스에 심는다

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

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

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

그리고 각 상태를 단일 Order 타입 안의 optional 필드로 뭉개지 않고, 상태별 인터페이스(Discriminated Union) 로 분리했다. 이렇게 하면 “예약된주문인데 reservedAt이 없음” 같은 모순을 타입 레벨에서 바로 차단할 수 있다.

즉, 우리가 얻는 건 단순한 타입 안정성이 아니다. AI가 코드를 생성할 때 따라야 할 문법적 레일이 생긴다. 레일이 생기면 결과가 조금 달라도 시스템 바깥으로 벗어나기 어렵다.

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

정리하면, AI 협업에서 품질을 올린 포인트는 “프롬프트 최적화”가 아니라 아래 3가지였다.

  1. 상태를 닫는다 (열린 string 금지)
  2. 전이를 중앙화한다 (상태 변경 규칙 단일화)
  3. 입력을 검증한다 (런타임 스키마 강제)

결국 중요한 건 AI를 더 똑똑하게 만드는 게 아니라, 시스템이 멍청한 출력도 안전하게 흡수할 수 있게 설계하는 것이었다.

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

아이디어를 한 번 잘 짜는 것보다 더 중요한 건, 이 구조를 팀의 기본값(default) 으로 만드는 것이다. 그래야 사람이 바뀌거나 AI 출력이 달라져도 결과 품질이 유지된다.

실무에서는 아래 4단계로 고정하는 게 가장 효과적이었다.

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

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

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

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

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

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

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

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

상태 변경은 컴포넌트 내부에서 직접 하지 않고, transitionOrder(order, action) 같은 도메인 함수로만 수행한다.

이 규칙 하나만 지켜도

  • 화면마다 다른 전이 규칙이 생기는 문제
  • 예외 케이스가 분산되는 문제
  • 테스트 포인트가 흩어지는 문제

를 크게 줄일 수 있다. 즉, 정책은 한 곳에, UI는 소비만 하도록 분리하는 거다.

런타임 검증을 반드시 붙인다

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

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

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

팀에 정착시키는 운영 팁

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

결론적으로, AI 협업 품질은 모델 성능보다 도메인 경계 설계 품질에 더 크게 좌우된다. 프론트엔드에서 인터페이스를 상태 기계처럼 설계하면, AI의 비결정성은 리스크가 아니라 생산성으로 바뀐다.

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);
// 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가 코드를 생성해도 패턴 매칭 레일 안에서 움직여 결과 품질이 안정된다.

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

여기서 한 단계 더 가면, 이 설계를 “좋은 아이디어”가 아니라 반복 가능한 시스템으로 만들 수 있다. 방법은 간단하다. 우리가 정한 모델링 규칙을 LLM Harness 툴에서 사용 가능한 스킬로 고정하고, PR 전 단계에서 셀프리뷰 루프를 의무화하는 것이다.

핵심은 단순히 “생성하고 리뷰한다”가 아니라, 생성과 검증 전 과정에서 질의를 반복해 모델링 품질을 끌어올리는 루프를 만드는 것이다.

  1. 생성 축(Implementation + 질의 루프)

    • 상태는 닫힌 타입으로 정의
    • 상태별 인터페이스(Discriminated Union) 사용
    • 전이는 순수 함수 단일 진입점으로 처리
    • 구현 중에도 AI에게 계속 질문한다
      • “이 상태는 독립 상태인가 파생 상태인가?”
      • “이 전이는 정책상 허용해야 하는가?”
      • “이 필드는 모든 상태에 필요한가, 특정 상태 전용인가?”
  2. 검증 축(Self-review + 재질의 루프)

    • 도메인 모델링 위반 여부
    • 팀 컨벤션 준수 여부
    • 선언적 패턴 훼손 여부
    • “동작은 하지만 모델이 열린 코드” 탐지
    • 검증 단계에서도 다시 질문한다
      • “이 실패 처리 방식이 비즈니스 우선순위와 맞는가?”
      • “런타임 검증 실패 시 사용자/운영 관점에서 의도된 동작인가?”

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

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

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

결국 이 방식의 본질은 프롬프트 튜닝이 아니다. 설계 규칙을 스킬로 제품화하고, 리뷰를 루프로 운영화하는 것이다. 그 순간부터 AI의 비결정성은 통제 대상이 되고, 팀의 코드베이스는 점점 더 결정적으로 진화한다.

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

여기서 더 중요한 전환은, AI를 “코드 생성기”로만 쓰지 않고 사고 확장 장치로 쓰는 것이다. 핵심은 구현 전에 설계를 더 자주 하고, 설계 과정에서 질의응답을 더 촘촘히 반복하는 데 있다.

좋은 데이터 모델링은 한 번에 정답을 맞히는 작업이 아니라, 질문을 통해 경계를 선명하게 만드는 반복 작업이다. 예를 들면

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

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

결국 생산성을 높이는 방법은 “더 빨리 코드를 치는 것”이 아니라, 더 자주 설계하고, 더 자주 질문해서, 더 좋은 모델을 먼저 확정하는 것이다. 견고한 시스템은 구현 속도에서 나오지 않고, 반복된 설계 판단의 밀도에서 나온다.

마지막으로 한 줄로 정리하면

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