- 발행일
STOMP 클라이언트를 useState에서 useRef로 — 정리 함수가 구독 객체를 못 보고 있었다
STOMP 클라이언트를 useState에서 useRef로
이 글은 2026년 9월에 당시 커밋 이력을 다시 보며 정리한 것입니다. 날짜는 작업한 날 기준입니다.
2024년 11월 셋째 주는 TripTune 채팅에 매달린 주였습니다. 21일 커밋 제목이 "wss는 왜 안될까?" 1탄부터 4탄, "STOMP의 4계절"이에요. 그 이야기는 wss 연결 실패 글2025-01-14WebSocket connection to wss failed — 후보 열 개를 놓고 하루 종일 좁힌 기록로컬에서 잘 되던 STOMP 채팅이 배포하니까 연결이 안 됐습니다. 원인 후보가 서버 상태부터 인증서까지 열 개쯤 되는 상황에서, 하루 8번의 배포 끝에 mixed content — https 페이지에서 ws:// 차단 — 하나로 좁혔습니다. 후보를 어떤 순서로 제거했는지와, 다음에 같은 일을 겪으면 뭘 먼저 볼지를 정리했습니다.과 두 번째 메시지부터 동기화가 깨지던 문제2025-01-14STOMP 채팅에서 두 번째 메시지부터 동기화가 깨지던 문제 — 범인은 key였다첫 메시지는 잘 가는데 두 번째부터 화면이 안 갱신되고, 새로고침하면 다 보이는 이상한 증상이었습니다. 저장은 되는데 렌더만 안 되는 것 — React key로 쓰던 messageId가 서버 응답에 없어서 프런트가 난수로 채우고 있었어요. 원인을 좁힌 과정과, 당시 정리에서 스스로 놓쳤던 것까지 다시 봤습니다.에 있습니다. 이 글은 그 사이에 낀 하루, 23일 밤과 24일 새벽 이야기입니다. 주제는 하나예요. 연결 객체를 어디에 둘 것인가.
23일 밤 — state를 하나 더 늘렸다
0f8f756 "채팅 구독 방법 설계"(23:36)의 Chatting.tsx는 이렇게 시작합니다.
const [client, setClient] = useState<Client | null>(null);
const [chatSubscription, setChatSubscription] = useState<StompSubscription | null>(null);
const [errorSubscription, setErrorSubscription] = useState<StompSubscription | null>(null);
STOMP 클라이언트와 구독 두 개가 전부 state입니다. 그 전 커밋에는 subscription 하나였는데, 이날 서버 에러 큐 /user/queue/errors를 구독하면서 둘로 나눴어요. 연결하는 effect의 끝은 이랬습니다.
stompClient.activate();
setClient(stompClient);
return () => {
if (chatSubscription) chatSubscription.unsubscribe();
if (errorSubscription) errorSubscription.unsubscribe();
if (stompClient.connected) stompClient.deactivate();
};
}, [scheduleId, brokerUrl, token, pathname]);
정리 함수가 chatSubscription을 읽어서 unsubscribe합니다. 그런데 이 정리 함수는 effect가 실행되던 순간의 클로저예요. 그 순간 chatSubscription은 null입니다. onConnect 콜백이 나중에 setChatSubscription(chatSub)을 불러도, 이미 만들어진 정리 함수가 보는 값은 바뀌지 않습니다. 그러니까 if (chatSubscription)은 늘 거짓이고, unsubscribe는 한 번도 불린 적이 없습니다. 마지막 줄의 stompClient.deactivate()가 연결째 끊어 주니까 화면에서는 티가 안 났어요.
React를 몇 년 썼는데도 이 자리에서는 state를 "값을 넣어 두는 상자"로 쓰고 있었습니다. 렌더와 무관하게 붙잡고 있어야 하는 객체는 state가 아니라 ref에 두는 건데, 채팅은 처음이라 "연결됐는지"를 화면이 알아야 한다고 생각했던 것 같습니다. 그래서 전부 state로 간 걸로 보여요.
이 커밋에는 호스트 화이트리스트(localhost·netlify만 연결)도 있었는데, 배포 도메인이 바뀌면 채팅이 조용히 안 되는 코드였고 여기서 읽는 client도 state라 같은 문제를 안고 있었습니다.
24일 새벽 — ref로 옮기고 state 셋을 지웠다
한 시간 뒤 6bd7f28 "채팅 구독 방법 설계 -> 리팩토링"(00:37)입니다. +30/−131.
+ const clientRef = useRef<Client | null>(null);
- const [client, setClient] = useState<Client | null>(null);
- const [chatSubscription, setChatSubscription] = useState<StompSubscription | null>(null);
- const [errorSubscription, setErrorSubscription] = useState<StompSubscription | null>(null);
stompClient.activate();
- setClient(stompClient);
+ clientRef.current = stompClient;
return () => {
- if (chatSubscription) chatSubscription.unsubscribe();
- if (errorSubscription) errorSubscription.unsubscribe();
- if (stompClient.connected) stompClient.deactivate();
+ if (clientRef.current) {
+ clientRef.current.deactivate();
+ clientRef.current = null;
+ }
};
- }, [scheduleId, brokerUrl, token, pathname]);
+ }, [scheduleId, brokerUrl, token]);
바뀐 게 넷입니다. 클라이언트를 ref에 두고, 구독 객체는 아예 보관하지 않고(연결을 끊으면 구독도 끝나니까), 정리 함수는 ref를 읽고, 의존성에서 pathname을 뺐어요. pathname은 화이트리스트 검사에만 쓰였는데 그 검사를 지웠으니 같이 나간 겁니다. 에러 큐 구독도 이때 지웠습니다. 전날 밤에 넣고 다음 날 새벽에 뺀 거예요. 왜 뺐는지는 커밋에 없습니다.
handleSendMessage도 ref를 읽게 바뀌면서 stompClient.connected 검사가 붙었습니다. 전에는 client가 있으면 바로 publish했는데, 연결이 끊긴 클라이언트에 publish하면 라이브러리가 던지는 걸 try/catch로 삼키고 있었어요.
초기 메시지 로드도 한 줄이 됐습니다. 전에는 1페이지를 받아 totalPages를 알아낸 뒤 마지막 페이지를 한 번 더 받는 두 번 요청이었는데, 첫 응답을 reverse()해서 쓰는 걸로 바꿨어요. 이건 서버가 주는 순서에 대한 전제인데, 그 전제를 어디서 확인했는지는 커밋에 없습니다.
리팩토링 커밋이 화면을 지웠다
−131 중 54줄은 다른 이유였습니다. 이 커밋의 JSX 부분 diff입니다.
return (
<div className={styles.chatContainer}>
- <div className={styles.header}>
- <h1 className={styles.chatTitle}>그룹 채팅</h1>
- </div>
- <div className={styles.messageContainer}>
- {messages.map((msg) => (
... 48줄 더 ...
+ {/* Remaining JSX */}
</div>
);
채팅 화면 전체가 주석 한 줄이 된 채 커밋됐습니다. 파일이 212줄에서 111줄이 된 건 그래서예요. 연결 로직에 집중하느라 JSX를 접어 두고 작업하다가 그대로 올린 것으로 보이는데, 정확한 경위는 없습니다. 12시간 뒤 5e97530 "리팩토링 : 누락 처리"(13:02)에서 58줄을 되살렸고 파일은 166줄이 됐습니다. 그 커밋에서 clientRef.current instanceof Client 가드도 붙었어요.
검증
- 검증 기록은 없습니다. 이 무렵 커밋 제목이 "테스트 지원 1차~5차", "wss 테스트"인데 전부 화면에서 눌러 본 기록이고 테스트 파일은 0개였습니다.
- 정리 함수의
chatSubscription이 늘 null이었다는 건 코드를 읽어서 안 것이고, 당시에 로그로 확인하지는 않았습니다. - JSX가 빠진 채 커밋된 건 12시간 뒤 복원 커밋이 있으니 그 사이에 알아챈 겁니다. 첫 배포가 나흘 뒤(11월 28일)라 사용자에게 나간 적은 없습니다.
남은 것 · 한계
- ref로 옮긴 건 맞았고, 이후 이 파일의 연결 코드는 그 구조를 유지했습니다. 12월 6일과 9일의 일정 화면 리팩토링에서도
clientRef는 그대로예요. - 구독 객체를 안 들고 있기로 한 건 반은 맞습니다.
deactivate()가 구독을 정리해 주지만, 한 연결에서 구독을 바꿔 끼워야 하는 상황이 오면 다시 들고 있어야 합니다. 그 상황은 안 왔습니다. - 에러 큐 구독을 왜 뺐는지 모릅니다. 서버 쪽 에러를 화면이 받을 길이 이날 사라졌고, 되살린 기록이 없습니다.
- 화이트리스트를 지운 건 좋았는데, 그 자리에 뭘 두지 않았습니다. 어느 환경에서든
brokerUrl만 있으면 연결을 시도해요. 잘못된 URL이면reconnectDelay: 5000으로 5초마다 재시도합니다. - 화면 JSX가 빠진 커밋이 올라간 건 커밋 전에 diff를 안 봤다는 뜻입니다. 이 시기 커밋 메시지가 어땠는지는 1년 뒤 Git 커밋 메시지 잘 쓰는 법2025-12-21Git 커밋 메시지 잘 쓰는 법 — 그리고 내 저장소 2년치로 검증해 본 결과커밋 메시지 컨벤션(타입, 50자 제목, 명령형)을 정리한 글입니다. 그런데 규칙 정리보다 값진 게 생겼어요 — 제 사이드 프로젝트 2년치 커밋 500여 개를 이력서 재료로 캐면서, 이 글의 "나쁜 예"를 전부 제가 저질렀다는 걸 확인했습니다. 나쁜 커밋의 비용이 언제 어떻게 청구되는지를 뒤에 붙였습니다.에서 저장소 2년치를 놓고 따져 봤습니다.
관련 글: WebSocket connection to wss failed2025-01-14WebSocket connection to wss failed — 후보 열 개를 놓고 하루 종일 좁힌 기록로컬에서 잘 되던 STOMP 채팅이 배포하니까 연결이 안 됐습니다. 원인 후보가 서버 상태부터 인증서까지 열 개쯤 되는 상황에서, 하루 8번의 배포 끝에 mixed content — https 페이지에서 ws:// 차단 — 하나로 좁혔습니다. 후보를 어떤 순서로 제거했는지와, 다음에 같은 일을 겪으면 뭘 먼저 볼지를 정리했습니다. · STOMP 채팅에서 두 번째 메시지부터 동기화가 깨지던 문제2025-01-14STOMP 채팅에서 두 번째 메시지부터 동기화가 깨지던 문제 — 범인은 key였다첫 메시지는 잘 가는데 두 번째부터 화면이 안 갱신되고, 새로고침하면 다 보이는 이상한 증상이었습니다. 저장은 되는데 렌더만 안 되는 것 — React key로 쓰던 messageId가 서버 응답에 없어서 프런트가 난수로 채우고 있었어요. 원인을 좁힌 과정과, 당시 정리에서 스스로 놓쳤던 것까지 다시 봤습니다. · Git 커밋 메시지 잘 쓰는 법2025-12-21Git 커밋 메시지 잘 쓰는 법 — 그리고 내 저장소 2년치로 검증해 본 결과커밋 메시지 컨벤션(타입, 50자 제목, 명령형)을 정리한 글입니다. 그런데 규칙 정리보다 값진 게 생겼어요 — 제 사이드 프로젝트 2년치 커밋 500여 개를 이력서 재료로 캐면서, 이 글의 "나쁜 예"를 전부 제가 저질렀다는 걸 확인했습니다. 나쁜 커밋의 비용이 언제 어떻게 청구되는지를 뒤에 붙였습니다. · useCallback 17개를 지운 "최적화" 커밋2024-11-07useCallback 17개를 지운 "최적화" 커밋 — 그리고 같은 커밋에서 새로 만든 문제커밋 제목이 "useCallback 최적화"인데 diff를 보면 useCallback을 추가한 줄은 0, 지운 줄은 17이었습니다. 의존성 배열에 zustand 세터를 넣고 있었고, 변수에 담지도 않은 useCallback 호출도 하나 있었어요. 지운 판단 자체는 지금도 맞다고 보는데, 같은 커밋에서 메모이제이션을 벗긴 함수를 useEffect 의존성에 넣어 매 렌더마다 인증 검사를 돌게 만들었습니다. 측정은 없었습니다. · 배포 첫날 커밋 열 개2024-11-28배포 첫날 커밋 열 개 — www 누락, 리라이트 삭제, 그리고 읽히지도 않던 middlewareTripTune을 처음 Netlify에 올린 날, 커밋 열 개가 전부 next.config.js와 middleware.ts를 만지는 것이었습니다. 백엔드 주소에 www가 빠졌고, 리라이트를 지웠다가 되살렸고, 다음날에는 middleware의 matcher를 통째로 뒤집었어요. 지금 커밋을 다시 보니 그 middleware는 위치 때문에 애초에 실행된 적이 없었을 가능성이 큽니다.