본문으로 건너뛰기

버그 제보

이상한 점을 알려 주시면 고칠게요. 지금 보고 있는 페이지와 브라우저 정보가 함께 담겨요.

페이지:

Frontend

실시간 채팅 앱의 데이터 흐름: REST와 WebSocket 역할 나누기

작성 2026. 10. 13.추가
목차6개 항목

채팅 앱의 데이터는 두 종류다. 이미 있는 것(과거 메시지, 채널 목록, 내 정보)과 지금 생기는 것(새 메시지, 접속 상태)이다. 앞은 요청하면 응답이 오는 REST가 맞고, 뒤는 서버가 먼저 밀어 줘야 하므로 WebSocket이 맞다. 두 채널을 어떻게 나누고 어디서 만나게 하는지가 채팅 앱 프론트엔드 설계의 핵심이다.

REST와 WebSocket의 역할

채널맡는 일예
REST상태 조회와 변경. 페이지네이션, 인증, 파일 업로드채팅 기록 불러오기, 메시지 저장, 멤버 초대
WebSocket서버에서 클라이언트로 이벤트 푸시새 메시지 도착, 온라인 사용자 목록 변화

쓰기는 REST, 알림은 소켓이다. 메시지를 보낼 때 소켓으로 직접 보내지 않고 POST로 저장한다. 서버는 저장에 성공하면 같은 방의 다른 사용자에게 소켓 이벤트를 발생시킨다. 이렇게 하면 저장 실패를 HTTP 응답으로 분명히 알 수 있고, 소켓이 끊겨 있어도 메시지는 유실되지 않는다.

URL에 리소스 계층이 드러나게

GET  /workspaces/:workspace/channels                    워크스페이스 안의 채널 목록
GET  /workspaces/:workspace/channels/:channel/chats      채널의 채팅 (?perPage=&page=)
POST /workspaces/:workspace/channels/:channel/chats      채팅 저장 → 서버가 message 이벤트 발생
GET  /workspaces/:workspace/channels/:channel/unreads    ?after=timestamp 이후 안 읽은 수
  • 소속 관계(워크스페이스 → 채널 → 채팅)가 경로에 그대로 보여, URL만 읽어도 무엇을 가져오는지 알 수 있다.
  • 목록은 perPage·page 쿼리로 페이지네이션한다. 채팅처럼 끝없이 쌓이는 데이터를 한 번에 받지 않는다.

소켓 연결의 수명

소켓은 범위(워크스페이스) 하나에 연결 하나만 유지한다. 컴포넌트마다 연결을 만들면 같은 이벤트를 여러 번 받는다.

// 범위별로 소켓을 하나만 보관하는 훅의 골격
const sockets: Record<string, Socket> = {}
 
function useSocket(scope?: string) {
  const disconnect = useCallback(() => {
    if (!scope) return
    sockets[scope]?.disconnect()
    delete sockets[scope]
  }, [scope])
  if (!scope) return [undefined, disconnect] as const
  sockets[scope] ??= io(`/ws-${scope}`, { transports: ['websocket'] })
  return [sockets[scope], disconnect] as const
}
  • 범위를 떠나면 disconnect()한다. effect의 cleanup이 그 자리다.
  • 이벤트 리스너를 on으로 등록한 effect는 반드시 cleanup에서 off 한다. 빠뜨리면 리렌더링마다 리스너가 쌓여 한 메시지가 여러 번 처리된다.

두 채널이 만나는 곳: 메시지 캐시

과거 기록과 실시간 메시지는 결국 같은 목록에 들어가야 한다. 서버 상태 라이브러리의 캐시가 그 합류점이 된다.

  1. 초기 로딩: 기록을 페이지 단위로 가져온다(SWR의 useSWRInfinite나 TanStack Query의 useInfiniteQuery).
  2. 보내기: optimistic UI. 캐시에 먼저 넣고 POST를 보낸다. 실패하면 되돌린다.
  3. 받기: 소켓 이벤트가 오면 지금 보고 있는 방의 메시지인지 확인한 뒤 캐시 앞에 끼워 넣는다. 내가 보낸 메시지는 이미 optimistic으로 들어가 있으므로 다시 넣지 않는다.
  4. 스크롤: 새 메시지가 왔을 때 사용자가 바닥 근처에 있으면 맨 아래로 내리고, 위로 올라가 과거를 읽는 중이면 위치를 유지한다.

긴 목록의 렌더링 최적화

포인트방법
날짜 구분선메시지를 날짜 키(YYYY-MM-DD)로 묶는 유틸을 두고 섹션 단위로 그린다(날짜 라이브러리)
무한 스크롤맨 위에 닿으면 이전 페이지를 요청한다. 위에 붙인 뒤 늘어난 높이만큼 스크롤을 보정해 보던 위치를 유지한다
메시지 항목 memo메시지 하나는 한 번 그려지면 거의 바뀌지 않는다. React.memo로 감싸 새 메시지가 올 때 전체 목록이 다시 그려지는 것을 막는다(리렌더링 원인 추적하기)

정리

  • 조회·변경은 REST, 이벤트 푸시는 WebSocket. 쓰기는 REST로 하고 서버가 소켓으로 알린다.
  • URL은 리소스의 소속 관계를 드러내고, 목록은 페이지네이션한다.
  • 소켓은 범위당 하나, 리스너는 cleanup에서 해제한다.
  • 두 채널의 데이터는 메시지 캐시에서 합류한다. optimistic UI와 중복 방지는 그 캐시 위에서 한다.

관련 노트: WebSocket, REST API, SWR로 데이터 가져오기, 생명주기

댓글

GitHub 계정으로 댓글을 남길 수 있어요.