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장. 경계를 설계하는 방법
마이크로서비스로 전환하려는 순간
가장 먼저 마주치는 질문은 이것이다.
서비스를 어디서 나눌 것인가?
이 질문에 답하지 못하면
서비스를 여러 개로 나누어도 결국 하나처럼 얽힌다.
마이크로서비스의 핵심은
많이 나누는 것이 아니라 올바르게 나누는 것이다.
기술이 아니라 책임으로 나눈다
서비스를 나누는 가장 쉬운 방법은 기술 기준이다.
- 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의 목적은 “멋진 용어를 아는 것”이 아니다.
목적은 딱 하나다.
책임을 기준으로 경계를 만들고,
공통 개념 때문에 그 경계가 무너지지 않게 하는 것
5장. 경계를 지키는 설계
4장에서 우리는
책임을 기준으로 경계를 나누는 방법을 살펴보았다.
하지만 여기서 멈추면 안 된다.
서비스를 나누는 것보다 더 어려운 것은
그 경계를 유지하는 것이다.
그리고 경계를 무너뜨리는 가장 빠른 방법은
데이터를 공유하는 것이다.
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는 공유하지 않는다
- 다른 서비스 테이블을 직접 보지 않는다
- 필요한 정보는 복제한다
- 과거의 사실은 스냅샷으로 고정한다
경계는 선언이 아니라
설계로 지켜진다.
6장. 경계를 연결하는 방법
서비스를 나누면
이제 하나의 프로세스 안에서 함수 호출하던 세상이 끝난다.
그 자리에
네트워크 호출이 들어온다.
마이크로서비스에서 통신은
단순한 기술 선택이 아니라
결합도를 결정하는 설계 선택이다.
함수 호출과 네트워크 호출의 차이
모놀리스에서는 이런 흐름이 자연스럽다.
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
하나라도 느려지면
전체 흐름이 지연된다.
이 구조는
물리적으로는 분리되어 있지만
논리적으로는 하나의 체인이다.
이것은 또 다른 형태의 분산 모놀리스다.
통신은 경계를 드러낸다
데이터 설계는 경계를 지키는 방법이었다.
통신 설계는
그 경계를 어떻게 연결할 것인가에 대한 문제다.
- 동기를 많이 쓰면 경계는 약해지고
- 비동기를 적절히 쓰면 경계는 단단해진다
마이크로서비스에서 통신은
기술 선택이 아니라
아키텍처 선택이다.
이 장의 핵심
서비스를 나누는 순간
통신은 필수가 된다.
- 함수 호출은 네트워크 호출이 되고
- 실패는 예외가 아니라 전제가 되며
- 통신 방식은 결합도를 결정한다
동기와 비동기는
기술 차이가 아니라
설계 철학의 차이다.
7장. 단일 진입점의 설계
서비스를 여러 개로 나누었다고 해서
외부 세계까지 나눌 필요는 없다.
프론트엔드 입장에서 중요한 것은
서비스 개수가 아니라
접속이 단순한가이다.
마이크로서비스 아키텍처에서는
대부분 하나의 관문이 존재한다.
이것이 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는
- 얇아야 하고
- 정책 중심이어야 하며
- 도메인 로직을 가지지 않아야 한다
- 고가용성을 전제로 설계되어야 한다
경계를 나누는 것만큼
그 경계를 외부와 연결하는 방법도 중요하다.
8장. 채널을 위한 설계
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는
- 채널별 최적화를 담당하고
- 내부 도메인 로직을 보호하며
- 프론트와 서비스 사이의 완충 역할을 한다
하지만
- 얇게 유지해야 하고
- 판단 로직을 흡수하지 않아야 하며
- 또 다른 모놀리스가 되지 않도록 관리해야 한다
마이크로서비스 아키텍처는
서비스 개수보다
경계의 명확성과 역할의 분리가 더 중요하다.
9장. 메시지는 정확히 한 번 오지 않는다
마이크로서비스에서 이벤트 기반 통신을 사용하면
구조는 보통 다음과 같다.
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 위에 설계된다.
그리고 그 전제를 받아들인 순간
다음 질문이 등장한다.
중복과 순서 문제를 전제로
시스템의 상태를 어떻게 설계할 것인가?
10장. 분산 환경에서의 정합성
9장에서 우리는 한 가지를 이해했다.
- 메시지는 중복될 수 있고
- 순서가 바뀔 수 있으며
- 정확히 한 번 전달은 비싸다
이제 질문은 이것이다.
이렇게 불완전한 환경에서
여러 서비스의 상태를 어떻게 맞출 것인가?
모놀리스에서는 이 질문이 단순했다.
모놀리스에서의 정합성
모놀리스에서는 하나의 데이터베이스 트랜잭션이 있었다.
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
중요한 것은 이것이다.
어떤 단계에서
어떤 단계로 바뀔 수 있는지
명확한 규칙이 있어야 한다.
예를 들어:
- 이미 취소된 주문이 다시 결제 완료가 되면 안 된다.
- 결제가 완료되기 전에 배송이 시작되면 안 된다.
이런 규칙이 정해져 있어야
시스템이 꼬이지 않는다.
멱등성은 필수다
9장에서 봤듯이
이벤트는 중복될 수 있다.
따라서 같은 이벤트가 여러 번 와도
결과는 한 번 처리된 것과 같아야 한다.
예:
-
결제 완료 이벤트가 두 번 와도
주문 상태는 한 번만 결제 완료가 되어야 한다. -
포인트 적립 이벤트가 두 번 와도
포인트는 한 번만 증가해야 한다.
멱등성은 선택이 아니라
전제 조건이다.
일시적 불일치를 허용하는 설계
마이크로서비스에서는
모든 것을 동시에 맞출 수 없다.
그래서 다음을 스스로에게 물어야 한다.
- 포인트 적립이 2초 늦어도 되는가?
- 알림이 조금 늦어도 되는가?
- 통계 수치는 몇 분 늦어도 되는가?
모든 것을 강하게 묶으면
시스템은 복잡해지고 확장성이 떨어진다.
최종 일관성을 받아들이는 것은
느슨해지는 것이 아니라
확장성을 선택하는 것이다.
반드시 고민해야 할 질문
정합성을 설계할 때
다음 질문에 답해야 한다.
- 반드시 동시에 완료되어야 하는 작업인가?
- 잠시 어긋난 상태를 허용할 수 있는가?
- 실패 시 되돌리는 방법은 명확한가?
- 상태가 바뀌는 규칙은 정의되어 있는가?
- 같은 이벤트가 여러 번 와도 안전한가?
이 질문을 건너뛰면
분산 시스템은 언젠가 예기치 않게 무너진다.
이 장의 핵심
마이크로서비스에서는
- 하나의 거대한 트랜잭션은 존재하지 않는다.
- 대신 여러 개의 작은 확실함이 있다.
- 전체 정합성은 단계적으로 완성된다.
- 실패는 롤백이 아니라 보상으로 처리된다.
- 상태가 어떻게 바뀌는지 규칙을 설계해야 한다.
- 멱등성은 반드시 필요하다.
정합성은 더 이상
데이터베이스가 책임지지 않는다.
아키텍처와 비즈니스 설계가
그 책임을 가진다.
11장. 이벤트는 항상 순서대로 오지 않는다
10장에서 우리는
정합성을 단계적으로 맞추는 설계를 배웠다.
하지만 그 설계에는 중요한 전제가 있다.
이벤트가 우리가 기대하는 순서로 도착한다.
현실은 다르다.
- 네트워크 지연
- 재시도
- 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라면
이전 단계 이벤트는 무시한다.
순서를 완벽히 보장하려 하지 말라
브로커 설정으로
대부분의 순서 문제는 해결할 수 있다.
하지만 설계 원칙은 이것이다.
순서가 깨져도 시스템이 무너지지 않도록 설계하라.
브로커는 편의를 제공한다.
안전성은 설계가 만든다.
이 장의 핵심
이벤트 기반 시스템에서는
- 순서는 범위 제한적으로만 보장된다.
- 키 단위 스트림 설계가 중요하다.
- 해당 스트림은 동시에 하나만 처리해야 한다.
- 전달 순서와 처리 완료 순서는 다를 수 있다.
- 외부 이벤트는 순서를 보장하지 않는다.
- 애플리케이션이 최종 방어를 해야 한다.
12장. 트랜잭션과 이벤트의 원자성
10장에서 우리는 정합성을 단계적으로 맞추는 방식을 배웠다.
11장에서 이벤트 순서 문제를 다루었다.
하지만 아직 해결하지 않은 치명적인 틈이 하나 있다.
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 모두 중복 가능성이 있다.
- 멱등성은 필수다.
13장. 동기 호출 기반 복원력
6장에서 우리는 서비스 간 통신 방식을 다루었다.
동기 호출과 비동기 이벤트는 각각 장단점이 있다.
9장부터 12장까지는 이벤트 기반 구조를 깊이 있게 살펴보았다.
그렇다면 이런 질문이 자연스럽게 떠오른다.
그럼 모든 통신을 비동기로 만들면 되지 않을까?
현실은 그렇지 않다.
마이크로서비스는 모든 것을 비동기로 처리할 수 없다.
일부 흐름은 반드시 “즉시 응답”을 요구한다.
예를 들어:
- 로그인 요청 → 토큰 발급 후 즉시 응답
- 결제 승인 요청 → 승인 여부 즉시 반환
- 관리자 화면 조회 → 여러 서비스에서 데이터 모아 즉시 반환
이런 흐름은 이벤트로 나중에 처리할 수 없다.
동기 호출은 현실적으로 피할 수 없는 선택이다.
문제는 이것이다.
동기 구조에서는 장애가 빠르게 전파된다.
그래서 동기 호출에는 별도의 복원력 설계가 필요하다.
동기 호출에서 장애가 전파되는 방식
다음과 같은 구조를 생각해보자.
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는 필수 전략이다.
- 완전한 정상보다 부분 동작 유지가 더 중요할 수 있다.
14장. 비동기 시스템의 장애와 복원 전략
동기 호출에서는 장애가 즉시 드러난다.
느려지면 바로 대기하고, 바로 전파된다.
비동기 구조는 다르다.
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는 격리 공간이다.
- 멱등성은 전제 조건이다.
- 모니터링 없이는 복원력도 없다.
15장. 관측 가능성
마이크로서비스에서는 하나의 요청이 한 서비스 안에서 끝나지 않는다.
예를 들어 사용자가 결제 버튼을 누르면 다음과 같은 흐름이 발생한다.
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 같은 중앙 시스템에서 검색한다.
- 관측 가능성은 복원력의 기반이다.
16장. 읽기와 쓰기의 모델을 분리하다 (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이 만나면
데이터를 바라보는 방식 자체가 달라진다.
17장. 상태 대신 이벤트를 저장하다 (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은
그 결과를 만들어낸 과정을 저장한다.
현재 상태는 저장된 값이 아니라
이벤트의 누적 결과일 뿐이다.
이제 질문이 남는다.
이벤트는 수정할 수 없다면
잘못된 차감은 어떻게 되돌릴 것인가?
다음 장에서는
보상 이벤트와 이벤트 불변성에 대해 다룬다.
18장. 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 변경이 아니라 메시지 진화다.
19장. 동시성과 일관성
동시에 차감 요청이 오면 어떻게 할 것인가
사용자의 포인트가 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과
현실적인 혼합 구조는 다르다. -
어떤 데이터를 기준으로 삼을지
반드시 설계 단계에서 정해야 한다.
20장. 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 두 가지다.
- 선택은 비즈니스 복잡도에 따라 달라진다.