Ceph 클러스터 장애가 발생했을 때,BE가 계속 retry를 하면서 오히려 응답이 더 느려지고 있었습니다.
배경
현재 Ceph API를 활용해서 Ceph 데이터를 서빙하는 백엔드를 개발하고 있습니다.
FE → G/W → BE → Ceph MGR API (Active-Standby 구조)
서비스 흐름 자체는 단순한데, Ceph의 Active-Standby 구조 때문에
303 Redirect, Fallback, Retry, Active MGR 갱신 같은 걸 전부 BE에서 직접 처리하고 있었습니다.
평소에는 별 문제 없이 돌아갔는데, Ceph 장애 상황에서는 다음과 같은 문제가 발생했습니다.
- 평균 응답 시간이 1.15초까지 증가
- redirect → retry → host 순회 → 재귀 호출 구조
- 장애 원인 파악이 어려움(인프라 문제인지, Ceph 문제인지, BE 문제인지)
- 코드 복잡도 증가 및 유지보수 비용 상승
처음에는 "성능 최적화 문제겠지" 하고 단순하게 생각했습니다. 재시도 로직을 좀 더 정교하게 만들면 되지 않을까?
근데 장애 상황을 재현해보면서 생각이 바뀌었습니다.
이 문제의 본질은 성능이나 재시도 로직이 아니라, "장애 처리 책임이 잘못된 레이어에 있었다"는 점이었습니다.
Active MGR 선택, Failover, Health Check — 이건 인프라가 해야 하는 일입니다.
그때부터 이 문제를 단순 리팩토링이 아닌 구조적 재설계 문제로 다시 정의했습니다.
1단계: 측정 가능한 상태 만들기
예상치 못한 발견
구조를 개선하기 전에, 먼저 기존 구조가 장애 상황에서 실제로 어떻게 동작하는지 측정해야 했습니다.
k6로 부하 테스트를 구성해서 두 가지를 비교했습니다. (Ceph 클러스터 장애 상황 기준)
- 기존 로직 (redirect → retry → host 순회)
- redirect 및 fallback 제거 후 단순 호출
그런데 테스트를 돌리자 예상과 전혀 다른 결과가 나왔습니다.
장애를 측정하려 했는데 측정 자체가 불가능한 상태였습니다.
원인을 분석해보니 Thundering Herd 문제가 숨어있었습니다.
- 동일 Provider에 동시 요청 → 인증 토큰 발급 API가 요청 수만큼 중복 호출
- 토큰 캐시 덮어쓰기
- Active MGR 갱신 이벤트가 중복 발행 → DB Optimistic Lock 충돌
BE 내부가 안정적이지 않으면 인프라 구조 개선의 효과를 검증할 수 없는 상태였습니다.
Single-flight 패턴 도입
우선 측정 가능한 상태를 만드는 게 첫 번째라고 판단했습니다.
처음에는 synchronized나 ReentrantLock으로 토큰 발급 구간을 순차 실행하는 방식을 검토했습니다.
근데 락 내부에서 외부 HTTP 호출이 수행되는 구조다 보니, 응답이 지연되면 모든 요청이 임계 구역 앞에서 줄을 서게 됩니다.
동시성 제어가 아니라 병목을 만드는 방식이었습니다.
그래서 Redis 기반 분산 락을 검토했습니다. 멀티 인스턴스 환경이니까 당연히 떠오르는 선택지였는데, 설계를 구체화하다 보니 고민이 생겼습니다.
- Redis 추가 네트워크 왕복
- 락 타임아웃 및 실패 처리 로직
- 락 보유 인스턴스 장애 시 복구 설계
문제를 해결하려고 또 하나의 장애 지점을 추가하는 꼴이었습니다.
여기서 한 가지 기준이 생겼습니다.
"이 토큰 발급 과정에서 정말 모든 인스턴스 사이에 완벽한 상호배제가 필요할까?"
실제 운영 환경에는 약 3개의 인스턴스가 돌고 있습니다.
분산 락 없이 최악의 상황에서도 동시 중복 호출은 최대 3회입니다.
토큰은 Redis에 TTL 15분으로 캐시되고 대부분의 요청은 캐시 히트로 처리됩니다.
문제가 되는 구간은 토큰 만료 직후의 매우 짧은 순간뿐이었습니다.
Ceph 인증 API는 JWT 기반의 stateless 구조라서, 반복 호출해도 클러스터 상태를 변경하지 않습니다.
중복 호출은 기능적 부작용 없이 외부 API 부하만 일시적으로 증가시키는 형태였습니다.
"복잡하게 해결하기보다, 지금 시스템에 맞는 수준에서 단순하게 해결"
분산 락으로 시스템 복잡도를 올리는 것보다, 인스턴스 내부에서만 동시 요청 제어만으로 충분하다고 생각해서,
Single-flight 패턴 을 적용했습니다.
getOrIssueToken()
├─ Redis 캐시 조회 (Cache Hit 시 즉시 반환)
├─ cache miss
│
├─ inFlight.computeIfAbsent(providerId)
│ └─ VirtualThreadExecutor.supplyAsync()
│ └─ issuer.call() ← Ceph /api/auth
│
├─ 다른 요청 → future.join() (가상 스레드 파킹 후 결과만 공유)
└─ 결과 반환
첫 요청만 외부 호출을 수행하고, 나머지 요청은 그 결과를 공유합니다.
완벽한 상호배제는 아니지만, 실질적 중복 호출은 대부분 제거 가능하고 추가 인프라 의존성도 없습니다.
결과적으로:
- 토큰 발급 API 호출 중복 제거 (N → 1 수준으로 수렴)
- DB Lock 경합 해소
- 신뢰할 수 있는 테스트 환경 확보
Single-flight를 먼저 도입해서 "측정 가능한 상태"를 만든 것이 이후 모든 개선의 시작점이 되었습니다.
2단계: 인프라 책임 분리 (HAProxy + VIP)
BE 내부를 안정화한 뒤, 이제 진짜 궁금했던 걸 측정할 수 있게 됐습니다.

동일한 장애 상황(Ceph Cluster Down)에서 비교해봤습니다.
| 구분 | 평균 응답 시간 |
|---|---|
| 기존 로직 (redirect → retry → host 순회) | 1,150ms |
| redirect 및 fallback 제거 후 단순 호출 | 7.2ms |
1,150ms → 7.2ms. 160배 차이.
이 수치를 보고 확신이 들었습니다. 문제는 "재시도를 어떻게 잘 할 것인가"가 아니라, "이 로직들이 애초에 BE의 책임인가"였습니다.
기존 BE는 Active MGR 선택, StandBy 판단, 장애 감지와 Fallback까지 전부 직접 하고 있었습니다.
장애가 발생하면 BE 내부 로직이 복잡하게 얽히면서 원인 분리도 안 되고, 레이턴시만 올라갔습니다.
"장애를 더 잘 처리하는 코드보다, 장애가 BE로 전파되지 않도록 경계를 명확히 하는 게 더 중요하다"
그래서 BE가 더이상 MGR 선택과 장애 판단을 직접 하지 않도록, Ceph Cluster 앞에 Reverse Proxy를 두고 BE는 항상 단일 진입점(VIP)만 호출하는 구조로 바꿨습니다.
Reverse Proxy로 Nginx도 가능했지만, HAProxy + Keepalived를 선택했습니다.
Ceph 공식 문서가 MGR HA 구성의 표준으로 제시하는 조합이었고, 검증된 아키텍처를 따르는 게 장애 대응과 협업 측면에서 리스크가 낮다고 판단했습니다.
HAProxy 책임:
- Active MGR 선택 (Health Check 기반)
- StandBy / Down MGR 자동 제외
- BE는 VIP만 호출
BE에서 제거된 로직:
- 303 Redirect 파싱
- Host 순차 Fallback
- 재귀적 재시도
- Active MGR 갱신 이벤트
BE에 남긴 최소 책임:
- 401 Unauthorized → Single-flight 기반 토큰 갱신 1회 재시도
- 그 외 timeout / 5xx → Fast-Fail
정리하면:
- 가용성 판단 → 인프라 책임
- 인증 관리 → 애플리케이션 책임
코드는 더 단순해졌고, 장애 원인은 명확히 분리됐습니다. 그리고 가장 중요한 건 시스템이 예측 가능해졌다는 점입니다.

3단계: 애플리케이션 회복력 (Resilience4j)
HAProxy 도입으로 MGR 선택과 장애 차단은 인프라로 넘겼습니다. BE는 이제 "어디로 요청을 보낼지"를 고민하지 않아도 됩니다.
근데 여전히 남은 문제가 있었습니다. "실패를 애플리케이션이 어떤 규칙으로 다룰 것인가"에 대한 정책이 없었습니다.
어떤 오류는 재시도해야 하고, 어떤 오류는 즉시 실패해야 하고, 어떤 장애는 공급자 단위로 격리되어야 했습니다.
인프라가 '빠른 실패'를 가능하게 만들었으니, 이제 애플리케이션은 실패에 어떻게 반응할지를 명시적으로 가져야 했습니다.
왜 Resilience4j인가
| 옵션 | 검토 결과 |
|---|---|
| Spring Retry | CB/Bulkhead 미지원 |
| Hystrix | 2018년 deprecated |
| Resilience4j | 경량, 모듈형, 설정 외부화 |
Spring Boot 3 환경에서 공식 지원되고, 설정 외부화와 메트릭 연동이 용이해서 선택했습니다.
필요한 만큼만, 하지만 확장 가능하게
도입하지 않은 패턴들도 있습니다. 현재 장애 패턴과 트래픽 특성에서 실질적 효용이 낮았기 때문입니다.
- Bulkhead: CB OPEN 시 FE가 이미 차단 → 스레드 고갈 위험 낮음
- RateLimiter: 내부망 환경으로 트래픽이 낮으며, Ceph에는 Rate Limit 없음
- TimeLimiter: 동기 호출이라 RestClient 타임아웃으로 충분
최종적으로 필요한 건 Retry + CircuitBreaker 두 가지였습니다.
설계
실행 순서: CB( Retry( Supplier ) )
- Retry 먼저: 일시적 오류(502, 503) 복구 시도
- CB 나중: Retry까지 실패한 "최종 결과"만 실패율 반영
- 반대면? Retry 시도마다 CB 카운트 → 성급한 OPEN
resilience4j:
retry:
retryExceptions:
- BadGateway # 502: HAProxy 일시 오류 (재시도 가능)
- ServiceUnavailable # 503: MGR 준비 안 됨 (재시도 가능)
ignoreExceptions:
- InternalServerError # 500: Ceph 로직 오류 (재시도 무의미)
circuitbreaker:
ignoreExceptions:
- InvalidCredentialsException # 인증 오류 ≠ 시스템 장애
Provider별 격리
String key = "ceph-" + providerUuid; // 클러스터마다 독립 CB
Supplier<T> decoratedSupplier = Retry.decorateSupplier(retry, supplier);
decoratedSupplier = CircuitBreaker.decorateSupplier(circuitBreaker, decoratedSupplier);
return decoratedSupplier.get();
Cluster A 장애 → A의 CB만 OPEN → B, C 정상 동작.
테스트 코드 작성
Resilience4j를 도입하면서 한 가지 의문이 들었습니다.
"application.yml의 설정값들이 실제 장애 상황에서 100% 의도대로 동작할까?"
설정이 복잡해질수록 런타임 동작은 예측 불가능해집니다.
그래서 장애 시나리오를 기반으로 테스트 코드로 직접 증명했습니다.
@Test
@DisplayName("InvalidCredentialsException 은 CircuitBreaker 실패로 기록되지 않는다")
void circuitBreakerIgnoreInvalidCredentialsException() {
for (int i = 0; i < 10; i++) {
assertThrows(InvalidCredentialsException.class, ...);
}
// CB는 여전히 CLOSED (의도한 동작)
assertThat(cb.getState()).isEqualTo(CLOSED);
}

결과
구조 변경만으로 장애 상황에서 다음과 같은 개선을 얻을 수 있었습니다.


| 항목 | 개선 전 | 개선 후 |
|---|---|---|
| 평균 응답시간 | 5.04s | 36.5ms |
| P95 레이턴시 | 10.03s | 78ms |
| dropped_iterations | 989 | 11 |
| 최대 VUs | 50 (max 도달) | 16 |
| 장애 클러스터 fast-fail | 불가 | 98~99% (100ms 이내) |
| CB 상태 모니터링 | 불가능 | Actuator 실시간 조회 |
이 수치는 정상 상황의 성능 향상이 아니라, 장애 상황에서의 회복 탄력성 차이입니다.
전체 여정 요약
"장애를 어떻게 처리할지가 아니라, 누가, 어느 레이어에서 책임질 것인가"
1단계: 측정 가능한 상태 만들기 (Single-flight)
├─ BE 내부 동시성 문제 제거
├─ 토큰 재발급 Thundering Herd 해소
└─ 성과: 신뢰 가능한 장애 테스트 환경 확보
2단계: 인프라 책임 분리 (HAProxy + VIP)
├─ MGR 선택 / Failover → 인프라로 위임
├─ Fast-Fail → 장애 전파 차단
└─ 성과: 평균 응답 160배 개선 (1,150ms → 7.2ms)
3단계: 애플리케이션 회복력 (Resilience4j)
├─ Provider별 CircuitBreaker → 클러스터 격리
├─ 정밀 Retry 정책 → 불필요한 재시도 제거
└─ 성과: 장애 격리 + 실시간 관찰 가능성 확보
회고
문제 정의의 재발견
시작은 팀내 FE 개발자분이 "API 응답 속도가 너무 느리다"고 알려준 것이었습니다.
확인해보니 Ceph 클러스터 장애로 실제 API 통신이 불가능한 상황이었는데, BE는 매 요청마다 Ceph로 다시 시도하며 에러를 반환하고 있었습니다. 장애가 발생한 클러스터로의 요청이 계속 시스템 내부로 유입되면서 레이턴시와 리소스 점유가 누적되고 있었습니다.
그때까지 저는 이 문제를 "이 로직들을 어떻게 개선할까?"라는 How의 문제로만 보고 있었습니다.
근데 구조를 뜯어볼수록 의문이 들었습니다.
"왜 이걸 BE에서 다 처리하고 있지?", "이 책임들은 인프라가 가져야 하는 거 아닌가?"
그 순간 문제가 다르게 보였습니다. 단순한 성능 최적화가 아니라 책임을 다시 배치해야 하는 설계 문제였습니다.
복잡한 설정은 검증되지 않은 코드와 같다
Resilience4j 설정이 의도대로 동작하는지를 테스트 코드로 증명하면서 다시 한번 느꼈습니다.
복잡한 설정일수록 테스트를 통해 동작을 증명해야만 시스템을 신뢰할 수 있다는 걸요.
마치며
이번 개선을 통해 가장 크게 느낀 건, 장애를 코드로 막아내는 것이 아니라 장애가 전파되지 않을 구조를 설계하는 것이 더 중요하다는 점이었습니다.
Active MGR 선택, Failover, 장애 감지는 인프라가 더 안정적으로 수행할 수 있는 영역이었고, BE는 정상 요청 처리와 인증 관리에 집중하는 편이 시스템 전체의 예측 가능성과 안정성에 훨씬 유리했습니다.
결국 중요한 건 기술을 얼마나 많이 아느냐가 아니라, "각 레이어에 맞는 책임을 어디에 둘 것인가"를 판단하는 능력이라고 생각합니다.
'dev > Ceph' 카테고리의 다른 글
| [Ceph] Custom Container 배포를 해보자! (+Prometheus 연동) (0) | 2025.11.26 |
|---|