MSA — 왜 이렇게 설계했나
msa 로 돌아가기
각 설계 결정의 상황 / 옵션 / 선택 / 왜 / 트레이드오프 5단 구조. 주니어와 토론할 때 "왜 그 옵션은 안 택했어요?" 에 답할 수 있도록.
§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 한 군데. 부팅 순서 강제.
트레이드오프
- 코드 중복 (entity 두 벌)
- 배포/관리 복잡도 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에 적합한 경량 대체재는?
자세한 Kafka 이야기 → kafka
§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명 경쟁이면 어떻게 변할까? (시나리오 §4 후보)
§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이 숨은 변수). 사후에 발견 → 학습으로 전환. → discussion §5 참조.