DDD 28분 읽기

프론트엔드에서 도메인은 어디에 있나 — 뷰 규칙은 비즈니스 규칙인가

DDD를 프론트엔드 코드에 대보면 내가 하루 종일 내리는 결정의 대부분이 '도메인 밖'으로 분류된다. 그럼 프론트엔드에는 도메인이 없는 걸까? 막힌 지점을 파고들어 '프론트엔드에서 비즈니스 규칙은 무엇인가'를 다시 정의한 기록.

DDD는 방향이 옳아 보였다. 그런데 프론트엔드 코드에 대보면, 내가 하루 종일 내리는 결정의 대부분이 “도메인 밖”으로 분류된다. 그럼 내 일에는 도메인이 없는 걸까? 그 막힌 지점을 파고들어 “프론트엔드에서 비즈니스 규칙은 무엇인가”를 다시 정의한 기록이다.

들어가며 — 좋아 보이는데 내 일에는 안 맞는 방법론

도메인 주도 설계(DDD)라는 개념은 처음 봤을 때부터 방향이 옳다고 느꼈다. 비즈니스에서 다루는 개념과 규칙, 계약이 코드에 제대로 반영되어야 한다는 주장에 반대할 이유가 없다. 우리가 만드는 건 결국 어떤 업무를 대신하는 물건이고, 그 업무의 언어가 코드에 안 보인다면 뭔가 잘못된 것이다.

그런데 실제로 프론트엔드 코드베이스에 적용해보려고 하면 계속 막혔다. 책과 글에서 말하는 도메인 레이어, 엔티티, 애그리게이트를 내 코드에 대보면 내가 하루 동안 내리는 결정의 대부분이 “도메인 밖”으로 분류된다. 프레젠테이션 레이어는 얇아야 하고 도메인 로직을 담으면 안 된다고 하는데, 내 일은 거의 전부 거기에 있다.

그래서 두 가지 중 하나로 흘렀다. “프론트엔 해당 없는 얘기네” 하고 덮거나, 억지로 도메인 레이어를 만들려고 서버가 이미 갖고 있는 규칙을 프론트로 가져오거나. 후자를 해보면 금방 알게 된다. 규칙이 두 곳에 생기고, 바뀔 때 한쪽을 잊는다. 줄이려던 문제가 늘어난다.

이 글은 그 막힌 지점을 파고들어 “프론트엔드에서 비즈니스 규칙은 무엇인가”를 다시 정의한 기록이다. 결론부터 말하면, 내가 오랫동안 “그냥 UI 작업”이라고 부르던 것의 절반은 비즈니스 규칙이었고, 나머지 절반은 아니었다. 그 둘을 가르는 선을 못 그었던 게 문제였다.


1. 먼저 정리 — 클린 아키텍처와 DDD는 같은 게 아니다

이 얘기를 하기 전에 내가 오래 갖고 있던 오해를 먼저 털어야 한다. 나는 클린 아키텍처와 도메인 주도 설계를 거의 같은 것으로 알고 있었다.

정리하면 두 개념은 축이 다르다.

클린 아키텍처도메인 주도 설계
축의존성 방향지식 표현
답하는 질문”무엇이 무엇에 의존해도 되나""무엇을 모델링해야 하나”
목적세부사항(DB·UI·프레임워크)을 갈아도 코어가 안 흔들리게업무 전문가와 개발자가 같은 언어로 같은 모델을 공유하게
성공 판정바깥을 교체해도 안쪽이 안 바뀐다코드를 읽으면 업무 규칙이 보인다

그래서 한쪽만 있는 코드가 실제로 존재한다. 레이어는 교과서처럼 깔끔한데 도메인 용어가 하나도 없는 코드(클린 아키텍처만), 업무 규칙과 용어는 정확한데 레이어 구분이 없는 코드(DDD만). 둘은 독립적이다.

한 문장으로 관계를 정리하면 이렇다.

DDD는 “무엇을 모델링할지”를 정하고, 클린 아키텍처는 “그 모델을 어디에 두고 무엇이 그것에 의존할지”를 정한다.

대립이 아니라 순서와 역할이 다르다.

두 축은 독립이다 — 레이어는 깔끔한데 도메인 용어가 없는 코드(클린 아키텍처만)와 용어는 정확한데 규율이 없는 코드(DDD만)가 실제로 존재한다. 혼동의 원인은 클린 아키텍처 가장 안쪽 원 이름이 Entities라는 것

그런데 이 혼동은 내가 유난히 헷갈린 게 아니다

이걸 깨닫고 나서 좀 억울했다. 문헌이 실제로 두 개를 섞어놨다. 클린 아키텍처 다이어그램의 가장 안쪽 원 이름이 “Entities”인데, 그 어휘는 DDD에서 온 것이다. 저자가 DDD의 용어를 차용했고, 이후 대부분의 실무 글이 두 개념을 하나의 동심원 그림에 그려놨다. 검색해서 나오는 “클린 아키텍처 + DDD 적용하기” 류의 글은 대체로 폴더 구조 얘기이고, 두 개념의 목적 차이를 구분해주지 않는다.

용어가 겹치면 개념도 겹친 것처럼 보인다. 이게 이 분야에서 반복되는 함정이라고 생각한다.


2. 내가 DDD에 끌린 실제 이유는 구조가 아니라 버그였다

축을 구분하고 나서 스스로에게 물었다. 그럼 나는 둘 중 무엇에 끌렸던 걸까.

답은 명확했다. 나는 폴더 구조나 의존성 화살표에 끌린 게 아니었다. 내가 반복해서 겪은 고통은 이거였다.

제품이 하기로 한 것과 코드가 실제로 하는 것이 갈린다. 그리고 그건 취향 문제가 아니라 버그다.

프로그래머는 같은 기능을 여러 방식으로 구현할 수 있다. 하지만 어떻게 구현하든, 제품이 제공하려는 것을 제대로 구현하지 못하면 그건 버그다. 코드가 아름다운지 여부와 무관하게 버그다.

그렇다면 코드가 업무 개념을 반영한 형태로 쓰여 있으면, 구현과 규칙이 갈리는 사건 자체를 줄일 수 있지 않을까. 이게 내가 DDD에서 기대한 것이었다.

이 프레이밍이 마음에 드는 이유가 하나 더 있다. 원전의 동기보다 검증 가능하다. Evans가 말하는 DDD의 동기는 “복잡성 관리와 소통”인데, 좋긴 하지만 측정할 수가 없다. 반면 “규칙과 구현이 갈린 사건 수”는 셀 수 있다. 실제로 세어보면 숫자가 나온다.

내가 일하는 코드베이스에서 세어봤을 때 나온 패턴은 이랬다.

  • 같은 업무 규칙이 여러 화면에 각자 분기로 구현되어 있고,
  • 그중 한 곳에서 버그가 보고되어 고치면,
  • 나머지 곳에서 몇 주 뒤 같은 증상이 다시 보고된다.

이걸 “중복 코드”라고 부르면 정확하지 않다. 복사·붙여넣기가 아니어서 중복 검출 도구에 안 걸리는 경우가 많다. 같은 규칙을 각자 독립적으로 재구현한 것이라 문자열이 겹치지 않는다. 그래서 도구는 건강하다고 하는데 사람은 계속 같은 버그를 고친다.

가장 인상적이었던 흔적은 어떤 분기 옆에 달린 주석이었다. “이 조건이 없어도 되는 상황 같은데” 같은 뉘앙스의, 확신 없는 메모. 규칙이 명세와 떨어져 있으면 개발자가 매번 불안하게 재판단한다. 그 불안이 코드에 주석으로 남는다. 나는 이게 규칙이 흩어졌다는 가장 정직한 증거라고 생각한다.


3. 그런데 프론트엔드에 대면 내 일의 대부분이 “도메인 밖”이 된다

여기까지는 좋았다. 문제는 이 동기를 프론트엔드에 적용하려는 순간 생겼다.

프론트엔드는 뷰와 분리될 수 없다. 그리고 프론트엔드에서 실제로 중요한 결정은 어떤 도메인 상태를 어떻게 보여줄지다. 그런데 기존 DDD 프레임에서 그건 프레젠테이션 레이어의 일이고, 프레젠테이션은 얇아야 하며 도메인이 아니다.

그러면 이런 상황이 된다. 내가 오늘 한 일을 나열하면:

  • 이 상태 값들 중 사용자에게 무엇을 우선 보여줄지 정했다
  • 값이 없을 때 빈칸을 둘지 다른 소스로 대체할지 정했다
  • 어떤 조건에서 이 버튼을 못 누르게 할지 정했다
  • 이 목록을 어떤 순서로 정렬할지 정했다
  • 같은 상태를 데스크탑과 모바일에서 같은 단어로 보이게 맞췄다

DDD 교과서 기준으로 보면 이 중 도메인 로직은 하나도 없다. 전부 프레젠테이션이다. 그런데 이 결정들이 틀리면 QA가 버그 티켓을 끊는다. 스타일 지적이 아니라 제품 결함으로 분류된다.

여기서 나는 방법론이 내 일을 설명하지 못하고 있다고 느꼈다. 그래서 질문을 뒤집었다.


4. 질문을 뒤집는다 — 뷰에 대한 규칙이 프론트엔드의 비즈니스 규칙인가

내 가설은 이거였다.

프론트엔드 입장에서는, 뷰에 대한 규칙이 곧 비즈니스 규칙이 아닐까.

앞 절의 판정 기준을 그대로 적용해보면 가설이 성립한다. 기준은 “제품이 제공하려는 것을 제대로 구현하지 못하면 버그”였다. 그리고 위 결정들이 틀리면 실제로 버그로 처리된다. 그러면 그 결정들은 제품이 하기로 한 약속의 일부이고, 곧 비즈니스 규칙이다.

이 가설이 맞다면 프론트엔드 DDD는 기존 DDD와 같은 것을 다른 대상에 적용하는 일이 아니다. 대상 자체가 다르다.

다만 이 가설을 그대로 밀면 반론에 무너진다. “그럼 CSS도 도메인이냐”는 질문에 답할 수 없다. 그래서 한 겹 더 갈라야 한다.


5. “뷰 규칙”은 두 층이다 — 표현 결정 vs 표현 수단

뷰 규칙을 뭉치지 말고 두 층으로 나눈다.

(A) 표현 결정(B) 표현 수단
무엇도메인 상태 → 사용자가 받는 의미그 의미 → 화면 요소
예”이 상태는 ‘반려’라고 부르고 경고 계열로 보인다” · “네 개의 상태 축 중 무엇을 우선 노출한다” · “값이 없으면 이 대체 소스를 쓴다” · “이 조건에서는 이 동작을 못 하게 보인다”배지냐 칩이냐, 정확한 색상 값, 여백, 애니메이션
누가 정하나기획·제품 (개발자가 혼자 정할 수 없다)개발자·디자인 시스템
틀리면제품 버그스타일 이슈
있어야 할 곳명세 + 코드의 한 곳(정본)화면 로컬

(A)는 비즈니스 규칙이다. (B)는 아니다.

내 가설의 정당한 부분이 정확히 (A)이고, 위험한 부분은 (A)와 (B)를 뭉치는 것이었다. 뭉치면 모든 UI 코드가 도메인이 되어 경계가 사라지고, 그러면 “무엇을 모으고 무엇을 흩어둬도 되는가”라는 실익이 없어진다.

표현 결정(A)은 기획이 정하고 정본 한 곳에 있어야 하며 틀리면 제품 버그다. 표현 수단(B)은 개발자가 정하고 화면 로컬에 있어도 된다. (A)에 구체적인 색값을 넣으면 특정 디자인 시스템에 묶여 결국 두 벌이 된다

이 구분이 코드에 어떻게 나타나는지가 중요하다. (A)는 의미까지만 다루고, (B)로의 변환은 화면이 한다.

// (A) 표현 결정 — 정본 한 곳. 의미 토큰까지만.
type StatusView = {
  labelKey: string;              // 무엇이라 부르는가
  tone: 'neutral' | 'positive' | 'warning';  // 어떤 의미로 읽히는가
};

const STATUS_VIEW = {
  DRAFT:     { labelKey: 'status.draft',     tone: 'neutral'  },
  SUBMITTED: { labelKey: 'status.submitted', tone: 'neutral'  },
  APPROVED:  { labelKey: 'status.approved',  tone: 'positive' },
  REJECTED:  { labelKey: 'status.rejected',  tone: 'warning'  },
} satisfies Record<DocumentStatus, StatusView>;
// (B) 표현 수단 — 화면 로컬. 의미 토큰을 자기 디자인 언어로 번역.
const StatusBadge = ({ status }: { status: DocumentStatus }) => {
  const { labelKey, tone } = STATUS_VIEW[status];
  return <Badge variant={variantOf(tone)}>{t(labelKey)}</Badge>;
};

tone: 'warning'까지가 제품의 결정이고, warning이 어떤 색의 어떤 컴포넌트가 되는지는 화면의 자유다. 그래서 모바일 화면이 다른 컴포넌트를 써도 의미는 갈리지 않는다. 반대로 라벨이나 톤을 화면마다 따로 정하면, 같은 상태가 두 곳에서 다르게 읽힐 수 있다.


6. 그래서 프론트엔드의 역할은 무엇인가

여기까지 오면 역할을 한 문장으로 정의할 수 있다.

서버는 “무엇이 참인가”의 소유자이고, 프론트엔드는 “참인 것이 사용자에게 무엇을 뜻하는가”의 소유자다.

이 정의가 세 가지를 동시에 해결한다.

  1. 이중 SoT를 구조적으로 회피한다. 프론트는 진실을 다시 계산하지 않는다. 번역만 한다.
  2. 그런데도 프론트에 정당한 도메인이 생긴다. 번역 규칙 자체가 제품이 정해야 하는 것이므로.
  3. 예외 케이스도 이 프레임에 들어온다. 서버에 물어볼 수 없어서 프론트가 의미를 직접 만들어야 하는 계산 — 기하 연산이나 표기 변환 같은 것 — 도 “의미를 만드는 일”의 일종이다.

3번을 조금 더 설명하면, 프론트엔드가 진짜 도메인 로직을 가져야 하는 경우가 분명히 있다. 여러 문헌이 이 예외를 인정하는데, 기준은 대체로 시각적·기하학적 복잡도다. 게임, 캔버스 기반 편집기, 3D 뷰어, 데이터 시각화처럼 서버 왕복 없이 즉시 계산해서 화면에 반영해야 하는 영역이다.

나는 치과 CAD 쪽 프론트엔드를 다루는데, 여기서는 이 예외가 꽤 넓다. 치식 표기 체계 변환(같은 치아를 두 개의 국제 표기법으로 상호 변환하는 것), 보철물 종류에 따른 형상 규칙, 메시 처리 같은 것들은 사용자가 화면에서 조작하는 즉시 반영되어야 하고 서버에 물을 수 없다. 이건 프론트가 소유하는 게 맞다.

하지만 같은 코드베이스에서 구독 등급 판정이나 권한, 저장 정합성 같은 건 서버가 소유해야 한다. 한 앱 안에서도 규칙마다 소유자가 다르다. 그래서 “프론트에 도메인 레이어를 둘 것인가”는 앱 단위 질문이 아니라 규칙 단위 질문이다.

프론트가 소유하는 것 세 가지

종류내용주의
① 표현 규칙도메인 상태 → 라벨·의미 색·우선순위·대체 표시프론트 정본. 화면 간 일관성이 곧 정확성이다
② 가능성 예고무엇을 할 수 있게 보여줄지 (버튼 활성/비활성)⚠️ 서버가 진짜 방어선이다. 프론트는 미리 알려주는 역할이므로 규칙을 재구현하지 말고 서버가 준 정보를 표시해야 한다
③ 왕복 불가 계산기하·표기 변환처럼 서버에 물을 수 없는 것프론트가 소유하는 게 정당한 유일한 “진짜 도메인 로직”

그리고 소유하면 안 되는 것: 권한, 과금, 정합성 판정. 이건 물어야 한다.

②가 가장 미묘하고, 실무에서 이중 SoT가 생기는 지점이 거의 항상 여기다. “이 상태에선 저장이 안 되니까 버튼을 비활성하자”를 프론트가 자기 판단으로 구현하면, 서버 규칙이 바뀔 때 조용히 갈린다. UI는 막는데 서버는 허용하거나, 그 반대가 된다.

그래서 규율은 이렇게 잡는다.

가능성 표시는 파생이어야 하고 재구현이어서는 안 된다.

서버 응답에 “이 리소스에 대해 지금 가능한 동작”이 실려 오면 프론트는 그걸 그리기만 한다. 안 실려 오면, 그게 API 설계의 개선점이지 프론트가 규칙을 복사할 이유는 아니다. 참고로 이건 REST의 하이퍼미디어 아이디어가 원래 하려던 일이기도 하다.

서버는 진실의 소유자, 프론트엔드는 의미의 소유자. 프론트가 소유하는 것은 표현 규칙·가능성 예고·왕복 불가 계산 세 가지이고, 권한·과금·정합성 판정은 소유하면 안 된다


7. 판정 리트머스 두 개

경계가 애매할 때 쓸 수 있는 기준이 필요하다. 나는 두 개를 쓴다.

리트머스 1 — 이 결정이 바뀔 때 기획이나 QA가 관여하는가?

  • 관여한다 → 표현 규칙. 명세에 있어야 하고 코드에서 한 곳에 모여야 한다
  • 개발자가 혼자 정해도 된다 → 표현 수단. 로컬에 둬도 된다

리트머스 2 — 이 결정이 두 화면에서 달라지면 버그인가?

  • 같은 상태가 데스크탑과 모바일에서 다른 단어로 보인다 → 버그 → 도메인
  • 여백이 다르다 → 버그 아님 → 로컬

리트머스 2는 실제 경험에서 나왔다. 어떤 코드베이스에서 상태 표시 로직이 데스크탑용과 모바일용 두 벌로 존재했고, 두 파일의 차이는 번역 키를 고르는 한 줄뿐이었다. 데스크탑은 긴 문구, 모바일은 짧은 문구를 쓰기 위한 분기였다.

문제는 이거다. 그 구조에서는 같은 상태가 두 화면에서 다른 의미로 보여도 아무것도 막지 못한다. 한쪽 라벨을 고치고 다른 쪽을 잊으면 조용히 갈린다. 그리고 코드만 봐서는 두 문구가 다른 게 의도인지 사고인지 알 수 없다.

여기서 알 수 있는 건, “긴 문구 / 짧은 문구”는 정당한 제품 요구이지만 그걸 파일 두 벌로 표현한 게 문제라는 것이다. 같은 표현 규칙 안에 길이 변형을 함께 두면(한 레코드에 label과 labelShort) 규칙은 한 곳에 남는다. 화면은 어느 쪽을 쓸지만 고른다.

표현 규칙이 흩어지면 일관성 위반이 조용히 성립한다. 이게 프론트엔드 특유의 버그 유형이라고 생각한다. 서버에는 이런 종류의 버그가 없다. 서버는 값 하나를 반환할 뿐이니까.


8. 그런데 모으는 것만으로는 안 된다

여기까지가 “무엇을 모을 것인가”였다. 그런데 이 글에서 가장 중요한 반전이 남았다. 나는 규칙을 잘 모으면 버그가 줄어든다고 생각했는데, 그 인과는 자동이 아니다.

내가 겪은 반례가 정확히 이랬다.

어떤 화면이 번역이 안 되어 영어로 고정되는 버그가 있었다. 처음엔 번역 리소스가 빠졌겠다고 생각했다. 확인해보니 번역은 11개 언어 전부 갖춰져 있었다. 정본은 완벽했다.

원인은 그 화면이 번역 훅을 네임스페이스 인자 없이 호출한 것이었다. 그래서 키를 못 찾고 기본값(영어)으로 떨어졌다. 규칙은 완벽하게 모여 있었는데, 그 화면이 규칙을 부르지 않았다.

여기서 배운 게 이거다.

단일 정본(SoT)은 중복을 막지만 누락은 막지 못한다.

중복은 “같은 규칙이 두 곳에 있는 것”이고, 누락은 “규칙이 적용되어야 할 곳에서 아예 안 불리는 것”이다. 정본을 만드는 작업은 앞의 문제만 해결한다. 뒤의 문제는 그대로 남고, 오히려 정본이 있으니 안심하게 되어 더 위험하다.

그래서 “반영한다”를 세 단계로 쪼개야 논지가 완결된다.

단계내용없으면
① 뭉침규칙이 한 곳에 있어 발견 가능한가새 화면 만드는 사람이 규칙의 존재를 모른다
② 강제”부를 수 있음”이 아니라 “안 부르면 못 만듦”인가위 사례처럼 조용히 새어나간다
③ 추적적용 대상인데 규칙을 안 쓰는 곳을 감사로 찾을 수 있나이미 새어나간 것을 발견할 수단이 없다

번역 리소스가 11개 언어 완비였는데 화면이 훅을 인자 없이 호출해 규칙을 아예 타지 않고 영어로 고정됐다. 뭉침은 발견성을, 강제는 누락을, 추적은 이미 새어나간 것을 막는다. SoT의 몫은 뭉침까지다

대부분의 DDD 글은 ①에서 멈춘다. 폴더를 나누고 모으는 데까지가 이야기의 끝이다. 하지만 실제로 버그를 막는 건 ②다.

②를 구현하는 방법은 결국 “잘못된 상태를 표현 불가능하게 만드는 것”이다. 번역 사례에서는, 네임스페이스가 바인딩된 전용 훅을 만들어 그 화면의 유일한 진입점으로 두면 인자를 빼먹는 실수 자체가 불가능해진다. 더 밀면 키 타입까지 묶어서 잘못된 키가 컴파일에 걸리게 할 수도 있다.

그리고 여기서 흥미로운 일이 벌어진다. ②는 의존성 규칙을 도구로 강제하는 일이다. 어떤 모듈이 무엇을 부를 수 있고 무엇을 못 부르는지를 정하는 것. 즉 1절에서 DDD와 분리했던 클린 아키텍처가 여기서 다시 합류한다. 무엇을 모을지는 DDD가 정하고, 그것을 우회할 수 없게 만드는 건 의존성 설계의 몫이다.

두 개념은 별개지만 실행 단계에서 만난다. 이게 내가 이 글을 쓰면서 가장 깔끔하게 정리된 부분이다.

한 가지 더. 검증에도 같은 함정이 있다. 위 번역 버그를 잡지 못한 테스트가 있었는데, 그 테스트는 번역 라이브러리를 목(mock)으로 대체하고 있었다. 목이 항상 그럴듯한 값을 돌려주니 규칙을 안 탔다는 사실 자체가 감춰졌다. 규칙이 실제로 적용됐는지 확인하려면 실제 구현을 태우고 결과가 언어별로 달라지는지 봐야 한다. ②를 만들었으면 ②가 작동하는지도 진짜로 확인해야 한다.


9. 그래서 단위는 애그리게이트가 아니라 표현 계약이다

마지막으로, 프론트엔드 도메인 모델의 단위를 정해야 한다.

기존 DDD의 중심 도구는 애그리게이트와 불변식이다. “이 상태는 이 경계 안에서만 바뀌고, 경계는 항상 유효한 상태를 유지한다”는 것. 그런데 이게 프론트엔드에서는 잘 맞지 않는다. 이유가 셋이다.

  1. 불변식은 서버가 이미 지킨다. 프론트가 또 지키면 이중이다.
  2. 파괴적 행동의 수명 경계가 서버에 있다. 무엇이 함께 생성되고 함께 삭제되는지는 서버 쪽 사실이다. 프론트 상태는 그것의 파생(서버 상태 + UI 상태)이라 경계를 그을 근거가 약하다.
  3. 대신 프론트엔 교과서에 없는 문제가 있다. 같은 도메인 상태를 여러 화면이 일관되게 뜻하게 하는 문제.

3번이 핵심이다. 그리고 이 문제의 해결 단위는 애그리게이트가 아니다.

프론트엔드 도메인 모델의 단위는 애그리게이트가 아니라 표현 계약이다.

표현 계약은 한 도메인 값의 모든 표현을 한 레코드로 묶은 것이다. 라벨, 번역 키, 의미 색, 그룹, 정렬 순서, 값이 없을 때의 대체까지. 그리고 결정적으로, 값이 추가되면 컴파일이 막는다.

// 서버 계약의 모든 값에 대해 표현이 정의됐음을 타입이 증명한다
const STATUS_VIEW = {
  DRAFT:     { labelKey: 'status.draft',     tone: 'neutral'  },
  SUBMITTED: { labelKey: 'status.submitted', tone: 'neutral'  },
  APPROVED:  { labelKey: 'status.approved',  tone: 'positive' },
  // REJECTED 를 빼면 여기서 컴파일 에러
} satisfies Record<DocumentStatus, StatusView>;

이게 왜 중요한가. 서버가 상태 값을 하나 추가했을 때, 지금까지의 코드베이스에서는 아무 일도 일어나지 않는다. 화면들은 조용히 그 값을 모르고, 라벨이 비거나 기본 분기로 떨어진다. QA가 발견하면 버그, 못 발견하면 사용자가 발견한다.

표현 계약이 있으면 서버 계약 변경이 컴파일 에러로 바뀐다. 규칙을 아는 곳이 한 군데니까, 거기서 한 번 막힌다.

그래서 이렇게 말할 수 있다.

프론트엔드에서 불변식은 “상태가 유효하다”가 아니라 “모든 상태에 표현이 있다”다.

이 문장이 이 글의 결론이다. 서버의 불변식이 데이터의 정합성을 지킨다면, 프론트의 불변식은 의미의 완전성을 지킨다. 사용자에게 뜻이 전달되지 않는 상태가 존재하지 않게 하는 것.

계약이 없으면 서버가 값을 추가해도 아무 일이 없고 화면 수만큼 놓칠 기회가 생긴다. 표현 계약이 있으면 화면이 몇 개든 한 곳에서 컴파일 에러로 한 번 막힌다

여기에 7절의 리트머스 2를 다시 붙이면 규율이 완성된다. 표현 계약 안에는 (A) 표현 결정만 넣는다. tone: 'warning'은 들어가고 color: '#d32f2f'는 안 들어간다. 후자를 넣는 순간 그 계약은 특정 디자인 시스템에 묶이고, 다른 화면이 못 쓰게 되어 다시 두 벌이 된다.


10. 그런데 이게 DDD인가?

여기서 예상되는 반론을 피하지 않고 받고 싶다. “표현 계약이니 의미 토큰이니 하는 건 그냥 좋은 설계 아닌가? 굳이 DDD라고 부를 이유가 있나?”

나는 이 반론이 어느 정도 맞다고 생각한다. 그리고 그게 문제가 아니라고도 생각한다.

DDD의 요지는 도메인을 코드에 드러내는 것이다. 애그리게이트나 밸류 오브젝트, 리포지토리 같은 용어는 그 요지를 달성하기 위한 도구지 목적이 아니다. 그래서 용어를 하나도 쓰지 않고 도메인이 코드에 드러난다면, 그건 DDD를 안 한 게 아니라 DDD의 요지를 지킨 것이다.

오히려 실무에서는 용어를 앞세우는 게 실패 요인인 경우가 많다. OrderAggregate, MoneyVO 같은 이름을 코드에 박고 팀 대화에 용어를 들이대면 거부감이 먼저 온다. 그리고 1절에서 봤듯이 용어가 겹치면 개념도 겹친 것처럼 보인다. 클린 아키텍처와 DDD를 혼동했던 것도 결국 용어 과잉의 결과였다.

그래서 나는 이렇게 정리했다. 멘탈 모델은 DDD를 따르고, 이름은 우리 업무 언어로 쓴다. 값 객체는 readonly 타입과 생성 함수로 존재하고 이름은 그 업무의 이름을 갖는다. 애그리게이트는 “이 상태는 이 모듈의 함수로만 바뀐다”는 export 경계로 존재한다. 안티 커럽션 레이어는 toXxx() 변환 함수로 존재한다. 접미사는 붙이지 않는다.

경계가 유지되는 건 이름 덕분이 아니라 도구가 강제하기 때문이다. 8절의 ②가 그 역할을 한다. 사람은 “규칙은 저기 모여 있다” 정도만 알면 되고, 위반은 lint와 타입이 막는다.


마치며 — 세 문장 요약

긴 글이었으니 압축한다.

  1. 클린 아키텍처와 DDD는 축이 다르다. 하나는 의존성 방향, 하나는 지식 표현. 문헌이 섞어놨을 뿐이다.
  2. 프론트엔드에서 비즈니스 규칙은 “표현 결정”이다. 도메인 상태가 사용자에게 무슨 뜻이 되는가 — 이건 제품이 정하고 틀리면 버그다. 반면 그것을 어떤 컴포넌트와 색으로 그리는지는 규칙이 아니다. 그리고 서버가 권위인 규칙은 프론트가 재구현하지 말고 물어야 한다.
  3. 모으는 것만으로는 안 된다. 정본은 중복만 막고 누락은 못 막는다. “부를 수 있음”을 “안 부르면 못 만듦”으로 바꾸는 단계까지 가야 하고, 그 단계에서 클린 아키텍처가 다시 합류한다.

그리고 실행 단위는 애그리게이트가 아니라 표현 계약이다. 프론트엔드의 불변식은 “모든 상태에 표현이 있다”다.


참고

  • Eric Evans, Domain-Driven Design — 원전. 유비쿼터스 언어와 바운디드 컨텍스트
  • Robert C. Martin, Clean Architecture — 의존성 규칙. 가장 안쪽 원 이름이 “Entities”인 이유를 보면 두 개념이 왜 섞이는지 알 수 있다
  • Khalil Stemmler, Does DDD Belong on the Frontend? — 프론트가 도메인 모델을 가져도 되는 조건. 시각적 복잡도 기준
  • Scott Wlaschin, Domain Modeling Made Functional — 타입으로 규칙을 인코딩하는 방법. class 없이 도메인 모델링하는 접근. TypeScript 이식
  • Chris Krycho, Making Illegal States Unrepresentable in TypeScript — 8절 ②의 구현 방법
  • Anti-Corruption Layer 패턴 — 외부 모델을 우리 모델로 번역하는 경계
  • Sandi Metz, The Wrong Abstraction — 성급하게 모으는 것의 비용. 되돌릴 수 있어야 한다
  • Manfred Steyer, DDD for Frontend Architectures — 도메인 간 접근을 공개 API로만 제한하고 lint로 강제하는 방법
  • Feature-Sliced Design — 프론트 폴더 구조 방법론. entities와 shared의 구분이 참고가 된다