본 콘텐츠의 이미지 및 내용은 AI로 생성되었습니다.
본 콘텐츠의 이미지 및 내용을 무단으로 복제, 배포, 수정하여 사용할 경우 저작권법에 의해 법적 제재를 받을 수 있습니다.
이미지 로딩 중...
AI Generated
2025. 12. 22. · 2 Views
재시도와 타임아웃 완벽 가이드
마이크로서비스에서 필수적인 재시도와 타임아웃 패턴을 실무 중심으로 배웁니다. Resilience4j를 활용한 안정적인 서비스 구축 방법을 단계별로 익힐 수 있습니다.
목차
1. @Retry 어노테이션
어느 날 김개발 씨가 외부 API를 호출하는 코드를 작성했습니다. 그런데 가끔씩 네트워크가 불안정해서 호출이 실패하곤 했습니다.
선배 개발자 박시니어 씨가 코드를 보더니 "재시도 로직은 왜 없어요?"라고 물었습니다.
@Retry 어노테이션은 메서드 실행이 실패했을 때 자동으로 다시 시도하는 기능입니다. 마치 전화를 걸었는데 통화 중일 때 몇 번 더 걸어보는 것과 같습니다.
이것을 사용하면 일시적인 네트워크 오류나 외부 서비스의 순간적인 장애를 쉽게 극복할 수 있습니다.
다음 코드를 살펴봅시다.
import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
@Service
public class PaymentService {
// 결제 API 호출 시 실패하면 자동으로 재시도
@Retry(name = "paymentRetry")
public PaymentResponse processPayment(PaymentRequest request) {
// 외부 결제 API 호출
return externalPaymentApi.charge(request);
}
// 재시도 실패 시 대체 메서드 실행
public PaymentResponse fallbackPayment(PaymentRequest request, Exception e) {
return PaymentResponse.failed("결제 서비스 일시 중단");
}
}
김개발 씨는 입사 6개월 차 백엔드 개발자입니다. 오늘은 결제 서비스를 개발하고 있습니다.
외부 결제 API를 호출하는 코드를 작성했는데, QA 팀에서 가끔씩 결제가 실패한다는 버그 리포트가 올라왔습니다. 로그를 확인해보니 "Connection timeout" 에러가 간헐적으로 발생하고 있었습니다.
김개발 씨는 당황했습니다. "분명히 API는 정상인데 왜 이럴까요?" 박시니어 씨가 다가와 설명했습니다.
"네트워크는 완벽하지 않아요. 일시적인 오류는 언제든 발생할 수 있죠.
그래서 재시도 로직이 필요합니다." 그렇다면 재시도란 정확히 무엇일까요? 쉽게 비유하자면, 재시도는 마치 친구에게 전화를 걸었는데 통화 중일 때 몇 번 더 걸어보는 것과 같습니다.
첫 번째 시도가 실패했다고 포기하지 않고, 잠시 기다렸다가 다시 시도해보는 것입니다. 많은 경우 두 번째나 세 번째 시도에서는 성공하게 됩니다.
재시도 로직이 없던 시절에는 어땠을까요? 개발자들은 try-catch 블록 안에 while 문을 넣어서 직접 재시도 로직을 구현해야 했습니다.
코드가 복잡해지고, 각 서비스마다 비슷한 코드가 반복되었습니다. 더 큰 문제는 재시도 횟수나 간격을 변경하려면 코드를 일일이 수정해야 한다는 점이었습니다.
바로 이런 문제를 해결하기 위해 @Retry 어노테이션이 등장했습니다. @Retry를 사용하면 메서드에 어노테이션 하나만 붙이면 됩니다.
재시도 로직이 자동으로 적용되어 코드가 훨씬 깔끔해집니다. 또한 설정 파일에서 재시도 정책을 관리할 수 있어 유지보수가 쉬워집니다.
무엇보다 AOP 방식으로 동작하기 때문에 비즈니스 로직과 재시도 로직이 완전히 분리됩니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 @Retry 어노테이션의 name 속성은 설정 파일에서 정의한 재시도 정책의 이름을 지정합니다. 이 부분이 핵심입니다.
processPayment 메서드가 예외를 던지면 Resilience4j가 자동으로 재시도를 수행합니다. fallbackPayment 메서드는 모든 재시도가 실패했을 때 호출되는 대체 메서드입니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 전자상거래 서비스를 개발한다고 가정해봅시다.
주문 처리 과정에서 결제 API, 재고 확인 API, 배송 API 등 여러 외부 서비스를 호출해야 합니다. 각 호출마다 @Retry를 적용하면 일시적인 네트워크 오류로 인한 주문 실패를 크게 줄일 수 있습니다.
실제로 쿠팡, 배달의민족 같은 대형 서비스에서도 이런 패턴을 적극적으로 사용하고 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 모든 예외에 대해 재시도를 적용하는 것입니다. 예를 들어 잘못된 파라미터로 인한 검증 오류는 몇 번을 재시도해도 성공할 수 없습니다.
따라서 재시도할 예외와 그렇지 않을 예외를 명확히 구분해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 들은 김개발 씨는 고개를 끄덕였습니다. "아, 그래서 Netflix나 AWS SDK에서도 자동 재시도를 지원하는 거군요!" @Retry 어노테이션을 제대로 이해하면 더 안정적이고 회복력 있는 서비스를 만들 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 재시도는 멱등성이 보장되는 작업에만 적용하세요
- 설정 파일에서 재시도 정책을 중앙 관리하면 유지보수가 쉽습니다
- 재시도 횟수는 보통 3회 이내로 설정하는 것이 적절합니다
2. 재시도 횟수와 간격
김개발 씨가 @Retry를 적용했더니 선배가 물었습니다. "재시도는 몇 번 하게 설정했어요?
간격은요?" 김개발 씨는 당황했습니다. 설정 없이 어노테이션만 붙였거든요.
재시도 횟수는 실패 시 몇 번까지 다시 시도할지 정하는 것이고, 재시도 간격은 각 시도 사이에 얼마나 기다릴지 정하는 것입니다. 마치 시험에서 문제를 못 풀었을 때 몇 번까지 다시 풀어볼지, 각 시도마다 몇 분씩 고민할지 정하는 것과 같습니다.
적절한 값을 설정하면 시스템 부하를 줄이면서도 높은 성공률을 유지할 수 있습니다.
다음 코드를 살펴봅시다.
# application.yml
resilience4j:
retry:
instances:
paymentRetry:
maxAttempts: 3 # 최대 3번 시도
waitDuration: 1000 # 1초 대기
retryExceptions:
- java.net.ConnectException
- java.net.SocketTimeoutException
ignoreExceptions:
- java.lang.IllegalArgumentException
박시니어 씨가 김개발 씨의 화면을 보며 말했습니다. "재시도 설정이 없네요.
기본값으로 동작하고 있어요." 김개발 씨가 물었습니다. "기본값이면 안 되나요?" 박시니어 씨가 고개를 저었습니다.
"서비스마다 최적의 값이 다르거든요. 결제 API와 조회 API는 재시도 전략이 달라야 합니다." 그렇다면 재시도 횟수와 간격은 어떻게 결정해야 할까요?
쉽게 비유하자면, 재시도 설정은 마치 낚시를 할 때 몇 번까지 던질지, 각 던짐 사이에 얼마나 기다릴지 정하는 것과 같습니다. 너무 자주 던지면 물고기가 놀라서 도망가고, 너무 오래 기다리면 기회를 놓칠 수 있습니다.
적절한 균형을 찾는 것이 핵심입니다. 재시도 설정이 없던 시절에는 어땠을까요?
개발자들은 코드에 직접 재시도 횟수와 대기 시간을 하드코딩했습니다. 값을 변경하려면 코드를 수정하고 다시 배포해야 했습니다.
더 큰 문제는 각 개발자마다 다른 값을 사용해서 일관성이 없었다는 점입니다. 어떤 서비스는 10번 재시도하고, 어떤 서비스는 1번만 시도했습니다.
바로 이런 문제를 해결하기 위해 설정 기반 재시도 관리가 등장했습니다. 설정 파일에서 재시도 정책을 관리하면 코드 수정 없이 값을 변경할 수 있습니다.
또한 환경별로 다른 설정을 적용할 수 있어 개발 환경에서는 빠르게 실패하고, 운영 환경에서는 충분히 재시도하도록 만들 수 있습니다. 무엇보다 모든 서비스가 일관된 정책을 따르게 할 수 있습니다.
위의 설정을 한 줄씩 살펴보겠습니다. 먼저 maxAttempts는 최초 시도를 포함한 총 시도 횟수입니다.
3으로 설정하면 최초 1번, 재시도 2번입니다. waitDuration은 각 재시도 사이의 대기 시간을 밀리초 단위로 지정합니다.
retryExceptions는 어떤 예외가 발생했을 때 재시도할지 정의하고, ignoreExceptions는 재시도하지 않을 예외를 지정합니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 금융 서비스를 개발한다고 가정해봅시다. 계좌 잔액 조회는 빠르게 실패해도 되니 maxAttempts를 2로, waitDuration을 500ms로 설정합니다.
반면 송금 처리는 중요하니 maxAttempts를 5로, waitDuration을 2000ms로 설정할 수 있습니다. 카카오뱅크나 토스 같은 핀테크 기업에서도 거래 유형에 따라 재시도 전략을 다르게 가져갑니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 재시도 횟수를 너무 많이 설정하는 것입니다.
예를 들어 10번 재시도하도록 설정하면 대기 시간까지 합쳐서 응답 시간이 너무 길어집니다. 사용자는 화면이 멈춘 것처럼 느낄 수 있습니다.
따라서 전체 응답 시간을 고려해서 적절한 값을 선택해야 합니다. 또 다른 실수는 모든 예외를 재시도 대상으로 설정하는 것입니다.
IllegalArgumentException이나 NullPointerException 같은 프로그래밍 오류는 재시도해도 절대 성공할 수 없습니다. 이런 예외는 ignoreExceptions에 추가해서 즉시 실패하도록 만들어야 합니다.
그래야 불필요한 재시도로 인한 시스템 부하를 줄일 수 있습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 조언을 듣고 김개발 씨는 결제 서비스에 적절한 재시도 설정을 추가했습니다. "이제 일시적인 네트워크 오류도 잘 처리되네요!" 재시도 횟수와 간격을 적절히 설정하면 안정성과 성능 사이의 균형을 맞출 수 있습니다.
여러분도 서비스 특성에 맞는 최적의 값을 찾아보세요.
실전 팁
💡 - 중요한 작업일수록 재시도 횟수를 늘리고 간격을 길게 설정하세요
- 전체 응답 시간이 10초를 넘지 않도록 조절하세요
- 운영 중 메트릭을 보며 설정값을 지속적으로 튜닝하세요
3. 지수 백오프
김개발 씨가 재시도 간격을 1초로 설정했더니, 외부 서비스 장애 시 요청이 폭주해서 상황이 더 악화되었습니다. 박시니어 씨가 "지수 백오프를 써봤어요?"라고 물었습니다.
지수 백오프는 재시도할 때마다 대기 시간을 지수적으로 늘리는 전략입니다. 마치 시험 문제를 다시 풀 때 첫 번째는 1분, 두 번째는 2분, 세 번째는 4분씩 고민하는 것과 같습니다.
이렇게 하면 외부 서비스가 복구될 시간을 충분히 주면서도, 시스템 전체의 부하를 크게 줄일 수 있습니다.
다음 코드를 살펴봅시다.
# application.yml
resilience4j:
retry:
instances:
paymentRetry:
maxAttempts: 4
waitDuration: 1000 # 초기 대기 시간 1초
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
# 1초 -> 2초 -> 4초 -> 8초로 증가
# 최대 대기 시간 제한 (선택사항)
exponentialMaxWaitDuration: 10000
김개발 씨는 충격을 받았습니다. 재시도 로직을 추가했는데 오히려 상황이 악화되다니요.
박시니어 씨가 모니터링 화면을 가리키며 설명했습니다. "보세요.
외부 결제 API가 30초간 다운되었을 때, 우리 서버에서 1초마다 재시도 요청을 보냈어요. 수백 개의 요청이 동시에 몰렸죠.
이게 바로 썬더링 허드 문제입니다." 김개발 씨가 물었습니다. "그럼 어떻게 해야 하나요?" 박시니어 씨가 답했습니다.
"지수 백오프를 사용하면 됩니다." 그렇다면 지수 백오프란 정확히 무엇일까요? 쉽게 비유하자면, 지수 백오프는 마치 친구 집 초인종을 누를 때와 같습니다.
첫 번째는 바로 누르고, 응답이 없으면 조금 기다렸다가 두 번째를 누릅니다. 그래도 없으면 더 길게 기다렸다가 세 번째를 누릅니다.
이렇게 하면 친구가 샤워 중이거나 잠깐 자리를 비운 경우에도 충분한 시간을 주게 됩니다. 고정된 간격으로 재시도하던 시절에는 어땠을까요?
모든 클라이언트가 동시에 같은 간격으로 재시도하면 서버에 트래픽이 몰립니다. 서버가 막 복구되려는 순간에 수천 개의 요청이 동시에 들어오면 다시 다운될 수 있습니다.
이를 "썬더링 허드"라고 부릅니다. 마치 천둥소리에 놀란 소 떼가 한꺼번에 달려드는 것과 같다고 해서 붙여진 이름입니다.
바로 이런 문제를 해결하기 위해 지수 백오프가 등장했습니다. 지수 백오프를 사용하면 재시도할 때마다 대기 시간이 기하급수적으로 늘어납니다.
첫 번째 재시도는 1초 후, 두 번째는 2초 후, 세 번째는 4초 후, 네 번째는 8초 후에 발생합니다. 이렇게 하면 요청이 시간적으로 분산되어 서버 부하가 크게 줄어듭니다.
또한 외부 서비스가 복구될 충분한 시간을 확보할 수 있습니다. 위의 설정을 한 줄씩 살펴보겠습니다.
enableExponentialBackoff를 true로 설정하면 지수 백오프가 활성화됩니다. exponentialBackoffMultiplier는 대기 시간의 증가 배수를 지정합니다.
2로 설정하면 매번 2배씩 늘어납니다. exponentialMaxWaitDuration은 대기 시간의 상한선을 정합니다.
아무리 늘어나도 10초를 넘지 않게 제한하는 것입니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 대규모 이커머스 서비스를 운영한다고 가정해봅시다. 블랙프라이데이 같은 대목에 외부 결제 게이트웨이가 잠시 과부하 상태가 됩니다.
이때 고정 간격 재시도를 사용하면 우리 서버의 재시도 요청이 상황을 더 악화시킵니다. 하지만 지수 백오프를 사용하면 결제 게이트웨이가 안정화될 시간을 주면서 점진적으로 요청을 보낼 수 있습니다.
AWS나 Google Cloud 같은 클라우드 서비스들도 API 호출 시 지수 백오프를 권장하고 있습니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 exponentialBackoffMultiplier를 너무 크게 설정하는 것입니다. 예를 들어 10으로 설정하면 두 번째 재시도가 10초, 세 번째가 100초 후에 발생합니다.
사용자는 이렇게 오래 기다릴 수 없습니다. 보통 2에서 3 사이의 값을 사용하는 것이 적절합니다.
또 다른 실수는 exponentialMaxWaitDuration을 설정하지 않는 것입니다. 제한 없이 대기 시간이 늘어나면 마지막 재시도는 수 분 후에 발생할 수 있습니다.
이미 사용자는 포기하고 떠난 후입니다. 따라서 합리적인 상한선을 설정해야 합니다.
보통 10초에서 30초 사이가 적절합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 설명을 듣고 김개발 씨는 지수 백오프를 적용했습니다. "이제 외부 서비스 장애 시에도 우리 서버는 안정적이네요!" 지수 백오프를 제대로 이해하면 대규모 트래픽 상황에서도 안정적인 서비스를 만들 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - exponentialBackoffMultiplier는 보통 2로 설정하세요
- exponentialMaxWaitDuration으로 최대 대기 시간을 제한하세요
- 지터를 추가하면 요청이 더욱 분산됩니다
4. @TimeLimiter 사용
재시도 로직을 완벽하게 구현한 김개발 씨. 그런데 이번에는 외부 API가 응답을 아예 안 보내는 상황이 발생했습니다.
요청이 무한정 기다리다가 서버 스레드가 모두 고갈되었습니다. 박시니어 씨가 "타임아웃은 설정했어요?"라고 물었습니다.
@TimeLimiter는 메서드 실행에 제한 시간을 두는 어노테이션입니다. 마치 시험 시간처럼 일정 시간 안에 완료되지 않으면 자동으로 중단시킵니다.
이것을 사용하면 느린 외부 서비스가 전체 시스템을 멈추게 하는 것을 방지할 수 있습니다.
다음 코드를 살펴봅시다.
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
import io.github.resilience4j.retry.annotation.Retry;
import java.util.concurrent.CompletableFuture;
@Service
public class PaymentService {
// 5초 안에 완료되지 않으면 타임아웃
@TimeLimiter(name = "paymentTimeout")
@Retry(name = "paymentRetry")
public CompletableFuture<PaymentResponse> processPayment(PaymentRequest request) {
return CompletableFuture.supplyAsync(() -> {
// 외부 결제 API 호출
return externalPaymentApi.charge(request);
});
}
// 타임아웃 발생 시 대체 메서드
public CompletableFuture<PaymentResponse> fallbackPayment(PaymentRequest request, Exception e) {
return CompletableFuture.completedFuture(
PaymentResponse.failed("처리 시간 초과")
);
}
}
김개발 씨는 또 다시 장애 알림을 받았습니다. 이번에는 서버의 모든 스레드가 WAITING 상태에 빠져 있었습니다.
박시니어 씨가 스레드 덤프를 보며 한숨을 쉬었습니다. "외부 API가 응답을 안 보내고 있네요.
우리 서버는 응답을 무한정 기다리고 있고요. 스레드 풀이 모두 고갈되어서 새로운 요청을 받을 수 없는 상태입니다." 김개발 씨가 당황하며 물었습니다.
"재시도 로직은 완벽한데 왜 이런 일이?" 박시니어 씨가 설명했습니다. "재시도는 실패했을 때의 이야기고, 아예 응답이 없으면 소용없어요.
타임아웃이 필요합니다." 그렇다면 타임아웃이란 정확히 무엇일까요? 쉽게 비유하자면, 타임아웃은 마치 음식점에서 주문한 음식을 기다릴 때와 같습니다.
30분이 지나도 음식이 안 나오면 기다림을 포기하고 다른 식당으로 가는 것입니다. 무한정 기다리면 배만 고파지고 시간만 낭비됩니다.
적절한 시점에 포기하고 다른 방법을 찾는 것이 현명합니다. 타임아웃 설정이 없던 시절에는 어땠을까요?
개발자들은 HTTP 클라이언트마다 개별적으로 타임아웃을 설정해야 했습니다. 그런데 이것도 놓치기 쉬웠습니다.
또한 비동기 작업의 경우 타임아웃을 구현하기가 더욱 복잡했습니다. Future에 타임아웃을 걸고, 별도의 스레드로 모니터링하고, 시간 초과 시 작업을 취소하는 코드를 직접 작성해야 했습니다.
바로 이런 문제를 해결하기 위해 @TimeLimiter가 등장했습니다. @TimeLimiter를 사용하면 메서드에 어노테이션 하나만 붙이면 됩니다.
설정한 시간 안에 메서드가 완료되지 않으면 자동으로 TimeoutException을 발생시킵니다. 또한 CompletableFuture와 함께 사용하여 비동기 작업도 쉽게 제어할 수 있습니다.
무엇보다 @Retry와 함께 사용하면 타임아웃과 재시도가 조화롭게 동작합니다. 위의 코드를 한 줄씩 살펴보겠습니다.
먼저 @TimeLimiter와 @Retry를 함께 사용하고 있습니다. 순서가 중요한데, TimeLimiter가 먼저 적용되고 그 다음 Retry가 적용됩니다.
processPayment 메서드는 CompletableFuture를 반환해야 합니다. TimeLimiter는 비동기 방식으로 동작하기 때문입니다.
supplyAsync로 실제 작업을 비동기로 실행하고, TimeLimiter가 제한 시간을 모니터링합니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 여행 예약 플랫폼을 개발한다고 가정해봅시다. 항공권 검색 시 여러 항공사의 API를 동시에 호출합니다.
어떤 항공사는 응답이 빠르지만, 어떤 곳은 매우 느립니다. TimeLimiter를 3초로 설정하면 느린 항공사의 응답을 기다리지 않고 빠르게 결과를 보여줄 수 있습니다.
스카이스캐너나 카약 같은 메타 검색 서비스에서 이런 패턴을 사용합니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 타임아웃 시간을 너무 짧게 설정하는 것입니다. 예를 들어 정상적으로 3초가 걸리는 작업에 1초 타임아웃을 설정하면 항상 실패합니다.
타임아웃 시간은 정상 응답 시간의 1.5배에서 2배 정도로 설정하는 것이 적절합니다. 또 다른 실수는 @TimeLimiter를 동기 메서드에 적용하는 것입니다.
TimeLimiter는 CompletableFuture를 반환하는 비동기 메서드에만 사용할 수 있습니다. 일반 동기 메서드에 적용하면 제대로 동작하지 않습니다.
HTTP 클라이언트 자체의 타임아웃과 TimeLimiter의 타임아웃을 혼동하지 않도록 주의해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 조언을 듣고 김개발 씨는 TimeLimiter를 적용했습니다. "이제 느린 외부 API 때문에 서버가 멈추는 일이 없네요!" @TimeLimiter를 제대로 이해하면 외부 의존성으로부터 시스템을 효과적으로 보호할 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 타임아웃 시간은 정상 응답 시간의 1.5-2배로 설정하세요
- CompletableFuture를 반환하는 비동기 메서드에만 사용하세요
- HTTP 클라이언트의 타임아웃도 함께 설정하면 더 안전합니다
5. 타임아웃 설정
김개발 씨가 @TimeLimiter를 코드에 추가했습니다. 그런데 "설정 파일은 어떻게 작성하나요?"라고 물으니 박시니어 씨가 "재시도 설정처럼 YAML 파일에 작성하면 됩니다"라고 답했습니다.
타임아웃 설정은 TimeLimiter의 동작 방식을 제어하는 구성입니다. 마치 시험 시간을 몇 분으로 정할지, 시간 초과 시 어떤 조치를 취할지 미리 정하는 것과 같습니다.
설정 파일에서 중앙 관리하면 코드 수정 없이 타임아웃 정책을 변경할 수 있습니다.
다음 코드를 살펴봅시다.
# application.yml
resilience4j:
timelimiter:
instances:
paymentTimeout:
timeoutDuration: 5s # 5초 타임아웃
cancelRunningFuture: true # 타임아웃 시 작업 취소
retry:
instances:
paymentRetry:
maxAttempts: 3
waitDuration: 1000
enableExponentialBackoff: true
exponentialBackoffMultiplier: 2
김개발 씨가 application.yml 파일을 열었습니다. 재시도 설정은 이미 작성했는데, 타임아웃 설정은 어떻게 추가해야 할지 막막했습니다.
박시니어 씨가 옆에서 화면을 보며 설명하기 시작했습니다. "resilience4j에는 여러 모듈이 있어요.
retry, timelimiter, circuitbreaker 등이죠. 각각 독립적으로 설정할 수 있습니다." 김개발 씨가 고개를 끄덕이며 물었습니다.
"그럼 timelimiter 아래에 설정을 추가하면 되는 거네요?" 박시니어 씨가 웃으며 답했습니다. "맞아요.
패턴은 비슷합니다." 그렇다면 타임아웃 설정에는 어떤 옵션들이 있을까요? 쉽게 비유하자면, 타임아웃 설정은 마치 배달 주문할 때 조건을 정하는 것과 같습니다.
몇 분 안에 도착해야 하는지, 시간 초과 시 주문을 취소할지 말지 등을 미리 결정하는 것입니다. 이런 조건들을 명확히 정해두면 예상치 못한 상황에서도 일관되게 대응할 수 있습니다.
타임아웃 설정을 코드에 하드코딩하던 시절에는 어떤 문제가 있었을까요? 개발자마다 다른 타임아웃 값을 사용했습니다.
어떤 서비스는 3초, 어떤 서비스는 10초, 또 어떤 서비스는 30초를 사용했습니다. 기준이 없었습니다.
값을 변경하려면 코드를 수정하고 다시 배포해야 했습니다. 긴급하게 타임아웃을 늘려야 하는 상황에서도 배포 과정을 거쳐야 했습니다.
바로 이런 문제를 해결하기 위해 설정 기반 타임아웃 관리가 등장했습니다. 설정 파일에서 타임아웃을 관리하면 환경별로 다른 값을 적용할 수 있습니다.
개발 환경에서는 디버깅을 위해 타임아웃을 길게, 운영 환경에서는 사용자 경험을 위해 짧게 설정할 수 있습니다. 또한 Spring Cloud Config와 연동하면 배포 없이 실시간으로 값을 변경할 수도 있습니다.
무엇보다 모든 서비스가 일관된 타임아웃 정책을 따르게 만들 수 있습니다. 위의 설정을 한 줄씩 살펴보겠습니다.
instances 아래에 paymentTimeout이라는 이름으로 설정을 정의합니다. 이 이름은 @TimeLimiter의 name 속성과 일치해야 합니다.
timeoutDuration은 제한 시간을 지정하는데, 초 단위(s), 밀리초 단위(ms), 분 단위(m) 등을 사용할 수 있습니다. cancelRunningFuture는 타임아웃 발생 시 실행 중인 작업을 취소할지 여부를 결정합니다.
cancelRunningFuture 옵션을 좀 더 자세히 살펴볼까요? true로 설정하면 타임아웃이 발생했을 때 CompletableFuture의 cancel 메서드를 호출합니다.
이렇게 하면 실행 중인 스레드에 인터럽트가 발생하여 작업이 중단될 수 있습니다. 하지만 모든 작업이 인터럽트에 반응하는 것은 아닙니다.
HTTP 호출 중이라면 제대로 취소되지 않을 수 있습니다. 실제 현업에서는 어떻게 활용할까요?
예를 들어 마이크로서비스 아키텍처를 구축한다고 가정해봅시다. 주문 서비스는 결제 서비스, 재고 서비스, 배송 서비스를 호출합니다.
각 서비스마다 적절한 타임아웃을 설정해야 합니다. 결제는 중요하니 10초, 재고 확인은 빠르게 3초, 배송지 검증은 5초 이런 식으로 서비스 특성에 맞게 설정합니다.
쿠팡이나 네이버 같은 대규모 서비스에서도 서비스 간 호출마다 타임아웃을 세밀하게 관리합니다. 하지만 주의할 점도 있습니다.
초보 개발자들이 흔히 하는 실수 중 하나는 모든 서비스에 동일한 타임아웃을 설정하는 것입니다. 단순 조회와 복잡한 계산 작업의 타임아웃이 같을 수는 없습니다.
각 작업의 특성을 고려해서 개별적으로 설정해야 합니다. 또 다른 실수는 재시도와 타임아웃을 함께 고려하지 않는 것입니다.
예를 들어 타임아웃을 5초로, 재시도를 3번으로 설정했다면 최악의 경우 15초가 걸릴 수 있습니다. 지수 백오프까지 적용하면 더 길어집니다.
전체 응답 시간을 계산해서 사용자가 기다릴 수 있는 범위 내로 조정해야 합니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 도움으로 김개발 씨는 완벽한 타임아웃 설정을 작성했습니다. "이제 설정 파일만 봐도 서비스의 타임아웃 정책을 한눈에 알 수 있네요!" 타임아웃 설정을 제대로 관리하면 안정적이고 예측 가능한 시스템을 만들 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 서비스 특성에 맞게 개별적으로 타임아웃을 설정하세요
- 재시도와 타임아웃을 곱해서 전체 응답 시간을 계산하세요
- 운영 중 실제 응답 시간 분포를 모니터링하며 튜닝하세요
6. 조합 사용 전략
모든 설정을 완료한 김개발 씨. 박시니어 씨가 "이제 재시도와 타임아웃을 함께 사용할 때의 전략을 이해해야 해요"라고 말했습니다.
김개발 씨가 "따로따로는 이해했는데, 함께 쓰면 뭐가 다른가요?"라고 물었습니다.
조합 사용 전략은 @Retry와 @TimeLimiter를 효과적으로 함께 사용하는 방법입니다. 마치 시험 문제를 풀 때 각 문제당 제한 시간을 두고, 못 풀면 다시 시도하되, 전체 시험 시간도 고려하는 것과 같습니다.
두 패턴을 조화롭게 조합하면 안정성과 성능 사이의 최적 균형을 찾을 수 있습니다.
다음 코드를 살펴봅시다.
import io.github.resilience4j.timelimiter.annotation.TimeLimiter;
import io.github.resilience4j.retry.annotation.Retry;
import org.springframework.stereotype.Service;
import java.util.concurrent.CompletableFuture;
@Service
public class PaymentService {
// TimeLimiter가 먼저, Retry가 나중에 적용됨
@TimeLimiter(name = "paymentTimeout")
@Retry(name = "paymentRetry", fallbackMethod = "fallbackPayment")
public CompletableFuture<PaymentResponse> processPayment(PaymentRequest request) {
return CompletableFuture.supplyAsync(() -> {
log.info("결제 처리 시도: {}", request.getOrderId());
return externalPaymentApi.charge(request);
});
}
// 모든 재시도 실패 시 폴백
private CompletableFuture<PaymentResponse> fallbackPayment(
PaymentRequest request, Exception e) {
log.error("결제 실패, 폴백 실행: {}", e.getMessage());
return CompletableFuture.completedFuture(
PaymentResponse.failed("결제 서비스 일시 중단")
);
}
}
박시니어 씨가 화이트보드를 꺼내며 설명하기 시작했습니다. "재시도와 타임아웃을 함께 사용하면 시너지 효과가 있어요.
하지만 잘못 조합하면 오히려 독이 될 수 있습니다." 김개발 씨는 진지한 표정으로 귀를 기울였습니다. 이제까지 배운 내용을 실전에서 어떻게 활용해야 할지 궁금했습니다.
박시니어 씨가 그림을 그리며 설명했습니다. "예를 들어 타임아웃 5초, 재시도 3번이라고 해봅시다.
최악의 경우 5초 × 3번 = 15초가 걸립니다. 여기에 지수 백오프까지 더하면?" 그렇다면 조합 사용 전략이란 정확히 무엇일까요?
쉽게 비유하자면, 조합 사용 전략은 마치 운동선수가 훈련할 때와 같습니다. 근력 운동만 하거나 유산소 운동만 하는 것이 아니라, 두 가지를 적절히 조합해서 최고의 컨디션을 만드는 것입니다.
재시도는 안정성을, 타임아웃은 성능을 담당합니다. 둘을 잘 조합하면 안정적이면서도 빠른 시스템을 만들 수 있습니다.
재시도와 타임아웃을 따로 사용하던 시절에는 어땠을까요? 재시도만 있으면 느린 응답에 무방비로 노출됩니다.
외부 서비스가 30초씩 걸리는데 재시도를 3번 하면 90초를 기다려야 합니다. 반대로 타임아웃만 있으면 일시적인 네트워크 오류로 쉽게 실패합니다.
단 한 번의 타임아웃으로 중요한 결제가 실패할 수 있습니다. 바로 이런 문제를 해결하기 위해 조합 사용 전략이 중요해졌습니다.
@TimeLimiter를 먼저 적용하고 @Retry를 나중에 적용하면 이상적입니다. 각 시도마다 타임아웃이 적용되고, 타임아웃으로 실패하면 재시도가 발생합니다.
이렇게 하면 한 번의 느린 응답 때문에 전체가 지연되지 않으면서도, 일시적인 오류는 재시도로 극복할 수 있습니다. 또한 fallbackMethod를 지정하면 모든 시도가 실패했을 때 우아하게 처리할 수 있습니다.
위의 코드를 한 줄씩 살펴보겠습니다. 먼저 @TimeLimiter를 위에, @Retry를 아래에 배치했습니다.
스프링의 AOP는 아래에서 위로 적용되므로 실제로는 Retry가 먼저, TimeLimiter가 나중에 감싸게 됩니다. 하지만 실행 순서는 TimeLimiter가 먼저입니다.
fallbackMethod는 모든 재시도가 실패했을 때만 호출됩니다. 각 재시도마다 호출되는 것이 아닙니다.
실무에서 최적의 값은 어떻게 찾을까요? 먼저 외부 서비스의 정상 응답 시간을 측정합니다.
평균이 2초라면 타임아웃은 4초 정도로 설정합니다. 그 다음 재시도 횟수를 결정합니다.
중요한 작업이라면 3번, 덜 중요하면 2번 정도가 적절합니다. 지수 백오프는 배수 2로 시작합니다.
이렇게 하면 4초 + 8초 + 16초 = 28초가 최대 응답 시간이 됩니다. 28초는 너무 길까요?
사용자 경험 관점에서 보면 그렇습니다. 따라서 exponentialMaxWaitDuration을 10초로 제한합니다.
그러면 4초 + 8초 + 10초 = 22초로 줄어듭니다. 여전히 길다면 재시도 횟수를 2번으로 줄입니다.
4초 + 8초 = 12초가 됩니다. 이 정도면 사용자가 기다릴 수 있는 범위입니다.
실제 현업에서는 어떻게 활용할까요? 예를 들어 은행 시스템을 개발한다고 가정해봅시다.
송금 처리는 매우 중요하므로 타임아웃 10초, 재시도 5번, 지수 백오프 최대 20초로 설정합니다. 전체 응답 시간이 길어도 괜찮습니다.
반면 잔액 조회는 빠르게 실패해도 되므로 타임아웃 2초, 재시도 2번, 지수 백오프 최대 5초로 설정합니다. 카카오뱅크나 토스 같은 핀테크 서비스에서도 거래 유형에 따라 전략을 달리 가져갑니다.
하지만 주의할 점도 있습니다. 초보 개발자들이 흔히 하는 실수 중 하나는 모든 서비스에 동일한 조합을 적용하는 것입니다.
조회 API와 변경 API는 전략이 달라야 합니다. 조회는 빠르게 실패해도 되지만, 변경은 신중해야 합니다.
또한 멱등성이 보장되지 않는 작업에 재시도를 적용하면 중복 처리가 발생할 수 있습니다. 또 다른 실수는 전체 응답 시간을 계산하지 않는 것입니다.
각각의 설정은 합리적으로 보이지만, 조합하면 사용자가 참을 수 없을 만큼 느려질 수 있습니다. 항상 최악의 경우를 가정해서 전체 응답 시간을 계산하고, 이것이 허용 범위 내인지 확인해야 합니다.
일반적으로 웹 애플리케이션은 3초 이내, 중요한 작업도 10초를 넘지 않는 것이 좋습니다. 모니터링도 중요합니다.
운영 환경에서 실제 재시도율, 타임아웃 발생률, 평균 응답 시간을 지속적으로 모니터링해야 합니다. 재시도가 너무 자주 발생하면 외부 서비스에 문제가 있는 것이고, 타임아웃이 빈번하면 설정값을 조정해야 합니다.
Prometheus와 Grafana를 연동하면 이런 메트릭을 실시간으로 확인할 수 있습니다. 다시 김개발 씨의 이야기로 돌아가 봅시다.
박시니어 씨의 자세한 설명을 듣고 김개발 씨는 완벽하게 이해했습니다. "재시도와 타임아웃을 함께 사용하니 훨씬 안정적이네요!" 김개발 씨는 결제 서비스에 최적의 조합을 적용했습니다.
타임아웃 5초, 재시도 3번, 지수 백오프 최대 10초. 전체 응답 시간은 최대 15초 정도로 계산되었습니다.
일주일 후, QA 팀에서 칭찬 메시지가 왔습니다. "네트워크가 불안정해도 결제가 잘 되네요!" 조합 사용 전략을 제대로 이해하면 안정적이면서도 성능이 좋은 마이크로서비스를 만들 수 있습니다.
여러분도 오늘 배운 내용을 실제 프로젝트에 적용해 보세요.
실전 팁
💡 - 작업 중요도에 따라 타임아웃과 재시도 횟수를 다르게 설정하세요
- 전체 응답 시간을 항상 계산하고 허용 범위 내로 제한하세요
- 운영 메트릭을 모니터링하며 지속적으로 최적화하세요
이상으로 학습을 마칩니다. 위 내용을 직접 코드로 작성해보면서 익혀보세요!
댓글 (0)
함께 보면 좋은 카드 뉴스
관찰 가능한 마이크로서비스 완벽 가이드
마이크로서비스 환경에서 시스템의 상태를 실시간으로 관찰하고 모니터링하는 방법을 배웁니다. Resilience4j, Zipkin, Prometheus, Grafana, EFK 스택을 활용하여 안정적이고 관찰 가능한 시스템을 구축하는 실전 가이드입니다.
EFK 스택 로깅 완벽 가이드
마이크로서비스 환경에서 로그를 효과적으로 수집하고 분석하는 EFK 스택(Elasticsearch, Fluentd, Kibana)의 핵심 개념과 실전 활용법을 초급 개발자도 쉽게 이해할 수 있도록 정리한 가이드입니다.
Grafana 대시보드 완벽 가이드
실시간 모니터링의 핵심, Grafana 대시보드를 처음부터 끝까지 배워봅니다. Prometheus 연동부터 알람 설정까지, 초급 개발자도 쉽게 따라할 수 있는 실전 가이드입니다.
Prometheus 메트릭 수집 완벽 가이드
Spring Boot 애플리케이션의 메트릭을 Prometheus로 수집하고 모니터링하는 방법을 배웁니다. Actuator 설정부터 PromQL 쿼리까지 실무에 필요한 모든 내용을 다룹니다.
스프링 관찰 가능성 완벽 가이드
Spring Boot 3.x의 Observation API를 활용한 애플리케이션 모니터링과 추적 방법을 초급 개발자 눈높이에서 쉽게 설명합니다. 실무에서 바로 적용할 수 있는 메트릭 수집과 분산 추적 기법을 다룹니다.