장애 개선

Kafka 리스너에서 HTTP 호출 장애 대응기: 문제 분석과 개선까지

Summer_berry 2025. 4. 22. 10:20

장애 상황

어느 날, 외부기관 HTTP 호출이 지연되는 이슈가 발생했다.
이 작은 문제가 예상보다 시스템 전체에 크게 영향을 끼쳤다.

발생 원인

  • 외부 방화벽 전환 과정에서 몇 초 동안 네트워크 순단이 일어났고,
  • 프록시 서버가 외부기관과 연결할 수 없는 상태가 됐다.

증상

  • 프록시 서버 타임아웃은 1분으로 설정돼 있었다.
  • 그런데 우리 서버의 HTTP 요청 부분에는 별도의 타임아웃 설정이 빠져 있어서,
    • 요청이 최대 1분간 블로킹됐다.
  • 이 요청을 처리하던 Kafka 리스너 스레드도 같이 블로킹되면서,
    • Kafka 메시지의 offset commit이 지연되고,
    • 결과적으로 메시지가 적체(backlog) 되는 상황까지 이어졌다.

장애 분석 요약

  • 프록시를 통해 외부기관 호출 시 소켓이 정상 종료되지 않으면서 블로킹 발생
  • HTTP 클라이언트에 read timeout 설정이 누락돼 있었음
  • Kafka 리스너가 동기 호출 방식으로 HTTP 요청을 하다 보니, 응답이 오지 않으면 리스너 스레드까지 블록
  • 이로 인해 시스템 전체 퍼포먼스가 급격히 나빠졌다

1차 개선 방안: 비동기 HTTP 요청 + 논리적 타임아웃 적용

왜 이렇게 접근했을까?

  • 외부 요청은 프록시 서버를 경유하는데, 프록시는 우리가 제어할 수 없는 영역이다.
  • 그래서 우리 서버 쪽에서라도 HTTP 클라이언트 레벨에서 요청을 제어할 방법이 필요했다.
  • 기존 동기 방식에서는, 소켓이 살아있으면 아무리 타임아웃 설정을 해도 제대로 끊기지 않는 경우가 있어,
    • 아예 Apache HttpAsyncClient를 사용해서 비동기로 전환하기로 했다.
  • 비동기로 요청을 보내고,
    CompletableFuture.get(5, TimeUnit.SECONDS) 처럼 논리적인 타임아웃을 걸어,
    • 응답이 늦으면 강제로 끊으려는 시도였다.
  • 참고로, WebClient도 고려했지만,
    • 프록시 서버가 IP 기반 통신만 지원해서, HTTPS 프록시 요청이 아예 안 돼서 제외했다.

적용한 내용

  • Apache HttpAsyncClient로 비동기 HTTP 요청
  • 5초 논리적 타임아웃 적용
  • 커넥션 회수 처리 (future.cancel(true) + IdleConnectionEvictor)

코드 예시

CloseableHttpAsyncClient client = HttpAsyncClients.createDefault();
client.start();

HttpGet request = new HttpGet("http://external-api.com");
Future<HttpResponse> future = client.execute(request, null);

try {
    HttpResponse response = future.get(5, TimeUnit.SECONDS); // 논리적 타임아웃
    // 응답 정상 처리
} catch (TimeoutException e) {
    future.cancel(true); // 타임아웃 시 요청 취소
} finally {
    client.close();
}

1차 개선 방안에 대한 고민

  • 논리적 타임아웃을 걸어도, 소켓 연결 자체가 살아있으면 커넥션 풀을 점점 갉아먹을 수 있다.
  • future.cancel(true)도 내부 커넥션을 직접 끊을 수는 없고,
  • IdleConnectionEvictor는 유휴 커넥션만 정리할 뿐, 진행 중인 커넥션은 건들 수 없다.
  • 결국 TPS가 높은 상황에서는, 불량 커넥션이 쌓여서 커넥션 풀 고갈 위험이 있었다.
  • 그리고 read timeout(4초) 대기 때문에 Kafka 리스너 스레드는 여전히 block될 수 있어서 완벽한 해결은 아니었다.

2차 개선 방안: Kafka 수신과 HTTP 요청 완전 분리 + 서킷브레이커 도입

왜 이렇게 방향을 바꿨을까?

  • 비동기로 요청을 보내더라도, 완벽히 커넥션을 끊을 수 없는 경우가 분명히 존재한다.
  • 비슷한 장애가 반복되면, 불량 커넥션이 쌓여서 시스템 전체 장애로 이어질 수 있다.
  • 그래서 아예 발상을 바꿨다:
    • Kafka 리스너는 수신만 하고 바로 커밋한다.
    • 이후 HTTP 요청은 별도 스레드에서 비동기로 처리한다.
  • 또 하나, 외부 프록시나 서버 장애로 TPS가 급증할 경우를 대비해,
    • HTTP 요청 처리부에 서킷브레이커를 적용해 Fail Fast로 막기로 했다.

최종 개선 방법

  • Kafka 리스너는 메시지 수신 + 즉시 커밋만 담당
  • HTTP 요청은 Async Processor를 통해 별도 비동기로 실행
  • HTTP 호출에는 서킷브레이커 적용 (Resilience4j 사용)

우리 서버의 요청 흐름도

지금 구조를 흐름도로 간단히 표현하면 이렇게 된다:

Kafka Topic
   ↓
Kafka Listener
   - 메시지 수신
   - Offset Commit
   - Async Processor 호출
   ↓
Async Processor (ThreadPool)
   - 외부기관 HTTP 호출
   - 서킷 브레이커 적용
   - 타임아웃 및 예외처리
 

즉, Kafka 리스너는 수신-커밋-전달만 하고,
HTTP 요청 실패/성공과는 상관없이 빠르게 다음 메시지 수신을 계속할 수 있는 구조로 만들었다.


추가 코드 예시

Kafka 리스너

@KafkaListener(topics = "my-topic", groupId = "group")
public void listen(String message) {
    asyncProcessor.process(message); // 수신 즉시 처리 위임
}

Async Processor

@Async
public CompletableFuture<Void> process(String message) {
    return CompletableFuture.runAsync(() -> {
        try {
            callExternalApi(message);
        } catch (Exception e) {
            log.error("외부기관 호출 실패", e);
        }
    });
}

서킷브레이커 적용 (Resilience4j)

@CircuitBreaker(name = "externalService1", fallbackMethod = "fallback")
public String callExternalApi1(String message) {
    // HTTP 호출
}
@CircuitBreaker(name = "externalService2", fallbackMethod = "fallback")
public String callExternalApi2(String message) {
    // HTTP 호출
}

public String fallback(String message, Throwable t) {
    log.warn("서킷브레이커 작동 - fallback 처리", t);
    return "fallback";
}

개선하면서 꼭 고려한 것들

1. 서킷 브레이커 적용 범위

  • HTTP 메소드 전체에 서킷브레이커를 걸면, 하나의 에러로 전체 API가 중단될 수 있다.
  • 그래서 API 요청별로 서킷브레이커를 세분화해 적용하는 걸 목표로 잡았다.

2. Async 처리 시 리소스 관리

  • 비동기 처리가 늘어나면,
    • Heap Memory
    • DB Connection Pool
    • HTTP Connection Pool 같은 자원들이 급격히 소모될 수 있다.
  • Async 전환 이후 예상 TPS에 맞춰 리소스 추가 증설을 준비해야 했다.

3. 커넥션 관리 세팅 강화

RequestConfig requestConfig = RequestConfig.custom()
        .setConnectTimeout(3000)
        .setSocketTimeout(3000)
        .setConnectionRequestTimeout(1000)
        .build();
  • 연결 타임아웃, 소켓 타임아웃, 커넥션 풀 타임아웃을 세팅해 연결 문제를 최대한 빠르게 감지할 수 있도록 했다.

정리

구분발생 문제개선 방향
HTTP 요청 타임아웃 누락 비동기 요청 + 논리적 타임아웃
Kafka 리스너 HTTP 블로킹으로 메시지 적체 수신만 담당, Async 처리 분리
장애 전파 외부 장애로 전체 영향 서킷브레이커로 Fail Fast 대응
리소스 관리 없음 TPS 증가 대비 사전 증설 고려

마무리하며

이번 경험을 통해 가장 크게 느낀 건,"시스템이 외부에 의존할수록, 내부는 더 빠르게 문제를 끊어내야 한다"는 점이었다.

조금의 방심이 서비스 전체에 영향을 줄 수 있다.


그래서 앞으로도 항상

  • 고가용성
  • 장애 전파 차단
  • 리소스 여유 확보

이 세 가지를 기본으로 생각하며 시스템을 설계해야겠다.