써킷브레이커 + 다층 캐싱: MSA 장애 전파 차단하기

이성혁·2025년 8월 15일

배경

2025년 다마고치 파라다이스가 전국적으로 대유행이었다. 우리 커머스에서도 3일 동안 오전 10시에 선착순으로 100개 한정 판매를 진행했고, 매번 5분 내에 완판됐다.

평소에 다른 이벤트를 하더라도 이렇게 사용자가 집중된 케이스가 없었는데 이번 이벤트에는 사용자가 예상했던 것보다 훨씬 더 많이 참여해주셨다.

문제는 우리가 MSA 구조로 되어 있으면서도 IDC 환경에서 운영되고 있어서 확장성에 한계가 있었다는 점이었다.

인프라 환경의 제약사항

IDC 물리 서버 환경:

  • 스케일 아웃 불가: 서버 추가에 최소 1-2주 소요 (하드웨어 구매 → 설치 → 설정)
  • 스케일 업 제한: 물리 서버 스펙 한계로 즉시 확장 불가
  • 고정 리소스: CPU/메모리 사용률이 90% 넘어도 즉시 대응 어려움
  • 네트워크 대역폭: IDC 내부 네트워크 대역폭 제한으로 서비스간 통신 지연

기존 MSA 아키텍처의 문제:

  • 장애 전파 방지 장치 없음: 써킷브레이커, 타임아웃, 재시도 정책 미적용
  • 서비스 의존성 강결합: 한 서비스 장애 시 연쇄적으로 전파되는 구조
  • 모니터링 부족: 서비스별 독립적인 헬스 체크 및 알림 체계 부재

문제는 판매 시작 10분 전 푸시 알림을 보내고 나서부터였다.

장애 상황

다마고치 판매 시작일

9:50 AM - 푸시 알림 발송 ("10분 후 다마고치 파라다이스 판매 시작!")

9:51 AM - 응답 시간이 서서히 늘어나기 시작

9:58 AM - 시스템 전체가 느려짐. 다른 상품 조회도 지연 발생

10:00 AM - 판매 시작과 동시에 시스템 다운

ERROR: HikariPool-1 - Connection is not available, request timed out after 5000ms

상황 분석

평소 동접자: 300-500명 (10만 MAU 기준)
푸시 알림 후: 3,000-5,000명 대기
판매 시작 순간: 15,000명+ 동시 접속

다마고치를 사려는 사람들이 푸시 받자마자 앱에 들어와서 10분간 대기했고, 10시 정각에 모든 사람이 동시에 상품 페이지를 새로고침했다.

MSA 환경에서의 추가 문제점

우리 시스템은 부분적인 MSA 구조로 되어 있었다:

  • Product Service: 상품 정보 조회 (MSA)
  • User Service: 사용자 인증/정보 (MSA)
  • Notification Service: 알림 발송 (MSA)
  • Order/Inventory System: 주문 처리 및 재고 관리 (레거시 모놀리틱)

문제는 사용자 조회 페이지에서 여러 MSA 서비스를 동시에 호출해야 한다는 점이었다:

상품 페이지 로딩 = Product Service + User Service + 브랜드 정보 + 리뷰 데이터
사용자 대시보드 = User Service + Product Service + 추천 시스템
마이페이지 = User Service + 주문이력 + 포인트 정보

평상시엔 각 MSA 서비스가 200ms 내로 응답했지만, 부하 상황에서는 조회 API 연쇄 지연이 발생했다.

IDC 환경에서 발생한 구체적 문제들

1) MSA 서버 리소스 고갈:

→ MSA 서비스들만 즉시 스케일링 불가능, 서버 증설 2주 소요

2) 네트워크 병목:

IDC 내부 네트워크: 1Gbps 대역폭 공유
서비스간 통신 지연: 200ms → 2-3초
DB 서버와의 연결: 물리적 네트워크 스위치 과부하

3) 커넥션 부족으로 시작된 연쇄 장애:

9:58 AM - DB 커넥션 풀 고갈 시작 (Hikari 60개 → 모두 사용 중)
9:59 AM - 모든 API 응답 지연 (1초 → 5초 → 타임아웃)  
10:00 AM - 서버 전체 응답 불가, 타임아웃 폭증
10:01 AM - 시스템 전면 마비 (조회/주문 모든 기능 중단)
→ 즉시 대응 불가, 이벤트 완료 후 원인 분석 및 해결 착수

4) 모니터링 사각지대:

  • 서비스별 독립적인 헬스체크 없음
  • 장애 전파 경로 추적 불가
  • 어느 서비스가 최초 원인인지 파악 어려움

장애 당시 상황

즉시 대응의 한계

커넥션 부족 문제가 핵심이었지만, IDC 환경의 물리적 제약으로 즉시 대응이 불가능했다:

실시간 상황:
- DB 커넥션 풀: 60개 모두 고갈
- 응답시간: 1초 → 5초 → 20초 → 타임아웃

결국 이벤트는 2분 만에 완판됐지만 시스템은 계속 불안정했다.

IDC 환경에서 즉시 대응이 어려웠다

  • 클라우드라면 Auto Scaling으로 대응
  • IDC는 물리적 제약으로 몇 주가 걸리는 작업들
  • 이벤트 완료 후 근본 원인 분석 및 개선 작업 시작

이벤트 완료 후 원인 분석

이벤트가 끝나고 나서야 차근차근 원인을 분석할 수 있었다.

1) 핵심 문제: DB 커넥션 풀 부족

가장 치명적인 문제는 Hikari 커넥션 풀이 60개밖에 없었다는 점이었다.

평상시: 동접 500명 → 커넥션 60개로 충분
이벤트: 동접 15,000명 → 커넥션 60개로 절대 부족

결과:
- 커넥션 대기 → 응답 지연 → 타임아웃 → 전체 시스템 마비

2) 부하 상황에서 드러난 쿼리 비효율성

평소엔 문제없던 쿼리들이 부하 상황에서 병목이 됐다.

-- 상품 조회 쿼리 (평소 0.1초 → 부하시 3초)
SELECT p.*, brand_info, review_data 
FROM products p + 4개 테이블 조인 + 서브쿼리

커넥션이 부족한 상황에서 이런 무거운 쿼리들이 더 오래 커넥션을 점유하면서 악순환.

3) IDC 환경의 스케일링 한계

클라우드였다면: Auto Scaling으로 이벤트 대응 가능
IDC 현실: 물리 서버 확장이 어려움

이벤트 완료 후 근본 해결책 도입

1) 써킷브레이커 도입 (MSA 장애 전파 방지)

가장 먼저 도입한 건 Resilience4j Circuit Breaker였다. 한 서비스의 장애가 전체로 번지는 걸 막기 위해서였다.

@Component
public class ExternalServiceClient {
    
    @CircuitBreaker(name = "user-service", fallbackMethod = "fallbackGetUser")
    @TimeLimiter(name = "user-service")
    @Retry(name = "user-service")
    public CompletableFuture<User> getUser(String userId) {
        return CompletableFuture.supplyAsync(() -> userServiceClient.getUser(userId));
    }
    
    public CompletableFuture<User> fallbackGetUser(String userId, Exception ex) {
        // 캐시된 사용자 정보 반환 또는 기본 정보
        return CompletableFuture.completedFuture(userCacheService.getUser(userId));
    }
}
# application.yml 설정
resilience4j:
  circuitbreaker:
    instances:
      user-service:
        failure-rate-threshold: 50
        wait-duration-in-open-state: 10s
        sliding-window-size: 10
        minimum-number-of-calls: 5
  timelimiter:
    instances:
      user-service:
        timeout-duration: 2s  # 기존 5초 → 2초로 단축

2) 다층 캐싱 전략 (로컬 + Redis)

부하를 분산하기 위해 L1(로컬) + L2(Redis) 캐시를 영역을 점검하며 추가로 적용을 확대했다.

@Configuration
public class CacheConfig {
    
    @Bean
    public CacheManager cacheManager(RedisConnectionFactory redisConnectionFactory) {
        // L1: 로컬 캐시 (Caffeine)
        CaffeineCacheManager local = new CaffeineCacheManager();
        local.setCaffeine(Caffeine.newBuilder()
            .maximumSize(10_000)
            .expireAfterWrite(Duration.ofMinutes(5))
            .recordStats());
        
        // L2: Redis 캐시
        RedisCacheManager redis = RedisCacheManager.builder(redisConnectionFactory)
            .cacheDefaults(RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofMinutes(30))
                .serializeKeysWith(RedisSerializationContext.SerializationPair
                    .fromSerializer(new StringRedisSerializer()))
                .serializeValuesWith(RedisSerializationContext.SerializationPair
                    .fromSerializer(new GenericJackson2JsonRedisSerializer())))
            .build();
        
        // 조합: 로컬 → Redis → DB 순서
        return new CompositeCacheManager(local, redis);
    }
}
@Service
public class ProductService {
    
    // 상품 기본 정보: L1 캐시 (5분) + L2 캐시 (30분)
    @Cacheable(value = "products", key = "#id")
    public Product getProduct(Long id) {
        return productRepository.findById(id);
    }
    
    // 재고 정보: L1 캐시만 (1분) - 실시간성 중요
    @Cacheable(value = "inventory", key = "#productId")
    public Inventory getInventory(Long productId) {
        return inventoryRepository.findByProductId(productId);
    }
    
    // 이벤트 상품: L1 + L2 모두 장시간 (1시간)
    @Cacheable(value = "event-products", key = "#id")  
    public Product getEventProduct(Long id) {
        return productRepository.findById(id);
    }
}

캐시 적중률 결과:

L1 캐시: 85% 적중 (평균 응답시간 2ms)
L2 캐시: 12% 적중 (평균 응답시간 50ms) 
DB 조회: 3% (평균 응답시간 200ms)

3) DB 커넥션 풀 대폭 확장

MSA 환경에서 여러 서비스가 동시에 DB를 호출하는 상황을 고려해 커넥션 풀을 대폭 확장했다.

# 이벤트 대응 설정
spring:
  datasource:
    hikari:
      maximum-pool-size: 150        # 기존 60 → 150 (2.5배 확장)
      minimum-idle: 50             # 기존 10 → 50
      connection-timeout: 3000     # 3초 (빠른 실패)
      validation-timeout: 1000
      max-lifetime: 1200000        # 20분 (기존 30분)
      idle-timeout: 300000         # 5분
      leak-detection-threshold: 60000  # 누수 감지 1분
      

타임아웃 정책 변경:

# 빠른 실패로 연쇄 지연 방지
resilience4j:
  timelimiter:
    instances:
      database:
        timeout-duration: 3s      # DB 쿼리 타임아웃
      external-api:
        timeout-duration: 2s      # 외부 API 호출 타임아웃
        

매일 개선 작업 (3일간)

Day 1 → Day 2 개선사항

첫날 장애 후 바로 분석에 들어갔다. 2분 매진이라는 짧은 시간 동안 많은 장애 로그가 발생했다.

Day 1 분석 결과:

- 피크 TPS: 8,000 req/sec (평소 200 req/sec)  
- DB 커넥션 풀 고갈: 60개 → 모두 사용 중
- 평균 응답시간: 12초 (목표 1초)
- 에러율: 15% (타임아웃, 커넥션 부족)

Day 2 적용사항:
1. 커넥션 풀 120개로 증설
2. 써킷브레이커 임계값 조정 (50% → 60%)
3. 상품 정보 캐시 TTL 1시간으로 연장

Day 2 → Day 3 개선사항

두 번째 날은 많이 나아졌지만 여전히 부족했다.

Day 2 분석 결과:

- 평균 응답시간: 3초 (개선됨)
- 에러율: 5% (크게 개선)
- 하지만 여전히 Redis 부하 증가
- L2 캐시만으로는 한계 노출

Day 3 최종 적용사항:
1. L1 로컬 캐시 도입으로 Redis 부하 95% 감소
2. 커넥션 풀 150개로 최종 확장
3. 타임아웃 3초로 단축 (빠른 실패)

회고 및 교훈

아쉬운 점

  1. 사전 준비 부족
  2. 부하 테스트 미흡: 실제 이벤트 상황과 유사한 테스트 부족
  3. 모니터링 지연: 문제 상황을 인지하는데 시간이 걸림

배운 점

  1. IDC 환경에서는 즉시 스케일링이 불가능하다

    • 물리 서버 추가: 최소 2주 소요 (구매→설치→설정)
    • 메모리/CPU 업그레이드: 물리적 한계와 다운타임 발생
    • 네트워크 대역폭: 공유 1Gbps로 서비스간 통신 병목
  2. MSA + IDC 조합은 장애 전파에 취약하다

    • 한 서비스의 지연이 전체 시스템을 마비시킬 수 있음
    • IDC 환경에서는 서버 증설로 해결 불가 → 써킷브레이커 필수
    • 각 서비스별 독립적인 장애 대응 능력 없으면 연쇄 붕괴
    • 모니터링과 알람 체계 부재 시 원인 파악도 어려움
  3. IDC에서는 캐시가 생명줄이다

    • L1(로컬) + L2(Redis) 다층 캐시로 97% 적중률 달성
    • Redis만으로는 한계, 로컬 캐시가 성능의 핵심
    • 서버 증설 불가능한 환경에서 캐시가 유일한 해법
  4. 타임아웃은 빠를수록 좋다

    • 20초 → 3초로 단축하니 전체 안정성 향상
    • 빠른 실패가 느린 성공보다 낫다
    • MSA + IDC 환경에서는 특히 중요
  5. 물리적 제약을 소프트웨어로 극복

    • DB 커넥션 풀: 60개 → 150개 (2.5배 확장)
    • 로컬 캐시로 DB 부하 감소
    • 네트워크 병목 → 써킷브레이커로 불필요한 호출 차단

후속 조치

이후 다마고치 파라다이스 판매는 안정적으로 진행됐고, 유사한 인기 상품 이벤트에도 같은 문제가 재발하지 않았다.

IDC 환경 특화 개선사항

장기 개선 계획 - AWS EKS 전환:

우리는 이미 AWS EKS(Elastic Kubernetes Service) 전환을 계획하고 있었는데, 이번 장애로 그 결정이 얼마나 필요한 일이었는지 절실히 깨달았다.

현재 (IDC) → 목표 (AWS EKS)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
수직 확장만 가능 → 수평 확장 자유자재
서버 증설 2주 소요 → 몇 분 내 Auto Scaling  
고정 리소스 비용 → 사용량 기반 과금
장애 시 수동 대응 → 자동 복구/재배포
물리적 제약 → 무제한 확장성

EKS 도입 후 기대 효과:

  • Auto Scaling: HPA/VPA로 트래픽에 따른 자동 확장
  • Self-Healing: Pod 장애 시 자동 재시작/재배포
  • Rolling Update: 다양한 배포 전략으로 서비스 안정성 향상
  • Resource Efficiency: 컨테이너 기반으로 리소스 최적화

MSA 가용성과 회복성에 대한 새로운 관점

이번 장애는 단순한 성능 문제가 아니라 MSA 아키텍처의 핵심 특성에 대해 깊이 고민하는 계기가 됐다.

회복성(Resilience) 4대 패턴 도입
1. Circuit Breaker: 장애 전파 차단
2. Timeout: 빠른 실패로 리소스 절약
3. Retry: 일시적 장애 극복 (지수 백오프)

다마고치 하나 때문에 시작된 장애였지만, 결국 우리를 더 나은 아키텍처로 이끌어준 고마운 경험이었다.

profile
항상 배우는 자세로 🪴

0개의 댓글