DDD · Architecture
문서와 코드가
사이좋게 함께 틀렸을 때
코드가 자기 문서와 맞는지만 보는 감사는 둘이 같은 쪽으로 틀렸을 때 그걸 잡을 수 없다. 그 감사가 보고하는 게 바로 둘이 맞는다는 사실이기 때문이다. 사용자가 한 프로젝트의 루트 설계 가이드를 어긴 곳 세 군데를 짚었다. 쓰기용 Repository를 주입받은 Query, JPA 애노테이션을 단 도메인 클래스, 엉뚱한 레이어에 놓인 notification 모듈이었다. 그리고 그 많은 감사가 왜 이 셋을 하나도 못 잡았느냐고 물었다. 솔직한 답은 셋 다 달랐고, 그중 버그는 하나뿐이었다.
지적 세 가지가 한 문장으로 들어왔다. 대상은 같은 백엔드 설계를 5개 언어로 나란히 구현해 둔 내 예제 프로젝트였다. java, go, kotlin, fastapi 구현체가 루트 가이드를 어기고 있는데, 아직 남아 있기엔 너무 기본적인 것들이라는 얘기였다. Query Handler가 쓰기도 할 수 있는 Repository로 데이터를 읽고 있었다. 도메인 클래스에는 ORM 애노테이션이 붙어 있었다. notification 모듈은 있으면 안 될 자리에 있는 것 같았다. 지적보다 뒤에 붙은 질문이 더 중요했다. 지금까지의 감사가 몇 번이나 이 셋을 그냥 지나쳤고, 왜 그랬을까.
진짜 버그였던 하나
fastapi의 GetTransactionsHandler는 AccountRepository에 의존하고 있었다. CreateAccountService가 save_account()를 부를 때 쓰는 바로 그 인터페이스다. 타입 시그니처에는 쿼리가 상태를 바꾸지 못하게 막는 장치가 없었다. 리뷰어가 눈치챌 계기도 없었다. fastapi의 cqrs-pattern.md가 이 모양을 올바른 예시로 적어 두었기 때문이다. 문서와 코드가 어긋난 게 아니었다. 둘이 똑같이 틀려 있었다.
그래서 인터페이스를 나눴다. 읽기 전용 AccountQuery를 두고, 쓰기가 가능한 AccountRepository는 모두 이를 상속하게 했다. 이제 Query Handler는 save_account()에 닿을 방법이 없다.
class AccountQuery(ABC):
"""A read-only interface — for the Query Handler only. Never exposes a write method
such as save() (see cqrs-pattern.md). Shares its method signatures with
AccountRepository (the write model) but is a separate contract — a Query Handler
must always depend only on this type.
"""
@abstractmethod
async def find_accounts(self, page: int, take: int, ...) -> tuple[list[Account], int]: ...
class AccountRepository(AccountQuery, ABC):
@abstractmethod
async def save_account(self, account: Account) -> None: ...java-springboot에는 같은 버그가 절반만 있었다. GetAccountService는 이미 제대로 나뉘어 있었는데 GetTransactionsService는 그대로였다. 프로젝트의 CLAUDE.md에 알려진 갭으로 적어 놓고 끝내 마무리하지 않은 것이다. kotlin과 go는 두 인터페이스를 이미 제대로 나눠 두었고, 문제는 이름뿐이었다. 컨벤션은 XxxQuery인데 XxxQueryRepository라고 쓰고 있었다. 겉보기엔 사소하지만, 이런 차이가 쌓이면 루트 문서와 언어별 문서가 어느새 서로 다른 걸 가리키게 된다.
놓친 게 아니었던 하나
kotlin의 도메인 클래스에는 @Entity, @Column 같은 JPA 애노테이션이 그대로 붙어 있었다. fastapi 버그와 같은 종류의 위반처럼 보였다. 그런데 확인해 보니 kotlin의 directory-structure.md가 이걸 일부러 허용한 예외로 적어 두고 있었다. 아키텍처 검사기(코드가 문서의 규칙을 따르는지 정적으로 검사하는 스크립트)의 domain-purity 규칙도 문서가 허용한 코드에서 실패하지 않도록 JPA 애노테이션만 콕 집어 빼 놓았다. 감사가 놓친 건 없었다. 만든 대로 돌았을 뿐이다.
java-springboot는 똑같은 트레이드오프를 두고 반대로 결정했다. 도메인과 영속성을 완전히 나누고, AccountJpaEntity/AccountMapper 쌍이 둘 사이를 변환한다. 같은 설계의 두 구현이 정반대를 골랐고, 둘 다 자기 문서와는 맞았다. kotlin의 예외를 남겨 두면 앞으로 들어올 언어마다 이 결정을 또 각자 내려야 한다. 다른 길은 더 어렵고 타협의 여지도 적었다. 그 생태계에서 아무리 자연스러운 관례라도, 도메인에서 예외를 받는 프레임워크는 없다는 것이다.
지금 루트 tactical-ddd.md에는 이렇게 적혀 있다.
프레임워크 데코레이터는 절대 쓰지 않는다. ORM 애노테이션(@Entity,@Column등)도 예외 없이 금지한다. "이 생태계의 관례"라는 이유만으로 예외를 받는 구현체는 없다.
kotlin은 Account.kt를 순수 도메인 클래스와 인프라 쪽 AccountJpaEntity + AccountMapper + MoneyEmbeddable로 나눴다. java-springboot에 이미 있던 패턴을 그대로 옮긴 것이다. AccountRepositoryImpl도 손봐서, 아직 내보내지 않은 도메인 이벤트를 매퍼를 거쳐 Outbox 저장과 같은 트랜잭션 안에서 커밋하게 했다. 이번 작업에서 가장 위험한 변경이었고, 그래서 이 변경만을 위해 고른 모델로 처리했다.
혼자서는 누구도 못 잡았을 하나
세 번째 지적은 위치 문제였다. fastapi와 go는 notification 코드를 Account 도메인 안에 두었고, nestjs·java·kotlin은 저마다 최상위 공유 모듈로 빼 두었다. 어느 쪽이 틀렸다고 단정하기 어려웠는데, 루트의 domain-service.md를 다시 읽고 답이 나왔다. 이 문서의 Technical Service 예시는 "이메일이나 SMS 발송"을 필요한 도메인 안에 남겨 두는 대표 사례로 들고 있다.
여러 도메인이 실제로 같은 구현을 공유하게 됐을 때만 최상위 공유 모듈로 승격을 고려한다(YAGNI). "다른 도메인이 언젠가 쓸 수도 있다"는 이유만으로 미리 최상위로 빼지 않는다.
문서를 따르고 있던 건 fastapi와 go였다. nestjs·java·kotlin은 서로 상관없이, 그런데 같은 방향으로 문서에서 벗어나 있었다. 언어 하나만 들여다보는 감사로는 절대 드러나지 않는 문제다. 같은 개념의 구현 5개를 나란히 놓아야 비로소 보이는데, 그때까지의 감사는 모두 언어를 하나씩 따로 봤다.
그동안의 감사는 왜 셋 다 놓쳤나
지적마다 구조적인 원인이 따로 있었다. 코드가 자기 문서와 맞는지만 보는 감사는 문서가 코드와 같은 쪽으로 틀려 있으면 아무것도 못 본다. fastapi에서 일어난 일이 이것이다. 한 언어의 검사기에만 있는 규칙은 나머지 4개 언어에는 규칙이 아니다. 이름이 어긋난 걸 잡았을 Repository 이름 검사는 nestjs 검사기에만 있었다. 그리고 구현을 하나씩 보는 감사는 비교해야만 드러나는 불일치를 구조적으로 볼 수 없다. notification 위치 문제는 그 비교 속에서만 보였다.
문서에 못 박은 것
코드를 고치는 건 쉬웠다. 어려운 건 과정을 고치는 일이었다. 다음 구현체가 또 저 혼자 판단하지 못하도록, 두 결정을 루트 문서에 분명한 문장으로 적었다. 도메인에서 ORM 예외는 없다. Technical Service는 둘 이상의 도메인이 실제로 같이 쓰기 전까지 그 도메인 안에 둔다.
고친 일은 5개 언어에 걸친 이슈 14개였고, 대부분은 코드 사본을 나눠 AI 에이전트를 병렬로 돌려 처리했다. kotlin 재작성에는 가장 신중한 모델을 썼다. nestjs의 모듈 이동은 같은 날 들어온 Card 도메인 변경과 부딪혀서 다시 검증했다. 마지막에 루트 문서를 정리하는 작은 작업은 손으로 했다.
그 많은 감사가 왜 이걸 못 잡았느냐는 질문에는 솔직한 답이 세 가지 있었다. 그중 불편한 답은, 세 위반 중 둘이 구조상 보일 수가 없었다는 것이다. 하나는 잡아내야 할 문서가 오히려 그 코드를 승인하고 있었고, 다른 하나는 5개 언어를 나란히 놓고 본 감사가 그때까지 한 번도 없었다.
docs/architecture/tactical-ddd.md(같은 백엔드 설계를 5개 언어로 구현해 둔 내 예제 프로젝트의 ORM 예외 없음 규칙 전문) · docs/architecture/domain-service.md(Technical Service 배치 원칙) · AccountRepositoryImpl.kt(Outbox 트랜잭션까지 포함한 domain/JPA 분리 코드)