2024. 9. 5. 21:19ㆍBack-End/Netty
며칠간 골머리를 앓게 한 이슈를 해결했다.
최근 오픈한 공공테이터포탈 서비스(Spring Boot + Netty소켓통신)로
개발서버에서 아무 이상이 없어 운영에 반영을 하였으나, 응답이 10초 정도 늦는 이슈가 발생했다.
당연히 우리쪽 문제는 아닐 거라 생각하고 데이터포탈에서 응답을 늦게 주는 것이라 단언했다.
그래도 혹시나 하는 맘에 방화벽단 로그를 살펴보니 우리 쪽이 클라이언트 일 때 포탈에 요청을 늦게 보내고 있었다..
현상은 정말이지 특이했다.
'운영서버에서만' 약 5분 이내에 요청한 이력이 없다가 새로운 요청이 들어오면 10초 정도 늦은 응답을 받게 된다.
그렇다면 5분 이내에 요청한 이력이 있다면?
-> 이상 없이 바로 응답이 들어옴.
처음에 의심한 건 네티 스레드풀이었으나 개발서버에선 이상이 없으니 섣불리 예단하기도 어려웠다.
실제로 연결되는 시점을 확인하려고 doOnConnected 시점에 로그를 찍어보니 이미 10초 후였다.
Root 레벨에서 Debug모드로 로깅을 하고 눈이 빠지게 살펴보니 이상한 점이 발견 됐다.
우선 네티(Netty)에서 `HttpClient.create()`를 사용해 `POST` 요청을 보내는 과정을 단계별로 이해해야 한다.
- GPT에게 물어봄
1. HttpClient.create() 호출
- `HttpClient.create()`는 네티의 `HttpClient`를 생성하는 과정입니다. 이 시점에서는 기본 설정이 완료되며, 이후 필요한 설정(호스트, 포트, SSL, 타임아웃 등)을 추가합니다.
- 예를 들어, `baseUrl`, `port`, `sslContext`, `timeout` 등을 설정할 수 있습니다.
2. SSL 설정 확인 (SSL/TLS)
- 만약 HTTPS로 요청을 보내는 경우, SSL이 사용됩니다.
- SSL 설정: `HttpClient`는 `SslProvider` 또는 `SslContext`를 통해 SSL 연결을 설정합니다. 이 과정에서 SSL/TLS 핸드셰이크가 이루어지며, 서버의 인증서가 확인됩니다.
- SSL이 필요하지 않다면, 이 단계는 건너뛰고 바로 TCP 연결로 넘어갑니다.
3. DNS 해석 (DnsNameResolver)
- DNS 해석: HTTP 요청을 보내기 전에 도메인 이름(예: `example.com`)을 IP 주소로 해석해야 합니다. 이 작업은 `DnsNameResolver`를 통해 이루어집니다.
- 네티는 `DnsQueryContext`를 사용해 DNS 서버에 쿼리를 보냅니다. 이 과정에서 도메인 이름을 변환하는 작업이 이루어지며, IP 주소를 받으면 연결을 설정할 준비를 합니다.
> 이때 DNS 쿼리는 `WRITE` 작업을 통해 네트워크 채널에 전송되며, `DnsQueryContext`는 이 작업을 관리합니다.
4. **TCP 소켓 연결 설정 (Bootstrap & Channel)**
- **Bootstrap 설정**: 네티는 `Bootstrap` 클래스를 사용해 비동기 소켓 연결을 설정합니다. 이 과정에서 사용자는 `NioEventLoopGroup`, `ChannelInitializer`, `ChannelPipeline` 등의 구성을 정의할 수 있습니다.
- 네티는 소켓 채널을 초기화하고, TCP 연결을 설정합니다. 이 단계에서 `doOnConnected`와 같은 콜백 함수가 호출될 수 있습니다.
5. **HTTP 요청 준비 (HttpRequestEncoder)
- **요청 준비**: DNS 해석이 끝나고, TCP 연결이 설정되면, 네티는 HTTP 요청을 보낼 준비를 합니다.
- **POST 요청 구성**: HTTP 요청을 구성하기 위해 `HttpRequestEncoder`가 사용됩니다. 이 클래스는 HTTP 요청을 네트워크를 통해 전송할 수 있는 형식으로 인코딩합니다.
- 예: 요청 메서드(`POST`), 헤더, 바디(Body) 등이 포함됩니다.
6. **POST 요청 전송 (Channel.writeAndFlush)
- **요청 전송**: POST 요청은 `ChannelPipeline`을 통해 전송되며, 네티의 `writeAndFlush` 메서드가 호출됩니다. 이 과정에서 데이터가 네트워크로 전송됩니다.
- HTTP 요청이 전송된 후, 서버의 응답을 기다립니다.
7. **응답 처리 (HttpResponseDecoder)**
- **응답 수신**: 서버에서 응답이 도착하면, 네티는 이를 처리하기 위해 `HttpResponseDecoder`를 사용해 HTTP 응답을 디코딩합니다.
- **응답 파이프라인 처리**: 응답은 `ChannelPipeline`을 통해 전달되며, 미리 등록된 핸들러들이 응답을 처리합니다.
- 예를 들어, `doOnResponse`와 같은 콜백 함수가 이 시점에 호출될 수 있습니다.
8. **TCP 연결 종료 및 자원 해제**
- **연결 종료**: 응답 처리가 끝난 후, TCP 연결을 유지하지 않으면 소켓 연결을 종료합니다.
- 네티의 채널이 닫히고, 사용된 자원은 해제됩니다.
요약
1. **`HttpClient.create()`**: HTTP 클라이언트 생성.
2. **SSL 설정 확인**: HTTPS 요청인 경우 SSL 설정 및 핸드셰이크 수행.
3. **DNS 해석**: `DnsNameResolver`를 통해 도메인 이름을 IP 주소로 변환.
4. **TCP 연결 설정**: 소켓 채널을 초기화하고 서버와 TCP 연결 설정.
5. **HTTP POST 요청 준비**: POST 요청을 구성하고 인코딩.
6. **POST 요청 전송**: `writeAndFlush`로 요청을 전송.
7. **응답 처리**: 서버의 응답을 디코딩하고 처리.
8. **연결 종료**: 응답 처리 후 연결을 종료하고 자원 해제.
이 과정에서 네티는 비동기로 작동하며, 각 단계가 완료될 때까지 이벤트 루프에서 대기하지 않고, 콜백이나 Future를 통해 결과를 처리합니다.
자, 이제 루트 레벨에서 발견한 특이점에 대해 살펴보자.
case1) 5분 이내 요청이 없었던 경우
- 새로운 Pooled Channel 생성
- DNS서버에 요청 URL 전달-1
- 5초 지연 발생
- DNS서버에 요청 URL 전달-2
- IP응답은 받으나 5초 지연 발생
- DNS서버에 요청 URL 전달-3
- IP 응답받고 소켓연결 정상 시도 (10초 지연)
case2) 5분 이내 요청이 있었던 경우
- 기존 풀 사용
- DNS 생략하고 소켓연결 시도
이 두 케이스를 비교하면서 디버깅을 했다. 그렇게 내린 결론은
- DNS서버와 네트워크 연결 이슈
- 커넥트 풀 병목현상
1번의 경우 DNS서버를 거치지 않도록 hosts파일에 IP를 박아두거나, DNS캐싱으로 유도하는 방법이었다.
참고로 Netty의 DnsNameResolver는 별도의 커스터마이징 없이 디폴트로 사용하면 DNS cashe를 이용하지 않고 DNS서버로 연결한다고 한다. 단, 동일한 유휴채널을 사용하게 되면 예외 같다.
2번은 ConnectionProvider를 커스터마이징 해서 create() 함수의 파라미터로 넣어주는 것이다.
계획대로라면 유휴시간을 길게 늘여 드문 드문 요청이 오더라도 같은 채널을 이용하게 하려 했다.
가장 간단한 방법인 hosts파일을 수정하니 문제가 해결되었다. DNS서버 지연 이슈는 인프라팀과 얘기해 차차 해결해야 한다.