너의 실패는 나의 실패가 아니야 - Self-contained Service

안녕하세요! 강남언니에서 병원 관련 백엔드 개발을 하고 있는 제이크와 하워드입니다.
강남언니는 병원, 후기, 예약 등의 도메인 영역에서 문제를 해결하고 있고, 이를 반영해 여러 개의 분산된 서비스들이 배치되어 있습니다. 분산 서비스 환경을 운영하다 보면, 한 서비스의 장애가 의존하는 서비스로 전파되는 순간을 한 번쯤 만나게 됩니다. 강남언니도 예외는 아니었고, 실제로 하나의 서비스 장애가 연쇄적으로 번지면서 전체 서비스가 영향받은 적이 있었습니다. 정말 식은땀 나는 경험이었죠.
이러한 장애를 겪고 나서 저희는 어떻게 장애 전파를 잘 관리하고 최소화할 수 있을지 고민했습니다. 병원 서비스에서 장애가 발생해도, 상품 서비스는 정상 작동시키겠다는 목표를 세웠습니다. 그리고 이 상황에서도 상품 서비스에서는 최대한 정합성이 어긋나지 않는 병원 정보를 전달하고자 했습니다. 목표 달성을 위한 선택지들을 고민했고 여러 방법을 검토한 후에 최종적으로 Self-contained Service(SCS)를 선택했습니다.
참고로 이 글에서 말하는 ‘상품’은 일반적인 커머스 상품이 아니라, 병원에서 제공하는 시술 이벤트를 다루는 도메인을 의미합니다. 독자분들의 편의를 위해 시술 이벤트 대신 상품이라는 단어를 사용했습니다.
이 글에서는 이 선택을 하기까지의 고민의 과정을 이야기합니다. 강남언니와 같은 분산 서비스 환경에서 장애가 어떻게 전파될 수 있는지, 그리고 그 전파를 최소화하기 위해 어떤 고민을 했고 SCS를 도입하면서 무엇을 신경 썼는지를 공유해보려고 합니다.
장애 전파를 최소화하기 위해 고민하시는 분들과 SCS 도입을 고민하시는 분들께 도움이 되기를 바랍니다.
Domain과 Bounded Context
먼저 병원과 상품에 대해 간단히 설명드리겠습니다. 병원은 말 그대로 강남언니에 등록되어 있는 성형외과/피부과 등을 지칭합니다. 상품은 앞서 말씀드렸듯 병원들이 강남언니에 등록하는 이벤트성 시술 상품을 의미합니다.
강남언니 서비스에서 병원과 상품은 별도의 문제 영역을 가지고 있고, 이는 별도의 문제 해결 영역인 Bounded Context를 구성하고 있습니다.

상품은 병원 Bounded Context에 의존하고 있습니다. 예를 들면, 상품이 등록되거나 수정될 때 병원 정보를 이용하여 글로벌 고객 응대 가능 여부 등의 유효성 검사를 수행해야 합니다. 또한 강남언니 사용자가 특정 상품을 조회할 때도 병원 정보를 함께 불러와 응답 모델을 조립하는 API가 존재합니다. 즉, 상품의 비즈니스 로직을 수행하거나 응답을 구성할 때 병원에 의존하는 부분들이 있습니다.
따라서, 병원 서비스에 장애가 발생하면 상품 서비스가 영향을 받게 됩니다. 그렇게 되면 상품의 등록/수정이 불가능해지고, 앱 내 상품의 노출에도 영향이 가게 됩니다.
장애전파를 막기 위한 고민

옵션 1 : Circuit Breaker + Fallback
병원 서비스에 장애가 나서 트래픽을 처리하지 못하고 요청이 쌓이고 있는 상황을 가정해 봅시다. 상품 서비스는 병원 서비스의 응답을 기다리느라 타임아웃이 나거나, 응답 지연이 길어지면서 전체 처리량이 급격히 떨어질 수 있습니다.
이때 상품 서비스에 Circuit Breaker를 적용했다면, 실패하는 요청을 계속 병원 서비스로 보내지 않고 빠르게 실패(Fail fast) 시킬 수 있었을 것입니다. 그리고 그 즉시 Fallback으로 전환해 “병원 정보 없이도 가능한 범위”의 응답을 만들어낼 수도 있었겠죠. 즉, 동기 호출로 인해 요청이 길게 붙잡히는 상황을 피하고, 상품 서비스 자체의 자원을 보호하는 데는 분명 도움이 됩니다.
다만 이것만으로는 “병원 서비스가 멈춰도 상품 서비스는 최대한 정합성이 일치하는 병원 정보를 제공할 수 있어야 한다”라는 목표를 달성하기 어렵다고 판단했습니다. Fallback으로 빈 데이터를 내려주거나 제한된 응답을 구성할 수는 있어도, 상품의 등록/수정 같은 핵심 기능은 병원 정보에 대한 검증과 조합이 필요합니다. 결국 대부분의 중요한 기능은 정상적으로 제공하기 어렵고, 서비스 운영 관점에서는 “멈추지 않게 보이지만 할 수 있는 게 거의 없는” 상태에 가까워질 가능성이 컸기 때문입니다.
옵션 2 : Hospital 데이터 캐싱
캐시는 분명 매력적인 대안이었습니다. 상품 서비스에서 병원 정보를 조회할 때 Redis 등에 저장해 둔다면 호출 비용을 아끼고 응답 속도도 획기적으로 높일 수 있으니까요. 원본 서비스에 장애가 생겨도 캐시에 데이터가 남아있는 동안은 버틸 수 있다는 점도 장점이었습니다.
하지만 캐시는 본질적으로 “언제든 날려버릴 수 있는 데이터”로 취급된다는 명확한 한계가 있었습니다. 캐시는 주로 응답 속도를 높이거나, 서비스간 과도한 트래픽을 예방하고자 하는 목적으로 많이 사용됩니다. 캐시 미스가 나면 원본에서 그 정보를 가지고 올 수 있음을 자연스레 가정합니다. 그래서 캐시가 비워지면, 결국 다시 원본 서비스에 의존해야 합니다. 아키텍처 관점에서 캐시는 "없어지면 단지 조금 느려질 뿐"이어야지, "없어지면 서비스가 중단되는" 존재가 되어서는 안 됩니다. 즉, 캐시만으로는 장애 상황에서의 완전한 가용성을 보장할 수 없어서, 병원 서비스가 멈춰도 상품 서비스가 돌아가야 하는 목표를 만족하기 어려웠습니다.

또 하나의 차이는 모델링 관점에 있습니다. 캐시는 보통 원본 데이터를 그대로(JSON 덩어리째) 저장하거나 단순한 Key-Value 형태로 저장합니다. 도메인 규칙과 불변 조건은 여전히 원본 BC에 남아 있고, 캐시는 그저 빠르게 읽기 위한 복사본일 뿐입니다. 반면 후술할 SCS에서의 복제본은 이 데이터를 해당 BC의 도메인 모델로 인정하고, 그 위에 비즈니스 규칙까지 함께 올리겠다는 선택에 가깝습니다. 병원 레플리카와 상품을 같은 BC 안에서 다루면서, 다른 BC에 질의하지 않고도 스스로 결정하고 응답할 수 있도록 만드는 것이죠.
우리가 선택하고 싶었던 것은 단순히 '속도가 빠른' 서비스가 아니라, 원본 서비스가 멈추더라도 '자기 힘으로 버틸 수 있는' 자립적인 서비스였습니다. 그래서 우리는 캐시를 조금 영리하게 쓰는 수준을 넘어, 도메인 이벤트를 기반으로 데이터를 내재화하는 SCS 패턴을 도입하는 쪽으로 방향을 잡았습니다.
해결책 : Self-contained Service(SCS)
결론적으로 저희는 이렇게 분산 서비스 환경에서 발생할 수 있는 가용성 문제를 SCS를 통해 해결하기로 결정했습니다. SCS의 핵심 철학은 “동기 요청을 처리하는 코드 경로에서 다른 서비스의 응답을 기다리지 않는다”는 것입니다.
데이터 복제
저희가 선택한 방식은 복잡하지 않습니다. 병원 변경 이벤트를 트리거로 삼아, 병원 서비스에서 최신 스냅샷을 읽어 와 상품 DB에 복제본을 덮어쓰는 방식입니다.
강남언니 시스템은 Event-driven Architecture를 추구하고 있기 때문에 대부분의 Aggregate 변경은 이벤트 발행을 보장합니다. 데이터 복제를 원하는 서비스에서는 발행된 이벤트를 수신하여 데이터를 받아오고 이를 자신의 서비스에 저장합니다. 또한 강남언니에서는 Thin event payload를 사용하고 있으므로 이벤트 수신 후 데이터를 가져오려면 데이터를 가져오기 위한 API를 한 번 호출해야 합니다. 이 과정을 그림으로 표현하면 아래와 같은 그림이 됩니다.

이벤트 전달 정책과 중복 내성
이벤트를 기반으로 데이터 복제를 수행하기 위해 신경 써야 할 지점들이 몇 가지 존재합니다.
그중 하나가 이벤트 전달 정책이고, 이 정책에 따라 소비자가 어떻게 대응해야 할지가 달라집니다. 일반적으로 이벤트 전달 방식은 At-least-once, Exactly-once, At-most-once 정도로 분류해 볼 수 있습니다. 현실에서 사용하는 메시지 브로커나 이벤트 스트리밍 시스템에서는 메시지 손실은 없지만 중복은 허용하는 At-least-once 방식이 가장 흔합니다.
At-least-once 방식을 통해 이벤트를 수신한다면 소비자 측에서 반드시 신경 써야 하는 것이 있습니다. 바로 멱등성입니다. 상품 서비스는 동일한 내용의 병원 변경 이벤트를 여러 번 수신하더라도 한 번 처리한 것과 동일한 상태를 유지해야 합니다. 다만 저희는 “동일 이벤트를 여러 번 처리해도 항상 같은 결과가 나오는” 수준의 엄밀한 멱등성까지를 목표로 하진 않았습니다. 저희가 원했던 것은, 가능한 단순한 방법으로 이벤트 중복 여부와 관계없이 원본의 최신 상태를 빠르게 반영해 복제 데이터를 최신으로 유지하는 것이었습니다. 그래서 이벤트를 ‘데이터 변경 명세’가 아니라 ‘최신 상태 동기화를 시작하는 트리거’로 정의하고, 병원 ID만 담은 Thin event를 사용했습니다. Thin event 는 Fat event 에 비해 이벤트 버전 관리 부담이 적기 때문에 운영 관리가 쉬운 장점이 있습니다.
Thin event: 이벤트에 식별자만 담고, 나머지 정보는 추가 조회로 보완하는 방식Fat event: 이벤트 분석과 처리에 필요한 속성까지 넉넉히 담아, 소비자가 추가 조회 없이 바로 쓰게 하는 방식
이 방식을 사용하면 소비자는 이벤트를 수신할 때마다 원본의 최신 스냅샷을 조회하여 로컬 DB에 그대로 덮어쓰는(Upsert) 연산을 수행하게 됩니다. 이는 같은 이벤트가 여러 번 처리되더라도 데이터가 중복 누적되거나 잘못된 상태로 연산 되는 부작용이 없는 방식입니다.
물론, 엄밀한 멱등성을 보장하기 위해 처리한 이벤트 ID를 별도 저장소에 기록하여 중복을 막는 방식이나, 멱등 키를 이용하는 방법도 있습니다. 저희가 채택한 “Thin event 수신 후 최신 상태 조회” 방법은 중복 여부와 무관하게 원본의 최신 상태를 동기화시킬 수 있습니다. 이는 우리의 목적을 충분히 만족하는 선택이었습니다. 다만, 이 접근 방식은 이벤트 수신을 트리거로 원본 조회를 수행하기 때문에 고트래픽 환경에서는 Fat event와 비교해 비용효율성을 평가해야 합니다.
이벤트의 순서
그렇다면 이벤트 순서는 신경 쓰지 않아도 되는 걸까요? 만약 특정 필드 변경 내용을 담은 Fat event 이벤트를 순서대로 적용해야 최종 상태가 맞아떨어지는 설계였다면, 키 단위의 순서 보장이 매우 중요했을 것입니다. 하지만, 이 사례처럼 Thin event + 최신 스냅샷을 조회하는 방식에서는 각 이벤트가 단지 “동기화 트리거” 역할만 하게 됩니다. 따라서 개별 이벤트의 도착 순서는 최종 복제 상태의 정확성에 크게 영향을 주지 않습니다.
초기 일괄 적재와 재구축 전략
초기 일괄 적재와 재구축 전략도 함께 고민해야 합니다. Fat event를 다룬다면 이미 발행되었던 이벤트를 재생하는 방식만으로도 원본 서비스 호출 없이 데이터를 다시 만들어볼 수 있습니다. Thin event를 다룬다면 구성할 데이터들의 ID를 먼저 조회한 뒤, 각 ID에 대해 API나 DB를 호출해 복제본을 채우게 됩니다. 또는 원본 서비스에 요청해서 전체 데이터에 대한 이벤트를 발행하는 방법도 있을 것입니다.
저희는 Thin event를 사용하고 있었기 때문에, 우선 복제 대상이 되는 데이터들의 ID를 준비한 다음 이 ID들을 기반으로 메시지 큐에 이벤트를 다시 발행하는 방식을 선택했습니다. 이벤트 처리기는 큐에 쌓인 이벤트를 소비하면서 API를 통해 원본 데이터를 조회하고, 그 결과를 이용해 복제본을 만들어냅니다. 재구축이 필요할 때도 같은 패턴을 그대로 활용해 큐에 이벤트를 재발행하는 전략을 사용했습니다. 이미 이벤트 처리기를 멱등하게 구현해두었기 때문에, 저희 입장로서는 이 방식이 가장 구현 난도가 낮은 선택이기도 했습니다. 물론 복제해야 하는 필드가 정말 단순하다면, 큐를 거치지 않고 원본 DB에서 값을 직접 읽어와 초기 데이터를 채우는 것도 가능합니다.
다만 이런 방식을 사용할 때는 반드시 원본 서버나 DB에 과도한 부하가 가지 않도록 신경 써야 합니다. 한 번에 너무 많은 건을 조회하면 서버나 DB에 큰 부하를 줄 수 있습니다. 따라서 Message Queue에 이벤트를 어떤 크기의 묶음으로 나누어 밀어 넣을지, 어떤 시간대(예를 들어 새벽 시간대)에 작업을 수행할지 등을 함께 고려할 필요가 있습니다.
결과적 일관성과 즉시 일관성
이런 SCS는 데이터 복제의 관점에서 CQRS(Command Query Responsibility Segregation)와 유사합니다. CQRS 패턴에서 쓰기 모델의 데이터가 변경되면 이 변경사항이 즉시 읽기 모델에 반영되는 것이 아니라, 어느 정도의 지연 시간을 두고 전파됩니다.
SCS 패턴에서 복제한 데이터도 원본 도메인의 이벤트를 수신받아 처리하는 과정을 거쳐 복제본에 변경사항이 반영됩니다. 이에 따라 두 데이터 사이 일치하는 시점까지 지연이 필연적으로 발생합니다. 하지만 이벤트를 끝까지 수신받아 처리한다면 복제된 데이터는 원본 데이터와 동일 값에 도달합니다. 이를 결과적 일관성(Eventual Consistency)이라고 합니다. 언젠가는 같아짐을 보장하는 것이죠.

즉시 일관성은 이보다 훨씬 직관적입니다. 읽기와 쓰기가 한 곳에서 (일반적으로 하나의 데이터베이스)에서 일어남을 의미합니다. 쓰기 작업이 완료되는 대로 즉시 그 데이터를 읽을 수 있고, 아주 잠시의 불일치도 허용하지 않습니다. 단순하지만 이 모델이 실패했을 때 연결된 서비스들로 실패가 전파됩니다.
CQRS는 쓰기(Command)를 담당하는 모델과 읽기(Query)를 담당하는 모델의 책임(Responsibility)을 분리(Segregation)하는 패턴입니다. 흔히 오해하는 부분이지만, 이는 단순히 물리적 분리가 아니라 개념적 분리를 통해 트랜잭션 모델을 쿼리 관심사로 어지럽히지 않으려는 의도가 본질입니다. 쓰기 작업은 복잡하고 오래 걸리며 자주 일어나지 않는 특징이 있다면, 읽기 작업은 비교적 단순하고 매우 자주 일어나는 특징이 있습니다. 이 두 책임을 각각 다른 모델이 최적화된 형태로 맡음으로써 성능상 이점을 얻을 수 있습니다. 특히 읽기 모델의 속도를 최대로 만들어야 할 때 유용하며, 필요한 비정규화된 데이터를 미리 준비해 저장할 수 있습니다. 다만 CQRS가 기술적으로 항상 결과적 일관성을 만들지는 않으며, 읽기 모델에 즉시 일관성을 적용하는 경우도 많습니다.
결과적 일관성과 도메인 정책
앞서 말씀드렸듯 SCS를 채택하면 결과적 일관성은 필연적으로 고려해야 할 요소입니다. 따라서 데이터를 복제해 비즈니스에 활용한다는 것은 단순한 기술적 구현의 문제를 넘어섭니다. '데이터가 잠시 다를 수 있다'라는 사실을 전제로 한 비즈니스 정책의 변화가 필요하므로, PO(Product Owner)나 현업 담당자와 긴밀한 논의가 선행되어야 합니다.
예를 들어보겠습니다. 병원 BC에서 글로벌 고객 응대 가능 여부가 변경되었습니다. 즉시 일관성 모델이라면 상품을 생성할 때 실시간으로 병원 상태를 체크하여 생성을 막을 수 있습니다. 하지만 결과적 일관성 모델에서는 병원의 상태 변경 이벤트가 상품 서비스에 도달하기까지 시차가 발생합니다. 그 사이에 상품 서비스는 여전히 '글로벌 고객 응대 가능' 상태로 인지하고 상품 생성이 이루어질 수도 있습니다.
따라서 이러한 변화는 단순히 “조금 늦게 반영될 수 있다”를 받아들이는 수준을 넘어, 그 지연 구간에서 어떤 행동을 허용하고 어떤 행동은 금지할지에 대한 합의를 다시 세운다는 뜻이기도 합니다. 결국 SCS를 도입한다는 것은, 이런 식의 타이밍·정책·보정 전략을 포함해 도메인 규칙을 ‘즉시 일관성 세계’에서 ‘결과적 일관성 세계’로 옮겨 적는 작업을 함께 수행하는 일이라고 볼 수 있습니다.
SCS의 단점
결과적 일관성의 세계이기 때문에, 원본 도메인 데이터와 복제한 데이터가 불일치 하는 구간이 존재합니다. 이 기간 동안 발생하는 요청에 대해서 시스템은 최신 버전의 데이터를 응답하지 못할 수 있습니다.
그래서 최신 데이터가 항상 필요한 민감한 정보의 경우에는 SCS 사용이 부적합합니다. 반대로 약간 늦은 데이터도 의미가 있는 경우엔 잘 어울립니다. 병원 정보는 약간 과거 데이터라도 의미가 있는 데이터이기 때문에 이런 정보 불일치 리스크는 감당이 가능한 수준이었습니다.
다만 이벤트 처리가 오랜 기간 수행되지 못하는 문제 상황에서는 불일치 구간이 예상보다 길어질 수 있습니다. 예를 들어 컨슈머 장애나 메시지 쌓임으로 이벤트 처리가 지연되면, 데이터가 최신 상태로 따라잡는 데 시간이 더 걸릴 수 있습니다.

그리고 복잡합니다. 복제 데이터를 보관할 데이터베이스를 준비해야 하고, 이벤트 처리를 추가해야 하기 때문입니다. 문제가 발생하면 어디서 문제가 발생하고 있는지 파악하기 위해 살펴볼 곳들도 배로 늘어납니다.
그래도 SCS를 선택한 이유
이처럼 SCS는 분명 만능 해결책이 아닙니다. 그럼에도 불구하고 저희가 SCS를 선택한 이유는, 우리가 달성하고자 하는 목표가 무엇인지 명확했기 때문입니다.
“병원 서비스가 멈춰도 상품 서비스는 멈추지 않게 만들기.”
저희에겐 강남언니 제품의 핵심 도메인인 상품 서비스의 가용성이 그만큼 중요하기 때문입니다. SCS를 선택하면서 얻게 되는 “데이터가 조금 늦게 반영될 수 있다”라는 특성은, 이 목표를 달성하기 위해 충분히 감수할 수 있는 가치 교환이라고 판단했습니다.
또한, SCS를 선택하면서 얻는 복잡도는 어느 정도 예측 가능한 비용이라는 점도 선택 이유였습니다. 데이터를 복제하고, 멱등성을 지키고, 복제본을 재구축하는 것은 도입 초기에 충분히 준비할 수 있는 요소들입니다. 반면 동기 호출로 인한 장애는 그 패턴을 예측하기 어렵고, 문제가 생겼을 때 치러야 하는 비용도 큽니다. 저희는 불확실한 장애 전파 리스크를 안고 가기보다는, 초기에 설계하고 운영할 수 있는 복잡성을 선택하는 편이 더 합리적이라고 판단했습니다.
변화의 결과
이 작업을 하면서 이루고자 했던 목표였던 장애 격리 및 최대한의 데이터 정합성 확보는 결과적 일관성을 레버리지 하여 달성할 수 있었습니다. 일관성을 포기하며 가용성을 확보한 것입니다. 병원 서비스에 장애가 발생하더라도 상품 서비스는 장애에 큰 영향을 받지 않게 되었습니다.
적용 이후 월 단위로 집계한 상품 API의 가용성은 P99.999를 만족했고, 장애 전파로 인한 다운타임은 관측되지 않았습니다.
추가로 HTTP API 통신이 아닌 DB 호출로 병원 정보를 불러올 수 있었기 때문에 레이턴시 감소 효과가 있었습니다.
마치며
이번 글에서는 Self-contained Service 패턴을 도입하여 장애 격리와 가용성 확보라는 목표를 달성한 과정을 살펴보았습니다. 다만 이 여정을 되돌아보며 자연스럽게 드는 질문이 있습니다.
"과연 이것이 최선의 선택이었을까?"
BC를 통합하는 방법은 어땠을까?
SCS를 도입하는 대신, 병원 BC와 상품 BC를 하나의 Bounded Context로 통합하는 방법도 선택지로는 충분히 가능했습니다. BC를 통합하면 결과적 일관성 대신 즉시 일관성을 유지할 수 있습니다. 이벤트 기반 동기화 로직도 줄어들고, 데이터 불일치 구간도 사라지니 운영은 더 단순해집니다. “데이터 복제”라는 복잡성을 감수하지 않아도 된다는 점은 분명 큰 장점이죠.
하지만 BC 통합은 다른 트레이드오프를 수반합니다. 두 도메인이 하나의 서비스로 묶이면서 각 도메인의 독립적인 배포와 확장이 어려워집니다. 도메인 간 변경이 서로의 릴리즈에 영향을 주기 시작하고, 한쪽의 요구사항이 다른 쪽의 개발/운영 리듬을 끌고 가는 순간도 생길 수 있습니다. 특히 병원 정보와 상품은 회사 내에서 서로 다른 팀이 관리하기도 했던 경험이 있었고, 각각의 변경 빈도와 이유도 달랐습니다. 이는 두 영역이 서로 다른 문제 영역으로 다뤄질 가능성을 보여주는 신호라고 생각했습니다. 그래서 “운영 단순성”만 보고 통합을 선택하기보다는, 장기적으로도 이 경계가 유지될 수 있는 방향을 우선 고려하게 되었습니다.
다시 선택의 기로에 선다면
돌이켜보면 SCS를 선택한 것은 정답이라기보다는 우리 상황에 맞는 합리적인 선택이었다고 생각합니다. 약간의 지연이 허용되는 도메인 특성이 SCS와 잘 맞았고, 장애 격리를 통한 가용성 확보가 우리에게 중요한 가치였기 때문입니다.
만약 실시간성이 중요하거나 데이터 불일치가 비즈니스에 치명적인 영향을 주는 도메인이었다면, 다른 선택을 했을 것입니다.
결국 아키텍처 설계는 정답을 찾는 과정이 아니라 트레이드오프를 이해하고 우선순위에 맞는 선택을 하는 과정입니다. SCS는 복잡도를 감수하고 가용성과 독립성을 얻는 선택이었고, 당시 우리에게는 그 선택이 필요했습니다.
여러분의 시스템에서는 어떤 선택이 더 합리적일지, 이 글이 그 고민에 작은 도움이 되었으면 합니다. 감사합니다!