
작성해주신 글은 외부 API 연동 시 발생할 수 있는 핵심적인 문제들을 아주 잘 짚어주셨습니다. 특히 Connection/Read Timeout의 구분과 트랜잭션 분리는 시니어 개발자들도 실무에서 종종 놓치는 중요한 포인트입니다.
8년 차 개발자의 시각을 더해, 이 내용을 실제 서비스 환경에서 운영 가능한 수준의 "엔지니어링 가이드" 형태로 보완해 보았습니다. 동료들에게 공유하기 좋은 기술 블로그나 사내 위키 스타일로 다듬었습니다.
외부 API 연동은 단순히 데이터를 주고받는 것을 넘어, 우리 서비스의 가용성과 데이터 정합성을 결정짓는 중요한 요소입니다.
운영 환경에서 장애를 최소화하기 위해 반드시 고려해야 할 체크리스트를 정리합니다.
단순히 "시간 제한"을 두는 것이 아니라, 네트워크 레이어와 비즈니스 특성에 따라 분리해서 생각해야 합니다.
Connection Timeout: 서버 간 TCP 연결을 수립하는 단계의 제한 시간입니다.
보통 아주 짧게 예를들어 3초미만 설정합니다.
이 단계에서 실패한다면 외부 서버의 다운이나 네트워크 단절을 의미합니다.
Read Timeout: 연결 후 응답을 기다리는 시간입니다.
외부 API의 평균 응답 시간 + 여유치로 설정해야 합니다.
너무 길면 우리 서버의 스레드 점유 시간이 길어지고, 너무 짧으면 정상 처리가 되었음에도 실패로 오판하게 됩니다.
가장 위험한 상황은 "외부 서버는 처리에 성공했지만, 우리 서버는 타임아웃으로 실패 처리한 경우"입니다.
문제점: 결제, 쿠폰 발급 등에서 중복 요청이 발생하여 금전적 손실이나 데이터 오염이 발생합니다.
해결책:
요청 헤더에 Idempotency-Key UUID 등을 포함하여 보냅니다.
외부 서버가 이 키를 지원한다면, 재시도 시에도 동일한 결과를 보장받을 수 있어 안전하게 재호출이 가능합니다.
외부 서버의 장애가 우리 서비스의 전체 장애로 번지는 'Cascading Failure'를 막아야 합니다.
| 패턴 | 설명 | 적용 사례 |
|---|---|---|
| Circuit Breaker | 일정 비율 이상의 에러 발생 시 호출을 즉시 차단하고 Fallback 응답을 반환합니다. | 추천 API, 광고 API 등 실패해도 메인 서비스에 지장이 없는 경우 |
| Bulkhead | 외부 API 전용 스레드 풀을 분리하여, 해당 API가 느려져도 메인 스레드 풀은 영향받지 않게 합니다. | 특정 파트너사 API 호출이 잦은 경우 |
| Retry with Backoff | 실패 시 즉시 재시도하지 않고, 점진적으로 대기 시간을 늘리며 재시도합니다. | 일시적인 네트워크 순단이 예상될 때 |
"DB 커넥션은 가장 귀한 자원입니다." 외부 API 호출을 트랜잭션 안에 넣는 것은 금물입니다.
외부 API가 10초간 응답이 없으면, DB 커넥션도 10초간 붙잡혀 있게 됩니다.
이는 곧 DB 커넥션 풀 고갈로 이어집니다.
@Transactional
fun badPractice() {
repository.save(data) // 커넥션 점유 시작
externalApi.call() // 여기서 10초 대기 시 커넥션도 10초간 묶임
}
작성해주신 코드처럼 비즈니스 로직과 외부 통신을 명확히 분리해야 합니다.
// 1. 오케스트레이션을 담당하는 Facade
class InvoiceFacade(
private val invoiceService: InvoiceService,
private val invoiceExternalClient: InvoiceExternalClient
) {
fun issueInvoice(request: InvoiceRequest) {
// DB 작업만 빠르게 처리하고 커넥션 반납
val invoice = invoiceService.createPendingInvoice(request)
try {
// 커넥션 없이 외부 API 호출
val result = invoiceExternalClient.request(invoice)
invoiceService.success(invoice.id, result)
} catch (e: Exception) {
invoiceService.fail(invoice.id, e.message)
}
}
}
실제 운영 환경에서는 "왜 실패했는가"를 아는 것이 핵심입니다.
8년 차 개발자의 시각에서, 서비스의 안정성을 한 단계 높여줄 Resilience4j 실전 구현과 메시지 큐(Kafka/RabbitMQ)를 이용한 비동기 재처리 설계를 정리해 드립니다.
단순히 라이브러리를 적용하는 것보다, 어떤 상황에서 서킷을 열고 닫을지 설정하는 것이 실력의 차이를 만듭니다.
외부 API의 특성에 따라 수치를 조절해야 합니다.
예를 들어 결제 API라면 좀 더 엄격하게, 추천 API라면 좀 더 너그럽게 설정합니다.
resilience4j:
circuitbreaker:
instances:
externalApiConfig:
slidingWindowSize: 10 # 최근 10개의 요청을 기준으로 판단
failureRateThreshold: 50 # 실패율 50% 이상 시 서킷 오픈
waitDurationInOpenState: 10s # 오픈 후 10초 뒤 다시 시도(Half-open)
permittedNumberOfCallsInHalfOpenState: 3 # Half-open 상태에서 3번 시도 후 결정
slowCallDurationThreshold: 2s # 2초 이상 걸리면 느린 호출로 간주
slowCallRateThreshold: 100 # 느린 호출이 100%면 서킷 오픈
@CircuitBreaker 어노테이션을 통해 비즈니스 로직과 장애 복구 로직을 분리합니다.
@Service
class ExternalApiService(
private val restTemplate: RestTemplate
) {
@CircuitBreaker(name = "externalApiConfig", fallbackMethod = "fallbackCall")
fun callExternalSystem(data: RequestDto): ResponseDto {
return restTemplate.postForObject("/api/external", data, ResponseDto::class.java)
?: throw RuntimeException("Empty Response")
}
// 서킷이 오픈되었거나 예외 발생 시 실행될 대체 로직
fun fallbackCall(data: RequestDto, e: Throwable): ResponseDto {
log.error("Circuit Breaker Open! 사유: ${e.message}")
// 1. 캐시된 데이터를 반환하거나
// 2. 기본값(Default) 응답
// 3. 혹은 사용자에게 "잠시 후 시도해달라"는 커스텀 에러 반환
return ResponseDto.empty()
}
}
실시간 응답이 필수적이지 않은 작업은 비동기 큐를 사용하는 것이 가장 견고한 설계입니다.
@Component
class ExternalApiConsumer(
private val externalService: ExternalService
) {
@RetryableTopic(
attempts = "3", // 최대 3번 시도
backoff = Backoff(delay = 2000, multiplier = 2.0), // 2초, 4초... 점진적 대기
topicSuffixingStrategy = TopicSuffixingStrategy.SUFFIX_WITH_INDEX_VALUE
)
@KafkaListener(topics = ["external-request-topic"])
fun consume(message: String) {
log.info("외부 API 호출 시도: $message")
externalService.call(message) // 여기서 예외 발생 시 자동으로 Retry 토픽으로 이동
}
@DltHandler
fun handleDlt(message: String) {
log.error("최종 실패! DLQ 적재 및 관리자 알림: $message")
// DB 상태를 'FINAL_FAIL'로 변경하거나 슬랙 알림 발송
}
}
8년 차 개발자로서 제안하는 선택 기준입니다.
| 상황 | 추천 방식 | 이유 |
|---|---|---|
| 사용자가 결과를 즉시 봐야 함 (ex: 결제 승인) | Circuit Breaker + Timeout | 빠른 응답(Fast-Fail)이 사용자 경험에 유리합니다. |
| 결과를 나중에 알아도 됨 (ex: 포인트 적립) | Kafka / Message Queue | 시스템 부하를 분산하고 100% 처리를 보장할 수 있습니다. |
| 외부 서버가 불안정함 | Retry + Exponential Backoff | 일시적인 오류일 경우 잠시 쉬었다가 다시 시도하는 것이 성공률이 높습니다. |
이번 마이그레이션 작업을 통해 다시 한번 느낀 점은 "완벽한 기술보다 상황에 맞는 최선의 선택이 중요하다"는 것입니다.
최신 기술인 Redis나 전용 분산 락 솔루션이 정답일 수 있겠지만, 현재 우리 서비스의 인프라 제약 안에서 엔진의 특성을 깊이 파고들어 문제를 해결하는 과정이 진정한 엔지니어링임을 경험했습니다.
특히 락의 점유가 단순히 데이터 정합성을 지키는 것을 넘어, DB 커넥션 풀과 스레드 모델에 미치는 영향까지 고려해야 한다는 점을 상기할 수 있었습니다.
비동기 락 시도와 커넥션 풀 분리 같은 방어적인 설계가 서비스의 가용성을 결정짓는 핵심임을 깨닫는 소중한 시간이었습니다.