웹소켓 채팅 프레임워크를 만들며, 가장 먼저 다시 정의한 것

columns
남종호
남종호Backend Developer
2026. 07. 15.

안녕하세요. 제로백데브 백엔드 개발자 남종호입니다.

최근 저희는 다양한 웹·앱 서비스에서 활용할 수 있는 웹소켓 기반 실시간 채팅 프레임워크를 개발하고 있습니다.

채팅 기능은 커뮤니티, 중고거래, 업무 시스템, 금융 서비스 등 다양한 서비스에서 기본 기능으로 자리 잡고 있지만, 실제 구현 과정에서는 생각보다 많은 예외 상황과 기술적인 고려가 필요합니다.

오늘은 프레임워크를 개발하면서 실제로 마주했던 문제들과, 이를 어떻게 해결했는지 이야기해보려고 합니다.

채팅 기능은 겉보기엔 단순합니다. 메시지를 보내면 화면에 뜨고, 상대방 화면에도 똑같이 뜬다. 이 한 줄을 구현하는 건 어렵지 않았습니다. socket.io로 서버를 하나 띄우고, 같은 방(room)에 접속한 클라이언트끼리 메시지를 뿌려주면 끝입니다. 실제로 하루 만에 "메시지가 실시간으로 오간다"는 화면까지는 만들 수 있었습니다. 문제는 그다음부터였습니다. 겉보기엔 똑같이 동작하는데, 특정 조건에서만 미묘하게 틀어지는 부분들이 있었고, 그 지점들이 채팅 기능의 진짜 난이도였습니다.

"내가 보낸 메시지"를 무엇으로 구분할 것인가

프레임워크를 처음 구성할 때 가장 먼저 고민했던 부분은 메시지의 소유권을 어떻게 판단할 것인가였습니다.

화면에서 내 메시지는 오른쪽 말풍선으로, 상대방 메시지는 왼쪽 말풍선으로 그려야 하는데, 이 "내 메시지인지"를 서버가 내려준 user(닉네임) 값과 클라이언트가 들고 있는 내 닉네임을 비교해서 판단하도록 처음에 짰습니다.

const isOwn = m.user === name;

로컬에서 혼자 테스트할 땐 아무 문제가 없었습니다. 그런데 이건 닉네임이 겹치는 순간 바로 깨지는 코드였습니다. 같은 이름을 쓰는 두 사람이 채팅방에 들어오면, 서로의 메시지가 전부 "내 메시지"로 보이는 상황이 생깁니다. PoC 단계라 넘어갈 수도 있었지만, 실제 서비스에서는 닉네임 중복이 일상이라 그대로 두면 반드시 재현될 버그였습니다.

해결은 단순했습니다. 닉네임이 아니라 연결 자체의 고유 식별자인 socket.id를 메시지에 실어 보내고, 이 값으로 본인 여부를 판단하도록 바꿨습니다.

// server/index.js
io.to(ROOM).emit("message", {
  id: socket.id, // 닉네임 대신 연결 단위 고유 값을 pk로 사용
  user: username,
  text,
  time: Date.now(),
});
// client/src/App.jsx
const isOwn = m.id === socket.id;

닉네임은 화면에 "누구인지" 보여주기 위한 표시용 값이고, "이게 내 메시지인가"를 가르는 식별자는 따로 있어야 한다는 걸 실제로 부딪히고 나서야 명확히 구분하게 됐습니다. 이름과 식별자를 같은 것으로 취급하면, 겹치는 순간 반드시 사고가 난다는 걸 배운 셈입니다.

눈에 보이지 않는 버그, 한글 입력

두 번째는 더 찾기 까다로운 종류였습니다. 영문으로 테스트할 땐 멀쩡했는데, 한글로 메시지를 입력하고 Enter로 전송하면 마지막 글자가 한 번 더 붙어서 전송되는 현상이 있었습니다. "가나다"를 치고 Enter를 누르면 "가나다다"처럼 보내지는 식입니다.

원인은 한글 입력 방식(IME)에 있었습니다. 한글은 자음과 모음을 조합해서 한 글자를 완성하는데, 이 조합이 끝나기 직전 상태에서 Enter를 누르면 브라우저가 "조합 완료"와 "Enter 키 입력"을 순서대로 발생시키면서 마지막 글자에 대한 keydown 이벤트가 한 번 더 잡히는 경우가 있습니다. 영문에는 이 조합 과정 자체가 없어서 테스트 중엔 전혀 드러나지 않았던 것이고요.

const onKey = (e) => {
  // 한글 등 IME 조합 중 Enter는 무시 (마지막 글자 중복 전송 방지)
  if (e.nativeEvent.isComposing || e.keyCode === 229) return;
  if (e.key === "Enter" && !e.shiftKey) {
    e.preventDefault();
    handleSend();
  }
};

isComposing 플래그와, 일부 브라우저 호환을 위한 keyCode === 229 체크를 같이 넣어서 조합 중인 입력에는 전송 로직이 반응하지 않도록 막았습니다. 이 버그가 특히 위험했던 이유는, 개발 중 흔히 쓰는 영문 테스트 문자열로는 절대 재현되지 않고 실제 사용자가 한글로 입력할 때만 나타난다는 점이었습니다. 국내 서비스라면 한글 입력을 기본 테스트 케이스에 반드시 포함해야 한다는 걸 다시 확인한 계기였습니다.

재접속 시 중복 입장 막기

세 번째는 리액트 특유의 함정이었습니다. 개발 모드에서 컴포넌트가 두 번 마운트되거나, 리렌더링이 일어날 때마다 join 이벤트를 다시 보내면 "OO님이 입장했습니다"라는 시스템 메시지가 여러 번 쌓이는 문제가 있었습니다. useEffect의 의존성 배열만 믿고 있었는데, 이것만으로는 중복 emit을 막기에 부족했습니다.

const joinedRef = useRef(false);

useEffect(() => {
  if (joinedRef.current) return;
  joinedRef.current = true;
  socket.emit("join", name);
}, [name]);

useRef로 "이미 입장했는가"를 별도로 추적해서, 리렌더링이 몇 번 일어나든 join은 정확히 한 번만 나가도록 고정했습니다. 실시간 연결을 다루는 코드는 렌더링 사이클과 별개로 "한 번만 일어나야 하는 일"을 명시적으로 관리해줘야 한다는 걸 체감했습니다.

UI에 시간을 많이 쓴 이유

채팅 로직 자체는 이렇게 정리됐지만, 실제로 시간이 더 든 건 화면이었습니다. 실무에서 채팅은 단독 화면이 아니라 커뮤니티나 중고거래 같은 다른 화면들 사이에 끼워져 있는 하나의 흐름입니다. 그래서 이번 PoC에는 홈 목업, 커뮤니티, 중고거래 목록·상세 화면까지 함께 만들어 "게시글 상세 → 판매자와 채팅"으로 이어지는 진입 흐름 전체를 구현했습니다. 채팅창의 상대방 아바타를 트레이드 상세 화면의 판매자 프로필 사진과 동일하게 맞춘 것도 같은 이유입니다. 채팅만 따로 완성도 있게 만드는 것보다, 실제로 사용자가 밟는 화면 흐름 위에서 웹소켓이 어떻게 동작하는지를 확인하는 게 PoC의 목적에 더 맞다고 판단했습니다.

정리하면

웹소켓 기반 실시간 채팅은 메시지를 주고받는 기능 하나만 구현한다고 끝나지 않습니다.

닉네임과 식별자를 어떻게 관리할 것인지, 한글 입력과 같은 브라우저 환경을 어떻게 처리할 것인지, 리액트의 렌더링 사이클과 실시간 연결을 어떻게 안정적으로 유지할 것인지까지 함께 고려해야 합니다.

이런 경험들을 하나씩 축적하면서 저희는 단순한 채팅 예제가 아니라, 다양한 프로젝트에서 재사용할 수 있는 웹소켓 기반 실시간 채팅 프레임워크를 만들어가고 있습니다.

앞으로도 실제 프로젝트를 수행하며 얻은 경험을 지속적으로 반영해 더 안정적이고 확장 가능한 형태로 발전시켜 나갈 예정입니다.

감사합니다.