CLOVA Nursing AI Agent

실시간 · 소켓

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

블루/그린 무중단 배포로 서버 POD가 교체되는 순간 소켓이 끊겼다. 손을 대보니 재연결이 하나가 아니었다 - Socket.io 내장 자동 재연결과 직접 만든 수동 재연결, 둘이 동시에 돌고 있었다. 이 둘이 어떻게 경쟁하는지를 모르면 로그조차 읽을 수 없었다. 로그를 상황별로 갈라 어느 메커니즘이 언제 이겼는지부터 규명하고, isReconnecting 플래그로 타이밍 구멍을 메운 기록이다.

작업 배경

지난 PR에서는 reconnection_request를 받으면 1초마다 setInterval로 계속 재연결을 요청했다. 무중단 배포에서만 오는 이벤트라, 이걸 흉내 내는 테스트 API를 버튼에 걸어 검증할 수 있게 해뒀다. 리뷰에서 "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"이 먼저 찍혔다. 처음엔 내 코드가 비결정적으로 도는 줄 알았는데, 두 갈래를 끝까지 따라가 보니 경쟁의 두 결과였다.

[시나리오 1] "소켓연결됨"만 찍히는 경우
서버가 reconnection_request 발송 후 소켓 끊음
  → startReconnectionAttempt() 실행 (인터벌 등록)
  → 인터벌 콜백이 돌기 전에 내장 재연결이 이미 성공
  → 콜백 시점엔 socket && isConnected == true
  → "소켓연결됨"만 출력하고 인터벌 종료
[시나리오 2] "socket null"이 찍히는 경우
서버가 reconnection_request 발송 후 소켓 끊음
  → startReconnectionAttempt() 실행 (인터벌 등록)
  → 인터벌 콜백이 돌 때 아직 재연결 안 됨
  → socket == null → "socket null" 출력
  → 그 후 connect() 호출해 수동 재연결

버그가 아니라 두 메커니즘이 경쟁하는 증거였다. 내장 재연결이 인터벌 한 틱(1초)보다 빠르면 시나리오 1, 느리거나 실패하면 시나리오 2 - 로그의 불규칙함은 "누가 먼저 도착했나"의 기록이었다.

소켓 재연결 테스트 콘솔 로그 - 초기 연결, BE/FE 무중단 배포 case별 재연결이 소켓 ID와 함께 찍힌 화면
소켓 재연결 테스트 콘솔 - 초기 연결에 이어 무중단 배포 case별로 재연결이 찍힌다. 기존 소켓 ID가 재사용되면 내장 재연결, 새 소켓 ID가 등장하면 새 객체를 만든 재연결이다.

로그가 어느 메커니즘이 이겼는지 알려줬다

둘을 구분할 단서는 소켓 ID에 있었다. 두 재연결은 ID를 남기는 방식이 달랐다.

메커니즘소켓 객체소켓 ID로그 신호
내장 재연결기존 객체 유지유지·갱신기존 ID 재사용 → 내장이 처리
수동 재연결새 객체 생성신규 발급새 ID 등장 → 수동이 처리

그래서 로그에 찍힌 소켓 ID가 이전과 같으면 내장 재연결이 붙인 것이고, 새 소켓 ID가 튀어나오면 수동 재연결이 새 객체를 만든 것이었다. "빠르지만 불안정한 내장" vs "확실한 수동"을 눈으로 가려낼 수 있는 지표였다. 이 관찰을 팀에 상황별 로그로 정리해 공유했다.

상황별 재연결 로그를 팀에 정리해 공유한 설명
팀에 남긴 상황별 로그 설명 - 기존 소켓 ID 재사용은 내장 재연결, 새 소켓 ID 등장은 수동 재연결. 무중단 배포 시 내장 Retry로 기존 소켓 재연결이 가능함을 테스트로 확인한 근거다.

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

그렇다면 수동 재연결을 빼고 내장 재연결에 맡기면 되지 않을까. 실제로 대부분의 경우 내장만으로도 잘 붙었다. 하지만 startReconnectionAttempt() 없이 두면 어느 시점부터 재연결이 멈춰 소켓이 영구히 끊기는 현상이 재현됐다. 내장 재연결은 빠르지만, 끝까지 책임지지는 않았다.

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

그래서 "빠르지만 불안정한 내장 재연결 + 확실한 수동 재연결" 두 메커니즘을 모두 유지하기로 했다. 한쪽이 실패해도 다른 쪽이 받쳐주는 이중 안전장치다. 환자 데이터를 다루는 의료 시스템이라 연결 안정성이 특히 중요했다 - 한 프레임의 끊김이 알림 하나를 놓치는 일이 될 수 있었다.

두 메커니즘이 경쟁했다 - 세 번의 테스트

유지는 정했지만, 둘을 어떻게 공존시키느냐가 남았다. 커밋을 세 번 갈아엎으며 조건을 좁혔다.

테스트접근결과
1초기 수동 재연결 로직재연결이 느리고, 영구 끊김도 발생
2상태 확인으로 속도 개선빨라졌지만 여전히 영구 끊김
3테스트 1로 되돌려 원인 정면 대응영구 끊김 해결

테스트 2는 특히 교묘했다. 속도는 잡았는데 영구 끊김이 남았고, 발생 조건이 좁아 재현이 어려웠다.

reconnection_request 수신 → 수동 재연결 로직 실행
  → 기존 소켓이 아직 안 끊겨 있음
  → if (socket && isConnected) 조건으로 재연결 중단  ← 여기서 손을 뗌
  → 직후 disconnect로 소켓 끊김
  → 내장 Retry가 성공하면 문제 없음
  → 하지만 내장 Retry가 실패하면, 수동도 이미 손을 뗐으므로 영구히 끊김

"소켓이 살아 있으니 재연결하지 마"라는 판단이, 바로 다음 순간 끊길 소켓 앞에서는 틀린 판단이 됐다. 두 안전장치가 같은 찰나에 동시에 손을 떼는 구멍이 있었던 것이다. 그래서 테스트 3에서는 속도를 잡은 로직을 버리고 테스트 1로 되돌아가, 이 타이밍 구멍 자체를 정조준했다.

타이밍 이슈 → isReconnecting 플래그

핵심 원인은 reconnection_requestdisconnect도착 순서였다.

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

reconnection_request 수신 시점엔 아직 소켓이 안 끊겨 있어 재연결을 건너뛰는데, 바로 다음에 disconnect가 와서 재연결 시도 없이 연결을 잃었다. 순간의 상태만 보면 "연결됨"이 맞지만, 그 직후 끊길 것을 상태는 모른다.

해결은 순간의 상태가 아니라 의도를 기억하는 것이었다. isReconnecting 플래그로 "재연결할 생각이 있다"를 붙잡아 두고, disconnect가 늦게 와도 플래그를 보고 재연결한다.

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

무중단 배포에서 이 플래그가 어떻게 두 경로를 모두 커버하는지는 이렇게 갈렸다.

  • Case 1 - 기존 소켓 재연결: reconnection_request 수신 → isReconnecting = true → 내장 재연결이 빠르게 붙어 새 소켓 ID로 연결 성공. POD가 교체돼도 서비스 중단 없이 전환된다.
  • Case 2 - 내장 재연결 실패: isReconnecting = true인 채로 disconnect가 오면, 위 핸들러가 플래그를 보고 수동 재연결을 이어받는다. 내장이 포기한 자리를 수동이 메운다.

리뷰에서도 reconnection_requestdisconnect 사이에 FE 재연결 로직이 끼는 이 타이밍 이슈가 짚였다 - 근본 해소는 BE 쪽 순서 보장이 이상적이지만, 지금은 FE에서 플래그로 방어해 두었다.

핵심: 로그아웃 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 가드

두 메커니즘을 모두 살리자, 이번엔 connect()가 여기저기서 불려 소켓 객체가 겹쳐 쌓이는 위험이 생겼다. 리스너가 중복 등록되고, 버려진 소켓이 메모리에 남았다. 그래서 connect() 진입에 가드를 세웠다.

connect: (url = SOCKET_BASE_URL) => {
  const { socket: currentSocket } = get();
 
  // 이미 연결된 소켓이 있으면 새로 만들지 않는다
  if (currentSocket && currentSocket.connected) {
    return;
  }
 
  // 끊긴 소켓이 남아 있으면 리스너까지 걷어내고 정리 - 메모리 누수 방지
  if (currentSocket) {
    currentSocket.removeAllListeners();
    currentSocket.disconnect();
    set({ socket: null });
  }
  // ... 새 소켓 생성
};

살아 있는 연결은 재사용하고, 죽은 소켓은 removeAllListeners()로 리스너를 떼어낸 뒤 버린다. 소켓 ID로 재연결 종류를 추적할 수 있었던 것도 객체를 하나로 관리한 덕이었다.

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)만 남겼다. 모든 오류에 수동으로 개입하려던 것이 오히려 문제였다.

결과

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

배운 점

  • 불규칙한 로그는 비결정이 아니라 경쟁의 흔적이었다. "소켓연결됨"과 "socket null"이 번갈아 찍힌 건 버그가 아니라, 내장과 수동 중 누가 먼저 도착했는지의 기록이었다. 로그를 상황별로 갈라야 두 메커니즘이 보였다.
  • 순간의 상태가 아니라 의도를 기억해야 했다. if (socket && isConnected)는 그 찰나엔 맞지만, 직후 끊길 소켓 앞에선 틀렸다. isReconnecting 플래그가 "재연결할 생각"을 붙잡아 타이밍 구멍을 메웠다.
  • 끊김을 만든 주체가 재연결의 기준이다. 클라이언트의 명시적 종료(언마운트)는 끊고, 서버 측 종료(무중단 배포)는 비정상 종료로 보고 재연결한다 - 같은 disconnect라도 출처가 의미를 갈랐다.
  • 도우려는 개입이 리듬을 깰 수 있다. connect_error마다 새 소켓을 만든 수동 재연결이, 정작 내장 재연결의 지수 백오프를 초기화시켰다. 때로는 손을 떼는 게 더 빠른 복구였다.
  • 안전장치는 하나로 몰지 않는다. 빠른 내장 재연결과 확실한 수동 재연결을 둘 다 살리되, 각자가 잘하는 자리(오류 전반 vs 서버 주도 요청)로 책임을 갈라 충돌 없이 겹쳤다.