MSA 설계 결정 9가지: 왜 모놀리스가 아니라 MSA, 왜 CQRS, 왜 같은 DB로 시작
선착순 티켓팅 시스템을 MSA로 설계하면서 내린 9개 결정을 '상황/옵션/선택/왜/트레이드오프' 5단으로 정리했다. MSA·CQRS·Bulkhead·Gateway·직접 인프라 구현·동시성 제어·진화 순서까지.
MSA: 왜 이렇게 설계했나
각 설계 결정의 상황 / 옵션 / 선택 / 왜 / 트레이드오프 5단 구조. 주니어와 토론할 때 "왜 그 옵션은 안 택했어요?"에 답할 수 있도록.
들어가며
이 글은 선착순 티켓팅 MSA 프로젝트의 설계 결정 로그다. 도착지 다이어그램과 진화 단계는 앞 글에 있고, 여기서는 각 결정의 골목길로 들어간다.
설계 결정을 글로 적을 때 자주 빠지는 함정은 "왜 이걸 골랐는지"만 적는 것이다. 그러면 결정처럼 보이지 않고 그냥 정답으로 보인다. 고른 옵션 뒤에 안 고른 옵션이 있고, 그 차이를 설명할 수 있을 때 비로소 결정이 결정다워진다.
그래서 모든 항목을 5단으로 나눴다.
- 상황 — 어떤 문제 앞에 서 있었는가
- 옵션 — 어떤 선택지가 있었는가
- 선택 — 무엇을 골랐는가
- 왜 — 그 옵션의 강점
- 트레이드오프 — 그 선택이 새로 만든 문제
추가로 각 항목에 토론거리도 붙였다. 주니어와 함께 풀어보면 좋은 질문들이다.
목차
- §1. 왜 MSA인가 (모놀리스가 아니라)
- §2. 왜 CQRS-ish 분리인가 (ticket-command / ticket-query)
- §3. 왜 같은 DB로 시작했나
- §4. 왜 Bulkhead (대기열 ↔ 예매 자원 격리)
- §5. 왜 Gateway를 앞에 두나
- §6. 왜 직접 인프라를 구현했나 (MyRedis, MyKafka)
- §7. 왜 비관락 + @Version 둘 다
- §8. 점진적 진화의 순서가 왜 이 순서인가
- §9. 의식적인 메타 원칙
§1. 왜 MSA인가 (모놀리스가 아니라)
상황
티켓팅 서비스. 핵심 도메인 몇 개(auth, 좌석 조회, 예약 쓰기, 대기열, 결제, ...)가 있다.
옵션
- (A) 모놀리스 — Spring Boot 하나에 모듈로 분리. 같은 JVM, 같은 DB
- (B) MSA — 도메인별 별도 서비스, 별도 배포, 보통 별도 DB
선택: B (MSA)
왜
- 트래픽 패턴이 도메인마다 다르다
- 좌석 조회: 항상 매우 높음 (한 사람이 N번 봄)
- 예약 쓰기: 폭발적으로 짧게 (오픈 직후 수십 분)
- 대기열 polling: 지속적, 인원수 비례
- 모놀리스면 한 서비스를 통째로 스케일해야 한다. 비효율적이다
- 장애 격리. 결제 모듈 버그가 좌석 조회를 멈추면 안 된다
- 독립 배포·기술 선택. Worker는 가벼운 언어로 가도 된다. 학습 단위가 분리된다
- 학습 목적이 MSA 자체. 모놀리스로 짜면 MSA 학습이 안 된다 (당연한 말이지만 진지하게)
트레이드오프 (의식하고 받아들임)
- 운영 복잡도 N배
- 분산 트랜잭션 어려움 (보상 트랜잭션·saga 패턴 학습 거리)
- 네트워크 지연 추가
- 데이터 일관성: 같은 데이터를 여러 서비스가 다른 모델로 가짐
- 소규모 팀·서비스에는 과잉
토론거리
- 모놀리스로 시작해서 나중에 MSA로 쪼개는 (Strangler Fig) 방식과 처음부터 MSA 시작의 차이는?
- "MSA는 만들기 어려운 게 아니라 운영하기 어렵다"는 말의 의미는?
§2. 왜 CQRS-ish 분리인가 (ticket-command / ticket-query)
상황
한 도메인(티켓)이지만 read와 write의 성격이 매우 다르다.
옵션
- (A) 단일
ticket-service안에 read/write API 둘 다 - (B)
ticket-command-service+ticket-query-service분리
선택: B
왜
- 읽기/쓰기 스케일링 곡선이 다름 — 분리하면 각각 독립 스케일
- DB Primary/Replica 분리할 때 query 쪽 코드 변경 거의 없음. URL만 바꾸면 된다
- 읽기 최적화 모델(Read Model)로 분기할 자리
- command: 정규화된
Reservation(status, createdAt) - query: 비정규화된
ReservationProjection(status, createdAt, eventTitle, seatLabel, totalPrice)
- command: 정규화된
- CQRS는 말로만 들어선 안 와닿는다. 두 서비스가 같은 DB를 읽기/쓰기로 나눠 가지는 게 가장 작은 CQRS다
의도적 디자인
- 같은 4개 엔티티를
com.example.ticketcommand.entity와com.example.ticketquery.entity양쪽에 중복 정의 — 추후 read model로 분기할 자유 보존 - query에
spring.datasource.hikari.read-only: true+ 모든@Service에@Transactional(readOnly=true)— replica 라우팅 힌트로 활용 + 권한 미리 잠가둠 ddl-auto: command=update / query=validate — 스키마 권한은 command 한 군데. 부팅 순서 강제
트레이드오프
- 코드 중복 (엔티티 두 벌)
- 배포/관리 복잡도 2배
- 단순 CRUD 앱에서는 과잉. 학습 목적이라 OK
토론거리
- "그냥 같은 서비스의 다른 컨트롤러로 분리하면 안 되나?" 어떤 경우엔 그게 맞는가?
- read model이 별도 DB일 때와 같은 DB일 때 무엇이 다른가?
§3. 왜 같은 DB로 시작했나
상황
CQRS를 하기로 했으면 보통 "command DB / query DB" 둘로 갈라야 한다고 가르친다.
옵션
- (A) 처음부터 두 DB + Kafka로 동기화
- (B) 같은 DB를 read/write 권한만 나눠서 시작 → 나중에 Primary/Replica → 더 나중에 완전 분리
선택: B
왜
- (A)는 데이터 정합성·outbox·eventual consistency 같은 학습량이 한꺼번에 폭발한다. 학습은 한 번에 한 변수
- (B)는 일단 동작하는 시스템을 얻고, 각 단계마다 점진적으로 진화한다
- query side의
read-only: true로 권한만 미리 잠가둠 → replica 분리할 때 URL 한 줄만 변경
진화 단계
[1] 같은 DB (강한 일관성, 학습 변수 0)
↓
[2] Primary/Replica 분리 (replica lag 등장 — 학습)
↓
[3] command DB / query DB 완전 분리 + Kafka 동기화 (eventual consistency, outbox — 학습)
각 단계에서 무엇이 풀리고 무엇이 새로 어려워지는지 직접 체감하는 게 목표다.
토론거리
- 단계 [2]에서 "방금 예약한 좌석이 조회에서 잠시 안 보임"이 발생한다. UI는 어떻게 대응?
- "강한 일관성을 포기하는 비용 vs 얻는 것" 균형은?
§4. 왜 Bulkhead (대기열 ↔ 예매 자원 격리)
다이어그램의 핵심 결정이다.
문제 상황
콘서트 오픈 직후: 수만 명이 동시에 대기열 polling. 동시에 (먼저 입장한) 수백 명이 예매 시도. 같은 서버군이 받으면 polling 트래픽이 예매를 죽인다.
Bulkhead 패턴이란
배의 격벽에서 따온 이름이다. 한 칸에 물이 들어와도 다른 칸은 안 잠긴다.
시스템 자원(서버, DB connection pool, thread pool, Redis)을 용도별로 격리하는 패턴이다.
우리 적용
- Queue API × N (대기열만 처리) + Queue LB + 대기열 Redis (ZSET/Active Set)
- Booking API × N (예매만 처리) + Booking LB + 잔여좌석 Redis (SETNX/Lua)
- 두 군이 자원을 공유하지 않음
효과
- polling 트래픽이 폭주해도 예매 처리에 영향 없음
- 대기열 Redis가 죽어도 진입한 사람의 예매는 계속됨 (의미: 폭발 반경이 작아짐)
트레이드오프
- 자원 활용률 낮을 수 있음 (한 쪽 idle인데 다른 쪽 부족)
- 구성 복잡도
토론거리
- "그냥 큰 서버 한 줄 두는 게 더 효율적이지 않나?" 언제 그게 맞고 언제 틀린가?
- Circuit Breaker 패턴과 Bulkhead의 관계는?
§5. 왜 Gateway를 앞에 두나
옵션
- (A) 클라이언트가 직접 각 서비스 호출
- (B) Gateway가 단일 진입점
선택: B
왜
- URL 라우팅 단일화 —
/queue/*→ Queue API,/booking/*→ Booking API. 클라이언트는 한 URL만 알면 됨 - 횡단 관심사 집중 — JWT 검증, rate limiting, 로깅, CORS, TLS termination을 한 곳에서
- 내부 서비스 토폴로지 캡슐화 — 내부에서 서비스 분리/합치기가 자유로움
- 버전 라우팅 —
/v1/*↔/v2/*같은 점진 마이그레이션
안 좋은 점
- 단일 장애점 (대비: 다중 인스턴스 + LB)
- 추가 hop = 추가 latency
- Gateway 자체가 복잡해짐
우리 선택
Spring Cloud Gateway. 같은 JVM 생태계, 같은 팀, 학습 비용 최소.
토론거리
- BFF (Backend For Frontend) 패턴과 API Gateway의 차이는?
- Service Mesh (Istio 등)와 API Gateway는 어디서 책임이 갈라지는가?
§6. 왜 직접 인프라를 구현했나 (MyRedis, MyKafka)
상황
운영하려면 AWS ElastiCache, AWS MSK, 또는 docker로 redis/kafka 띄우면 된다. 그게 정상이다.
왜 직접 구현
- 학습 목적. "Kafka가 빠르다"고 들어도 왜 빠른지는 안 와닿는다. partition, segment, sparse index, length-prefix framing을 직접 짜보면 "아, 이래서 이런 trade-off가 있구나"가 와닿는다
- 추상화 너머의 감각. 매니지드 서비스가 죽었을 때 디버깅에서 큰 차이를 만든다
- MVP만 구현. 모든 기능 다 만들지 않는다 — 핵심만
의도적으로 안 넣은 것 (MyKafka 예)
- Replication (Leader/Follower + ISR)
- KRaft 컨트롤러
- Log compaction
- Exactly-once
- Zero-copy
→ 각각 "왜 필요한가"의 학습 거리. 부재가 한계로 드러나면 그때 구현한다.
트레이드오프
- 실전 production에는 부적합 (당연)
- 학습 시간이 큼
토론거리
- 어떤 경우에 매니지드 vs 직접 운영 vs 직접 구현을 선택하는가?
- 학습용으로 직접 구현하는 거 외에 production에 적합한 경량 대체재는?
§7. 왜 비관락 + @Version 둘 다
코드
@Lock(LockModeType.PESSIMISTIC_WRITE)
fun findAllByIdIn(ids: Collection<String>): List<SeatEntity>
@Version
var version: Long = 0왜 둘 다
- 비관락 (PESSIMISTIC_WRITE): 두 명이 같은 좌석 동시 클릭 시 트랜잭션 직렬화.
SELECT ... FOR UPDATE로 DB가 직접 막음 - @Version (낙관락): 비관락이 풀린 직후의 짧은 race window를 한 번 더 차단. 미래에 비관락을 풀고 낙관락으로 갈아탈 때 사전 준비
동시성 제어 옵션 비교
| 방식 | 장점 | 단점 | 어디 좋나 |
|---|---|---|---|
| 비관락 | 명확, 정확 | DB 락 대기 → 트래픽 폭주 시 병목 | 경쟁 빈도 높고 충돌 비용 큰 경우 |
| 낙관락 | 락 안 잡음, 처리량 ↑ | 충돌 시 재시도 로직 | 경쟁 빈도 낮은 경우 |
| Redis SETNX | DB 부담 X | Redis 의존, 정합성 검증 어려움 | 빠른 응답, 짧은 lock |
| Kafka 파티션 큐잉 | 자연 직렬화 | 응답 지연, 복잡도 ↑ | 처리 순서 보장 + 비동기 OK |
다이어그램의 잔여좌석 Redis(SETNX + Lua)는 3번이다. 우리는 1번에서 시작해 부하 테스트로 한계 측정 후 진화한다.
토론거리
- 시나리오 B 측정에서 비관락이 잡혔는데도 latency가 줄었다. 왜? (힌트: JIT warm-up)
- 좌석당 100명 경쟁이면 어떻게 변할까?
§8. 점진적 진화의 순서가 왜 이 순서인가
[1] command 동기 쓰기 (현재)
[2] + Worker (MyKafka 비동기 영속화)
[3] + DB Primary/Replica
[4] + Queue API + Scheduler
[5] + Gateway 완성 (JWT, 라우팅)
이 순서의 근거
- [1]→[2]: API 폭주가 DB 폭주로 직결되는 문제 해결. MyKafka 이미 만들었으니 자연스러운 다음 단계
- [2]→[3]: 조회 부하가 다음 병목. write를 비동기로 풀어도 read 부하는 그대로
- [3]→[4]: 진입 자체를 제어. write/read 모두 풀어도 폭주 자체를 받지 않는 게 궁극
- [4]→[5]: Gateway 정식화. 그 전엔 각 서비스가 직접 노출되어도 학습 가능
왜 이 순서가 아니면 안 되나
- [4]→[1] 식 역순: 대기열부터 만들면 정작 뒤쪽 서비스가 받는 트래픽이 빈약해서 "왜 대기열이 필요한가" 체감이 안 됨
- [3] 먼저 → [2] 나중: replica 분리해도 write 쪽은 여전히 동기. write 부하가 먼저 한계 도달함
토론거리
- "Worker(2)와 Replica(3) 중 어느 게 먼저 필요해질지"는 부하 테스트가 답한다. 어느 지표를 봐야 하는가?
§9. 의식적인 메타 원칙
이 프로젝트 내내 따르는 6개 규칙이다. 각 결정에 영향을 미친다.
- 학습은 한 번에 한 변수. 새 개념과 새 도구를 동시에 도입하지 않는다
- 동작하는 최소 → 측정 → 한계 → 진화. prototype-first가 아니라 evolve-first
- 다이어그램 그대로 베끼지 않는다. 다이어그램은 도착지 힌트, 길은 직접 걷는다
- 공유 모델로 묶지 않는다 (지금은). CQRS의 자유도 보존
- 권한을 미리 잠가둔다 (query read-only). 미래 분리가 쉬워지는 방향
- 인프라 설치는 사용자가 직접. 자동화는 안내까지만
이 원칙들이 깨질 때 학습이 생긴다
시나리오 B 측정에서 원칙 1을 의도치 않게 어겼다. JIT warm-up이 숨은 변수로 끼어들어서, 비관락이 더 많이 잡혔는데도 latency가 줄어드는 역설이 나왔다. 사후에 발견 → 학습으로 전환.
원칙은 무조건 지키려고 두는 게 아니라, 깨졌을 때 그걸 알아채는 기준으로 두는 것이다.
마치며
설계 결정을 9개로 쪼개 적으면 한 가지가 분명해진다. "MSA를 도입하라"는 한 줄짜리 결정이 아니라, 도메인 분리·CQRS·같은 DB 시작·Bulkhead·Gateway·동시성 제어·진화 순서가 모두 엮인 결정 묶음이라는 점이다.
각각의 결정은 다음 두 가지 중 하나를 고른 결과다.
- 지금 풀면 이득이 큰 문제 → 도입
- 지금 풀면 학습량이 폭발하는 문제 → 권한만 미리 잠가두고 다음 단계로 미룸
이 두 가지가 동시에 만족되는 결정은 거의 없다. 그래서 항상 "지금 어느 한쪽을 받아들이고 다른 쪽은 어떻게 미루는가"가 핵심 질문이 된다. 위 9개 결정이 그에 대한 우리의 답이다.
이 시리즈는 이어진다. 다음 글들에서 CQRS·Kafka 같은 각 부품의 깊은 이야기를 따로 다룬다. 런타임 관점이 궁금하면 이벤트 루프도 곁들여 읽어보면 좋다.