1. 개요

식품안전나라 공공 API 대용량 동기화 및 회원가입 메일(SMTP) 발송 등 외부 연동 인프라에서 발생하는 순시 네트워크 단절과 상대 서버의 일시적 다운 현상으로 인해, DoEatFit 서비스 백엔드에 심각한 응답 지연과 500 내부 에러가 전이되는 장애를 겪었습니다. 외부 서버에 트래픽 폭증이 생기면 백엔드가 날린 동기화 요청 스레드는 타임아웃 한계선까지 수십 초 동안 차단(Blocking) 상태에 갇혔고, 이는 결국 사용자의 브라우저 화면이 멈추는 불량한 사용성으로 고스란히 표출되었습니다. 이를 극복하기 위해 스프링 부트 3.4.x 표준 규격에 부합하는 타임아웃 설정 마이그레이션을 단행하고, 비동기 스레드 풀 격리 및 선언적 재시도 프레임워크인 Spring Retry를 전격 탑재하여 복잡한 Busy Wait 루프 없이 통신의 내결함성과 시스템 복원력을 획기적으로 향상시켰습니다. 그 기술적 해법을 공유합니다.


2. 핵심 내용

2-1. 🚨 외부 API 장애 전파와 스레드 차단 병목

  • 문제 현상:
    • 외부 공용 API 서버나 이메일 인증 발송용 SMTP 서버는 DoEatFit 개발팀이 직접 제어할 수 없는 독립 영역입니다.
    • 이들 서버의 순시 불통이나 통신 지연이 발생할 때마다 WAS의 메인 스레드가 함께 묶이며 병목을 일으켰고, 결국 전체 서비스 이용자가 500 Internal Server Error 폭탄을 맞이하게 되는 구조적 취약성을 노출했습니다.
  • 기존 수동 대응의 한계:
    • 초기에는 수동 while 루프 내에 Thread.sleep을 버무려 예외 시 수 초 쉬고 다시 요청하는 방식을 구축했으나, 대기 시간 도중 런타임 스레드 자원을 계속 붙잡고 있어 스레드 풀 전체를 고사시키는 “바쁜 대기(Busy Wait)” 경고와 코드 가시성 훼손이라는 또 다른 문제를 낳았습니다.

2-2. 💡 선언형 Spring Retry 및 비동기 스레드 격리 구현

  • 선언형 복원력 아키텍처 수립:
    • 복잡한 재시도 루프와 대기 시간 지수 계산식을 비즈니스 서비스 코드 깊숙이 심지 않고, 스프링 AOP 프록시 애너테이션과 격리된 스레드 풀을 결합하여 장애 확산을 차단하는 견고한 아키텍처를 구축했습니다.
graph TD
    A["사용자 요청"] --> B{"Async 스레드 풀 격리"}
    B -- "@Async 백그라운드 스레드 전환" --> C["FoodDataSyncService"]
    C --> D{"Spring Retry AOP 프록시"}
    D -- "1차 통신 실패 감지" --> E["Exponential Backoff (지수적 대기)"]
    E -- "2차 / 3차 재시도 수행" --> F["성공: 동기화 완료"]
    D -- "최대 재시도 초과" --> G["@Recover 폴백 발동: 이력 보존 및 안전 복구"]
  • RestTemplateConfig 타임아웃 표준 신스펙 도입:
    • Spring Boot 3.4.x 이상 규격에 발맞춰 기존에 제거될 예정인 Deprecated 직접 세터 메서드 대신, connectTimeout()readTimeout()을 전격 이식하여 접속 연결 5초, 데이터 리드 10초의 타임아웃 경계선을 단단히 굳혔습니다.
    @Bean
    public RestTemplate restTemplate(RestTemplateBuilder builder) {
        return builder
                .setConnectTimeout(Duration.ofSeconds(5))
                .setReadTimeout(Duration.ofSeconds(10))
                .build();
    }
  • 비동기 재시도 및 폴백 이식 (FoodDataSyncService.java):
    • @Async를 통해 대량 API 페칭 헤비 타스크를 별도 전용 스레드 풀로 넘겨 사용자에게 즉시 응답을 돌려주도록 처리했습니다.
    • @Retryable을 장착하여 ResourceAccessException 포착 시 기본 2초 대기 후 매 회 2배씩 늘려가며(지수 백오프) 최대 3회 재시도를 가동하며, 최종 실패 시 @Recover 수복 안전핀을 동작시켰습니다.
    @Async("taskExecutor")
    @Retryable(
        retryFor = { ResourceAccessException.class },
        maxAttempts = 3,
        backoff = @Backoff(delay = 2000, multiplier = 2.0)
    )
    public void syncFoodDatabase() {
        foodDataClient.fetchAndSaveLatestData();
    }
     
    @Recover
    public void recoverSyncFailure(ResourceAccessException exception) {
        log.error("🚨 [최종 실패] 외부 API 서버 불통. 복구를 개시합니다.", exception);
        historyRepository.save(new FoodSyncHistoryEntity("FAILED", exception.getMessage()));
    }

2-3. ✅ 결과 검증 및 모던 복원력 라이브러리 검토

  • 결과 검증:
    • Mock API 서버의 응답 딜레이를 20초 이상 인위적으로 높여 실패를 강제 유도했습니다.
    • 백엔드는 타임아웃을 즉시 인지하고 지수적 백오프에 근거해 2초, 4초, 8초의 텀을 두고 정확히 재시도를 수행했으며, 일시적 트래픽 튕김 환경에서는 3차 이내에 깨끗이 조회를 수복함을 증명했습니다.
    • 완전 불능 상태에서도 WAS가 사망하지 않고 @Recover 구문이 발동하여 정합성 깨짐 없이 실패 이력 데이터를 MySQL에 조용히 덤프하고 세션을 안전하게 정리했습니다.
  • 추천 오픈소스 라이브러리 검토:
    1. Spring Retry
      • 평가: 스프링 진영 내에서 가장 단순하고 우아하게 @Retryable 애너테이션 한 줄로 강력한 재시도 지수 백오프 가드를 얹을 수 있는 최고의 경량 복원 솔루션입니다.
    2. Resilience4j
      • 평가: 추후 시스템이 마이크로서비스(MSA) 기반으로 대대적으로 스케일아웃 확장될 때는 이 라이브러리가 독보적인 글로벌 산업 표준입니다.
      • 특정 서비스 지연 시 요청 경로 자체를 완전히 잠가 연쇄 붕괴를 예방하는 ‘서킷 브레이커(Circuit Breaker)’, 특정 엔드포인트 동시 커넥션 개수를 통제하는 ‘벌크헤드(Bulkhead)’, 설정 임계치 이상 호출을 차단하는 ‘레이트 리미터(Rate Limiter)’ 등 정교한 방어 메커니즘을 두루 갖추고 있어 시스템 고도화 시 필수로 검토해 보실 것을 제안합니다.