CLOVA Nursing AI Agent

무중단 배포 시 끊기는 소켓 대응 — 이중 재연결

블루/그린 무중단 배포로 서버 POD가 교체되는 순간 소켓이 끊겼다. Socket.io 내장 재연결과 수동 재연결 — 둘이 어떻게 경쟁하는지를 모르면 손댈 수 없는 문제였다.

작업 배경

지난 PR에서는 reconnection_request를 받으면 1초마다 setInterval로 계속 재연결을 요청했다. 리뷰에서 "interval 대신, connect 후 응답을 기다렸다가 실패하면 재시도하는 방식"을 제안받아 개선에 착수했다.

지난 PR 리뷰 코멘트
지난 PR 리뷰 — setInterval 대신 connect 후 응답을 기다리는 방식 제안.

connect_error마다 무조건 재연결할지, reconnection_request 이벤트일 때만 플래그로 처리할지 팀과 논의하며 방향을 잡았다.

재연결 방식 팀 논의
connect_error 기준 vs reconnection_request 플래그 — 접근을 팀과 논의.

두 가지 재연결 메커니즘

연결이 끊기면 두 메커니즘이 동시에 작동했다.

  1. Socket.io 내장 자동 재연결 — 별도 코드 없이 기본 작동. 빠르지만 완벽히 안정적이진 않다.
  2. 수동 재연결(startReconnectionAttempt)reconnection_request 수신 시 1초마다 상태를 확인하며 connect() 시도.
startReconnectionAttempt: () => {
  const { reconnectionInterval } = get();
  if (reconnectionInterval) return;
  const interval = setInterval(() => {
    const { socket, isConnected, connect, stopReconnectionAttempt } = get();
    if (socket && isConnected) {
      stopReconnectionAttempt();
      return;
    }
    connect();
  }, 1000);
  set({ reconnectionInterval: interval });
};

불규칙한 로그의 정체

테스트할 때 로그가 들쭉날쭉했다. 때로는 "소켓연결됨"만, 때로는 "socket null"이 먼저 찍혔다.

  • 내장 재연결이 인터벌 체크 전에 성공 → "소켓연결됨"만 출력
  • 내장 재연결이 느리거나 실패 → 인터벌에서 "socket null" 출력 후 수동 connect()

버그가 아니라 두 메커니즘이 경쟁하는 증거였다.

내장 재연결만으로는 부족하다

그렇다고 수동 재연결을 빼면 안 됐다. 내장 재연결만 두면 어느 시점부터 재연결이 멈춰 소켓이 영구히 끊기는 현상이 재현됐다.

startReconnectionAttempt 없이 내장 재연결만 두면, 어느 시점부터 영구히 끊긴다.

그래서 "빠르지만 불안정한 내장 재연결 + 확실한 수동 재연결" 두 메커니즘을 모두 유지하기로 했다. 한쪽이 실패해도 다른 쪽이 받쳐주는 벨트와 서스펜더 전략이다. (환자 데이터를 다루는 의료 시스템이라 연결 안정성이 특히 중요했다.)

타이밍 이슈 → isReconnecting 플래그

테스트 중 영구 끊김의 핵심 원인을 찾았다.

서버에서 재연결 요청 받음        // reconnection_request 수신
소켓이 이미 연결됨, 재연결 불필요  // if (socket && isConnected) 로 중단
소켓 연결 끊김                  // 직후 disconnect

reconnection_request 수신 시점엔 아직 소켓이 안 끊겨 있어 재연결을 건너뛰는데, 바로 다음에 disconnect가 와서 재연결 시도 없이 연결을 잃었다.

해결은 isReconnecting 플래그였다. 재연결 의도를 상태로 들고 있다가, disconnect가 늦게 와도 플래그를 보고 재연결한다.

socket.on("disconnect", () => {
  const { isReconnecting, startReconnectionAttempt } = get();
  if (isReconnecting) {
    startReconnectionAttempt(); // 재연결 의도가 있으면 끊겨도 재시도
  }
});

핵심: 로그아웃 vs 무중단 배포 (컴포넌트 언마운트)

"로그아웃하면 왜 자동 재연결이 안 되지?"의 답은 컴포넌트 언마운트였다.

const useConnectSocket = () => {
  const { connect, disconnect } = useSocketStore();
  useEffect(() => {
    connect();
    return () => disconnect(); // 언마운트 시 명시적 종료
  }, [connect, disconnect]);
};
  • 로그아웃/페이지 이동 → 컴포넌트 언마운트 → cleanup disconnect() 호출 → 명시적 종료 (isReconnecting: false). Socket.io는 명시적 종료를 재연결 안 함으로 처리 → 의도대로 끊김.
  • 무중단 배포 → 컴포넌트는 마운트 유지 → cleanup 실행 안 됨. 서버가 연결을 종료하면 Socket.io는 비정상 종료로 보고 자동 재연결 → 거기에 isReconnecting 수동 재연결까지 받쳐준다.

reconnection_request는 "곧 끊길 것"이라는 예고일 뿐, 실제 종료는 서버 측에서 일어나기 때문에 비정상 종료로 간주돼 재연결이 트리거된다.

connect_error 수동 재연결이 내장 재연결을 방해했다

재연결이 여전히 4~6초씩 걸렸다. 원인은 connect_error마다 connect()를 호출해 매번 새 소켓을 생성한 것이었다. 새 인스턴스가 생기니 내장 재연결의 지수 백오프가 초기화돼 제대로 못 돌았다.

connect_error의 수동 재연결을 제거하고, 내장 재연결 옵션만 튜닝했다.

const socket = io(url, {
  transports: ["websocket"],
  auth: { token: `Bearer ${getCookie("access_token")}` },
  reconnection: true,
  reconnectionAttempts: Infinity,
  reconnectionDelay: 1000,
  reconnectionDelayMax: 2000,
  timeout: 3000,
});

결과

  • 무중단 배포·네트워크 불안정에서도 소켓 연결 안정성 확보
  • connect_error 수동 재연결 제거로 내장 재연결의 지수 백오프 정상화
  • 수동 재연결은 reconnection_request(서버 주도)에만 남겨, 내장 재연결과 충돌 없이 보완

수동 방식을 모든 오류에 쓰던 것에서 서버가 명시적으로 요청할 때만 쓰도록 제한한 게 핵심이었다.