장애 상황
어느 날, 외부기관 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 증가 대비 사전 증설 고려 |
마무리하며
이번 경험을 통해 가장 크게 느낀 건,"시스템이 외부에 의존할수록, 내부는 더 빠르게 문제를 끊어내야 한다"는 점이었다.
조금의 방심이 서비스 전체에 영향을 줄 수 있다.
그래서 앞으로도 항상
- 고가용성
- 장애 전파 차단
- 리소스 여유 확보
이 세 가지를 기본으로 생각하며 시스템을 설계해야겠다.