Retry, Timeout과 장애 전파 방지

Retry

장애를 감지했다면 가장 먼저 시도해 볼 수 있는 강력한 전략은 재시도(Retry)입니다.

일시적인 네트워크 오류나 순간적인 부하로 인한 실패는 한 번 더 시도하는 것만으로도 정상 복구될 가능성이 높습니다. 하지만 무턱대고 재시도 로직을 구현하다간 코드가 복잡해 질 수 있습니다.

예시: 20% 확률로 실패하는 메일 서비스

실제 운영 환경에서는 장애가 드물게 발생하지만, 실습을 위해 20%의 확률로 실패하는 상황을 가정합니다.

  • 정상 흐름: 이메일 유효성 검사 통과 → 메일 발송 성공

  • 장애 상황 (20%): SMTPConnectionException 발생 (재시도 대상)

  • 잘못된 요청: InvalidEmailException 발생 (재시도 X, 400 에러 반환)

목표는 "일시적인 SMTP 연결 오류가 발생했을 때, 자동으로 재시도하여 성공시키는 것"입니다.

반면, 잘못된 이메일 주소(Invalid Email)는 백 번 재시도해도 실패할 것이므로, 즉시 에러를 반환해야 합니다.

Spring Retry로 재시도 구현하기

재시도 로직을 try-catch-while 문으로 직접 짜면 비즈니스 로직이 복잡해집니다.

Spring Retry를 사용하면 어노테이션 하나로 깔끔하게 해결할 수 있습니다.

@Service
public class MailSenderService {

    @Retryable(
        retryFor = {RetryableException.class}, // 1. 재시도할 예외 지정 (SMTPConnectionException의 부모)
        maxAttempts = 3,                       // 2. 최대 시도 횟수 (최초 시도 포함)
        backoff = @Backoff(delay = 2000)       // 3. 대기 시간 (2초 간격)
    )
    public void sendEmail(String email) {
        // ... 비즈니스 로직 (이메일 검증 및 발송) ...
        // 20% 확률로 SMTPConnectionException 발생!
    }
    
    @Recover // 4. 모든 재시도가 실패했을 때 실행될 폴백(Fallback) 메서드
    public void recover(RetryableException e, String email) {
        log.error("모든 재시도 실패... 별도 처리가 필요합니다.");
        // DB에 저장하거나 알림 발송 등 후속 처리
    }
}
  • retryFor:

    • RetryableException과 그 자식 예외(SMTPConnectionException)만 재시도합니다.

    • InvalidEmailException은 재시도하지 않습니다.

  • maxAttempts:

    • 3번 시도합니다. (최초 1회 + 재시도 2회)

  • backoff:

    • 실패 후 바로 시도하지 않고 2초 대기(Delay)합니다.

    • 일시적 장애가 해소될 시간을 벌어주는 중요한 설정입니다.

  • @Recover:

    • 3번 다 실패하면 이 메서드가 실행되어 안전하게 실패 처리를 마무리합니다.

Timeout과 장애 전파 방지

재시도를 통해 일시적으로 장애를 극복할 수 있지만, 재시도보다 더 근본적으로 선행되어야 할 것이 있습니다.

“언제까지 기다렸다가 실패로 간주할 것인가?”

외부 서비스의 응답이 1분, 10분 동안 오지 않는데 마냥 기다리고 있다면 어떻게 될까요? 단순히 그 요청만 느려지는 게 아니라, 호출한 서버의 스레드(Thread)가 고갈되어 시스템 전체가 마비되는 결과로 이어집니다.

타임아웃이 왜 장애 전파 방지의 핵심인지, 그리고 Tomcat(MVC)과 WebFlux 환경에서 자원 관리의 차이가 어떻게 나타나는지 알아보겠습니다.

타임아웃은 왜 필요한가?

결제 요청이 PG사(PG Service)로 넘어갔다고 가정해 봅시다. 통상적인 결제는 5초 이내에 끝나는 것을 기대하는데, PG사에 장애가 생겨 응답을 주는 데 60초가 걸린다면 어떤 일이 벌어질까요?

  1. User: 결제 버튼을 누르고 무한 로딩에 걸립니다.

  2. Payment Service: PG사의 응답을 기다리느라 스레드(Thread) 하나를 점유하고 대기(Blocking)합니다.

  3. Main Service: Payment Service를 호출한 메인 서비스도 영향을 받아 대기합니다.

  4. 결과: 들어오는 요청마다 스레드를 점유하고 놓아주지 않으니, 결국 가용 스레드가 0개가 되어(Thread Pool Exhaustion), 결제와 상관없는 다른 로그인 요청, 조회 요청까지 모두 Connection Refused로 모두 거부됩니다.

이것이 바로 장애 전파입니다. 이를 막기 위해서 적절한 타임아웃 설정이 필요합니다.

Spring MVC에서의 Tomcat 스레드 고갈

Spring MVC는 기본적으로 Tomcat을 사용하며, 요청 당 하나의 스레드(Thread-per-request) 모델을 따릅니다.

장애 상황을 재현하기 위해 Tomcat의 자원을 설정을 통해 제한합니다.

  • server.tomcat.max-threads = 10 (최대 스레드 10개)

  • server.tomcat.accept-count = 5 (대기열 큐 5개)

  • 상황: 100개의 동시 요청을 보냄. (PG사는 10% 확률로 60초 지연 발생)

결과는 대부분의 요청이 java.net.ConnectException: Connection refused 예외를 발생하며 실패하게 됩니다.

그 이유는 무엇일까요?

10%의 확률로 발생한 지연 요청들이 10개의 스레드를 모두 점유해 버렸기 때문입니다. 스레드가 꽉 차고 대기열(Queue)까지 가득 차니, 톰캣은 이후 들어오는 요청을 아예 받아들이지도 못하고 연결을 거부(Refused)해 버리는 것입니다.

Spring WebFlux의 비동기 처리

이번에는 동일한 상황을 Spring WebFlux(Netty)로 변경하여 실행해 봅니다. WebFlux는 비동기/논블로킹(Non-blocking) 방식을 사용합니다.

모든 요청이 성공합니다. (60초 걸리는 요청은 늦게 끝나겠지만, 다른 요청을 막지는 않습니다.)

그 이유는 무엇일까요?

WebFlux는 요청을 보내고 응답을 기다리는 동안 스레드를 차단(Block)하지 않고 반납합니다. 응답이 오면 그때 다시 스레드가 할당되어 처리합니다. 따라서 소수의 스레드로도 수많은 동시 요청을 처리할 수 있어 스레드 고갈 문제에서 훨씬 자유롭습니다.

Connection Timeout vs Read Timeout

구분
Connection TImeout
Read Timeout

적용 단계

연결 수립 시도 단계

연결 수립 후 데이터 수신 대기 단계

목적

서버와 연결을 맺는 시간을 제한

연결된 서버로부터 응답을 받는 시간을 제한

발생 시점 예시

서버에 접속 요청 후 응답이 없을 때

서버에 요청을 보내고 응답을 기다릴 때

실패 의미

서버와 연결 자체를 실패

서버와 연결은 성공했으나 응답 수신에 실패할 때

Connection Timeout (연결 타임아웃)

  • 서버와 TCP 연결(3-way handshake)을 맺는 데 걸리는 시간입니다.

  • 이게 발생했다는 건 상대 서버가 아예 꺼져있거나, 네트워크 자체가 막혀있다는 뜻입니다.

Read Timeout (읽기 타임아웃)

  • 연결은 됐는데, 데이터를 받는 데 걸리는 시간입니다.

  • 서버는 요청을 처리하지만, 처리가 늦어지는 것입니다.

예외는 어디서 잡고, 어떻게 던져야 할까요?

재시도와 타임아웃 구현을 다루면서 예외를 외부 API를 호출하는 클라이언트에서 잡아서 던져야 할지, 아니면 서비스 계층에서 처리해야 할지와 같은 고민을 해볼 수 있습니다.

  • 메일 예제에서는 SMTP Clinet를 호출하는 서비스 계층에서 재시도

  • 결제 예제에서는 PG를 호출하는 PG Client에서 예외를 핸들링하여 재시도

이런 차이는 적절한 기준을 가지고 적절한 위치에 적용해야 합니다.

예외 핸들링 위치를 결정하는 기준

1. 라이브러리 의존성 격리

외부 라이브러리(RestTemplate, WebClient, SMTP Client 등)는 각자 고유한 예외(ReadTimeoutException, RestClientException 등)를 던집니다.

[나쁜 예] 라이브러리의 예외를 서비스나 컨트롤러까지 그대로 흘려보내는 경우입니다.

이렇게 하면 서비스 코드가 특정 라이브러리(Netty)에 강하게 결합됩니다.

나중에 HTTP 클라이언트를 OpenFeign이나 RestTemplate으로 바꾸면, 서비스 코드의 catch 블록을 전부 다 고쳐야 합니다.

[좋은 예] 라이브러리를 호출하는 클라이언트(Client/Adapter) 계층에서 예외를 잡고, 우리 시스템의 커스텀 예외로 바꿔서 던져야 합니다.

이렇게 하면 서비스 계층은 구체적인 통신 기술을 몰라도 되고, PaymentGatewayTimeoutException만 처리하면 되므로 유지보수성이 크게 향상(DIP 준수)됩니다.

2. 재시도 범위 최소화

@Retryable을 어디에 붙이느냐에 따라 재시도되는 코드의 범위가 달라집니다.

메일 전송 서비스에 이메일 유효성 검사(Validation) 로직과 SMTP 전송 로직이 섞여 있다고 가정해 보겠습니다.

[서비스 전체에 재시도 적용 시 문제점]

만약 smtpClient.send()에서 실패하면, validate(email)부터 다시 실행됩니다.

물론 유효성 검사는 가볍지만, 만약 무거운 DB 작업이나 중복되면 안 되는 로직이 섞여 있다면 문제가 커집니다.

[해결책: 책임 분리] 재시도가 필요한 외부 통신 로직만 별도 클래스로 분리하고, 그곳에 재시도를 적용하는 것이 안전합니다.

앞에서 결제 예시와 유사한 코드로 구성되면서, 이렇게 하면 재시도 단위를 좁게 제한하여 사이드 이펙트를 최소화할 수 있습니다.

요약: 예외 처리 기준

  1. 어디서 잡을까? (Catch)

    • 외부 라이브러리를 사용하는 가장 최전선(Client/Adapter Class)에서 잡는것이 좋습니다.

  2. 무엇을 던질까? (Throw)

    • 기술 종속적인 예외(NettyException 등)를 그대로 던지지 말고, 비즈니스 의미를 담은 커스텀 예외(PaymentException 등)로 포장(Wrap)해서 던지는 것이 좋습니다.

  3. 어디를 재시도할까? (Retry)

    • 서비스 전체를 재시도하지 말고, 실패 가능성이 높은 외부 통신 구간만 별도 클래스로 분리하여 재시도를 적용하는 것이 좋습니다.

이러한 예외 처리 기준은 단순히 에러를 막는 것이 아니라, 좋은 구조를 만드는 핵심이 될 수 있습니다.

  • 추상화: 구현 기술이 바뀌어도 비즈니스 로직은 변경되지 않고 보호받을 수 있습니다.

  • 격리: 재시도는 반드시 필요한 부분만 수행될 수 있습니다.

Last updated