GraphQL과 DDD (2) - Relay, DDD, CQRS는 어디서 충돌할까?
GraphQL과 DDD (2) — Relay, DDD, CQRS는 어디서 충돌할까?
Intro
이 글은 시리즈의 두 번째 글입니다. 앞선 글에서는 GraphQL을 REST의 연장선처럼 쓰게 되는 순간들, 그래프 탐색이라는 감각, 그리고 Relay Cursor Connection이 왜 생각보다 설득력 있게 느껴졌는지를 정리했습니다.
이번 글에서는 그 다음 질문을 조금 더 직접적으로 다뤄보려 합니다.
GraphQL, Relay, DDD, CQRS를 같이 가져가면 실제로 어디서 충돌하는가?
저는 이 질문이 중요하다고 생각합니다. GraphQL과 DDD는 겉으로 보면 꽤 잘 어울립니다. GraphQL은 도메인의 타입과 관계를 풍부하게 드러낼 수 있고, DDD는 그 도메인을 어떤 언어와 경계로 설계해야 하는지 생각하게 만들어 주니까요. CQRS까지 얹으면 읽기와 쓰기를 분리해서 모델을 더 또렷하게 볼 수도 있습니다.
문제는, 실무에서는 이 셋이 자동으로 잘 붙지 않는다는 점입니다.
- GraphQL은 가능한 한 하나의 연결된 그래프로 보이려 하고
- Relay는 그 그래프를 일관된 규약으로 다루려 하고
- DDD는 경계와 소유권을 엄격하게 지키려 하고
- CQRS는 읽기와 쓰기의 성격 차이를 끝까지 분리하려 합니다
즉, 서로 잘 맞는 부분이 있는 만큼 정면으로 부딪히는 지점도 분명히 존재합니다.
그래서 이 글에서는 “우리는 무엇을 따르고 있다”를 먼저 나열하기보다, 실제로 충돌하는 지점들을 하나씩 풀어보는 방식으로 정리해 보려 합니다. 그리고 그 충돌을 풀기 위해 왜 mutationId, raw id 비공개 payload, resolve field, read replica 환경에서의 mutation payload 최신성 보장, 모듈러 모놀리스+future federation 같은 결정을 하게 되었는지도 그 문맥 안에서 설명해 보겠습니다.
1. GraphQL은 하나의 그래프가 되고 싶고, DDD는 경계를 지키고 싶다
가장 먼저 부딪히는 지점은 여기입니다.
GraphQL, 특히 Relay 계열의 사고방식은 기본적으로 API를 하나의 연결된 그래프로 바라보게 만듭니다. 사용자는 viewer에서 시작해서 team, orders, payments, shipment, recommendations로 계속 탐색해 들어갈 수 있기를 기대합니다. 이 관점에서는 개별 도메인보다도 연결성이 먼저 보입니다.
반면 DDD는 반대로 생각합니다. 도메인은 자연스럽게 연결되어 있더라도, 그 연결이 곧 소유권의 통합을 뜻하는 것은 아닙니다. 주문 컨텍스트의 Order, 배송 컨텍스트의 Shipment, 정산 컨텍스트의 Invoice는 사용자에게는 하나의 흐름처럼 보일 수 있지만, 내부적으로는 각각 다른 규칙과 책임, 생명주기를 갖습니다.
충돌은 여기서 생깁니다.
- GraphQL은 “사용자에게 하나로 보이게 하자”고 말하고
- DDD는 “내부 책임은 절대 하나로 섞지 말자”고 말합니다
이 긴장을 제대로 다루지 않으면, 통합 그래프를 만든다는 명분 아래 모든 필드가 하나의 거대한 타입으로 합쳐지고, 결국 어느 모듈이 무엇에 책임지는지 흐려지기 시작합니다. 사용자 경험은 좋아 보일 수 있지만, 쓰기 규칙과 소유권은 빠르게 망가집니다.
그래서 왜 Relay 계열 규율을 엄격하게 가져가려 하는가
이 지점에서 Relay 계열 컨벤션이 중요한 이유는, 그 규율이 단순히 스키마를 예쁘게 정리하는 취향 문제가 아니기 때문입니다. Node, Connection, Payload 같은 구조를 엄격하게 유지하면 적어도 “그래프를 어떻게 보여줄 것인가”에 대한 문법이 흔들리지 않습니다.
즉,
- 그래프는 하나처럼 보이게 하되
- 어떤 엔티티가 식별 가능하고
- 어떤 관계가 탐색 가능하며
- 어떤 필드가 연결의 일부인지
를 일정한 패턴 안에서 다룰 수 있습니다.
저는 이게 중요하다고 봅니다. 경계를 보존하려면 오히려 바깥에 보이는 그래프의 문법은 더 엄격해야 하기 때문입니다. 그래야 연결성과 소유권을 분리해서 생각할 수 있습니다.
그래서 왜 모듈러 모놀리스에서도 경계를 세게 잡는가
“어차피 지금은 모놀리스인데, 일단 편하게 합쳐도 되지 않나?”라는 유혹이 이 지점에서 늘 생깁니다. 그런데 이 유혹을 받아들이는 순간, GraphQL의 통합 그래프는 아주 쉽게 내부 결합의 핑계가 됩니다.
그래서 지금 구조에서 모듈러 모놀리스를 택하더라도, 그걸 느슨한 단일 앱처럼 다루지 않으려는 이유가 분명해집니다.
- 각 모듈은 미래의 bounded context 후보이고
- 각 모듈은 나중의 subgraph 후보이며
- 지금은 한 프로세스 안에 있어도, 소유권은 미리 분리해 두어야 합니다
여기서 특히 중요한 원칙이 하나 더 있습니다. BC 경계에서의 참조는 항상 DI로 추상화하고, 마치 외부 API를 호출하듯 취급한다는 점입니다. 추후 실제 API 호출로 바뀌더라도 전환 비용을 낮추기 위해서입니다.
이 원칙이 중요한 이유는, 같은 프로세스 안에 있다고 해서 다른 bounded context의 내부 구현을 바로 가져다 쓰기 시작하는 순간, 경계가 설계 문서에만 남고 코드에서는 사실상 사라지기 때문입니다. 반대로 BC 경계 참조를 인터페이스로 추상화해 두면, 지금은 in-process 호출이더라도 개발자는 그 참조를 자연스럽게 “남의 시스템을 부르는 호출” 처럼 다루게 됩니다.
즉, 통합 그래프를 지향한다고 해서 내부 모듈 경계까지 통합하는 것이 아니라, 오히려 그래프는 통합하되 소유권은 더 엄격히 분리해야 합니다.
2. GraphQL Mutation은 풍부한 응답을 원하고, CQRS Command는 말수가 적고 싶다
두 번째 충돌은 Mutation에서 가장 선명하게 드러납니다.
GraphQL 클라이언트는 보통 Mutation을 호출한 직후, 갱신된 객체를 바로 받고 싶어 합니다. 예를 들어 어떤 주문을 수정했다면, 클라이언트는 곧바로 최신 order 상태를 payload 안에서 받고 싶어 합니다. GraphQL 입장에서는 아주 자연스러운 기대입니다. “한 요청 안에서 필요한 응답을 모두 얻는다”는 사용성이 중요하니까요.
그런데 CQRS 관점에서 보면 이야기가 달라집니다. Command는 본질적으로 상태 변경 요청이지, 풍부한 읽기 모델 반환 API가 아닙니다. Command 처리 결과는 되도록 작고 분명해야 합니다. 많이 반환할수록 Command와 Query의 경계가 흐려집니다.
바로 여기서 충돌합니다.
- GraphQL Mutation은 “바로 풍부한 결과를 달라”고 하고
- CQRS Command는 “나는 상태만 바꾸고 조용히 끝나고 싶다”고 합니다
그래서 왜 raw id를 스키마에 직접 노출하지 않는가
이 지점에서 또 하나의 중요한 결정이 나옵니다. 코드 내부에서는 Command 결과를 아주 작게 유지하고 싶지만, 그렇다고 GraphQL payload를 완전히 빈 껍데기로 만들 수는 없습니다. 이때 가장 손쉬운 방법은 내부에서 알고 있는 id를 그냥 orderId 같은 field로 스키마에 노출하는 것입니다.
하지만 저는 이 방식이 아주 매력적이면서도 동시에 위험하다고 느낍니다. 그렇게 되면 GraphQL payload가 풍부한 object 응답으로 가기 전에, 클라이언트가 내부 command 결과의 원시 식별자(raw id) 에 너무 직접적으로 의존하게 되기 때문입니다.
그래서 지금 구조에서는 이렇게 봅니다.
- 코드 내부의 command 처리 결과는 object를 다시 찾을 수 있는 id만 최소한으로 반환한다
- 하지만 그 id를 GraphQL 계약에 그대로 노출하지는 않는다
- 대신 payload는
order같은 object field를 제공한다 - resolver가 내부 id를 이용해 그 object를 읽어온다
이 결정이 중요한 이유는, GraphQL 스키마가 command handler의 내부 반환 형태를 그대로 드러내지 않게 해주기 때문입니다. 즉,
- 코드 레벨에서는 CQRS를 지키고
- 스키마 레벨에서는 GraphQL의 사용성을 살리되
- 스키마에는 raw id를 공개하지 않고
- 그 사이의 결합은 resolver에서 흡수하는 구조가 됩니다.
이건 단순한 구현 기술이 아니라, Command와 Query를 한 인터페이스 안에서 공존시키기 위한 경계 설정이라고 보는 편이 맞다고 생각합니다.
3. GraphQL payload는 최신 object를 기대하고, read DB는 지연될 수 있다
앞의 문제를 조금 더 밀고 가면, 세 번째 충돌이 바로 나옵니다.
GraphQL payload에 order 같은 object field가 있다면, 사용자는 거의 당연하게 방금 변경한 결과가 바로 반영된 최신 상태를 기대합니다. 특히 Mutation 직후의 응답은 사실상 read-your-write를 기대하게 됩니다.
그런데 시스템에 primary / read DB 분리가 있는 순간, 기본 조회 경로는 read DB로 흘러갈 가능성이 큽니다. 문제는 Mutation 직후 payload resolver까지 같은 경로를 타면, Command는 성공했는데 payload가 읽어 온 정보는 아직 반영되지 않은 상태일 수 있다는 점입니다.
여기서 충돌이 생깁니다.
- GraphQL은 “Mutation payload 안에서 최신 object를 보여 달라”고 하고
- read DB는 “아직 반영이 늦을 수도 있다”고 합니다
그래서 왜 read replica 환경의 mutation payload 최신성을 코드 레벨에서 보장하려 하는가
이 문제를 스키마 설명으로 덮을 수는 없다고 생각합니다. 스키마에 order: Order가 적혀 있다면, 클라이언트는 그걸 쓸 수 있는 데이터 계약으로 받아들입니다. “조금 뒤에 반영될 수도 있다”는 말은 Mutation 직후 payload 기대를 만족시키지 못합니다.
그런데 여기서 인프라 이야기보다 먼저 필요한 것이 하나 있습니다. FE와 BE가 mutation 이후의 일관성 모델을 어디까지 기대하는지 먼저 합의하는 것입니다.
- FE는 mutation payload 안의 object가 즉시 최신 상태라고 기대하는가?
- 아니면 mutation 성공만 확인하고, 최신 projection 반영은 뒤늦게 따라와도 되는가?
- payload 안의 object field는 강한 read-your-write를 약속하는가, 아니면 eventual consistency도 허용하는가?
이 합의가 없으면, 백엔드는 “어차피 곧 맞춰질 데이터”라고 생각하고 read DB를 읽고, 프런트는 “방금 바꾼 결과가 왜 payload에 안 보이지?”라고 받아들이게 됩니다. 결국 문제는 기술보다 계약의 불일치에서 먼저 생깁니다.
그 합의 위에서 저희가 중요하게 보는 것은, read replica 환경에서 Mutation으로 인해 만들어지는 payload 정보만큼은 코드 레벨에서 최신성이 보장되어야 한다는 점입니다. 가장 단순한 방법은 mutation payload를 만들기 위한 resolver를 read DB가 아니라 primary로 보내는 것입니다.
즉 기본 감각은 이렇습니다.
- 일반 조회는 read DB를 사용할 수 있다
- 하지만 mutation payload를 만들기 위한 resolver는 primary 혹은 그에 준하는 write-aware read path를 타야 한다
- 적어도 Mutation 응답 안에서만큼은 stale read를 허용하지 않는다
다만 저희가 말하는 “코드 레벨 보장”은 꼭 primary 직행만 뜻하지는 않습니다. 상황에 따라 더 좋은 방법이 있다면 그 방식으로 보장하면 됩니다. 예를 들면:
- primary read로 직접 보장하기
- GTID 기반으로 replica catch-up 을 확인한 뒤 읽기
- AWS 로컬 쓰기 전달(local write forwarding) 같은 기능을 활용하기
- ProxySQL 같은 계층에서 write-after-read 경로를 제어하기
즉 중요한 것은 구현 수단의 이름이 아니라, Mutation payload의 최신성이 우연이 아니라 의도적으로 보장되는가입니다. primary를 직접 읽든, GTID로 replica catch-up을 기다리든, ProxySQL이나 write forwarding으로 경로를 제어하든, 중요한 것은 FE와 합의한 일관성 모델을 실제 응답 경로가 충족하는가입니다.
핵심은 하나입니다.
Mutation payload의 최신성은 스키마가 아니라 코드와 조회 경로 선택으로 보장해야 한다
저는 이걸 CQRS를 포기하는 결정이 아니라, GraphQL이라는 인터페이스가 요구하는 즉시성에 맞춰 Mutation 응답 경로만 별도로 다루는 결정으로 봅니다. 스키마가 풍부한 응답을 약속했다면, 적어도 그 payload를 만드는 순간만큼은 최신성을 보장하는 경로를 타게 하는 편이 맞다고 생각합니다.
4. GraphQL Query는 경계를 계속 넘고 싶고, DDD는 Aggregate를 끊어 생각하고 싶다
다음 충돌은 조회에서 드러납니다.
GraphQL Query는 기본적으로 탐색적입니다. 한 노드에서 시작해 관련 필드를 따라 계속 들어갈 수 있습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
query {
order(id: "order-1") {
customer {
recentPayments {
edges {
node {
settlement {
ledgerEntry {
id
}
}
}
}
}
}
}
}
이런 쿼리는 GraphQL 관점에서는 아주 자연스럽습니다. 하지만 DDD 관점에서는 질문이 생깁니다.
- 우리는 지금 몇 개의 aggregate 경계를 넘고 있는가?
- 이 탐색은 읽기 모델인가, 아니면 도메인 모델 자체인가?
- 여기서 보이는 연결이 곧 쓰기 권한까지 뜻하는가?
DDD의 aggregate는 일관성 경계입니다. 그런데 GraphQL의 탐색은 그 경계를 읽기 모델 차원에서 아주 손쉽게 넘어가게 만듭니다.
그래서 왜 Query와 Mutation을 다르게 바라봐야 하는가
이 충돌을 풀기 위해 저는 Query와 Mutation을 같은 모델로 보면 안 된다고 생각합니다.
- Query는 read model로 본다
- Mutation은 write model / command entry로 본다
이 구분이 서 있으면, GraphQL Query가 여러 경계를 넘나드는 것 자체는 크게 이상하지 않게 됩니다. 읽기 모델은 원래 사용자 경험과 조회 편의를 위해 여러 소스를 조합할 수 있으니까요.
반대로 Mutation까지 이 논리로 열어버리면 곧바로 문제가 시작됩니다. 조회의 자유로움이 곧 쓰기의 자유로움이 되는 순간, aggregate 경계와 owner context는 거의 의미를 잃게 됩니다.
즉 GraphQL의 탐색성을 살리고 싶다면 오히려 더 강하게,
- 읽기에서 넘는 경계와
- 쓰기에서 넘으면 안 되는 경계
를 분리해서 생각해야 합니다.
5. GraphQL은 한 번에 많이 바꾸고 싶어 하고, DDD/CQRS는 한 번에 적게 바꾸고 싶다
또 하나 자주 부딪히는 지점은 Mutation의 범위입니다.
GraphQL을 쓰다 보면 자연스럽게 이런 유혹이 생깁니다.
- 주문 생성
- 재고 차감
- 배송 생성
- 알림 발송
을 한 mutation에서 한꺼번에 처리하고 싶어집니다. 사용자 입장에서는 “주문하기”라는 하나의 행위처럼 보이기 때문입니다.
하지만 DDD와 CQRS 관점에서는 이건 상당히 위험합니다.
- 하나의 mutation이 여러 owner context를 동시에 건드리게 되고
- command 가 아니라 workflow 전체가 mutation 안으로 들어오고
- 실패 처리, 재시도, 보상, 순서 문제가 순식간에 복잡해집니다
즉 GraphQL의 사용성은 “한 번에 다 하자” 쪽으로 당기고, DDD/CQRS는 “하나의 명령은 가능한 한 좁고 분명해야 한다” 쪽으로 당깁니다.
그래서 왜 Mutation 하나를 하나의 command로 보려 하는가
이 충돌을 풀기 위해서는 Mutation을 오케스트레이션 엔진처럼 쓰기보다, 의도가 분명한 command 진입점으로 보는 편이 낫다고 생각합니다.
즉,
- 한 mutation = 가능하면 하나의 command
- 가능하면 하나의 aggregate owner 안에서 닫히게 하고
- 나머지는 domain event, saga, process manager, projection update 로 푼다
이 구조가 중요한 이유는, GraphQL mutation이 곧바로 시스템 전체 workflow를 대신하지 않게 해주기 때문입니다. GraphQL은 인터페이스이고, DDD/CQRS는 내부 모델입니다. 이 둘을 겹쳐 놓을수록 편해 보이지만, 장기적으로는 훨씬 빠르게 무너집니다.
6. 지금부터 Future Federation을 의식하는 이유도 결국 같은 문제다
모듈러 모놀리스에서 출발해도 나중에 Federation이나 분산 구조를 고려한다면, 사실 지금까지 말한 충돌은 더 이상 이론 문제가 아닙니다.
지금 한 프로세스 안에서 느슨하게 섞어둔 것들은 나중에 거의 그대로 비용이 되어 돌아옵니다.
- 지금 owner context 가 흐리면 나중에 subgraph ownership 이 흐려지고
- 지금 read / write 경계가 흐리면 나중에 서비스 간 호출 설계가 꼬이고
- 지금 GraphQL payload 가 내부 모델에 과하게 의존하면 나중에 분리 비용이 커집니다
그래서 Federation을 지금 당장 도입하지 않더라도,
- 모듈 경계는 미래의 subgraph 경계 후보처럼 보고
- 다른 모듈의 데이터는 참조/조회 모델로 접근하고
- BC 경계 참조는 항상 DI로 추상화해서 외부 API처럼 취급하고(추후 실제 API 호출로 바뀌기 쉽게 하기 위해)
- 직접 쓰기 규칙 변경은 owner module 을 통해서만 일어나게 하고
- 공용 reference 규약은 유지하되 내부 구현 결합은 낮추는 쪽으로 가야 합니다
즉 future federation 을 의식하는 이유도 결국 하나입니다.
통합 그래프와 도메인 경계를 동시에 지키는 연습을 지금부터 해야 하기 때문입니다.
7. 그래서 지금의 결정들은 각각 무엇을 막고 있는가
지금까지의 충돌을 다시 보면, 각각의 설계 결정은 단순한 취향이 아니라 특정 문제를 막기 위한 장치처럼 보입니다.
mutationId
- Mutation을 단순 함수 호출이 아니라 command 로 다루기 위한 장치
- 추적성, 중복 제어, 의도 식별을 강화
raw id 비공개 payload + resolve field
- command handler 의 내부 반환을 스키마 계약에 그대로 노출하지 않기 위한 장치
- CQRS의 최소 응답 원칙과 GraphQL의 풍부한 payload 요구를 분리
read replica 환경에서의 mutation payload 최신성 보장
- GraphQL payload 가 약속한 최신 응답을 실제로 믿을 수 있게 만드는 장치
- primary read, GTID, local write forwarding, ProxySQL 등으로 mutation 응답만큼은 stale read를 막음
- 그 전에 FE와 mutation payload의 일관성 모델을 먼저 명확히 합의
모듈러 모놀리스 + future federation 관점
- 지금의 내부 편의가 미래의 통합 그래프 분해 비용으로 돌아오지 않게 하기 위한 장치
- ownership 과 bounded context 를 미리 보존
- BC 경계 참조를 DI로 추상화해 지금부터 외부 API 호출 감각을 훈련하고, 추후 실제 API 호출로 바뀌는 비용을 낮춤
즉 지금의 결정들은 단순히 “우리는 이렇게 한다”가 아니라,
- GraphQL이 넓히려는 것과
- DDD/CQRS가 지키려는 것
사이의 충돌을 각각 다른 지점에서 흡수하기 위한 선택들로 보는 편이 더 정확하다고 생각합니다.
결론
GraphQL, Relay, DDD, CQRS를 같이 놓고 보면, 이 조합은 분명 매력적입니다. 하나의 연결된 그래프를 제공하면서도, 도메인 경계와 읽기/쓰기 모델을 같이 챙길 수 있기 때문입니다.
하지만 그 매력은 자동으로 얻어지지 않습니다. 오히려 잘 맞는 부분이 많은 만큼, 충돌 지점을 무시하면 더 빠르게 무너집니다.
- GraphQL은 더 많이 연결하려 하고
- Relay는 그 연결을 일관된 규약으로 다루려 하고
- DDD는 경계와 소유권을 엄격하게 지키려 하고
- CQRS는 읽기와 쓰기를 끝까지 분리하려 합니다
그래서 중요한 것은 “무엇을 따르고 있다”를 나열하는 것보다,
각 규율이 어디서 서로 부딪히는지 먼저 이해하고, 그 충돌을 어떤 장치로 흡수하고 있는지를 분명히 아는 것
이라고 생각합니다.
제 식으로 요약하면 이렇습니다.
통합 그래프를 지향하더라도, 경계는 통합하지 않는다. 풍부한 payload를 제공하더라도, command는 말수가 적어야 한다. 최신 응답을 약속했다면, 그 일관성은 코드가 책임져야 한다.
아마 저에겐 이 세 문장이, 지금 GraphQL과 Relay, DDD, CQRS를 함께 바라보는 가장 가까운 설명인 것 같습니다.