모놀리스에서 마이크로서비스까지
모놀리스에서 마이크로서비스로 전환하는 과정에서 마주치는 개념, 패턴, 그리고 현실적인 선택들을 다룹니다.
마이크로서비스는 도구이지 목적이 아니다.
목차
1부. 왜 마이크로서비스인가
마이크로서비스란 무엇이고, 무엇을 얻고 무엇을 잃는가. 전환 결정의 출발점.
- 1장. 마이크로서비스의 기본 개념과 장점
- 2장. 마이크로서비스의 그림자
- 3장. 반드시 이해해야 할 핵심 개념들
2부. 모놀리스를 어떻게 떠날 것인가
빅뱅이 아닌 점진적 전환. 라우팅과 Gateway, 데이터 마이그레이션까지.
- 4장. 한 번에 옮길 수 없다 — 점진적 전환의 원칙
- 5장. Strangler Fig — 모놀리스를 감싸 잘라내기
- 6장. Gateway를 위로 끌어올리기 — 모놀리스 앞에 새 관문 두기
- 7장. 데이터 마이그레이션 — 공유 DB에서 분리된 DB까지
3부. 경계를 설계하다
서비스를 어디서 나누고, 어떻게 그 경계를 유지할 것인가.
- 8장. 경계를 설계하는 방법 — DDD와 Bounded Context
- 9장. 무엇부터 떼어낼 것인가 — 추출 우선순위 결정법
- 10장. 경계를 지키는 설계 — DB per Service와 스냅샷
- 11장. 경계를 연결하는 방법 — 동기와 비동기
4부. 외부와 연결하다
클라이언트와 시스템 사이의 관문, 채널 특화, 그리고 인증의 흐름.
- 12장. 단일 진입점의 설계 — API Gateway
- 13장. 채널을 위한 설계 — BFF
- 14장. 인증과 인가 — 분산 환경에서 사용자 정보가 흐른다
5부. 이벤트 기반 통신의 현실
메시지는 유실되고, 중복되고, 순서가 뒤바뀐다. 그 위에서 시스템을 어떻게 설계할 것인가.
- 15장. 이벤트로 연결되는 시스템
- 16장. 메시지는 정확히 한 번 오지 않는다
- 17장. 이벤트는 항상 순서대로 오지 않는다
- 18장. 같은 이벤트는 여러 번 온다 — 멱등성
6부. 분산 환경의 정합성
트랜잭션이 사라진 자리에 Outbox·Saga·검증이 들어선다.
- 19장. 분산 환경에서의 정합성 — 최종 일관성
- 20장. 트랜잭션과 이벤트의 원자성 — Outbox
- 21장. 이벤트를 안전하게 전달하는 방법 — Outbox + Inbox
- 22장. Saga 패턴
- 23장. 전환 중의 데이터 정합성 — Dual-write에서 살아남기
7부. 장애와 복원력
장애는 사라지지 않는다. 다만 퍼지지 않게 만든다.
- 24장. 동기 호출 기반 복원력
- 25장. Circuit Breaker
- 26장. 비동기 시스템의 장애와 복원 전략
- 27장. 관측 가능성 — Trace · Metric · Log
8부. 데이터 모델의 진화
분산 환경에서 읽기와 쓰기의 모델은 분리된다.
- 28장. JOIN이 사라진 세계 — 분산 환경에서의 조회
- 29장. 읽기와 쓰기의 모델을 분리하다 — CQRS
- 30장. 상태 대신 이벤트를 저장하다 — Event Sourcing
- 31장. Event Sourcing의 불변성과 보상 전략
- 32장. 동시성과 일관성
9부. 코드 아키텍처
서비스 내부의 의존성을 어떻게 설계할 것인가.
- 33장. 레이어 아키텍처
- 34장. 의존성은 왜 문제가 되는가
- 35장. 헥사고날 아키텍처
- 36장. 실제 코드 구조로 보는 헥사고날 아키텍처
10부. 안티패턴과 함정
분산 모놀리스, Nanoservice, 그리고 돌아온 사람들의 이야기.
- 37장. 분산 모놀리스의 다양한 얼굴
- 38장. Nanoservice — 너무 잘게 쪼갠 함정
- 39장. 마이크로서비스로 갔다가 돌아온 사례들
11부. 운영과 종합
안전한 배포, 통합 사례, 그리고 다시 처음의 질문으로.
- 40장. 배포 전략 — Blue/Green, Canary, Feature Flag
- 41장. 실전 사례 — 모놀리스 전자상거래 전환기
- 42장. 다시, 마이크로서비스는 의무가 아니다 (에필로그)
책의 흐름
1부 ─ 왜 마이크로서비스인가?
2부 ─ 모놀리스에서 어떻게 떠날 것인가?
3부 ─ 서비스를 어디서 나눌 것인가?
4부 ─ 외부와 어떻게 연결할 것인가?
5부 ─ 이벤트 기반 통신의 현실
6부 ─ 분산 환경에서 정합성을 어떻게 보장하나?
7부 ─ 장애를 어떻게 견디나?
8부 ─ 데이터 모델은 어떻게 진화하나?
9부 ─ 서비스 내부 코드는 어떻게 구성하나?
10부 ─ 어떤 함정을 피해야 하나?
11부 ─ 어떻게 운영하고 마무리할 것인가?
총 11부 · 42장.
어떻게 읽을 것인가
처음부터 끝까지 — 1부부터 11부까지 순서대로 읽으면 전체 여정을 따라갈 수 있습니다.
필요한 부분만 — 지금 막힌 주제로 바로 가도 됩니다. 각 챕터는 자체 결론으로 마무리되므로 흐름이 끊기지 않습니다.
- 추출 우선순위가 막혔다면 → 9장
- 데이터 분리가 문제라면 → 7장 또는 23장
- 이벤트 처리가 꼬였다면 → 5부 전체
- 분산 모놀리스 의심이 든다면 → 37장
- 너무 잘게 쪼개졌다면 → 38장
자세한 안내는 서문을 참고하세요.
서문 — 이 책을 시작하기 전에
이 책은 모놀리스에서 마이크로서비스로 옮겨가는 사람을 위한 책이다. 지금 그 한가운데 있거나, 시작을 고민하고 있거나, 이미 옮긴 후의 문제를 다루는 사람을 위한 책이다.
왜 이 책을 썼는가
마이크로서비스에 관한 책은 이미 많다. 패턴을 정리한 책도, 도구를 다루는 책도 있다.
하지만 실제 전환의 현장에서 부딪치는 질문은 그 책들이 잘 다루지 않는다.
- 무엇부터 떼어낼 것인가
- 모놀리스가 Gateway일 때 어떻게 풀어낼 것인가
- 신·구 시스템이 공존하는 동안 데이터를 어떻게 다룰 것인가
- 분산 모놀리스로 회귀하지 않으려면 무엇을 봐야 하는가
이 책은 그 질문들에 답하기 위해 쓰였다.
누가 읽으면 좋은가
이 책은 다음 사람들에게 가장 유용하다.
- 모놀리스를 운영 중이고 마이크로서비스 전환을 고민하는 개발자
- 이미 전환 중이고 길을 잃지 않으려는 팀
- 분리된 서비스들이 점점 분산 모놀리스처럼 느껴지는 회사
- 마이크로서비스의 개념과 패턴을 한 번에 정리하고 싶은 사람
다음 분들에게는 덜 적합할 수 있다.
- 마이크로서비스를 처음 들어보는 입문자 — 일부 기본 개념은 가볍게만 다룬다
- Kubernetes·Service Mesh 같은 인프라 도구의 운영 매뉴얼을 찾는 사람 — 이 책의 범위가 아니다
- 특정 언어·프레임워크의 구현 방법을 찾는 사람 — 이 책은 언어 중립적이다
이 책의 특징
1️⃣ 개념보다 결정에 집중한다
“마이크로서비스가 무엇인가“보다 “언제·어떻게 전환할 것인가“에 더 무게를 둔다.
2️⃣ 패턴마다 비용을 정직하게 짚는다
모든 패턴은 비용이 있다. 이 책은 장점만 보여주지 않는다.
3️⃣ 안티패턴을 별도로 다룬다
10부 전체를 함정과 회귀 사례에 할애한다. 좋은 결정만큼 나쁜 결정의 패턴을 아는 것도 중요하다.
4️⃣ 짧은 호흡으로 쓰였다
GitHub에서 md 뷰어로 읽기에 좋도록 짧은 문장과 빈 줄로 호흡을 만들었다.
어떻게 읽을 것인가
이 책은 두 가지 방식으로 읽을 수 있다.
처음부터 끝까지
1부 → 11부 순서대로 읽으면 모놀리스에서 마이크로서비스까지의 전체 여정을 따라간다.
처음 마이크로서비스를 접하는 사람, 큰 그림을 잡고 싶은 사람에게 권장한다.
필요한 부분만 골라서
각 챕터는 독립적으로도 읽을 수 있다. 지금 막힌 부분이 있다면 해당 챕터로 바로 가도 된다.
- 추출 우선순위가 막혔다면 → 9장
- 데이터 분리가 문제라면 → 7장 또는 23장
- 이벤트 처리가 꼬였다면 → 5부 전체
- 분산 모놀리스 의심이 든다면 → 37장
각 챕터는 자체 결론으로 마무리되므로 부분적으로 읽어도 흐름이 끊기지 않는다.
이 책이 다루지 않는 것
이 책은 의도적으로 다음을 다루지 않는다.
- 컨테이너·Kubernetes 운영
- Service Mesh의 설정
- CI/CD 파이프라인 구축
- 보안·컴플라이언스의 세부
- 로컬 개발 환경 구성
- 특정 언어·프레임워크
이 주제들은 각자 책 한 권 분량이다. 이 책은 그 위에 얹어 읽는 책이다.
책의 흐름
[1부] 왜 마이크로서비스인가? — 결정의 시작
[2부] 어떻게 떠날 것인가? — 전환 전략
[3부] 경계를 설계하다 — DDD와 책임 분리
[4부] 외부와 연결하다 — Gateway·BFF·인증
[5부] 이벤트 기반 통신의 현실 — 메시지의 비결정성
[6부] 분산 환경의 정합성 — 트랜잭션의 재정의
[7부] 장애와 복원력 — 살아남는 시스템
[8부] 데이터 모델의 진화 — 읽기와 쓰기의 분리
[9부] 코드 아키텍처 — 서비스 내부 설계
[10부] 안티패턴과 함정 — 실패의 모양
[11부] 운영과 종합 — 배포·사례·에필로그
총 11부 · 42장.
마지막으로
이 책에는 한 가지 큰 메시지가 흐른다.
마이크로서비스는 도구이지 목적이 아니다.
이 메시지는 1장에서 시작해서 42장까지 이어진다.
도구의 힘을 알고, 그 비용도 알고, 자기 상황에 맞게 결정하고, 필요하다면 결정을 되돌릴 줄 아는 사람.
이 책은 그런 결정을 돕기 위해 쓰였다.
이제 1장으로 넘어가자.
1장. 마이크로서비스의 기본 개념과 장점
시스템은 성장한다.
그리고 성장한 시스템은 언젠가 구조에 대해 다시 질문하게 만든다.
“지금 구조가 최선인가?”
“이대로 계속 가도 되는가?”
“나누는 것이 맞는가, 유지하는 것이 맞는가?”
마이크로서비스는 하나의 해답이 될 수 있다.
그러나 그 전에 반드시 스스로에게 물어야 할 것이 있다.
정말 지금 넘어가야 하는가?
마이크로서비스란 무엇인가
마이크로서비스는 단순히 서비스를 작게 나누는 방식이 아니다.
독립적으로 배포 가능하고
독립적으로 확장 가능하며
독립적으로 진화할 수 있는
비즈니스 단위 서비스들의 집합
핵심은 ‘작음’이 아니라 독립성이다.
각 서비스는
- 자체 코드베이스
- 자체 데이터 저장소
- 독립 배포
- 독립 확장
- 명확한 도메인 책임
을 가진다.
기능 계층이 아니라
비즈니스 책임 단위로 분리되는 구조다.
마이크로서비스의 장점
1. 독립 확장
트래픽이 특정 영역에 집중될 경우
그 서비스만 확장할 수 있다.
자원 사용 효율이 높아지고
불필요한 비용 증가를 막을 수 있다.
2. 독립 배포
한 기능의 수정이 전체 재배포를 의미하지 않는다.
배포 리스크가 낮아지고
변경 속도가 빨라진다.
3. 장애 격리
한 서비스 장애가 전체 시스템을 중단시키지 않도록
설계할 수 있다.
복구 범위가 작아진다.
4. 조직 단위 자율성
서비스 단위로 팀이 움직일 수 있다.
조직 성장과 함께 구조도 확장할 수 있다.
그러나, 모놀리스는 정말 나쁜가?
여기서 반드시 멈춰야 한다.
모놀리스는 잘못된 구조가 아니다.
많은 경우 여전히 가장 합리적인 선택이다.
모놀리스는
- 구조가 단순하다
- 운영이 쉽다
- 트랜잭션 관리가 명확하다
- 네트워크 장애 고민이 없다
- 디버깅이 직관적이다
특히 다음과 같은 상황이라면
모놀리스를 유지하는 것이 더 나을 수 있다.
- 팀 규모가 작다
- 트래픽 패턴이 단순하다
- 도메인이 복잡하게 분리되어 있지 않다
- 배포 빈도가 높지 않다
- 시스템 변경 속도가 안정적이다
마이크로서비스는 복잡성을 제거하지 않는다.
복잡성을 외부로 이동시킨다.
코드 내부의 의존성이
네트워크 의존성으로 바뀌고,
트랜잭션 문제는
데이터 일관성 문제로 바뀐다.
디버깅은
분산 추적 문제로 바뀐다.
우리가 스스로에게 던져야 할 질문
전환을 고민할 때는 다음 질문에 답해보아야 한다.
- 특정 기능만 독립적으로 확장해야 하는 상황이 반복되는가?
- 배포가 조직 전체의 스트레스가 되었는가?
- 하나의 장애가 전체 시스템으로 확산되는가?
- 팀이 커지면서 코드 충돌과 의존성이 증가했는가?
- 도메인 경계가 명확하게 구분되는가?
이 질문에 여러 개가 “예”라면
전환을 진지하게 고려할 시점일 수 있다.
하지만 그렇지 않다면
모놀리스 최적화가 더 합리적인 선택일 수도 있다.
전환은 선택이지 의무가 아니다
마이크로서비스는 성장 전략이다.
기술 트렌드가 아니다.
넘어가는 이유가 분명해야 한다.
- 확장성 문제 해결
- 조직 구조와의 정렬
- 배포 독립성 확보
- 장애 격리 필요
이 중 어느 것도 절실하지 않다면
지금은 준비 단계일 수 있다.
이 장의 핵심
마이크로서비스는 강력한 구조다.
그러나 모든 시스템에 필요한 구조는 아니다.
중요한 것은 “쪼갤 수 있느냐”가 아니라
“쪼개야 하는 이유가 있는가” 다.
2장. 마이크로서비스의 그림자
1장에서 우리는 마이크로서비스의 장점을 살펴보았다.
독립 배포, 독립 확장, 장애 격리.
하지만 구조를 나눈다는 것은
단순히 깔끔해지는 일이 아니다.
보이지 않던 문제들이
겉으로 드러나는 일이기도 하다.
이 장에서는
마이크로서비스가 만들어내는 현실적인 변화들을 살펴본다.
함수 호출이 아니라, 네트워크 호출이다
모놀리스에서는 이런 일이 자연스럽다.
주문 생성 → 결제 함수 호출 → 포인트 적립 함수 호출
같은 프로그램 안에서 호출된다.
실패 가능성은 거의 없다.
하지만 마이크로서비스에서는 다르다.
주문 서비스 → (네트워크) → 결제 서비스 → (네트워크) → 포인트 서비스
이제 호출은 네트워크를 건너간다.
그리고 네트워크는 완벽하지 않다.
- 잠시 느려질 수 있다
- 응답이 늦어질 수 있다
- 중간에 끊길 수 있다
- 상대 서비스가 일시적으로 죽어 있을 수 있다
이제 단순한 함수 호출이 아니라
실패를 전제로 설계해야 하는 호출이 된다.
모든 것이 한 번에 끝나지 않는다
모놀리스에서는 이런 보장이 있다.
- 주문 생성
- 결제 성공
- 포인트 적립
이 세 가지가 하나라도 실패하면 모두 취소된다.
사용자는 항상 “완성된 상태”만 보게 된다.
하지만 마이크로서비스에서는
상황이 달라질 수 있다.
예를 들어,
- 주문은 정상 생성되었다
- 결제는 성공했다
- 하지만 포인트 적립 서비스가 잠시 멈춰 있었다
이 경우 사용자는
- 주문 내역은 보이는데
- 포인트는 아직 적립되지 않은 상태를 보게 될 수 있다
조금 뒤에 포인트는 정상적으로 들어온다.
이처럼
일부 정보가 잠시 늦게 반영되는 상태를 허용하는 방식을
최종 일관성(Eventual Consistency)이라고 부른다.
중요한 것은,
데이터가 틀리는 것이 아니라
“시간 차이를 허용한다”는 점이다.
디버깅이 달라진다
모놀리스에서는 로그를 한 곳에서 보면 된다.
하지만 마이크로서비스에서는
- 주문 서비스 로그
- 결제 서비스 로그
- 포인트 서비스 로그
- 메시지 큐 로그
를 모두 확인해야 한다.
하나의 사용자 요청이
여러 서비스를 거쳐 이동하기 때문이다.
문제가 생기면
“어디에서 멈췄는지”를 추적해야 한다.
이제 디버깅은 코드 추적이 아니라
흐름 추적이 된다.
배포는 쉬워지지만, 관리 대상은 늘어난다
모놀리스는 하나만 배포하면 된다.
마이크로서비스는 서비스 수만큼
배포 대상이 존재한다.
- 버전 충돌 문제
- API 변경 문제
- 호환성 문제
- 롤백 전략
자동화가 없다면
운영 난이도는 오히려 높아질 수 있다.
데이터는 더 이상 중앙에서 보호되지 않는다
모놀리스에서는 하나의 데이터베이스가
모든 데이터를 관리한다.
마이크로서비스에서는
각 서비스가 자신의 데이터를 가진다.
이 말은 곧,
- 다른 서비스 데이터는 직접 수정할 수 없고
- 반드시 API나 이벤트를 통해 요청해야 하며
- 데이터 정합성은 협업으로 맞춰야 한다는 뜻이다
설계가 부족하면
데이터가 서로 어긋나는 일이 생길 수 있다.
장애는 사라지지 않는다
마이크로서비스는 장애를 없애는 구조가 아니다.
다만,
하나가 죽어도
전체가 같이 죽지 않게 만드는 구조
다.
그러려면
- 응답 대기 시간 설정
- 재시도 정책
- 과도한 호출 차단
- 트래픽 제한
같은 설계가 필요하다.
이 준비가 없다면
구조를 나누어도 장애는 그대로 전파된다.
우리가 스스로에게 던져야 할 질문
마이크로서비스의 장점이 매력적으로 보이더라도
다음 질문에 답해보아야 한다.
- 우리는 네트워크 실패를 설계로 다뤄본 경험이 있는가?
- 일부 데이터가 잠시 늦게 반영되는 상황을 받아들일 수 있는가?
- 로그와 모니터링 체계가 준비되어 있는가?
- 자동 배포 환경이 구축되어 있는가?
준비되지 않은 상태에서 전환하면
복잡성만 증가할 수 있다.
이 장의 핵심
마이크로서비스는 더 강력한 구조다.
하지만 더 많은 책임을 요구한다.
모놀리스의 문제를 해결하는 대신
새로운 문제를 가져온다.
중요한 것은
장점만 보고 선택하지 않는 것이다.
우리는 이제 장점과 그림자를 모두 보았다.
3장. 반드시 이해해야 할 핵심 개념들
2장에서 우리는 마이크로서비스가 가져오는 현실을 보았다.
- 네트워크는 실패할 수 있고
- 데이터는 잠시 어긋날 수 있으며
- 디버깅은 복잡해지고
- 운영 부담은 증가한다
그렇다면 질문이 생긴다.
이런 복잡성을 우리는 어떻게 통제하는가?
마이크로서비스는 감으로 설계하는 구조가 아니다.
몇 가지 핵심 개념을 이해하지 못하면
쉽게 “분산 모놀리스”가 된다.
이 장에서는 반드시 알아야 할 기본 개념들을 정리한다.
도메인 중심 설계(DDD)
마이크로서비스에서 가장 먼저 해야 할 일은
서비스의 경계를 정하는 것이다.
문제는, 경계를 “기술” 기준으로 나누면 실패한다는 점이다.
예를 들어,
- Controller 서비스
- Service 서비스
- DAO 서비스
이렇게 나누는 것은 의미가 없다.
서비스는 비즈니스 책임 단위로 나누어야 한다.
도메인 중심 설계(DDD)는
이 경계를 정하기 위한 사고 방식이다.
핵심은 다음과 같다.
- 하나의 서비스는 하나의 명확한 책임을 가진다
- 다른 서비스의 내부 모델을 모른다
- 각 서비스는 자신만의 언어와 규칙을 가진다
이때 사용하는 개념이
Bounded Context(경계가 정의된 영역) 다.
경계가 명확하지 않으면
서비스를 나눠도 서로 강하게 얽히게 된다.
응집력과 결합도
서비스를 나눴다고 해서
자동으로 독립성이 생기지는 않는다.
겉으로는 분리되었지만
실제로는 서로 강하게 의존하는 구조가 될 수 있다.
이를 판단하는 기준이
응집력과 결합도다.
응집력이 높다는 것은
한 서비스 안의 기능들이
같은 책임을 향해 움직이고 있다는 뜻이다.
결합도가 낮다는 것은
다른 서비스에 최소한으로 의존한다는 뜻이다.
만약 한 서비스가
다른 서비스의 데이터베이스를 직접 조회하거나
내부 모델을 알아야 동작한다면
그 구조는 이미 강하게 묶여 있는 상태다.
이런 구조를
“분산 모놀리스”라고 부른다.
서비스는 나뉘었지만
배포도, 수정도, 장애도 함께 움직인다.
마이크로서비스의 핵심은
얼마나 많이 나누었는가가 아니라
얼마나 잘 끊었는가다.
데이터베이스 분리
마이크로서비스의 가장 중요한 원칙 중 하나는
서비스마다 독립된 데이터 저장소를 가진다
는 것이다.
여기서 중요한 변화가 생긴다.
모놀리스에서는
여러 작업을 하나의 트랜잭션으로 묶을 수 있었다.
- 주문 생성
- 결제 성공
- 포인트 적립
이 세 가지는 동시에 완료되거나
모두 취소되었다.
그러나 서비스가 분리되면
각 단계는 서로 다른 시스템에서 처리된다.
주문은 성공했지만
포인트 적립은 잠시 늦어질 수 있다.
사용자는 잠깐 동안
완전하지 않은 상태를 볼 수 있다.
이처럼
“시간 차이를 허용하는 데이터 모델”을
최종 일관성이라고 한다.
중요한 것은
데이터가 틀리는 것이 아니라
즉시 완전하지 않을 수 있다는 점을 설계로 관리하는 것이다.
서비스 간 통신 방식
서비스를 나누면 반드시 통신이 필요하다.
통신 방식은 크게 두 가지다.
동기 방식
요청을 보내고 즉시 응답을 기다린다.
- REST
- gRPC
장점은 직관적이다.
단점은 의존성이 강해진다는 것이다.
한 서비스가 느려지면
다른 서비스도 함께 느려진다.
비동기 방식
이벤트나 메시지를 보내고
응답을 기다리지 않는다.
- 메시지 큐
- 이벤트 스트림
장점은 느슨한 결합과 확장성이다.
단점은 설계가 복잡해진다는 점이다.
- 메시지가 중복될 수 있고
- 순서가 바뀔 수 있으며
- 처리 지연이 발생할 수 있다
이때 필요한 개념이
멱등성(Idempotency) 이다.
같은 요청이 여러 번 와도
결과가 한 번 처리된 것처럼 유지되도록 설계하는 것이다.
장애 격리 설계
마이크로서비스 환경에서는
“실패하지 않도록” 설계하는 것이 아니라
“실패가 퍼지지 않도록” 설계해야 한다.
이를 위해 필요한 기본 개념들이 있다.
- Timeout : 언제까지 기다릴 것인가
- Retry : 다시 시도할 것인가
- Circuit Breaker : 일정 수준 이상 실패하면 차단할 것인가
- Rate Limit : 과도한 요청을 제한할 것인가
이 설계가 없다면
하나의 장애가 전체 시스템으로 번질 수 있다.
왜 이 개념들이 중요한가
마이크로서비스는 단순히 구조를 나누는 것이 아니다.
- 경계를 잘못 나누면 복잡성이 증가하고
- 데이터를 잘못 다루면 정합성이 무너지며
- 통신을 잘못 설계하면 장애가 확산된다
이 개념들은 선택 사항이 아니라
생존 조건이다.
이 장의 핵심
마이크로서비스는 기술이 아니라
사고 방식의 전환이다.
- 책임을 어떻게 나눌 것인가
- 데이터를 어떻게 다룰 것인가
- 실패를 어떻게 설계할 것인가
이 질문에 답할 수 있을 때
비로소 전환을 논의할 준비가 된다.
4장. 한 번에 옮길 수 없다 — 점진적 전환의 원칙
1부에서 우리는 마이크로서비스의 장점과 그림자를 보았다. 그리고 반드시 이해해야 할 핵심 개념들을 정리했다.
이제 다음 질문이 남는다.
그래서, 어떻게 옮길 것인가?
많은 팀이 이 단계에서 같은 결정을 내린다.
“모놀리스를 다시 짜자. 깨끗하게 마이크로서비스로.”
그리고 대부분 실패한다.
빅뱅 마이그레이션이 실패하는 이유
빅뱅 방식은 단순해 보인다.
flowchart LR
Monolith --> Big[전면 재작성]
Big --> Microservices[새 마이크로서비스]
- 새 아키텍처로 처음부터 다시 짠다
- 완성되면 한 번에 전환한다
- 기존 시스템은 폐기한다
이 방식은 거의 항상 막힌다. 이유는 다섯 가지다.
1️⃣ 기존 시스템은 멈추지 않는다
새 시스템을 만드는 동안에도 모놀리스는 계속 변한다.
- 버그 수정
- 신규 기능 추가
- 정책 변경
새 시스템이 따라잡지 못한다.
2️⃣ 모놀리스의 진짜 동작을 아무도 모른다
오래된 모놀리스에는 문서화되지 않은 비즈니스 규칙이 숨어 있다.
- 코드 깊은 곳의 if문
- 우연히 의존하게 된 동작
- 옛 고객을 위한 특수 처리
새로 짜면 이 모든 게 사라진다. 그리고 운영 중에 발견된다.
3️⃣ 전환 시점에 모든 위험이 몰린다
빅뱅 방식은 단 한 번의 큰 결정이다.
- 데이터 마이그레이션
- 트래픽 전환
- 검증
- 롤백
모두 같은 날 동시에 일어난다.
이 중 하나만 어긋나도 전체가 무너진다.
4️⃣ 검증할 방법이 없다
새 시스템이 모놀리스와 똑같이 동작하는지 프로덕션 트래픽 없이는 알 수 없다.
그런데 프로덕션 트래픽은 “전환의 날“에야 처음 흐른다.
5️⃣ 비즈니스가 기다려주지 않는다
빅뱅은 보통 1~2년이 걸린다. 그동안 새 기능은 어디에 넣어야 하는가?
- 모놀리스에만 넣으면 새 시스템과 격차가 더 벌어진다
- 양쪽에 넣으면 두 번 일한다
- 새 시스템에만 넣으면 사용자는 못 쓴다
어느 쪽도 답이 아니다.
점진적 전환의 원칙
그래서 현실적인 답은 하나다.
모놀리스와 새 시스템이 공존하면서, 한 조각씩 옮긴다.
flowchart LR
M1[모놀리스] --> M2[모놀리스 + 일부 분리] --> M3[모놀리스 축소 + 다수 분리] --> M4[마이크로서비스 중심]
이 방식의 핵심은 세 가지다.
1️⃣ 작게, 자주, 검증하며 옮긴다
한 번에 떼어내는 것은 하나의 도메인이다. 그 하나가 완전히 안정화된 후 다음으로 넘어간다.
2️⃣ 신·구 시스템은 오래 공존한다
공존 기간은 짧으면 6개월, 길면 수년이다. 이 기간 자체가 정상이라는 것을 받아들여야 한다.
3️⃣ 언제든 되돌릴 수 있게 만든다
각 단계마다 롤백 시나리오가 준비되어야 한다. 한 도메인 추출이 실패하면 즉시 모놀리스로 트래픽을 되돌릴 수 있어야 한다.
무엇부터 떼어낼 것인가
점진적이라 해도 순서는 중요하다. 잘못된 순서로 시작하면 막힌다.
좋은 후보의 특징:
- 도메인 경계가 비교적 명확하다
- 다른 영역과의 결합이 약하다
- 변경 빈도가 높거나 확장 요구가 크다
- 실패해도 핵심 비즈니스에 치명적이지 않다
흔한 첫 후보:
- 로그 수집
- 알림·푸시 발송
- 통계·분석
- 검색 인덱싱
이런 영역은 실패해도 주문이 멈추지 않는다.
추출 우선순위를 결정하는 방법은 9장에서 자세히 다룬다.
점진적 전환의 비용
이 방식이 무료는 아니다. 다음을 받아들여야 한다.
⚠️ 두 시스템을 동시에 운영한다
운영 부담은 잠시 동안 늘어난다.
- 모니터링 대상 증가
- 인프라 비용 증가
- 디버깅 복잡도 증가
⚠️ 일관성 검증이 필요하다
같은 데이터를 두 시스템이 다루는 동안 정합성을 검증할 도구가 필요하다.
이 주제는 23장에서 자세히 다룬다.
⚠️ 전환 자체에 인력이 든다
“전환 팀“이 따로 필요할 정도로 사람이 든다.
기존 기능 개발 속도가 일시적으로 떨어진다.
이 비용을 인정하지 않으면 중간에 멈추거나 빅뱅으로 회귀한다.
책 전체의 흐름
이 책 2부 전체는 점진적 전환을 위한 방법들이다.
flowchart TB
Ch4["4장. 원칙"] --> Ch5["5장. Strangler Fig"]
Ch5 --> Ch6["6장. Gateway 위로"]
Ch6 --> Ch7["7장. 데이터 마이그레이션"]
각 장은 전환의 다른 면을 다룬다.
- 5장 — 큰 그림: 어떻게 감싸 잘라낼 것인가
- 6장 — 입구 재배치: Gateway를 어디에 둘 것인가
- 7장 — 데이터: 분리된 DB로 어떻게 옮길 것인가
이 장의 핵심
- 빅뱅 마이그레이션은 거의 항상 실패한다
- 모놀리스는 멈추지 않고, 빅뱅은 모든 위험을 한 시점에 몰아넣는다
- 점진적 전환은 신·구 시스템의 공존을 전제로 한다
- 작게 옮기고, 자주 검증하고, 언제든 되돌릴 수 있게 만든다
- 추출 순서는 결합도와 위험도를 기준으로 정한다
- 공존기의 운영 비용은 정상이라는 것을 받아들여야 한다
5장. Strangler Fig — 모놀리스를 감싸 잘라내기
4장에서 우리는 점진적 전환의 원칙을 보았다.
- 작게, 자주, 검증하며 옮긴다
- 신·구 시스템은 오래 공존한다
- 언제든 되돌릴 수 있게 만든다
그렇다면 구체적으로 어떻게 하는가?
대표적인 방법이 Strangler Fig 패턴이다.
이름의 유래
Strangler Fig는 열대우림에서 자라는 나무다.
- 다른 나무를 감싸며 자란다
- 점점 자기 몸을 키운다
- 결국 안의 원래 나무는 죽고
- 바깥의 새 나무만 남는다
소프트웨어 비유로 가져온 사람은 마틴 파울러다.
모놀리스를 새 시스템이 천천히 감싸 들어간다. 시간이 지나면 안의 모놀리스는 사라지고, 바깥만 남는다.
핵심 아이디어
기존 시스템을 멈추지 않는다. 대신 옆에 새 시스템을 만들고, 라우팅 계층이 어느 쪽으로 보낼지 결정한다.
flowchart LR
Client --> Router
Router -->|기존 기능| Monolith
Router -->|새 기능| NewService
처음에는 거의 모든 트래픽이 모놀리스로 간다. 한 기능씩 새 서비스로 옮기며 라우터가 점점 더 많은 트래픽을 새 쪽으로 보낸다.
마지막에 모놀리스로 가는 트래픽이 0이 되면 모놀리스를 폐기할 수 있다.
단계별 흐름
전환은 보통 다음 단계를 거친다.
1️⃣ 라우팅 계층 도입
flowchart LR
Client --> Router --> Monolith
- 라우터는 처음에 모든 요청을 모놀리스로 보낸다
- 기능적으로는 아무것도 안 바뀐다
- 이 단계의 목적은 구조 변경
이 라우팅 계층이 흔히 6장에서 다룰 API Gateway가 된다.
2️⃣ 한 기능 추출, 라우팅 분기
flowchart LR
Client --> Router
Router -->|orders| OrderService
Router -->|rest| Monolith
- 첫 도메인을 새 서비스로 떼어낸다
- 라우터가 경로별로 분기한다
- 한 기능만 새 시스템으로
3️⃣ 점진적 추출 반복
flowchart LR
Client --> Router
Router -->|orders| OrderService
Router -->|payments| PaymentService
Router -->|inventory| InventoryService
Router -->|rest| Monolith
- 도메인 하나씩 옮긴다
- 매번 라우팅 규칙이 추가된다
- 모놀리스가 담당하는 영역은 줄어든다
4️⃣ 모놀리스 폐기
라우터가 모놀리스로 보내는 트래픽이 0이 되면 모놀리스를 제거한다.
Strangler Fig의 핵심 — 라우팅
이 패턴의 진짜 힘은 라우팅 분리에 있다.
라우팅이 외부에 있기 때문에
- 클라이언트는 내부 변화를 모른다
- 한 기능만 새 서비스로 보내고, 나머지는 그대로 둘 수 있다
- 문제 발생 시 라우팅만 되돌리면 즉시 롤백
만약 라우팅이 모놀리스 안에 있다면 새 기능을 호출하기 위해 모놀리스를 거쳐야 하고 경로 변경마다 모놀리스를 배포해야 한다.
라우팅이 모놀리스 안에 있으면 Strangler Fig는 작동하지 않는다.
이 문제를 해결하는 것이 6장의 주제다.
어떻게 정말 “감싸는가”
추출 과정에서 흔히 마주치는 케이스를 보자.
케이스 A — 새 기능을 새 서비스에 만든다
가장 쉽다.
- 새 기능은 처음부터 새 서비스에서 시작
- 라우터는 새 경로로만 새 서비스로 보낸다
케이스 B — 기존 기능을 그대로 옮긴다
흔한 케이스.
- 기존 기능을 새 서비스에 똑같이 구현
- 검증 후 라우팅 전환
이때 중요한 원칙:
새 서비스는 모놀리스의 모든 동작을 그대로 따라야 한다.
세 가지 이유:
- 사용자가 차이를 느끼면 안 된다
- 검증이 가능해야 한다
- 롤백이 의미가 있어야 한다
리팩토링이나 개선은 추출이 끝난 후에 한다. 추출과 리팩토링을 동시에 하지 않는다.
케이스 C — 두 시스템에서 같은 데이터를 다룬다
가장 어렵다.
- 사용자 정보처럼 양쪽에서 다 필요한 데이터
- 양쪽이 같이 쓰면서 다른 곳이 진짜인지 결정 필요
이 문제는 7장에서 자세히 다룬다.
검증을 어떻게 하나
Strangler Fig의 가장 큰 이점 중 하나는 실 트래픽으로 검증할 수 있다는 것이다.
대표적인 두 가지 방법:
1️⃣ Shadow Traffic — 그림자 호출
sequenceDiagram
participant Client
participant Router
participant Monolith
participant NewService
Client->>Router: 요청
Router->>Monolith: 실제 처리
Router-->>NewService: 같은 요청 복사 (응답 무시)
Monolith-->>Router: 응답
Router-->>Client: 응답
- 사용자에게는 모놀리스 응답이 간다
- 새 서비스에도 같은 요청을 복제해서 보낸다
- 새 서비스의 응답은 무시
- 두 응답을 비교해서 차이를 분석한다
→ 사용자 영향 0으로 검증 가능.
2️⃣ Canary — 일부 트래픽만 새 서비스로
flowchart LR
Router -->|95%| Monolith
Router -->|5%| NewService
- 5% → 25% → 50% → 100% 단계적 증가
- 문제 발생 시 즉시 비율 감소
- 메트릭으로 차이 추적
→ 문제 발생 범위를 제한 가능.
이 패턴의 한계
Strangler Fig는 만능이 아니다.
⚠️ 데이터 경계가 모호하면 막힌다
모놀리스 안에서 여러 도메인이 같은 테이블을 쓰고 있다면 한 도메인만 분리하기 어렵다.
데이터 분리는 7장의 주제다.
⚠️ 라우팅이 너무 복잡해질 수 있다
추출이 진행될수록 라우팅 규칙이 많아진다.
규칙이 100개를 넘으면 라우터 자체가 또 다른 모놀리스가 된다.
⚠️ 공존기가 길어지면 두 배의 부담
신·구 시스템을 동시에 운영하는 비용은 실재한다. 이 점은 4장에서 짚었다.
이 장의 핵심
- Strangler Fig는 모놀리스 옆에 새 시스템을 두고 한 기능씩 옮기는 패턴이다
- 핵심은 라우팅 계층 분리다 — 라우팅이 모놀리스 안에 있으면 작동하지 않는다
- 추출 시 새 서비스는 모놀리스의 동작을 그대로 따라야 한다
- 추출과 리팩토링을 동시에 하지 않는다
- Shadow Traffic과 Canary로 실 트래픽 검증이 가능하다
- 데이터 경계가 모호하면 이 패턴만으로는 부족하다
6장. Gateway를 위로 끌어올리기 — 모놀리스 앞에 새 관문 두기
5장에서 우리는 Strangler Fig 패턴을 보았다. 모놀리스를 감싸 하나씩 잘라낸다는 아이디어다.
하지만 실제로 시도해보면 이상한 벽에 부딪힌다.
잘라낸 조각은 누가 호출할 것인가?
많은 회사에서 답은 같다.
모놀리스가 호출한다.
문제는 여기서 시작된다.
모놀리스가 Gateway일 때 — 흔한 출발점
전형적인 시작은 이런 구조다.
flowchart LR
Client --> Monolith
Monolith --> LogService
Monolith --> PushService
분리되기 쉬운 것부터 떼어냈다.
- 로그 수집
- 푸시 발송
- 검색 인덱싱
이런 기능은 도메인과 멀어 떼어내기 쉽다.
그리고 모놀리스는
- 인증을 처리하고
- 라우팅을 결정하고
- 공통 로직을 수행한 뒤
- 일부 작업을 분리된 서비스에 위임한다
겉보기에는 잘 돌아간다.
모놀리스가 사실상 Gateway 역할을 한다.
핵심 도메인을 떼어내려 할 때 막힌다
이제 다음 단계로 가야 한다.
주문, 결제, 회원 같은 핵심 도메인을 떼어내려 한다.
하지만 그러려면 두 선택지뿐이다.
1️⃣ 클라이언트가 새 서비스를 직접 호출한다
flowchart LR
Client --> Monolith
Client --> NewOrderService
문제:
- 클라이언트가 내부 구조를 알아야 한다
- 인증을 누가 어떻게 책임지는가?
- 모놀리스 변경 시 클라이언트도 함께 영향
- 모바일·웹·앱마다 별도 처리 필요
2️⃣ 모놀리스가 새 서비스를 호출한다
flowchart LR
Client --> Monolith
Monolith --> NewOrderService
문제:
- 모놀리스의 의존성이 더 깊어진다
- 모놀리스를 거치지 않는 흐름은 만들 수 없다
- 결국 모놀리스가 더 비대해진다
두 선택지 모두
분리는 했지만, 결합은 그대로다.
근본 원인 — Gateway가 잘못된 자리에 있다
이 막힘의 본질은 단순하다.
모놀리스가 Gateway 역할을 하고 있기 때문이다.
Gateway가 모놀리스 안에 있으면
- 인증 로직이 모놀리스 안에 묶인다
- 라우팅 결정도 모놀리스 안에서 일어난다
- 새 서비스를 추가하려면 모놀리스를 거쳐야 한다
- 모놀리스가 빠지면 모든 흐름이 끊긴다
그래서 다음 단계는 분명하다.
Gateway를 모놀리스 위로 끌어올린다.
새로운 구조
flowchart TB
Client --> Gateway
Gateway --> Auth
Gateway --> Monolith
Gateway --> NewOrderService
Gateway --> LogService
Gateway --> PushService
이제 Gateway가 진짜 입구다.
- 클라이언트는 항상 Gateway로 들어온다
- 인증은 Auth 서비스가 담당한다
- 모놀리스도, 신규 서비스도 모두 Gateway 밑에 있다
이 구조의 의미는 크다.
모놀리스는 더 이상 시스템의 입구가 아니다. 여러 백엔드 중 하나가 된다.
왜 Auth부터 분리하는가
Gateway 도입 시 가장 먼저 분리해야 하는 것은 인증이다.
이유는 단순하다.
1️⃣ 모든 요청이 거치는 첫 관문이다
인증이 모놀리스 안에 묶여 있으면 Gateway가 인증을 위해 매번 모놀리스를 호출해야 한다.
→ 사실상 모놀리스를 못 떼어낸다.
2️⃣ 다른 서비스가 독립적으로 인증할 수 있어야 한다
신규 서비스가 토큰을 검증할 때 모놀리스에 묻는다면, 그 서비스는 독립적이지 않다.
3️⃣ 토큰 형식을 통일할 수 있다
세션 기반 모놀리스에서 토큰 기반(JWT 등)으로 옮겨가는 자연스러운 전환점이 된다.
단계별 전환 흐름
전환은 한 번에 끝나지 않는다. 보통 다음 단계를 거친다.
단계 1️⃣ — Gateway·Auth 도입, 모든 트래픽 우회
flowchart LR
Client --> Gateway
Gateway --> Auth
Gateway --> Monolith
- Gateway가 클라이언트 입구가 된다
- 모든 요청을 일단 모놀리스로 그대로 전달
- Auth만 분리해서 토큰 검증 책임을 가진다
이 단계의 목표는 구조 변경이지 기능 추출이 아니다.
단계 2️⃣ — 한 도메인 추출, 라우팅 분기
flowchart LR
Client --> Gateway
Gateway --> Auth
Gateway -->|기존 경로| Monolith
Gateway -->|새 경로| OrderService
- 주문 같은 한 도메인을 떼어낸다
- Gateway에서 경로별로 분기
/orders→ OrderService- 나머지 → Monolith
단계 3️⃣ — 점진적 트래픽 이전
도메인을 떼어냈다고 즉시 100% 이전하지 않는다.
- Feature Flag로 일부 트래픽만 새 서비스로
- 검증 후 비율 증가
- 문제 발생 시 즉시 모놀리스로 롤백
단계 N — 모놀리스가 충분히 작아질 때까지 반복
핵심 도메인 하나하나를 같은 방식으로 떼어낸다.
모놀리스는 점점 작아지고 어느 순간 마지막 도메인 서비스가 된다.
흔히 발생하는 문제들
이 패턴은 단순해 보이지만 함정이 있다.
⚠️ 모놀리스에 인증 우회 통로가 남는다
모놀리스는 자체 세션·쿠키를 가진다. 이걸 그대로 두면
- Gateway를 우회하는 호출이 가능해진다
- 인증 정책이 두 곳에 분산된다
대응:
모놀리스 내부 인증은 Gateway가 발급한 토큰만 신뢰하게 바꾼다.
⚠️ 라우팅 변경이 한 번에 일어난다
라우팅을 한 번에 바꾸면 문제 발생 시 전체가 영향을 받는다.
대응:
- Feature Flag 또는 비율 기반 라우팅
- 두 시스템 동시 호출 후 결과 비교 (Shadow traffic)
⚠️ Gateway가 비대해진다
비즈니스 로직이 Gateway로 올라오기 시작하면 또 다른 모놀리스가 된다.
대응:
Gateway는 정책·라우팅·인증까지만. 도메인 로직은 절대 넣지 않는다.
이 원칙은 12장에서 다시 자세히 다룬다.
⚠️ 공존 기간이 길다
이 전환은 짧으면 6개월, 길면 수년이 걸린다. 그 기간 내내 모놀리스와 새 서비스가 동시에 살아 있다.
이 공존기 자체의 운영 비용을 인정해야 한다.
이 패턴이 잘 맞는 상황
- 모놀리스가 인증·라우팅·공통 로직까지 다 들고 있다
- 분리된 서비스조차 모놀리스를 거쳐야 동작한다
- 핵심 도메인을 떼어내려는데 입구를 어떻게 바꿀지 막힌다
이 셋 중 둘 이상이라면 이 패턴이 합리적인 선택이다.
이 장의 핵심
- 분리되기 쉬운 기능부터 떼어내다 보면 모놀리스가 Gateway 역할을 하게 된다
- 이 상태에서는 핵심 도메인 추출이 막힌다
- 해결책은 Gateway를 모놀리스 위로 끌어올리는 것이다
- 가장 먼저 분리해야 하는 것은 인증(Auth)이다
- 라우팅 변경은 점진적이어야 한다 (Feature Flag, Shadow traffic)
- Gateway는 얇게 유지하고 도메인 로직을 흡수시키지 않는다
- 모놀리스와 신규 서비스의 공존기 자체가 운영 비용임을 인정한다
7장. 데이터 마이그레이션 — 공유 DB에서 분리된 DB까지
6장에서 우리는 Gateway를 위로 끌어올렸다. 이제 트래픽 입구는 깔끔하다.
하지만 진짜 어려운 문제가 남는다.
데이터는 어떻게 옮길 것인가?
서비스 분리보다 데이터 분리가 어렵다. 대부분의 전환이 막히는 지점이 여기다.
왜 데이터 분리가 어려운가
모놀리스의 DB는 보통 다음 특징을 가진다.
- 한 트랜잭션이 여러 테이블을 묶는다
- JOIN이 도처에 있다
- Foreign key가 도메인을 가로지른다
- 여러 도메인이 같은 테이블을 공유한다
이 상태에서 한 도메인만 빼내려고 하면
- 한 테이블만 떼어내도 JOIN이 깨진다
- 트랜잭션을 두 DB로 나눌 수 없다
- 외래 키 제약이 무너진다
데이터는 코드보다 훨씬 깊이 얽혀 있다.
두 가지 큰 선택
데이터 분리에는 두 가지 큰 접근이 있다.
1️⃣ 코드 먼저 분리, DB는 나중에
flowchart LR
NewService --> SharedDB[(공유 DB)]
Monolith --> SharedDB
- 서비스 코드는 떼어내지만
- DB는 일단 그대로 공유한다
- 나중에 DB를 분리한다
장점:
- 추출 위험이 낮다
- 서비스 검증부터 할 수 있다
단점:
- 분산 모놀리스의 위험이 커진다
- DB 변경 시 양쪽이 영향
- “나중에“가 영원히 안 올 수 있다
2️⃣ DB 먼저 분리, 코드는 그대로
flowchart LR
Monolith --> NewDB[(새 DB)]
Monolith --> OldDB[(원래 DB)]
- DB를 먼저 두 개로 나눈다
- 모놀리스가 두 DB를 모두 쓴다
- 그 다음 코드를 분리한다
장점:
- 진짜 경계가 일찍 드러난다
- JOIN 의존성을 일찍 발견한다
단점:
- 모놀리스가 더 복잡해진다
- 단기 운영 부담이 크다
어떤 것을 선택할 것인가
정답은 없다. 하지만 실무에서 자주 통하는 방향은 있다.
추출 첫 단계에서는 DB를 함께 분리한다. 단, 한 도메인의 데이터에 한해서.
여러 도메인을 동시에 분리하지 말고 한 도메인의 코드와 데이터를 한 번에 묶어 옮긴다.
이렇게 하면
- 분산 모놀리스 위험이 작다
- 한 번에 한 도메인만 위험에 둔다
- 검증 범위가 명확하다
데이터를 옮기는 세 가지 기술
본격적인 데이터 분리에는 세 가지 기법이 있다. 대부분의 전환은 이 셋의 조합이다.
1️⃣ Backfill — 과거 데이터를 새 DB로 복사
flowchart LR
OldDB[(원래 DB)] --> Script[일회성 복사]
Script --> NewDB[(새 DB)]
- 특정 시점까지의 데이터를 새 DB로 복사
- 보통 큰 배치 작업
- 시간이 오래 걸린다
이것만으로는 부족하다. 복사하는 동안에도 데이터가 계속 변하기 때문이다.
2️⃣ Dual-write — 두 DB에 동시에 쓴다
flowchart LR
App --> OldDB[(원래 DB)]
App --> NewDB[(새 DB)]
- 애플리케이션이 양쪽 DB에 동시에 쓴다
- 한쪽 실패 시 처리가 까다롭다
- 정합성 검증이 반드시 필요하다
Dual-write의 운영 문제는 23장에서 자세히 다룬다.
3️⃣ CDC — DB의 변경 로그를 따라간다
flowchart LR
OldDB[(원래 DB)] --> CDC[Change Data Capture]
CDC --> NewDB[(새 DB)]
- DB의 트랜잭션 로그(binlog 등)를 실시간으로 읽는다
- 변경을 새 DB에 자동 적용한다
- 애플리케이션 코드 변경이 거의 없다
도구:
- Debezium
- AWS DMS
- 클라우드 사업자 제공 CDC
CDC의 장점은 애플리케이션이 한 DB만 알아도 된다는 점이다.
일반적인 마이그레이션 흐름
대부분의 데이터 마이그레이션은 다음 단계를 거친다.
flowchart TB
Step1["1. 새 DB 스키마 준비"]
Step2["2. Backfill — 과거 데이터 복사"]
Step3["3. CDC 또는 Dual-write — 변경 동기화"]
Step4["4. 읽기 트래픽 일부 전환"]
Step5["5. 검증·비교"]
Step6["6. 쓰기 트래픽 전환"]
Step7["7. 원래 DB 폐기"]
Step1 --> Step2 --> Step3 --> Step4 --> Step5 --> Step6 --> Step7
각 단계의 핵심:
Step 1 — 새 DB 스키마 준비
- 원래 스키마를 그대로 옮기지 않는다
- 새 도메인 경계에 맞게 재설계한다
- 다른 도메인 컬럼은 빼고 스냅샷만 남긴다
Step 2 — Backfill
- 과거 데이터를 일회성으로 복사
- 큰 테이블은 배치를 잘게 쪼개야 한다
- 진행 중 데이터 변경을 어떻게 처리할지 미리 정한다
Step 3 — 변경 동기화
- CDC 또는 Dual-write
- 이 시점부터 두 DB는 “거의 같은” 상태
- 거의 같지 정확히 같지는 않다
Step 4 — 읽기 전환
- 일부 읽기 요청을 새 DB로 보낸다
- Shadow Read로 두 DB 응답을 비교
- 차이가 임계치 이하가 될 때까지 반복
Step 5 — 검증·비교
- 두 DB의 정합성을 정기적으로 검증
- 자동화된 검증 스크립트
- 차이가 발견되면 원인 분석
Step 6 — 쓰기 전환
- 새 DB가 쓰기의 주인이 된다
- 원래 DB는 일정 기간 동기화 유지 (롤백용)
Step 7 — 원래 DB 폐기
- 충분한 안정화 기간 후
- 원래 테이블에서 컬럼 제거 또는 테이블 삭제
흔히 빠지는 함정
⚠️ JOIN을 없애지 않는다
서비스를 나눠도 원래 DB에 다른 도메인 테이블을 JOIN해서 읽으면 경계는 무너진 상태다.
→ 새 서비스는 자기 DB만 쓴다.
⚠️ 스키마를 그대로 옮긴다
원래 스키마는 모놀리스에 최적화되어 있다.
- 다른 도메인 컬럼들이 섞여 있다
- 정규화가 도메인을 가로지른다
새 도메인 경계에 맞춰 스키마를 다시 설계해야 한다.
⚠️ Backfill 중에 시간이 멈춘 줄 안다
Backfill은 보통 몇 시간~며칠이 걸린다. 그 사이에도 데이터는 계속 변한다.
- 시작 시점 기준으로 복사할지
- 종료 시점까지 변경을 따라잡을지
전략을 미리 정해야 한다.
⚠️ 정합성 검증을 안 한다
“옮겼으니 끝“이 아니다.
- 양쪽 DB의 행 수를 비교
- 샘플링해서 값을 비교
- 차이 발견 시 자동 알림
이 검증 없이는 운영 중에 사고로 발견한다.
롤백 시나리오
각 단계마다 되돌릴 수 있어야 한다.
| 단계 | 롤백 방법 |
|---|---|
| Backfill | 새 DB 삭제 후 다시 |
| 동기화 | CDC/Dual-write 중단 |
| 읽기 전환 | 라우팅 비율 0으로 |
| 쓰기 전환 | 쓰기 대상을 원래 DB로 |
쓰기 전환 후에도 원래 DB의 동기화를 일정 기간 유지해야 한다.
그래야 진짜 위급 상황에서 되돌릴 수 있다.
이 장의 핵심
- 서비스 분리보다 데이터 분리가 어렵다
- 코드 먼저 vs DB 먼저 — 보통 한 도메인 단위로 함께 분리
- Backfill·Dual-write·CDC 세 기법의 조합으로 옮긴다
- 마이그레이션은 다단계이며 각 단계마다 검증이 필요하다
- 스키마는 그대로 옮기지 말고 새 도메인 경계에 맞춰 재설계
- 정합성 검증이 없으면 운영 중에 사고로 발견한다
- 쓰기 전환 후에도 일정 기간 동기화를 유지해 롤백 여지를 남긴다
8장. 경계를 설계하는 방법 — DDD와 Bounded Context
마이크로서비스로 전환하려는 순간
가장 먼저 마주치는 질문은 이것이다.
서비스를 어디서 나눌 것인가?
이 질문에 답하지 못하면
서비스를 여러 개로 나누어도 결국 하나처럼 얽힌다.
마이크로서비스의 핵심은
많이 나누는 것이 아니라 올바르게 나누는 것이다.
기술이 아니라 책임으로 나눈다
서비스를 나누는 가장 쉬운 방법은 기술 기준이다.
- API 서버
- 비즈니스 로직 서버
- 데이터 서버
하지만 이렇게 나누면 구조만 갈라질 뿐, 비즈니스 책임은 그대로 섞여 있다.
결국 함께 수정되고, 함께 배포되고, 함께 장애가 난다.
마이크로서비스에서의 분리는
기술 계층이 아니라 비즈니스 책임 단위로 이루어져야 한다.
이 사고 방식이 도메인 중심 설계(DDD)다.
DDD의 핵심 질문
DDD는 복잡한 이론이 아니다.
핵심 질문은 이것이다.
이 시스템은 어떤 책임 덩어리들로 이루어져 있는가?
전자상거래 시스템을 예로 들면, 비즈니스 관점에서 책임은 보통 이렇게 나뉜다.
- 상품 관리
- 주문 처리
- 결제 처리
- 배송 관리
각 책임 덩어리는 서로 다른 규칙과 상태를 가진다.
DDD에서는 이런 책임 영역을 Bounded Context(경계가 정의된 영역) 라고 부른다.
경계를 나눌 때 가장 흔한 함정: “공통 개념”이 경계를 무너뜨린다
여기까지 오면 많은 팀이 다음 고민을 한다.
“좋아, 상품/주문/결제/배송으로 나눴어.
그런데 사용자 정보는 어디에 두지?”
이 질문이 중요한 이유는,
경계를 무너뜨리는 대부분의 원인이 ‘여러 영역에서 동시에 쓰이는 공통 개념’ 이기 때문이다.
대표적인 공통 개념이 바로 사용자(User) 다.
- 주문은 구매자가 필요하고
- 결제는 결제 주체가 필요하고
- 배송은 수령인이 필요하다
즉, User는 여러 Context에서 “필요”하다.
이때 접근을 잘못하면 User가 중앙 모델이 되면서 경계가 흐려지기 시작한다.
왜 “User를 공용 모델처럼 쓰기”가 위험해질 수 있는가
많은 시스템이 이렇게 설계한다.
- User Service
- Order Service
- Payment Service
- Delivery Service
이 구성이 자체로 틀린 것은 아니다.
문제는 다음 상황이 겹칠 때다.
- Order가 User 테이블을 직접 조회한다
- Payment가 User의 등급/상태를 실시간으로 참조한다
- Delivery가 User의 최신 주소를 매번 조회한다
이렇게 되면 User는 “그저 한 서비스”가 아니라
사실상의 중앙 시스템(공유 모델/공유 데이터) 이 된다.
- User 스키마가 바뀌면 여러 서비스가 같이 수정되고
- User가 느려지면 주문/결제/배송도 같이 느려지고
- User 장애가 나면 전체 흐름이 막힌다
겉으로는 나뉘어 있지만 실제로는 강하게 묶인다.
이 상태가 흔히 말하는 분산 모놀리스로 가는 지점이다.
그럼 User는 어떻게 다뤄야 하는가
여기서 핵심은 이것이다.
User를 없애는 게 아니라,
모든 서비스가 User “전체”를 공유하지 않게 만드는 것이다.
각 Context는 “자기 일이 돌아가는데 필요한 사용자 정보”만 가지면 된다.
즉, 각 Context는 자기 관점의 사용자 정보만 최소한으로 들고 간다.
주문·결제·배송은 사용자 정보를 이렇게 가진다
주문(Order) 관점에서 필요한 사용자 정보
주문은 보통 이것만 있으면 된다.
- buyer_id
- 주문 당시 이름(스냅샷)
- 주문 당시 배송지(스냅샷)
예:
Order
- order_id
- buyer_id
- buyer_name_snapshot
- shipping_address_snapshot
주문은 “사용자 최신 프로필”을 매번 참조하지 않아도 된다.
주문은 주문 시점의 정보가 더 중요하다.
결제(Payment) 관점에서 필요한 사용자 정보
결제는 보통 이것만 있으면 된다.
- payer_id
- 결제 승인에 필요한 최소 검증 결과(또는 상태)
예:
Payment
- payment_id
- payer_id
- verification_result
결제가 배송지/프로필/마케팅 동의까지 알아야 할 이유는 없다.
배송(Delivery) 관점에서 필요한 사용자 정보
배송은 보통 이것이면 충분하다.
- 수령인 이름
- 배송지 주소
배송은 사용자 프로필의 변화와 독립적으로 움직일 수 있다.
전자상거래 Context 구조
아래는 책임 기준(Context 기준)으로 나눈 예시다.
flowchart LR
subgraph Product_Context
P[상품 관리]
end
subgraph Order_Context
O[주문 처리]
end
subgraph Payment_Context
Pay[결제 처리]
end
subgraph Delivery_Context
D[배송 관리]
end
Product_Context --> Order_Context
Order_Context --> Payment_Context
Order_Context --> Delivery_Context
이 구조에서 경계를 지키는 핵심은 단순하다.
- 각 Context는 자기 데이터를 가진다
- 다른 Context의 DB를 직접 조회하지 않는다
- 필요한 사용자 정보는 “전체 공유”가 아니라 최소 정보/스냅샷/자기 관점 데이터로 가진다
이 장의 핵심
DDD의 목적은 “멋진 용어를 아는 것”이 아니다.
목적은 딱 하나다.
책임을 기준으로 경계를 만들고,
공통 개념 때문에 그 경계가 무너지지 않게 하는 것
9장. 무엇부터 떼어낼 것인가 — 추출 우선순위 결정법
8장에서 우리는 경계를 어떻게 설계하는지 보았다. DDD와 Bounded Context로 책임 단위를 나눈다.
하지만 책임을 다 나누었다 해도 여전히 남는 질문이 있다.
그 중 무엇부터 떼어낼 것인가?
한 번에 다 떼어낼 수는 없다. 4장에서 본 점진적 전환은 순서가 필요하다.
이 장에서는 그 순서를 정하는 방법을 다룬다.
잘못된 첫 후보
많은 팀이 처음에 같은 실수를 한다.
“가장 중요한 도메인을 먼저 떼어내자.”
예: 주문, 결제, 회원
이유는 “그 영역이 가장 자주 바뀌고 가장 중요하니까“다.
하지만 결과는 보통 이렇다.
- 도메인이 가장 깊이 얽혀 있어 떼어내기 어렵다
- 실패하면 핵심 비즈니스가 영향을 받는다
- 운영팀이 새 시스템에 익숙해질 시간이 없다
첫 추출은 학습 단계다. 가장 중요한 도메인을 첫 후보로 두지 않는다.
첫 후보를 고르는 기준
좋은 첫 후보는 다음 특징을 가진다.
1️⃣ 결합도가 낮다
다른 도메인과의 의존성이 적다.
- 다른 테이블을 거의 JOIN하지 않는다
- 다른 도메인의 이벤트를 받기만 하거나, 보내기만 한다
- 외부 시스템 연동이 잘 격리되어 있다
2️⃣ 실패해도 핵심 비즈니스가 안 죽는다
이 영역에 문제가 생겨도 주문·결제·로그인은 계속 동작해야 한다.
3️⃣ 확장 또는 변경 요구가 있다
순수하게 떼어내고 싶다는 욕구만으로는 부족하다.
- 트래픽이 특정 영역에 집중된다
- 그 영역의 배포 주기가 다른 곳보다 빠르다
- 그 영역만 다른 기술 스택을 쓰고 싶다
이유가 있어야 동력이 생긴다.
4️⃣ 도메인 경계가 비교적 명확하다
비즈니스 사람이 들었을 때 “이건 이 영역의 일이지“라고 즉시 답할 수 있다.
흔한 첫 후보들
대부분의 회사에서 다음 영역이 첫 후보가 된다.
| 영역 | 왜 좋은가 |
|---|---|
| 알림·푸시 | 비즈니스 핵심이 아니고, 실패해도 큰 문제 없음 |
| 로그·통계 수집 | 단방향 데이터 흐름, 외부 호출 거의 없음 |
| 검색·인덱싱 | 읽기 전용, 비동기 갱신 가능 |
| 이메일 발송 | 외부 SMTP 연동만, 도메인 경계 명확 |
| 파일 업로드·처리 | 트래픽 패턴 다르고, 자원 요구 다름 |
이런 영역은 분리되기 쉬운 변두리다.
핵심을 떼어내기 전에 변두리에서 운영 경험을 쌓는다.
핵심 도메인을 떼어낼 때
변두리를 충분히 떼어냈다면 이제 핵심 도메인 차례다.
여기서는 우선순위 결정이 더 까다롭다.
결정에 쓰이는 두 축
| 축 | 의미 |
|---|---|
| 효과 | 떼어내면 얻는 가치 (성능·확장·조직 정렬·변경 속도) |
| 난이도 | 추출의 어려움 (결합도·데이터 얽힘·트래픽 위험) |
flowchart TB
subgraph 효과
High[효과 큼]
Low[효과 작음]
end
subgraph 난이도
Easy[난이도 낮음]
Hard[난이도 높음]
end
이 두 축으로 4분면 매트릭스를 만든다.
| 효과 큼 | 효과 작음 | |
|---|---|---|
| 난이도 낮음 | A: 우선 추출 | C: 시간 날 때 |
| 난이도 높음 | B: 준비 후 추출 | D: 보류 |
- A — 최우선
- B — 준비 단계가 길지만 결국 해야 함
- C — 여유 있을 때
- D — 그냥 두는 게 낫다
효과를 어떻게 측정하는가
“효과 큼“은 추상적이다. 구체적인 신호로 바꾼다.
1️⃣ 트래픽 집중
특정 영역이 전체 트래픽의 큰 부분을 차지한다. 독립 확장이 필요하다.
2️⃣ 배포 주기 차이
그 영역만 자주 바뀐다. 다른 영역과 함께 배포하는 게 비효율적이다.
3️⃣ 팀 구조 불일치
특정 팀이 그 영역만 책임진다. 지금은 다른 팀과 코드를 공유한다.
4️⃣ 기술 요구의 차이
특정 영역은 다른 언어나 기반이 더 적합하다. 지금은 전체가 같은 스택이라 어쩔 수 없다.
이 중 둘 이상이라면 효과는 크다.
난이도를 어떻게 측정하는가
1️⃣ 결합도 분석
이 영역이 다른 도메인과 얼마나 얽혀 있는가?
- 호출 관계 (in/out)
- 공유 테이블 수
- 공유 트랜잭션 범위
- 외래 키 의존
2️⃣ 데이터 의존 분석
이 영역의 데이터를 떼어내기 얼마나 어려운가?
- JOIN으로 묶인 테이블 수
- 다른 도메인이 이 데이터를 직접 읽는가
- 트랜잭션 단위가 도메인을 가로지르는가
3️⃣ 트래픽 위험
실패 시 사용자에게 미치는 영향.
- 핵심 비즈니스 경로인가
- 사용자가 즉시 인지하는가
- 매출에 직결되는가
의존성 그래프를 그려본다
추출 전에 한 번은 의존성 그래프를 그려야 한다.
flowchart LR
User --> Order
Order --> Payment
Order --> Inventory
Order --> Delivery
Payment --> User
Delivery --> User
Inventory --> Product
이 그래프를 보면
- 어떤 도메인이 허브인지
- 어떤 도메인이 외곽인지
- 어떤 의존이 단방향이고 어떤 게 양방향인지
알 수 있다.
추출하기 좋은 후보는 외곽이다. 허브를 처음 건드리면 모든 게 흔들린다.
이벤트 스토밍 — 경계 발견을 돕는 워크숍
추출 후보를 정하기 어려울 때 이벤트 스토밍이 도움이 된다.
방법은 단순하다.
- 비즈니스에서 일어나는 모든 “사건(이벤트)“을 포스트잇에 적는다
- 예:
주문 생성됨,결제 승인됨,재고 차감됨,배송 시작됨
- 예:
- 시간 순서대로 벽에 붙인다
- 비슷한 영역끼리 묶는다
- 묶음의 경계가 곧 후보 도메인이 된다
이 워크숍의 가치는
- 코드가 아니라 비즈니스 관점으로 본다
- 숨어 있던 도메인이 드러난다
- 비기술자도 참여할 수 있다
우선순위 결정 — 정리된 절차
종합하면 다음 절차다.
flowchart TB
S1["1. 모든 도메인 목록 작성"]
S2["2. 효과 점수 매기기 (트래픽·배포·팀·기술)"]
S3["3. 난이도 점수 매기기 (결합·데이터·위험)"]
S4["4. 4분면 매트릭스에 배치"]
S5["5. A 영역에서 첫 후보 선정"]
S6["6. 의존성 그래프로 확인"]
S7["7. 첫 추출 → 검증 → 다음으로"]
S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7
이 절차의 가장 큰 적은 욕심이다.
- 한 번에 여러 도메인을 동시에
- 가장 어려운 도메인부터
- 가장 중요한 도메인부터
이 세 가지 유혹을 모두 거절해야 한다.
이 장의 핵심
- 점진적 전환은 추출 순서를 정하는 일에서 시작된다
- 첫 추출은 학습 단계다. 핵심 도메인을 첫 후보로 두지 않는다
- 흔한 첫 후보는 알림·로그·검색·이메일 같은 변두리다
- 핵심 도메인은 효과×난이도 매트릭스로 우선순위를 정한다
- 의존성 그래프와 이벤트 스토밍이 결정을 돕는다
- 욕심을 거절한다 — 한 번에 하나씩, 외곽부터 안으로
10장. 경계를 지키는 설계 — DB per Service와 스냅샷
8장에서 우리는
책임을 기준으로 경계를 나누는 방법을 살펴보았다.
하지만 여기서 멈추면 안 된다.
서비스를 나누는 것보다 더 어려운 것은
그 경계를 유지하는 것이다.
그리고 경계를 무너뜨리는 가장 빠른 방법은
데이터를 공유하는 것이다.
DB를 공유하는 순간 무슨 일이 벌어지는가
많은 팀이 이렇게 말한다.
“서비스는 나누되, DB는 하나 써도 되지 않나요?”
겉보기에는 효율적이다.
- 트랜잭션도 편하고
- JOIN도 자유롭고
- 데이터 일관성도 유지하기 쉽다
하지만 그 순간 경계는 무너진다.
예를 들어보자.
- Order 서비스가 User 테이블을 직접 조회한다
- Payment 서비스도 User 테이블을 JOIN한다
- Delivery도 같은 DB에서 데이터를 읽는다
이제 User 스키마가 변경되면
세 서비스가 모두 영향을 받는다.
User 테이블이 느려지면
모든 서비스가 느려진다.
배포는 독립적이지 않다.
결국 하나의 거대한 시스템과 다르지 않다.
이것이
DB 공유가 분산 모놀리스로 가는 지름길인 이유다.
DB per Service 원칙
마이크로서비스에서 가장 중요한 원칙 중 하나는 이것이다.
서비스마다 독립된 데이터 저장소를 가진다.
이 말은 단순히 “DB 인스턴스를 나눈다”는 뜻이 아니다.
의미는 더 강하다.
- 다른 서비스의 DB를 직접 조회하지 않는다
- 다른 서비스 테이블에 JOIN하지 않는다
- 다른 서비스의 스키마에 의존하지 않는다
각 서비스는 자기 데이터에 대한 소유권을 가진다.
소유권이 분리되어야 책임도 분리된다.
데이터 중복은 정말 나쁜 것일까
여기서 많은 개발자가 불편함을 느낀다.
“그럼 데이터가 중복되지 않나요?”
맞다.
중복된다.
하지만 중요한 질문은 이것이다.
데이터 중복이 더 위험한가?
아니면 강결합이 더 위험한가?
마이크로서비스에서는
대부분의 경우 강결합이 더 위험하다.
데이터는 복제할 수 있다.
하지만 결합된 구조는 쉽게 끊을 수 없다.
스냅샷 전략 — 기록과 현재 상태는 다르다
전자상거래 예시로 다시 돌아가보자.
Order는 주문 당시의 정보를 기록한다.
- 주문자 이름
- 배송 주소
- 상품 가격
여기서 중요한 점이 있다.
주문은 “과거의 사실”이다.
사용자가 나중에 주소를 변경해도
이미 완료된 주문의 배송지는 바뀌지 않는다.
따라서 Order는
User의 최신 정보를 매번 조회할 필요가 없다.
주문 시점의 정보를
스냅샷으로 저장하면 된다.
예:
Order
- order_id
- buyer_id
- buyer_name_snapshot
- shipping_address_snapshot
- order_price_snapshot
이렇게 하면
- User 프로필 변경과 무관하게
- Order는 독립적으로 유지된다
이것이 스냅샷 전략이다.
스냅샷이 필요한 이유
만약 Order가 매번 User의 최신 주소를 조회한다면 어떻게 될까?
- User 서비스가 느려지면 주문 조회도 느려진다
- User 스키마 변경 시 Order도 수정해야 한다
- User 장애가 주문 조회를 막는다
즉, Order는 독립적이지 않다.
스냅샷은
과거의 비즈니스 사실을 고정시키는 장치다.
이는 단순한 최적화가 아니라
경계를 지키는 설계다.
데이터 복제는 어떻게 생각해야 하는가
마이크로서비스에서는
데이터 복제를 두려워해서는 안 된다.
중요한 것은 두 가지다.
-
데이터 소유권은 명확해야 한다
- User 데이터의 원본은 User Context가 가진다
- Order는 필요한 정보만 복제한다
-
복제는 단방향이어야 한다
- 원본이 변경되어도 복제된 과거 기록은 변경되지 않는다
복제는 허용하되
공유는 금지하는 것이 원칙이다.
경계를 무너뜨리는 신호들
다음과 같은 상황이 있다면
이미 경계가 흔들리고 있을 가능성이 높다.
- 다른 서비스 DB에 직접 SELECT를 날리고 있다
- 다른 서비스 테이블에 JOIN을 하고 있다
- 스키마 변경 시 여러 팀이 동시에 수정해야 한다
- 함께 배포하지 않으면 불안하다
- 한 DB 장애가 전체 시스템을 멈춘다
이 중 하나라도 해당된다면
DB 설계를 다시 봐야 한다.
이 장의 핵심
서비스를 나누는 것은 시작일 뿐이다.
진짜 경계는
데이터 소유권과 접근 방식에서 만들어진다.
- DB는 공유하지 않는다
- 다른 서비스 테이블을 직접 보지 않는다
- 필요한 정보는 복제한다
- 과거의 사실은 스냅샷으로 고정한다
경계는 선언이 아니라
설계로 지켜진다.
11장. 경계를 연결하는 방법 — 동기와 비동기
서비스를 나누면
이제 하나의 프로세스 안에서 함수 호출하던 세상이 끝난다.
그 자리에
네트워크 호출이 들어온다.
마이크로서비스에서 통신은
단순한 기술 선택이 아니라
결합도를 결정하는 설계 선택이다.
함수 호출과 네트워크 호출의 차이
모놀리스에서는 이런 흐름이 자연스럽다.
createOrder()
→ validateUser()
→ processPayment()
모두 같은 프로세스 안에서 실행된다.
하지만 서비스가 나뉘면 이렇게 바뀐다.
Order Service → Payment Service
이제 호출은 네트워크를 건너간다.
그리고 네트워크는 다음과 같은 특성을 가진다.
- 느릴 수 있다
- 실패할 수 있다
- 지연될 수 있다
- 일시적으로 끊길 수 있다
즉, 통신은 항상 실패 가능성을 가진다.
통신 방식은 크게 두 가지다
마이크로서비스 간 통신 방식은 크게 나누면 두 가지다.
- 동기 방식 (요청-응답)
- 비동기 방식 (이벤트 기반)
이 둘은 단순한 기술 차이가 아니라
결합도의 차이다.
동기 통신 — 즉시 응답을 기다리는 방식
예:
- REST API
- gRPC
Order가 Payment를 호출하고
결과를 바로 기다린다.
장점:
- 이해하기 쉽다
- 흐름이 직관적이다
- 트랜잭션처럼 느껴진다
하지만 단점이 있다.
- Payment가 느리면 Order도 느려진다
- Payment가 죽으면 Order도 실패한다
- 호출 체인이 길어질수록 장애 전파가 커진다
동기 통신은
강한 결합을 만든다.
비동기 통신 — 메시지를 남기고 처리하는 방식
예:
- 메시지 큐
- 이벤트 스트림
- Pub/Sub
Order는 결제 요청 이벤트를 발행하고
Payment는 이를 구독하여 처리한다.
Order는 Payment의 응답을 기다리지 않는다.
장점:
- 서비스 간 결합이 약하다
- 한 서비스 장애가 즉시 전파되지 않는다
- 확장성이 좋다
하지만 단점도 있다.
- 흐름이 눈에 보이지 않는다
- 처리 지연이 발생할 수 있다
- 설계가 더 복잡하다
비동기 통신은
결합도를 낮추지만
설계 난이도를 높인다.
언제 동기를 쓰고, 언제 비동기를 쓰는가
이 질문은 기술 질문이 아니라
비즈니스 질문이다.
다음과 같이 생각해볼 수 있다.
즉시 결과가 반드시 필요한 경우
- 결제 승인 여부
- 로그인 인증
→ 동기 통신이 적합하다.
나중에 처리되어도 되는 경우
- 알림 발송
- 통계 집계
- 로그 처리
- 포인트 적립
→ 비동기 통신이 적합하다.
핵심은 이것이다.
반드시 즉시 응답이 필요한가?
그렇지 않다면
비동기를 고려하는 것이 결합도를 낮춘다.
통신 설계에서 반드시 고려해야 할 것
1️⃣ 타임아웃
얼마나 기다릴 것인가?
기다림이 무한이면
시스템은 멈춘다.
2️⃣ 재시도(Retry)
실패하면 다시 시도할 것인가?
하지만 무한 재시도는
더 큰 장애를 만든다.
3️⃣ 멱등성
같은 요청이 여러 번 들어와도
결과가 한 번 처리된 것처럼 유지되어야 한다.
비동기 통신에서는
메시지가 중복될 수 있기 때문이다.
4️⃣ 장애 전파 차단
한 서비스 장애가
연쇄적으로 퍼지지 않도록 설계해야 한다.
동기 체인이 길어질수록
위험은 커진다.
잘못된 통신 설계의 예
다음과 같은 구조는 위험하다.
Order → Payment → User → Notification → Logging
하나라도 느려지면
전체 흐름이 지연된다.
이 구조는
물리적으로는 분리되어 있지만
논리적으로는 하나의 체인이다.
이것은 또 다른 형태의 분산 모놀리스다.
통신은 경계를 드러낸다
데이터 설계는 경계를 지키는 방법이었다.
통신 설계는
그 경계를 어떻게 연결할 것인가에 대한 문제다.
- 동기를 많이 쓰면 경계는 약해지고
- 비동기를 적절히 쓰면 경계는 단단해진다
마이크로서비스에서 통신은
기술 선택이 아니라
아키텍처 선택이다.
이 장의 핵심
서비스를 나누는 순간
통신은 필수가 된다.
- 함수 호출은 네트워크 호출이 되고
- 실패는 예외가 아니라 전제가 되며
- 통신 방식은 결합도를 결정한다
동기와 비동기는
기술 차이가 아니라
설계 철학의 차이다.
12장. 단일 진입점의 설계 — API Gateway
서비스를 여러 개로 나누었다고 해서
외부 세계까지 나눌 필요는 없다.
프론트엔드 입장에서 중요한 것은
서비스 개수가 아니라
접속이 단순한가이다.
마이크로서비스 아키텍처에서는
대부분 하나의 관문이 존재한다.
이것이 API Gateway다.
왜 단일 진입점이 필요한가
서비스가 여러 개로 나뉘면
프론트엔드가 각 서비스에 직접 붙는 구조도 가능하다.
겉보기에는 단순해 보인다.
flowchart TB
Frontend --> UserService
Frontend --> OrderService
Frontend --> PaymentService
Frontend --> ProductService
Frontend --> DeliveryService
하지만 시간이 지나면 문제가 드러난다.
- 인증을 어디서 처리할 것인가?
- 공통 로깅은 누가 담당하는가?
- 에러 형식이 서비스마다 다르면?
- 서비스 주소가 바뀌면 프론트는 어떻게 되는가?
- 서비스가 늘어나면 프론트 복잡도는?
이제 프론트는 내부 서비스 구조를 알아야 한다.
어떤 서비스가 무엇을 담당하는지도 이해해야 한다.
이 상태는
서비스를 나눴지만
결합은 여전히 강한 구조다.
단일 진입점을 둔 구조
API Gateway를 두면 구조는 이렇게 바뀐다.
flowchart TB
Frontend --> APIGateway
APIGateway --> UserService
APIGateway --> OrderService
APIGateway --> PaymentService
APIGateway --> ProductService
APIGateway --> DeliveryService
이 구조에서 중요한 점은 다음과 같다.
- 프론트는 내부 서비스 개수를 모른다
- 인증과 정책은 Gateway에서 처리된다
- 내부 서비스 변경이 외부에 직접 노출되지 않는다
외부는 단순해지고
내부는 독립적으로 진화할 수 있다.
API Gateway의 본질
Gateway는 또 하나의 비즈니스 서비스가 아니다.
그 역할은 명확하다.
- 요청을 적절한 서비스로 라우팅한다
- 인증 및 인가를 수행한다
- 공통 정책을 집행한다
- 외부와 내부 사이의 계약을 유지한다
Gateway는
정책과 규약을 집행하는 관문이다.
Gateway가 담당해야 할 것
1️⃣ 라우팅
요청을 내부 서비스로 전달한다.
내부 서비스 구조가 바뀌어도 외부 인터페이스는 유지된다.
2️⃣ 인증과 인가
- 토큰 검증
- 권한 확인
- 공통 보안 정책
이 기능을 각 서비스에 중복 구현하지 않는다.
3️⃣ 요청 제한과 보호
- Rate Limit
- 트래픽 제어
- 기본적인 보안 필터링
서비스를 보호하는 1차 방어선이다.
4️⃣ 공통 로깅과 추적
Gateway는 모든 요청이 지나가는 지점이다.
따라서
- Trace ID 생성
- 공통 로그 형식 유지
- 요청 처리 시간 측정
등을 처리하기에 이상적인 위치다.
5️⃣ API 계약 유지
서비스가 나뉘면
API는 단순한 인터페이스가 아니라 계약이 된다.
Gateway는
- 요청/응답 형식 유지
- 에러 포맷 통일
- 버전 분기 처리
등을 통해
이 계약을 관리한다.
Gateway가 해서는 안 되는 것
Gateway는 강력하다.
그래서 쉽게 비대해질 수 있다.
다음과 같은 일이 벌어지면 위험하다.
- 비즈니스 판단 로직이 들어간다
- 여러 서비스 데이터를 조합해 복잡한 계산을 한다
- 상태를 저장하기 시작한다
- 도메인 규칙을 구현한다
이 순간 Gateway는
얇은 관문이 아니라
또 다른 중앙 서버가 된다.
경계를 나눴지만
다시 한 곳에 로직이 모이기 시작한다.
단일 진입점은 단일 장애점이 될 수 있다
Gateway는 모든 요청이 통과하는 지점이다.
따라서 반드시 고려해야 한다.
- 수평 확장 구조
- 타임아웃 설정
- 기본 실패 응답 전략
- 트래픽 폭주 대비
Gateway가 멈추면
전체 시스템이 멈춘다.
단일 진입점은 필요하지만
단일 장애점이 되어서는 안 된다.
Gateway와 내부 경계의 관계
Gateway는
내부 서비스 경계를 없애는 구조가 아니다.
오히려 그 경계를 보호한다.
- 외부는 단순하게 유지하고
- 내부는 독립적으로 발전하도록 돕는다
이 균형이 중요하다.
이 장의 핵심
마이크로서비스에서는
내부 구조는 분리되지만
외부 접점은 단순해야 한다.
API Gateway는
- 얇아야 하고
- 정책 중심이어야 하며
- 도메인 로직을 가지지 않아야 한다
- 고가용성을 전제로 설계되어야 한다
경계를 나누는 것만큼
그 경계를 외부와 연결하는 방법도 중요하다.
13장. 채널을 위한 설계 — BFF
API Gateway는 외부와 내부를 연결하는 관문이다.
하지만 모든 프론트엔드가 같은 형태의 API를 원하는 것은 아니다.
웹과 모바일은 다르고,
관리자 화면과 일반 사용자 화면도 다르다.
이때 등장하는 개념이
BFF(Backend for Frontend)다.
왜 Gateway만으로는 부족해지는가
API Gateway의 역할은 분명하다.
- 라우팅
- 인증/인가
- 공통 정책
- 계약 유지
하지만 화면 단에서는 이런 요구가 생긴다.
- 한 화면에서 여러 서비스 데이터를 한 번에 받아오고 싶다
- 모바일은 네트워크 호출을 최소화하고 싶다
- 웹은 상세 데이터를 더 많이 필요로 한다
- 관리자 화면은 내부 필드까지 필요하다
이 요구를 모두 Gateway에서 처리하면
Gateway가 비대해진다.
비즈니스 조합 로직이 Gateway로 올라오기 시작한다.
그 순간 Gateway는
정책 관문이 아니라
중앙 집계 서버가 된다.
BFF의 등장
BFF는 말 그대로
특정 프론트엔드를 위한 백엔드다.
구조는 다음과 같다.
flowchart LR
Client_Web --> APIGateway
Client_Mobile --> APIGateway
Client_Admin --> APIGateway
APIGateway --> BFF_Web
APIGateway --> BFF_Mobile
APIGateway --> BFF_Admin
BFF_Web --> OrderService
BFF_Web --> ProductService
BFF_Mobile --> OrderService
BFF_Mobile --> ProductService
BFF_Admin --> OrderService
BFF_Admin --> PaymentService
BFF_Admin --> UserService
이 구조에서
- Gateway는 정책과 보안을 담당하고
- BFF는 채널에 맞는 응답을 구성한다
- 실제 도메인 로직은 각 서비스에 남아 있다
BFF의 역할
BFF는 비즈니스 서비스를 대체하지 않는다.
그 역할은 다음과 같다.
- 화면에 맞는 응답 구조로 변환
- 여러 서비스 호출 결과를 조합
- 채널 특화 최적화
- 과도한 네트워크 호출 감소
예를 들어:
웹 화면에서는
- 상품 목록
- 재고 상태
- 할인 정보
- 리뷰 요약
을 한 번에 보여주고 싶을 수 있다.
이때 BFF가 여러 서비스를 호출해
하나의 응답으로 조합해준다.
BFF가 필요한 상황
다음과 같은 경우 BFF를 고려할 수 있다.
- 웹과 모바일의 요구사항이 크게 다를 때
- 응답 최적화가 채널별로 달라야 할 때
- 특정 채널에서 복잡한 데이터 조합이 필요할 때
- 프론트 팀과 백엔드 팀의 배포 속도가 다를 때
BFF는
채널 독립성을 높이는 장치다.
BFF의 위험성
하지만 BFF도 쉽게 비대해진다.
다음과 같은 징후가 보이면 위험하다.
- 도메인 규칙이 BFF로 올라오기 시작한다
- 서비스 상태 변경을 BFF에서 처리한다
- BFF가 사실상의 핵심 로직 서버가 된다
이 경우 구조는 다시 중앙 집중형으로 돌아간다.
BFF는
“조합”은 하되
“판단”은 하지 않아야 한다.
Gateway와 BFF의 차이
정리해보면 다음과 같다.
| 구분 | API Gateway | BFF |
|---|---|---|
| 목적 | 정책과 관문 | 채널 최적화 |
| 책임 | 인증, 라우팅, 공통 규약 | 응답 조합, 화면 최적화 |
| 비즈니스 로직 | 없음 | 최소한 |
| 위치 | 외부 경계 | 채널별 경계 |
Gateway는 시스템의 입구이고
BFF는 채널의 완충지대다.
BFF는 반드시 필요한가?
모든 시스템에 BFF가 필요한 것은 아니다.
- 프론트 채널이 하나뿐이라면
- 응답 구조가 단순하다면
- 화면 조합 로직이 복잡하지 않다면
BFF 없이도 충분하다.
BFF는
복잡성이 생겼을 때 도입하는 도구다.
이 장의 핵심
서비스를 나누고
Gateway로 외부를 단순화했다면,
다음 고민은
“채널별 요구를 어떻게 수용할 것인가”이다.
BFF는
- 채널별 최적화를 담당하고
- 내부 도메인 로직을 보호하며
- 프론트와 서비스 사이의 완충 역할을 한다
하지만
- 얇게 유지해야 하고
- 판단 로직을 흡수하지 않아야 하며
- 또 다른 모놀리스가 되지 않도록 관리해야 한다
마이크로서비스 아키텍처는
서비스 개수보다
경계의 명확성과 역할의 분리가 더 중요하다.
14장. 인증과 인가 — 분산 환경에서 사용자 정보가 흐른다
지금까지 우리는 시스템을 어떻게 나누고, 연결하고, 조회하는지 살펴봤다.
이제 남은 질문은 이것이다.
누가 이 시스템을 사용할 수 있는가
그리고 어디까지 할 수 있는가
인증과 인가
먼저 개념부터 정리하자.
인증(Authentication)은
누구인가를 확인하는 과정이다
인가(Authorization)는
무엇을 할 수 있는가를 결정하는 과정이다
로그인은 인증이고, 권한 체크는 인가다.
모놀리스에서는 단순했다
모놀리스에서는 요청이 하나의 서버에서 처리된다.
로그인하면 세션을 만들고, 요청마다 세션만 확인하면 된다.
인증은 서버 내부에서 끝난다.
마이크로서비스에서는 어떻게 달라지는가
마이크로서비스에서는 하나의 요청이 여러 서비스를 거친다.
예:
Client → Order → Payment → User
이 흐름에서 각 서비스는
요청을 받을 때마다 인증 정보를 알아야 한다.
만약 각 서비스가 인증을 따로 구현한다면
- Order 서비스도 인증 체크
- Payment 서비스도 인증 체크
- User 서비스도 인증 체크
같은 로직이 모든 서비스에 반복된다.
이 구조는 비효율적이고,
관리도 어려워진다.
그래서 인증 정보는 요청과 함께 전달되어야 한다.
분산 시스템에서는 인증도 데이터처럼 흐른다
세션 기반 인증
세션 방식은 여전히 많이 사용된다.
구조는 단순하다.
- 로그인 시 세션 생성
- Redis / Memcached 저장
- 요청 시 세션 조회
장점:
- 구현이 쉽다
- 서버에서 상태 제어 가능
하지만 마이크로서비스에서는 문제가 생긴다.
- 모든 서비스가 세션 저장소에 의존
- 매 요청마다 Redis 조회 발생
- 장애 시 전체 서비스 영향
즉, 세션은 중앙 의존성을 만든다.
토큰 기반 인증
이 문제를 해결하기 위해 토큰 방식이 사용된다.
대표적으로 JWT가 있다.
구조는 이렇게 바뀐다.
- 로그인 시 토큰 발급
- 클라이언트가 토큰 보관
- 요청마다 토큰 전달
각 서비스는
- 토큰을 검증하고
- 필요한 정보를 추출한다
세션 조회가 필요 없다.
세션 vs 토큰
세션과 토큰의 차이는 명확하다.
세션은 상태 기반이다.
토큰은 상태 없는 방식이다.
세션:
- 서버에 상태 저장
- 중앙 의존성 발생
토큰:
- 클라이언트가 상태 보관
- 서비스 독립적으로 검증
실무에서는 혼합한다
현실에서는 하나만 쓰지 않는다.
다음과 같은 구조가 많다.
- 로그인 → 세션 또는 인증 서버
- API 호출 → 토큰 사용
또는
- Gateway에서 인증 처리
- 내부 서비스는 토큰 기반 처리
이렇게 하면
- 사용자 상태 관리
- 서비스 독립성
을 동시에 가져갈 수 있다.
인증은 어디서 처리할까
또 하나의 중요한 결정이 있다.
인증을 어디서 처리할 것인가
1️⃣ Gateway에서 처리
모든 요청을 한 곳에서 검증한다.
내부 서비스는 인증 로직을 가지지 않는다.
장점:
- 중앙 관리
- 서비스 단순화
단점:
- Gateway 의존성 증가
2️⃣ 각 서비스에서 처리
각 서비스가 토큰을 직접 검증한다.
장점:
- 서비스 독립성 유지
단점:
- 인증 로직 중복
실무에서는 두 방식을 함께 사용한다.
- Gateway에서 1차 검증
- 서비스에서 최소 검증
인가 (권한 관리)
인가는 인증보다 더 복잡하다.
가장 단순한 방식은 Role 기반이다.
예:
- USER
- ADMIN
하지만 서비스가 커지면 부족하다.
더 현실적인 권한
- 리소스 기반 권한
- 사용자 속성 기반 권한
- 상태 기반 권한
예:
- 본인 주문만 조회 가능
- 특정 상태에서만 수정 가능
인가 로직은 비즈니스와 강하게 연결된다.
이벤트 기반 시스템에서의 문제
이벤트 구조에서는 또 다른 문제가 생긴다.
이벤트에는 사용자 정보가 없다.
예:
- OrderCreated 이벤트
- 누가 생성했는지 알 수 없음
다른 서비스는 사용자 없이 이벤트를 처리하게 된다.
해결 방법
이 문제를 해결하려면
- userId를 이벤트에 포함하거나
- 별도의 audit 정보를 관리해야 한다
즉, 인증 정보도 함께 전달해야 한다.
이 장의 핵심
- 인증은 “누구인가”
- 인가는 “무엇을 할 수 있는가”
- 요청은 여러 서비스를 거친다
- 인증 정보는 함께 전달되어야 한다
- 세션은 중앙 의존성을 만든다
- 토큰은 서비스 독립성을 만든다
- 실무에서는 두 방식을 함께 사용한다
- 이벤트에서는 사용자 정보 전달을 고려해야 한다
한 줄 정리
분산 시스템에서는 인증도 흐르고, 권한도 설계해야 한다
15장. 이벤트로 연결되는 시스템
지금까지 우리는
이벤트 시스템의 현실을 봤다.
- 메시지는 중복될 수 있고
- 순서는 보장되지 않으며
- 정합성은 단계적으로 맞춰야 한다
그럼에도 불구하고
많은 시스템이 이벤트 기반 아키텍처를 선택한다.
왜일까?
기존 방식 — 직접 호출
전통적인 방식은 단순하다.
flowchart LR
ServiceA --> ServiceB
Service A는 Service B에게 요청한다.
“이 작업을 처리해줘”
이 구조는 이해하기 쉽다. 하지만 점점 문제가 생긴다.
- 서비스 간 의존성이 강해진다
- 장애가 전파된다
- 확장이 어려워진다
이벤트 기반 방식 — 상태를 알린다
이벤트 기반 아키텍처에서는
방식이 완전히 바뀐다.
flowchart LR
A[Service A] -->|Event| Broker
Broker --> B[Service B]
Broker --> C[Service C]
Service A는 더 이상 요청하지 않는다.
“이런 일이 발생했다”
라고 알릴 뿐이다.
핵심 구성 요소
이 구조는 세 가지로 이루어진다.
1️⃣ 발행자 (Publisher)
이벤트를 생성하는 주체다.
예:
- 주문 생성
- 결제 완료
- 회원 가입
발행자는
누가 이 이벤트를 사용할지 모른다.
단지 “일어났음을 알릴 뿐”이다.
2️⃣ 이벤트 (Event)
이미 발생한 사실을 표현한다.
중요한 점은 이것이다.
이벤트는 명령이 아니라 사실이다
예:
- ❌
CreateOrder - ✅
OrderCreated
3️⃣ 구독자 (Subscriber)
이벤트를 받아 처리하는 주체다.
예:
- 포인트 적립 서비스
- 알림 서비스
- 통계 시스템
구독자는
- 누가 발행했는지 몰라도 되고
- 몇 개 서비스가 발행하는지도 몰라도 된다
“이 이벤트를 받겠다”만 정의하면 된다
느슨한 결합 (Loose Coupling)
이벤트 기반 아키텍처의 핵심은 이것이다.
서로를 몰라도 동작한다
기존 방식
A → B → C
- A는 B를 알아야 하고
- B는 C를 알아야 한다
👉 하나가 바뀌면 전부 영향 받는다
이벤트 방식
A → Event → (B, C, D ...)
- A는 아무도 몰라도 된다
- B, C, D는 서로 몰라도 된다
👉 각자 자기 일만 한다
장점
이벤트 기반 구조는 다음과 같은 장점을 가진다.
1️⃣ 서비스 간 의존성 감소
발행자는
구독자를 알 필요가 없다.
구독자도
발행자를 알 필요가 없다.
2️⃣ 확장성
새로운 기능이 필요하면
구독자만 추가하면 된다
기존 서비스는 수정하지 않는다.
3️⃣ 장애 격리
하나의 서비스가 실패해도
다른 서비스에는 직접적인 영향이 없다.
4️⃣ 비동기 처리
요청-응답을 기다리지 않기 때문에
전체 시스템이 더 유연해진다.
단점
하지만 이 구조는
대가 없이 얻어지지 않는다.
1️⃣ 정합성 문제
모든 처리가 동시에 완료되지 않는다.
최종 일관성을 받아들여야 한다
2️⃣ 추적의 어려움
하나의 요청이
여러 서비스로 퍼진다.
- 어디까지 처리됐는지
- 어디서 실패했는지
추적이 어려워진다.
3️⃣ 멱등성 문제
이벤트는 중복될 수 있다.
같은 이벤트를 여러 번 처리해도 안전해야 한다
4️⃣ 순서 문제
이벤트는 순서대로 오지 않는다.
순서에 의존하면 시스템이 깨진다
이벤트 기반 설계의 본질
이 구조의 핵심은 이것이다.
요청이 아니라 결과를 공유한다
기존 방식:
“이걸 해줘”
이벤트 방식:
“이 일이 일어났어”
이 차이는 단순한 구현 방식의 차이가 아니다.
시스템을 바라보는 관점의 변화다
이 장의 핵심
이벤트 기반 아키텍처는
- 요청 대신 이벤트로 연결된다
- 발행자, 이벤트, 구독자로 구성된다
- 서로를 몰라도 동작하는 느슨한 결합 구조다
- 확장성과 유연성을 제공한다
하지만 동시에
- 정합성 문제를 감수해야 하고
- 추적이 어려워지며
- 멱등성과 순서 문제를 반드시 해결해야 한다
16장. 메시지는 정확히 한 번 오지 않는다
마이크로서비스에서 이벤트 기반 통신을 사용하면
구조는 보통 다음과 같다.
flowchart LR
Producer --> Broker --> Consumer
- Producer는 메시지를 발행한다.
- Broker는 메시지를 저장하고 전달한다.
- Consumer는 메시지를 받아 처리한다.
겉으로 보기에는 단순하다.
하지만 이 구조는
네트워크 위에서 동작한다는 사실을 반드시 기억해야 한다.
네트워크는 완벽하지 않다
네트워크는 다음과 같은 특성을 가진다.
- 지연될 수 있다.
- 패킷이 유실될 수 있다.
- 연결이 일시적으로 끊길 수 있다.
- 응답(ACK)이 손실될 수 있다.
이 환경에서
“메시지가 정확히 한 번 전달된다”는 보장을 만드는 것은
기술적으로 매우 어렵다.
그래서 메시지 시스템은
세 가지 전달 모델을 제공한다.
메시지 전달의 세 가지 모델
1️⃣ At Most Once — 최대 한 번
- 재전송하지 않는다.
- 중복은 발생하지 않는다.
- 대신 유실될 수 있다.
ACK를 받지 못해도
다시 보내지 않는다.
장점:
- 단순하다.
- 비용이 낮다.
- 처리량이 높다.
단점:
- 메시지가 사라질 수 있다.
2️⃣ At Least Once — 최소 한 번
- ACK를 받지 못하면 재전송한다.
- 유실 가능성은 매우 낮다.
- 대신 중복이 발생할 수 있다.
흐름을 보자.
sequenceDiagram
Producer->>Broker: 메시지 전송
Broker->>Consumer: 메시지 전달
Consumer-->>Broker: ACK (유실)
Broker->>Consumer: 재전송
Consumer는 이미 처리했지만
ACK가 유실되면 Broker는 재전송한다.
→ 동일 메시지가 두 번 처리될 수 있다.
이것은 예외 상황이 아니라
설계상 자연스러운 결과다.
3️⃣ Exactly Once — 정확히 한 번
이상적인 모델처럼 보인다.
- 유실도 없고
- 중복도 없다.
하지만 이를 보장하려면:
- 브로커는 메시지 처리 상태를 정확히 추적해야 하고
- 소비자의 처리 결과와 일관성을 유지해야 하며
- 재전송과 상태 관리를 전역적으로 통제해야 한다.
이는 사실상
분산 트랜잭션에 가까운 복잡성을 만든다.
결과적으로:
- 성능 저하
- 구현 복잡성 증가
- 확장성 제한
“정확히 한 번”은
기술적으로 가능하지만 무료가 아니다.
어떤 모델이 정답인가
세 모델은 우열 관계가 아니다.
중요한 것은
어떤 위험을 감수할 것인가이다.
At Most Once가 적합한 경우
유실이 서비스 핵심에 영향을 주지 않는 경우.
예:
- 대량 로그 수집
- 사용자 행동 분석 이벤트
- 메트릭 전송
- 실시간 모니터링 데이터
이 경우 일부 메시지가 사라져도
전체 통계나 서비스 기능에는 큰 문제가 없다.
오히려 재전송 비용과 중복 처리 부담이
더 비효율적일 수 있다.
At Least Once가 적합한 경우
다음과 같은 핵심 도메인 이벤트는
사라지면 안 된다.
- 주문 생성
- 결제 승인
- 재고 차감
- 회원 상태 변경
이 경우 유실은 치명적이다.
그래서 대부분의 핵심 비즈니스 이벤트는
At Least Once 모델을 선택한다.
대신 중복을 감수한다.
Exactly Once는 언제 쓰이는가
특정 금융 처리나
강력한 정합성이 절대적으로 필요한 시스템에서는
Exactly Once가 선택될 수 있다.
하지만 일반적인 웹 서비스 환경에서는
복잡성과 비용이 너무 크다.
At Least Once를 선택하면 생기는 일
많은 핵심 이벤트가
At Least Once를 기반으로 동작한다는 것은
다음 현실을 받아들여야 한다는 의미다.
1️⃣ 메시지는 중복될 수 있다
재시도, ACK 손실, 브로커 재시작 등으로 인해
같은 메시지가 여러 번 도착할 수 있다.
중복은 오류가 아니라
정상 동작의 일부다.
2️⃣ 메시지 순서는 보장되지 않을 수 있다
예를 들어 다음 두 이벤트가 있다고 하자.
- 주문 생성
- 주문 취소
이론적으로는 생성 → 취소 순서여야 한다.
하지만 실제 도착은 다음과 같을 수 있다.
sequenceDiagram
Producer->>Broker: 주문 생성
Producer->>Broker: 주문 취소
Broker->>Consumer: 주문 취소
Broker->>Consumer: 주문 생성
순서 역전은 충분히 발생할 수 있다.
네트워크 지연, 파티션 재조정, 재시도 등
여러 요인 때문이다.
3️⃣ ACK 시점이 설계에 영향을 준다
Consumer는 언제 ACK를 보내야 하는가?
- DB 저장 후?
- 외부 API 호출 후?
- 메모리 처리 후?
ACK 시점에 따라
중복 처리 전략과 실패 복구 방식이 달라진다.
이것은 단순한 구현 세부사항이 아니라
아키텍처 결정이다.
이벤트 기반 설계의 기본 전제
이벤트 기반 아키텍처를 선택했다면
다음 사실을 받아들여야 한다.
- 메시지는 유실될 수 있다.
- 메시지는 중복될 수 있다.
- 메시지는 순서가 바뀔 수 있다.
- Exactly Once는 비용이 높다.
- 전달 모델은 목적에 따라 선택해야 한다.
이 전제를 무시하면
시스템은 언젠가 예상치 못한 방식으로 깨진다.
이 장의 핵심
이벤트 기반 통신은
함수 호출의 확장판이 아니다.
네트워크 위에서 동작하는
비결정적 시스템이다.
- 완벽한 전달은 어렵다.
- 중복은 자연스럽다.
- 순서 역전은 발생할 수 있다.
- 모델 선택은 비용과 위험의 문제다.
대부분의 핵심 도메인 이벤트는
At Least Once 위에 설계된다.
그리고 그 전제를 받아들인 순간
다음 질문이 등장한다.
중복과 순서 문제를 전제로
시스템의 상태를 어떻게 설계할 것인가?
17장. 이벤트는 항상 순서대로 오지 않는다
16장에서 우리는
메시지가 중복되고 유실될 수 있음을 보았다.
하지만 그 위에 또 다른 전제가 있다.
이벤트가 우리가 기대하는 순서로 도착한다.
현실은 다르다.
- 네트워크 지연
- 재시도
- Consumer 재시작
- 브로커 리밸런싱
- 병렬 처리
이 모든 요인이
이벤트 순서를 뒤섞을 수 있다.
왜 순서가 깨지는가
이벤트 흐름은 단순해 보인다.
flowchart LR
Producer --> Broker --> Consumer
하지만 실제 환경에서는 다음과 같은 일이 발생한다.
1️⃣ 네트워크 지연
먼저 발생한 이벤트가
나중에 도착할 수 있다.
2️⃣ 재시도
ACK 손실이나 타임아웃으로 인해
이전 이벤트가 뒤늦게 재전송될 수 있다.
3️⃣ 병렬 소비
여러 Consumer가 동시에 처리하면
완료 순서가 달라질 수 있다.
즉,
발생 순서와 처리 완료 순서는 다를 수 있다.
브로커 레벨에서의 순서 보장 (대부분의 경우 해결)
다행히 대부분의 메시지 브로커는
제한적인 범위 안에서 순서를 보장한다.
하지만 중요한 점은 이것이다.
순서 보장은 항상 “범위 제한적”이다.
1️⃣ Kafka — 파티션 단위 순서
Kafka는 파티션 내부에서만 순서를 보장한다.
같은 Key를 같은 파티션으로 보내면
그 Key에 대해서는 순서가 유지된다.
핵심 전략:
같은 비즈니스 키는 같은 파티션으로 보낸다.
예:
- 주문 ID를 파티션 키로 사용
- 사용자 ID를 파티션 키로 사용
주의:
- 다른 파티션 간 순서는 보장되지 않는다.
- 파티션 수를 변경하면 분배 전략이 달라질 수 있다.
2️⃣ AWS SQS FIFO — Message Group ID
SQS FIFO는
같은 Message Group ID 내에서 순서를 보장한다.
- 같은 Group ID → 순서 유지
- 다른 Group ID → 순서 보장 없음
하지만 Group 단위로 직렬 처리되기 때문에
병렬성이 일부 제한된다.
3️⃣ RabbitMQ
RabbitMQ는 큐 내부에서는
전달 순서를 유지한다.
그러나:
- 여러 Consumer가 병렬로 처리하면
- ACK 시점에 따라 체감 순서가 달라질 수 있다.
엄격한 순서를 원한다면:
- 단일 Consumer 사용
- Prefetch 조정
- 병렬 처리 제한
이 필요하다.
4️⃣ Google Cloud Pub/Sub
Pub/Sub은 Ordering Key를 제공한다.
같은 Ordering Key를 사용하면
그 키 단위로 순서를 유지한다.
단, 장애나 재시도 상황에서는
중복 및 재정렬 가능성을 고려해야 한다.
⚠️ 브로커 순서 보장이 성립하려면
브로커 설정만으로 순서가 보장되는 것은 아니다.
다음 조건이 충족되어야 한다.
1️⃣ 같은 키는 반드시 같은 스트림으로 묶여야 한다
- Kafka → 같은 Key는 같은 파티션
- SQS FIFO → 같은 Message Group ID
- Pub/Sub → 같은 Ordering Key
키가 흔들리면
순서 보장은 즉시 깨진다.
2️⃣ 해당 스트림은 동시에 하나만 처리해야 한다
“소비자가 하나여야 한다”는 뜻은 아니다.
정확히는:
같은 파티션(또는 Group/Ordering Key)은
동시에 하나의 소비자만 처리해야 한다.
Kafka는 consumer group 내에서
파티션을 하나의 consumer에게만 할당한다.
하지만:
- Consumer 내부에서 병렬 처리하면
- 처리 완료 순서가 바뀔 수 있다.
즉,
전달 순서 ≠ 처리 완료 순서
라는 점을 반드시 이해해야 한다.
3️⃣ 재시도와 리밸런싱을 고려해야 한다
- Consumer 재시작
- 파티션 리밸런싱
- ACK 실패
- Visibility timeout 만료
이 상황에서는
이미 처리한 메시지가 다시 오거나
체감 순서가 뒤바뀔 수 있다.
브로커는 90%를 해결해주지만
100%는 아니다.
그래도 깨지는 경우 — 외부 이벤트 시스템
지금까지는 우리가 직접 제어할 수 있는
메시지 브로커를 기준으로 설명했다.
하지만 현실에서는
외부 시스템이 보내는 이벤트도 처리해야 한다.
예를 들면:
- 결제 대행사(PG) 웹훅
- 물류사 배송 상태 콜백
- 외부 인증 서비스 이벤트
- SaaS 서비스 상태 변경 알림
이러한 이벤트는
Kafka나 SQS처럼 세밀하게 통제할 수 없다.
대부분 HTTP Webhook 형태로 전달되며
순서를 보장하지 않는다.
사례 — AWS IVS
AWS IVS는
Amazon Interactive Video Service의 약자로
라이브 스트리밍을 제공하는 AWS 서비스다.
스트리밍 상태가 바뀌면
다음과 같은 이벤트를 보낸다.
- Stream Start
- Stream End
- Recording Ready
이 이벤트들은
순서를 보장하지 않는다.
즉:
- Stream End가 먼저 오고
- Stream Start가 나중에 올 수 있다.
네트워크 지연, 재시도, 내부 처리 순서 등
여러 요인이 작용하기 때문이다.
이 경우 브로커 설정으로 해결할 수 없다.
애플리케이션 레벨 대응 전략
브로커는 1차 방어선이다.
최종 방어선은 애플리케이션이다.
1️⃣ 버전 기반 처리
각 이벤트에 version을 둔다.
- 현재 version보다 낮으면 무시
- 더 높은 version만 반영
상태가 뒤로 가지 않도록 한다.
2️⃣ 현재 상태 기준 검증
이미 CANCELED 상태라면
PAYMENT_COMPLETED 이벤트를 무시한다.
현재 상태가
이벤트를 수용할 수 있는지 판단한다.
3️⃣ 타임스탬프 비교
이벤트 발생 시간을 비교해
더 오래된 이벤트는 폐기한다.
단, 시간 동기화 문제는 고려해야 한다.
4️⃣ 단방향 상태 설계
상태가 뒤로 돌아가지 않도록 설계한다.
예:
CREATED → PAYMENT_PENDING → COMPLETED
이미 COMPLETED라면
이전 단계 이벤트는 무시한다.
5️⃣ 상태 조회 기반 처리
이벤트에는 변경 사실만 담고,
실제 데이터는 API로 최신 상태를 조회한다.
이벤트는 트리거, 데이터는 조회
장점:
- 메시지 경량화
- 순서 역전에도 정합성 유지
단점:
- 추가 API 호출로 인한 부하 증가
순서를 완벽히 보장하려 하지 말라
브로커 설정으로
대부분의 순서 문제는 해결할 수 있다.
하지만 설계 원칙은 이것이다.
순서가 깨져도 시스템이 무너지지 않도록 설계하라.
브로커는 편의를 제공한다.
안전성은 설계가 만든다.
이 장의 핵심
이벤트 기반 시스템에서는
- 순서는 범위 제한적으로만 보장된다.
- 키 단위 스트림 설계가 중요하다.
- 해당 스트림은 동시에 하나만 처리해야 한다.
- 전달 순서와 처리 완료 순서는 다를 수 있다.
- 외부 이벤트는 순서를 보장하지 않는다.
- 애플리케이션이 최종 방어를 해야 한다.
18장. 같은 이벤트는 여러 번 온다 — 멱등성
이벤트는 중복될 수 있다.
그리고 이벤트는 순서대로 오지도 않는다.
이 두 가지가 합쳐지면
시스템은 쉽게 꼬인다.
같은 이벤트가 여러 번, 순서 없이 도착한다.
이번 장에서는
그 중 “중복” 문제를 다룬다.
왜 중복 이벤트가 발생하는가
이벤트 시스템은 보통
다음 전략을 선택한다.
최소 한 번(at-least-once) 전달
이 구조에서는
중복을 피할 수 없다.
1️⃣ ACK 실패
처리는 끝났지만 ACK이 전달되지 않으면
브로커는 같은 메시지를 다시 보낸다.
2️⃣ 재시도
Producer 또는 Broker가
전송 실패 시 재시도하면서 중복이 발생한다.
3️⃣ Consumer 재시작
Consumer가 재시작되면
이미 처리한 메시지를 다시 처리할 수 있다.
4️⃣ 외부 이벤트
Webhook 기반 시스템은
응답이 없으면 동일 이벤트를 재전송한다.
중복 이벤트가 만드는 문제
중복 이벤트를 그대로 처리하면
시스템은 쉽게 깨진다.
예:
- 결제가 2번 처리됨
- 포인트가 2번 적립됨
- 상태가 잘못 변경됨
즉,
같은 이벤트를 여러 번 처리해도
결과는 한 번 처리한 것과 같아야 한다.
멱등성이란 무엇인가
멱등성이란
동일한 요청을 여러 번 수행해도
결과가 변하지 않는 성질
이다.
브로커는 멱등성을 보장하지 않는다
일부 시스템은 멱등성을 지원하지만
그 범위는 제한적이다.
브로커는 중복을 줄여줄 뿐
비즈니스 중복까지 막아주지 않는다.
결국 멱등성은
애플리케이션의 책임이다.
애플리케이션 레벨 대응 전략
중복 문제의 본질은 이것이다.
이미 처리했는가?
1️⃣ 고유 ID 기반 처리
이벤트에 고유 ID를 두고
이미 처리한 ID는 무시한다.
장점:
- 가장 확실한 방법
단점:
- 저장소 관리 필요
2️⃣ 상태 기반 처리
현재 상태를 기준으로
이벤트를 적용할지 판단한다.
예:
- 이미 COMPLETED → 다시 처리하지 않음
장점:
- 추가 저장소 없이 가능
단점:
- 상태 설계에 의존
3️⃣ 조건부 업데이트
DB에서 조건을 걸어
중복 처리를 방지한다.
예:
UPDATE order
SET status = 'COMPLETED'
WHERE id = ? AND status != 'COMPLETED';
장점:
- DB에서 원자성 보장
단점:
- 복잡한 로직에는 한계
4️⃣ 멱등 키 사용
요청에 멱등 키를 포함하고
같은 키 요청은 한 번만 처리한다.
장점:
- API 레벨 제어 가능
단점:
- 클라이언트 협력 필요
5️⃣ 결과 재사용
처리 결과를 저장하고
중복 요청 시 재사용한다.
장점:
- 완전한 멱등성
단점:
- 저장 비용 증가
순서와 멱등성은 함께 깨진다
현실에서는 이 두 문제가 동시에 발생한다.
- 중복 이벤트 + 순서 역전
- 늦게 온 이벤트 + 이미 처리된 상태
따라서 설계 원칙은 이것이다.
순서가 깨지고 중복이 와도
결과는 항상 올바르게 유지되어야 한다.
멱등성은 선택이 아니라 전제다
이벤트 기반 시스템에서는
- 중복은 항상 발생한다
- 한 번만 처리된다는 보장은 없다
따라서
중복을 제거하려 하지 말고
중복이 와도 안전하게 만들어라
이 장의 핵심
이벤트 기반 시스템에서는
- 이벤트는 여러 번 전달된다
- 중복은 예외가 아니라 기본이다
- 브로커는 멱등성을 보장하지 않는다
- 멱등성은 애플리케이션의 책임이다
- 순서와 중복 문제는 함께 고려해야 한다
19장. 분산 환경에서의 정합성 — 최종 일관성
16장에서 우리는 한 가지를 이해했다.
- 메시지는 중복될 수 있고
- 순서가 바뀔 수 있으며
- 정확히 한 번 전달은 비싸다
이제 질문은 이것이다.
이렇게 불완전한 환경에서
여러 서비스의 상태를 어떻게 맞출 것인가?
모놀리스에서는 이 질문이 단순했다.
모놀리스에서의 정합성
모놀리스에서는 하나의 데이터베이스 트랜잭션이 있었다.
BEGIN
주문 생성
결제 승인
포인트 적립
COMMIT
중간에 실패하면
전부 되돌아간다.
사용자는 항상
완성된 결과만 보게 된다.
이것이 강한 일관성이다.
마이크로서비스에서 달라지는 점
서비스가 나뉘면 상황이 달라진다.
- 주문은 Order 서비스가 처리하고
- 결제는 Payment 서비스가 처리하고
- 포인트는 Point 서비스가 처리한다
각 서비스는 자기 데이터베이스를 가진다.
이들을 하나의 트랜잭션으로 묶을 수 없다.
따라서 “한 번에 맞추는 방식” 대신
“단계적으로 맞추는 방식”을 선택해야 한다.
최종 일관성이라는 개념
마이크로서비스는 보통
최종 일관성을 전제로 설계된다.
의미는 단순하다.
잠시 어긋날 수는 있지만
결국에는 맞춰진다.
예를 들어:
- 주문 생성 완료
- 결제 승인 완료
- 포인트 적립은 몇 초 뒤 반영
사용자는 잠시 동안
포인트가 반영되지 않은 상태를 볼 수 있다.
하지만 시간이 지나면
모든 데이터는 일관된 상태로 수렴한다.
이것이 최종 일관성이다.
설계 방식의 변화
모놀리스에서는 이렇게 생각했다.
어떻게 한 번에 끝낼 것인가?
마이크로서비스에서는 이렇게 생각해야 한다.
어떻게 단계적으로 완성시킬 것인가?
이 차이가 핵심이다.
Saga 패턴 — 단계별 처리 방식
Saga는 여러 서비스에 걸친 작업을
단계적으로 처리하는 방식이다.
각 단계는
각 서비스 내부에서만 트랜잭션을 보장한다.
예를 들어 주문 처리 흐름은 다음과 같다.
sequenceDiagram
participant Order
participant Payment
participant Point
Order->>Payment: 결제 요청 이벤트
Payment-->>Order: 결제 완료 이벤트
Order->>Point: 포인트 적립 이벤트
각 서비스는
자기 일만 확실히 처리한다.
전체는 이벤트 흐름으로 연결된다.
실패는 어떻게 되돌리는가
결제가 실패하면 어떻게 될까?
sequenceDiagram
participant Order
participant Payment
Order->>Payment: 결제 요청
Payment-->>Order: 결제 실패
Order->>Order: 주문 취소 처리
이미 수행된 작업을
데이터베이스 롤백으로 되돌릴 수는 없다.
대신 비즈니스 로직으로 되돌린다.
예:
- 결제 성공 후 배송 시작 → 배송 취소
- 포인트 적립 후 주문 취소 → 포인트 차감
이것을 보상 처리라고 한다.
중간 상태를 인정해야 한다
마이크로서비스에서는
중간 단계를 숨길 수 없다.
예를 들어 주문은
다음과 같은 단계를 가질 수 있다.
- CREATED
- PAYMENT_PENDING
- PAYMENT_COMPLETED
- CANCELED
- FAILED
중요한 것은 이것이다.
어떤 단계에서
어떤 단계로 바뀔 수 있는지
명확한 규칙이 있어야 한다.
예를 들어:
- 이미 취소된 주문이 다시 결제 완료가 되면 안 된다.
- 결제가 완료되기 전에 배송이 시작되면 안 된다.
이런 규칙이 정해져 있어야
시스템이 꼬이지 않는다.
멱등성은 필수다
16장에서 봤듯이
이벤트는 중복될 수 있다.
따라서 같은 이벤트가 여러 번 와도
결과는 한 번 처리된 것과 같아야 한다.
예:
-
결제 완료 이벤트가 두 번 와도
주문 상태는 한 번만 결제 완료가 되어야 한다. -
포인트 적립 이벤트가 두 번 와도
포인트는 한 번만 증가해야 한다.
멱등성은 선택이 아니라
전제 조건이다.
일시적 불일치를 허용하는 설계
마이크로서비스에서는
모든 것을 동시에 맞출 수 없다.
그래서 다음을 스스로에게 물어야 한다.
- 포인트 적립이 2초 늦어도 되는가?
- 알림이 조금 늦어도 되는가?
- 통계 수치는 몇 분 늦어도 되는가?
모든 것을 강하게 묶으면
시스템은 복잡해지고 확장성이 떨어진다.
최종 일관성을 받아들이는 것은
느슨해지는 것이 아니라
확장성을 선택하는 것이다.
반드시 고민해야 할 질문
정합성을 설계할 때
다음 질문에 답해야 한다.
- 반드시 동시에 완료되어야 하는 작업인가?
- 잠시 어긋난 상태를 허용할 수 있는가?
- 실패 시 되돌리는 방법은 명확한가?
- 상태가 바뀌는 규칙은 정의되어 있는가?
- 같은 이벤트가 여러 번 와도 안전한가?
이 질문을 건너뛰면
분산 시스템은 언젠가 예기치 않게 무너진다.
이 장의 핵심
마이크로서비스에서는
- 하나의 거대한 트랜잭션은 존재하지 않는다.
- 대신 여러 개의 작은 확실함이 있다.
- 전체 정합성은 단계적으로 완성된다.
- 실패는 롤백이 아니라 보상으로 처리된다.
- 상태가 어떻게 바뀌는지 규칙을 설계해야 한다.
- 멱등성은 반드시 필요하다.
정합성은 더 이상
데이터베이스가 책임지지 않는다.
아키텍처와 비즈니스 설계가
그 책임을 가진다.
20장. 트랜잭션과 이벤트의 원자성 — Outbox
19장에서 우리는 정합성을 단계적으로 맞추는 방식을 배웠다.
17장에서 이벤트 순서 문제를 다루었다.
하지만 아직 해결하지 않은 치명적인 틈이 하나 있다.
DB는 커밋됐는데 이벤트는 발행되지 않았다면?
이 문제는 이벤트 기반 아키텍처에서
가장 흔하고, 가장 위험한 실패 지점이다.
문제 상황
A 서비스가 다음과 같이 동작한다고 가정하자.
BEGIN TRANSACTION
주문 저장
COMMIT
이벤트 발행 (OrderCreated)
겉보기에는 자연스러운 흐름이다.
하지만 이런 일이 발생할 수 있다.
- DB Commit 성공
- 이벤트 발행 직전 서버 다운
- 브로커 네트워크 장애
- 이벤트 전송 중 예외 발생
결과:
- DB에는 주문이 존재한다.
- 다른 서비스는 주문이 생성된 사실을 모른다.
- Saga 흐름이 멈춘다.
- 시스템 정합성이 붕괴된다.
이건 예외 처리가 부족한 문제가 아니다.
구조적으로 발생 가능한 문제다.
왜 단순 재시도는 충분하지 않은가
많은 개발자가 이렇게 생각한다.
“이벤트 발행 실패하면 다시 보내면 되지 않나?”
하지만 다음을 생각해보자.
- 재시도 로직이 메모리에만 있다면?
- 프로세스가 죽으면?
- 배포 중 재시작되면?
이벤트는 영원히 사라질 수 있다.
Try-Catch + Retry는
일시적 오류에는 대응할 수 있지만
구조적 유실을 막지는 못한다.
문제의 본질
핵심은 이것이다.
DB 트랜잭션과 메시지 발행은 하나의 원자적 연산이 아니다.
DB는 로컬 트랜잭션을 보장한다.
메시지 브로커는 완전히 다른 시스템이다.
이 둘을 하나의 트랜잭션으로 묶지 않는 한
그 사이에는 항상 틈이 존재한다.
해결책 — Outbox 패턴
Outbox 패턴은
이 틈을 메우기 위한 설계다.
핵심 아이디어는 단순하다.
이벤트를 즉시 브로커로 보내지 말고,
DB 안에 함께 저장하라.
기본 구조
flowchart LR
Service --> DB[(Business Data)]
Service --> DB[(Outbox Table)]
OutboxProcessor --> Broker
동작 흐름
- DB 트랜잭션 시작
- 비즈니스 데이터 저장
- Outbox 테이블에 이벤트 레코드 저장
- 트랜잭션 커밋
- 별도 프로세스가 Outbox를 읽어 브로커로 전송
- 성공 시 상태 변경
코드 흐름은 다음과 같다.
BEGIN
주문 저장
Outbox에 OrderCreated 이벤트 저장
COMMIT
여기까지는 하나의 트랜잭션이다.
이제 DB에 이벤트가 안전하게 남는다.
설령 서버가 즉시 죽어도
Outbox 레코드는 사라지지 않는다.
Outbox 처리 방식
Outbox를 브로커로 내보내는 방식은 여러 가지가 있다.
1️⃣ 주기적 Polling 방식
- 일정 주기로 Outbox 테이블 조회
- 상태가 PENDING인 이벤트 전송
- 성공 시 SENT로 업데이트
장점:
- 구현이 단순하다
- 이해하기 쉽다
단점:
- 폴링 주기만큼 지연 발생 가능
2️⃣ 즉시 발행 + 실패분 Polling (하이브리드)
실무에서 자주 사용하는 절충안이다.
- 트랜잭션 안에서 Outbox에 이벤트 저장
- 커밋 직후 애플리케이션이 즉시 한 번 발행 시도
- 성공 시 SENT로 업데이트
- 실패한 레코드만 Polling 프로세스가 재시도
이 방식은:
- 정상 케이스는 지연 없이 빠르게 전파하고
- 장애 케이스는 폴링으로 안전하게 복구한다
단, 중요한 점이 있다.
즉시 발행은 Outbox에 기록된 데이터를 기반으로 해야 한다.
Outbox를 우회해서 직접 발행하면 안 된다.
3️⃣ CDC(Change Data Capture)
CDC는 접근 방식이 다르다.
애플리케이션이 Outbox를 읽는 대신
DB의 트랜잭션 로그를 감시한다.
DB는 내부적으로
“어떤 데이터가 언제 어떻게 변경되었는지” 기록을 남긴다.
CDC 도구(예: Debezium)는:
- 이 트랜잭션 로그를 실시간으로 읽고
- Outbox 테이블의 변경을 감지하고
- Kafka 같은 브로커로 자동 전송한다
즉, 애플리케이션은:
- Outbox에 저장까지만 책임지고
- 브로커 전송은 CDC 파이프라인이 담당한다
장점:
- 거의 실시간 처리
- 애플리케이션 코드 단순화
단점:
- 운영 복잡성 증가
- 커넥터/오프셋 관리 필요
Outbox도 중복에서 자유롭지 않다
Outbox는 이벤트 유실을 막는다.
하지만 중복을 완전히 제거하지는 못한다.
왜냐하면 다음과 같은 틈이 존재하기 때문이다.
케이스 A — 전송 성공, 상태 업데이트 실패
- 브로커 전송 성공
- SENT 업데이트 중 DB 오류 발생
- 다음 폴링에서 같은 이벤트 재전송
결과: 중복 이벤트 발생
케이스 B — 워커 간 경합
- 두 워커가 동시에 같은 PENDING 레코드를 읽음
- 둘 다 전송 시도
이 경우도 중복이 발생할 수 있다.
이를 줄이기 위해:
- 상태를 PENDING → PROCESSING으로 원자적 변경
- SELECT FOR UPDATE
- SKIP LOCKED
- lease 기반 처리
같은 기법을 사용한다.
하지만 0% 중복은 현실적으로 어렵다.
CDC도 재시도와 중복이 존재한다
CDC 역시 내부적으로:
- 커넥터 재시작
- 브로커 전송 실패
- 오프셋 재조정
과정을 거치며
재전송이나 리플레이가 발생할 수 있다.
즉, CDC도 중복 가능성을 전제로 설계해야 한다.
그래서 멱등성은 필수다
결론은 명확하다.
Outbox는 유실을 막고, 멱등성은 중복을 안전하게 만든다.
어떤 방식이든:
- 이벤트에는 고유 ID를 부여하고
- 소비자는 해당 ID 기준으로 한 번만 처리하도록 설계하고
- 상태 기반 검증을 함께 수행해야 한다
Outbox는 안전한 출발점이고,
멱등성은 안전한 도착점이다.
둘 중 하나라도 없으면
분산 시스템은 언제든 흔들린다.
이 장의 핵심
- DB 커밋과 이벤트 발행은 기본적으로 분리되어 있다.
- 그 사이에는 일관성 붕괴 위험이 존재한다.
- 단순 재시도는 충분하지 않다.
- Outbox 패턴은 DB 트랜잭션 안에 이벤트를 함께 저장함으로써 유실을 막는다.
- 즉시 발행 + Polling 또는 CDC 방식으로 전송할 수 있다.
- Outbox와 CDC 모두 중복 가능성이 있다.
- 멱등성은 필수다.
21장. 이벤트를 안전하게 전달하는 방법 — Outbox + Inbox
15장에서 우리는
이벤트 기반 아키텍처를 살펴봤다.
- 서비스는 직접 호출하지 않고
- 이벤트로 연결되며
- 느슨하게 결합된다
하지만 이 구조에는 현실적인 문제가 있다.
이벤트는 유실될 수 있고, 중복될 수 있다
그렇다면 질문은 이것이다.
이벤트를 어떻게 안전하게 전달할 것인가?
문제는 양쪽에서 발생한다
이벤트 흐름은 단순해 보인다.
flowchart LR
Producer --> Broker --> Consumer
하지만 실제 문제는
양쪽에서 동시에 발생한다.
1️⃣ 보내는 쪽 문제 (Producer)
DB는 저장됐는데 이벤트는 발행되지 않았다
→ 20장에서 본 원자성 문제
2️⃣ 받는 쪽 문제 (Consumer)
같은 이벤트가 여러 번 처리된다
→ 18장에서 본 멱등성 문제
해결 전략 — Outbox + Inbox
이 두 문제를 함께 해결하는 방법이 있다.
Outbox + Inbox 패턴
Outbox — 보내는 쪽을 안전하게 만든다
Outbox 패턴의 핵심은 단순하다.
이벤트를 즉시 보내지 말고
DB 트랜잭션 안에 함께 저장하라
동작 흐름
flowchart LR
Service --> DB[(Business Data)]
Service --> DB[(Outbox)]
OutboxWorker --> Broker
- 비즈니스 데이터 저장
- Outbox 테이블에 이벤트 저장
- 트랜잭션 커밋
- 별도 프로세스가 이벤트 발행
효과
- 이벤트 유실 방지
- 장애 상황에서도 재시도 가능
Inbox — 받는 쪽을 안전하게 만든다
Inbox는 소비자 측 패턴이다.
핵심은 이것이다.
이미 처리한 이벤트인지 기록한다
동작 흐름
flowchart LR
Broker --> Consumer
Consumer --> Inbox[(Inbox Table)]
Consumer --> Logic[Business Logic]
- 이벤트 수신
- Inbox 테이블에 event_id 기록 시도
- 이미 존재하면 무시
- 없으면 처리 진행
핵심 구현
INSERT INTO inbox(event_id)
VALUES (?)
ON CONFLICT DO NOTHING;
→ insert 성공 시에만 처리
효과
- 중복 이벤트 차단
- 멱등성 보장
Outbox와 Inbox는 함께 사용해야 한다
둘 중 하나만으로는 충분하지 않다.
Outbox만 있는 경우
- 이벤트는 유실되지 않는다
- 하지만 중복은 그대로 발생한다
Inbox만 있는 경우
- 중복은 제어된다
- 하지만 이벤트 자체가 사라질 수 있다
함께 사용하는 경우
flowchart LR
Outbox --> Broker --> Inbox
- 유실 방지
- 중복 제어
👉 현실적인 최선의 구조
그래도 Exactly Once는 아니다
중요한 사실 하나
Outbox + Inbox를 사용해도 Exactly Once는 아니다
여전히
- 재시도
- 중복 전송
- 순서 문제
는 존재한다.
하지만
중복되더라도 안전한 시스템은 만들 수 있다
이벤트 시스템의 현실적인 목표
이벤트 기반 아키텍처의 목표는
완벽함이 아니다.
유실 없이, 중복을 허용하되, 안전하게 처리하는 것
Outbox와 Inbox는
이 목표를 가장 현실적으로 달성하는 방법이다.
이 장의 핵심
- 이벤트 전달 문제는 양쪽에서 발생한다
- Outbox는 이벤트 유실을 막는다
- Inbox는 중복 처리를 제어한다
- 둘을 함께 사용해야 안정성이 확보된다
- Exactly Once는 어렵지만 충분히 안전한 시스템은 만들 수 있다
22장. Saga 패턴
먼저 이런 상황을 생각해보자
사용자가 상품을 구매한다.
겉보기에는 하나의 “구매” 작업이다.
하지만 시스템 내부에서는 다음과 같은 단계가 실행된다.
- 주문 생성
- 결제 승인
- 포인트 차감
이 세 작업이 각각 다른 서비스에 있다면?
- Order Service
- Payment Service
- Point Service
이제 이런 상황을 생각해보자.
결제는 성공했는데
포인트 차감이 실패했다면 어떻게 해야 할까?
하나의 서비스 안에서는 단순하다
모든 작업이 하나의 데이터베이스 안에 있다면 문제는 단순하다.
BEGIN;
INSERT ORDER;
UPDATE PAYMENT;
UPDATE POINT;
COMMIT;
중간에 실패하면 ROLLBACK 하면 된다.
데이터베이스가 자동으로 이전 상태로 되돌려 준다.
서비스가 나뉘면 롤백이 불가능하다
마이크로서비스 환경에서는
각 서비스가 서로 다른 데이터베이스를 사용한다.
예를 들어:
- 결제는 이미 Payment DB에 커밋되었다.
- 주문은 이미 Order DB에 저장되었다.
이 상태에서 전체를 ROLLBACK 할 방법은 없다.
이미 커밋된 데이터는 되돌릴 수 없다.
그래서 접근 방식을 바꿔야 한다.
Saga란 무엇인가
Saga는
여러 서비스에 걸친 하나의 작업을
단계별로 실행하고
실패하면 이미 완료된 단계를 취소하는 추가 작업을 실행하는 방식
이다.
여기서 중요한 점은 이것이다.
- 전통적인 트랜잭션은 “한 번에 롤백”한다.
- Saga는 “취소하는 작업을 새로 실행”한다.
롤백과 Saga의 차이
전통적인 트랜잭션
A 실행
B 실행
C 실패
→ A, B 자동 롤백
DB가 이전 상태로 되돌린다.
Saga
A 실행 (완료)
B 실행 (완료)
C 실패
→ B 취소 실행
→ A 취소 실행
여기서
- B 취소
- A 취소
이 취소 작업이 바로 보상(Compensation)이다.
보상이란 무엇인가
보상은
이미 완료된 작업을 상쇄하기 위한 새로운 작업
이다.
중요한 점은 이것이다.
- 과거를 지우는 것이 아니다.
- 반대 의미의 작업을 추가로 실행하는 것이다.
예를 들어:
| 원래 작업 | 보상 작업 |
|---|---|
| 주문 생성 | 주문 취소 |
| 결제 승인 | 결제 취소 |
| 포인트 차감 | 포인트 복구 |
보상은 “상태를 되돌린다”기보다
“반대 효과를 만들어낸다”에 가깝다.
Saga의 두 가지 구현 방식
Saga는 구현 방식에 따라 두 가지로 나뉜다.
- Choreography
- Orchestration
1️⃣ Choreography 방식
중앙 조정자가 없다.
각 서비스가 이벤트를 주고받으며 흐름을 이어간다.
정상 흐름
sequenceDiagram
participant Order
participant Payment
participant Point
Order->>Payment: OrderCreated
Payment->>Point: PaymentCompleted
Point->>Point: 포인트 차감
포인트 차감 실패 시
sequenceDiagram
participant Order
participant Payment
participant Point
Point-->>Payment: PointDeductionFailed
Payment->>Payment: 결제 취소
Payment-->>Order: PaymentCancelled
Order->>Order: 주문 취소
각 서비스가 실패 이벤트를 받아
자신의 보상 작업을 실행한다.
특징
- 중앙 관리자가 없다
- 각 서비스가 자신의 보상 책임을 가진다
장점
- 구조가 단순하다
- 서비스 간 결합도가 낮다
- 이벤트 기반 구조와 잘 맞는다
단점
- 전체 흐름을 한눈에 보기 어렵다
- 서비스가 많아지면 이벤트 흐름이 복잡해진다
- 디버깅이 어렵다
2️⃣ Orchestration 방식
중앙에 Saga 조정자가 존재한다.
이 조정자가 전체 흐름을 관리한다.
정상 흐름
sequenceDiagram
participant Saga
participant Order
participant Payment
participant Point
Saga->>Order: 주문 생성
Saga->>Payment: 결제 요청
Saga->>Point: 포인트 차감
실패 시 보상
sequenceDiagram
participant Saga
participant Order
participant Payment
participant Point
Saga->>Order: 주문 생성
Saga->>Payment: 결제 요청
Saga->>Point: 포인트 차감
Point-->>Saga: 실패
Saga->>Payment: 결제 취소
Saga->>Order: 주문 취소
조정자가 실패를 감지하고
보상 순서를 직접 실행한다.
특징
- 중앙에서 전체 흐름을 제어한다
- 상태 관리가 명확하다
장점
- 복잡한 흐름 관리에 유리하다
- 디버깅이 쉽다
- 보상 순서를 명확히 제어할 수 있다
단점
- 중앙 컴포넌트가 복잡해질 수 있다
- 설계가 잘못되면 병목이 될 수 있다
어떤 방식을 선택해야 할까
단순한 비즈니스 흐름
- 단계가 적고
- 보상 로직이 단순하다면
→ Choreography가 적합하다.
복잡한 흐름
- 단계가 많고
- 실패 조건이 복잡하고
- 보상 순서 제어가 중요하다면
→ Orchestration이 더 적합하다.
이 장의 핵심
- 하나의 DB에서는 트랜잭션이 문제를 해결한다.
- 서비스가 분리되면 강한 롤백은 불가능하다.
- Saga는 단계별 실행 + 실패 시 취소 작업 실행 방식이다.
- 취소 작업을 보상(Compensation)이라고 한다.
- 구현 방식은 Choreography와 Orchestration 두 가지다.
- 선택은 비즈니스 복잡도에 따라 달라진다.
23장. 전환 중의 데이터 정합성 — Dual-write에서 살아남기
22장에서 우리는 Saga 패턴을 보았다. 이미 분산된 시스템에서 어떻게 정합성을 맞추는지 다뤘다.
하지만 마이크로서비스 전환 중에는 더 까다로운 문제가 있다.
모놀리스와 신규 서비스가 같은 데이터를 동시에 다룬다.
이 공존기는 보통 수개월에서 수년에 걸친다. 그 기간 내내 데이터 정합성을 어떻게 유지할 것인가?
7장에서 데이터 마이그레이션의 큰 그림을 다뤘다. 이 장은 그 안의 가장 위험한 구간에 집중한다.
공존기에 데이터가 어디에 있는가
전환 중 데이터는 보통 다음 상태에 있다.
flowchart LR
Monolith --> OldDB[(원래 DB)]
NewService --> NewDB[(새 DB)]
OldDB -.->|동기화| NewDB
- 모놀리스는 원래 DB를 본다
- 신규 서비스는 새 DB를 본다
- 두 DB 사이에 동기화가 흐른다
이 상태에서 무엇이 위험한가?
두 DB가 어긋날 수 있다. 그리고 한 번 어긋나면 발견하기 어렵다.
동기화의 두 가지 방식
7장에서 본 두 가지 동기화 방식을 다시 보자.
1️⃣ Dual-write
flowchart LR
App --> OldDB[(원래 DB)]
App --> NewDB[(새 DB)]
애플리케이션이 양쪽 DB에 동시에 쓴다.
장점:
- 즉시 반영된다
- 별도 인프라가 필요 없다
단점:
- 한쪽 실패 시 정합성이 깨진다
- 트랜잭션이 두 DB에 걸려 있다
- 코드가 복잡해진다
2️⃣ CDC (Change Data Capture)
flowchart LR
App --> OldDB[(원래 DB)]
OldDB --> CDC[CDC Pipeline]
CDC --> NewDB[(새 DB)]
DB의 변경 로그를 별도 파이프라인이 따라간다.
장점:
- 애플리케이션은 한 DB만 안다
- 비동기로 자연스럽다
단점:
- 지연이 있다 (수 ms ~ 수 초)
- 별도 파이프라인 운영 부담
어떤 것을 선택하는가
| 상황 | 권장 |
|---|---|
| 데이터의 즉시 일관성이 중요 | Dual-write |
| 약간의 지연 허용 가능 | CDC |
| 원래 DB 스키마를 못 바꾼다 | CDC |
| 한쪽 실패 시 자동 복구 원함 | CDC |
| 단순한 구조 우선 | Dual-write |
대부분의 케이스에서 CDC가 더 안전하다.
이유:
- 애플리케이션이 한 DB만 보면 되므로 코드가 단순하다
- 두 DB 트랜잭션을 묶을 필요가 없다
- 12장에서 본 Outbox 패턴과 잘 어울린다
그러나 즉시 일관성이 필요한 영역(예: 잔액, 재고)에서는 Dual-write가 더 적합할 수 있다.
Dual-write의 위험
Dual-write를 선택했다면 다음을 받아들여야 한다.
위험 1 — 한쪽이 실패한다
sequenceDiagram
participant App
participant OldDB
participant NewDB
App->>OldDB: INSERT (성공)
App->>NewDB: INSERT (실패)
결과: 원래 DB에는 있고, 새 DB에는 없다.
위험 2 — 순서가 어긋난다
두 트랜잭션이 동시에 양쪽에 쓰면 적용 순서가 달라질 수 있다.
sequenceDiagram
participant T1
participant T2
participant OldDB
participant NewDB
T1->>OldDB: UPDATE balance=100
T2->>OldDB: UPDATE balance=150
T2->>NewDB: UPDATE balance=150
T1->>NewDB: UPDATE balance=100
원래 DB: 150 (최신) 새 DB: 100 (옛 값이 덮어씌움)
위험 3 — 부분 트랜잭션
원래 DB의 한 트랜잭션이 여러 테이블을 묶고 있다면 새 DB로 분리할 때 트랜잭션이 깨질 수 있다.
Dual-write를 안전하게 만드는 방법
1️⃣ 한쪽을 진실의 원천으로 정한다
Source of Truth는 항상 한 곳이다.
전환 초기:
- 원래 DB가 진실
- 새 DB는 복제본
전환 후반:
- 새 DB가 진실
- 원래 DB는 복제본 (롤백용)
진실의 원천이 어디인지를 매 시점 명확히 한다.
2️⃣ 실패는 진실의 원천에서 막는다
진실의 원천이 원래 DB라면
- 원래 DB 쓰기가 성공해야 응답한다
- 새 DB 쓰기는 실패해도 무시 (별도 모니터링)
- 어긋남은 정기 검증으로 잡는다
이렇게 하면 두 DB 트랜잭션을 묶지 않아도 된다.
3️⃣ 새 DB 쓰기는 비동기로
sequenceDiagram
participant App
participant OldDB
participant Queue
participant Worker
participant NewDB
App->>OldDB: INSERT
App->>Queue: 이벤트 발행
Worker->>Queue: 수신
Worker->>NewDB: INSERT
이 구조는 사실상 12장의 Outbox 패턴이다.
- 애플리케이션은 원래 DB만 신뢰
- 새 DB는 큐를 통해 따라간다
- 실패 시 큐에서 재시도
정합성 검증 — 가장 자주 빠지는 부분
Dual-write든 CDC든 자동 정합성 검증 없이는 운영할 수 없다.
검증의 세 단계
1️⃣ 행 수 비교
-- 원래 DB
SELECT COUNT(*) FROM orders;
-- 새 DB
SELECT COUNT(*) FROM orders;
차이가 있으면 즉시 알람.
2️⃣ 샘플링 비교
무작위로 100개의 ID를 골라
두 DB의 해당 행을 비교한다.
* 컬럼 값이 다르면 차이 기록
* 임계치 이상이면 알람
3️⃣ 시계열 드리프트 추적
flowchart LR
Now[현재] --> Compare[비교]
OneMinAgo[1분 전] --> Compare
Compare --> Metric[일치율 메트릭]
매 분 일치율을 측정해서 시간에 따른 변화를 본다.
- 일치율이 99.9% 미만이면 알람
- 갑자기 떨어지면 더 큰 문제
드리프트가 발생했을 때
검증에서 차이가 발견되었다. 무엇을 할 것인가?
첫 단계 — 원인 파악
차이의 패턴을 본다.
- 한 특정 시점 이후에만 차이가 있다 → 그 시점에 무슨 일이?
- 특정 ID 범위에만 차이 → 특정 코드 경로 의심
- 무작위 분포 → 동기화 자체의 일시적 실패
둘째 단계 — 진실의 원천 기준으로 보정
진실의 원천이 정해져 있다면 보정은 단순하다.
- 원천에서 새 DB로 일방향 복원
- 보정 스크립트 자동화
셋째 단계 — 재발 방지
같은 종류의 드리프트가 다시 안 생기게 한다.
- 동기화 로직 점검
- 자동 검증의 빈도 증가
- 알람 임계치 조정
진짜 위험은 발견 못한 차이다
드리프트는 발견되면 고칠 수 있다.
진짜 위험은
차이가 있는 줄 모르고 신규 DB로 쓰기 전환이 일어나는 것이다.
이걸 막는 단 하나의 방법은
자동 검증을 매 시점 돌리는 것이다.
검증 없이 쓰기 전환은 도박이다.
롤백 시나리오
쓰기 전환 후에도 원래 DB의 동기화를 유지해야 한다.
flowchart LR
App -->|Primary| NewDB[(새 DB)]
NewDB -->|Sync| OldDB[(원래 DB)]
이유:
- 새 DB에 문제가 생길 수 있다
- 진실의 원천을 다시 원래 DB로 되돌려야 할 수 있다
- 동기화가 끊겨 있으면 롤백 시점에 데이터를 잃는다
이 동기화는 보통
- 쓰기 전환 후 1~3개월 유지
- 충분한 안정화 기간 후 종료
이 장의 핵심
- 공존기의 가장 큰 위험은 두 DB 사이의 정합성 깨짐이다
- Dual-write는 즉시지만 위험하고, CDC는 지연이 있지만 안전하다
- Source of Truth는 항상 한 곳이어야 한다 — 매 시점 명확히
- Outbox + 큐 + Worker 구조로 Dual-write의 위험을 줄일 수 있다
- 자동 정합성 검증은 선택이 아니라 필수다
- 발견된 드리프트는 고칠 수 있지만 발견 못한 드리프트는 못 고친다
- 쓰기 전환 후에도 일정 기간 역방향 동기화를 유지한다
24장. 동기 호출 기반 복원력
11장에서 우리는 서비스 간 통신 방식을 다루었다.
동기 호출과 비동기 이벤트는 각각 장단점이 있다.
15장부터 23장까지 이벤트 기반 구조를 깊이 있게 살펴보았다.
그렇다면 이런 질문이 자연스럽게 떠오른다.
그럼 모든 통신을 비동기로 만들면 되지 않을까?
현실은 그렇지 않다.
마이크로서비스는 모든 것을 비동기로 처리할 수 없다.
일부 흐름은 반드시 “즉시 응답”을 요구한다.
예를 들어:
- 로그인 요청 → 토큰 발급 후 즉시 응답
- 결제 승인 요청 → 승인 여부 즉시 반환
- 관리자 화면 조회 → 여러 서비스에서 데이터 모아 즉시 반환
이런 흐름은 이벤트로 나중에 처리할 수 없다.
동기 호출은 현실적으로 피할 수 없는 선택이다.
문제는 이것이다.
동기 구조에서는 장애가 빠르게 전파된다.
그래서 동기 호출에는 별도의 복원력 설계가 필요하다.
동기 호출에서 장애가 전파되는 방식
다음과 같은 구조를 생각해보자.
flowchart LR
Client --> Gateway --> Order
Order --> Payment
Payment --> PG
PG가 느려지면 어떤 일이 벌어질까?
- Payment는 응답을 기다린다.
- Order는 Payment를 기다린다.
- Gateway는 Order를 기다린다.
- Client는 응답을 기다린다.
작은 지연이 연쇄적으로 확산된다.
이것이 장애 전파(Failure Propagation) 다.
동기 구조의 문제는 단순하다.
기다림이 길어질수록 자원이 점유되고,
자원이 고갈되면 전체가 멈춘다.
동기 복원력의 목표
동기 호출 복원력의 목표는 다음과 같다.
- 무한 대기를 막는다.
- 실패를 빠르게 감지한다.
- 장애가 다른 기능으로 번지지 않게 한다.
- 전체 중단 대신 부분 기능 유지 상태를 만든다.
이를 위해 몇 가지 기본 전략이 필요하다.
1️⃣ Timeout — 기다림에 상한을 둬라
Timeout은 가장 기본이지만 가장 중요하다.
Timeout이 없으면:
- 요청이 계속 대기
- 스레드/이벤트 루프 점유
- 커넥션 풀 고갈
- 정상 요청까지 실패
느림은 결국 전체 장애로 이어진다.
동기 호출에는 반드시 적절한 Timeout이 있어야 한다.
그리고 중요한 점은:
외부 API뿐 아니라 브로커 전송 같은 외부 I/O도 동일하게 Timeout 대상이다.
다만 이벤트 기반 구조의 장애 양상은 다르므로,
비동기 구조는 다음 장에서 별도로 다룬다.
2️⃣ Retry — 조심해서 사용하라
재시도는 유용하지만 위험하다.
일시적인 네트워크 오류는 재시도로 해결될 수 있다.
하지만 상대 서비스가 이미 과부하 상태라면?
모든 호출자가 재시도를 시작하면
트래픽은 기하급수적으로 증가한다.
이를 Retry Storm라고 한다.
따라서 재시도에는 규칙이 필요하다.
- 최대 횟수 제한
- 지수 백오프(Exponential Backoff)
- 랜덤 지터(Jitter)
- 멱등한 요청만 재시도
재시도는 자동 복구 도구이지,
만능 해결책이 아니다.
3️⃣ Circuit Breaker — 고장 난 의존성은 잠시 끊어라
계속 실패하는 서비스를 계속 호출하면
내 서비스도 같이 죽는다.
Circuit Breaker는 다음과 같이 동작한다.
- 실패율이 일정 기준을 넘으면 호출 차단(Open)
- 일정 시간 후 일부 요청만 허용(Half-Open)
- 회복되면 다시 정상 상태(Closed)
이 방식은 두 가지를 보호한다.
- 장애가 난 서비스
- 호출자 서비스
Circuit Breaker는
장애 확산을 막는 차단기다.
4️⃣ Bulkhead — 자원을 구획으로 나눠라
많은 사람들이 오해하는 부분이다.
“서비스가 분리되어 있는데 왜 또 격리가 필요한가?”
Bulkhead는 서비스 격리가 아니라
자원 격리다.
한 서비스 내부에서도 자원은 공유된다.
예:
- 외부 API 호출
- 내부 DB 조회
- 캐시 접근
외부 API가 느려지면
그 호출을 처리하던 스레드와 커넥션이 점유된다.
결국 내부 기능까지 영향을 받을 수 있다.
Bulkhead 전략은:
- 외부 호출 전용 스레드 풀 분리
- 기능별 자원 풀 분리
- 중요한 기능을 별도 워커로 격리
한쪽이 폭주해도
다른 쪽이 살아남도록 만드는 것이다.
5️⃣ Fail Fast — 느린 실패는 더 위험하다
동기 구조에서 가장 위험한 것은
“조금씩 느려지다가 결국 멈추는 상황”이다.
이미 장애가 명확하다면:
- 빠르게 실패를 반환하고
- 자원을 보호하는 것이 낫다.
Fail Fast는 포기가 아니라
시스템을 살리는 전략이다.
6️⃣ Graceful Degradation — 최소 기능 유지
완전한 정상 상태를 유지하지 못하더라도
부분 기능을 유지할 수 있다.
예:
- 추천 서비스 장애 → 기본 목록 제공
- 일부 상세 정보 실패 → 캐시 데이터 제공
- 외부 통계 실패 → 이전 데이터 사용
복원력은 단순히 “살아있는 것”이 아니라
“어떻게 살아있게 할 것인가”의 문제다.
동기 복원력 설계 체크리스트
동기 호출이 있는 모든 지점에서 다음을 점검해야 한다.
- Timeout이 설정되어 있는가?
- Retry는 제한되어 있는가?
- Circuit Breaker가 있는가?
- 자원은 격리되어 있는가? (Bulkhead)
- 실패 시 빠르게 포기하는가? (Fail Fast)
- 대체 동작이 준비되어 있는가?
이 질문에 답하지 못하면
복원력은 설계되지 않은 것이다.
이 장의 핵심
- 마이크로서비스는 모든 것을 비동기로 만들 수 없다.
- 동기 호출은 현실적으로 필요하다.
- 동기 구조에서는 장애가 빠르게 전파된다.
- 복원력은 “기다림을 제한하고, 번짐을 막는 것”이다.
- Timeout, Retry, Circuit Breaker, Bulkhead, Fail Fast는 필수 전략이다.
- 완전한 정상보다 부분 동작 유지가 더 중요할 수 있다.
25장. Circuit Breaker
이런 상황을 생각해보자
Order Service가 Payment Service를 호출한다.
그런데 Payment Service에 장애가 발생했다.
- 응답이 매우 느리다
- 타임아웃이 발생한다
- 간헐적으로 실패한다
Order Service는 어떻게 될까?
- 계속 Payment를 호출한다
- 스레드가 대기한다
- 커넥션이 점점 쌓인다
- 결국 Order Service도 느려진다
이것을 장애 전파라고 한다.
하나의 서비스 장애가 다른 서비스로 퍼진다.
단순 재시도는 해결책이 아니다
장애가 발생하면 보통 재시도를 한다.
하지만 장애 상태에서 무작정 재시도하면:
- 네트워크 부하 증가
- 장애 서비스에 더 큰 압박
- 전체 시스템 지연 증가
오히려 상황이 더 나빠질 수 있다.
그래서 필요한 것이 Circuit Breaker다.
Circuit Breaker란 무엇인가
Circuit Breaker는
장애가 발생한 서비스로의 호출을 일정 시간 차단하여
장애가 시스템 전체로 확산되는 것을 막는 패턴
이다.
이 이름은 전기 차단기에서 왔다.
- 과부하가 발생하면 회로를 끊는다
- 일정 시간이 지나면 다시 연결을 시도한다
상태 이름이 헷갈리는 이유
Circuit Breaker는 “회로 상태” 기준으로 이름이 붙었다.
- Closed → 회로가 닫혀 있어서 전류가 흐른다 → 요청이 전달된다
- Open → 회로가 열려 있어서 전류가 끊긴다 → 요청이 차단된다
즉,
Closed는 정상 연결 상태
Open은 차단 상태
이다.
이 점을 먼저 이해해야 다이어그램이 자연스럽다.
Circuit Breaker의 세 가지 상태
- Closed
- Open
- Half-Open
1️⃣ Closed (정상 상태)
- 요청이 실제 서비스로 전달된다.
- 실패율을 모니터링한다.
- 정상 동작 상태다.
sequenceDiagram
participant Order
participant CB as CircuitBreaker
participant Payment
Order->>CB: 결제 요청
CB->>Payment: 요청 전달
Payment-->>CB: 응답
CB-->>Order: 응답 반환
2️⃣ Open (차단 상태)
- 일정 실패율을 넘으면 전환된다.
- 요청을 실제 서비스로 보내지 않는다.
- 즉시 실패를 반환한다.
sequenceDiagram
participant Order
participant CB as CircuitBreaker
Order->>CB: 결제 요청
CB-->>Order: 즉시 실패 반환
Payment 서비스에는 요청이 가지 않는다.
3️⃣ Half-Open (시험 상태)
- 일정 시간이 지나면 전환된다.
- 일부 요청만 실제 서비스로 전달한다.
- 성공하면 Closed로 복귀
- 실패하면 다시 Open
sequenceDiagram
participant Order
participant CB as CircuitBreaker
participant Payment
Order->>CB: 시험 요청
CB->>Payment: 일부 요청 전달
Payment-->>CB: 성공 또는 실패
상태 전이 다이어그램
stateDiagram-v2
[*] --> Closed
Closed : 요청 전달 상태
Closed --> Open : 실패율 초과
Open : 요청 차단 상태
Open --> HalfOpen : 대기 시간 경과
HalfOpen : 시험 호출 상태
HalfOpen --> Closed : 성공
HalfOpen --> Open : 실패
이제 상태와 설명이 정확히 매칭된다.
Circuit Breaker가 필요한 이유
- 장애 확산 방지
- 리소스 보호 (스레드, 커넥션)
- 빠른 실패 반환
- 시스템 안정성 확보
Circuit Breaker는 장애를 해결하지 않는다.
장애가 더 커지지 않도록 격리하는 장치다.
실무에서 고려해야 할 요소
1️⃣ 실패 기준
- 연속 실패 횟수?
- 일정 시간 내 실패율 %?
- 타임아웃도 실패로 볼 것인가?
2️⃣ Open 상태 유지 시간
- 너무 짧으면 계속 흔들린다.
- 너무 길면 복구가 늦어진다.
3️⃣ Fallback 전략
Open 상태에서 무엇을 반환할 것인가?
- 기본값
- 캐시 데이터
- 사용자에게 오류 메시지
- 비동기 처리 전환
Circuit Breaker는 차단만 하는 것이 아니라
대체 전략과 함께 설계해야 한다.
이 장의 핵심
- 분산 시스템에서는 장애가 전파된다.
- 무작정 재시도는 위험하다.
- Circuit Breaker는 일정 조건에서 호출을 차단한다.
- Closed는 정상 연결 상태다.
- Open은 차단 상태다.
- Half-Open은 복구 시험 상태다.
- 목적은 장애 확산 방지다.
26장. 비동기 시스템의 장애와 복원 전략
동기 호출에서는 장애가 즉시 드러난다.
느려지면 바로 대기하고, 바로 전파된다.
비동기 구조는 다르다.
flowchart LR
A --> Broker --> B
A는 이벤트를 발행하고 응답을 끝낸다.
B가 느려도 A는 모른다.
그래서 비동기는 안정적으로 보인다.
하지만 문제는 여기 있다.
비동기 시스템의 장애는 “조용히 쌓이다가 터진다.”
이 장에서는 그 구조와 대응 전략을 살펴본다.
비동기 시스템에서 발생하는 장애 유형
1️⃣ 메시지 적체 (Backlog)
Consumer가 느려지면:
- 메시지가 큐에 쌓이고
- 지연이 증가하고
- Eventually 처리 시간이 SLA를 초과한다
이건 즉시 장애가 아니라
지연 축적형 장애다.
2️⃣ 소비자 폭주
적체가 발생하면
운영자는 Consumer를 늘린다.
하지만:
- DB가 병목이면?
- 외부 API가 병목이면?
- Lock 경쟁이 심하면?
Consumer 수만 늘려도
처리량이 늘지 않는다.
오히려 DB 과부하로 이어질 수 있다.
3️⃣ 재시도 폭발
메시지 처리 실패 → 재시도
일시적 오류라면 괜찮다.
하지만 구조적 오류라면?
- 동일 메시지 반복 실패
- 재시도 루프
- 브로커 트래픽 증가
- DLQ 증가
이건 동기 구조의 Retry Storm과 유사하지만
더 은밀하게 진행된다.
4️⃣ 브로커 장애
브로커 자체가:
- 과부하
- 디스크 부족
- 네트워크 문제
- 파티션 리밸런싱
을 겪으면:
- 발행 지연
- 소비 지연
- 순서 흔들림
- 재처리 증가
이건 시스템 전체의 비동기 흐름을 흔든다.
비동기 복원력 전략
1️⃣ Backpressure — 무한히 받아들이지 말라
Consumer가 처리 속도보다
Producer가 더 빠르면 적체가 생긴다.
Backpressure 전략은:
- 처리 가능한 속도만큼만 수용
- 버퍼 크기 제한
- 소비자 처리량 기준으로 확장
Reactive 시스템에서 중요한 개념이다.
무한 큐는 복원력이 아니다.
2️⃣ DLQ (Dead Letter Queue)
처리 실패 메시지를
메인 스트림에서 분리한다.
장점:
- 전체 흐름 보호
- 반복 실패 메시지 격리
- 운영 분석 가능
중요한 것은:
DLQ는 “쓰레기통”이 아니라 “진단 공간”이어야 한다.
3️⃣ 재시도 전략 설계
비동기에서도 재시도는 필요하다.
하지만:
- 무한 재시도 금지
- 지수 백오프 적용
- 실패 유형 분리
- 치명적 오류는 즉시 DLQ
재시도는 복구 수단이지
지연 축적 도구가 되어선 안 된다.
4️⃣ 멱등성은 필수
비동기 시스템은:
- 중복
- 재처리
- 리플레이
가 기본 전제다.
따라서:
- 이벤트 ID 기반 처리
- 상태 기반 검증
- 중복 방지 테이블
등이 필요하다.
멱등성 없이는
복원력도 없다.
5️⃣ 소비자 확장 전략
Consumer 수를 늘리는 것은
무조건 정답이 아니다.
확장 전 확인해야 할 것:
- 병목은 어디인가?
- DB IOPS는 충분한가?
- 외부 API Rate Limit은?
- Lock 경합은 없는가?
비동기 확장은
시스템 전체를 고려해야 한다.
6️⃣ 모니터링 지표
비동기 시스템은 눈에 잘 보이지 않는다.
따라서 반드시 모니터링해야 한다.
- Consumer Lag
- 큐 길이
- 처리 시간
- 실패율
- DLQ 증가율
비동기 복원력은
관측 없이는 존재하지 않는다.
동기 vs 비동기 장애의 차이
| 구분 | 동기 | 비동기 |
|---|---|---|
| 장애 노출 | 즉시 | 지연 |
| 전파 속도 | 빠름 | 느림 |
| 위험 유형 | 스레드/커넥션 고갈 | 메시지 적체 |
| 대응 전략 | Timeout, Circuit Breaker | Backpressure, DLQ |
동기는 “즉시 붕괴” 위험이 있고
비동기는 “조용한 축적 후 폭발” 위험이 있다.
설계 시 스스로에게 물어야 할 질문
- Consumer Lag이 10배 증가하면 어떻게 되는가?
- DLQ가 급증하면 누가 보고 있는가?
- 재시도 정책은 설계되어 있는가?
- 메시지 적체가 SLA를 넘으면 어떻게 대응하는가?
- 브로커 장애 시 시스템은 어떻게 동작하는가?
이 장의 핵심
- 비동기 구조는 즉시 장애 전파를 줄인다.
- 하지만 메시지 적체와 재시도 폭발이라는 위험을 가진다.
- Backpressure는 필수 전략이다.
- DLQ는 격리 공간이다.
- 멱등성은 전제 조건이다.
- 모니터링 없이는 복원력도 없다.
27장. 관측 가능성 — Trace · Metric · Log
마이크로서비스에서는 하나의 요청이 한 서비스 안에서 끝나지 않는다.
예를 들어 사용자가 결제 버튼을 누르면 다음과 같은 흐름이 발생한다.
Client → API Gateway → Order Service → Payment Service → PG사
이 요청이 실패했을 때 우리는 이런 질문을 던지게 된다.
- Gateway 문제인가?
- Order 로직 문제인가?
- Payment 처리 문제인가?
- 외부 PG사 문제인가?
모놀리스였다면 하나의 로그 파일만 보면 되었겠지만,
마이크로서비스에서는 서비스마다 로그가 흩어져 있다.
그래서 관측 가능성이 필요하다.
관측 가능성은 단순히 “로그를 남기는 것”이 아니라,
요청의 흐름을 추적하고,
병목을 찾고,
정확한 원인을 식별할 수 있도록 설계하는 것
이다.
관측 가능성의 3가지 도구
관측 가능성은 세 가지 도구로 구성된다.
- 메트릭 (Metric)
- 트레이스 (Trace)
- 로그 (Log)
이 세 가지는 각각 다른 질문에 답한다.
| 도구 | 질문 | 역할 |
|---|---|---|
| 메트릭 | 문제가 있는가? | 이상 감지 |
| 트레이스 | 어디가 문제인가? | 병목 분석 |
| 로그 | 정확히 무슨 일이 있었는가? | 원인 분석 |
이 순서는 실제 장애 대응 순서이기도 하다.
1️⃣ 메트릭 — 시스템이 건강한지 보는 도구
메트릭은 시스템 상태를 숫자로 표현한다.
대표적인 메트릭:
- 요청 수 (RPS)
- 평균 응답 시간
- p95 / p99 지연 시간
- 에러율
- CPU / 메모리
- Consumer Lag
- DLQ 증가율
메트릭은 빠르게 이상을 감지한다.
예:
- 평소 결제 에러율 0.5%
- 오늘 4%로 상승
→ “문제가 있다”는 신호
하지만 메트릭은 원인을 알려주지 않는다.
메트릭은 경보 장치다.
원인 분석 도구는 아니다.
2️⃣ 트레이스 — 요청이 어디를 거쳤는지 보여준다
트레이스는 하나의 요청이 여러 서비스를 거치는 흐름을 보여준다.
트레이스는 두 개념으로 구성된다.
- Trace ID: 요청 전체를 대표하는 ID
- Span ID: 각 구간(서비스 호출)마다 부여되는 ID
하나의 요청은 하나의 Trace ID를 가진다.
각 서비스 구간은 별도의 Span으로 기록된다.
예:
| Trace ID | Span | 서비스 | 소요 시간 | 부모 |
|---|---|---|---|---|
| abc123 | span-1 | Gateway | 20ms | 없음 |
| abc123 | span-2 | Order | 40ms | span-1 |
| abc123 | span-3 | Payment | 2,900ms | span-2 |
| abc123 | span-4 | PG Call | 2,850ms | span-3 |
이를 시각화하면:
Trace ID: abc123
├─ Gateway 20ms
├─ Order 40ms
└─ Payment 2,900ms
└─ PG Call 2,850ms ← 병목
이걸 보면 바로 알 수 있다.
- 병목은 Payment
- 그 안에서도 PG Call이 대부분 차지
트레이스는 다음을 가능하게 한다.
- 구간별 소요 시간 분석
- 부모-자식 호출 관계 파악
- 병목 지점 식별
즉,
트레이스는 “구조와 시간”을 보여준다.
3️⃣ 로그와 Correlation ID — 요청을 묶는 방법
문제: 로그가 흩어져 있다
각 서비스는 각자 로그를 남긴다.
요청이 동시에 수천 개 들어오면 로그는 이렇게 섞인다.
[Order] 주문 생성 성공
[Payment] PG 호출 시작
[Order] 주문 생성 성공
[Payment] Timeout 발생
어떤 주문의 결제인지 알 수 없다.
해결: Correlation ID
Correlation ID는
하나의 요청에 붙는 사건 번호
다.
작동 방식은 단순하다.
- 외부 요청이 들어오면 ID를 하나 생성한다.
- 이 ID를 다음 서비스로 전달한다.
- 모든 서비스는 로그에 이 ID를 함께 기록한다.
- 문제가 생기면 이 ID로 전체 검색한다.
예:
[Gateway] CID=3f9c2a81 요청 시작
[Order] CID=3f9c2a81 주문 생성 성공
[Payment] CID=3f9c2a81 PG 호출
[Payment] CID=3f9c2a81 Timeout 발생
CID=3f9c2a81로 검색하면 이 요청의 전체 로그를 모을 수 있다.
중요한 점
Correlation ID는 묶기 전용이다.
- 이 로그들이 같은 요청에 속한다는 사실만 알려준다.
- 구간별 소요 시간이나 호출 관계는 보여주지 않는다.
Correlation ID vs Trace ID
Trace ID는 요청 전체를 대표한다.
Span ID는 각 구간을 나타낸다.
요즘은 Trace ID를 로그에도 같이 찍어서
Correlation ID 역할을 겸하는 경우가 많다.
즉,
Trace ID 하나로 로그 검색 + 트레이스 분석을 모두 처리하는 것이 일반적인 구조다.
비동기 이벤트에서의 ID 전파
비동기 구간에서는 Header가 없으므로
이벤트 본문에 traceId를 넣어야 한다.
{
"event": "OrderCreated",
"orderId": "123",
"traceId": "abc123"
}
이걸 하지 않으면 비동기 구간에서 추적이 끊긴다.
누가 ID를 만드는가?
원칙은 단순하다.
요청의 가장 바깥 경계가 생성한다.
보통은 API Gateway가 생성한다.
- 외부 요청 수신
- Trace ID 생성
- HTTP Header에 포함
- 내부 호출 시 자동 전달
중요한 점:
대부분은 개발자가 직접 구현하지 않는다.
OpenTelemetry 같은 라이브러리가:
- ID 생성
- Header 전파
- Span 생성
- 로그 필드 자동 삽입
을 처리한다.
개발자는:
- 로그 포맷에 traceId 포함
- 이벤트에 traceId 전달
정도만 신경 쓰면 된다.
실제 장애 대응 흐름
현실에서는 다음과 같이 진행된다.
1️⃣ 메트릭 알람 발생
- 결제 에러율 급증
2️⃣ 트레이스 확인
- 특정 구간에서 지연 발생
- Payment → PG 구간에서 병목
3️⃣ 로그 검색 (중앙 로그 시스템)
로그는 보통 중앙으로 수집된다.
예:
- ELK (Elasticsearch + Logstash + Kibana)
- OpenSearch
- CloudWatch Logs
운영자는 Kibana에서:
traceId: abc123
검색한다.
모든 서비스 로그가 한 번에 나온다.
서비스마다 SSH 접속해서 검색하지 않는다.
관측 가능성은 설계의 일부다
관측 가능성은 나중에 붙이는 기능이 아니다.
설계 단계에서 결정해야 한다.
- ID는 어디서 생성하는가?
- 동기/비동기 모두 전파되는가?
- 로그는 구조화되어 있는가?
- SLO 기준은 정의되어 있는가?
- Lag은 모니터링되는가?
관측 가능성이 없으면:
- 장애는 늦게 발견된다
- 원인 분석은 추측이 된다
- 복구 시간은 길어진다
관측 가능성이 있으면:
- 문제를 빠르게 감지하고
- 병목을 정확히 찾고
- 로그로 원인을 확정할 수 있다
이 장의 핵심
- 메트릭은 이상 감지 도구다.
- 트레이스는 병목 분석 도구다.
- 로그는 원인 분석 도구다.
- Trace ID는 분산 요청을 연결하는 핵심이다.
- ID 생성과 전파는 대부분 라이브러리가 처리한다.
- 로그는 ELK 같은 중앙 시스템에서 검색한다.
- 관측 가능성은 복원력의 기반이다.
28장. JOIN이 사라진 세계 — 분산 환경에서의 조회
우리는 계속 시스템을 분리해왔다.
서비스가 나뉘고, 데이터베이스도 함께 나뉘었다.
이 변화는 하나의 중요한 결과를 만든다.
더 이상 JOIN을 사용할 수 없다
모놀리스에서는 문제가 아니었다
모놀리스에서는 조회가 단순했다.
하나의 DB 안에 모든 데이터가 있었기 때문이다.
SELECT *
FROM order o
JOIN user u ON o.user_id = u.id
WHERE u.age > 20;
JOIN으로 필요한 데이터를 한 번에 가져오면 끝이었다.
마이크로서비스에서는 달라진다
서비스가 분리되면 데이터도 분리된다.
- Order DB
- User DB
- Payment DB
이 상태에서는 다른 서비스의 DB를 직접 조회할 수 없다.
즉, JOIN이 사라진다.
문제는 조회에서 발생한다
쓰기(Write)는 비교적 단순하다.
각 서비스가 자신의 데이터를 처리하면 된다.
하지만 조회(Query)는 다르다.
화면은 여러 서비스의 데이터를 동시에 요구한다.
- 주문 + 사용자
- 주문 + 결제
- 통계 + 상세
이 시점에서 조회는 쿼리가 아니라 설계 문제가 된다.
분산 시스템에서는 조회도 설계해야 한다
단순한 해결은 통하지 않는다
처음에는 이렇게 생각하기 쉽다.
Order 조회 → User API 호출 → 데이터 조합
하지만 이 방식은 문제가 생긴다.
- 호출 횟수 증가 (N+1)
- 응답 속도 저하
- 장애 전파
결국 규모가 커지면 버틸 수 없다.
해결 전략들
1️⃣ API Composition
여러 서비스를 호출해서 데이터를 조합한다.
장점:
- 구현이 단순
- 실시간 데이터
단점:
- 성능 문제
- 장애 전파
2️⃣ 데이터 복제 (Denormalization)
다른 서비스 데이터를 일부 복제한다.
예:
- user_name
- user_age
장점:
- 조회 빠름
- JOIN 불필요
단점:
- 데이터 정합성
- 동기화 필요
비정규화는 선택이 아니라 전략이다
3️⃣ 이벤트 기반 조회 모델
이벤트를 받아 조회 전용 데이터를 만든다.
User 이벤트 → Order 조회 데이터 갱신
장점:
- 복잡한 조회 가능
- 성능 좋음
단점:
- 지연 발생
- 이벤트 처리 필요
→ CQRS Read 모델과 연결된다
Write는 정규화, Read는 비정규화
4️⃣ 검색/인덱스 시스템
데이터를 검색 시스템에 저장한다.
예:
- Elasticsearch
장점:
- 강력한 필터링
- 빠른 조회
단점:
- 별도 인프라
- 동기화 필요
어떤 전략을 선택할까
정답은 하나가 아니다.
상황에 따라 선택한다.
- 단순 조회 → API Composition
- 빠른 조회 → 데이터 복제
- 복잡 조회 → CQRS
- 검색 중심 → 검색 시스템
중요한 오해들
- API 호출로 JOIN 대체 가능 → ❌
- 데이터 복제는 나쁜 설계 → ❌
- 조회는 항상 최신이어야 한다 → ❌
이 장의 핵심
- JOIN은 사라진다
- 조회는 설계 문제다
- API 방식은 한계가 있다
- 비정규화는 필수 전략이다
- CQRS는 조회 문제 해결 구조다
- 상황에 맞게 선택해야 한다
29장. 읽기와 쓰기의 모델을 분리하다 — CQRS
우리는 왜 또 분리하려 하는가
우리는 계속해서 분리해왔다.
- 모놀리스에서 서비스로 분리했다.
- 서비스별로 데이터베이스를 분리했다.
- 동기 호출을 이벤트 기반 비동기로 전환했다.
- 강한 일관성을 포기하고 최종 일관성을 받아들였다.
그런데도 시스템은 여전히 복잡하다.
특히 다음과 같은 상황에서 문제가 드러난다.
- 조회 트래픽이 쓰기보다 압도적으로 많다.
- 복잡한 검색·정렬·집계 쿼리가 존재한다.
- 조회 로직이 애플리케이션 CPU를 점유한다.
- 조회 최적화를 위한 인덱스가 쓰기 성능을 떨어뜨린다.
읽기와 쓰기는 목적이 다르다.
그렇다면 하나의 모델로
두 가지 상반된 요구를 동시에 만족시켜야 할 이유가 있을까?
이 질문에서 CQRS가 시작된다.
Command와 Query는 무엇인가
먼저 용어를 간단히 정리하자.
-
Command는 시스템의 상태를 변경하는 요청이다.
예: 주문 생성, 회원 가입, 결제 승인 -
Query는 시스템의 상태를 조회하는 요청이다.
예: 주문 목록 조회, 회원 정보 조회, 통계 조회
구분 기준은 단 하나다.
시스템의 상태를 변경하는가, 그렇지 않은가.
CQRS는 이 구분을 구조 차원으로 끌어올린다.
CQRS란 무엇인가
CQRS(Command Query Responsibility Segregation)는
상태를 변경하는 모델과
상태를 조회하는 모델을 분리하는 패턴이다.
여기서 핵심은 코드 정리가 아니다.
핵심은 데이터 모델의 분리다.
- Write 모델은 비즈니스 규칙과 트랜잭션 무결성에 집중한다.
- Read 모델은 조회 성능과 응답 형태에 집중한다.
두 모델은 목적이 다르기 때문에
데이터 구조도 달라질 수 있다.
CQRS의 최종 구조
이 문서에서 말하는 CQRS는 다음과 같은 형태를 의미한다.
flowchart LR
Client -->|Command 요청| CommandAPI
CommandAPI --> WriteModel
WriteModel --> WriteDB
WriteModel -->|Domain Event 발행| EventBus
EventBus --> ReadModel
ReadModel --> ReadDB
Client -->|Query 요청| QueryAPI
QueryAPI --> ReadModel
이 구조의 본질은 다음과 같다.
- Write DB와 Read DB는 서로 다른 목적을 가진다.
- Read DB는 조회에 맞게 설계된 전용 스키마를 가질 수 있다.
- 두 모델은 이벤트를 통해 동기화된다.
- 즉시 일관성이 아니라 최종 일관성을 전제로 한다.
Write 모델과 Read 모델의 역할 차이
Write 모델
- 트랜잭션 중심
- 정규화된 도메인 모델 유지
- 비즈니스 규칙 강제
- 상태 변경이 목적
Read 모델
- 조회 전용
- 비정규화 가능
- 집계 컬럼 사전 계산 가능
- 화면 요구사항 중심 설계
Write 모델을 조회에 맞게 왜곡할 필요가 없고,
Read 모델을 무결성 때문에 복잡하게 만들 필요도 없다.
이 분리가 CQRS의 핵심이다.
이벤트 기반 동기화와 Projection
Write 모델에서 상태가 변경되면 도메인 이벤트가 발행된다.
예:
- OrderCreated
- OrderCancelled
- PaymentCompleted
Read 모델은 이 이벤트를 구독해 조회 모델을 갱신한다.
이때 이벤트를 기반으로 조회 전용 데이터를 생성·갱신하는 과정을 Projection이라 한다.
Projection은 단순한 복제가 아니다.
조회 화면에 맞게 데이터를 가공하고,
필요한 집계를 미리 계산해 저장하는 과정이다.
sequenceDiagram
participant C as Client
participant W as WriteModel
participant E as EventBus
participant R as ReadModel
C->>W: 주문 생성
W->>E: OrderCreated 발행
E->>R: 이벤트 전달
R->>R: 조회 모델 갱신 (Projection)
이 구조는 즉시 일관성을 보장하지 않는다.
Write 직후 Query가 최신 상태를 보장하지 않을 수 있다.
CQRS는 이를 받아들이는 구조다.
CQRS를 이해할 때 혼동하기 쉬운 지점
CQRS를 처음 접하면 다음과 같은 오해가 생길 수 있다.
- Command와 Query 클래스를 나누면 CQRS인가?
- Primary/Replica 구조면 CQRS인가?
이 구조들은 일부 유사해 보이지만
이 문서에서 정의하는 CQRS의 핵심은 아니다.
- 단순한 코드 분리는 모델 분리가 아니다.
- Primary/Replica는 읽기 트래픽 분산 전략이다.
- 같은 스키마를 공유하면 읽기 모델을 자유롭게 설계하기 어렵다.
이 문서에서 말하는 CQRS는
Read 모델이 Write 모델과 다른 데이터 구조를 가지는 상태
를 의미한다.
독립 확장은 선택의 문제다
CQRS 구조에서는 필요하다면
Command 영역과 Query 영역을 물리적으로 분리할 수 있다.
flowchart TB
subgraph Command 영역
CommandAPI
WriteModel
WriteDB
end
subgraph Query 영역
QueryAPI
ReadModel
ReadDB
end
WriteModel -->|Event| EventBus
EventBus --> ReadModel
이 경우 다음과 같은 효과가 생긴다.
- 애플리케이션 CPU 분리
- Query 서비스만 수평 확장 가능
- 조회 트래픽 증가 시 Write 영역 보호
그러나 이것은 CQRS의 필수 조건이 아니다.
서비스 분리는 다음과 같은 비용을 동반한다.
- 네트워크 호출 증가
- 배포 단위 분리
- 장애 처리 복잡성 증가
- 운영 및 모니터링 분리
따라서 물리적 분리는
조회 트래픽이 압도적으로 크거나,
Write 영역을 강하게 보호해야 할 때 선택하는 전략이다.
모델 분리와 서비스 분리는 동일하지 않다.
CQRS의 비용
CQRS는 성능을 얻는 대신 복잡성을 수용하는 구조다.
최종 일관성
Write 직후 Query 결과가 최신이 아닐 수 있다. 사용자 경험 설계가 필요하다.
이벤트 처리 복잡성
- 이벤트 중복
- 순서 보장
- 멱등성 설계
- 재처리 전략
이전 장에서 다룬 이벤트 기반 구조의 복잡성이 그대로 이어진다.
Projection 재구성
Read DB가 손상되면
이벤트 로그를 기반으로 다시 구성해야 한다.
이 지점에서 다음 장의 주제와 연결된다.
이 장의 핵심
우리는 계속해서 분리해왔다.
- 서비스 분리
- 데이터 분리
- 동기/비동기 분리
그리고 이제
읽기 모델과 쓰기 모델을 분리했다.
CQRS는 단순한 성능 최적화 기법이 아니다.
복잡성을 통제하기 위한 구조적 선택이다.
다음 장에서는
상태 대신 이벤트를 저장하는 방식,
Event Sourcing을 다룬다.
CQRS와 Event Sourcing이 만나면
데이터를 바라보는 방식 자체가 달라진다.
30장. 상태 대신 이벤트를 저장하다 — Event Sourcing
우리는 무엇을 저장해왔는가
포인트 시스템을 생각해보자.
일반적인 시스템에서는 이렇게 저장한다.
user_id = 10
point_balance = 12,000
포인트가 적립되거나 차감되면
잔액을 업데이트하고,
별도의 로그 테이블에 기록을 남긴다.
즉,
- 현재 잔액이 기준이다.
- 로그는 참고 자료다.
이 방식은 단순하고 이해하기 쉽다.
하지만 질문이 하나 생긴다.
- 특정 시점의 잔액은 어떻게 알 수 있을까?
- 잔액이 잘못 계산되었다면 무엇을 기준으로 복구할까?
- 모든 변경 과정을 완전히 신뢰할 수 있을까?
우리는 “결과”만 저장하고 있다.
Event Sourcing은 여기서 출발한다.
Event Sourcing이란 무엇인가
Event Sourcing은
상태를 저장하는 대신
상태를 만들어낸 이벤트를 저장하는 방식이다.
포인트 예시로 비교해보자.
상태 저장 방식
balance = 12,000
이벤트 저장 방식
PointEarned(10000)
PointSpent(3000)
PointEarned(5000)
현재 잔액은 저장된 값이 아니라
이 이벤트들을 순서대로 적용한 계산 결과다.
로그 방식과 무엇이 다른가
여기서 이런 의문이 생긴다.
로그도 남기고 있으면
결국 계산 가능하지 않나?
맞다. 계산은 가능하다.
하지만 차이는 “무엇이 진짜 데이터인가”에 있다.
로그 + 상태 방식
flowchart LR
Client --> Service
Service --> BalanceTable[(balance)]
Service --> LogTable[(log)]
- 진짜 값은
balance - 로그는 보조 자료
- 상태가 기준(Source of Truth)
로그는 상태의 부산물이다.
Event Sourcing 방식
flowchart LR
Client --> CommandHandler
CommandHandler --> EventStore[(Event Store)]
EventStore --> StateRebuilder
StateRebuilder --> CurrentBalance
- 진짜 값은 이벤트
- 상태는 계산 결과
- 이벤트가 기준(Source of Truth)
여기서 철학이 완전히 바뀐다.
로그는 결과를 기록한다
이벤트는 결과를 만들어낸 원인이다
Replay: 과거를 다시 적용하다
Event Sourcing의 핵심 개념은 Replay다.
Replay는
저장된 이벤트를 처음부터 다시 적용해
현재 상태를 재구성하는 과정이다.
sequenceDiagram
participant ES as EventStore
participant Agg as PointAggregate
ES->>Agg: PointEarned(10000)
Agg->>Agg: balance=10000
ES->>Agg: PointSpent(3000)
Agg->>Agg: balance=7000
ES->>Agg: PointEarned(5000)
Agg->>Agg: balance=12000
Replay가 가능하다는 것은 다음을 의미한다.
- 언제든지 상태를 재계산할 수 있다.
- 특정 시점의 상태를 복원할 수 있다.
- 계산 로직이 바뀌어도 다시 적용할 수 있다.
Snapshot이 필요한 이유
이벤트가 많아지면 문제가 생긴다.
예를 들어 한 사용자가 수년간 활동했다면
이벤트가 수만 개가 될 수 있다.
매번 처음부터 Replay하는 것은 비효율적이다.
그래서 Snapshot을 사용한다.
Snapshot은
특정 시점의 계산 결과를 저장해 두는 것
flowchart LR
EventStore --> Snapshot[(balance=9500)]
Snapshot --> ApplyNewEvents
ApplyNewEvents --> CurrentBalance
Replay는 Snapshot 이후 이벤트만 적용하면 된다.
Event Sourcing의 장점
완전한 변경 이력 보존
포인트 도메인에서는 매우 중요하다.
- 부정 사용 추적
- 감사 대응
- 분쟁 해결
시점 기반 계산 가능
“2024년 1월 1일 기준 잔액”을 계산할 수 있다.
읽기 모델 재생성 가능
이벤트는 변하지 않는다.
계산 방식이나 조회 구조가 바뀌어도
이벤트를 다시 적용하면 된다.
Event Sourcing의 비용
설계 난이도 증가
- 이벤트는 불변이어야 한다.
- 이벤트 버전 관리가 필요하다.
- 도메인 모델 설계가 중요해진다.
저장 공간 증가
이벤트는 계속 누적된다.
사고방식의 전환
상태 중심 사고에서
이벤트 중심 사고로 전환해야 한다.
그렇다면 CQRS와는 무슨 관계인가
Event Sourcing은 CQRS의 필수 요소는 아니다.
서로 독립적인 개념이다.
그러나 이벤트만 저장하는 구조에서는
이벤트를 그대로 조회하기 어렵다.
이벤트는 “쓰기 기록”이기 때문이다.
따라서 실제로는
- 이벤트를 저장하고
- 그 이벤트를 기반으로 조회 전용 모델을 만들게 된다.
이 순간 자연스럽게 읽기 모델과 쓰기 모델이 분리된다.
즉,
Event Sourcing을 적용하면
결과적으로 CQRS 구조와 잘 어울리게 된다.
하지만 둘은 동일한 개념이 아니다.
- CQRS는 모델 분리 전략
- Event Sourcing은 저장 전략
언제 Event Sourcing을 고려해야 하는가
- 금전/포인트/정산 도메인
- 변경 이력이 매우 중요한 시스템
- 특정 시점 복원이 필요한 경우
- 복잡한 비즈니스 규칙이 존재하는 경우
단순 CRUD 시스템에는 과하다.
이 장의 핵심
전통적인 시스템은 결과를 저장한다.
Event Sourcing은
그 결과를 만들어낸 과정을 저장한다.
현재 상태는 저장된 값이 아니라
이벤트의 누적 결과일 뿐이다.
이제 질문이 남는다.
이벤트는 수정할 수 없다면
잘못된 차감은 어떻게 되돌릴 것인가?
다음 장에서는
보상 이벤트와 이벤트 불변성에 대해 다룬다.
31장. Event Sourcing의 불변성과 보상 전략
이벤트는 왜 수정하면 안 되는가
Event Sourcing에서는
이벤트가 시스템의 기준 데이터(Source of Truth)다.
예를 들어 이런 기록이 있다고 하자.
PointEarned(10000)
PointSpent(3000)
이 기록은 “일어난 사실”이다.
- 10,000 포인트가 적립되었고
- 3,000 포인트가 사용되었다
이것은 과거의 사실이다.
Event Sourcing에서는
이미 발생한 사실을 나중에 수정하지 않는다.
이 원칙을 불변성(Immutable) 이라고 한다.
그런데 잘못 차감되었다면?
문제 상황을 보자.
PointEarned(10000)
PointSpent(3000)
그런데 알고 보니
3,000 포인트가 잘못 차감되었다.
상태 기반 시스템이라면 이렇게 할 수 있다.
balance = balance + 3000
하지만 Event Sourcing에서는
기존 이벤트를 수정하지 않는다.
대신 새로운 이벤트를 추가한다.
PointEarned(10000)
PointSpent(3000)
PointRefunded(3000)
과거를 지우지 않고
새로운 사실을 기록한다.
이것이 보상 이벤트(Compensating Event) 다.
왜 이렇게까지 해야 하는가
이 방식에는 중요한 의미가 있다.
- 모든 변경 이력이 남는다.
- 무엇이 잘못되었는지 추적 가능하다.
- 감사(Audit)와 분쟁 대응이 가능하다.
이벤트는 로그가 아니라
시스템의 공식 기록이기 때문이다.
이벤트 버전 관리란 무엇인가
여기서 또 하나의 질문이 생긴다.
이벤트 구조가 바뀌면 어떻게 하나?
예를 들어 처음에는 이벤트가 이랬다고 하자.
{
"type": "PointEarned",
"amount": 10000
}
나중에 정책이 바뀌어
적립 사유(reason)를 추가해야 한다.
{
"type": "PointEarned",
"amount": 10000,
"reason": "promotion"
}
여기서 중요한 점은:
테이블을 두 개 만들지 않는다.
DDL을 매번 변경하지 않는다.
보통 Event Store는 이렇게 생긴다.
event_store
---------------------------------
id
aggregate_id
event_type
event_version
payload (JSON)
created_at
payload는 JSON이다.
구조가 바뀌면:
- 기존 이벤트는 version = 1
- 새 이벤트는 version = 2
로 저장한다.
테이블은 그대로 유지한다.
이벤트 삭제는 가능한가
원칙적으로는 삭제하지 않는다.
왜냐하면:
- 삭제는 과거를 없애는 행위이기 때문이다.
- Replay 시 상태가 달라질 수 있다.
다만 현실에서는 다음과 같은 경우가 있다.
- 법적 삭제 요구(GDPR 등)
- 민감 정보 제거 필요
이 경우에는:
- 암호화
- 마스킹
- 이벤트 재작성(Migration)
같은 전략이 필요하다.
Event Sourcing은 단순한 기술 패턴이 아니라
운영 전략까지 포함한다.
순수 구조와 현실적인 구조
순수 Event Sourcing
- balance 컬럼 없음
- 항상 이벤트 기반 계산
- Snapshot 사용
현실적 혼합 구조
- 이벤트는 저장
- balance 테이블도 유지
- 이벤트 INSERT + balance UPDATE를 한 트랜잭션으로 처리
실무에서는 혼합 구조를 많이 사용한다.
이 장의 핵심
Event Sourcing에서 중요한 원칙은 세 가지다.
- 이벤트는 불변이다.
- 잘못된 처리는 보상 이벤트로 해결한다.
- 이벤트 버전 관리는 DDL 변경이 아니라 메시지 진화다.
32장. 동시성과 일관성
동시에 차감 요청이 오면 어떻게 할 것인가
사용자의 포인트가 5,000이라고 하자.
두 개의 요청이 동시에 들어온다.
요청 A: -4,000
요청 B: -4,000
둘 다 “잔액 충분”이라고 판단하면
최종 잔액은 -3,000이 된다.
이 문제는 어떤 구조를 쓰든 반드시 해결해야 한다.
핵심 질문은 이것이다.
동시에 상태를 바꾸려고 할 때
어떻게 안전하게 처리할 것인가?
상태 기반 시스템에서의 해결 방법
전통적인 RDB 구조에서는 보통 이렇게 한다.
SELECT balance FROM user_balance
WHERE user_id = 10
FOR UPDATE;
- 해당 행을 잠근다.
- 한 요청이 끝날 때까지 다른 요청은 기다린다.
- 순서대로 처리된다.
이 방법은 단순하지만
트래픽이 많아지면 대기 시간이 늘어날 수 있다.
Event Sourcing에서는 무엇이 달라지는가
Event Sourcing에서는
잔액이 직접 저장되지 않는다.
잔액은 이벤트를 모두 더해서 계산한 결과다.
그래서 문제는 이렇게 바뀐다.
같은 사용자에 대해
동시에 이벤트를 추가하려 하면 어떻게 막을 것인가?
여기서 사용하는 방식이 낙관적 락이다.
version을 이용한 동시성 제어
Event Store는 보통 이런 구조를 가진다.
event_store
-----------------------------------------
id (PK)
aggregate_id
version
event_type
payload (JSON)
created_at
그리고 반드시 다음 제약을 둔다.
UNIQUE (aggregate_id, version)
이 제약이 동시성의 핵심이다.
실제 동작 방식
현재 사용자의 마지막 version이 5라고 하자.
두 요청이 동시에 들어온다.
- 둘 다 version=5를 읽는다.
- 둘 다 다음 version=6으로 계산한다.
- 둘 다 version=6으로 INSERT 시도한다.
INSERT INTO event_store
(aggregate_id, version, event_type, payload)
VALUES
(10, 6, 'PointSpent', '{...}');
- 먼저 실행된 요청은 성공
- 두 번째 요청은 UNIQUE 제약 위반으로 실패
이것이 낙관적 락이다.
락을 미리 잡지 않고,
충돌이 나면 실패시키는 방식이다.
재시도는 어떻게 하나
UNIQUE 오류가 발생하면:
- 다시 이벤트를 조회한다.
- 현재 version을 다시 확인한다.
- 잔액을 다시 계산한다.
- 가능하면 새 version으로 다시 INSERT한다.
이 과정은 애플리케이션에서 처리한다.
여기서 현실적인 질문
balance 테이블을 같이 두고
거기서 낙관적 락을 적용하면 더 빠르지 않나?
이 질문은 매우 현실적이다.
그래서 실무에서는 두 가지 방식이 존재한다.
1️⃣ 순수 Event Sourcing 방식
- 잔액(balance) 테이블이 없다.
- 이벤트만 저장한다.
- 잔액은 이벤트를 계산해서 얻는다.
- 동시성은 version + UNIQUE 제약으로 제어한다.
장점:
- 구조가 명확하다.
- 언제든지 다시 계산할 수 있다.
단점:
- 구현이 복잡하다.
- 계산 비용이 있다.
2️⃣ 현실적인 혼합 방식 (실무에서 많이 사용)
- balance 테이블을 유지한다.
- balance에 version 컬럼을 둔다.
- balance UPDATE로 동시성 제어한다.
- 이벤트는 함께 저장한다.
처리 흐름
- 현재 balance와 version 조회
- 잔액이 충분한지 확인
- 다음 SQL 실행
UPDATE user_balance
SET balance = balance - 4000,
version = version + 1
WHERE user_id = 10
AND version = 5;
영향받은 row가 1개면 성공
0개면 충돌 → 재시도
- 같은 트랜잭션 안에서 이벤트 INSERT
INSERT INTO event_store
(aggregate_id, version, event_type, payload)
VALUES
(10, 6, 'PointSpent', '{...}');
중요한 점은 이것이다.
balance UPDATE와 이벤트 INSERT는
반드시 같은 트랜잭션 안에서 실행되어야 한다.
두 방식의 가장 큰 차이
순수 방식에서는
이벤트가 모든 것의 기준이다.
혼합 방식에서는
실시간 판단은 balance가 맡고,
이벤트는 변경 기록을 남긴다.
여기서 반드시 정해야 할 것이 있다.
balance와 이벤트가 다를 경우
무엇을 기준으로 삼을 것인가?
이걸 설계 단계에서 정하지 않으면
운영 중에 혼란이 생긴다.
나중에 이벤트로 검증하면 되지 않나?
가능하다.
예를 들어 배치 작업으로:
- 이벤트를 모두 더해서 잔액 계산
- balance 테이블과 비교
- 차이가 있으면 알림 또는 보정 처리
하지만 이것은 사후 점검이다.
실시간 차감의 안전성은
트랜잭션과 낙관적 락이 보장한다.
중복 요청 문제 (멱등성)
네트워크 오류로 같은 요청이 두 번 들어오면
이벤트가 두 번 저장될 수 있다.
이를 막기 위해 request_id를 둔다.
UNIQUE (request_id)
같은 요청은 한 번만 처리된다.
이 구조에서는 멱등성이 매우 중요하다.
이 장의 핵심
동시성과 일관성 문제는 피할 수 없다.
Event Sourcing을 쓰든,
상태 기반 시스템을 쓰든 반드시 해결해야 한다.
핵심은 다음과 같다.
-
동시에 들어오는 요청은
version 기반 낙관적 락으로 제어한다. -
트랜잭션은 여전히 RDB가 보장한다.
-
순수 Event Sourcing과
현실적인 혼합 구조는 다르다. -
어떤 데이터를 기준으로 삼을지
반드시 설계 단계에서 정해야 한다.
33장. 레이어 아키텍처
지금까지 우리는
이벤트 기반 시스템의 구조를 살펴봤다.
- 이벤트로 연결되고
- 비동기로 동작하며
- Outbox와 Inbox로 안정성을 확보한다
그렇다면 이제 질문이 생긴다.
이 복잡한 시스템을 코드로는 어떻게 구성할 것인가?
대부분의 시스템은 이렇게 시작한다
많은 서비스는 다음과 같은 구조로 시작한다.
flowchart LR
Controller --> Service --> Repository
이 구조는 매우 익숙하다.
- Controller → 요청을 받는다
- Service → 비즈니스 로직을 수행한다
- Repository → DB에 접근한다
이것을 레이어 아키텍처라고 한다.
레이어 아키텍처의 개념
레이어 아키텍처는
시스템을 역할별로 나눈 구조다.
1️⃣ Controller (Presentation Layer)
외부 요청을 받는 역할
- HTTP 요청 처리
- 입력값 검증
- 응답 반환
2️⃣ Service (Application Layer)
비즈니스 로직을 담당
- 주문 생성
- 결제 처리
- 상태 변경
3️⃣ Repository (Infrastructure Layer)
데이터 저장소 접근
- DB 조회 / 저장
- 외부 API 호출
핵심 특징
이 구조의 핵심은 이것이다.
위에서 아래로 의존한다
Controller → Service → Repository
- Controller는 Service를 알고
- Service는 Repository를 안다
- 반대 방향은 없다
왜 이 구조를 사용하는가
레이어 아키텍처는
오랫동안 사용된 이유가 있다.
1️⃣ 이해하기 쉽다
구조가 단순하다.
- 어디에 무엇을 넣어야 할지 명확하다
2️⃣ 빠르게 개발할 수 있다
초기 개발 속도가 빠르다.
- 별도 구조 설계 없이 바로 구현 가능
3️⃣ 역할이 명확하다
- Controller → 입출력
- Service → 로직
- Repository → 데이터
하지만 시스템이 커지면 문제가 생긴다
초기에는 잘 동작한다.
하지만 서비스가 커질수록
문제가 드러나기 시작한다.
문제 1️⃣ 비즈니스 로직이 흩어진다
Service에는 비즈니스 로직이 있어야 한다.
하지만 현실은 다르다.
Service
├─ DB 조회
├─ Kafka 이벤트 발행
├─ 외부 API 호출
├─ 비즈니스 로직
결과:
무엇이 핵심 로직인지 흐려진다
문제 2️⃣ 인프라 코드가 침투한다
Service는 점점 더 많은 것을 알게 된다.
- 어떤 DB를 쓰는지
- Kafka를 어떻게 보내는지
- Redis를 어떻게 쓰는지
즉,
비즈니스 로직이 외부 기술에 의존하게 된다
문제 3️⃣ 테스트가 어려워진다
Service를 테스트하려면
- DB가 필요하고
- Kafka가 필요하고
- 외부 API가 필요하다
결과:
단위 테스트가 힘들어진다
문제 4️⃣ 변경이 어렵다
예를 들어:
- Kafka → SQS 변경
- MySQL → DynamoDB 변경
이런 변경이 발생하면
Service 코드까지 수정해야 한다
문제의 본질
위 문제들의 공통 원인은 이것이다.
의존성이 한 방향으로 고정되어 있다
Service → DB
Service → Kafka
Service → API
Service는 점점 더 많은 것에 의존하게 된다.
이벤트 기반 시스템에서는 더 심각해진다
이벤트 기반 구조에서는
외부 의존성이 더 많아진다.
- 메시지 브로커
- Outbox
- Inbox
- 캐시
- 외부 서비스
결과:
Service 레이어가 모든 것을 알게 된다
여기서 질문이 생긴다
이 시점에서 자연스럽게 질문이 나온다.
비즈니스 로직을 외부 기술로부터 분리할 수 없을까?
다음 단계
이 질문을 해결하기 위해
다음 개념이 필요하다.
- 의존성 방향
- 제어 역전 (Inversion of Control)
- 인터페이스 분리
다음 장에서는
이 문제를 어떻게 해결할 수 있는지 살펴본다.
이 장의 핵심
- 레이어 아키텍처는 가장 일반적인 구조다
- Controller → Service → Repository 구조를 가진다
- 이해하기 쉽고 빠르게 개발할 수 있다
- 하지만 시스템이 커지면 한계가 드러난다
- 비즈니스 로직이 외부 기술에 의존하게 된다
- 이 문제를 해결하기 위한 개념이 필요하다
34장. 의존성은 왜 문제가 되는가
33장에서 우리는
레이어 아키텍처의 구조를 보았다.
- Controller → Service → Repository
- 위에서 아래로 의존하는 구조
이 구조는 단순하고 이해하기 쉽다.
하지만 시스템이 커질수록
문제가 발생한다.
의존성이란 무엇인가
의존성이란
한 코드가 다른 코드를 알고 사용하는 관계다.
예를 들어:
class OrderService {
private OrderRepository repository = new MySQLOrderRepository();
}
이 경우:
OrderService는 MySQLOrderRepository에 의존한다
문제는 의존성 자체가 아니다
의존성은 나쁜 것이 아니다.
문제는 이것이다.
무엇이 무엇에 의존하는가
변하는 것과 변하지 않는 것
시스템에는 두 가지 종류가 있다.
변하는 것
- DB (MySQL → DynamoDB)
- 메시지 브로커 (Kafka → SQS)
- 외부 API
- 캐시, 인프라 기술
이들은 상황에 따라
언제든 바뀔 수 있다.
변하지 않아야 하는 것
- 주문 생성 로직
- 결제 처리 규칙
- 상태 전이 규칙
이것은
서비스의 핵심 비즈니스 로직이다.
우리가 원하는 상태
자연스럽게 이렇게 생각할 수 있다.
변하지 않아야 하는 것은
변하는 것에 영향을 받으면 안 된다
하지만 현재 구조는 반대다
레이어 구조를 다시 보자.
Service → DB
Service → Kafka
Service → External API
이 구조의 의미는 이것이다.
비즈니스 로직이 외부 기술에 의존한다
왜 이것이 문제인가
이 상태에서는
변경이 발생할 때마다 문제가 생긴다.
1️⃣ 기술 변경이 로직을 흔든다
- DB 변경
- 메시지 시스템 변경
→ Service 코드 수정 필요
2️⃣ 테스트가 어려워진다
Service를 테스트하려면
- DB가 필요하고
- Kafka가 필요하다
→ 빠른 테스트 불가능
3️⃣ 코드가 점점 복잡해진다
Service
├─ DB 처리
├─ Kafka 발행
├─ 외부 API 호출
├─ 비즈니스 로직
→ 핵심 로직이 흐려진다
문제의 본질
핵심은 이것이다.
변하지 않아야 하는 것이
변하는 것에 의존하고 있다
이 구조에서는
시스템이 안정될 수 없다.
그래서 방향을 바꾼다
이 문제를 해결하려면
의존성 방향을 바꿔야 한다.
현재 구조
Service → DB
우리가 원하는 구조
DB → Service
즉,
외부 기술이 비즈니스 로직에 의존해야 한다
이게 가능한가?
여기서 자연스럽게 질문이 나온다.
DB가 Service를 어떻게 의존하지?
이 질문을 해결하는 것이
다음 개념들이다.
인터페이스
먼저, 역할을 정의한다.
interface OrderRepository {
void save(Order order);
}
Service는 이렇게 바뀐다.
class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
이제 Service는
구현체가 아니라 “역할”에 의존한다
중요한 변화
이 구조에서 핵심은 이것이다.
Service는 어떤 DB를 쓰는지 모른다
제어 역전 (Inversion of Control)
이제 객체를 누가 만들까?
기존:
Service가 직접 생성
변경 후:
외부에서 만들어서 넣어준다
이것을
제어 역전 (IoC)
이라고 한다.
의존성 주입 (Dependency Injection)
실제 연결은 이렇게 이루어진다.
OrderRepository repo = new MySQLOrderRepository();
OrderService service = new OrderService(repo);
이것을
의존성 주입 (DI)
이라고 한다.
결과적으로 바뀌는 것
Before
Service → MySQLRepository
After
Service → Repository Interface
Repository 구현체 → Service에 주입
이 변화의 의미
이 변화는 단순한 코드 스타일이 아니다.
1️⃣ 비즈니스 로직이 보호된다
Service는 더 이상
- DB
- Kafka
- 외부 API
를 알지 않는다
2️⃣ 기술 변경이 쉬워진다
- MySQL → DynamoDB 변경
→ Service 수정 없음
3️⃣ 테스트가 쉬워진다
OrderRepository fake = new FakeRepository();
OrderService service = new OrderService(fake);
→ 외부 의존 없이 테스트 가능
이벤트 기반 시스템에서의 의미
이벤트 기반 구조에서는
외부 의존성이 더 많다.
- 메시지 브로커
- Outbox
- Inbox
- 외부 API
이 상태에서 의존성 방향이 잘못되면
시스템은 빠르게 복잡해진다
이제 다음 단계
지금까지 우리는
- 의존성의 문제를 이해했고
- 방향을 왜 바꿔야 하는지 보았고
- 그 방법을 배웠다
이제 남은 질문은 이것이다.
이 구조를 시스템 전체에 적용하면 어떻게 될까?
그 답이
헥사고날 아키텍처
다.
이 장의 핵심
- 의존성 자체는 문제가 아니다
- 문제는 “의존성의 방향”이다
- 변하지 않아야 하는 것은 보호되어야 한다
- 외부 기술은 언제든 바뀔 수 있다
- 의존성 방향을 뒤집어야 구조가 안정된다
- 인터페이스, IoC, DI가 이를 가능하게 한다
35장. 헥사고날 아키텍처
34장에서 우리는
의존성의 문제를 살펴봤다.
- 변하지 않아야 하는 것이
- 변하는 것에 의존하면
- 시스템은 계속 흔들린다
그래서 우리는 결론을 얻었다.
외부 기술이 비즈니스 로직에 의존해야 한다
그렇다면 질문이 남는다.
이 개념을 시스템 구조로 만들 수 있을까?
그 답이
헥사고날 아키텍처 (Hexagonal Architecture)
이다.
헥사고날 아키텍처란 무엇인가
헥사고날 아키텍처는
시스템을 다음과 같이 구성한다.
[외부] → [Adapter] → [Port] → [Domain]
핵심은 이것이다.
도메인은 외부를 모른다
왜 “헥사고날”인가
이 구조는
여러 방향으로 연결될 수 있는 형태를 가진다.
즉,
- DB
- Kafka
- 외부 API
- 테스트 코드
모두 동일한 방식으로
도메인에 연결된다.
기존 구조와 비교
레이어 아키텍처
Controller → Service → Repository
문제:
- Service가 모든 것을 안다
- DB, Kafka, API가 섞인다
헥사고날 아키텍처
외부 → Adapter → Port → Domain
특징:
- 외부는 Adapter에서 처리
- Domain은 순수하게 유지
핵심 구성 요소
1️⃣ Domain (도메인)
비즈니스 로직이 존재하는 곳이다.
예:
- 주문 생성
- 결제 처리
- 상태 전이
도메인은
- DB를 모르고
- Kafka를 모르고
- API도 모른다
비즈니스 규칙만 가진다
2️⃣ Port (포트)
도메인이 외부와 통신하기 위한 인터페이스다.
예:
- 저장 포트
- 이벤트 발행 포트
- 외부 서비스 호출 포트
포트는
무엇을 할 것인가만 정의한다
3️⃣ Adapter (어댑터)
포트를 실제로 구현하는 영역이다.
예:
- DB Repository
- Kafka Producer
- HTTP Client
- Outbox / Inbox 처리
어댑터는
어떻게 할 것인가를 담당한다
의존성 방향
헥사고날에서 가장 중요한 규칙이다.
Adapter → Port → Domain
의존성은 항상 도메인 방향으로 향한다
이벤트 시스템에 적용하면
지금까지 배운 내용을
헥사고날로 보면 이렇게 정리된다.
이벤트 발행 (Outbox)
Domain → EventPort → OutboxAdapter
- Domain은 이벤트 발행을 요청한다
- 실제 저장은 Outbox Adapter가 수행한다
이벤트 소비 (Inbox)
ConsumerAdapter → Domain
- Kafka Consumer는 Adapter
- Domain은 처리만 수행한다
중요한 변화
이 구조에서 가장 중요한 변화는 이것이다.
도메인은 외부 기술을 전혀 모른다
이 구조가 주는 장점
1️⃣ 비즈니스 로직 보호
도메인은
- DB 변경
- 메시지 시스템 변경
에 영향을 받지 않는다
2️⃣ 테스트 용이성
외부 의존 없이
도메인을 단독으로 테스트할 수 있다
3️⃣ 기술 교체 가능
- Kafka → SQS
- MySQL → DynamoDB
→ Adapter만 교체하면 된다
4️⃣ 확장성
새로운 기능은
Adapter 추가로 해결 가능
단점
1️⃣ 초기 복잡도
구조를 나누는 데 시간이 필요하다
2️⃣ 코드 증가
포트와 어댑터로 인해 코드가 늘어난다
3️⃣ 과도한 설계 위험
작은 서비스에서는
오히려 과한 구조가 될 수 있다
언제 사용하는가
다음과 같은 경우에 적합하다.
- 이벤트 기반 시스템
- 외부 의존성이 많은 서비스
- 장기적으로 유지보수해야 하는 시스템
전체 흐름을 다시 보면
지금까지 우리는
다음 단계를 거쳐왔다.
문제 (중복, 순서, 유실)
→ 해결 (Outbox, Inbox, 멱등성)
→ 구조 (헥사고날)
핵심 메시지
헥사고날 아키텍처의 본질은 이것이다.
비즈니스 로직을 외부로부터 보호하는 구조
이 장의 핵심
- 의존성 방향을 통제해야 한다
- 도메인은 외부 기술을 몰라야 한다
- 포트는 역할을 정의한다
- 어댑터는 구현을 담당한다
- 헥사고날은 이 구조를 만드는 방법이다
36장. 실제 코드 구조로 보는 헥사고날 아키텍처
35장에서 우리는
헥사고날 아키텍처를 살펴봤다.
- 도메인은 외부를 모르고
- 포트는 역할을 정의하며
- 어댑터는 외부 기술을 담당한다
이제 질문은 이것이다.
이 구조를 실제 코드로는 어떻게 나눌 것인가?
전체 구조
헥사고날 아키텍처는
보통 다음과 같은 디렉토리 구조를 가진다.
src/
├─ domain/
├─ application/
├─ port/
├─ adapter/
│ ├─ in/
│ └─ out/
└─ config/
각 영역의 역할
1️⃣ domain/
비즈니스 로직이 존재하는 영역이다.
domain/
├─ Order.java
├─ OrderStatus.java
└─ OrderService.java
여기에는:
- 엔티티
- 도메인 로직
- 상태 전이 규칙
이 들어간다.
외부 기술에 대한 코드는 절대 들어가지 않는다
2️⃣ application/
유스케이스를 담당하는 영역이다.
application/
└─ CreateOrderUseCase.java
역할:
- 도메인 호출
- 흐름 제어
3️⃣ port/
도메인이 외부와 통신하기 위한 인터페이스다.
port/
├─ in/
│ └─ CreateOrderCommand.java
└─ out/
├─ OrderRepositoryPort.java
└─ EventPublisherPort.java
port/in
외부에서 들어오는 요청 정의
- UseCase 인터페이스
- Command 객체
port/out
외부로 나가는 요청 정의
- DB 저장
- 이벤트 발행
- 외부 API 호출
4️⃣ adapter/
외부 기술을 구현하는 영역이다.
adapter/in
adapter/in/
└─ OrderController.java
- HTTP Controller
- Kafka Consumer
adapter/out
adapter/out/
├─ persistence/
│ └─ OrderRepositoryImpl.java
├─ messaging/
│ └─ KafkaEventPublisher.java
└─ outbox/
└─ OutboxAdapter.java
5️⃣ config/
객체를 연결하는 영역이다.
config/
└─ AppConfig.java
여기서:
- 구현체 생성
- 의존성 주입
흐름을 따라가 보자
주문 생성 요청이 들어오는 경우:
Controller → UseCase → Domain → Port → Adapter → DB
실제 흐름
- Controller가 요청을 받는다
- UseCase 호출
- Domain 로직 실행
- RepositoryPort 호출
- Adapter가 DB 저장
이벤트 발행 흐름 (Outbox)
Domain → EventPort → OutboxAdapter → DB
- Domain은 이벤트 발행을 요청만 한다
- 실제 저장은 Outbox Adapter가 수행
이벤트 소비 흐름 (Inbox)
KafkaConsumer → UseCase → Domain
- Consumer는 Adapter
- Domain은 처리만 수행
중요한 규칙
1️⃣ domain은 adapter를 몰라야 한다
domain → adapter (X)
adapter → domain (O)
2️⃣ port를 통해서만 통신한다
domain → port → adapter
3️⃣ 외부 기술은 adapter에만 존재한다
- DB
- Kafka
- Redis
- API
잘못된 구조 예시
다음과 같은 코드는 피해야 한다.
class OrderService {
KafkaProducer kafka;
JdbcTemplate jdbc;
}
문제:
- 도메인이 외부 기술에 의존
올바른 구조 예시
class OrderService {
private final OrderRepositoryPort repository;
private final EventPublisherPort publisher;
}
이 구조의 효과
1️⃣ 코드가 명확해진다
- 어디에 무엇을 넣을지 명확
2️⃣ 테스트가 쉬워진다
new OrderService(fakeRepository, fakePublisher);
3️⃣ 기술 교체가 쉬워진다
- Kafka → SQS 변경
- DB 변경
→ adapter만 수정
실무에서 자주 하는 실수
1️⃣ port 없이 바로 adapter 사용
→ 의존성 다시 깨짐
2️⃣ domain에 framework 코드 넣기
→ 구조 무너짐
3️⃣ application 레이어 생략
→ 흐름이 domain에 몰림
정리
헥사고날 아키텍처는
개념만으로는 부족하다.
실제 코드 구조로 나누어야 의미가 있다
이 장의 핵심
- 헥사고날은 디렉토리 구조로 구현된다
- domain, port, adapter로 분리한다
- 의존성은 항상 domain 방향으로 향한다
- 외부 기술은 adapter에만 존재해야 한다
- 구조를 지켜야 아키텍처가 유지된다
37장. 분산 모놀리스의 다양한 얼굴
지금까지 우리는 마이크로서비스를 설계하고 운영하는 방법을 보았다.
이제 마지막 부에서는 함정을 살펴본다.
가장 흔하고 가장 위험한 함정은 단 하나다.
나누었지만 결합은 그대로 남은 상태.
이것이 분산 모놀리스다.
겉으로는 마이크로서비스처럼 보인다. 서비스가 여러 개고, 각자 배포되고, 각자 코드베이스를 가진다. 하지만 본질은 모놀리스다.
이 장에서는 분산 모놀리스가 가지는 여러 얼굴을 본다.
분산 모놀리스란
분산 모놀리스의 정의는 단순하다.
물리적으로는 분산되었지만 논리적으로는 하나로 묶여 있는 시스템.
징후:
- 한 서비스를 배포할 때 다른 서비스도 함께 배포해야 한다
- 한 서비스 장애가 다른 모든 서비스를 죽인다
- 코드 한 줄 바꾸려고 여러 서비스를 동시에 수정한다
- 서비스 간 호출 체인이 끝없이 길다
이 중 하나라도 해당된다면 이미 분산 모놀리스에 가깝다.
얼굴 1️⃣ — 공유 DB
가장 흔한 분산 모놀리스의 얼굴이다.
flowchart TB
OrderService --> SharedDB[(공유 DB)]
PaymentService --> SharedDB
UserService --> SharedDB
DeliveryService --> SharedDB
- 서비스는 나뉘었다
- DB는 하나다
- 서비스끼리 같은 테이블을 JOIN한다
왜 이렇게 되는가
- “서비스만 나누면 충분하다“는 오해
- DB 분리의 비용을 피하려 함
- 트랜잭션이 묶여야 한다고 생각함
증상
- 스키마 변경 시 여러 팀이 동시에 수정
- DB 장애가 모든 서비스로 전파
- 서비스 독립 확장이 불가능
해결
7장에서 다룬 데이터 분리를 단계적으로 진행한다. 이건 가장 비싼 일이지만, 가장 중요하다.
얼굴 2️⃣ — 긴 동기 호출 체인
flowchart LR
A --> B --> C --> D --> E
서비스를 분리했는데 모든 호출이 동기 REST/RPC로 길게 이어진다.
왜 이렇게 되는가
- 비동기 설계에 대한 두려움
- “기다리지 않으면 어떻게 응답하지?”
- 이벤트 인프라가 없음
증상
- 한 서비스가 느려지면 전체가 느려진다
- 한 서비스가 죽으면 체인 전체가 죽는다
- 새 서비스가 추가될수록 응답 시간이 길어진다
13장에서 동기와 비동기의 결합도 차이를 다뤘다.
해결
- 동기 호출 체인 깊이를 제한
- 필요한 일은 이벤트로 분리
- Circuit Breaker로 전파 차단 (25장)
얼굴 3️⃣ — 함께 배포해야 하는 서비스들
“서비스 A를 배포할 때는 B와 C도 같이 배포해야 한다”
이 말이 자주 나온다면 분산 모놀리스다.
왜 이렇게 되는가
- API 변경이 호환되지 않는다
- 이벤트 스키마가 변경되었다
- 공유 라이브러리가 업데이트되었다
증상
- 배포가 다시 큰 이벤트가 된다
- 배포 일정 조율에 시간이 든다
- 한 팀이 다른 팀의 일정에 의존
해결
- API/이벤트 버전 관리
- 하위 호환을 보장하는 변경
- 공유 라이브러리 최소화
얼굴 4️⃣ — 공유 도메인 모델
common-domain.jar
├─ User
├─ Order
├─ Payment
└─ ...
여러 서비스가 같은 도메인 모델 라이브러리를 쓴다.
- User 클래스를 모든 서비스가 import
- 라이브러리 버전을 다 같이 올린다
왜 이렇게 되는가
- “중복 코드 싫다“는 본능
- DDD를 잘못 이해함
- 코드 통일을 우선시함
증상
- 모델 변경이 모든 서비스의 변경
- 라이브러리 버전 충돌이 잦음
- 서비스 독립성이 사라짐
해결
각 Bounded Context는 자기 모델을 가진다.
같은 이름의 개념도 각 서비스 안에서는 다른 형태일 수 있다.
이 원칙은 8장에서 다뤘다.
얼굴 5️⃣ — 모놀리스를 Gateway로 두는 구조
flowchart LR
Client --> Monolith
Monolith --> NewServiceA
Monolith --> NewServiceB
분리된 서비스들이 있지만 모든 입구는 모놀리스다.
왜 이렇게 되는가
- 변두리부터 떼어내다 보니 자연스럽게
- 인증·라우팅이 모놀리스에 묶여 있음
- Gateway 도입 비용을 미룸
증상
- 핵심 도메인 추출이 막힌다
- 모놀리스가 줄어들지 않는다
- 분리된 서비스의 독립성이 제한적
해결
6장에서 본 “Gateway를 위로 끌어올리기” 패턴.
얼굴 6️⃣ — 비즈니스 로직이 흩어져 있다
주문 생성 로직:
- OrderService에 일부
- PaymentService에 일부
- API Gateway에 일부 검증
- BFF에 일부 변환
한 비즈니스 결정이 여러 서비스에 흩어진다.
왜 이렇게 되는가
- Gateway나 BFF에 로직을 슬쩍 넣음
- 서비스 경계가 책임이 아닌 기술로 나뉨
- 도메인 분석 없이 분리함
증상
- 비즈니스 변경 시 여러 서비스 수정
- 새 기능 추가가 점점 어려워짐
- 도메인 전문가가 코드를 못 따라감
해결
- 도메인 책임 단위로 경계 재정렬
- Gateway·BFF는 로직 없이 얇게 유지
- DDD의 Bounded Context 원칙 복귀
얼굴 7️⃣ — 분산 트랜잭션을 시도한다
서비스가 분리된 후 원래의 트랜잭션을 그대로 유지하려고 한다.
sequenceDiagram
participant TxCoordinator
participant OrderService
participant PaymentService
TxCoordinator->>OrderService: Begin
TxCoordinator->>PaymentService: Begin
OrderService->>TxCoordinator: Ready
PaymentService->>TxCoordinator: Ready
TxCoordinator->>OrderService: Commit
TxCoordinator->>PaymentService: Commit
2PC, XA 같은 분산 트랜잭션을 사용한다.
왜 이렇게 되는가
- 최종 일관성을 받아들이지 못함
- “한 번에 끝내야 한다“는 강박
- Saga 패턴의 학습 비용을 피함
증상
- 시스템 전반의 성능 저하
- 락 경합 증가
- 가용성 하락
- 한 서비스 장애가 전체 트랜잭션 차단
해결
19장의 최종 일관성과 22장의 Saga 패턴을 받아들인다.
분산 모놀리스의 신호 — 자가 진단
다음 질문에 솔직하게 답해본다.
| 질문 | Yes / No |
|---|---|
| 한 서비스 배포가 종종 다른 서비스 변경을 요구한다 | |
| 한 서비스 장애가 전체 시스템을 멈춘다 | |
| 서비스끼리 같은 DB·테이블을 쓴다 | |
| 동기 호출 체인이 5단계 이상이다 | |
| 공유 도메인 라이브러리에 의존한다 | |
| 모놀리스가 여전히 입구 역할을 한다 | |
| 비즈니스 로직이 여러 서비스에 흩어져 있다 | |
| 2PC·XA 같은 분산 트랜잭션을 쓴다 |
Yes가 3개 이상이면 분산 모놀리스에 가깝다. 4개 이상이면 그냥 분산 모놀리스다.
왜 분산 모놀리스가 가장 나쁜가
분산 모놀리스는 모놀리스보다 나쁘다.
| 구분 | 모놀리스 | 분산 모놀리스 | 마이크로서비스 |
|---|---|---|---|
| 운영 복잡도 | 낮음 | 높음 | 높음 |
| 결합도 | 높음 | 높음 | 낮음 |
| 독립 배포 | 불가 | 사실상 불가 | 가능 |
| 디버깅 | 쉬움 | 어려움 | 어려움 |
| 장애 격리 | 없음 | 없음 | 있음 |
분산 모놀리스는
- 모놀리스의 결합도를 가지면서
- 마이크로서비스의 운영 복잡도까지 짊어진다
둘 중 나쁜 점만 모은 구조다.
빠져나오는 방법
분산 모놀리스에서 빠져나오는 길은 빅뱅 재구축이 아니다.
하나씩 결합도를 끊어 나간다.
- 공유 DB라면 → 한 도메인의 데이터부터 분리
- 동기 체인이라면 → 한 호출을 이벤트로
- 함께 배포한다면 → 한 API의 호환성부터 잡기
- 공유 모델이라면 → 한 서비스의 모델부터 자기 모델로
빠져나오는 과정도 점진적이다. 그리고 4장에서 본 원칙이 그대로 적용된다.
이 장의 핵심
- 분산 모놀리스는 마이크로서비스 전환의 가장 흔한 실패 형태다
- 공유 DB, 긴 동기 호출, 함께 배포, 공유 모델, 모놀리스 Gateway, 흩어진 로직, 분산 트랜잭션 — 모두 같은 함정의 다른 얼굴이다
- 분산 모놀리스는 모놀리스의 결합도와 마이크로서비스의 운영 복잡도를 동시에 짊어진 상태다
- 빠져나오는 길은 빅뱅이 아니다 — 하나씩 결합도를 끊어 나간다
38장. Nanoservice — 너무 잘게 쪼갠 함정
37장에서 우리는 분산 모놀리스를 보았다. “너무 안 나누어진” 상태의 함정이었다.
반대편에도 함정이 있다.
너무 잘게 나누어진 상태.
이것이 Nanoservice다.
마이크로서비스라는 이름은 작음을 강조하지만 중요한 건 작음이 아니라 독립성이다.
작아지려고만 하면 다른 종류의 문제가 생긴다.
Nanoservice란
Nanoservice는 보통 다음 특징을 가진다.
- 한 서비스의 책임이 하나의 함수 수준이다
- 단독으로는 비즈니스 의미가 없다
- 다른 서비스 없이는 동작하지 않는다
- 호출 빈도가 매우 잦다
예:
UserNameFetchService— 사용자 이름 한 컬럼을 반환EmailValidatorService— 이메일 형식 한 가지 검증PriceCalculatorService— 가격 계산 한 가지
겉으로는 “단일 책임 원칙“이라고 부를 만하다. 하지만 마이크로서비스의 단위는 함수가 아니다.
왜 이런 일이 벌어지는가
1️⃣ “작을수록 좋다“는 오해
“마이크로“라는 말 때문에 작을수록 더 마이크로서비스적이라고 생각한다.
2️⃣ SRP를 잘못 적용
단일 책임 원칙(SRP)은 클래스 단위 원칙이다. 서비스 단위에 그대로 적용하면 너무 잘게 쪼갠다.
3️⃣ 재사용성에 대한 환상
“이 함수를 다른 데서도 쓸 거니까 서비스로 빼자.”
대부분 그 “다른 데서“가 오지 않는다. 온다 해도 라이브러리로 충분하다.
4️⃣ 팀 분할의 과잉
“한 사람이 한 서비스를 맡으면 깔끔하지 않을까?”
조직 구조가 서비스 분할에 영향을 주는 건 자연스럽지만 극단으로 가면 서비스가 너무 많아진다.
Nanoservice의 비용
1️⃣ 네트워크 호출이 폭증한다
flowchart LR
OrderService --> UserNameService
OrderService --> UserEmailService
OrderService --> UserAddressService
OrderService --> UserPhoneService
한 주문을 처리하기 위해 사용자 정보 한 덩어리를 받으려고 4번 호출한다.
같은 일을 모놀리스에서는 함수 호출 4번이다. 함수 호출은 마이크로초. 네트워크 호출은 밀리초.
1000배 느려진다.
2️⃣ 운영 부담이 폭증한다
서비스 100개와 서비스 10개의 운영 부담은 10배가 아니라 훨씬 더 크다.
- 100개의 배포 파이프라인
- 100개의 모니터링 대상
- 100개의 로그·메트릭
- 100개의 알람 룰
3️⃣ 변경이 더 어려워진다
작은 비즈니스 변경 하나가 여러 Nanoservice를 동시에 수정하게 만든다.
- 새 필드 추가 → 5개 서비스 수정
- API 변경 → 호환성 깨짐
- 함께 배포해야 한다
이러면 37장의 분산 모놀리스 함정에 빠진다.
너무 잘게 쪼개면 결국 분산 모놀리스가 된다.
4️⃣ 디버깅이 악몽이 된다
한 요청이 20개 서비스를 거치면 어디서 문제가 났는지 찾기 어렵다.
27장의 관측 가능성이 필수가 되지만 관측해야 할 서비스가 너무 많아진다.
적절한 서비스 크기는
정답은 없다. 하지만 좋은 기준은 있다.
1️⃣ “두 피자 팀” 원칙
한 서비스는 팀 하나가 책임질 수 있는 크기여야 한다.
- 너무 작으면 한 사람도 남는다
- 너무 크면 한 팀이 다 못 본다
2️⃣ 비즈니스 책임 단위
한 서비스는 비즈니스 사람에게 설명할 수 있는 단위여야 한다.
- “이건 주문 서비스입니다” → OK
- “이건 사용자 이름 조회 서비스입니다” → 너무 작음
3️⃣ 독립 배포의 가치
서비스를 분리하는 비용 대비 독립 배포·확장의 가치가 있는가?
가치가 없다면 분리하지 않는다.
4️⃣ 단독으로 의미 있는가
서비스가 단독으로 비즈니스 가치를 만들어내는가?
- 주문 서비스 — 주문을 받는다 → OK
- 사용자 이름 서비스 — 이름을 반환한다 → 의미 없음
Nanoservice를 통합하는 법
이미 너무 잘게 쪼개졌다면 어떻게 합칠 것인가.
단계 1 — 도메인 단위로 묶기
같은 도메인의 Nanoservice들을 묶는다.
이전:
UserNameService
UserEmailService
UserAddressService
UserPhoneService
이후:
UserService (위 4개를 흡수)
단계 2 — 호출 빈도 기준으로 묶기
자주 함께 호출되는 서비스는 사실상 하나의 도메인일 가능성이 높다.
- 항상 같이 호출 → 합쳐도 된다
- 가끔 같이 호출 → 그대로 두기
단계 3 — 통합도 점진적으로
합치는 과정도 점진적이다.
- Strangler Fig를 거꾸로 적용
- 한 Nanoservice씩 흡수
- 호출 패턴이 안정될 때까지 검증
함수와 서비스의 차이
이 함정에서 빠져나오려면 함수와 서비스의 차이를 명확히 이해해야 한다.
| 구분 | 함수 | 서비스 |
|---|---|---|
| 단위 | 책임 한 줄 | 도메인 |
| 호출 비용 | 마이크로초 | 밀리초 |
| 배포 | 함께 | 독립 |
| 장애 격리 | 없음 | 있음 |
| 운영 부담 | 0 | 있음 |
| 단독 가치 | 없어도 됨 | 있어야 함 |
이 비용 차이를 인정하면 자연스럽게 서비스를 더 큰 단위로 그린다.
“충분히 큰” 서비스의 모습
좋은 서비스 크기의 신호:
- 비즈니스 사람에게 한 마디로 설명 가능
- 한 팀이 책임지기에 적당
- 단독으로 의미 있는 일을 한다
- 외부 호출에 강하게 의존하지 않는다
- 자체 데이터 모델을 가진다
이 다섯 가지가 충족되면 서비스 크기는 적절하다.
이 장의 핵심
- 마이크로서비스의 단위는 함수가 아니라 비즈니스 책임이다
- 너무 잘게 쪼개면 네트워크 비용·운영 부담·변경 비용이 폭증한다
- Nanoservice는 결국 분산 모놀리스로 회귀한다
- 적절한 크기의 기준: 두 피자 팀, 비즈니스 단위, 독립 배포 가치, 단독 의미
- 이미 잘게 쪼개진 서비스는 도메인 단위로 다시 묶을 수 있다
39장. 마이크로서비스로 갔다가 돌아온 사례들
37장에서는 분산 모놀리스의 함정을, 38장에서는 너무 잘게 쪼갠 Nanoservice 함정을 보았다.
이 두 함정에 깊이 빠진 회사들은 결국 같은 결정을 내린다.
마이크로서비스에서 다시 모놀리스로 돌아간다.
이런 사례들은 실패가 아니다. 오히려 가장 정직한 학습이다.
이 장에서는 잘 알려진 회귀 사례들과 그들이 무엇을 배웠는지를 본다.
회귀가 실패가 아닌 이유
마이크로서비스에서 모놀리스로 돌아가는 것을 “실패“라고 부를 수 없다.
이유는 단순하다.
아키텍처는 비즈니스 단계에 맞아야 한다.
- 회사가 작을 때는 모놀리스가 맞다
- 회사가 커지면 마이크로서비스가 필요할 수 있다
- 회사가 다시 단순해지면 다시 모놀리스가 나을 수 있다
선택이 바뀌는 것은 자연스럽다. 중요한 것은 왜 바꿨는가를 이해하는 것이다.
사례 1️⃣ — Segment
데이터 분석 플랫폼 Segment는 2017년 마이크로서비스에서 모놀리스로 회귀했다.
어떤 상황이었는가
- 수십 개의 데이터 통합 마이크로서비스
- 각 서비스가 외부 시스템 하나로 데이터를 보냄
- 큐, Auto-scaling, 모니터링이 각 서비스마다
무엇이 문제였는가
- 운영 복잡도가 비즈니스 가치보다 컸다
- 새 통합을 추가할 때마다 새 서비스, 새 파이프라인
- 50개 서비스의 알람 관리가 불가능
- 비슷한 코드가 50번 반복
어떻게 돌아갔는가
- 통합 로직을 하나의 코드베이스로 합침
- 단일 모놀리스 서비스로 모든 통합 처리
- 운영 부담이 극적으로 감소
무엇을 배웠는가
마이크로서비스의 비용을 정직하게 계산해야 한다. 분리의 가치보다 운영 비용이 크면 분리하지 않는다.
사례 2️⃣ — Istio
서비스 메시 Istio는 자기 자신을 여러 마이크로서비스(Pilot, Citadel, Galley 등)로 구성했다.
2020년 이를 단일 바이너리(istiod)로 합쳤다.
어떤 상황이었는가
- 4~5개의 컨트롤 플레인 컴포넌트
- 각자 별도 배포·업그레이드
- 사용자가 5개를 관리해야 함
무엇이 문제였는가
- 컴포넌트 간 강한 의존성
- 함께 배포해야 하는 경우가 대부분
- 사용자 입장에서 운영이 너무 복잡
어떻게 돌아갔는가
- 단일 프로세스로 통합 (istiod)
- 내부 모듈은 여전히 분리되어 있음
- 사용자 입장에서는 하나의 컴포넌트
무엇을 배웠는가
물리적 분리와 논리적 분리는 다르다. 모듈은 잘 분리되어 있되, 배포는 하나여도 된다.
사례 3️⃣ — Amazon Prime Video
2023년, Amazon Prime Video는 영상 모니터링 시스템을 마이크로서비스에서 모놀리스로 통합했다. 비용이 90% 감소했다고 발표했다.
어떤 상황이었는가
- 영상 품질을 분석하는 분산 시스템
- AWS Step Functions와 Lambda 기반
- 각 분석 단계가 별도 서비스
무엇이 문제였는가
- 단계마다 데이터를 S3에 저장하고 다시 읽음
- 직렬화/역직렬화 비용
- AWS 호출 비용
- 단계 간 데이터 전송 비용
어떻게 돌아갔는가
- 모든 분석 단계를 단일 프로세스로 통합
- 데이터를 메모리에서 직접 전달
- 인프라 비용 90% 감소
무엇을 배웠는가
데이터를 자주 주고받는 처리는 분리하면 비싸진다. 분리의 단위는 데이터 흐름을 봐야 한다.
회귀 결정에 공통적으로 있는 신호
여러 사례를 보면 공통 신호가 있다.
1️⃣ 운영 비용이 비즈니스 가치를 초과한다
- 새 기능 출시보다 운영에 시간이 더 든다
- 알람 관리가 불가능한 수준
- 인프라 비용이 비정상적으로 높다
2️⃣ 변경이 점점 느려진다
- 한 변경이 여러 서비스를 거친다
- 함께 배포해야 하는 경우가 대부분
- 디버깅이 시간을 다 잡아먹는다
3️⃣ 같은 처리에 너무 많은 네트워크 호출이 든다
- 한 요청에 수십 번의 서비스 호출
- 데이터를 자주 직렬화/역직렬화
- 클라우드 호출 비용이 비상식적
4️⃣ 비즈니스가 단순해졌다
- 트래픽이 줄었거나
- 도메인이 단순해졌거나
- 팀이 작아졌거나
이 중 두 개 이상이 보이면 회귀를 진지하게 고려할 시점일 수 있다.
“마이크로서비스 회귀“가 의미하는 것
회귀했다고 모든 분리를 없애지는 않는다.
회귀 후 구조는 보통 이렇다.
flowchart TB
Core[큰 모놀리스 서비스] --> ExternalA[정말 필요한 서비스 A]
Core --> ExternalB[정말 필요한 서비스 B]
- 대부분을 하나의 큰 서비스로 합친다
- 정말로 분리될 가치가 있는 영역만 따로 둔다
- “마이크로서비스 vs 모놀리스“가 아니라 적정 크기로 재정렬
이걸 “Right-sizing” 또는 “Macroservices” 라고 부르기도 한다.
회귀하지 않으려면 처음부터 무엇을 해야 하는가
이 사례들이 주는 교훈은 분명하다.
1️⃣ 분리할 이유를 먼저 정한다
1장에서 봤다.
“쪼갤 수 있느냐“가 아니라 “쪼개야 하는 이유가 있는가”
이유 없는 분리는 비용만 남긴다.
2️⃣ 적정 크기로 시작한다
처음부터 잘게 쪼개려고 하지 않는다.
- 큰 서비스 몇 개로 시작
- 필요해지면 더 쪼갠다
- 작게 시작해서 키우는 것보다 큰 데서 줄이는 게 어렵다
3️⃣ 운영 부담을 정직하게 본다
새 서비스를 만들기 전에 묻는다.
- 누가 운영할 것인가?
- 알람은 누가 받을 것인가?
- 장애는 누가 대응할 것인가?
이 답을 못하면 만들지 않는다.
4️⃣ 데이터 흐름을 본다
서비스를 나누는 기준이 비즈니스 책임과 데이터 흐름을 모두 봐야 한다.
데이터가 한 흐름 안에서 여러 번 주고받아진다면 그 흐름은 한 서비스 안에 있어야 한다.
모놀리스가 부끄러운 게 아니다
회귀한 회사들은 부끄러워하지 않는다. 오히려 자신감 있게 발표한다.
이유는
아키텍처는 도구다. 도구는 상황에 맞게 바꿀 수 있다.
마이크로서비스가 멋있어서 쓰는 게 아니라 필요해서 쓰는 것이다. 필요 없어지면 돌아가는 것이 합리적이다.
이 책 전체가 강조하는 메시지가 여기 있다.
마이크로서비스는 의무가 아니다.
이 메시지는 42장에서 다시 한 번 정리한다.
이 장의 핵심
- 마이크로서비스에서 모놀리스로 돌아가는 것은 실패가 아니다
- Segment·Istio·Amazon Prime Video — 모두 운영 비용이 가치를 초과해서 회귀했다
- 회귀의 공통 신호: 운영 비용, 느려지는 변경, 과도한 네트워크 호출, 비즈니스 단순화
- 회귀해도 모든 분리를 없애지 않는다 — 적정 크기로 재정렬한다
- 회귀하지 않으려면 분리할 이유, 적정 크기, 운영 부담, 데이터 흐름을 처음부터 본다
- 모놀리스는 부끄러운 선택이 아니다 — 상황에 맞다면 옳은 선택이다
40장. 배포 전략 — Blue/Green, Canary, Feature Flag
마이크로서비스의 가장 큰 장점 중 하나는 독립 배포다.
하지만 독립적으로 배포할 수 있다는 것과 안전하게 배포할 수 있다는 것은 다르다.
이 장에서는 배포의 위험을 어떻게 다룰지를 본다.
왜 배포 전략이 따로 필요한가
모놀리스 시절 배포는 단순했다.
- 정해진 시간에 전체를 내리고 올린다
- 문제 생기면 이전 버전으로 되돌린다
- 한 달에 몇 번, 큰 이벤트
마이크로서비스에서는 다르다.
- 수십 개 서비스가 각자 배포
- 하루에도 수십 번
- 사용자가 안 보는 시간이 따로 없다
배포는 매일 일어나는 일이 되었다.
그래서 배포 자체가 위험을 다루는 도구가 되어야 한다.
1️⃣ Rolling Update — 가장 기본
flowchart LR
OldA[v1] --> NewA[v2]
OldB[v1] --> NewB[v2]
OldC[v1] --> NewC[v2]
- 같은 서비스의 여러 인스턴스를
- 한 번에 하나씩 새 버전으로 교체
장점:
- 다운타임이 거의 없다
- 인프라 비용이 추가로 들지 않는다
- Kubernetes 같은 오케스트레이터의 기본
단점:
- 새 버전과 옛 버전이 짧은 시간 함께 동작 → 호환성 필요
- 문제 발견 후 롤백이 점진적이라 느리다
대부분의 일상 배포는 이 방식이다.
2️⃣ Blue/Green — 두 환경 전환
flowchart TB
Router --> Blue[v1 — 현재 운영]
Router -.-> Green[v2 — 새 버전 대기]
- 두 개의 환경(Blue/Green)을 둔다
- 현재 트래픽은 Blue로
- 새 버전을 Green에 배포·검증
- Router를 한 번에 Green으로 전환
장점:
- 전환이 즉각적이다
- 롤백도 즉각적 (Router를 다시 Blue로)
- 새 환경을 완전히 검증한 후 전환
단점:
- 인프라가 2배 필요
- 데이터 일관성 처리가 복잡
- 환경 간 동기화 필요
언제 쓰는가:
- 큰 변경의 안전한 전환
- 데이터 마이그레이션과 함께 (7장 참고)
- 다운타임을 절대 허용하지 못할 때
3️⃣ Canary — 점진적 노출
flowchart LR
Router -->|95%| OldVersion[v1]
Router -->|5%| NewVersion[v2]
- 일부 트래픽만 새 버전으로
- 메트릭을 보며 비율 증가
- 문제 발견 시 비율 감소 또는 0
비율 흐름:
1% → 5% → 25% → 50% → 100%
각 단계에서 검증 시간을 둔다.
장점:
- 실 트래픽으로 검증
- 문제 영향 범위를 제한
- 점진적 확신
단점:
- 신·구 버전이 동시에 운영
- 호환성 보장 필요
- 메트릭 자동화 필수
언제 쓰는가:
- 위험도가 있는 변경
- 새 알고리즘 검증
- 성능 영향이 불확실할 때
4️⃣ Feature Flag — 코드와 배포의 분리
배포 전략은 아니지만 함께 쓰이는 강력한 도구다.
if (featureFlag.isEnabled("new-pricing")) {
return newPricingLogic();
} else {
return oldPricingLogic();
}
- 새 코드를 배포만 해두고
- 실제 동작은 런타임 플래그로 제어
장점:
- 배포와 릴리스를 분리
- 사용자 그룹별 점진적 노출
- 즉시 끄기 가능 (Kill switch)
- A/B 테스트 가능
단점:
- 플래그가 쌓이면 코드가 복잡
- 플래그 청소가 필요
- 분기 테스트 부담
어떤 전략을 언제 쓰는가
| 상황 | 전략 |
|---|---|
| 일상적인 버그 수정 | Rolling Update |
| 새 기능 출시 | Canary + Feature Flag |
| 큰 아키텍처 변경 | Blue/Green |
| 위험한 알고리즘 변경 | Canary |
| 실험적 기능 | Feature Flag (배포는 Rolling) |
| 데이터 마이그레이션과 결합 | Blue/Green |
이 전략들은 서로 배타적이 아니다. 보통 함께 쓴다.
예:
- Rolling Update로 배포
- Feature Flag로 기능 숨김
- Canary처럼 일부 사용자에게만 플래그 활성화
마이크로서비스에서 추가로 고려해야 할 것
1️⃣ 호환성
여러 버전이 동시에 운영되는 동안 API와 이벤트 스키마가 호환되어야 한다.
API 변경 원칙:
- 새 필드는 옵셔널로 추가
- 기존 필드는 한 번에 제거하지 않음
- Breaking change는 새 버전으로 분기
이벤트 스키마 변경 원칙:
- 18장에서 본 멱등성과 함께
- 이전 버전 소비자가 새 이벤트를 무시할 수 있게
2️⃣ DB 마이그레이션
DB 스키마 변경은 배포와 별개의 위험이다.
원칙:
- Expand-then-contract
- 새 컬럼 추가 (Expand)
- 양쪽 코드가 같이 동작
- 옛 컬럼 제거 (Contract)
- 한 배포에서 양쪽을 다 하지 않는다
3️⃣ 서비스 간 배포 순서
A 서비스가 B 서비스의 새 API를 호출한다면
- B를 먼저 배포 (새 API 제공)
- A를 다음에 배포 (새 API 호출)
- 순서가 반대면 A가 깨진다
4️⃣ 자동 롤백 기준
배포 후 메트릭이 나빠지면 자동 롤백.
기준 예:
- 에러율 임계치 초과 (예: 5% 이상)
- p99 지연 시간 50% 이상 증가
- 핵심 비즈니스 메트릭 하락
자동 롤백 없이 100개 서비스를 운영할 수는 없다.
배포의 흐름 (실무 예시)
flowchart TB
Commit[코드 커밋] --> Build[빌드 + 테스트]
Build --> StagingDeploy[Staging 배포]
StagingDeploy --> SmokeTest[Smoke Test]
SmokeTest --> ProdCanary[Production Canary 1%]
ProdCanary --> Metrics{메트릭 OK?}
Metrics -->|No| Rollback[자동 롤백]
Metrics -->|Yes| Increase[비율 증가]
Increase --> Full[100% 배포 완료]
각 단계에서
- 메트릭 확인
- 자동 검증
- 인간 개입 가능 지점
이 구조가 자동화되어 있어야 마이크로서비스의 배포 부담을 감당할 수 있다.
배포는 코드의 일부다
마이크로서비스에서는 배포 전략도 코드 설계의 일부로 봐야 한다.
- 코드는 두 버전이 공존할 수 있게 작성
- API는 호환 가능하게 변경
- DB는 점진적으로 변경
- 기능은 Feature Flag로 분리
이 사고가 없으면 배포할 때마다 손에 땀을 쥐게 된다.
이 장의 핵심
- 마이크로서비스의 독립 배포는 안전한 배포 전략과 함께여야 한다
- Rolling Update — 기본, 일상 배포
- Blue/Green — 한 번에 전환, 즉시 롤백
- Canary — 점진적 노출, 메트릭 기반 결정
- Feature Flag — 배포와 릴리스의 분리, Kill switch
- API·이벤트·DB 호환성, 서비스 간 배포 순서, 자동 롤백을 함께 설계한다
- 배포 전략은 운영 도구가 아니라 코드 설계의 일부다
41장. 실전 사례 — 모놀리스 전자상거래 전환기
지금까지 우리는 마이크로서비스 전환에 필요한 거의 모든 개념과 패턴을 다루었다.
이 장에서는 그 모든 것을 하나의 이야기로 묶는다.
가상의 전자상거래 회사 “ShopX” 가 모놀리스에서 마이크로서비스로 전환하는 여정이다.
각 단계에서 어떤 결정을 내렸고, 어떤 함정을 피했는지를 따라간다.
시작 — 모놀리스 시절
ShopX는 5년 차 전자상거래 회사다.
- 일 주문량 5만 건
- 개발자 30명
- 단일 PHP/MySQL 모놀리스
- 데이터베이스 한 개
- 새 기능 배포는 매주 화요일
문제는 점점 쌓였다.
- 결제 영역만 트래픽이 5배 더 많은데 전체를 확장해야 한다
- 한 팀이 코드를 수정하면 다른 팀이 멈춘다
- 배포가 큰 이벤트가 되었다
- 한 영역 장애가 전체를 멈춘다
이 시점에서 마이크로서비스 전환을 결정했다.
단계 1 — 분리 가능성 확인 (1부의 적용)
먼저 1장에서 던진 질문에 답했다.
정말 지금 넘어가야 하는가?
답:
- 트래픽 패턴이 영역마다 다르다 ✓
- 배포가 조직 전체의 스트레스다 ✓
- 한 장애가 전체를 멈춘다 ✓
- 도메인 경계가 어느 정도 명확하다 ✓
→ 전환 결정.
그리고 2장에서 본 그림자도 인정했다.
- 네트워크 호출 실패를 다뤄야 한다
- 최종 일관성을 받아들여야 한다
- 운영 부담이 늘어난다
각오를 마치고 시작했다.
단계 2 — 점진적 전환 결정 (2부의 적용)
4장의 원칙에 따라 빅뱅 재구축은 제외했다.
작게, 자주, 검증하며 옮긴다.
5장의 Strangler Fig 패턴을 골랐다. 모놀리스 옆에 새 시스템을 두고 한 도메인씩 옮긴다.
단계 3 — 첫 추출 (9장의 적용)
9장에서 본 것처럼 첫 추출은 학습 단계다.
ShopX는 핵심 도메인을 첫 후보로 두지 않았다. 대신 알림 발송을 골랐다.
이유:
- 다른 도메인과 결합이 약하다 (이벤트만 받는다)
- 실패해도 주문이 멈추지 않는다
- 트래픽이 폭발적으로 늘 때가 있다 (마케팅 발송)
flowchart LR
Monolith --> NotificationService
NotificationService --> Email
NotificationService --> Push
NotificationService --> SMS
3개월 후 알림 서비스가 안정적으로 동작했다. 운영팀은 마이크로서비스 운영을 처음 경험했다.
같은 방식으로
- 로그 수집 서비스
- 검색 인덱싱 서비스
- 이미지 처리 서비스
를 차례로 떼어냈다.
1년이 지나자 분리된 서비스가 5개가 되었다.
단계 4 — 막힘 — 모놀리스가 Gateway다
이 시점에서 막혔다.
- 핵심 도메인(주문·결제·상품)을 떼어내려 한다
- 그런데 모놀리스가 모든 입구다
- 인증·라우팅·세션이 모놀리스 안에 묶여 있다
6장에서 본 정확히 그 상황이었다.
flowchart LR
Client --> Monolith
Monolith --> NotificationService
Monolith --> LogService
Monolith --> SearchService
해결책: Gateway를 위로 끌어올린다.
단계 5 — Gateway와 Auth 도입
flowchart TB
Client --> Gateway
Gateway --> Auth
Gateway --> Monolith
Gateway --> NotificationService
Gateway --> LogService
- 새 Gateway가 모든 요청의 입구가 된다
- Auth 서비스가 인증을 전담
- 모놀리스는 토큰을 검증만 한다 (스스로 인증하지 않는다)
이 단계가 가장 위험했다. 세션 기반 인증을 JWT로 바꾸면서 6개월 동안 두 인증 방식이 공존했다.
세션 → 토큰 변경 시 12장의 인증 흐름을 그대로 따랐다.
단계 6 — 핵심 도메인 추출 시작
Gateway가 깔끔해진 후 9장의 매트릭스로 추출 순서를 정했다.
| 도메인 | 효과 | 난이도 | 결정 |
|---|---|---|---|
| 주문 | 큼 (트래픽 집중) | 높음 (강결합) | B (준비 후) |
| 결제 | 큼 (영역 분리 필요) | 매우 높음 (트랜잭션) | B |
| 상품 | 중 (변경 잦음) | 낮음 (조회 위주) | A (우선) |
| 회원 | 큼 (모든 곳에서 참조) | 매우 높음 | D (보류) |
| 배송 | 중 | 중 | A |
A부터 추출 시작.
상품 → 배송 → 주문 → 결제 순으로 옮겨갔다.
단계 7 — 데이터 분리 (7장의 적용)
상품 서비스를 떼어낼 때 7장에서 본 데이터 마이그레이션 절차를 따랐다.
flowchart TB
S1["새 DB 스키마 준비"]
S2["Backfill — 과거 상품 복사"]
S3["CDC로 변경 동기화"]
S4["읽기 트래픽 점진적 전환"]
S5["정합성 검증"]
S6["쓰기 트래픽 전환"]
S7["원래 테이블 폐기"]
S1 --> S2 --> S3 --> S4 --> S5 --> S6 --> S7
이 동안 23장의 Dual-write/CDC 운영을 했다. 드리프트 검증 스크립트를 매 시간 돌렸다.
처음에 0.3%의 드리프트가 발견되어 원인을 찾는 데 2주가 걸렸다.
단계 8 — 이벤트 기반 통신 도입
서비스가 늘어나면서 동기 호출 체인이 길어졌다.
Order → Payment → Inventory → Notification
13장에서 본 장애 전파 위험이 보였다.
해결책: 이벤트 기반 통신으로 전환.
flowchart LR
Order --> EventBus
EventBus --> Inventory
EventBus --> Notification
EventBus --> Analytics
15장의 이벤트 기반 아키텍처 원칙을 따랐고
- 14장의 At-Least-Once 모델 받아들임
- 16장의 멱등성 설계
- 17장의 순서 깨짐 대응
- 20장의 Outbox로 원자성 보장
- 21장의 Outbox + Inbox로 안전한 전달
가 모두 적용됐다.
단계 9 — 정합성 보장
19장에서 본 최종 일관성을 인정하고 22장의 Saga 패턴을 도입했다.
주문 흐름의 보상 처리:
sequenceDiagram
participant Order
participant Payment
participant Inventory
Order->>Payment: 결제 요청
Payment-->>Order: 결제 완료
Order->>Inventory: 재고 차감
Inventory-->>Order: 재고 부족
Order->>Payment: 결제 취소 (보상)
복잡한 흐름은 Orchestration, 단순한 흐름은 Choreography를 썼다.
단계 10 — 복원력과 관측 (7부의 적용)
서비스 수가 늘면서 24·25장의 복원력 패턴이 필수가 되었다.
- 모든 동기 호출에 Timeout
- 외부 의존에는 Circuit Breaker
- 자원 격리 (Bulkhead)
- 비동기는 Backpressure와 DLQ
27장의 관측 가능성 도구를 도입했다.
- Trace ID로 분산 추적
- 메트릭 대시보드
- 중앙 로그 수집 (ELK)
이 인프라 없이는 100개 서비스를 운영할 수 없다.
단계 11 — CQRS 도입 (8부의 적용)
조회 트래픽이 폭증한 상품 영역에 26장의 CQRS를 도입했다.
flowchart LR
Write[Write Model] --> WriteDB[(Write DB)]
Write -->|Event| EventBus
EventBus --> ReadModel[Read Model]
ReadModel --> ReadDB[(Read DB)]
Client -->|조회| ReadModel
Client -->|변경| Write
조회는 비정규화된 Read DB에서. 쓰기는 정규화된 Write DB에서.
조회 성능이 10배 빨라졌다. 포인트 시스템에는 27·28장의 Event Sourcing도 적용했다.
단계 12 — 코드 아키텍처 (9부의 적용)
서비스 안의 코드도 정리해야 했다.
처음에 30장의 레이어 아키텍처를 썼는데 31장에서 본 의존성 문제가 발생했다.
서비스 코드가
- DB
- Kafka
- 외부 API
모두에 의존하게 되었다.
32·33장의 헥사고날 아키텍처로 전환했다.
src/
├─ domain/ ← 비즈니스 로직만
├─ application/ ← 유스케이스
├─ port/ ← 인터페이스
├─ adapter/ ← DB·Kafka·HTTP
└─ config/ ← 의존성 주입
테스트가 훨씬 쉬워졌다.
단계 13 — 함정을 피했는가
3년 후 ShopX의 모습을 살펴보자.
flowchart TB
Client --> Gateway
Gateway --> Auth
Gateway --> ProductService
Gateway --> OrderService
Gateway --> PaymentService
Gateway --> DeliveryService
Gateway --> NotificationService
Gateway --> LegacyMonolith[모놀리스 — 회원만 남음]
- 핵심 도메인 5개가 분리됨
- 모놀리스는 회원 도메인만 남아 마지막 서비스가 됨
- 약 15개의 서비스 (변두리 포함)
37장의 분산 모놀리스 체크리스트로 점검했다.
| 항목 | 결과 |
|---|---|
| 함께 배포해야 하는 서비스 | 없음 |
| 한 장애가 전체 죽음 | 없음 |
| 공유 DB | 없음 |
| 5단계 이상 동기 체인 | 없음 |
| 공유 도메인 라이브러리 | 최소화됨 |
분산 모놀리스는 피했다.
38장의 Nanoservice 체크리스트로도 점검했다.
- 서비스가 15개 정도 — 적정
- 각 서비스가 단독으로 의미 있다 — OK
- 한 팀이 한 서비스 책임 — OK
Nanoservice도 피했다.
무엇을 배웠는가
ShopX의 3년 여정에서 가장 중요한 학습 다섯 가지:
1️⃣ 처음에는 학습이 우선이다
알림·로그·검색 같은 변두리부터 시작해서 운영 경험을 쌓은 것이 결정적이었다.
2️⃣ Gateway는 가장 먼저 분리한다
모놀리스가 Gateway면 핵심 추출이 막힌다.
3️⃣ 데이터 분리는 코드 분리보다 어렵다
데이터 마이그레이션과 정합성 검증이 전환에서 가장 시간을 많이 쓴 부분이었다.
4️⃣ 이벤트 기반은 늦지 않게 도입한다
동기 호출 체인이 길어지면 빨리 이벤트 기반으로 전환해야 한다.
5️⃣ 회귀를 두려워하지 않는다
추출했다가 합친 서비스도 있었다. 처음에 너무 작게 쪼갠 것을 한 번씩 통합하는 결정도 필요했다.
이 장의 핵심
- 마이크로서비스 전환은 한 번의 결정이 아니라 여러 해에 걸친 과정이다
- 1부의 판단 → 2부의 점진적 전환 → 3~9부의 패턴이 단계마다 적용된다
- 첫 추출은 변두리부터, 핵심 도메인은 충분히 준비한 후
- Gateway 위로 끌어올리기는 핵심 추출 전 단계의 결정적 작업이다
- 데이터 분리·정합성 검증·이벤트 기반·복원력·관측 — 모두가 필수다
- 분산 모놀리스와 Nanoservice 함정은 정기적으로 점검해야 한다
- 회귀할 줄 아는 것도 좋은 아키텍처 결정이다
42장. 다시, 마이크로서비스는 의무가 아니다 (에필로그)
긴 여정이 끝났다.
우리는 1장에서 마이크로서비스의 개념과 장점을 보았고 2장에서 그 그림자를 보았다.
그리고 41장까지 경계 설계, 통신, 정합성, 복원력, 데이터 모델, 코드 아키텍처, 함정과 운영을 모두 살펴봤다.
이제 책을 닫기 전에 1장에서 던진 질문으로 돌아간다.
정말 지금 넘어가야 하는가?
우리가 무엇을 얻었고 무엇을 잃었는가
이 책의 모든 패턴을 적용하면 다음을 얻는다.
- 영역별 독립 확장
- 영역별 독립 배포
- 장애 격리
- 조직 자율성
그러나 다음을 잃거나 떠안는다.
- 운영 복잡도
- 네트워크 호출의 불확실성
- 데이터 정합성 책임
- 분산 추적·관측 인프라
- 분산 트랜잭션의 포기
이 균형은 회사마다 다르다. 같은 회사라도 단계마다 다르다.
모든 회사에 같은 답은 없다
작은 회사가 마이크로서비스를 도입하면 필요 없는 복잡성만 떠안는다.
큰 회사가 모놀리스를 고집하면 조직 성장에 시스템이 따라가지 못한다.
답은 항상 “지금 우리 상황“에서 나온다.
이 책이 줄 수 있는 건 다음 둘이다.
- 결정에 필요한 개념들
- 결정 후 마주칠 패턴들
결정 그 자체는 책이 대신해줄 수 없다.
다시 한 번, 핵심 질문
전환 결정의 다섯 가지 질문을 다시 본다.
- 특정 기능만 독립적으로 확장해야 하는가?
- 배포가 조직 전체의 스트레스인가?
- 한 장애가 전체로 확산되는가?
- 팀이 커지면서 코드 충돌이 증가하는가?
- 도메인 경계가 명확히 구분되는가?
여러 개에 “예“라면 전환을 고려한다. 그렇지 않다면 모놀리스 최적화가 더 현명할 수 있다.
이 질문에 정직하게 답하는 것이 첫 번째 일이다.
만약 전환을 결정했다면
2부의 원칙을 기억한다.
한 번에 옮길 수 없다.
- 점진적으로
- 한 도메인씩
- 검증하면서
- 되돌릴 수 있게
빅뱅의 유혹을 거절하는 것이 가장 큰 결정이다.
만약 이미 전환 중이라면
정기적으로 함정을 점검한다.
- 37장의 분산 모놀리스 체크리스트
- 38장의 Nanoservice 체크리스트
- 23장의 정합성 검증
이 점검 없이는 모르는 사이 함정에 빠진다.
만약 전환 후 후회가 든다면
39장에서 본 회귀 사례들이 답이다.
회귀는 실패가 아니다.
이미 분리된 서비스 중 일부를 다시 합치는 결정은 용기 있는 선택이다.
마이크로서비스도 모놀리스도 도구일 뿐이다.
상황에 맞게 바꾼다.
책 너머의 일
이 책은 마이크로서비스의 개념과 패턴에 집중했다.
그러나 실제 전환은 그 이상이다.
- 조직과 팀 구조 (Conway’s Law)
- 컨테이너와 오케스트레이션
- CI/CD 파이프라인
- 보안과 컴플라이언스
- 로컬 개발 환경
- Contract Testing
- Service Mesh
- 다국가/멀티 리전
이 주제들은 각자 책 한 권 분량이다. 필요한 시점에 별도로 학습해야 한다.
이 책은 그 시작점일 뿐이다.
마지막 메시지
이 책 전체를 한 문장으로 줄이면 이렇다.
마이크로서비스는 강력한 도구다. 그러나 모든 시스템에 필요한 도구는 아니다.
이 두 사실을 동시에 인정하는 사람이 좋은 아키텍처 결정을 내린다.
- 도구의 힘을 안다
- 그 비용도 안다
- 자기 상황을 안다
- 결정을 두려워하지 않는다
- 결정을 되돌릴 줄도 안다
이 책이 그 결정에 도움이 되었기를 바란다.
닫는 말
마이크로서비스는 끝없는 여정이다.
오늘 옳은 아키텍처가 내일은 잘못된 것이 될 수 있다.
중요한 것은 흔들리지 않는 원칙이 아니라 상황을 읽고 다시 결정하는 능력이다.
이 책의 모든 페이지가 그 능력을 키우는 데 조금이라도 도움이 되었기를 바란다.
이제 책을 닫고 당신의 시스템으로 돌아가자.
이 장의 핵심
- 마이크로서비스는 도구이지 목적이 아니다
- 모든 회사·모든 단계에 같은 답은 없다
- 전환을 결정하면 점진적으로, 함정을 점검하며 진행한다
- 회귀할 줄 아는 것도 좋은 결정이다
- 이 책은 시작점이다 — 결정과 실행은 당신의 몫이다