문제 현상 이면에 숨겨진 원인 찾기: TCP 통신 트러블슈팅

주싱·2023년 2월 28일
1

Trouble Shooting

목록 보기
20/21

개발한 시스템에서 문제가 발생하면 우리가 가장 먼저 만나는 것은 문제의 표면적인 현상입니다. 우리는 문제를 해결해야 하는 엔지니어로서 문제의 현상 이면에 숨겨진 진짜 원인을 찾아야 합니다. 그래야 문제를 해결할 수 있습니다. 어느날 동료에게 전화 한 통을 받았습니다. 외부 TCP 서버와 통신을 하는 서비스 컴포넌트에서 이상한 오류가 나는데 원인을 찾기 힘들다고 했습니다. 제가 개발한 TCP 공통 모듈과 연관은 혹시 없는지 확인도 해줄 겸 도움을 요청해 왔습니다. 우선 문제 아래에서 일어나는 일을 보기 위해 네트워크 패킷을 분석을 보았는데 문제 현상과는 완전히 다른 차원의 문제가 그 안에 숨겨져 있었습니다.

문제의 표면적인 현상

TCP 클라이언트 역할을 하는 통신 컴포넌트에서 ‘IllegalStateException’ 오류가 발생합니다. 오류 내용은 Promise 객체가 이미 완료되었는데 다시 완료 설정을 하고 있다고 합니다. 오류가 나는 코드는 서버로 명령을 전송하기 위해 TCP 연결을 수립하고, 명령을 전송한 후 응답을 수신하면 연결을 닫시 끊고 있습니다. 명령에 대한 응답을 대기하기 위해 문제가 된 Promise 객체를 사용하고 있습니다. 하나의 명령 처리를 위해 매번 TCP 연결을 새롭게 수립하고, Promise 객체도 매번 새롭게 생성합니다. 문제 현상과 코드의 실행 흐름만 봐서는 원인을 가늠하기 힘듭니다.

문제 아래에서 일어나는 일

문제 아래에서 일어나는 일을 보기 위해 Wireshark로 네트워크 패킷을 캡처해 보았습니다. 캡처된 패킷들을 분석하다 보니 두 개의 명령이 지연없이 연속으로 전송되는 경우 문제가 발생하며 다음과 같은 특이한 현상들이 내부적으로 일어나는 것을 확인할 수 있었습니다.

  1. 첫 번째 명령에 대한 응답이 완료되어 클라이언트에서 연결을 종료하지만, 서버에서는 연결을 종료하지 않는 Half Close 상태가 됩니다. (참고→ 4-way handshake for tcp termination)
  2. 클라이언트는 Half Close 상태에서 두 번째 명령 처리를 위해 새로운 연결을 시도합니다.
  1. 서버는 클라이언트가 새로운 연결을 맺자 마자 비정상 연결 시도임을 알리는 20바이트 메시지를 클라이언트로 전송하고, 새로운 연결을 즉시 끊어버립니다.

원인 분석하기

위에서 분석한 네트워크 패킷들을 근거로 서버는 Half Close 상태에 새로운 연결을 받아주지 않는다는 결론을 내릴 수 있습니다. 그리고 왜 그런지는 서버의 제품 스펙을 통해 이해할 수 있었습니다. 서버는 명령을 수신 받는 제어 포트로 오직 1명의 클라이언트만 접속할 수 있도록 허용하고 있었습니다. 그리고 Half Close 상태는 완전히 연결이 끊어진 상태가 아님으로 이미 1명이 접속해 있는 것으로 간주한 것이었습니다. 이로 인해 연속적으로 전송하는 명령에서 두 번째 명령은 항상 실패하게 되었습니다. 이것이 문제의 핵심적인 원인입니다. 처음에 이미 완료된 promise 객체를 중복 설정하는 오류는 이 문제로 부터 파생된 부수적인 문제라고 할 수 있습니다. 이미 완료된 promise 객체가 중복 설정된 이유는 두 번째 명령을 위한 연결 후 명령을 전송하기도 전에 오류 응답이 수신되었고(마치 명령에 대한 응답 처럼) 이전 명령 처리에 사용된 promise 객체를 null 처리하지 않고 가지고 있었기 때문에 이미 처리 완료된 promise 객체를 다시 설정하고 있었던 것이었습니다.

핵심 문제 해결

우선 문제의 핵심을 해결하기 위해서 Half Close 상태가 되는 걸 피해야 했습니다. 서버 코드는 수정할 수 없는 제약이 있어서 클라이언트에서 방법을 찾아야 했습니다. 이상적인 방법으로는 클라이언트에서 연결을 끊은 뒤에, 서버에서 역시 연결을 끊는 이벤트가 발생할 때 까지 대기하면 깔끔한 해결이 가능합니다. 그러나 그런 방법이 있는지 찾을 수 없었습니다. 그래서 일단 간단한 방법으로 클라이언트에서 연결을 끊은 뒤에 서버의 연결 끊기 동작이 완료되도록 일정한 시간 대기하도록 해 봤습니다. 우아한 방법은 아니지만 실시간 성능을 요하는 명령이 아니라 용인할 만합니다. 그리고 100 밀리초 안에 서버가 연결을 끊지 않는다면 그땐 진짜 시스템 오류로 봐도 무방할 것 같습니다.

controlClient.disconnect();

// 4-way tcp close handshake 완료를 일정 시간 기다립니다. 
// 그렇지 않으면 서버는 기존 연결이 있다고 인식하여, 새로운 연결을 Close 시킵니다.
Thread.sleep(100);

위와 같이 코드를 변경하고 테스트를 해보면 아래와 같이 연결 종료 동작이 서버와 클라이언트 상호간에 수행되는 것을 확인할 수 있었습니다.

부수적인 문제 해결

추가적으로 발견된 부수적인 취약점도 해결했습니다. 먼저 명령 처리가 완료되면 일회성으로 사용된 Promise 객체는 null 처리해 주고 null 인 경우 어떤 응답이 수신되어도 Promise 설정을 하지 않도록 했습니다. 조금 더 우아하게 개선한다면 명령에 대한 응답과 오류 메시지는 서로 다른 ID를 가지고 있기 때문에 Promise 객체를 명령 ID와 매칭시켜서 관련된 응답이 수신된 경우에만 설정해 줄 수도 있겠습니다.

마치며

정리하면 Promise 객체가 중복 설정되는 문제는 숨겨진 진짜 문제로부터 파생된 표면적인 것에 불과했습니다. 진짜 문제는 TCP 프로토콜의 Half Close 상태가 발생하는 복잡한 조건들 때문이었습니다. 개인적으로 문제의 현상에서 진짜 원인을 찾아가는 좋은 학습 예제가 되어 준 것 같습니다. 감사합니다.

profile
소프트웨어 엔지니어, 일상

0개의 댓글