마이크로서비스 — 나누는 순간 분산이 시작된다
서비스를 잘게 나누면 독립 배포와 독립 확장이라는 큰 이득을 얻는다. 팀마다 자기 서비스를 따로 배포하고, 부하가 몰리는 서비스만 골라 키울 수 있다. 그런데 이득에는 대가가 따른다 — 모놀리스에서는 함수 호출 한 번, 트랜잭션 하나로 묶기였던 것이 이제 네트워크 너머로 가는 순간, 모든 게 어려워진다. 마이크로서비스의 핵심 과제는 결국 이 "분산이 가져온 어려움"을 다루는 일이다 — *분산 트랜잭션(Saga)·이중 쓰기(Outbox)·복원력(서킷 브레이커)*을 차례로, 왜 그래야 하는지까지 본다.
나누면 DB가 갈라진다 — 그리고 2PC는 왜 안 쓰나
MSA의 원칙은 서비스마다 자기 DB를 갖는 것(database per service)이다. 그래야 서비스가 진짜로 독립된다 — 남의 테이블을 함부로 건드릴 수 없으니. 그런데 바로 이 순간 여러 서비스에 걸친 ACID 트랜잭션이 불가능해진다. "주문하고 결제하고 재고를 줄이는" 일이 세 개의 서로 다른 DB에 흩어지기 때문이다.
그렇다면 분산 트랜잭션(2PC, 2단계 커밋)을 쓰면 되지 않을까? 안 쓴다. 2PC는 *조율자(coordinator)*가 모든 참여자에게 "준비됐나?"를 묻고(phase 1) 모두 YES면 "커밋하라"(phase 2)고 지시하는 방식인데, 여기엔 치명적인 블로킹 문제가 있다 — 참여자들이 YES를 투표한 뒤 조율자가 phase 2에서 죽으면, 참여자들은 락을 쥔 채 조율자가 살아날 때까지 멈춘다. 조율자가 30초 만에 복구되면 그 트랜잭션에 걸린 모든 행이 30초간 잠긴다. 게다가 조율자는 단일 장애점이고, 모든 참여자가 서로를 기다리며 자원을 붙잡아 가용성과 확장성을 해친다. (출처: The Two-Phase Commit Problem.) 그래서 정석은 ACID는 한 서비스 안에 가두고, 서비스 사이는 강한 일관성을 포기하고 결과적 일관성으로 조율하는 것이다. 그 방식이 Saga다.
분산 트랜잭션의 대안 — Saga
Saga는 여러 로컬 트랜잭션의 연쇄다. 각 서비스가 자기 트랜잭션을 커밋하고 다음 단계를 트리거하며 진행한다. 중간에 실패하면 통째로 롤백할 수가 없으니(이미 앞 단계들이 커밋됐다) — 대신 보상 트랜잭션, 곧 이미 한 일을 되돌리는 역연산(결제 취소·재고 복원)으로 만회한다. ACID 대신 **BASE(결과적 일관성)**를 받아들이는 것이다 — Saga가 끝나는 시점에는 시스템이 일관된 상태에 도달한다.
방식은 둘로 갈린다. *코레오그래피(choreography)*는 중앙 조율자 없이 각 서비스가 이벤트를 발행하고 다른 서비스가 그 이벤트에 반응하며 스스로 진행한다("주문 생성" 이벤트 → 결제·재고가 각자 반응). 단순하고 결합이 느슨하지만, 참여자가 많아지면 이벤트가 사방으로 튀어 전체 흐름을 한눈에 보기 어렵다. *오케스트레이션(orchestration)*은 *중앙 조율자(saga 코디네이터)*가 "이거 해 → 응답 기다림 → 저거 해" 순서를 명령으로 지휘하고 실패 시 보상을 부른다 — 흐름이 한눈에 보이고 복잡한 사가에 유리한 대신, 조율자가 한 점이 된다. (출처: Saga: Orchestration vs Choreography.) 어느 쪽이든 핵심은 — 2PC의 깨지기 쉬운 글로벌 락 대신, 트랜잭션을 되돌릴 수 있는 로컬 단계들의 연쇄로 설계해 글로벌 락 없이 확장성과 장애 내성을 얻는 것이다.
상태와 이벤트가 어긋날 때 — Outbox
그런데 Saga의 각 단계를 자세히 보면 미묘한 문제가 숨어 있다. 한 단계는 보통 "DB를 갱신하고 + 다음을 위한 이벤트를 발행"하는데, 이 둘을 원자적으로 하기가 어렵다. DB는 커밋됐는데 이벤트 발행이 실패하면(혹은 그 반대면), 상태와 이벤트가 어긋난다 — 결제는 됐는데 알림 이벤트가 안 나가는 식이다. 이게 이중 쓰기(dual write) 문제다.
해법이 Outbox 패턴이다. 이벤트를 곧장 브로커로 보내는 대신, 비즈니스 데이터와 똑같은 로컬 트랜잭션으로 Outbox 테이블에 기록해 함께 커밋/롤백되게 하고, 별도 릴레이(Debezium 같은 CDC)가 그 테이블 변경을 읽어 발행한다(07편에서 본 그것이다). 다만 발행 단계가 at-least-once라 중복이 생길 수 있으니, 멱등 컨슈머와 짝지어야 "골드 스탠더드"가 된다.
서비스를 어떻게 찾고 부르나 — 게이트웨이와 디스커버리
서비스가 여럿이 되면 클라이언트가 그 많은 서비스를 어떻게 부르느냐도 문제다.
API 게이트웨이(Spring Cloud Gateway)가 단일 진입점이 되어 라우팅·인증·레이트리밋·응답 집계를 맡으니, 클라이언트는 내부 토폴로지를 몰라도 된다. 그리고 서비스 디스커버리(Eureka·Consul·쿠버네티스 DNS)가 있어, 인스턴스가 레지스트리에 등록하면 호출자는 이름으로 찾아 부른다(lb://order-service). 이때 어느 인스턴스로 보낼지는 Spring Cloud LoadBalancer가 클라이언트 쪽에서 고르는데(클라이언트 사이드 로드밸런싱), 그래서 동적으로 바뀌는 IP와 스케일에 자동으로 대응한다. 다만 게이트웨이가 각 서비스의 비즈니스 규칙까지 대신하면 안 된다 — 그건 비대해진 게이트웨이라는 안티패턴이고, 도메인 규칙은 어디까지나 각 서비스의 몫이다.
장애가 번지지 않게 — 서킷 브레이커, 그리고 그 너머
네트워크 너머의 호출은 실패와 지연이 일상이다. 그래서 한 서비스의 장애가 호출 사슬을 타고 번지는 연쇄 장애를 막아야 한다.
서킷 브레이커(Netflix Hystrix가 개발 중단된 뒤 Resilience4j가 표준이 됐다)는 호출 실패율이 임계치를 넘으면 Open으로 전환해 그 서비스 호출을 즉시 차단(빠른 실패 + 폴백)한다. 일정 시간 뒤 Half-Open으로 시험 호출을 해 보고, 회복됐으면 Closed로 돌아온다. 왜 차단이 오히려 회복을 돕느냐면 — 장애 난 서비스에 계속 요청을 퍼부으면 그쪽은 더 죽고 이쪽도 대기 스레드가 쌓여 함께 죽는다. 반대로 빨리 끊고 폴백하면 나는 살아남고, 장애 서비스도 부하가 빠져 회복할 틈을 얻는다. 이 서킷 브레이커는 재시도·타임아웃·벌크헤드·레이트리밋과 함께 쓰이는데(회복탄력성 편의 주제다), 흥미로운 흐름은 — 이 모든 복원력·라우팅·보안 로직을 애플리케이션 코드에서 떼어내 사이드카 프록시(서비스 메시, 예: Istio)로 옮기는 것이다. 그러면 각 서비스는 비즈니스에만 집중하고, 횡단 관심사는 메시가 코드 밖에서 처리한다. (출처: Spring Cloud & Service Mesh.)
마지막으로, 한 요청이 여러 서비스를 가로지르니 어디서 느려지고 터졌는지를 알려면 분산 추적(traceId를 서비스마다 전파)이 필수다 — 이것이 관측성 편으로 이어진다.
정리하면, 마이크로서비스는 나누는 순간 분산이 시작되어 — DB가 갈라져 2PC는 블로킹·단일 장애점으로 못 쓰니 *Saga(로컬 트랜잭션 연쇄 + 보상, 코레오그래피/오케스트레이션)*로 결과적 일관성을 받아들이고, 그 과정의 이중 쓰기는 Outbox + 멱등 컨슈머로 메우며, 서비스는 *게이트웨이와 디스커버리(클라이언트 사이드 LB)*로 찾아 부르고, 장애 전파는 *서킷 브레이커(Resilience4j)*로 끊는다(나아가 서비스 메시로 코드 밖으로 뺀다). 무엇보다 잊지 말 것은 분산은 공짜가 아니라 결과적 일관성·관측성·운영 복잡도를 떠안는 선택이라는 점이다 — 그래서 "작게 시작하라"는 조언이 늘 따라붙는다.