문서 읽는 데 75분 · C9

C-9: WebSocket 클라이언트 ① — 통로를 연다

목차 62
전체 59강 중 20강 · 리액트
난이도 · 중급선수지식HTML·CSS·JS

ℹ️TypeScript · React · Next.js 트랙이에요. html-css-js에서 만든 순수 JS 화면을 React 컴포넌트로 다시 짭니다. 웹 기초를 먼저 익히고 오면 좋아요.

안녕하세요, 홍순구입니다.

지난 시간에 우리는 앱을 빠르게 만들었는데, 다시 그리는 횟수를 여섯에서 영으로 줄였고 처음 내려받는 양도 100 KB를 덜어냈어요.

그 모든 걸 하고 나서도 우리 앱에는 이상한 구멍이 하나 남아 있는데, 오늘은 그 구멍 이야기부터 시작할게요.

지금 여러분이 우리 인스타그램을 보고 있다고 해봅시다. 화면에 게시물이 열 장 떠 있는데, 그때 minji 님이 첫 번째 게시물에 좋아요를 누릅니다.

여러분 화면은 어떻게 될까요?

아무 일도 안 일어납니다. 좋아요 숫자는 1240 그대로 있는데, minji 님이 눌렀다는 걸 우리는 새로고침을 하기 전까지 모릅니다.

우리 앱은 우리가 누를 때만 화면이 바뀝니다.

💡 오늘 수업의 핵심 — "물어봐야만 아는 방식으로는 못 푸는 문제가 있습니다"

지금까지 우리가 서버와 이야기한 방식은 전부 같은 모양이었습니다. 우리가 묻고, 서버가 답합니다. 피드를 달라고 하면 주고, 좋아요를 눌렀다고 하면 받아줘요.

이 방식에는 한 가지 전제가 깔려 있습니다. 먼저 말을 거는 쪽은 언제나 우리라는 것.

서버는 우리에게 먼저 말을 걸 수가 없어서, 할 말이 있어도 아무리 좋은 소식이나 급한 일이 있어도 우리가 물어볼 때까지 기다려야 해요.

오늘은 그 전제를 깹니다.

🎯 학습 목표

  • 다시 물어보는 방식으로 어디까지 되는지를 재보고, 그 한계를 숫자로 확인합니다.
  • 우리가 열 통로가 무엇 위에 서 있는지 — TCP부터 핸드셰이크까지 — 를 짚고 갑니다.
  • 규약 없는 날 통로에 직접 붙어보고, 거기에 무엇이 빠져 있는지 확인합니다.
  • STOMP 프레임을 눈으로 읽고, 목적지 이름의 규칙을 익힙니다.
  • 앱에 연결을 하나 열고, 서버가 보내온 소식을 캐시에 얹습니다.
  • 연결 상태를 어디에 담아야 하는지 세 갈래 규칙으로 정합니다.
  • 끊어졌을 때 어떻게 다시 붙는지, 왜 조금씩 늦게 다시 붙어야 하는지 확인합니다.
텍스트
 오늘의 여정

 1. 다시 물어보면 되잖아요      3초마다 묻기 — 재보면 평균 2초 늦는다
        
 2. 통로는 무엇 위에 서 있나    TCP  핸드셰이크  101
        
 3. 날 통로에 붙어본다          열리긴 하는데, 무슨 말인지 모르겠다
          (규칙을 우리가 만들어야 하나?)
 4. STOMP 프레임을 읽는다       이미 누가 만들어뒀다
        
 5. 앱에 연결 하나              activate · 어디에 둘 것인가
        
 6. 남이 누른 좋아요가 화면에    구독한 것을 캐시에 얹는다
        
 7. 연결 상태는 어디에          세 갈래 규칙으로 정한다
        
 8. 끊어지면 다시 붙는다        조금씩 늦게 두드리는 이유

Step 1: "다시 물어보면 되지 않나요"

가장 먼저 떠오르는 답부터 해봅니다

문제는 분명합니다. 남이 누른 걸 우리가 모른다는 것.

여기서 대부분의 사람이 먼저 떠올리는 답이 있어요. 그러면 계속 물어보면 되잖아요.

틀린 생각이 아닙니다. 실제로 많은 서비스가 몇 초에 한 번씩 서버에 "뭐 바뀐 거 있나요?" 하고 물어보는 방식으로 만들어져 있는데, 이걸 폴링이라고 불러요.

우리는 이미 이걸 할 줄 아는데, 한 줄이면 됩니다.

tsx
// apps/web-spa/src/queries/posts.ts
export function useFeedQueryPolling(intervalMs: number, tag?: string) {
  return useQuery({
    queryKey: feedKey(tag),
    queryFn: () => fetchFeed(tag),

    // 이 한 줄이 폴링이다. 정해둔 간격마다 알아서 다시 물어본다.
    refetchInterval: intervalMs,
  });
}

refetchInterval 하나 붙이면 3초마다 알아서 피드를 다시 물어보기 때문에, 남이 누른 좋아요도 3초 안에는 우리 화면에 들어옵니다.

끝난 것 같은데, 지난 시간에 우리가 정한 순서가 있죠. 재고 나서 판단하기로 했잖아요.

얼마나 늦게 아는지 재봅니다

간격을 3초로 두고, 남이 좋아요를 누른 순간부터 우리 화면이 바뀌는 순간까지를 여덟 번 쟀습니다.

텍스트
 남이 누른 뒤 우리가 알아채기까지 (간격 3초 · 8회)

 2749ms  ██████████████████████████
 1862ms  ██████████████████
  730ms  ███████
 2993ms  ████████████████████████████
 2959ms  ████████████████████████████
 2056ms  ███████████████████
 1103ms  ██████████
 2591ms  ████████████████████████

 가장 빠를 때  730ms
 가장 늦을 때  2993ms
 평균         2130ms

숫자가 들쭉날쭉하죠. 당연합니다. 폴링은 자기 시계대로 도니까요. 운 좋게 물어보기 직전에 눌리면 금방 알고, 물어본 직후에 눌리면 다음 차례까지 꼬박 기다려야 합니다.

그래서 평균은 대략 간격의 절반쯤이 되는데, 3초 간격이면 평균 1.5초이고 여기에 서버가 답하는 시간이 더해져서 2초쯤 나왔어요.

⚠️ 이 숫자는 연습용 서버가 400ms 뒤에 답하도록 맞춰둔 상태에서 잰 값입니다. 여러분이 재면 조금 다를 수 있어요.

그럼 간격을 줄이면 되지 않나요

됩니다. 0.5초로 줄이면 평균 0.25초 만에 알게 돼요. 거의 실시간처럼 느껴집니다.

대신 요청이 여섯 배로 늘어납니다.

여기서 잠깐 산수를 해볼게요. 3초 간격이면 1분에 20번, 한 시간에 1200번입니다. 사용자가 천 명이면 한 시간에 120만 번이에요. 0.5초로 줄이면 720만 번이고요.

그 1200번 중에 실제로 답이 달라진 건 몇 번일까요?

인기 게시물이 아니라면 하루에 몇 번입니다. 나머지 1190번은 "그대로예요"라는 답을 받으려고 보낸 요청이에요.

텍스트
 폴링이 하는 일 (한 시간 · 3초 간격)

 요청 1200번  ████████████████████████████████████████
 그중 달라진 답    ▌  (몇 번)

 나머지는 전부 "그대로예요" 를 확인하려고 보낸 것

그리고 이건 간격으로 못 푸는 문제입니다

간격을 줄이면 늦는 시간이 줄고 요청이 늘어나는데, 늘리면 정반대예요. 둘 다 만족시키는 값은 없어서, 하나를 얻으면 하나를 잃습니다.

C-6 과제에 경매 최고가 이야기가 있었죠. 마감 10초 전에 최고가가 바뀌는데 우리 화면이 3초 늦게 안다면, 그건 늦은 정도가 아니라 틀린 값을 보여주고 있는 겁니다. 그 값을 보고 입찰하니까요.

간격을 0.1초로 줄이면 될까요? 그러면 사용자 천 명이 초당 만 번씩 서버를 두드립니다. 실시간을 흉내 내려고 서버를 죽이는 셈이에요.

문제의 뿌리는 간격이 아닙니다. 우리가 물어봐야만 안다는 것이 뿌리예요.

바뀐 걸 아는 쪽은 서버인데, 말할 수가 없어요. 우리가 물어볼 때까지 알고도 가만히 있어야 합니다.

그러니 고칠 곳은 간격이 아니라 서버가 먼저 말할 수 있게 하는 것입니다.

💡 한 줄 정리

폴링은 간격만큼 늦게 알고, 그 간격을 줄이면 요청이 그만큼 늘어납니다. 둘 다 만족하는 값이 없다면 그건 값을 잘못 고른 게 아니라 방식이 문제인 겁니다.

🙋 학생 질문 — "튜터님, 그럼 폴링은 나쁜 건가요? 쓰면 안 되나요?"

아니요. 폴링은 지금도 아주 많이 쓰입니다.

기준은 이겁니다. 몇 초 늦어도 괜찮은가?

배포 상태를 보여주는 화면이라면 5초 늦어도 아무 문제 없어요. 오히려 폴링이 낫습니다. 한 줄이면 되고, 연결을 관리할 필요도 없고, 서버가 재시작돼도 알아서 다시 붙거든요.

반대로 채팅이나 경매처럼 늦으면 틀린 값이 되는 화면이라면 폴링으로는 안 됩니다.

오늘 배울 방식은 폴링을 대체하는 게 아니라 폴링으로 안 되는 자리에 쓰는 다른 도구인데, "언제 이게 과한가"는 다음 시간에 따로 다룰게요.


Step 2: "통로가 무엇 위에 서 있나"

새 기술을 만나기 전에 바닥부터

우리가 오늘 열 통로의 이름은 WebSocket인데, 이름을 배우기 전에 이게 무엇 위에 서 있는지부터 짚고 갈게요. 그래야 왜 이렇게 생겼는지가 이해됩니다.

지금까지 우리가 쓴 HTTP 요청이 실제로 어떻게 오가는지 보면 이렇습니다.

텍스트
 우리가 fetch 를 한 번 부를 때

 브라우저                                서버
    │                                      │
    │  1. TCP 연결을 만든다 (손잡기)       │
    │ ───────────────────────────────────> │
    │ <─────────────────────────────────── │
    │                                      │
    │  2. 그 위로 HTTP 요청을 보낸다       │
    │  GET /api/posts ─────────────────>   │
    │  <──────────── 200 + 게시물 열 장    │
    │                                      │
    │  3. 볼일이 끝났다                    │
    │                                      │

바닥에 TCP가 있는데, 두 컴퓨터 사이에 데이터가 오갈 수 있는 선을 놓는 일이에요. 이 선은 양방향이며, 한 번 놓이면 양쪽 다 아무 때나 보낼 수 있어요.

그 위에 HTTP가 얹혀 있는데, HTTP는 그 양방향 선을 한쪽 방향으로만 씁니다. "요청 하나에 응답 하나"라는 규칙을 스스로 정해뒀거든요.

여기가 중요합니다. 선이 한 방향이라서 서버가 말을 못 거는 게 아닙니다. 선은 원래 양방향인데, 그 위에 얹은 규칙이 한 방향만 쓰기로 정해둔 거예요.

그러니 우리에게 필요한 건 새로운 선이 아닙니다. 같은 선 위에 다른 규칙을 얹는 것입니다.

그게 WebSocket입니다

WebSocket은 이렇게 동작합니다. 먼저 평범한 HTTP 요청을 보내는데, 그 요청에 특별한 헤더를 붙여요. "이 연결, 규칙을 바꿔서 계속 쓰면 안 될까요?"

서버가 받아들이면 그 순간부터 같은 TCP 선 위에서 다른 규칙이 돌아가는데, 요청과 응답 짝이 사라지고 양쪽 다 아무 때나 보낼 수 있게 돼요.

이 교섭 과정을 핸드셰이크라고 부르는데, 우리 연습용 서버에 직접 해보면 이렇게 답합니다.

텍스트
 우리가 보내는 것
   GET /ws HTTP/1.1
   Connection: Upgrade
   Upgrade: websocket
   Sec-WebSocket-Version: 13
   Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==

 서버가 답하는 것
   HTTP/1.1 101 Switching Protocols
   Upgrade: websocket
   Connection: Upgrade
   Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

우리가 지금까지 본 응답 코드는 200, 404, 500 같은 것들이었죠. 101은 처음 보는데, "규칙을 바꿉니다"라는 뜻이에요.

Sec-WebSocket-KeySec-WebSocket-Accept는 서로가 진짜 WebSocket을 말할 줄 아는지 확인하는 증표이며, 우리가 보낸 키에 규격이 정해둔 문자열을 붙여서 계산하면 저 Accept 값이 나와야 해요. 아무 서버나 "네"라고 답하면 안 되니까요.

주소도 바뀌어서, http://가 아니라 ws://로 시작해요. 보안 연결이면 wss://고요. httpss가 붙는 것과 같은 규칙입니다.

텍스트
 계층으로 보면

  ┌─ STOMP ────────────┐   Step 4 에서 배울 것
  ├─ WebSocket ────────┤   양쪽이 아무 때나 말할 수 있는 규칙
  ├─ TCP ──────────────┤   두 컴퓨터 사이의 양방향 선
  └────────────────────┘

 HTTP 도 TCP 위에 있다. 층이 다른 게 아니라 이웃이다.

💡 한 줄 정리

WebSocket은 새로운 선이 아니라 원래 양방향이던 TCP 선 위에 다른 규칙을 얹은 것입니다. 그래서 시작은 평범한 HTTP 요청이고, 101 응답을 받는 순간부터 규칙이 갈립니다.

🙋 학생 질문 — "튜터님, 그냥 처음부터 WebSocket으로 연결하면 되지 왜 HTTP로 시작하나요?"

좋은 질문이에요. 답은 가는 길에 다른 것들이 서 있기 때문입니다.

우리 요청은 브라우저에서 서버까지 곧장 가지 않는데, 중간에 회사 방화벽, 프록시, 로드밸런서 같은 것들을 지나가요. 이것들은 대부분 HTTP는 알아듣지만 처음 보는 규칙은 그냥 막아버립니다.

그래서 WebSocket은 일부러 HTTP로 시작하는데, 중간에 선 것들 입장에서는 평범한 요청이 지나가는 걸로 보이고 101을 주고받은 뒤에야 규칙이 바뀝니다.

포트도 같은 이유로 80·443을 그대로 씁니다. 새 포트를 쓰면 방화벽에 막히거든요.


Step 3: "날 통로에 붙어보면 무엇이 없나"

일단 붙여봅시다

이제 브라우저에서 직접 열어볼게요. 라이브러리 없이, 브라우저에 원래 들어 있는 WebSocket으로요.

연습용 서버에 이런 걸 열어뒀는데, 보낸 것을 그대로 돌려주기만 하는 통로예요.

tsx
// apps/web-spa/src/components/RawSocketDemo.tsx
export const RAW_SOCKET_URL = 'ws://localhost:8090/ws-raw';

export function RawSocketDemo({ createSocket }: RawSocketDemoProps) {
  const [log, setLog] = useState<string[]>([]);
  const [open, setOpen] = useState(false);
  const socketRef = useRef<WebSocket | null>(null);

  useEffect(() => {
    const socket = createSocket === undefined ? new WebSocket(RAW_SOCKET_URL) : createSocket();
    socketRef.current = socket;

    socket.onopen = () => setOpen(true);
    socket.onclose = () => setOpen(false);

    // 오는 것은 문자열 하나뿐이다. 무슨 종류인지, 누구에게 온 것인지,
    // 어디에 대한 답인지 알려주는 자리가 없다.
    socket.onmessage = (event: MessageEvent<string>) => {
      setLog((current) => [...current, event.data]);
    };

    return () => socket.close();
  }, [createSocket]);

  // ...
}

new WebSocket(주소) 한 줄이면 열리고, 네 가지 사건을 받아요. 열렸을 때(onopen), 메시지가 왔을 때(onmessage), 닫혔을 때(onclose), 잘못됐을 때(onerror).

fetch와 비교해보면 모양이 완전히 다릅니다. fetch는 부르면 답이 오는 함수였죠. 여기엔 부르는 함수가 없어서, 열어두고 기다립니다.

서버가 먼저 말을 겁니다

우리가 연습용 서버를 이렇게 만들어뒀는데, 연결되고 0.3초 뒤에 서버가 먼저 인사를 보냅니다.

텍스트
 서버가 먼저 보내는 인사

우리는 아무것도 안 물어봤는데, 화면에 글자가 떴어요.

지금까지 우리 앱에서는 한 번도 없던 일입니다. 이제껏 화면에 뜬 모든 것은 우리가 요청해서 받아온 것이었어요. 이건 우리가 요청하지 않았는데 왔습니다.

Step 1의 문제가 여기서 풀립니다. 통로가 열려 있으면 서버는 할 말이 생겼을 때 그냥 말하면 되고, 우리가 물어볼 때까지 기다릴 필요가 없습니다.

그런데 뭔가 빠져 있습니다

좋습니다. 그럼 이걸로 우리 앱을 만들어볼까요?

좋아요 소식을 보내야 하고, 댓글 소식도 보내야 하고, 나중엔 쪽지도 보내야 합니다. 전부 이 통로 하나로요.

서버가 이렇게 보냈다고 해봅시다.

텍스트
 좋아요 1300
 minji: 안녕!

받는 쪽에서 이 둘을 어떻게 구분하죠?

onmessage로 오는 건 문자열 하나가 전부입니다. 이게 좋아요 소식인지 쪽지인지 알려주는 자리가 없어요. 누구에게 온 건지, 어느 게시물에 대한 건지도 없고요.

그럼 우리가 정해야 합니다. 앞에 종류를 붙이든지, JSON으로 감싸든지요.

텍스트
 우리가 규칙을 만든다면...

 { "종류": "좋아요", "게시물": 1, "개수": 1300 }
 { "종류": "쪽지",   "보낸이": "minji", "내용": "안녕!" }

할 수는 있는데, 여기서 멈추고 생각해볼 게 있어요.

  • 여러 종류의 소식을 구분하려면 종류를 적어야 한다
  • 관심 있는 것만 받으려면 "나 이거 받을게요"라고 신청해야 한다
  • 신청을 취소하는 방법도 있어야 한다
  • 서버로 보낼 때도 어디로 가는 건지 적어야 한다
  • 연결이 되자마자 내가 누구인지 알려야 한다

이걸 전부 우리가 만들면, 우리 팀만 아는 규칙이 됩니다. 백엔드와 프론트엔드가 매번 맞춰야 하고, 새 사람이 오면 처음부터 배워야 해요.

이 목록은 실시간 기능을 만드는 사람이면 누구나 필요한 것들입니다.

그래서 이미 만들어져 있어요.

💡 한 줄 정리

날 WebSocket은 통로만 열어줍니다. 그 위로 무엇이 오가는지에 대한 약속은 하나도 없어서, 종류를 구분하는 것부터 구독을 신청하는 것까지 전부 직접 만들어야 합니다.

🙋 학생 질문 — "튜터님, 저희 앱은 좋아요 소식만 보낼 건데도 라이브러리가 필요한가요?"

소식 한 종류만 오간다면 날 WebSocket으로 충분할 수도 있습니다. 실제로 그렇게 만든 서비스도 많아요.

다만 두 가지를 미리 생각해보세요.

첫째, 끊겼을 때 다시 붙는 일을 직접 해야 합니다. 이게 생각보다 까다로워요. Step 8에서 왜 그런지 보게 될 겁니다.

둘째, 지금은 한 종류라도 대개 늘어납니다. 우리도 다음 시간에 쪽지와 알림이 붙어요. 그때 가서 규칙을 만들면, 이미 돌아가는 코드를 고쳐가며 만들게 됩니다.

우리 백엔드가 이미 그 규칙으로 말하고 있다면 선택의 여지가 없습니다. 그쪽 언어를 써야죠. 그게 다음 Step이에요.


Step 4: "STOMP 프레임을 읽는다"

이름부터

STOMP는 Simple Text Oriented Messaging Protocol의 줄임말입니다. 우리말로 옮기면 "간단한, 글자로 된, 메시지 주고받기 규칙" 정도예요.

이름에 들어간 글자로 된(Text Oriented)이 핵심입니다. 사람이 눈으로 읽을 수 있게 생겨서, HTTP처럼 무슨 일이 벌어지는지 들여다보기 쉽습니다.

WebSocket이 통로라면 STOMP는 그 통로 위에서 쓰는 말입니다. Step 2의 계층 그림에서 맨 위에 있던 그거예요.

프레임 한 덩이의 생김새

STOMP가 주고받는 한 덩이를 프레임이라고 부르는데, 이렇게 생겼어요.

텍스트
 SEND                       명령 (무엇을 하려는지)
 destination:/app/ping      헤더 (부가 정보, 여러 줄 가능)
 content-type:application/json
                            빈 줄 (여기까지가 머리)
 {"message":"안녕하세요"}       본문
 ␀                          끝 표시 (NULL 한 바이트)

HTTP와 거의 같은 모양입니다. 첫 줄에 명령, 그다음 헤더들, 빈 줄, 그리고 본문이죠. 다른 건 맨 끝에 눈에 안 보이는 한 바이트가 붙는다는 것뿐이에요. 통로가 계속 열려 있으니 "여기서 한 덩이가 끝났다"를 알려줄 표시가 필요하거든요.

실제로 오가는 것

우리 앱과 서버가 붙을 때 실제로 오간 프레임입니다.

텍스트
 >>> CONNECT                       우리가 보낸다 — "연결합시다"
 login:jaehoon
 accept-version:1.2,1.1,1.0
 heart-beat:10000,10000

 <<< CONNECTED                     서버가 답한다 — "좋습니다"
 user-name:jaehoon
 session:sess-1
 heart-beat:10000,10000
 version:1.2

 >>> SUBSCRIBE                     우리가 보낸다 — "이거 받을게요"
 id:sub-0
 destination:/topic/posts

 >>> SEND                          우리가 보낸다 — "이거 처리해주세요"
 destination:/app/ping

accept-version을 보세요. "저는 1.2, 1.1, 1.0을 할 줄 압니다"라고 알려주고, 서버가 version:1.2로 "그럼 1.2로 합시다"라고 답합니다. 서로 할 줄 아는 것 중에 제일 좋은 걸로 맞추는 거예요.

heart-beat는 Step 8에서 다루는데, id:sub-0은 구독마다 붙는 이름표입니다. 나중에 이 구독만 취소하려면 이름이 있어야 하니까요.

서버가 소식을 보낼 때는 MESSAGE 프레임으로 오는데, 우리가 신청한 구독 이름표를 달아서 오므로 받는 쪽은 "이게 내가 신청한 것 중 어느 것에 대한 소식인지"를 알 수 있습니다.

Step 3에서 우리가 직접 만들려던 것들이 전부 여기 있죠.

목적지 이름에 규칙이 있습니다

/topic/posts, /app/ping — 앞부분이 다릅니다. 이건 우연이 아니라 약속이에요.

텍스트
 /topic/...    여러 사람에게 뿌리는 곳
               구독한 사람 전부가 받는다
               예) /topic/posts — 게시물에 생긴 일

 /queue/...    한 사람에게만 가는 곳
               다음 시간에 쪽지를 여기로 받는다

 /app/...      우리가 서버로 보내는 곳
               서버의 처리 함수가 이걸 받는다

/topic/queue듣는 곳이며 /app보내는 곳입니다. 방향이 반대예요.

이 이름들은 우리가 정한 게 아니라 백엔드 설정에 적혀 있는 것을 그대로 따르는 겁니다. 백엔드 과목에서 서버 쪽을 만들 때 이런 설정을 보게 될 거예요.

텍스트
 브로커가 뿌리는 곳     /topic · /queue
 클라이언트가 보내는 곳  /app
 통로를 여는 주소       /ws

프론트엔드와 백엔드가 이 표만 맞춰두면 서로 다른 언어로 짜여 있어도 말이 통합니다. 그게 규약을 쓰는 이유예요.

💡 한 줄 정리

STOMP는 WebSocket 통로 위에서 쓰는 글자로 된 약속입니다. 명령·헤더·본문으로 된 프레임을 주고받고, 목적지 이름의 앞부분이 그 메시지가 여럿에게 가는지 한 사람에게 가는지 서버로 가는지를 나타냅니다.

🙋 학생 질문 — "튜터님, 검색해보니 SockJS라는 게 같이 나오던데 이것도 써야 하나요?"

안 써도 됩니다. 그리고 지금은 안 쓰는 쪽이 보통이에요.

SockJS는 WebSocket을 못 쓰는 환경을 위한 대비책이었습니다. WebSocket이 막혀 있으면 다른 방법으로 몰래 흉내 내주는 도구였어요. 오래된 브라우저나 까다로운 회사 방화벽을 상대하려고 만들어진 겁니다.

지금은 사정이 달라졌습니다. WebSocket을 못 쓰는 브라우저가 사실상 없고, SockJS 클라이언트는 2022년 이후로 새 판이 안 나오고 있습니다.

다만 이름은 알아두세요. 회사에 들어가면 예전에 만들어진 코드에서 만날 수 있고, 서버 설정에 withSockJS() 같은 게 붙어 있는 걸 보게 될 수도 있습니다. 그때 "이건 옛날 브라우저 대비책이구나" 하고 알아보면 됩니다.


Step 5: "연결은 앱에 하나"

라이브러리를 들입니다

STOMP를 말해주는 라이브러리를 씁니다.

Bash
npm install @stomp/stompjs

이 라이브러리는 런타임 의존성이 0개이며, 딸려 오는 게 없어요. E-5에서 Shadcn을 설치했을 때 여덟 개가 함께 깔렸던 것과 대조적입니다.

연결을 만듭니다

TypeScript
// apps/web-spa/src/realtime/stompClient.ts
import { Client, ReconnectionTimeMode, type StompConfig } from '@stomp/stompjs';

// 백엔드가 열어둔 문. HTTP 가 아니라 ws 로 시작한다.
export const BROKER_URL = 'ws://localhost:8090/ws';

// 우리가 듣는 곳. /topic 은 여럿에게 뿌리는 자리라는 뜻이다.
export const POSTS_TOPIC = '/topic/posts';

export function createStompClient(overrides: Partial<StompConfig> = {}): Client {
  return new Client({
    brokerURL: BROKER_URL,

    // 끊기면 알아서 다시 붙는다. 처음에는 0.5초 뒤에 시도한다.
    reconnectDelay: 500,

    // 서로 살아 있는지 확인하는 신호.
    heartbeatIncoming: 10_000,
    heartbeatOutgoing: 10_000,

    ...overrides,
  });
}

// 앱 전체가 쓰는 연결 하나.
export const stompClient = createStompClient();

⚠️ 재연결 설정 몇 줄은 Step 8에서 채웁니다. 지금은 붙는 것까지만 볼게요.

마지막 줄을 보세요. 연결을 모듈 바깥에서 한 번 만들었습니다.

이 모양, 낯설지 않으시죠. queryClient를 그렇게 만들었고 라우터도 그렇게 만들었는데, 이유가 같습니다. 컴포넌트 안에서 만들면 다시 그려질 때마다 새로 생기거든요.

여기서는 그 결과가 더 심각합니다. 캐시가 새로 생기면 받아둔 값을 잃는 정도지만, 연결이 새로 생기면 서버 쪽 연결 수가 계속 늘어납니다. 화면 하나에 연결 하나씩이면 사용자 한 명이 연결 열 개를 붙잡고 있게 돼요.

어디서 열까요

연결을 만들었으니 이제 열어야 합니다. activate()를 부르면 열려요. 그럼 이걸 어디서 부를까요?

우리는 이런 걸 이어주는 자리를 이미 알고 있습니다. 화면과 바깥 세상을 잇는 곳, useEffect 죠.

tsx
// apps/web-spa/src/realtime/RealtimeBridge.tsx
export function RealtimeBridge({ client = stompClient }: { client?: Client }) {
  const queryClient = useQueryClient();

  useEffect(() => {
    const { setStatus, countAttempt } = useConnectionStore.getState();
    setStatus('connecting');

    client.configure({
      onConnect: () => {
        setStatus('connected');
        // 구독은 다음 Step 에서 채운다
      },
    });

    client.activate();

    return () => {
      void client.deactivate();
      useConnectionStore.getState().reset();
    };
  }, [client, queryClient]);

  return null;
}

그리는 게 없어서 null을 돌려줍니다. 화면에 아무것도 안 그리고 연결만 관리하는 컴포넌트예요.

정리 함수에서 deactivate()를 부르는 걸 잊지 마세요. B-4에서 배운 그대로이므로, 시작한 것은 치웁니다.

이 컴포넌트를 앱에 한 번 그려둡니다.

tsx
// apps/web-spa/src/main.tsx
createRoot(rootElement).render(
  <StrictMode>
    <AppProviders>
      {/* 통로도 라우터·캐시와 같은 자리다. 앱에 한 번만 열고, 어느 화면에 있든 열려 있다.
          캐시에 얹어야 하니 AppProviders 안쪽이어야 한다. */}
      <RealtimeBridge />
      <RouterProvider router={router} />
    </AppProviders>
  </StrictMode>,
);

라우터보다 바깥입니다. 화면을 옮겨 다녀도 연결은 그대로 살아 있어야 하니까요. 밝기와 캐시를 라우터 바깥에 둔 것과 같은 이유예요.

⚠️ 여기서 편집기가 빨간 줄을 그을 수 있습니다

configure를 쓴 자리, 조금 이상하지 않으셨나요? 이 라이브러리는 이렇게 써도 됩니다.

TypeScript
client.onConnect = () => { /* ... */ };

훨씬 간단해 보이는데, 이렇게 쓰면 편집기에 빨간 줄이 그어집니다.

텍스트
 Modifying a variable defined outside a component or hook is not allowed.
 (컴포넌트나 훅 바깥에서 만든 것을 고치면 안 됩니다)

지난 시간에 우리가 배운 그 규칙인데, 그때 확인했죠. 이 규칙을 어긴 파일은 컴파일러가 통째로 건너뛴다는 것을요.

실제로 확인해봤는데, 대입하는 형태로 쓰고 빌드 결과를 열어보면 이 파일에는 자동 최적화가 하나도 안 들어갑니다. configure로 바꾸면 들어가고요.

텍스트
 client.onConnect = ...      건너뜀 (자동 최적화 0)
 client.configure({ ... })   캐시 4칸

라이브러리가 configure를 열어둔 이유가 여기서 만납니다. 대입 대신 이쪽을 쓰세요.

편의는 공짜가 아닙니다

지난 시간에 우리는 회원가입 화면을 떼어내서 처음 내려받는 양을 100 KB 줄였습니다. 500 KB 경고도 그때 사라졌죠.

이번에 라이브러리를 들이면서 그 경고가 돌아왔습니다.

텍스트
 C-8 을 마쳤을 때   499.92 kB
 라이브러리를 들인 뒤  524.64 kB   (+24.72 kB)

25 KB가 첫 화면에 얹혔는데, 지난 시간에 애써 덜어낸 양의 4분의 1이 도로 붙은 거예요.

이게 나쁜 거래인지 아닌지는 재봐야 압니다. 우리가 얻은 건 폴링 요청이 통째로 사라지는 것과, 몇 초 늦던 소식을 즉시 받는 것이에요. 실시간이 꼭 필요한 서비스라면 25 KB는 싼 값입니다. 그렇지 않다면 비싼 값이고요.

지난 시간의 마지막 문장이 여기서도 그대로 통하는데, 재보기 전에는 어느 쪽이 큰지 모릅니다.

💡 한 줄 정리

연결은 캐시·라우터와 같은 자리에 하나만 만들어 앱 전체가 씁니다. 컴포넌트 안에서 만들면 서버 쪽 연결 수가 화면 수만큼 늘어납니다.

🙋 학생 질문 — "튜터님, StrictMode 때문에 개발 중에 연결이 두 번 열리지 않나요?"

정확한 지적이며, B-4에서 본 그 동작입니다. 개발 중에는 effect가 한 번 돌고, 정리되고, 다시 돕니다.

실제로 연결이 열렸다 닫혔다 다시 열리는데, 서버 로그를 보면 그게 보여요.

이게 문제가 안 되는 이유가 있는데, 정리 함수에서 deactivate()를 제대로 부르고 있거든요. 열린 것이 닫히고 새로 열리는 거라, 연결이 두 개 쌓이지는 않습니다.

만약 정리 함수를 안 썼다면 연결이 두 개가 되고, 소식도 두 번씩 받게 됩니다. StrictMode는 그런 실수를 개발 중에 미리 드러내주는 장치이며, 여기서도 제 역할을 하고 있습니다.


Step 6: "남이 누른 좋아요가 내 화면에"

구독하고, 받은 것을 어디에 둘까

이제 통로가 열렸습니다. 소식을 신청할 차례예요.

신청하기 전에 정할 게 있습니다. 받은 소식을 어디에 둘 것인가.

useState에 담으면 그 컴포넌트만 알게 됩니다. 피드 화면도 상세 화면도 같은 게시물을 그리고 있는데, 한 곳만 새 숫자를 알게 되죠.

우리는 이미 이 문제를 겪었습니다. 지난 시간들에서 진실이 두 군데 있으면 반드시 갈린다는 걸 봤고, 답도 정했습니다. 게시물에 대한 진실은 캐시 한 곳에 둔다.

그러니 서버가 보낸 소식도 캐시에 얹으면 됩니다. 화면은 캐시를 보고 있으니 알아서 다시 그려져요.

온 것을 그대로 믿지는 않습니다

받은 메시지의 본문은 글자입니다. 우리 타입으로 바꿔야 해요.

TypeScript
// apps/web-spa/src/lib/postEvents.ts
export interface LikeEvent {
  type: 'like';
  postId: number;
  likeCount: number;
  actor: string;
}

export interface CommentEvent {
  type: 'comment';
  postId: number;
  commentCount: number;
  actor: string;
}

export type PostEvent = LikeEvent | CommentEvent;

type이라는 공통 칸을 두고 그 값으로 갈리는 모양. A-2에서 배운 판별 유니온입니다. 여기서 쓰입니다.

글자를 이 타입으로 좁히는 함수를 만듭니다.

TypeScript
// apps/web-spa/src/lib/postEvents.ts
export function parsePostEvent(raw: string): PostEvent | null {
  let value: unknown;
  try {
    value = JSON.parse(raw);
  } catch {
    return null;
  }

  if (typeof value !== 'object' || value === null) return null;
  const candidate = value as Record<string, unknown>;

  if (typeof candidate.postId !== 'number') return null;

  if (candidate.type === 'like' && typeof candidate.likeCount === 'number') {
    return {
      type: 'like',
      postId: candidate.postId,
      likeCount: candidate.likeCount,
      actor: typeof candidate.actor === 'string' ? candidate.actor : '',
    };
  }
  // ... comment 도 같은 모양으로

  return null;
}

모양이 안 맞으면 null을 돌려줍니다. 서버가 보낸 거라도 우리가 아는 모양인지는 확인해야 해요.

왜 이렇게까지 하냐면, 이건 우리가 요청해서 받은 답이 아니기 때문입니다. useQuery로 받는 값은 우리가 부른 함수의 결과라 모양을 예상할 수 있어요. 통로로 들어오는 건 우리가 부른 적 없는 것이 아무 때나 들어옵니다. 서버가 새 버전으로 배포되면서 우리가 모르는 종류를 보내기 시작할 수도 있고요.

캐시에 얹습니다

TypeScript
// apps/web-spa/src/lib/postEvents.ts
export function applyPostEvent(posts: Post[] | undefined, event: PostEvent): Post[] | undefined {
  if (posts === undefined) return posts;

  return posts.map((post) => {
    if (post.id !== event.postId) return post;

    return event.type === 'like'
      ? { ...post, likeCount: event.likeCount }
      : { ...post, commentCount: event.commentCount };
  });
}

B-2에서 배운 그 모양입니다. 바뀐 것만 새로 만들고 나머지는 있던 걸 그대로 써요.

여기 아주 중요한 한 줄이 빠져 있습니다. 무엇이 안 바뀌는지 보세요.

liked를 안 건드립니다.

남이 누른 좋아요는 숫자를 올리지 내 하트를 빨갛게 만들지 않아요. 숫자는 서버가 세는 것이고, 내 하트가 빨간지는 내 것입니다. 이걸 놓치면 다른 사람이 좋아요를 누를 때마다 내가 누른 것처럼 하트가 켜집니다.

이제 구독을 붙입니다.

tsx
// apps/web-spa/src/realtime/RealtimeBridge.tsx
    client.configure({
      // 붙을 때마다 불린다 — 처음 붙을 때도, 끊겼다 다시 붙을 때도.
      // 그래서 구독을 여기서 한다. 다시 붙으면 구독도 다시 살아난다.
      onConnect: () => {
        setStatus('connected');

        client.subscribe(POSTS_TOPIC, (message) => {
          const event = parsePostEvent(message.body);
          if (event === null) return;

          queryClient.setQueryData<Post[]>(feedKey(), (current) =>
            applyPostEvent(current, event),
          );
        });
      },
    });

왜 다시 물어보지 않나요

지난 시간에 우리는 뭔가 바뀌면 invalidateQueries로 다시 물어봤습니다. 여기서는 setQueryData로 값을 직접 넣고 있어요. 왜 다를까요?

푸시는 새 값을 들고 오기 때문입니다.

무효화는 "받아둔 게 낡았으니 다시 물어봐"라는 뜻이에요. 우리가 좋아요를 눌렀을 때는 서버가 정확히 뭘 어떻게 바꿨는지 몰라서 다시 물어봐야 했습니다.

지금은 서버가 "1번 게시물 좋아요가 1300이 됐어요"라고 값까지 말해줬으니, 알고 있는 걸 또 물어볼 이유가 없습니다.

텍스트
 우리가 눌렀을 때 (지난 시간)
   눌렀다  서버에 보낸다  뭐가 바뀌었는지 모른다  다시 물어본다

 남이 눌렀을 때 (지금)
   서버가 새 값을 들고 온다  그대로 캐시에 넣는다  물어볼 것이 없다

요청이 한 번도 안 나갑니다. Step 1의 폴링과 견주면 차이가 확연하죠.

두 탭으로 확인합니다

여기서 흉내가 아니라 진짜로 확인할 수 있습니다. 브라우저 탭을 두 개 띄우세요.

  1. 연습용 서버를 띄웁니다.
  2. 탭을 두 개 열고 둘 다 우리 앱을 띄웁니다.
  3. 탭 A에서만 로그인하고 첫 게시물 하트를 누릅니다.
  4. 탭 B를 봅니다. 아무것도 안 건드렸는데 숫자가 바뀌어 있습니다.

⚠️ 좋아요는 로그인해야 눌립니다. 로그인 없이 누르면 서버가 거절해서 아무 일도 안 일어나요.

실제로 해보면 이렇게 됩니다.

텍스트
 탭 A (누른 쪽)          탭 B (가만히 있던 쪽)

 하트  ❤️ 빨강            하트  🤍 그대로 비어 있음
 숫자  1241              숫자  1240  1241 로 바뀜
 로그인  jaehoon          로그인  안 함

탭 B를 보세요. 숫자는 따라 올랐는데 하트는 여전히 비어 있습니다.

이게 방금 그 한 줄이 하는 일이에요. 남이 누른 것이지 내가 누른 게 아니니까요. 여기서 liked까지 덮어썼다면 탭 B의 하트도 빨개졌을 거고, 그 사람은 누른 적 없는 게시물에 좋아요가 눌려 있는 걸 보게 됩니다.

탭 B는 이 숫자를 받으려고 요청을 한 번도 안 보냈습니다. Step 1에서 3초마다 물어보던 그 일을 아예 안 해요.

💡 한 줄 정리

서버가 보낸 소식을 캐시에 얹으면 그 캐시를 보고 있는 화면이 알아서 다시 그려집니다. 푸시는 새 값을 들고 오므로 무효화가 아니라 값을 직접 넣습니다.

🙋 학생 질문 — "튜터님, 제가 누른 좋아요도 통로로 돌아오면 두 번 반영되지 않나요?"

날카로운 질문이에요. 실제로 그렇게 됩니다. 우리가 누르면 서버는 모두에게 뿌리고, 그 모두에는 우리도 들어 있거든요.

문제가 안 생기는데, 이유는 우리가 더하기가 아니라 덮어쓰기를 하고 있기 때문이에요.

applyPostEvent를 다시 보세요. likeCount + 1이 아니라 likeCount: event.likeCount입니다. 서버가 "1241입니다"라고 하면 몇 번을 받아도 1241이에요.

만약 더하기로 만들었다면 우리가 누른 것이 1242가 됐을 겁니다. 서버가 센 값을 그대로 받는 모양이라 안전한 거예요.

실무에서는 여기에 더해서 "누가 한 일인지"를 보고 내가 한 것이면 무시하기도 합니다. 그래서 소식에 actor를 실어 보내는 거고요. 지금 우리는 덮어쓰기라 그럴 필요가 없습니다.


Step 7: "연결 상태는 어디에 담나"

사용자에게 알려줘야 합니다

실시간에는 폴링에 없던 문제가 하나 있습니다. 끊길 수 있다는 것.

폴링은 요청 하나가 실패해도 3초 뒤에 다시 물어봅니다. 사용자는 눈치도 못 채요. 통로가 끊기면 소식이 아예 안 오는데, 화면은 멀쩡해 보입니다. 마지막으로 받은 값이 그대로 떠 있으니까요.

이게 위험합니다. 사용자는 지금 보고 있는 숫자가 최신인 줄 압니다. 실은 연결이 끊긴 뒤로 멈춰 있는 값인데요.

그러니 알려줘야 합니다. 그러려면 연결 상태를 어딘가에 담아야 하고요.

세 갈래로 물어봅니다

C-4에서 정한 규칙이 있죠. 무언가를 담을 곳을 정할 때 던지는 세 질문입니다.

서버에서 온 값인가? 아닙니다. 연결 상태는 서버가 준 데이터가 아니라 우리와 서버 사이의 상태예요. 서버는 자기가 우리와 연결됐는지 알지만, 우리에게 "당신은 지금 끊겼습니다"라고 알려줄 수는 없습니다. 끊겼으니까요.

주소에 담아야 하나? 아닙니다. ?connected=true를 공유받은 사람에게 무슨 의미가 있겠어요. 새로고침하면 어차피 처음부터 다시 붙습니다.

한 화면 안에서만 쓰나? 아닙니다. 머리말에 표시할 건데, 그 값을 정하는 곳은 화면 바깥의 연결이에요.

세 질문을 다 지나면 남는 답은 하나입니다. 클라이언트 전역 상태.

TypeScript
// apps/web-spa/src/stores/useConnectionStore.ts
export type ConnectionStatus = 'idle' | 'connecting' | 'connected' | 'offline';

export const useConnectionStore = create<ConnectionState>()((set) => ({
  status: 'idle',
  attempts: 0,

  // 붙는 데 성공하면 시도 횟수는 처음으로 돌린다.
  setStatus: (status) => set(status === 'connected' ? { status, attempts: 0 } : { status }),

  countAttempt: () => set((current) => ({ attempts: current.attempts + 1 })),

  reset: () => set({ status: 'idle', attempts: 0 }),
}));

머리말에 표시합니다

tsx
// apps/web-spa/src/components/ConnectionIndicator.tsx
export function ConnectionIndicator() {
  // 필요한 한 조각만 고른다. status 가 안 바뀌면 이 컴포넌트는 안 다시 그려진다.
  const status = useConnectionStore((state) => state.status);

  return (
    <span data-slot="connection-indicator" className="flex items-center gap-1.5 text-xs text-faint">
      <span aria-hidden="true" className={`size-2 rounded-full ${DOT[status]}`} />
      {LABEL[status]}
    </span>
  );
}

selector로 status 한 조각만 골라 씁니다. attempts가 아무리 바뀌어도 이 표시는 다시 그려지지 않아요. C-4에서 배운 그대로입니다.

이 store는 React 바깥에서도 읽고 쓸 수 있어야 합니다. 상태를 바꾸는 쪽이 컴포넌트가 아니라 연결이거든요. useConnectionStore.getState()로 부르는 게 그래서예요. Context 였다면 이게 안 됩니다.

⚠️ 여기에 role을 달지 않는 이유

이런 표시를 만들 때 role="status"를 붙이고 싶어집니다. 상태를 나타내니까요.

우리 앱에는 이미 그 역할을 쓰는 게 있습니다. 알림 토스트요.

role="status"는 "방금 무슨 일이 있었는지"를 알려주는 자리입니다. 화면을 읽어주는 프로그램이 그 부분이 바뀌면 소리 내어 읽어주는데, 늘 떠 있는 표시까지 같은 역할을 달면 둘 중 무엇을 읽어야 할지 알 수 없게 됩니다.

그래서 이 표시에는 역할을 안 답니다. 늘 떠 있는 정보이지 방금 일어난 사건이 아니니까요.

💡 한 줄 정리

연결 상태는 서버가 준 값도 아니고 주소에 담을 것도 아니어서 클라이언트 전역에 담습니다. 상태를 바꾸는 쪽이 컴포넌트가 아니라 연결이라, React 바깥에서 쓸 수 있는 store가 맞습니다.

🙋 학생 질문 — "튜터님, 연결이 끊기면 화면을 아예 막아야 하지 않나요?"

서비스 성격에 따라 다릅니다.

경매나 주식 호가처럼 틀린 값을 보여주는 게 위험한 화면이라면 막는 쪽이 맞아요. 값을 흐리게 하거나 "연결이 끊겨 값이 멈췄습니다"를 크게 띄웁니다.

반면 우리 인스타그램은 좋아요 숫자가 몇 초 낡아도 아무도 안 다칩니다. 이럴 때 화면을 막으면 오히려 나빠요. 사진도 못 보고 댓글도 못 읽게 되니까요.

우리가 작은 점 하나로 만든 건 그래서입니다. 알고 싶은 사람은 알 수 있고, 관심 없는 사람은 하던 걸 계속할 수 있게요.

다음 시간에 "끊긴 동안 놓친 것"을 다룰 때 이 이야기를 한 번 더 하게 됩니다.


Step 8: "끊어지면 다시 붙는다"

직접 끊어봅니다

가장 확실한 확인 방법이 있어요. 서버를 죽여보는 겁니다.

앱을 띄워둔 채로 연습용 서버를 끄고, 머리말을 봅니다.

텍스트
 서버가 살아 있을 때    ● 실시간      (초록)
 서버를 껐을 때        ● 연결 끊김   (빨강)

바뀝니다. 우리가 아무것도 안 눌렀는데요. 통로가 끊기면 브라우저가 알려주고, 그걸 받아서 상태를 바꾼 거예요.

이제 서버를 다시 켜보세요. 잠깐 기다리면 이렇게 됩니다.

텍스트
 서버를 다시 켰을 때    ● 실시간      (초록)

새로고침도 안 했는데 돌아왔습니다. 라이브러리가 알아서 다시 붙은 거예요.

서버 쪽 기록을 보면 더 중요한 게 보입니다.

텍스트
 [stomp] guest 님이 연결했습니다 (sess-1) — 전체 1
 [stomp] 구독 /topic/posts (sess-1)

구독까지 다시 신청했습니다.

이게 왜 저절로 될까요? Step 6에서 구독을 어디에 뒀는지 떠올려보세요. onConnect 안에 넣었죠. onConnect는 붙을 때마다 불립니다. 처음 붙을 때도, 다시 붙을 때도요. 그래서 다시 붙으면 구독도 다시 살아납니다.

만약 구독을 activate() 바로 뒤에 한 줄로 적어뒀다면 어땠을까요. 처음 한 번만 신청되고, 다시 붙은 뒤에는 통로만 열려 있고 소식은 안 오는 상태가 됩니다. 연결 표시는 초록인데 아무것도 안 오는, 찾기 아주 어려운 문제가 돼요.

얼마나 자주 두드릴까

이제 한 가지를 더 정해야 합니다. 서버가 죽어 있을 때 우리는 얼마 만에 다시 시도할까요?

0.5초마다 두드린다고 해봅시다. 사용자 입장에서는 좋아요. 서버가 살아나면 0.5초 안에 붙으니까요.

서버가 잠깐 죽은 게 아니라 10분 동안 죽어 있다면 어떻게 될까요.

텍스트
 사용자 1000명 · 0.5초마다 재시도 · 10분

 1000명 × 초당 2번 × 600초 = 1,200,000번

서버가 죽어 있는 동안 120만 번을 두드리는데, 더 나쁜 건 서버가 살아나는 바로 그 순간이라는 겁니다. 죽어 있는 동안 기다리던 천 명이 0.5초 안에 전부 몰려들어서, 겨우 일어난 서버가 그 첫 순간에 다시 쓰러질 수 있어요.

그래서 실패할 때마다 조금씩 더 기다리는 방법을 씁니다. 처음엔 0.5초, 그다음엔 1초, 2초, 4초로요. 이걸 지수 백오프라고 부릅니다.

텍스트
 고정 간격 (0.5초씩)
                      계속 같은 힘으로 두드린다

 지수 백오프
                                 점점 뜸해진다
  0.5 1   2       4               8

서버가 금방 살아나면 빨리 붙고, 오래 걸리면 알아서 뜸해집니다.

설정 세 줄

직접 만들 필요는 없습니다. 라이브러리에 들어 있어요.

TypeScript
// apps/web-spa/src/realtime/stompClient.ts
    // 끊기면 알아서 다시 붙는다. 처음에는 0.5초 뒤에 시도한다.
    reconnectDelay: 500,

    // 실패할 때마다 기다리는 시간을 두 배로 늘린다.
    // 기본값은 LINEAR — 그러면 모두가 같은 간격으로 서버 문을 두드린다.
    reconnectTimeMode: ReconnectionTimeMode.EXPONENTIAL,

    // 아무리 늘어나도 여기까지만. 안 두면 15분까지 벌어진다(라이브러리 기본값).
    maxReconnectDelay: 30_000,

기본값이 LINEAR라는 데 주의하세요. 아무것도 안 적으면 5초 간격으로 계속 두드립니다. 지수 백오프를 쓰려면 적어줘야 해요.

maxReconnectDelay도 중요합니다. 두 배씩 늘리기만 하면 끝없이 벌어지거든요. 상한을 안 두면 라이브러리 기본값인 15분까지 갑니다. 사용자가 점심 먹고 돌아왔는데 15분을 더 기다려야 하는 상황이 생겨요.

실제로 재보면 이렇게 늘어납니다.

텍스트
 서버를 세 번 거절시키고 잰 간격

 552ms  1002ms  2002ms  4003ms

정확히 두 배씩이죠.

조용히 끊기는 경우

마지막으로 하나만 더요. Step 4에서 넘어간 heart-beat 이야기입니다.

연결이 끊기는 방법에는 두 가지가 있습니다. 서버가 "저 갑니다" 하고 닫는 경우와, 아무 말 없이 사라지는 경우요.

앞의 경우는 브라우저가 바로 알아챕니다. 우리가 방금 서버를 껐을 때가 그랬어요.

뒤의 경우가 고약합니다. 지하철에 들어가서 신호가 끊기거나, 중간의 장비가 조용히 연결을 버리면 양쪽 다 아무것도 못 받은 채로 "연결돼 있다" 고 믿습니다. 소식은 안 오는데 표시는 초록인 상태가 되는 거예요.

서로 주기적으로 신호를 보냅니다.

TypeScript
    // 서로 살아 있는지 확인하는 신호. 선이 조용히 끊긴 것을 알아채는 방법이다.
    heartbeatIncoming: 10_000,
    heartbeatOutgoing: 10_000,

10초마다 한 번씩 "저 살아 있어요"를 주고받는 겁니다. 올 때가 됐는데 안 오면 끊긴 걸로 봐요. Step 4에서 본 heart-beat:10000,10000 헤더가 이걸 서로 맞추는 자리였습니다.

💡 한 줄 정리

다시 붙는 일은 라이브러리가 하지만, 얼마 만에 다시 시도할지는 우리가 정합니다. 실패할수록 뜸하게 두드려야 서버가 살아나는 순간에 다 같이 몰려들어 다시 쓰러뜨리지 않습니다.

🙋 학생 질문 — "튜터님, 그럼 실무에서도 이 설정 그대로 쓰면 되나요?"

시작점으로는 괜찮습니다. 다만 실무에서 하나 더 얹는 게 있어요. 흩뿌리기입니다.

지수 백오프를 써도 문제가 남습니다. 천 명이 동시에 끊겼다면, 그 천 명의 대기 시간이 전부 똑같거든요. 0.5초에 다 같이, 1초에 다 같이, 2초에 다 같이 두드립니다. 뜸해지긴 하는데 여전히 뭉쳐서 와요.

기다리는 시간에 무작위 값을 조금 섞습니다. 2초라면 1.5초에서 2.5초 사이 아무 값으로 주면, 몰려드는 게 흩어져요.

이 라이브러리에는 그 기능이 없어서 필요하면 직접 넣어야 합니다. 다만 사용자 수가 그 정도로 많지 않다면 지금 설정으로 충분해요. 필요해지기 전에 미리 넣을 것은 아닙니다. 지난 시간에 배운 순서대로, 재보고 나서요.


마무리

오늘 한 일을 한 줄로 줄이면 이렇습니다. 말을 걸 수 있는 쪽을 하나 더 늘렸습니다.

지금까지 우리 앱에서 먼저 말을 거는 쪽은 언제나 우리였어요. 이제 서버도 말을 걸 수 있습니다.

오늘 배운 핵심 세 가지

💡 하나 — 값을 잘못 고른 것과 방식이 틀린 것은 다릅니다. 폴링의 간격을 아무리 잘 골라도 늦는 시간과 요청 수 중 하나는 나빠집니다. 둘 다 만족하는 값이 없다면 그건 값의 문제가 아니에요.

💡 둘 — 통로는 통로일 뿐입니다. WebSocket을 열면 글자가 오갈 수 있게 됩니다. 그 글자가 무슨 뜻인지는 아무도 안 정해줘요. STOMP는 그 뜻을 정해둔 약속이고, 그래서 프론트엔드와 백엔드가 서로 다른 언어로 짜여 있어도 말이 통합니다.

💡 셋 — 진실이 한 곳에 있으면 새 통로가 생겨도 화면은 안 고쳐도 됩니다. 오늘 우리는 서버가 보낸 소식을 캐시에 얹기만 했습니다. PostCardFeedSection도 한 줄 안 고쳤어요. 지난 시간에 진실을 캐시 한 곳으로 모아둔 값을 오늘 받았습니다.

오늘 닫은 것

지난 시간에 넘긴 것 닫힌 곳
우리는 우리가 누를 때만 화면이 바뀐다 ✅ Step 6
물어봐야만 아는 방식으로는 못 푸는 문제 ✅ Step 1
통로 자체를 먼저 이해하고 간다 ✅ Step 2·4
규칙을 지키는 것과 빠른 것은 하나다 ✅ Step 5

다음 시간 예고

오늘 우리가 연 통로로는 지금 한 가지 소식만 다닙니다. 게시물에 생긴 일이요.

통로를 열어놓고 보니 하고 싶은 게 더 생깁니다.

누가 나를 팔로우하면 알고 싶고, 내 게시물에 댓글이 달리면 알림을 받고 싶어요. 그리고 쪽지도요.

여기서 새로운 문제가 하나 생깁니다. 오늘 쓴 /topic은 구독한 사람 전부에게 가는데, 쪽지는 그러면 안 되잖아요. 나에게 온 쪽지가 모두에게 뿌려지면 큰일입니다.

Step 4에서 이름만 보고 넘어간 /queue가 그 자리예요. 다음 시간에는 그것으로 1:1 쪽지를 만들고, 알림을 붙입니다.

하나 더 있습니다. 오늘 우리는 끊겼다 다시 붙는 걸 확인했는데, 끊겨 있는 동안 일어난 일은 어떻게 될까요? 그때 온 소식은 우리에게 안 옵니다. 다시 붙어도 지나간 건 지나간 거예요.

실시간을 붙였다고 다시 물어보는 방식을 완전히 버릴 수 없는 이유가 거기 있습니다. 다음 시간에 그 구멍을 메웁니다.


과제

[구현] 댓글 수도 실시간으로

오늘 우리는 좋아요 소식만 화면에 반영했습니다. 그런데 서버는 댓글이 달렸을 때도 소식을 보내고 있어요.

applyPostEvent는 이미 댓글 소식을 처리할 줄 압니다. 확인해보세요.

  • 탭을 두 개 띄우고 탭 A에서 댓글을 답니다.
  • 탭 B의 "댓글 32개 모두 보기" 숫자가 바뀌는지 봅니다.
  • 안 바뀐다면 어디까지 왔는지 짚어보세요. 서버가 안 보내는 건지, 우리가 안 받는 건지, 받았는데 화면이 안 그리는 건지요.
  • ⚠️ 댓글도 로그인해야 달립니다.

[구현] 연결이 끊긴 걸 더 잘 알리기

지금은 작은 점 하나로만 알립니다. 좀 더 친절하게 만들어보세요.

  • 끊긴 지 10초가 지나면 "연결이 끊겨 새 소식을 못 받고 있어요"를 눈에 띄게 띄웁니다.
  • 다시 붙으면 그 안내를 치웁니다.
  • store에 있는 attempts를 써서 "3번째 다시 시도 중"처럼 보여줘도 좋습니다.
  • 다 만들고 나서, 이게 우리 서비스에 과한지 아닌지를 한 문장으로 적어주세요. Step 7의 🙋 질문을 참고하세요.

[탐구] 프레임을 눈으로 보기

Client를 만들 때 debug 옵션을 주면 오가는 프레임이 전부 찍힙니다.

TypeScript
debug: (line) => console.log(line),
  • 붙였다 떼면서 어떤 프레임이 오가는지 보세요.
  • 브라우저 개발자 도구의 네트워크 탭에서 ws로 걸러보면 프레임을 표 형태로도 볼 수 있습니다. 둘을 비교해보세요.
  • 10초쯤 가만히 두면 뭔가 계속 오갑니다. 무엇인지, 왜 그런지 적어주세요.
  • ⚠️ 확인이 끝나면 debug를 떼세요. 배포본에 남으면 사용자 콘솔에 전부 찍힙니다.

[탐구] 두 방식의 요청 수를 세어보기

Step 1의 폴링과 오늘 만든 방식을 같은 조건에서 견줘보세요.

  • 개발자 도구 네트워크 탭을 열고 1분 동안 아무것도 안 하며 지켜봅니다.
  • refetchInterval: 3000을 켰을 때 요청이 몇 번 나가는지 셉니다.
  • 껐을 때 몇 번 나가는지 셉니다.
  • 두 숫자를 적고, 그 차이가 사용자 천 명이면 얼마가 되는지 계산해보세요.

생각해볼 주제

1. 실시간인데 왜 아직도 REST를 쓰고 있을까

오늘 좋아요를 누르는 길을 다시 보세요. 우리는 여전히 useMutation으로 서버에 요청을 보냅니다. 통로가 열려 있는데도요.

/app/like 같은 목적지로 통로를 통해 보낼 수도 있었을 텐데, 그러지 않았어요.

무엇을 통로로 보내고 무엇을 요청으로 보내야 할까요? 판단 기준을 세워보세요. 힌트를 하나 드리면, 요청에는 있지만 통로에는 없는 것이 하나 있습니다.

2. 연결 하나에 사용자 하나면, 서버는 몇 명을 감당할까

HTTP 요청은 짧습니다. 왔다 가면 끝이에요. 그래서 서버는 순간순간 처리 중인 요청만 붙들고 있으면 됩니다.

WebSocket은 사용자가 앱을 켜두는 내내 자리를 차지합니다. 사용자 만 명이면 연결 만 개가 계속 열려 있어요.

이게 서버에 어떤 부담이 될까요? 그리고 서버를 한 대 더 늘리면 이 문제가 풀릴까요, 아니면 새로운 문제가 생길까요? 사용자 A는 1번 서버에, 사용자 B는 2번 서버에 붙어 있다고 생각해보세요.

3. 우리가 다시 붙는 동안 사용자는 무엇을 보고 있었나

Step 8에서 우리는 지수 백오프를 넣었습니다. 서버를 보호하는 좋은 선택이었는데, 그 선택의 값을 치르는 사람이 있습니다. 8초를 기다리는 중이라면, 서버가 이미 살아났어도 사용자는 8초를 더 기다려요.

서버를 보호하는 것과 사용자를 기다리게 하는 것 사이에서 어디에 선을 그어야 할까요? 그리고 이 상충을 줄일 방법은 없을까요? 사용자가 다시 화면을 보러 왔다는 신호를 우리가 알 수 있다면 무엇을 할 수 있을지 생각해보세요.

✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 댓글 수도 실시간으로 (30점)

채점 포인트

항목 배점 설명
두 탭으로 실제 확인 10 탭 A에서 댓글을 달고 탭 B의 숫자가 바뀌는 것을 눈으로 확인했는가
고칠 게 없다는 것을 알아냄 10 코드를 고치기 전에 먼저 확인해보고, 이미 동작한다는 것을 발견했는가
왜 이미 되는지 설명 10 소식의 종류가 두 가지이고 둘 다 처리하도록 만들어져 있음을 짚었는가

풀이 예시

답은 "고칠 게 없다"입니다.

두 탭을 띄우고 탭 A에서 댓글을 달면, 탭 B의 "댓글 32개 모두 보기"가 33개로 바뀝니다. 한 줄도 안 고쳐도요.

왜 그런지 코드를 따라가보면 세 군데가 이미 준비돼 있습니다.

첫째, 서버가 댓글이 달렸을 때도 소식을 보냅니다. 좋아요 때와 같은 곳(/topic/posts)으로요.

둘째, 우리가 만든 타입이 두 종류를 다 알고 있습니다.

TypeScript
// apps/web-spa/src/lib/postEvents.ts
export type PostEvent = LikeEvent | CommentEvent;

셋째, 캐시에 얹는 함수가 종류에 따라 갈립니다.

TypeScript
// apps/web-spa/src/lib/postEvents.ts
    return event.type === 'like'
      ? { ...post, likeCount: event.likeCount }
      : { ...post, commentCount: event.commentCount };

이 분기 덕분에 좋아요 소식이 오면 좋아요 숫자만, 댓글 소식이 오면 댓글 숫자만 갈립니다.

만약 안 바뀌었다면 이 순서로 짚어보세요. 어디서 끊겼는지가 금방 나옵니다.

확인할 것 방법 여기서 끊겼다면
서버가 보내나 서버를 띄운 창의 기록을 본다 서버 쪽 문제
우리가 받나 debug를 켜고 MESSAGE 프레임이 오는지 본다 구독이 안 걸렸다
캐시에 들어가나 개발자 도구의 캐시 창에서 숫자를 본다 좁히기에서 걸러졌다
화면이 그리나 위 셋이 다 되는데 화면만 그대로 화면이 캐시를 안 보고 있다

자주 나오는 실수

확인해보기 전에 코드부터 고칩니다. CommentEvent를 처리하는 분기가 이미 있는데 또 만드는 경우가 많아요. 지난 시간에 배운 순서 그대로입니다. 재보고 나서 고칩니다.

로그인을 안 하고 댓글을 답니다. 서버가 거절해서 아무 일도 안 일어나는데, 화면에는 별 반응이 없어서 "실시간이 안 되네"로 오해하기 쉬워요.

💡 튜터의 한마디

이 과제의 진짜 목적은 댓글이 아니라 확장의 값을 느껴보는 겁니다.

Step 6에서 우리는 판별 유니온을 쓰고, 좁히는 함수를 따로 두고, 캐시에 얹는 함수를 따로 뒀어요. 좋아요 하나만 처리할 거였으면 과한 구조였습니다. 그냥 setQueryData 안에서 숫자를 바꿔도 됐죠.

그 구조의 값을 오늘 받았습니다. 두 번째 종류가 늘었는데 고칠 게 없었어요. 세 번째, 네 번째가 늘어도 PostEvent에 한 줄, 분기에 한 줄이면 됩니다.

🎯 [과제 2 예시답안] 연결이 끊긴 걸 더 잘 알리기 (30점)

채점 포인트

항목 배점 설명
10초 뒤에 뜬다 8 끊기자마자가 아니라 정해둔 시간이 지난 뒤에 뜨는가
다시 붙으면 치운다 7 연결이 돌아왔을 때 안내가 사라지는가
예약을 취소한다 8 10초가 되기 전에 다시 붙으면 아예 안 뜨는가 (정리 함수)
과한지 아닌지 판단 7 우리 서비스에 이 안내가 맞는지를 한 문장으로 적었는가

풀이 예시

tsx
// apps/web-spa/src/components/OfflineNotice.tsx
export function OfflineNotice({ afterMs = 10_000 }: { afterMs?: number }) {
  const status = useConnectionStore((state) => state.status);
  const attempts = useConnectionStore((state) => state.attempts);
  const [waited, setWaited] = useState(false);

  useEffect(() => {
    // 끊긴 상태가 아니면 셀 것이 없다.
    if (status !== 'offline') return;

    const timer = setTimeout(() => setWaited(true), afterMs);

    // 다시 붙거나 화면에서 사라지면 예약해둔 것을 취소하고 처음으로 돌린다.
    return () => {
      clearTimeout(timer);
      setWaited(false);
    };
  }, [status, afterMs]);

  // 안내를 띄울지는 state 로 따로 들고 있지 않는다.
  // "끊겼고" + "기다릴 만큼 기다렸다" 두 가지에서 그때그때 계산한다.
  const showNotice = status === 'offline' && waited;

  if (!showNotice) return null;

  return (
    <p role="alert" className="rounded-md bg-rose-500/10 px-3 py-2 text-sm text-rose-600">
      연결이 끊겨 새 소식을 못 받고 있어요. 다시 연결하는 중입니다
      {attempts > 0 ? ` (${attempts}번째 시도)` : ''}.
    </p>
  );
}

핵심이 두 개 있습니다.

하나는 정리 함수입니다. B-4에서 배운 그대로예요. clearTimeout이 없으면 잠깐 끊겼다가 2초 만에 다시 붙었는데도 예약해둔 것이 살아 있어서, 이미 멀쩡히 연결된 상태에서 10초 뒤에 "연결이 끊겼어요"가 뜹니다. 사용자는 영문을 모르죠.

다른 하나는 띄울지 말지를 state로 안 들고 있다는 것입니다. 이걸 showNotice라는 state로 만들고 싶어지는데, 그러면 다시 붙었을 때 그것도 꺼주는 코드를 또 써야 하고, 그 "꺼주는 코드"를 effect 안에 바로 쓰면 편집기가 막습니다.

텍스트
 Avoid calling setState() directly within an effect
 (effect 안에서 곧바로 setState 를 부르지 마세요)

두 가지를 조합해서 그때그때 계산하면 이 문제가 통째로 사라집니다. 끊긴 상태가 아니면 showNotice는 저절로 거짓이 되니까요.

여기서 role="alert"를 쓴 것도 눈여겨보세요. Step 7에서 늘 떠 있는 표시에는 역할을 안 달았는데, 이건 답니다. 이건 방금 일어난 일을 알리는 것이라서요. 성격이 다릅니다.

자주 나오는 실수

setTimeout만 걸고 정리를 안 합니다. 위에 적은 그 문제가 생기고, 짧게 끊겼다 붙기를 반복하면 예약이 쌓여서 안내가 여러 번 떴다 사라졌다 해요.

끊기자마자 띄웁니다. 만들기는 제일 쉬운데, 지하철에서 잠깐 신호가 흔들릴 때마다 큰 안내가 번쩍여서 훨씬 시끄러운 앱이 됩니다.

판단은 이렇게 적으면 충분합니다

우리 인스타그램에는 이 정도가 적당하다. 좋아요 숫자가 몇 초 낡아도 아무도 안 다치므로 화면을 막을 이유는 없고, 다만 "지금 값이 멈춰 있다"는 사실은 알려주는 편이 낫다. 대신 잠깐 끊긴 것까지 알릴 필요는 없어서 10초를 기다린다.

💡 튜터의 한마디

이 과제에는 정답 코드보다 중요한 게 있어요. "만들 수 있다"와 "만들어야 한다"는 다르다는 것입니다.

10초라는 숫자에도 근거가 있어야 합니다. 왜 3초가 아니고 10초일까요? 우리 재연결은 0.5초에서 시작해 두 배씩 늘어나니까, 서버가 잠깐 흔들린 거라면 대개 몇 초 안에 붙습니다. 10초를 넘겼다는 건 잠깐이 아니라는 뜻이에요.

이런 식으로 앞에서 정한 값과 이어서 생각하는 습관을 들이면 숫자를 감으로 고르는 일이 줄어듭니다.

🎯 [과제 3 예시답안] 프레임을 눈으로 보기 (20점)

채점 포인트

항목 배점 설명
프레임 흐름을 적음 7 CONNECT → CONNECTED → SUBSCRIBE 순서를 확인했는가
가만히 둘 때 오가는 것의 정체 8 하트비트라는 것과 왜 필요한지를 짚었는가
두 도구를 비교 5 개발자 도구와 debug 출력의 차이를 적었는가

풀이 예시

debug를 켜면 이런 순서로 찍힙니다.

텍스트
 Opening Web Socket...
 Web Socket Opened...
 >>> CONNECT
 login:jaehoon
 accept-version:1.2,1.1,1.0
 heart-beat:10000,10000

 <<< CONNECTED
 version:1.2
 heart-beat:10000,10000

 >>> SUBSCRIBE
 id:sub-0
 destination:/topic/posts

가만히 두면 오가는 것의 정체는 하트비트입니다. 10초마다 양쪽이 한 번씩 신호를 보내요. 내용은 사실상 없습니다. 줄바꿈 한 글자예요.

왜 필요하냐면, 연결이 조용히 끊기는 경우가 있기 때문입니다. 서버가 정상적으로 닫으면 브라우저가 바로 알지만, 중간 장비가 연결을 조용히 버리거나 신호가 끊기면 양쪽 다 모른 채로 "연결돼 있다" 고 믿습니다.

그때 이 신호가 답을 줍니다. 올 때가 됐는데 안 오면 끊긴 걸로 보는 거예요.

두 도구의 차이:

debug 출력 개발자 도구 네트워크 탭
보이는 것 STOMP 프레임 (해석된 것) WebSocket 프레임 (날것)
방향 표시 >>> <<< 화살표와 색
하트비트 안 보임 보임 (빈 프레임)
남는가 콘솔을 지우면 사라짐 기록이 남음

debug는 라이브러리가 해석한 뒤라 읽기 편하고, 네트워크 탭은 실제로 오간 것 전부를 보여줍니다. 하트비트는 라이브러리가 알아서 처리해서 debug에는 잘 안 찍혀요. 그래서 "가만히 뒀는데 뭐가 오간다"는 네트워크 탭에서 더 잘 보입니다.

자주 나오는 실수

debug를 켜둔 채로 둡니다. 배포본에서 사용자 콘솔에 프레임이 전부 찍힙니다. 다 보고 나면 떼세요. 남겨야 한다면 지난 시간에 개발자 도구를 감쌌던 것처럼 개발 중에만 켜지게 감싸면 됩니다.

💡 튜터의 한마디

프레임을 직접 보는 습관은 실무에서 아주 큽니다.

실시간 기능이 안 될 때 물어볼 것은 딱 세 가지예요. 붙었나? 구독했나? 오고 있나? 프레임을 보면 이 세 질문에 1분 안에 답이 나옵니다.

CONNECTED가 없으면 붙는 데서 막힌 거고, SUBSCRIBE가 있는데 MESSAGE가 안 오면 서버가 안 보내는 거고, MESSAGE가 오는데 화면이 그대로면 우리 코드 문제입니다.

이걸 안 보고 추측으로 고치기 시작하면 하루가 갑니다.

🎯 [과제 4 예시답안] 두 방식의 요청 수를 세어보기 (20점)

채점 포인트

항목 배점 설명
두 숫자를 직접 셈 8 폴링을 켰을 때와 껐을 때를 각각 1분간 확인했는가
사용자 천 명으로 환산 6 곱셈을 해보고 규모를 실감했는가
통로도 공짜가 아님을 짚음 6 요청 0번이라고 비용이 0 은 아니라는 것을 알아챘는가

풀이 예시

1분 동안 아무것도 안 하고 지켜보면 이렇게 나옵니다.

텍스트
 refetchInterval: 3000    1분에 20번
 통로로 받기              1분에 0번

사용자 천 명으로 환산하면 1분에 2만 번과 0번입니다. 하루로 늘리면 2880만 번과 0번이고요.

여기까지가 절반입니다. 나머지 절반은 이겁니다.

요청이 0번이라고 비용이 0은 아닙니다.

폴링은 요청이 왔다 가면 끝이라 서버가 그 순간에만 일합니다. 통로는 사용자가 앱을 켜두는 내내 서버 쪽에 자리를 차지해요. 사용자 천 명이면 연결 천 개가 계속 열려 있습니다.

그러니 정확한 비교는 이렇게 됩니다.

폴링 통로
요청 수 아주 많다 처음 한 번
서버가 붙들고 있는 것 그 순간 처리 중인 것만 사용자 수만큼의 연결
늦는 시간 간격의 절반쯤 거의 없다
첫 화면에 얹히는 양 0 25 KB

어느 쪽이 싼지는 서비스마다 다릅니다. 사용자가 잠깐 들렀다 가는 서비스라면 폴링이 쌀 수 있고, 오래 켜두는 서비스라면 통로가 쌉니다.

자주 나오는 실수

요청 수만 보고 "통로가 완승"이라고 적습니다. 재는 자리를 하나만 본 겁니다. 지난 시간에 번들 크기를 두 숫자로 봐야 했던 것과 같아요. 하나만 보면 안 보이는 게 있습니다.

💡 튜터의 한마디

이 과제를 넣은 이유는 비교의 축을 스스로 늘려보라는 거였어요.

"요청이 줄었다"는 우리가 오늘 얻은 것이고 눈에 잘 띄는데, 무언가를 얻었을 때는 어디서 값을 치렀는지를 함께 찾아봐야 합니다. 오늘의 값은 두 군데예요. 첫 화면에 얹힌 25 KB, 그리고 서버가 계속 붙들고 있어야 하는 연결.

이 습관이 있으면 기술 선택을 설명할 수 있게 되는데, "실시간이 좋아서 썼어요"와 "요청을 이만큼 줄이는 대신 이런 값을 치르기로 했어요"는 완전히 다른 답이에요.


🤔 [생각해볼 주제 1] 실시간인데 왜 아직도 REST를 쓰고 있을까

문제 상황 요약

통로가 열려 있는데도 좋아요는 여전히 useMutation으로 보냅니다. /app/like 같은 목적지로 통로를 통해 보낼 수도 있었는데 그러지 않았어요. 무엇을 어느 길로 보내야 할까요?

튜터의 가이드 및 해설

힌트에서 말한 "요청에는 있지만 통로에는 없는 것"은 응답입니다.

HTTP 요청은 짝이 있어요. 보내면 답이 옵니다. 그 답에 상태 코드가 실려 있고, 우리는 그걸 보고 성공인지 실패인지 압니다. 지난 시간에 만든 되돌리기가 통째로 이것 위에 서 있어요. 서버가 403을 주니까 화면을 되돌린 겁니다.

STOMP의 SEND에는 짝이 없습니다. 보내고 끝이에요. 서버가 받았는지, 처리했는지, 거절했는지 알 수 없습니다.

그럼 답을 받으려면 어떻게 할까요? 서버가 결과를 다시 우리에게 보내주고, 우리는 그걸 기다려야 하는데, 그 답이 내가 보낸 그것에 대한 답인지는 어떻게 알까요? 우리가 보낼 때 표를 하나 붙이고 서버가 그 표를 그대로 실어 보내야 하며, 답이 안 오면 언제까지 기다릴지도 정해야 하고요.

이걸 다 만들면 무엇이 될까요. 요청과 응답을 다시 만든 것이 됩니다. 이미 있는 것을 어렵게 다시 만든 셈이죠.

그래서 실무의 기준은 대체로 이렇게 정리됩니다.

어느 길로
내가 무언가를 바꾼다 요청 성공·실패를 알아야 화면을 되돌릴 수 있다
남에게 생긴 일을 안다 통로 답이 필요 없다. 알기만 하면 된다
순서가 중요한 것 요청 응답을 받고 다음을 하면 순서가 지켜진다
늦어도 되는 알림 통로 놓쳐도 큰일 안 난다

한 줄로 줄이면 이렇습니다. 답이 필요하면 요청, 소식이면 통로.

채팅은 예외처럼 보이는데, 실은 예외가 아닙니다. 채팅 앱들도 보낸 메시지 옆에 시계 표시를 두고 서버가 받았다는 확인이 오면 체크로 바꿔요. 그 확인이 곧 응답을 다시 만든 것입니다.

🎯 면접관을 홀리는 핵심 멘트

"WebSocket을 붙였다고 REST를 걷어내지는 않았습니다. 쓰기는 실패를 알아야 롤백을 할 수 있어서 응답이 있는 REST로 두고, 남이 만든 변경을 받는 쪽만 구독으로 처리했습니다. STOMP의 SEND는 응답이 없어서, 거기에 결과를 실으려면 상관관계 ID와 타임아웃을 직접 만들어야 하는데 그건 요청·응답을 다시 구현하는 일이거든요."

🤔 [생각해볼 주제 2] 연결 하나에 사용자 하나면, 서버는 몇 명을 감당할까

문제 상황 요약

HTTP 요청은 왔다 가면 끝이지만 WebSocket은 앱을 켜두는 내내 자리를 차지합니다. 사용자 만 명이면 연결 만 개가 계속 열려 있고, 서버를 한 대 더 늘리면 새로운 문제가 생깁니다.

튜터의 가이드 및 해설

첫 번째 질문 — 무엇이 부담이 되나.

연결 하나마다 서버는 몇 가지를 붙들고 있어야 합니다. 열린 소켓 하나, 주고받을 데이터를 잠깐 담아둘 자리, 그리고 이 사람이 무엇을 구독했는지 같은 기억이요.

하나하나는 작습니다. 문제는 이게 사용자 수만큼 쌓인다는 것이고, 요청과 달리 줄어들지 않는다는 겁니다. HTTP 라면 아무도 요청을 안 보내는 순간 서버는 아무것도 안 붙들고 있어요. 연결은 사용자가 앱을 닫을 때까지 그대로입니다.

그래서 실시간 서버는 "초당 몇 건"이 아니라 "동시에 몇 개"로 이야기합니다. 재는 단위가 다릅니다.

두 번째 질문 — 서버를 늘리면 풀리나.

여기가 진짜입니다. 늘리면 새 문제가 생깁니다.

사용자 A는 1번 서버에, B는 2번 서버에 붙어 있다고 해봅시다. A가 좋아요를 눌렀어요. 그 요청은 1번 서버가 받는데, 1번 서버가 "모두에게 뿌려"라고 하면 그 모두는 1번 서버에 붙어 있는 사람들뿐입니다.

B는 못 받습니다. 2번 서버는 그런 일이 있었는지도 몰라요.

텍스트
 A ── 1번 서버 ─── 소식이 여기까지만 간다
                     │
                     ╳  (서로 모른다)
                     │
 B ── 2번 서버 ─── B 는 못 받는다

서버를 늘리면 처리량은 늘어나는데 소식이 서버 경계를 못 넘습니다. 이건 사용자가 적을 때는 절대 안 드러나서, 서버가 한 대일 때는 완벽하게 동작하다가 늘리는 순간 "왜 어떤 사람은 못 받죠?"가 됩니다.

해법의 방향은 서버들 바깥에 소식을 중계하는 자리를 하나 더 두는 겁니다. 1번 서버가 거기에 알리면 2번 서버도 그걸 듣고 자기 쪽 사람들에게 뿌리는 식이에요. 백엔드 쪽에서 이걸 다루게 됩니다.

프론트엔드가 여기서 알아야 할 건 하나예요. 우리 코드는 이 문제를 못 고칩니다. 그리고 이 문제는 우리 화면에서 "가끔 소식이 안 온다"로 나타납니다. 그때 우리 코드를 아무리 들여다봐도 답이 안 나와요.

🎯 면접관을 홀리는 핵심 멘트

"WebSocket은 요청 수가 아니라 동시 연결 수로 용량을 재야 합니다. 그리고 서버를 늘려도 그대로 풀리지 않는데, 메모리 브로커는 자기에게 붙은 연결에만 뿌리기 때문에 다른 서버에 붙은 사용자는 소식을 못 받거든요. 서버 한 대일 때는 멀쩡하다가 늘리는 순간 드러나는 문제라, 스케일아웃 계획이 있으면 브로커를 서버 바깥으로 빼는 걸 먼저 이야기합니다."

🤔 [생각해볼 주제 3] 우리가 다시 붙는 동안 사용자는 무엇을 보고 있었나

문제 상황 요약

지수 백오프는 서버를 보호하는데, 그 값을 사용자가 치릅니다. 8초를 기다리는 중이라면 서버가 이미 살아났어도 8초를 더 기다려요.

튜터의 가이드 및 해설

먼저 이 상충이 진짜인지부터 봅시다.

진짜입니다. 그런데 한쪽으로 치우쳐 있어요. 서버가 죽는 일은 드물고, 죽었을 때 몰려드는 피해는 큽니다. 반대로 사용자가 몇 초 더 기다리는 건 자주 있어도 피해가 작아요.

기본값은 서버 보호 쪽으로 기울이는 게 맞는데, 상한은 둬야 해요. Step 8에서 maxReconnectDelay를 30초로 정한 게 그겁니다. 안 두면 15분까지 벌어지는데, 그건 사실상 "다시는 안 붙는다"와 같습니다.

이 상충을 줄이는 방법은 있습니다.

힌트에서 말한 "사용자가 다시 화면을 보러 왔다는 신호"가 열쇠예요.

백오프가 길어지는 상황을 생각해보면, 대개 사용자가 그 화면을 안 보고 있는 동안입니다. 다른 탭에 가 있거나, 노트북을 덮어뒀거나요. 그동안 8초든 30초든 아무 상관이 없어요. 어차피 안 보고 있으니까요.

문제가 되는 건 사용자가 돌아온 순간입니다. 화면을 다시 보러 왔는데 30초를 기다려야 한다면 그건 나쁩니다.

그러니 이렇게 하면 됩니다. 사용자가 돌아왔다는 신호를 받으면 기다리던 것을 취소하고 즉시 한 번 시도하며, 대기 시간도 처음 값으로 되돌립니다.

브라우저는 이 신호를 줍니다. 탭이 다시 앞으로 왔는지, 창이 다시 초점을 받았는지를요. 우리가 쓰는 캐시 라이브러리가 "화면에 돌아오면 다시 물어본다"를 하는 것도 같은 신호를 쓰는 거예요.

이렇게 하면 두 마리를 다 잡습니다. 아무도 안 보는 동안에는 뜸하게 두드려서 서버를 지키고, 보러 온 사람에게는 즉시 붙어줍니다.

여기서 한 걸음 더 나가면 이런 질문이 됩니다. 안 보고 있는 동안에는 아예 연결을 끊어두는 건 어떨까요? 서버 입장에서는 훨씬 좋습니다. 안 보는 사람의 연결을 안 붙들고 있어도 되니까요.

대신 값이 있습니다. 돌아올 때마다 다시 붙어야 하고, 안 보는 동안 온 소식은 전부 놓칩니다. 그 놓친 것을 어떻게 메울지가 다음 시간의 주제예요.

🎯 면접관을 홀리는 핵심 멘트

"지수 백오프는 서버를 지키는 대신 사용자를 기다리게 하는데, 저는 이 둘이 꼭 상충하지는 않는다고 봅니다. 백오프가 길어지는 구간은 대개 사용자가 그 화면을 안 보고 있을 때라서요. 그래서 상한을 두고, 탭이 다시 활성화되면 대기를 취소하고 즉시 재시도하면서 지연을 초기값으로 되돌립니다. 안 보는 동안에는 뜸하게, 보러 온 순간에는 즉시가 되죠."

전체 목록 리액트