운영하다 만난 세 가지 의문
- RabbitMQ 컨슈머가 죽으면 메시지가 즉시 재큐잉됩니다. 브로커는 어떻게 즉시 알았을까요?
- 좀비가 된 컨슈머는 30분(ack timeout)이나 기다립니다. 왜 얘는 즉시 알 수 없었을까요?
- 업로드가 중간에 끊기면 폴백이 발동합니다. "끊김"은 누가, 언제 판정한 걸까요?
셋 다 답이 한 가지 사실에서 나옵니다.
전제: TCP에서 침묵은 정상이다
TCP 연결은 케이블 어딘가에 실체로 존재하는 게 아닙니다. 양쪽 컴퓨터의 커널이 각자 메모리에 적어둔 상태(어디까지 보냈나, 어디까지 받았나, 버퍼)일 뿐입니다. 그리고 주고받을 데이터가 없으면 아무 패킷도 흐르지 않습니다. 조용한 연결은 0바이트이고, 그게 TCP의 정상 상태입니다.
여기서 문제가 생깁니다. 상대가 조용할 때, 그 침묵이 "보낼 게 없어서"인지 "죽어서"인지 패킷 수준에서는 완전히 똑같이 보입니다. 즉 상대의 죽음은 저절로 알게 되는 게 아니라, 누군가 종료 통지(FIN/RST)를 보내줘야만 즉시 알 수 있습니다. 통지가 없는 죽음은, 뭔가 보내보고 응답이 없다는 걸 겪은 뒤에야 압니다.
죽음이 알려지는 세 가지 방식
| 시나리오 | 무슨 일이 벌어지나 | 상대가 아는 시점 |
| 앱이 close() 호출 | FIN을 주고받으며 양쪽 합의로 종료 | 즉시 |
| 프로세스만 급사 (OOM kill, kill -9) | 커널이 남은 소켓을 정리하며 통지(FIN/RST)를 대신 보냄 | 즉시 |
| 머신·네트워크 급사 (전원, 케이블, 커널 패닉) | 아무 패킷도 안 나감 | 보내던 중이면 재전송이 계속 실패한 끝에(수 분 뒤), 조용한 중이면 영영 모름 |
세 번째 줄이 도입부 3번 의문의 답입니다. 업로드 끊김을 판정한 건 보내는 쪽 TCP입니다. 보낼 게 있는데 응답 없는 재전송이 반복되다 한도가 차면 "끊겼다"고 판정합니다. 반대로 아무것도 안 보내는 중이었다면 판정할 재료 자체가 없습니다. 그래서 RabbitMQ 같은 시스템은 하트비트를 씁니다. 주기적으로 신호를 주고받게 해서, 응답이 끊기면 죽은 것으로 판정하는 겁니다.
죽지 않은 네 번째 경우: 좀비
그런데 표의 세 줄 어디에도 안 들어가는 경우가 있습니다. 프로세스가 살아있고, 연결도 진짜로 멀쩡한데, 일만 안 하는 경우입니다. 무한루프, 데드락, 응답 없는 외부 호출에 걸려 멈춘 스레드가 그렇습니다.
머신이 죽은 경우와 겉보기는 같지만, 결정적 차이가 있습니다. 머신 급사는 하트비트를 보내면 응답이 없어서 알수있지만, 좀비는 하트비트 응답을 통신 담당(IO 스레드)이 대신 잘 해주기 때문에 연결 감시로는 구분할 수 없습니다.
이 컨슈머는 죽은 게 아니니 FIN이 나갈 리 없고, 하트비트에도 정상적으로 응답합니다. 연결을 감시하는 어떤 수단으로도 잡히지 않습니다. 연결이 아니라 작업이 멈춘 거라서, "받아간 메시지를 30분 안에 처리 완료(ack)하지 않으면 회수한다"는 작업 단위의 시간제한(ack timeout)으로만 잡을 수 있습니다.
도입부 2번 의문의 답이 이겁니다. 좀비가 30분을 기다리는 건 감지가 느려서가 아니라, ack timeout 말고는 이 경우를 잡을 수단이 없어서입니다. 정리하면 감지 수단은 세 가지입니다.
| 수단 | 잡는것 | 시점 |
| 종료 통지 (FIN/RST) | 프로세스의 죽음 | 즉시 |
| 하트비트 | 머신·네트워크의 죽음 | 분 단위 |
| ack timeout | 살아있는데 일 안 하는 좀비 | 30분 |
프로세스가 죽었는데 FIN은 누가 보냈나
kill -9는 프로세스에게 정리할 기회를 주지 않는 신호입니다. close()를 호출할 틈도 없이 죽습니다. 그런데도 상대는 즉시 압니다. 누가 FIN을 보낸 걸까요?
처음에는 이게 이상했습니다. 프로세스가 죽으면 그 자원은 전부 회수되고, 소켓 정리도 프로세스 몫이라고 생각했기 때문입니다. 알고 보니 소유 방향을 반대로 알고 있었습니다. 소켓은 프로세스의 메모리에 없습니다. 커널 메모리에 있습니다. 프로세스가 가진 건 파일 디스크립터라는 참조값(정수 하나)뿐입니다. 그리고 프로세스를 만들고, 메모리를 나눠주고, 죽이는 것까지 전부 커널이 하는 일이라, 커널이 프로세스의 죽음을 모를 수가 없습니다. 그래서 프로세스가 죽을 때 실제로 벌어지는 일은 이렇습니다.
- 커널이 프로세스의 메모리를 회수합니다.
- 커널 자신의 메모리에는 그 프로세스가 들고 있던 소켓 참조 목록이 남아 있습니다.
- 커널은 그 참조마다 소켓을 닫으며 상대에게 FIN/RST를 보냅니다.
앱이 close()를 부른 경우와 kill -9로 죽은 경우의 차이는 "누가 시켰나"뿐이고, 실제로 FIN을 보내는 건 언제나 커널입니다. 직접 확인해봤습니다.
// server.java
ServerSocket serverSocket = new ServerSocket();
serverSocket.bind(new InetSocketAddress("localhost", 9999));
Socket socket = serverSocket.accept();
System.out.println("대기 시작!!!");
int data = socket.getInputStream().read();
System.out.println("data=" + data + " 도착!");
// client.java
Socket clientSocket = new Socket();
clientSocket.connect(new InetSocketAddress("localhost", 9999));
System.out.println(ProcessHandle.current().pid());
Thread.sleep(1000000);
read()로 대기 중인 서버를 두고, 붙어있던 클라이언트 프로세스를 kill -9로 죽인 순간의 패킷입니다.
19:52:40.904725 IP 127.0.0.1.51278 > 127.0.0.1.9999:
Flags [F.], seq 1, ack 1, length 0
19:52:40.904800 IP 127.0.0.1.9999 > 127.0.0.1.51278:
Flags [.], ack 2, length 0
19:52:40.912424 IP 127.0.0.1.9999 > 127.0.0.1.51278:
Flags [F.], seq 1, ack 2, length 0
앱은 아무 코드도 실행하지 못하고 죽었는데 FIN이 나갑니다. 소켓의 주인이 커널이라는 증거입니다.
왜 이런 구조일까요. 제 나름대로 이유를 꼽아보면:
- 네트워크 카드는 한 장인데 통신하려는 프로세스는 수백 개입니다. 도착한 패킷이 누구 것인지 배분하려면 단일 관리자가 필요합니다.
- 프로세스가 하드웨어를 직접 만질 수 있으면 남의 포트인 척 패킷을 위조할 수 있습니다. 커널이 유일한 접점이면 주인을 확인하고 막을 수 있습니다.
- 재전송이나 ACK 응답은 앱이 GC로 잠깐 멈춰 있는 동안에도 지켜져야 하고, 앱이 급사했을 때 종료를 통지할 누군가가 남아 있어야 합니다. TCP는 앱보다 오래 살아있는 쪽이 맡아야 하는 일입니다.
사고실험: 네트워크 카드를 프로세스마다 주면 안 될까
"카드가 한 장이라 관리자가 필요하다"는 말이 맞는지 뒤집어서 확인해봤습니다. 프로세스마다 카드를 1:1로 준다면?
- IP는 카드마다 붙기 때문에 프로세스마다 IP가 따로 생깁니다. 외부 입장에선 "서버 하나의 8080"이 아니라 프로세스별 IP를 알아야 접속할 수 있게 됩니다. 상대가 내 서버의 내부 배치까지 알아야 하는 구조가 됩니다.
- 프로세스가 죽었을 때 FIN을 보내줄 주체 문제도 그대로 남습니다. 카드는 받은 바이트를 쏘기만 할 뿐 연결 상태를 모르기 때문에 못 합니다.
어떤 방식으로 바꿔봐도 장부(포트→프로세스 대응)와 뒷정리(종료 통지)를 맡을 관리자가 필요했습니다. 커널을 빼면 그 일이 사라지는 게 아니라 전부 앱이 직접 떠안게 됩니다.
정리
- 소켓이 프로세스 소유였다면, 프로세스가 죽는 순간 소켓도 같이 사라지고 FIN을 보낼 주체도 없어집니다. 즉시 알 수 있던 죽음이 전부 한참 뒤에야 알게 되는 죽음으로 바뀝니다. 컨슈머가 죽었을 때 메시지가 즉시 재큐잉되는 건 이 설계가 주는 이점입니다.
- 하지만 커널이 잡아주는 건 딱 거기까지입니다. 머신째 죽으면 통지할 커널이 없고, 좀비는 애초에 죽지도 않았습니다. 그래서 시스템들은 하트비트로 침묵을 정보로 바꾸고, ack timeout으로 작업을 감시합니다.
- 한 줄로: 통지받으면 즉시, 하트비트로 알면 분 단위, 일이 안 끝나는 건 30분. 침묵은 정보가 아니라서, 시스템은 메시지를 만들어내고 타임아웃으로 포기 시점을 정합니다.
'네트워크' 카테고리의 다른 글
| HTTP/1.1 에선 멀티플렉싱을 처리하지 못하는걸까? (0) | 2025.03.15 |
|---|