닫힌 상태로 도메인주도 설계
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가지였다.
- 상태를 닫는다 (열린 string 금지)
- 전이를 중앙화한다 (상태 변경 규칙 단일화)
- 입력을 검증한다 (런타임 스키마 강제)
결국 중요한 건 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 전 단계에서 셀프리뷰 루프를 의무화하는 것이다.
핵심은 단순히 “생성하고 리뷰한다”가 아니라, 생성과 검증 전 과정에서 질의를 반복해 모델링 품질을 끌어올리는 루프를 만드는 것이다.
-
생성 축(Implementation + 질의 루프)
- 상태는 닫힌 타입으로 정의
- 상태별 인터페이스(Discriminated Union) 사용
- 전이는 순수 함수 단일 진입점으로 처리
- 구현 중에도 AI에게 계속 질문한다
- “이 상태는 독립 상태인가 파생 상태인가?”
- “이 전이는 정책상 허용해야 하는가?”
- “이 필드는 모든 상태에 필요한가, 특정 상태 전용인가?”
-
검증 축(Self-review + 재질의 루프)
- 도메인 모델링 위반 여부
- 팀 컨벤션 준수 여부
- 선언적 패턴 훼손 여부
- “동작은 하지만 모델이 열린 코드” 탐지
- 검증 단계에서도 다시 질문한다
- “이 실패 처리 방식이 비즈니스 우선순위와 맞는가?”
- “런타임 검증 실패 시 사용자/운영 관점에서 의도된 동작인가?”
운영 흐름은 다음처럼 고정된다.
요구사항 입력 → AI 구현 → AI 셀프리뷰 → 수정 → 재검증 → PR 제출
중요한 건 “리뷰 코멘트를 많이 받는 것”이 아니라, 수정 가능한 핵심 이슈만 추려서 루프를 짧게 유지하는 것이다. 그래야 팀이 피로하지 않고, 규칙이 실제 개발 속도 안에서 살아남는다.
결국 이 방식의 본질은 프롬프트 튜닝이 아니다. 설계 규칙을 스킬로 제품화하고, 리뷰를 루프로 운영화하는 것이다. 그 순간부터 AI의 비결정성은 통제 대상이 되고, 팀의 코드베이스는 점점 더 결정적으로 진화한다.
구현 중심에서 설계 중심으로
여기서 더 중요한 전환은, AI를 “코드 생성기”로만 쓰지 않고 사고 확장 장치로 쓰는 것이다. 핵심은 구현 전에 설계를 더 자주 하고, 설계 과정에서 질의응답을 더 촘촘히 반복하는 데 있다.
좋은 데이터 모델링은 한 번에 정답을 맞히는 작업이 아니라, 질문을 통해 경계를 선명하게 만드는 반복 작업이다. 예를 들면
- 이 상태는 정말 독립 상태인가, 아니면 파생 상태인가?
- 이 전이는 허용해야 하나, 예외로 막아야 하나?
- 이 필드는 모든 상태에 필요한가, 특정 상태에서만 유효한가?
- 런타임에서 반드시 검증해야 할 입력 경계는 어디인가?
이 질문들을 AI와 짧은 루프로 자주 돌리면, 모델은 점점 닫히고 전이 규칙은 더 명확해진다. 그 결과 구현 단계에서는 선택지가 줄어들고, 시스템은 더 견고해진다.
결국 생산성을 높이는 방법은 “더 빨리 코드를 치는 것”이 아니라, 더 자주 설계하고, 더 자주 질문해서, 더 좋은 모델을 먼저 확정하는 것이다. 견고한 시스템은 구현 속도에서 나오지 않고, 반복된 설계 판단의 밀도에서 나온다.
마지막으로 한 줄로 정리하면
“좋은 코드 생성”이 목표가 아니라, “좋은 코드만 통과하는 루프”를 설계하는 게 목표다.