MSA2026년 5월 27일9분 읽기

MSA 설계 결정 9가지: 왜 모놀리스가 아니라 MSA, 왜 CQRS, 왜 같은 DB로 시작

선착순 티켓팅 시스템을 MSA로 설계하면서 내린 9개 결정을 '상황/옵션/선택/왜/트레이드오프' 5단으로 정리했다. MSA·CQRS·Bulkhead·Gateway·직접 인프라 구현·동시성 제어·진화 순서까지.

#MSA#Architecture#CQRS#Bulkhead#Gateway#Concurrency#Kafka#Redis#DesignDecisions

MSA: 왜 이렇게 설계했나

각 설계 결정의 상황 / 옵션 / 선택 / 왜 / 트레이드오프 5단 구조. 주니어와 토론할 때 "왜 그 옵션은 안 택했어요?"에 답할 수 있도록.


들어가며

이 글은 선착순 티켓팅 MSA 프로젝트설계 결정 로그다. 도착지 다이어그램과 진화 단계는 앞 글에 있고, 여기서는 각 결정의 골목길로 들어간다.

설계 결정을 글로 적을 때 자주 빠지는 함정은 "왜 이걸 골랐는지"만 적는 것이다. 그러면 결정처럼 보이지 않고 그냥 정답으로 보인다. 고른 옵션 뒤에 안 고른 옵션이 있고, 그 차이를 설명할 수 있을 때 비로소 결정이 결정다워진다.

그래서 모든 항목을 5단으로 나눴다.

  1. 상황 — 어떤 문제 앞에 서 있었는가
  2. 옵션 — 어떤 선택지가 있었는가
  3. 선택 — 무엇을 골랐는가
  4. — 그 옵션의 강점
  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)

  1. 트래픽 패턴이 도메인마다 다르다
    • 좌석 조회: 항상 매우 높음 (한 사람이 N번 봄)
    • 예약 쓰기: 폭발적으로 짧게 (오픈 직후 수십 분)
    • 대기열 polling: 지속적, 인원수 비례
    • 모놀리스면 한 서비스를 통째로 스케일해야 한다. 비효율적이다
  2. 장애 격리. 결제 모듈 버그가 좌석 조회를 멈추면 안 된다
  3. 독립 배포·기술 선택. Worker는 가벼운 언어로 가도 된다. 학습 단위가 분리된다
  4. 학습 목적이 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

  1. 읽기/쓰기 스케일링 곡선이 다름 — 분리하면 각각 독립 스케일
  2. DB Primary/Replica 분리할 때 query 쪽 코드 변경 거의 없음. URL만 바꾸면 된다
  3. 읽기 최적화 모델(Read Model)로 분기할 자리
    • command: 정규화된 Reservation(status, createdAt)
    • query: 비정규화된 ReservationProjection(status, createdAt, eventTitle, seatLabel, totalPrice)
  4. CQRS는 말로만 들어선 안 와닿는다. 두 서비스가 같은 DB를 읽기/쓰기로 나눠 가지는 게 가장 작은 CQRS

의도적 디자인

  • 같은 4개 엔티티를 com.example.ticketcommand.entitycom.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 한 줄만 변경

진화 단계

text
[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

  1. URL 라우팅 단일화/queue/* → Queue API, /booking/* → Booking API. 클라이언트는 한 URL만 알면 됨
  2. 횡단 관심사 집중 — JWT 검증, rate limiting, 로깅, CORS, TLS termination을 한 곳에서
  3. 내부 서비스 토폴로지 캡슐화 — 내부에서 서비스 분리/합치기가 자유로움
  4. 버전 라우팅/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 둘 다

코드

kotlin
@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 SETNXDB 부담 XRedis 의존, 정합성 검증 어려움빠른 응답, 짧은 lock
Kafka 파티션 큐잉자연 직렬화응답 지연, 복잡도 ↑처리 순서 보장 + 비동기 OK

다이어그램의 잔여좌석 Redis(SETNX + Lua)는 3번이다. 우리는 1번에서 시작해 부하 테스트로 한계 측정 후 진화한다.

토론거리

  • 시나리오 B 측정에서 비관락이 잡혔는데도 latency가 줄었다. 왜? (힌트: JIT warm-up)
  • 좌석당 100명 경쟁이면 어떻게 변할까?

§8. 점진적 진화의 순서가 왜 이 순서인가

text
[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개 규칙이다. 각 결정에 영향을 미친다.

  1. 학습은 한 번에 한 변수. 새 개념과 새 도구를 동시에 도입하지 않는다
  2. 동작하는 최소 → 측정 → 한계 → 진화. prototype-first가 아니라 evolve-first
  3. 다이어그램 그대로 베끼지 않는다. 다이어그램은 도착지 힌트, 길은 직접 걷는다
  4. 공유 모델로 묶지 않는다 (지금은). CQRS의 자유도 보존
  5. 권한을 미리 잠가둔다 (query read-only). 미래 분리가 쉬워지는 방향
  6. 인프라 설치는 사용자가 직접. 자동화는 안내까지만

이 원칙들이 깨질 때 학습이 생긴다

시나리오 B 측정에서 원칙 1을 의도치 않게 어겼다. JIT warm-up이 숨은 변수로 끼어들어서, 비관락이 더 많이 잡혔는데도 latency가 줄어드는 역설이 나왔다. 사후에 발견 → 학습으로 전환.

원칙은 무조건 지키려고 두는 게 아니라, 깨졌을 때 그걸 알아채는 기준으로 두는 것이다.


마치며

설계 결정을 9개로 쪼개 적으면 한 가지가 분명해진다. "MSA를 도입하라"는 한 줄짜리 결정이 아니라, 도메인 분리·CQRS·같은 DB 시작·Bulkhead·Gateway·동시성 제어·진화 순서가 모두 엮인 결정 묶음이라는 점이다.

각각의 결정은 다음 두 가지 중 하나를 고른 결과다.

  • 지금 풀면 이득이 큰 문제 → 도입
  • 지금 풀면 학습량이 폭발하는 문제 → 권한만 미리 잠가두고 다음 단계로 미룸

이 두 가지가 동시에 만족되는 결정은 거의 없다. 그래서 항상 "지금 어느 한쪽을 받아들이고 다른 쪽은 어떻게 미루는가"가 핵심 질문이 된다. 위 9개 결정이 그에 대한 우리의 답이다.

이 시리즈는 이어진다. 다음 글들에서 CQRS·Kafka 같은 각 부품의 깊은 이야기를 따로 다룬다. 런타임 관점이 궁금하면 이벤트 루프도 곁들여 읽어보면 좋다.

#MSA#Architecture#CQRS#Bulkhead#Gateway#Concurrency#Kafka#Redis#DesignDecisions

황호민

Backend Engineer · Java/Kotlin · Spring Boot · Next.js