2025년 다마고치 파라다이스가 전국적으로 대유행이었다. 우리 커머스에서도 3일 동안 오전 10시에 선착순으로 100개 한정 판매를 진행했고, 매번 5분 내에 완판됐다.
평소에 다른 이벤트를 하더라도 이렇게 사용자가 집중된 케이스가 없었는데 이번 이벤트에는 사용자가 예상했던 것보다 훨씬 더 많이 참여해주셨다.
문제는 우리가 MSA 구조로 되어 있으면서도 IDC 환경에서 운영되고 있어서 확장성에 한계가 있었다는 점이었다.
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 + User Service + 브랜드 정보 + 리뷰 데이터
사용자 대시보드 = User Service + Product Service + 추천 시스템
마이페이지 = User Service + 주문이력 + 포인트 정보
평상시엔 각 MSA 서비스가 200ms 내로 응답했지만, 부하 상황에서는 조회 API 연쇄 지연이 발생했다.
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 환경에서 즉시 대응이 어려웠다
이벤트가 끝나고 나서야 차근차근 원인을 분석할 수 있었다.
가장 치명적인 문제는 Hikari 커넥션 풀이 60개밖에 없었다는 점이었다.
평상시: 동접 500명 → 커넥션 60개로 충분
이벤트: 동접 15,000명 → 커넥션 60개로 절대 부족
결과:
- 커넥션 대기 → 응답 지연 → 타임아웃 → 전체 시스템 마비
평소엔 문제없던 쿼리들이 부하 상황에서 병목이 됐다.
-- 상품 조회 쿼리 (평소 0.1초 → 부하시 3초)
SELECT p.*, brand_info, review_data
FROM products p + 4개 테이블 조인 + 서브쿼리
커넥션이 부족한 상황에서 이런 무거운 쿼리들이 더 오래 커넥션을 점유하면서 악순환.
클라우드였다면: Auto Scaling으로 이벤트 대응 가능
IDC 현실: 물리 서버 확장이 어려움
가장 먼저 도입한 건 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초로 단축
부하를 분산하기 위해 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)
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 호출 타임아웃
첫날 장애 후 바로 분석에 들어갔다. 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 분석 결과:
- 평균 응답시간: 3초 (개선됨)
- 에러율: 5% (크게 개선)
- 하지만 여전히 Redis 부하 증가
- L2 캐시만으로는 한계 노출
Day 3 최종 적용사항:
1. L1 로컬 캐시 도입으로 Redis 부하 95% 감소
2. 커넥션 풀 150개로 최종 확장
3. 타임아웃 3초로 단축 (빠른 실패)
IDC 환경에서는 즉시 스케일링이 불가능하다
MSA + IDC 조합은 장애 전파에 취약하다
IDC에서는 캐시가 생명줄이다
타임아웃은 빠를수록 좋다
물리적 제약을 소프트웨어로 극복
이후 다마고치 파라다이스 판매는 안정적으로 진행됐고, 유사한 인기 상품 이벤트에도 같은 문제가 재발하지 않았다.
장기 개선 계획 - AWS EKS 전환:
우리는 이미 AWS EKS(Elastic Kubernetes Service) 전환을 계획하고 있었는데, 이번 장애로 그 결정이 얼마나 필요한 일이었는지 절실히 깨달았다.
현재 (IDC) → 목표 (AWS EKS)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
수직 확장만 가능 → 수평 확장 자유자재
서버 증설 2주 소요 → 몇 분 내 Auto Scaling
고정 리소스 비용 → 사용량 기반 과금
장애 시 수동 대응 → 자동 복구/재배포
물리적 제약 → 무제한 확장성
EKS 도입 후 기대 효과:
이번 장애는 단순한 성능 문제가 아니라 MSA 아키텍처의 핵심 특성에 대해 깊이 고민하는 계기가 됐다.
회복성(Resilience) 4대 패턴 도입
1. Circuit Breaker: 장애 전파 차단
2. Timeout: 빠른 실패로 리소스 절약
3. Retry: 일시적 장애 극복 (지수 백오프)
다마고치 하나 때문에 시작된 장애였지만, 결국 우리를 더 나은 아키텍처로 이끌어준 고마운 경험이었다.