MSA에서 무엇보다 중요한 것: 장애를 격리하는 설계
author
postNo
status
thumbnail
description
category
tags
createdAt
updatedAt
오래전, 3~4년 전쯤 “MSA가 반드시 좋은 것은 아니며, 모든 서비스에 필요한 것도 아니다”라는 글을 쓴 적이 있다. 블로그를 옮기는 과정에서 원문은 사라졌지만, 당시 가졌던 생각은 지금도 크게 달라지지 않았다.
그동안 MSA를 채택하는 회사는 훨씬 많아졌다. 하지만 나는 여전히 MSA가 모든 회사에 필요한 아키텍처이거나, 시스템의 문제를 해결해주는 만능 해법이라고 생각하지 않는다.
오히려 충분한 이유 없이 MSA를 도입하면 서비스 간 통신, 데이터 정합성, 분산 트랜잭션, 장애 전파, 배포와 모니터링처럼 이전에는 고민하지 않아도 됐던 문제들이 새롭게 생긴다. 복잡도를 감당할 준비가 되어 있지 않다면, 문제를 해결하기 위해 도입한 아키텍처가 오히려 더 많은 장애와 운영 비용을 만들어낼 수도 있다.
그래서 이번 글에서는 MSA를 도입할지 판단할 때 무엇을 고려해야 하는지, 그리고 실제로 MSA를 설계할 때 반드시 고민해야 할 지점은 무엇인지 정리해보려고 한다.
 

MSA가 지향하는건 단순히 서버 분리가 아니라 장애의 격리이다

MSA를 도입하면 서비스가 잘게 나뉘고 각 팀이 독립적으로 개발하고 배포할 수 있다. 그러나 시스템을 여러 서비스로 분리했다고 해서 장애까지 자동으로 분리되는 것은 아니다. 오히려 서비스 간 네트워크 호출이 늘어나면서 작은 지연 하나가 여러 서비스를 타고 번지는 새로운 실패 경로가 생긴다.
MSA에서 무엇보다 중요한 것은 서비스의 개수가 아니라 장애를 격리할 수 있는가이다.
 

상대 서비스는 언제든 응답하지 않을 수 있다

notion image
서비스 A가 서비스 B를 호출하는 상황을 생각해보자. B가 명확한 오류를 즉시 반환한다면 A는 그 오류를 처리할 수 있다. 더 위험한 상황은 B가 아무 응답도 하지 않거나 평소보다 매우 늦게 응답하는 경우다.
A가 응답을 계속 기다리면 요청을 처리하던 스레드와 커넥션이 점유된다. 같은 호출이 반복되면 자원이 고갈되고, 처음에는 정상이던 A까지 응답하지 못하게 된다. A를 호출하는 또 다른 서비스도 같은 방식으로 영향을 받는다. 하나의 느린 서비스가 전체 시스템의 장애로 확산되는 전형적인 과정이다.
따라서 외부 서비스를 호출하는 코드는 항상 다음 전제에서 출발해야 한다.
내가 호출하는 서비스는 언제든 실패하거나, 늦거나, 응답하지 않을 수 있다.
실패 자체를 완전히 없앨 수는 없다. 대신 실패를 빠르게 감지하고, 제한된 범위 안에 가두며, 나머지 시스템이 계속 동작하도록 만들어야 한다.
 
 

1. 외부 호출의 타임아웃은 반드시 설정해야 한다

notion image
외부 호출에 타임아웃이 없으면 상대 서비스의 지연 시간이 곧 내 서비스의 지연 시간이 된다. 모든 네트워크 호출에는 연결 타임아웃과 응답 타임아웃을 명시해야 한다.
타임아웃 값은 단순히 넉넉하게 잡는 것이 아니라 요청 전체의 시간 예산 안에서 정해야 한다. 예를 들어 클라이언트가 3초를 기다릴 수 있다면, 중간 서비스가 하위 서비스에 3초를 모두 사용해서는 안 된다. 네트워크 지연, 자체 처리, fallback에 필요한 시간을 남겨야 한다.
너무 짧으면 정상 요청까지 실패하고, 너무 길면 장애 전파를 막지 못한다. 실제 지연 시간의 백분위수와 서비스 수준 목표를 관찰하며 조정해야 한다.

2. 재시도는 실패를 복구하기도, 증폭하기도 한다

일시적인 네트워크 오류에는 재시도가 효과적일 수 있다. 하지만 장애가 지속되는 상황에서 모든 서비스가 동시에 여러 번 재시도하면, 이미 힘든 서비스에 더 많은 트래픽을 보내는 결과가 된다. 1개의 요청이 호출 단계마다 3번씩 재시도되면 하위 서비스가 받는 요청 수는 기하급수적으로 늘어날 수 있다.
안전한 재시도에는 조건이 필요하다.
  • 연결 실패나 일시적인 5xx처럼 회복 가능성이 있는 오류에만 적용한다.
  • 멱등성이 보장된 작업에 사용한다. 결제나 주문 생성처럼 중복 실행이 위험한 작업에는 멱등성 키가 필요하다.
  • 횟수와 총 소요 시간을 제한한다.
  • 지수 백오프와 jitter를 사용해 재시도 시점이 한꺼번에 몰리지 않게 한다.
  • 호출 경로의 모든 계층이 아니라 책임이 명확한 한 계층에서만 수행한다.
재시도는 기본 반응이 아니라, 실패의 성격을 이해한 뒤 선택하는 복구 전략이다.

3. fallback은 정책에 따라 결정할 수 있다

호출이 실패했을 때 어떤 응답을 제공할지는 기능의 중요도 혹은 제품의 정책에 따라 달라진다.
추천 목록을 가져오지 못했다면 빈 목록이나 최근 캐시를 보여줘도 핵심 기능은 계속 사용할 수 있다. 반면 결제 승인 결과를 확인하지 못한 상황에서 임의의 성공 값을 반환해서는 안 된다. 즉시 처리할 필요가 없다면 요청을 큐에 저장하고 나중에 처리할 수도 있다.
fallback은 장애를 숨기는 장치가 아니다. 사용자가 받아들일 수 있는 축소된 기능이 무엇인지 제품 관점에서 먼저 정의해야 한다. fallback이 실행된 사실 역시 로그와 지표로 남겨야 한다.

4. 서킷 브레이커로 반복되는 실패를 차단한다

타임아웃이 개별 요청의 대기 시간을 제한한다면, 서킷 브레이커는 반복적으로 실패하는 서비스에 대한 호출 자체를 잠시 멈춘다.
  • Closed: 요청을 정상적으로 전달하고 실패율과 느린 호출 비율을 관찰한다.
  • Open: 임계값을 넘으면 호출을 즉시 거절하거나 fallback으로 전환한다.
  • Half-Open: 일정 시간이 지난 뒤 제한된 요청만 보내 회복 여부를 확인한다.
서킷이 열리면 장애가 난 서비스에 불필요한 부하를 더하지 않고, 호출하는 서비스의 스레드와 커넥션도 보호할 수 있다. 다만 실패율 임계값만 설정해서 끝나는 것은 아니다. 최소 호출 수, 관찰 구간, 느린 호출 기준, Open 유지 시간과 Half-Open 시험 요청 수를 서비스 특성에 맞게 조정해야 한다.

5. 격리는 별도의 자원을 할당해서 의존성을 줄인다

하나의 외부 서비스가 느려졌을 때 모든 요청이 같은 스레드 풀과 커넥션 풀을 공유하면, 서킷이 열리기 전에 다른 정상 기능까지 자원을 잃을 수 있다. 이를 막으려면 의존성이나 중요 기능별로 동시 실행 수, 커넥션 풀, 작업 큐와 같은 자원을 분리해야 한다. 흔히 벌크헤드 패턴이라고 부르는 방식이다.
예를 들어 추천 서비스 호출이 느려지더라도 주문 조회에 필요한 자원은 남아 있어야 한다. 한 의존성의 장애가 다른 기능의 처리 용량을 빼앗지 못하게 하는 것이 격리의 핵심이다.

6. 즉시 응답이 필요 없다면 비동기로 처리한다

notion image
모든 작업을 동기 HTTP 호출로 연결할 필요는 없다. 이메일 발송, 통계 집계, 후속 알림처럼 즉시 결과가 필요하지 않은 작업은 Kafka나 메시지 큐를 사용해 비동기로 처리할 수 있다.
비동기 메시징은 생산자와 소비자가 같은 순간에 정상이어야 한다는 제약을 없앤다. 소비자가 잠시 중단되어도 메시지를 보관했다가 회복 후 처리할 수 있다. 대신 중복 처리, 순서, 재처리, DLQ, 메시지 적체와 최종 일관성을 새롭게 관리해야 한다. 큐를 사용했다는 사실만으로 신뢰성이 생기는 것은 아니다.

7. Rate Limit과 Backpressure로 감당할 수 있는 만큼만 받는다

서비스가 안정적으로 처리할 수 있는 양보다 많은 요청을 받으면 지연이 늘고, 지연은 타임아웃과 재시도를 불러 장애를 더 키운다. Rate Limit은 단위 시간당 요청 수를 제한해 과부하를 예방한다. Token Bucket은 순간적인 요청 증가를 일부 허용하면서 평균 처리량을 제한하고, Leaky Bucket은 요청을 비교적 일정한 속도로 흘려보낸다.
분산 환경에서는 인스턴스별 Local Limit과 전체 트래픽을 기준으로 하는 Global Limit의 차이를 고려해야 한다. 처리할 수 없는 요청을 무작정 큐에 쌓는 대신 빠르게 거절하거나 생산 속도를 늦추는 backpressure도 함께 설계해야 한다.

8. 관측할 수 없는 장애는 격리할 수도 없다

분산 시스템에서는 하나의 요청이 여러 서비스를 거친다. 로그 한 줄만으로는 어디에서 지연되거나 실패했는지 찾기 어렵다. Trace ID와 Span ID를 전파하는 분산 트레이싱을 적용하면 요청의 전체 경로와 각 구간의 소요 시간을 확인할 수 있다. OpenTelemetry, Jaeger, Zipkin 같은 도구가 이를 돕는다.
다만 tracing은 장애 전파를 직접 막는 장치가 아니라, 문제를 발견하고 보호 정책을 조정하기 위한 관측 수단이다. 다음 지표를 함께 살펴봐야 한다.
  • 의존성별 성공률, 오류율과 응답 시간 분포
  • 타임아웃과 재시도 횟수
  • 서킷 브레이커 상태 전환과 fallback 실행 수
  • 스레드·커넥션 풀 사용량과 대기열 길이
  • Rate Limit 거절 수와 메시지 큐 적체량

중요한 것은 얼마나 장애 전파를 잘 막을 수 있을것인가 이다

timeout, retry, circuit breaker, queue, rate limit을 모두 적용했다고 시스템이 자동으로 안전해지는 것은 아니다. 서로 잘못 조합하면 오히려 장애를 증폭할 수 있다. 긴 타임아웃 뒤의 반복 재시도, 제한 없는 큐, 의미 없는 기본 응답이 대표적이다.
각 외부 호출마다 다음 질문에 답할 수 있어야 한다.
  1. 상대 서비스가 느리거나 중단되면 이 요청은 얼마나 기다릴 것인가?
  1. 어떤 오류를 몇 번까지 재시도할 것인가?
  1. 실패했을 때 기능을 축소할 수 있는가, 아니면 명확히 실패해야 하는가?
  1. 이 호출이 점유할 수 있는 자원과 동시 실행 수의 한계는 무엇인가?
  1. 반복 실패를 언제 차단하고, 회복을 어떻게 확인할 것인가?
  1. 장애 상태를 어떤 지표와 trace로 발견할 것인가?
 
MSA의 목적은 장애가 발생하지 않는 시스템을 만드는 것이 아니다. 한 서비스의 실패가 다른 서비스의 생존까지 위협하지 않도록 경계를 만드는 것이다. 서비스 간 호출 실패가 곧 내 서비스의 장애가 된다면, 코드는 나뉘어 있어도 시스템은 아직 충분히 분리된 것이 아니다.
 
서비스 간 격리는 MSA의 부가 기능이 아니라 기본 철학이다.