C-10: WebSocket 클라이언트 ② — 실시간을 얹는다
목차 72
1200번.
지난 시간에 우리가 없앤 요청 수입니다. 3초마다 물어보면 한 시간에 1200번이었는데, 그걸 0으로 만들었고 남이 누른 좋아요는 오히려 2초가 아니라 즉시 우리 화면에 들어오게 됐습니다.
그렇게 열어둔 통로로 지금 다니는 소식은 아직 딱 한 종류예요. 게시물에 생긴 일이요.
안녕하세요, 홍순구입니다. 오늘은 그 통로 위에 나머지를 얹는데, 첫 걸음부터 막혀요. 우리가 지난 시간에 쓴 방식은 구독한 사람 전부에게 가는 방식이거든요.
쪽지는 그러면 안 됩니다.
💡 오늘 수업의 핵심 — "나에게만 오게 하는 것은 우리가 정할 수 없습니다"
오늘 여러분은 이상한 코드를 하나 보게 됩니다.
두 사람이 서로 다른 쪽지를 받는데, 그 쪽지를 받겠다고 서버에 말하는 코드는 두 사람이 글자 하나 안 다릅니다. 복사해서 붙여넣은 것처럼요.
같은 것을 요청했는데 다른 것이 도착합니다. 어떻게 그럴 수 있을까요?
가르는 쪽이 우리가 아니기 때문입니다. 오늘 그 이야기부터 시작합니다.
🎯 학습 목표
- 지금 통로로 쪽지를 보내면 무슨 일이 생기는지 직접 확인하고, 왜 다른 방법이 필요한지 짚습니다.
- 지금까지 듣기만 하던 통로로 우리가 먼저 보내는 방법을 익힙니다.
/user접두사가 어떻게 "나에게만"을 만드는지, 그 판단을 누가 하는지 이해합니다.- 지나간 쪽지는 요청으로, 새로 오는 쪽지는 통로로 받아 한 곳에 모읍니다.
- 통로에는 응답이 없다는 사실이 화면을 어떻게 바꾸는지 만들어봅니다.
- 알림을 붙이고, 끊겨 있던 동안 놓친 것을 메웁니다.
- 실시간이 과한 화면과, 우리 코드로는 못 고치는 문제를 구분합니다.
오늘의 여정
1. 쪽지를 지금 통로로 보내면 구독한 사람 전부가 읽는다
2. 이번엔 우리가 보낸다 publish · /app · 안 붙었으면 던진다
3. 같은 줄인데 나에게만 온다 /user — 가르는 쪽은 서버
4. 쪽지가 화면에 쌓인다 지나간 것은 요청으로, 새 것은 통로로
5. 보낸 것이 갔는지 모른다 통로엔 응답이 없다 → 표를 붙인다
6. 알림은 같은 길 다른 줄 토스트는 이미 만들어뒀다
7. 끊긴 동안 온 것은 안 온다 다시 붙는 순간 물어본다
8. 여기까지가 할 수 있는 것 못 고치는 문제를 아는 것도 실력
Step 1: "쪽지를 지금 통로로 보내면"
있는 것으로 먼저 해봅니다
새로운 걸 배우기 전에, 이미 아는 것으로 해보는 게 순서입니다.
우리에게는 통로가 있어요. 서버가 /topic/posts로 뿌리면 구독한 모두가 받는다는 것을 지난 시간에 두 탭으로 확인했으니까, 쪽지도 같은 방법으로 하면 되지 않을까요? /topic/dm 같은 곳을 하나 더 만들어서, minji 님이 보낸 쪽지를 거기로 뿌리는 겁니다.
한번 그림으로 그려볼게요.
서버가 /topic/dm 으로 뿌린다
┌─ jaehoon ← 받는다 (받을 사람)
│
서버 ──────┼─ minji ← 받는다 (보낸 사람이니 괜찮다)
│
└─ seungwoo ← 받는다 ← ⚠️ 이 사람은 왜?
seungwoo 님은 이 대화와 아무 상관이 없는데도, /topic을 구독하고 있다는 이유만으로 남의 쪽지를 읽게 돼요.
이게 정말 그런지 재봅니다
말로만 하면 믿기 어려우니 실제로 확인했습니다. 두 사람을 같은 서버에 붙여놓고, 한쪽에는 /topic으로 다른 쪽에는 /user/queue로 같은 내용을 보내봤어요.
같은 서버에 두 사람을 붙여두고 소식 하나를 보낼 때, 각자에게 도착한 개수입니다.
| 서버가 보낸 방법 | jaehoon | minji |
|---|---|---|
/topic/posts으로 뿌림 |
1 | 1 |
| minji에게만 보냄 | 0 | 1 |
윗줄이 지금 우리가 쓰는 방식인데, 뿌리면 전부 받아요. 게시물 좋아요라면 그게 맞습니다. 공개된 정보니까요.
아랫줄이 오늘 만들 방식입니다.
여기서 두 가지 문제가 갈립니다
첫째는 개인정보 문제입니다. 남의 쪽지가 내 브라우저까지 온다는 건, 개발자 도구만 열면 읽을 수 있다는 뜻이에요. 화면에 안 그리는 걸로는 못 막는데, 이미 도착했으니까요.
둘째는 양 문제입니다. 서비스 사용자가 만 명이면, 쪽지 하나가 오갈 때마다 만 명에게 그 쪽지가 배달됩니다. 아무도 안 쓸 데이터를 만 번 보내는 거예요.
두 문제 다 화면에서는 못 고치니까, 애초에 안 보내야 해요.
그럼 어떻게 나눌까요
여기서 잠깐 생각해볼 게 있습니다. 누구에게 보낼지를 누가 정해야 할까요?
클라이언트가 정한다고 해봅시다. "나는 jaehoon 이니까 jaehoon 앞으로 온 것만 주세요"라고 구독하는 거예요.
그럴듯한데, 이러면 아무나 minji라고 적어서 구독할 수 있습니다. 브라우저에서 도는 코드는 사용자가 마음대로 고칠 수 있으니까요.
그러니 정하는 쪽은 서버여야 합니다. 서버는 우리가 누구인지 이미 알고 있어요. 로그인할 때 확인했으니까요.
⚠️ 지난 시간에 만든 통로는 이 확인을 안 거쳤습니다. 로그인 없이도 붙었죠. 오늘 그것부터 고칩니다.
💡 한 줄 정리
/topic은 공개된 소식을 위한 곳입니다. 받을 사람이 정해져 있는 소식은 다른 방법이 필요하고, 그 방법에서 누구에게 갈지를 정하는 쪽은 서버여야 합니다.
🙋 학생 질문 — "튜터님, 그냥 화면에서 걸러내면 안 되나요? 내 것만 그리게요."
동작은 하고 화면에는 내 쪽지만 뜨긴 하지만, 그건 커튼을 치는 것이지 안 보내는 게 아닙니다.
브라우저 개발자 도구의 네트워크 탭을 열면 오간 프레임이 전부 보입니다. 화면에 안 그렸을 뿐 데이터는 이미 그 사람 컴퓨터에 도착해 있어요. 마음먹으면 다 읽습니다.
보안에서 이걸 부르는 이름이 있습니다. 클라이언트 측 검증이라고 해요. 편의를 위해 화면에서 한 번 거르는 건 좋지만, 그것만으로 지키려 하면 안 지켜집니다. 진짜 방어선은 항상 서버에 있어야 합니다.
지난 시간에 좋아요를 통로가 아니라 요청으로 보낸 것도 같은 이야기예요. 지켜야 할 것이 있으면 서버가 판단해야 합니다.
Step 2: "이번엔 우리가 보낸다"
지금까지 우리는 듣기만 했습니다
지난 시간 코드를 다시 보면, 통로로 한 일이 하나뿐이었어요. subscribe로 듣는 것.
쪽지를 보내려면 우리도 말을 해야 하는데, 지난 시간에 프레임 종류를 읽어봤던 거 기억하시죠. SEND라는 게 있었습니다. 클라이언트가 서버로 보낼 때 쓰는 프레임이고, 목적지가 /app으로 시작하면 브로커가 아니라 서버의 처리 코드가 받는다고 했습니다.
라이브러리로는 한 줄입니다.
// apps/web-spa/src/realtime/stompClient.ts
// 나에게만 오는 곳. 앞의 /user 는 목적지 이름의 일부가 아니라
// "이 줄에서 나에게 온 것만 달라" 는 표시다. 누구에게 갈지는 서버가 정한다.
export const DM_QUEUE = '/user/queue/dm';
// 우리가 서버로 보내는 곳. /app 으로 시작하는 것은 브로커가 아니라
// 서버의 처리 코드가 받는다 (백엔드의 @MessageMapping).
export const DM_SEND = '/app/dm.send';
부르는 모양은 이렇습니다. 목적지와 보낼 내용, 두 가지만 주면 됩니다.
client.publish({
destination: '/app/dm.send',
body: JSON.stringify({ conversationId: 1, content: '안녕하세요' }),
});
publish를 부르면 SEND 프레임이 나갑니다. 백엔드 쪽에서는 /app/dm.send에 붙여둔 처리 코드가 그걸 받아요.
보내는 사람 이름이 안 보이죠
위 코드를 다시 보세요. 방 번호와 내용만 들어 있습니다. 누가 보냈는지가 없어요.
빠뜨린 게 아닙니다. 일부러 안 적은 겁니다.
만약 우리가 적어 보낸다면 어떻게 될까요? 개발자 도구를 열고 senderUsername을 minji로 바꿔서 보내면, 서버는 minji가 보낸 걸로 믿습니다. 남의 이름으로 쪽지를 보낼 수 있게 되는 거라서, 보내는 사람은 서버가 압니다. 우리가 통로를 열 때 이미 말해뒀거든요.
통로를 열 때 이름을 싣습니다
STOMP로 붙을 때 CONNECT 프레임이 나간다고 했죠. 거기에 헤더를 실어 보낼 수 있습니다.
// apps/web-spa/src/realtime/RealtimeBridge.tsx
client.configure({
// 출입증을 서버에 내미는 자리. 진짜 서버는 여기서 JWT 를 검사한다.
// 로그인 전에는 아무것도 안 싣는다 — 그래도 /topic 은 받는다.
connectHeaders: username === null ? {} : { login: username },
서버는 이 이름을 그 연결에 기억해둡니다. 이후 그 연결로 들어오는 모든 SEND는 그 사람이 보낸 것으로 처리돼요.
⚠️ 연습용 서버라 이름만 받지만, 실제 서버는 여기서 토큰을 검사합니다. 백엔드 쪽에서 CONNECT 프레임을 가로채 토큰을 확인하고, 통과한 사람만 연결을 허락해요.
그런데 로그인은 통로가 열린 뒤에 합니다
여기서 순서 문제가 하나 생깁니다.
앱을 켜면 통로부터 열립니다. 그때는 아직 로그인 전이라 실을 이름이 없어요. 그러다 사용자가 로그인 버튼을 누르면 이름이 생기지만, 이미 열린 통로에는 이름이 안 실려 있습니다. 서버는 우리를 여전히 손님으로 알고 있어서, 사람이 바뀌면 통로를 다시 엽니다.
// apps/web-spa/src/realtime/RealtimeBridge.tsx
// 누구로 들어와 있는지가 바뀌면 통로도 다시 열어야 한다.
// 서버는 CONNECT 때 받은 이름으로 "누구에게 보낼지" 를 가르기 때문이다.
const username = useSessionStore((state) => state.username);
이 값을 effect의 의존성에 넣으면 됩니다. 이름이 바뀌는 순간 정리 함수가 돌아 통로를 닫고, 새 이름으로 다시 열어요. B-4에서 배운 effect의 정리와 재실행이 여기서 그대로 쓰입니다.
그 이름은 어디에 담나요
여기서 우리가 이미 세워둔 규칙을 씁니다. 상태를 어디에 담을지 정하는 세 갈래 규칙이요.
로그인한 사람이 누구인지는 서버가 주인인 값일까요? 아닙니다. 서버는 토큰이 유효한지만 알지, 이 브라우저가 누구로 들어와 있는지는 이쪽 사정이에요.
주소에 담을까요? 그것도 아닙니다. 주소를 복사해서 친구에게 보냈는데 친구가 나로 보이면 큰일이니까요.
머리말도 알아야 하고 통로도 알아야 하고 쪽지 화면도 알아야 하니까, store입니다.
// apps/web-spa/src/stores/useSessionStore.ts
interface SessionState {
username: string | null;
signIn: (username: string) => void;
signOut: () => void;
}
export const useSessionStore = create<SessionState>()((set) => ({
username: null,
signIn: (username) => set({ username }),
signOut: () => set({ username: null }),
}));
C-4에서 만든 확인 상자 store와 똑같이 생겼죠. 규칙이 같으면 결과도 같습니다.
안 붙어 있을 때 보내면 어떻게 될까요
궁금해서 해봤습니다. 통로를 열지 않은 채로 publish를 불러본 거예요.
여러분은 어떻게 될 거라고 예상하세요? 조용히 사라질까요, 아니면 나중에 붙었을 때 보내줄까요?
둘 다 아닙니다. 던집니다.
TypeError: There is no underlying STOMP connection
라이브러리가 보내기 전에 연결을 확인하고, 없으면 예외를 던져요. 큐에 쌓아뒀다 나중에 보내주지 않습니다.
이건 사실 친절한 설계인데, 조용히 사라지는 게 제일 나쁘거든요. 사용자는 보낸 줄 아는데 아무 데도 안 간 상태가 되니까요.
대신 우리가 할 일이 생기는데, 끊겨 있으면 보내기 버튼을 잠가야 해요. 안 그러면 앱이 터집니다.
⚠️ subscribe도 똑같이 검사합니다. 붙기 전에는 구독도 못 해요. 다음 Step에서 이 때문에 한 번 막힙니다.
💡 한 줄 정리
publish로 /app에 보내면 서버의 처리 코드가 받습니다. 보내는 사람은 우리가 적는 게 아니라 서버가 연결에 기억해둔 이름으로 판단하고, 연결이 없으면 보내기는 조용히 사라지지 않고 던집니다.
🙋 학생 질문 — "튜터님, 로그인할 때마다 통로를 다시 여는 게 낭비 아닌가요?"
낭비처럼 보이지만 대안이 마땅치 않습니다.
STOMP 규격에는 "이미 열린 연결의 신원을 바꾸는" 방법이 없어요. CONNECT는 연결을 시작할 때 한 번 보내는 프레임이고, 그 뒤에 다시 보낼 수 없는데, 실제로 이런 일이 얼마나 자주 일어날까요? 사용자 한 명이 앱을 쓰는 동안 로그인은 보통 한 번입니다. 다시 여는 비용은 한 번뿐이에요.
오히려 이 편이 안전합니다. 사람이 바뀌면 이전 사람 앞으로 오던 구독이 전부 정리되거든요. 연결을 재활용하면 "앞사람 쪽지가 뒷사람에게 오는" 사고를 직접 막아야 합니다.
Step 3: "같은 줄을 구독했는데 나에게만 온다"
목적지 앞에 /user를 붙입니다
지금부터가 오늘의 핵심인데, 쪽지를 받으려면 이렇게 구독합니다.
// apps/web-spa/src/realtime/stompClient.ts
export const DM_QUEUE = '/user/queue/dm';
/user/queue/dm. 앞에 /user가 붙었죠.
여기서 반드시 짚고 갈 것이 있습니다. /user는 목적지 이름의 일부가 아닙니다.
/topic/posts는 실제로 그런 이름의 게시판이 서버에 있는 것이었지만, /user/queue/dm은 그런 이름의 게시판이 있는 게 아닙니다. 서버에게 보내는 표시예요. "이 줄로 오는 것 중에서 나에게 온 것만 주세요"라는 뜻입니다.
그래서 이상한 일이 생깁니다
jaehoon 님의 브라우저에서 구독하는 코드를 봅시다.
client.subscribe('/user/queue/dm', handler);
이번엔 minji 님의 브라우저입니다.
client.subscribe('/user/queue/dm', handler);
같습니다. 글자 하나 안 다릅니다.
두 사람이 서버에 보내는 SUBSCRIBE 프레임의 목적지도 똑같은데, 도착하는 쪽지는 다릅니다.
보내는 코드는 같고, 받는 것은 다르다
jaehoon 의 브라우저 ──SUBSCRIBE /user/queue/dm──┐
│
[ 서버 ] ← 여기서만 갈린다
│ (연결마다 누구인지 안다)
minji 의 브라우저 ──SUBSCRIBE /user/queue/dm──┘
서버는 각 연결이 누구인지 알고 있습니다. Step 2에서 CONNECT에 이름을 실어 보냈으니까, 같은 문자열을 받고도 사람마다 다른 곳에 묶어둘 수 있습니다.
백엔드 쪽에서 이 변환을 해주는 부품이 따로 있어요. 서버 코드는 그냥 "minji에게 /queue/dm으로 보내"라고 말하고, 그 부품이 minji의 연결을 찾아 배달합니다.
화면을 만들어봅니다
쪽지 화면을 새로 만듭니다. 주소는 /dm/:conversationId예요. 게시물 상세와 똑같이 생겼어요. 방이 몇 개든 표는 한 줄이면 됩니다.
// apps/web-spa/src/routes/routes.ts
// 대화방도 같은 모양이다. 방이 몇 개든 이 표는 한 줄이다.
{ path: 'dm/:conversationId', Component: DmPage },
여기서는 화면이 통로를 직접 듣습니다.
// apps/web-spa/src/routes/DmPage.tsx
// 붙기 전에는 구독도 못 한다. 지난 시간에 담아둔 연결 상태가 여기서 쓰인다.
const status = useConnectionStore((state) => state.status);
useEffect(() => {
if (status !== 'connected') return;
// 이 줄은 누가 들어와 있든 글자 하나 안 다르다.
// 그런데 도착하는 것은 사람마다 다르다 — 가르는 쪽이 서버이기 때문이다.
const subscription = client.subscribe(DM_QUEUE, (message) => {
const received = parseDirectMessage(message.body);
if (received === null || received.conversationId !== roomId) return;
setMessages((current) => appendMessage(current, received));
});
// 화면을 떠나면 구독을 거둔다. 연결은 그대로 두고 이 줄만 끊는 것이다.
return () => subscription.unsubscribe();
}, [client, roomId, status]);
세 가지가 눈에 띄죠.
첫째, 연결 상태를 먼저 봅니다. Step 2 마지막에 예고한 그 문제예요. 붙기 전에는 구독도 못 하니까, 지난 시간에 store에 담아둔 상태가 connected가 될 때까지 기다립니다. 담아둘 때는 표시등 하나 켜려고 만든 값이었는데, 오늘 다른 데서 쓰이네요.
둘째, 정리 함수에서 구독을 거둡니다. subscribe가 돌려주는 것에 unsubscribe가 붙어 있어요. 이걸 부르면 그 줄만 끊깁니다. 연결은 그대로 열려 있어요.
셋째, 방 번호가 다르면 버립니다. 통로는 내 앞으로 온 쪽지를 전부 보내주는데, 지금 보고 있는 방의 것만 그려야 하니까요.
보낸 것을 화면이 어떻게 아나요
여기서 재미있는 게 하나 있습니다. 우리가 보낸 쪽지는 어떻게 우리 화면에 뜰까요?
먼저 그려놓을 수도 있겠지만, 서버가 보낸 사람에게도 되돌려 보내서 받는 코드 하나로 양쪽이 다 처리돼요.
minji 가 보낸 쪽지가 가는 길
minji ──SEND /app/dm.send──→ 서버 ──┬──→ jaehoon 의 /user/queue/dm
│
└──→ minji 의 /user/queue/dm (되돌려 보냄)
받는 사람에게 한 번, 보낸 사람에게 한 번. 화면 쪽에서는 구분할 필요가 없습니다. 어느 쪽이든 "쪽지가 하나 도착했다"로 처리하면 되니까요.
⚠️ 이건 그냥 편의가 아닙니다. Step 5에서 이게 왜 중요한지 다시 만납니다.
같은 것이 두 번 오면
통로는 "한 번만 보낸다"를 약속하지 않습니다. 재연결이 얽히거나 서버가 다시 보내면 같은 쪽지가 두 번 올 수 있어서, 얹기 전에 번호를 확인합니다.
// apps/web-spa/src/lib/dmEvents.ts
// 받아둔 쪽지 목록에 새 것을 얹는다.
//
// ⚠️ 같은 것이 두 번 올 수 있다. 통로는 "한 번만 간다" 를 약속하지 않는다.
// 번호가 같은 것이 이미 있으면 그대로 둔다.
export function appendMessage(
messages: DirectMessage[] | undefined,
incoming: DirectMessage,
): DirectMessage[] {
const current = messages ?? [];
if (current.some((it) => it.messageId === incoming.messageId)) return current;
return [...current, incoming];
}
같은 것이 이미 있으면 그대로 둡니다. 새 배열을 안 만들어요. 이러면 화면도 다시 안 그려집니다.
두 탭으로 확인합니다
여기서부터는 직접 해볼 차례입니다.
- 연습용 서버와 앱을 띄우고 탭을 두 개 엽니다.
- 머리말에서 한쪽은
jaehoon, 다른 쪽은minji를 골라 로그인합니다. - 두 탭 다
/dm/1로 들어갑니다. - minji 쪽에서 쪽지를 하나 보냅니다.
jaehoon 쪽 화면에 그 쪽지가 뜹니다. 새로고침은 안 했어요.
서버 쪽 기록을 보면 사람마다 구독이 따로 잡혀 있는 것도 보입니다. 같은 문자열을 받았는데 서버 안에서는 두 개의 다른 줄로 갈라져 있는 거예요.
💡 한 줄 정리
/user는 목적지 이름이 아니라 "나에게 온 것만"이라는 표시입니다. 두 사람의 구독 코드는 같고, 가르는 판단은 연결마다 누구인지 아는 서버가 합니다.
🙋 학생 질문 — "튜터님, 구독을 화면에서 하는 게 맞나요? 지난 시간엔 앱에서 했잖아요."
좋은 지적입니다. 지금은 화면이 직접 듣고 있죠.
일단 이렇게 두고 갑니다. 그래야 다음 Step에서 무엇이 안 되는지가 눈에 보이거든요.
미리 힌트를 드리면, 화면이 듣는다는 건 화면을 떠나면 못 듣는다는 뜻입니다. 지금은 그게 문제인지 아닌지 잘 안 보여요. 다음 Step에서 확인하고 옮깁니다.
기준을 먼저 세우고 코드를 옮기는 것과, 코드를 옮겨놓고 기준을 나중에 설명하는 것은 배우는 입장에서 아주 다릅니다. 순서를 지켜서 갈게요.
Step 4: "쪽지가 화면에 쌓인다"
새로 들어가면 텅 비어 있습니다
Step 3을 만들고 나서 쪽지 화면을 새로고침해보세요.
아무것도 없습니다.
조금 전까지 주고받은 쪽지가 전부 사라졌어요. 서버는 그것들을 잘 들고 있는데도요.
당연합니다. 우리 화면은 통로로 오는 것만 받거든요. 통로가 열리기 전에 오간 것은 통로로 안 옵니다.
숫자로 보면 이렇습니다.
| 서버가 들고 있는 쪽지 | 화면에 뜬 쪽지 | 우리가 보낸 요청 | |
|---|---|---|---|
| 새로 들어갔을 때 | 2개 | 0개 | 0번 |
요청이 0번이라는 게 핵심입니다. 물어본 적이 없으니 모르는 게 당연해요.
지나간 것은 요청으로 받습니다
여기서 두 갈래로 나눕니다.
쪽지가 화면에 오는 두 가지 길
지나간 것 ──→ GET /api/conversations/1/messages (요청 · 들어갈 때 한 번)
새로 온 것 ──→ /user/queue/dm (통로 · 계속)
읽어오는 코드는 C-6에서 만든 것과 똑같이 생겼습니다.
// apps/web-spa/src/api/dm.ts
// 통로가 열리기 전에 오간 것은 통로로 안 온다. 그건 요청으로 받아야 한다.
export async function fetchMessages(conversationId: number): Promise<DirectMessage[]> {
const response = await api.get<DirectMessage[]>(`/conversations/${conversationId}/messages`);
return response.data;
}
// apps/web-spa/src/queries/dm.ts
// 키와 부를 함수를 한 덩어리로 묶는다. 통로도 화면도 이 하나를 가져다 쓴다 —
// 키가 어긋날 자리가 없어진다 (C-6 의 postQuery 와 같은 모양).
export function messagesQuery(conversationId: number) {
return queryOptions({
queryKey: dmKey(conversationId),
queryFn: () => fetchMessages(conversationId),
});
}
그럼 통로로 온 것은 어디에 둘까요
여기가 오늘의 갈림길입니다.
지금 화면은 통로로 온 것을 useState에 쌓고 있는데, 요청으로 받아온 것은 캐시에 있어서 같은 대화의 쪽지가 두 곳에 나뉘어 있는 거예요.
C-7에서 우리가 뭐라고 했죠? 진실이 둘이면 하나는 거짓말입니다.
그때는 좋아요 개수였어요. 화면이 자기 셈으로 세고 서버도 따로 세다가 갈라졌죠. 지금은 쪽지 목록이고, 갈라지는 방식은 똑같습니다.
그러니 한 곳으로 모읍니다. 통로가 온 것을 캐시에 얹어요. 지난 시간에 좋아요 소식을 얹었던 그 방식 그대로입니다.
// apps/web-spa/src/realtime/RealtimeBridge.tsx
// 쪽지도 같은 자리에서 듣는다. 화면이 아니라 앱이 듣는 이유는
// 쪽지 화면을 떠나 있는 동안에도 받아둬야 하기 때문이다.
client.subscribe(DM_QUEUE, (message) => {
const received = parseDirectMessage(message.body);
if (received === null) return;
// 얹는 방식은 좋아요와 똑같다. 진실을 캐시 한 곳에 모아뒀으니
// 화면은 이 줄이 있는 줄도 모르고 다시 그려진다.
queryClient.setQueryData<DirectMessage[]>(dmKey(received.conversationId), (current) =>
appendMessage(current, received),
);
});
구독이 화면에서 앱으로 올라갔습니다
방금 코드가 어디에 있는지 보세요. DmPage가 아니라 통로를 여는 곳입니다.
Step 3의 🙋 질문에서 예고한 것이 이겁니다. 기준은 하나예요.
그 소식이 그 화면을 떠나 있는 동안에도 필요한가?
쪽지는 필요합니다. 홈을 보고 있는 동안 쪽지가 와도 받아둬야 해요. 나중에 쪽지 화면에 들어갔을 때 거기 있어야 하니까, 앱이 듣습니다. 화면은 캐시만 그려요.
// apps/web-spa/src/routes/DmPage.tsx
// 통로가 열리기 전에 오간 것은 여기로 받는다.
// 통로는 이 캐시에 얹기만 하니, 화면은 어느 쪽으로 온 것인지 몰라도 된다.
//
// ⚠️ 들어오자마자 묻지 않는다. 남의 쪽지를 아무나 못 보게 서버가 막고 있어서,
// 로그인 전에 물어보면 거절만 받고 그 실패가 캐시에 남는다.
const { data: messages, isPending: loadingHistory } = useQuery({
...messagesQuery(roomId),
enabled: username !== null,
});
화면에서 subscribe가 사라졌습니다. useState도요. 남은 것은 캐시를 읽는 한 줄뿐이에요.
enabled는 처음 보는 옵션이죠. "이 조건이 맞을 때만 물어봐라"는 뜻입니다. 로그인 전에는 서버가 거절할 게 뻔하니 아예 안 묻는 겁니다.
확인해봅니다
두 탭을 다시 띄우고 이렇게 해보세요.
- 두 탭 다 로그인하고
/dm/1로 들어갑니다. 지나간 쪽지 2개가 먼저 뜹니다. - jaehoon 쪽에서 홈으로 이동합니다. 쪽지 화면을 떠난 거예요.
- minji 쪽에서 쪽지를 하나 보냅니다.
- jaehoon 쪽에서 다시 쪽지 화면으로 들어갑니다.
그 쪽지가 있습니다. 떠나 있는 동안 앱이 받아뒀거든요. 한 가지 더 눈여겨보면, 새 쪽지가 왔다고 서버에 다시 물어보지 않았습니다. 요청은 처음 들어갈 때 한 번뿐이에요. 나머지는 전부 통로가 채웠습니다.
💡 한 줄 정리
지나간 것은 요청으로, 새로 오는 것은 통로로 받되 담는 곳은 하나이고, 화면을 떠나 있는 동안에도 필요한 소식이라면 화면이 아니라 앱이 들어야 합니다.
🙋 학생 질문 — "튜터님, 통로로 온 걸 캐시에 얹는 게 좀 이상해요. 캐시는 서버에서 받아온 걸 담는 곳 아닌가요?"
좋은 감각이지만, 한 걸음만 더 가보죠.
캐시가 담는 것은 "우리가 요청해서 받아온 것"이 아니라 "서버가 진실이라고 말한 것"입니다.
통로로 온 쪽지도 서버가 보낸 겁니다. 우리가 물어봐서 온 게 아닐 뿐, 출처는 똑같아요. 그러니 같은 곳에 담는 게 맞습니다.
오히려 반대로 생각해보세요. 통로로 온 것을 따로 담으면 무슨 일이 생길까요? 화면은 두 곳을 합쳐서 그려야 하고, 중복을 걸러야 하고, 순서를 맞춰야 하는데, 그 합치는 코드가 화면마다 생겨요.
지난 시간에 좋아요 소식을 캐시에 얹었을 때 PostCard를 한 줄도 안 고쳤죠. 같은 이유입니다.
Step 5: "보낸 것이 갔는지 우리는 모른다"
지난 시간 과제의 답이 여기서 쓰입니다
지난 시간 생각해볼 주제에 이런 게 있었죠. "통로가 열려 있는데 좋아요는 왜 아직도 요청으로 보내나요?"
답은 응답이었습니다. 요청에는 짝이 있어서 성공인지 실패인지 알 수 있는데, 통로의 SEND에는 짝이 없다는 것.
오늘 그 말이 실제로 무슨 뜻인지 봅니다. 우리는 방금 쪽지를 통로로 보냈어요. 그러니 갔는지 모릅니다.
좋아요를 누를 때 (요청) 쪽지를 보낼 때 (통로)
POST /posts/1/like SEND /app/dm.send
│ │
▼ ▼
200 · 500 · 403 (아무것도 없음)
│
└─→ 실패면 되돌린다 └─→ 되돌릴 근거가 없다
C-7에서 만든 되돌리기가 전부 응답 위에 서 있었습니다. 서버가 403을 줬으니까 화면을 되돌린 거예요. 통로에는 그 403이 안 옵니다.
그런데 우리에겐 이미 단서가 하나 있습니다
Step 3에서 지나간 것 기억나세요? 서버가 보낸 사람에게도 되돌려 보낸다고 했죠.
그게 곧 확인입니다. 되돌아왔다는 건 서버까지 갔고, 서버가 저장했다는 뜻이니까요.
문제는 하나뿐이에요. 돌아온 것이 내가 보낸 그것인지 어떻게 알까요? 같은 시간에 여러 개를 보냈을 수도 있고 상대가 똑같은 글자를 보냈을 수도 있어서, 보낼 때 표를 하나 붙입니다.
// apps/web-spa/src/routes/DmPage.tsx
function send() {
const content = draft.trim();
if (content === '') return;
// 통로에는 응답이 없다. 그래서 우리가 표를 붙여 보내고,
// 서버가 그 표를 그대로 실어 돌려주는 것을 "갔다" 의 증거로 삼는다.
const clientId = crypto.randomUUID();
client.publish({
destination: DM_SEND,
body: JSON.stringify({ conversationId: roomId, content, clientId }),
});
setOutgoing((current) => [...current, { clientId, content, failedReason: null }]);
setDraft('');
}
crypto.randomUUID()는 브라우저가 만들어주는 겹치지 않는 문자열입니다. 서버는 이걸 해석하지 않고 그대로 실어 돌려줘요.
확인 전에는 어디에 담을까요
여기서 또 한 번 담는 곳을 정해야 합니다.
방금 보낸 쪽지는 서버가 아직 모르는 것이고 저장됐는지도 몰라서, 서버 상태가 아닙니다.
C-4의 규칙을 그대로 적용하면 답이 나옵니다. 서버가 주인이 아니고, 주소에 담을 것도 아니고, 이 화면만 알면 되는 값. 화면의 useState입니다.
// apps/web-spa/src/types/dm.ts
// 아직 서버가 받았는지 모르는, 우리 화면에만 있는 쪽지.
// 서버가 모르는 것은 서버 상태가 아니다 — 그래서 캐시가 아니라 화면이 들고 있는다.
export interface PendingMessage {
clientId: string;
content: string;
failedReason: string | null;
}
확인이 오면 목록에서 빠집니다
이제 화면에는 두 종류가 있습니다. 서버가 확인해준 것과 아직 기다리는 것인데, 이 둘을 손으로 옮겨 담지 않습니다. 계산으로 가릅니다.
// apps/web-spa/src/routes/DmPage.tsx
// 서버가 우리 표를 그대로 실어 돌려준 것들. 이것들은 확인이 끝났다.
const confirmed = new Set(messages?.map((it) => it.clientId).filter((it) => it !== undefined));
const stillOutgoing = outgoing.filter((it) => !confirmed.has(it.clientId));
캐시에 들어온 쪽지 중에 표가 붙은 것들을 모으고, 기다리던 것 중에 그 표가 있는 것은 뺍니다.
이게 왜 나을까요? 옮겨 담는 코드를 쓰면 "언제 옮길지"를 정해야 하고, 그건 effect가 됩니다. 계산으로 하면 그럴 일이 없어요. B-4에서 "effect로 안 해도 되는 것"을 골라내던 그 기준입니다.
화면에는 이렇게 나옵니다.
보내기를 누른 직후
minji 한강 사진 그거 어디서 찍은 거예요?
반포대교 남단이요! 해 지기 30분 전이 제일 좋아요
노을 시간 맞춰 가볼게요 보내는 중…
서버가 되돌려준 뒤
minji 한강 사진 그거 어디서 찍은 거예요?
반포대교 남단이요! 해 지기 30분 전이 제일 좋아요
노을 시간 맞춰 가볼게요
메신저 앱에서 보낸 메시지 옆에 시계가 떴다가 체크로 바뀌는 것, 그게 정확히 이겁니다.
그럼 실패는 어떻게 알까요
통로에 응답이 없다면 실패도 안 오는 걸까요?
여기서 백엔드가 규약을 하나 더 만들어둡니다. 실패 전용 줄이에요.
// apps/web-spa/src/realtime/stompClient.ts
// 보내기가 거절당했을 때 답이 오는 곳. 정상 쪽지와 다른 줄이다.
export const ERRORS_QUEUE = '/user/queue/errors';
정상 쪽지는 /user/queue/dm으로, 거절은 /user/queue/errors로 옵니다. 같은 사람에게 오는 것이니 둘 다 /user로 시작하는데, 그래서 클라이언트는 두 줄을 다 구독해야 합니다. 하나만 구독하면 절반은 영영 못 듣습니다.
// apps/web-spa/src/routes/DmPage.tsx
// 보내기가 거절당하면 그 답은 정상 쪽지와 다른 줄로 온다.
//
// 쪽지는 앱이 듣고 이건 화면이 듣는다. 기준은 "그 소식이 화면을 떠나 있는 동안에도
// 필요한가" 다. 쪽지는 필요하고, 내가 이 화면에서 보낸 것의 실패는 여기서만 쓸모가 있다.
useEffect(() => {
if (status !== 'connected') return;
const subscription = client.subscribe(ERRORS_QUEUE, (message) => {
const error = parseStompError(message.body);
if (error === null || error.clientId === null) return;
setOutgoing((current) =>
current.map((it) =>
it.clientId === error.clientId ? { ...it, failedReason: error.message } : it,
),
);
});
return () => subscription.unsubscribe();
}, [client, status]);
주석에 적힌 판단을 눈여겨보세요. 쪽지는 앱이 듣는데 실패는 화면이 듣습니다. 어긋나 보이지만 기준은 같아요. 떠나 있는 동안에도 필요한가. 내가 이 화면에서 방금 보낸 것의 실패는 여기서만 쓸모가 있습니다.
거절이 오면 그 쪽지 옆이 "보내는 중…"에서 "안 갔어요 — 참여하지 않은 대화방입니다"로 바뀝니다. 서버가 준 사유를 그대로 보여주는 거예요.
끊겨 있으면 아예 못 누르게 합니다
Step 2에서 확인한 것 기억나시죠. 안 붙어 있을 때 publish를 부르면 던집니다.
그러니 버튼을 잠급니다.
<Button type="submit" size="sm" disabled={status !== 'connected'}>
보내기
</Button>
지난 시간에 담아둔 연결 상태가 오늘 세 번째로 쓰였습니다. 표시등에 한 번, 구독 시점에 한 번, 이제 버튼 잠금에 한 번이요.
💡 한 줄 정리
통로에는 응답이 없으니 확인을 우리가 만듭니다. 보낼 때 표를 붙이고, 서버가 그 표를 실어 돌려주면 확정하고, 거절은 따로 마련된 줄로 받습니다.
🙋 학생 질문 — "튜터님, 이럴 거면 그냥 쪽지도 요청으로 보내는 게 낫지 않나요?"
아주 합리적인 질문이고, 실제로 그렇게 만든 서비스도 많습니다.
정리해보면 이렇습니다.
| 요청으로 보내기 | 통로로 보내기 | |
|---|---|---|
| 성공·실패 | 응답에 실려 온다 | 따로 만들어야 한다 |
| 상대에게 알리기 | 통로가 또 필요하다 | 이미 열려 있다 |
| 연결 관리 | 없다 | 끊김·재연결을 다뤄야 한다 |
보시면 어느 쪽도 완전히 이기지 않습니다.
실무에서 통로로 보내는 쪽을 고르는 이유는 대개 왕복이 짧아서예요. 요청은 매번 새로 연결하고 헤더를 붙여 보내지만, 통로는 이미 열려 있어서 프레임 하나만 흘려보내면 됩니다. 채팅처럼 짧은 것을 자주 보내는 곳에서 이 차이가 커져요.
그리고 오늘 우리가 만든 표(clientId)는 버리는 코드가 아닙니다. 요청으로 보내더라도 같은 것을 두 번 보내는 사고를 막으려면 이런 표가 필요하거든요. 네트워크가 끊겨서 재시도했는데 사실 첫 번째도 도착해 있었다면, 표가 없으면 쪽지가 두 개 생깁니다.
Step 6: "알림은 같은 길로 온다"
세 번째 줄을 하나 더 엽니다
쪽지를 만들고 나면 알림은 쉽습니다. 이미 다 배웠거든요.
내 게시물에 누가 좋아요를 누르거나 댓글을 달면, 그 소식은 나에게만 와야 합니다. 남들에게 뿌릴 이유가 없어요. 그러니 /user를 씁니다.
// apps/web-spa/src/realtime/stompClient.ts
// 나에게 온 알림이 오는 곳.
export const NOTIFICATIONS_QUEUE = '/user/queue/notifications';
이제 우리가 듣는 줄이 넷입니다.
| 줄 | 무엇이 오나 | 누가 받나 |
|---|---|---|
/topic/posts |
게시물의 좋아요·댓글 수 | 구독한 전부 |
/user/queue/dm |
나에게 온 쪽지 | 나만 |
/user/queue/notifications |
나에게 온 알림 | 나만 |
/user/queue/errors |
내 보내기가 거절당한 사유 | 나만 |
같은 통로 하나 위에 네 줄이 다닙니다. 연결은 여전히 앱에 하나예요.
모르는 종류는 버립니다
알림에는 종류가 있습니다. 좋아요, 댓글, 팔로우처럼요. 서비스가 커지면 종류가 늘어나요.
여기서 미래를 한 번 생각해봅시다. 서버가 나중에 MENTION이라는 새 종류를 보내기 시작하면, 우리 화면은 어떻게 될까요?
우리가 아무 확인도 안 하면 모르는 종류가 그대로 흘러들어옵니다. 화면에 이상한 게 뜨거나 운이 나쁘면 터지기 때문에, 아는 종류만 통과시킵니다.
// apps/web-spa/src/lib/notificationEvents.ts
const KINDS = ['LIKE', 'COMMENT', 'FOLLOW'] as const;
function isKind(value: unknown): value is AppNotification['type'] {
return typeof value === 'string' && (KINDS as readonly string[]).includes(value);
}
value is AppNotification['type']이 보이시죠. A-2에서 배운 사용자 정의 타입 가드입니다. 서버가 준 것은 unknown이고, 이 함수를 통과해야 우리 타입이 됩니다.
이러면 서버가 새 종류를 늘려도 옛 화면은 그냥 그것을 지나칩니다. 안 터져요.
띄우는 것은 이미 만들어뒀습니다
알림을 화면에 어떻게 보여줄까요?
잠깐 떴다 사라지는 한 줄이면 충분합니다. 그런데 그거, 우리 앱에 이미 있어요. B-4에서 만든 토스트요.
// apps/web-spa/src/components/NotificationToaster.tsx
// 토스트는 이미 만들어둔 것을 그대로 쓴다. "방금 일어난 일" 을 알리는
// 자리라서 role="status" 가 붙어 있는데, 알림이 바로 그것이다.
return <Toast message={current.message} />;
지난 시간에 연결 표시를 만들 때는 role="status"를 일부러 안 붙였습니다. 늘 떠 있는 표시는 "방금 일어난 일"이 아니니까요.
알림은 정확히 그 반대라서, 방금 일어난 일이고 이번엔 맞습니다.
통로와 토스트를 어떻게 잇나요
통로는 앱에 있고, 토스트를 그리는 것은 컴포넌트입니다. 둘을 이을 것이 필요해요.
이것도 이미 해본 적이 있습니다. C-4의 확인 대화상자를 떠올려보세요. 어디서든 열 수 있어야 해서 store에 담고, 앱에 컴포넌트를 하나 그려뒀죠.
// apps/web-spa/src/stores/useNotificationStore.ts
export const useNotificationStore = create<NotificationState>()((set) => ({
current: null,
// 겹치면 나중 것이 이긴다. 알림 상자는 한 번에 하나만 띄운다.
show: (notification) => set({ current: notification }),
dismiss: () => set({ current: null }),
reset: () => set({ current: null }),
}));
통로가 show를 부르고, 컴포넌트가 그립니다.
// apps/web-spa/src/realtime/RealtimeBridge.tsx
// 알림도 나에게만 오는 소식이다. 쪽지와 같은 길, 다른 줄.
client.subscribe(NOTIFICATIONS_QUEUE, (message) => {
const notification = parseNotification(message.body);
if (notification === null) return;
useNotificationStore.getState().show(notification);
});
그리고 앱에 한 번만 그려둡니다.
// apps/web-spa/src/AppProviders.tsx
{/* 확인 상자는 감쌀 것이 없다. 앱에 한 번 그려두기만 하면 된다. */}
<ConfirmDialog />
{/* 알림 상자도 같은 자리다. 어느 화면에 있든 떠야 한다. */}
<NotificationToaster />
두 줄이 나란히 있는 게 보이시죠. 성격이 같으면 두는 곳도 같습니다.
띄웠으면 치웁니다
알림이 계속 떠 있으면 화면을 가립니다. 시간이 지나면 사라져야 해요.
// apps/web-spa/src/components/NotificationToaster.tsx
// 띄웠으면 치우는 것까지가 한 일이다 (B-4 에서 세운 규칙).
useEffect(() => {
if (current === null) return;
const timerId = setTimeout(dismiss, DURATION);
return () => clearTimeout(timerId);
}, [current, dismiss]);
정리 함수에서 타이머를 지우는 것도 B-4 그대로입니다. 안 지우면 알림이 연달아 올 때 앞 타이머가 뒤 알림을 일찍 치워버려요.
확인해봅니다
- 탭 두 개를 띄우고 각각 다른 사람으로 로그인합니다.
- minji 쪽에서 jaehoon 님의 게시물에 좋아요를 누릅니다.
- jaehoon 쪽 화면 아래에 알림이 뜹니다.
그리고 한 가지 더 보세요. minji 쪽에는 알림이 안 뜹니다. 자기가 한 일이니까요. 서버가 애초에 안 보냅니다.
같은 좋아요 한 번인데 두 갈래로 갈립니다. 좋아요 개수는 /topic으로 모두에게 가고, 알림은 /user로 게시물 주인에게만 갑니다.
| minji가 좋아요 한 번 | jaehoon | minji |
|---|---|---|
/topic/posts로 온 소식 |
1 | 1 |
/user/queue/notifications로 온 알림 |
1 | 0 |
💡 한 줄 정리
알림은 쪽지와 같은 길, 다른 줄로 옵니다. 모르는 종류는 버려서 서버가 늘려도 안 깨지게 하고, 띄우는 것은 이미 만들어둔 토스트와 store 패턴을 그대로 씁니다.
🙋 학생 질문 — "튜터님, 알림이 여러 개 한꺼번에 오면 어떻게 되나요?"
지금 코드는 나중 것이 이깁니다. store에 하나만 담고 있으니까요.
앞 알림이 사라지기 전에 새 알림이 오면 글자만 갈립니다. 앞 것은 못 읽고 지나가요.
이게 괜찮은 선택일까요? 상황에 따라 다릅니다.
알림이 드문드문 오는 서비스라면 괜찮습니다. 코드가 단순하고, 사용자가 놓친 것은 알림 목록에서 볼 수 있으니까요. 서버가 알림을 저장해두고 있고, 목록을 주는 주소도 있습니다.
반대로 알림이 몰아쳐 오는 서비스라면 문제가 됩니다. 그때는 배열로 담고 여러 개를 쌓아 보여주거나, 아예 "새 알림 3개"처럼 묶어서 보여줘요.
여기서 중요한 것은 지금 무엇을 고른 것인지 아는 것입니다. 하나만 띄우기로 한 것은 우리가 내린 결정이고, 조건이 바뀌면 다시 결정하면 됩니다.
Step 7: "끊긴 동안 온 것은 안 온다"
지난 시간에 남겨둔 구멍입니다
지난 시간 마지막에 이런 이야기를 했죠. 끊겼다 다시 붙는 것까지는 만들었는데, 끊겨 있는 동안 일어난 일은 어떻게 되느냐고요.
오늘 그걸 확인합니다. 직접 해보세요.
- 앱을 띄우고 첫 게시물의 좋아요 개수를 봅니다. 1240이라고 해봅시다.
- 연습용 서버를 끕니다. 연결 표시가 빨갛게 바뀝니다.
- 그 사이에 서버 쪽 값이 1300으로 올라갔다고 해봅시다. 남들이 누른 거예요.
- 서버를 다시 켭니다. 표시가 초록으로 돌아옵니다.
화면은 몇일까요?
1240입니다.
다시 붙었는데도 틀린 값이에요. 통로가 끊겨 있던 동안 서버가 보낸 소식은 어디에도 안 남습니다. 다시 붙는다고 그때 것을 다시 보내주지 않아요.
시간 →
붙어 있음 ●─────────● ●────────
↑ ↑
끊김 다시 붙음
그 사이 서버에서 일어난 일: 1240 → 1300
우리가 받은 소식: 없음
다시 붙은 뒤 화면: 1240 ← 틀렸다
통로는 편지함이 아닙니다
여기서 통로의 성질을 하나 확실히 해둡시다.
우체통이라면 집을 비운 동안 온 편지가 쌓여 있습니다. 돌아와서 열어보면 되죠.
통로는 그렇지 않습니다. 전화에 가깝습니다. 전화를 안 받으면 그 말은 그냥 사라져요. 상대가 다시 걸어주지 않는 한요.
그러니 다시 붙었을 때 우리가 해야 할 일이 있습니다. 그동안 뭐가 바뀌었는지 물어보는 것이에요.
다시 붙는 순간 물어봅니다
고치는 코드는 짧습니다.
// apps/web-spa/src/realtime/RealtimeBridge.tsx
onConnect: () => {
setStatus('connected');
// ★ 끊겨 있던 동안 일어난 일은 통로로 안 온다. 지나간 건 지나간 것이다.
// 그래서 다시 붙은 순간 받아둔 것을 전부 낡은 것으로 표시해 다시 물어본다.
//
// 처음 붙을 때는 안 한다 — 화면이 방금 받아온 것을 또 받을 이유가 없다.
// 실시간을 붙였다고 물어보는 방식을 버릴 수 없는 이유가 여기 있다.
if (connectedBefore.current) {
void queryClient.invalidateQueries();
}
connectedBefore.current = true;
invalidateQueries를 인자 없이 부르면 받아둔 것 전부가 낡은 것으로 표시됩니다. 지금 화면이 보고 있는 것부터 다시 물어봐요.
C-7에서 좋아요를 누른 뒤에 불렀던 그 함수입니다. 그때는 특정 키만 지정했는데 오늘은 전부예요. 무엇이 낡았는지 우리가 알 수 없기 때문입니다. 끊겨 있는 동안 무슨 일이 있었는지 모르니까요.
처음 붙을 때와 다시 붙을 때는 다릅니다
connectedBefore라는 것이 보이시죠. 왜 필요할까요?
onConnect는 붙을 때마다 불리고 앱을 처음 켤 때도 불리는데, 그때 무효화하면 어떻게 될까요?
화면이 방금 서버에서 받아온 것을 곧바로 또 받으면 첫 화면에서 요청이 두 배가 되므로, 갈랐습니다. 확인해보면 이렇게 나옵니다.
| 요청 횟수 | |
|---|---|
| 처음 붙었을 때 | 1번 |
| 끊겼다 다시 붙었을 때 | 2번 (다시 물어봄) |
useRef를 쓴 이유도 짚고 갈게요. 이 값은 화면에 안 나옵니다. 바뀌어도 다시 그릴 이유가 없어요. A-5에서 배운 그 기준입니다. 화면에 나오는 값이면 useState, 기억만 하면 되면 useRef.
그래서 실시간은 물어보기를 못 버립니다
여기가 오늘의 균형점입니다.
지난 시간에 우리는 폴링을 걷어내 요청 1200번을 0번으로 만들었는데, 오늘 다시 물어보고 있어요.
모순처럼 보이지만 아닙니다. 재는 방법이 다릅니다.
| 폴링 | 오늘 | |
|---|---|---|
| 언제 묻나 | 3초마다 무조건 | 끊겼다 다시 붙을 때만 |
| 한 시간에 몇 번 | 1200번 | 0번 (안 끊기면) |
| 무엇을 위해 | 바뀐 게 있나 확인하려고 | 놓친 것을 메우려고 |
평소에는 0번입니다. 끊길 때만 한 번이에요. 폴링을 되살린 게 아니라, 통로가 못 하는 일에만 요청을 쓰는 겁니다.
⚠️ 그러니 실시간을 붙였다고 REST API를 걷어내면 안 됩니다. 통로는 요청 위에 얹는 것이지 대체하는 게 아니에요.
💡 한 줄 정리
통로는 편지함이 아니라 전화입니다. 끊겨 있는 동안 온 것은 사라지므로, 다시 붙는 순간 물어봐서 메웁니다. 처음 붙을 때와는 해야 할 일이 다릅니다.
🙋 학생 질문 — "튜터님, 서버가 놓친 것만 골라서 다시 보내주면 안 되나요?"
됩니다. 실제로 그렇게 하는 시스템도 있어요.
방법은 이렇습니다. 서버가 보낸 소식마다 번호를 매기고, 클라이언트가 "나 47번까지 받았어요"라고 말하면서 다시 붙는 겁니다. 서버는 48번부터 보내주는 방식인데, 그만큼 치러야 할 값이 큽니다.
서버가 보낸 소식을 저장하고 있어야 합니다. 사람마다, 언제까지요. 일주일 만에 돌아온 사람에게 일주일치를 다 보내줄까요? 그러면 그동안 저장해둬야 하고요.
클라이언트가 어디까지 받았는지 믿을 수 있어야 하는데, 브라우저가 조작되면 남의 소식을 달라고 할 수도 있습니다.
그래서 대부분은 우리가 오늘 한 방식을 씁니다. 다시 붙으면 그냥 지금 상태를 통째로 물어보는 거예요. 서버는 아무것도 저장 안 해도 되고, 클라이언트도 아무것도 안 믿어도 됩니다. 대신 요청 한 번을 더 쓰죠.
메시지가 하나도 빠지면 안 되는 시스템 — 금융 거래나 주문 처리 같은 곳 — 에서는 번호를 매기는 쪽을 씁니다. 비용을 치를 만한 이유가 있으니까요.
Step 8: "여기까지가 우리가 할 수 있는 것"
먼저 오늘 얻은 것을 세어봅니다
두 시간에 걸쳐 통로를 열고 그 위에 기능을 얹었습니다. 화면에 뭐가 생겼는지 정리하면 이렇습니다.
| 기능 | 어느 길로 | 왜 그 길인가 |
|---|---|---|
| 남이 누른 좋아요·댓글 수 | /topic |
공개된 정보라 모두에게 가도 된다 |
| 내가 누르는 좋아요 | 요청 | 실패하면 되돌려야 해서 응답이 필요하다 |
| 쪽지 받기 | /user/queue |
받을 사람이 정해져 있다 |
| 쪽지 보내기 | /app |
짧은 것을 자주 보낸다 |
| 지나간 쪽지 | 요청 | 통로는 지나간 것을 모른다 |
| 알림 | /user/queue |
받을 사람이 정해져 있다 |
한 앱 안에 두 방식이 섞여 있는데, 그게 정상이에요.
그런데 실시간이 늘 좋은 건 아닙니다
지난 시간 첫 Step에서 미뤄둔 이야기를 이제 합니다. 언제 이게 과한가.
통로를 열면 치르는 값이 있습니다. 하나씩 보면 작은데 모으면 꽤 됩니다.
먼저 내려받는 양입니다. 라이브러리를 들이면서 첫 화면에 25 KB가 늘었어요. 지난 시간에 그걸 정직하게 짚었죠. 코드를 쪼개서 없앴던 500 KB 경고가 되돌아왔습니다.
그리고 서버가 붙들고 있는 것입니다. 요청은 왔다 가면 끝인데 연결은 앱을 켜두는 내내 남아요. 사용자 만 명이면 연결 만 개가 계속 열려 있습니다.
마지막으로 우리가 다뤄야 할 것이 늘어납니다. 끊김, 재연결, 놓친 것 메우기, 보낸 것 확인하기. 오늘 만든 것의 절반이 여기에 쓰였어요.
그러니 이렇게 물어보면 됩니다.
이 화면, 몇 초 늦어도 괜찮은가?
괜찮다 ──→ 다시 물어보기로 충분하다 (한 줄이면 되고 끊김을 안 다뤄도 된다)
아니다 ──→ 통로가 필요하다
늦으면 "틀린 값" 이 되는가?
그렇다 ──→ 통로가 필요하다 (채팅 · 경매 · 실시간 협업)
아니다 ──→ 다시 물어보기로 충분하다 (배포 상태 · 대시보드 · 알림 목록)
배포 상태를 보여주는 화면이라면 5초 늦어도 아무 문제 없습니다. 오히려 폴링이 나아요. 코드가 짧고, 서버가 재시작돼도 알아서 붙거든요.
⚠️ 도구를 배웠다고 쓸 곳을 찾지 마세요. 재보고 필요할 때 쓰는 것이 순서입니다. C-8에서 성능을 다룰 때 세운 그 순서와 같아요.
우리 코드로는 못 고치는 것들
이제 오늘의 마지막 이야기입니다. 실시간에는 화면에서 아무리 잘 짜도 안 풀리는 문제가 있습니다.
하나 — 순서를 보장하지 않습니다. 한 연결 안에서는 보낸 순서대로 도착하지만, 서로 다른 줄로 온 것 사이의 순서는 아무도 약속하지 않아요. 쪽지가 /user/queue/dm으로 오고 알림이 /user/queue/notifications로 올 때, 어느 쪽이 먼저 도착할지는 그때그때 다릅니다.
둘 — 같은 것이 두 번 올 수 있습니다. 그래서 오늘 번호를 확인하고 얹었죠. 이건 우리가 막을 수 있는 쪽입니다.
셋 — 서버가 여러 대면 소식이 경계를 못 넘습니다. 지난 시간 생각해볼 주제에서 다뤘던 그 문제예요. 사용자 A는 1번 서버에, B는 2번 서버에 붙어 있으면, 1번 서버가 "모두에게 뿌려"라고 해도 그 모두는 1번 서버에 붙은 사람들뿐입니다.
셋 중에서 우리가 고칠 수 있는 것은 둘뿐입니다. 하나와 셋은 화면 코드로 안 됩니다.
그런데 여기서 중요한 게 있어요. 못 고친다는 걸 아는 것이 실력입니다.
"가끔 소식이 안 와요"라는 제보를 받았다고 해봅시다. 우리 코드만 들여다보면 아무리 봐도 답이 안 나옵니다. 구독도 잘 하고 있고, 얹는 코드도 맞아요.
그때 "서버가 몇 대죠?"라고 물어볼 수 있으면, 그 하루가 절약됩니다.
💡 한 줄 정리
통로는 요청을 대체하지 않고 얹힙니다. 몇 초 늦어도 되는 화면에는 과하고, 늦으면 틀린 값이 되는 화면에는 필요합니다. 그리고 실시간 문제의 일부는 화면 코드로 못 고칩니다.
🙋 학생 질문 — "튜터님, 서버가 여러 대인 문제는 프론트엔드가 정말 아무것도 못 하나요?"
고칠 수는 없지만 덜 아프게 할 수는 있습니다.
오늘 Step 7에서 만든 것이 바로 그 완충입니다. 다시 붙을 때 물어보게 해뒀죠. 소식을 놓쳐도 화면을 다시 보러 오거나 연결이 한 번 끊겼다 붙으면 값이 맞춰집니다.
같은 이유로, 실시간으로 온 값만 믿고 그리는 화면은 위험합니다. 그 값이 안 올 수 있으니까요. 오늘처럼 "요청으로 받은 것 위에 통로가 얹히는" 모양이면, 통로가 못 오더라도 화면은 최소한 요청으로 받은 값을 보여줍니다.
그리고 하나 더. 이걸 문제로 인식해서 백엔드와 이야기하는 것 자체가 프론트엔드의 일입니다. 백엔드 쪽에서는 서버들 바깥에 소식을 중계하는 것을 하나 더 둬서 풉니다. 그 이야기를 꺼낼 수 있으려면 이 문제를 알고 있어야 해요.
마무리
두 시간에 걸친 실시간 이야기가 끝났습니다.
지난 시간에는 통로를 열었고, 오늘은 그 위에 기능을 얹었는데, 오늘 배운 것 중에 진짜 오래 남을 것은 기능이 아닙니다.
오늘 배운 핵심 세 가지
💡 하나 — 같은 코드가 다른 결과를 낼 수 있습니다. 두 사람이 구독하는 문자열은 글자 하나 안 달랐는데 도착하는 것은 달랐어요. 클라이언트 코드만 보고 있으면 영영 이해 못 하는 동작입니다. 판단하는 쪽이 어디인지를 알아야 보입니다.
💡 둘 — 통로에는 응답이 없습니다. 보내고 끝이에요. 그래서 확인이 필요하면 우리가 만들어야 하고, 만들다 보면 요청·응답을 다시 짓게 됩니다. 그럴 바에는 요청을 쓰는 게 낫다는 판단도 함께 배웠어요.
💡 셋 — 실시간은 물어보기를 대체하지 않습니다. 통로가 끊긴 동안은 아무것도 못 받고, 지나간 것도 못 받습니다. 그래서 지나간 것은 요청으로, 놓친 것은 다시 붙을 때 요청으로 메웠어요. 얹는 것이지 갈아치우는 게 아닙니다.
오늘 닫은 것
| 지난 시간에 넘긴 것 | 닫힌 곳 |
|---|---|
/topic은 구독한 사람 전부에게 간다 |
✅ Step 1·3 |
Step 4에서 이름만 보고 넘어간 /queue |
✅ Step 3 |
| 끊겨 있는 동안 온 소식은 못 받는다 | ✅ Step 7 |
| 통로에는 요청에 있는 것이 하나 없다 | ✅ Step 5 |
| 언제 이게 과한가 | ✅ Step 8 |
| 서버가 여러 대면 경계를 못 넘는다 | ✅ Step 8 |
다음 시간 예고
여기까지가 Category C입니다. 되짚어보면 꽤 왔어요.
주소로 화면을 나누고, 주소가 데이터를 데려오게 하고, 값을 나르는 문제를 풀고, 서버에서 가져오고 쓰고, 빠르게 만들고, 실시간을 얹었습니다. 우리 인스타그램은 이제 브라우저에서 도는 완성된 앱이 됐는데, 처음부터 있던 성질이 하나 있습니다. 모든 것이 브라우저에서 일어난다는 것.
주소창에 /p/1을 치면 브라우저가 먼저 빈 화면을 받고, 그다음 우리 코드를 내려받고, 그다음에야 서버에 게시물을 물어봅니다. 그 사이에 사용자는 빈 화면을 봐요.
그리고 검색 엔진이 우리 페이지를 읽으러 오면 어떻게 될까요? 빈 화면을 봅니다. 자바스크립트를 안 돌리는 방문자에게 우리 인스타그램은 그냥 흰 종이예요.
다음 시간부터 Category D입니다. 화면을 서버에서 먼저 그려서 보내는 방식을 배웁니다. 지금까지 만든 것을 버리는 게 아니라, 어디까지가 브라우저의 일이고 어디부터가 서버의 일인지 다시 나누는 시간이에요.
Next.js로 갑니다. 폴더 이름이 곧 주소가 되는 방식부터 시작할게요.
과제
[구현] 안 읽은 쪽지 개수를 머리말에
지금은 쪽지 화면에 들어가야만 새 쪽지가 왔는지 압니다. 머리말에서 알 수 있게 만들어보세요.
- 통로로 쪽지가 오면 개수를 하나 올립니다.
- 쪽지 화면에 들어가면 그 개수를 0으로 되돌립니다.
- 머리말 "쪽지" 옆에 개수를 보여줍니다. 0이면 안 보이게요.
- 내가 보낸 쪽지도 되돌아온다는 것을 잊지 마세요. 그것까지 세면 내가 보낼 때마다 개수가 올라갑니다.
- 이 개수를 어디에 담을지 세 갈래 규칙으로 먼저 정하고, 왜 그렇게 정했는지 한 문장으로 적어주세요.
[구현] 돌아온 사람에게는 즉시 붙는다
지난 시간 생각해볼 주제 3의 답을 실제로 만들어봅니다.
- 브라우저는 탭이 다시 앞으로 왔는지를 알려줍니다. 그 신호를 받으면 기다리던 것을 취소하고 즉시 한 번 시도하세요.
- 대기 시간도 처음 값으로 되돌립니다.
- ⚠️ 이미 붙어 있을 때는 아무것도 하면 안 됩니다. 멀쩡한 연결을 끊었다 붙이면 손해만 봐요.
- 만들고 나서, 탭을 오래 숨겨뒀다가 돌아왔을 때 화면 값이 맞는지 확인해보세요. Step 7에서 만든 것과 어떻게 맞물리는지 함께 적어주세요.
[탐구] 네 줄을 눈으로 보기
우리는 지금 한 연결 위에서 네 줄을 듣고 있습니다. 실제로 그런지 확인해보세요.
- 개발자 도구의 네트워크 탭에서
ws로 걸러 연결 하나를 찾습니다. - 오간 프레임 중
SUBSCRIBE를 전부 찾아 목적지를 적어보세요. - 로그인 전과 후에 각각 확인해서, 무엇이 달라지는지 적어보세요.
- 연결이 몇 개인지도 세어보세요. 네 줄을 듣는데 연결은 몇 개인가요?
[탐구] 표를 떼면 무슨 일이 생기나
Step 5에서 붙인 clientId를 일부러 빼보세요.
- 보낼 때
clientId를 안 실어 보냅니다. - 쪽지를 하나 보내고 화면을 지켜보세요.
- "보내는 중…"이 어떻게 되는지 적어보세요.
- 왜 그렇게 되는지 설명해보세요. 화면이 무엇을 근거로 확정 판정을 하고 있었나요?
- ⚠️ 확인이 끝나면 되돌리세요.
생각해볼 주제
1. 오늘 만든 쪽지에 아직 없는 것
우리 쪽지 화면에는 실무 메신저에 있는 것들이 여럿 빠져 있습니다.
그중에 읽음 표시를 생각해봅시다. 상대가 내 쪽지를 읽었는지 알려주는 기능이요.
이걸 만들려면 무엇이 필요할까요? 어떤 시점에 무엇을 누구에게 보내야 하는지 그려보세요. 그리고 그 과정에서 오늘 배운 것 중 무엇을 다시 쓰게 되는지 짚어보세요.
2. 통로가 하나인 것이 늘 좋을까
우리는 연결을 앱에 하나만 열었습니다. 화면마다 열면 서버 쪽 연결이 화면 수만큼 늘어나니까요.
그런데 그 하나가 모든 것을 나릅니다. 좋아요도, 쪽지도, 알림도요.
여기에 어떤 위험이 있을까요? 그리고 우리 앱이 커져서 실시간으로 다루는 것이 스무 가지가 되면 이 구조가 어떻게 변할지 생각해보세요. 힌트를 드리면, 지금은 붙을 때 모든 줄을 한꺼번에 구독하고 있습니다.
3. 화면에 안 뜨는 실패
Step 5에서 우리는 보내기 실패를 화면에 표시했는데, 그건 서버가 거절해서 답을 준 경우예요.
답조차 안 오는 경우를 생각해봅시다. 보내는 순간 연결이 끊겨서 프레임이 중간에 사라졌다면요? 서버는 그 쪽지를 받은 적이 없으니 거절 답도 안 보냅니다.
우리 화면은 어떻게 될까요? 그리고 이 상황을 사용자에게 어떻게 알려야 할까요? 시간이라는 단서를 쓸 수 있을지 생각해보세요.
✅ 예시 답안정답 보기
🎯 [과제 1 예시답안] 안 읽은 쪽지 개수를 머리말에 (30점)
채점 포인트
| 항목 | 배점 | 설명 |
|---|---|---|
| 담는 곳을 규칙으로 정함 | 8 | 세 갈래로 따져서 store를 고르고, 그 이유를 한 문장으로 적었는가 |
| 내가 보낸 것을 안 셈 | 10 | 되돌아온 내 쪽지까지 세면 안 된다는 것을 알아채고 걸렀는가 |
| 들어가면 0 으로 | 6 | 쪽지 화면에 들어갈 때 되돌리는 것까지 했는가 |
| 0 이면 안 보이기 | 6 | 개수가 0 일 때 아무것도 안 그리는가 |
풀이 예시
먼저 담는 곳부터 정하는데, 세 갈래로 따져보면 답이 하나로 좁혀집니다.
서버가 주인일까요? 아닙니다. 서버는 우리가 그 쪽지를 읽었는지 모릅니다. 알려면 "읽음"을 서버에 보내야 하는데 우리는 그걸 안 만들었어요.
주소에 담을까요? 아닙니다. 주소를 복사해서 친구에게 보냈는데 친구 화면에 내 안 읽은 개수가 뜨면 이상하죠.
머리말도 알아야 하고 쪽지 화면도 알아야 하니, store입니다.
interface UnreadState {
count: number;
add: () => void;
clear: () => void;
}
export const useUnreadStore = create<UnreadState>()((set) => ({
count: 0,
add: () => set((current) => ({ count: current.count + 1 })),
clear: () => set({ count: 0 }),
}));
여기서 함정이 하나 있습니다.
통로로 쪽지가 오면 무조건 하나 올리고 싶어지지만, 그러면 내가 보낼 때마다 개수가 올라갑니다.
Step 3에서 확인한 것 기억하세요? 서버는 보낸 사람에게도 되돌려 보냅니다. 그러니 내가 보낸 쪽지도 통로로 되돌아와요.
// ⚠️ 내가 보낸 것도 되돌아온다. 그것까지 세면 보낼 때마다 개수가 올라간다.
export function countIfFromOther(senderUsername: string, me: string | null): boolean {
return me !== null && senderUsername !== me;
}
보낸 사람이 나인지 확인하고 셉니다.
통로 쪽에는 한 줄만 얹으면 됩니다.
export function subscribeUnread(client: Client) {
return client.subscribe(DM_QUEUE, (message) => {
const received = parseDirectMessage(message.body);
if (received === null) return;
const me = useSessionStore.getState().username;
if (countIfFromOther(received.senderUsername, me)) {
useUnreadStore.getState().add();
}
});
}
실제로는 통로를 여는 곳의 쪽지 구독 안에 이 판단을 함께 넣으면 됩니다. 줄을 하나 더 열 필요는 없어요. 같은 쪽지를 두 번 구독하면 서버 쪽 부담만 늘어납니다.
들어가면 0으로 되돌립니다.
export function useClearUnreadOnEnter() {
const clear = useUnreadStore((state) => state.clear);
useEffect(() => clear(), [clear]);
}
0이면 안 그립니다.
export function UnreadBadge() {
const count = useUnreadStore((state) => state.count);
if (count === 0) return null;
return <span data-slot="unread-badge">{count}</span>;
}
자주 나오는 실수
내가 보낸 것까지 세는 것. 가장 흔합니다. 혼자 테스트하면 잘 안 보여요. 내가 보낼 때마다 개수가 올라가는데 "왔나 보다" 하고 넘어가게 됩니다. 두 탭으로 확인해야 잡힙니다.
개수를 useState로 머리말에 두는 것. 그러면 쪽지 화면에서 0으로 되돌릴 방법이 없습니다. 두 컴포넌트가 부모-자식도 아니거든요. C-3에서 본 그 문제입니다.
쪽지 구독을 하나 더 여는 것. 개수를 세려고 subscribe를 한 번 더 부르면, 서버는 같은 쪽지를 두 번 보냅니다. 이미 듣고 있는 곳에 한 줄을 더하면 됩니다.
🎯 [과제 2 예시답안] 돌아온 사람에게는 즉시 붙는다 (25점)
채점 포인트
| 항목 | 배점 | 설명 |
|---|---|---|
| 기다리던 것을 취소함 | 10 | 예약된 재시도를 지우고 곧바로 시도했는가 |
| 붙어 있을 때 안 건드림 | 8 | 멀쩡한 연결을 끊었다 붙이지 않았는가 |
| Step 7과 맞물림 설명 | 7 | 즉시 붙는 것과 놓친 것 메우기가 어떻게 이어지는지 적었는가 |
풀이 예시
브라우저가 주는 신호를 그대로 씁니다.
탭이 다시 앞으로 왔는지는 document.visibilityState로 알 수 있습니다. 바뀌는 순간에는 visibilitychange가 옵니다.
export function useRetryOnVisible(client: Client) {
useEffect(() => {
function onVisible() {
if (document.visibilityState !== 'visible') return;
void retryNow(client);
}
document.addEventListener('visibilitychange', onVisible);
return () => document.removeEventListener('visibilitychange', onVisible);
}, [client]);
}
핵심은 "기다리던 것을 어떻게 취소하나"입니다.
라이브러리는 다음 시도를 타이머로 예약해둡니다. 그 타이머가 아직 안 터졌으면, 서버가 이미 살아났어도 우리는 계속 기다려요.
deactivate()가 그 예약을 지우기 때문에, 곧바로 activate()하면 남은 대기 시간을 건너뜁니다.
export async function retryNow(client: Client): Promise<void> {
// ⚠️ 멀쩡히 붙어 있으면 아무것도 하지 않는다. 끊었다 붙이면 손해만 본다.
if (client.connected) return;
await client.deactivate();
client.activate();
}
첫 줄의 확인이 중요합니다. 사용자가 탭을 오갈 때마다 이 함수가 불리는데, 붙어 있는 연결까지 끊었다 붙이면 그때마다 구독이 다시 걸리고 Step 7의 무효화까지 돌아요. 아무 문제 없는 상황에서 요청이 늘어납니다.
재보면 이렇습니다.
다시 붙기까지 30초를 기다리도록 해두고 연결을 끊으면, 손대지 않는 한 시도가 안 늘어납니다. retryNow를 부르는 순간 곧바로 붙어요.
| 다시 붙기까지 | |
|---|---|
| 그냥 두면 | 예약된 시간만큼 (여기서는 30초) |
| 돌아온 신호를 받으면 | 즉시 |
Step 7과는 어떻게 맞물릴까요.
이 둘은 짝입니다.
즉시 다시 붙는 것만 있으면 화면 값이 틀린 채로 붙고, 놓친 것을 메우는 것만 있으면 메울 기회가 늦게 옵니다.
둘이 같이 있어야 사용자가 돌아온 순간 붙고, 물어보고, 맞는 값이 뜹니다. 다시 붙으면 Step 7의 무효화가 자동으로 따라오니까 우리가 더 할 일은 없어요.
자주 나오는 실수
activate()만 부르는 것. 이미 활성 상태라 아무 일도 안 일어납니다. 예약된 타이머는 그대로예요.
붙어 있는지 확인 안 하는 것. 탭을 오갈 때마다 연결이 끊겼다 붙습니다. 그때마다 구독이 다시 걸리고 무효화가 돌아서, 아무 문제 없을 때 요청만 늘어납니다.
window의 focus만 쓰는 것. 다른 창을 잠깐 클릭했다 돌아와도 불립니다. 탭이 실제로 숨겨졌다 돌아온 것을 보려면 visibilitychange가 맞습니다.
🎯 [과제 3 예시답안] 네 줄을 눈으로 보기 (20점)
채점 포인트
| 항목 | 배점 | 설명 |
|---|---|---|
| 구독 목적지 네 개를 찾음 | 8 | 프레임에서 SUBSCRIBE를 골라 목적지를 적었는가 |
| 로그인 전후 차이 | 6 | 무엇이 달라지는지 정확히 짚었는가 |
| 연결이 하나임을 확인 | 6 | 네 줄이 한 연결 위에 있다는 것을 확인했는가 |
풀이 예시
개발자 도구에서 찾는 법.
네트워크 탭을 열고 WS로 거릅니다. ws://localhost:8090/ws 하나가 보여요. 그것을 누르면 메시지 탭에 오간 프레임이 나옵니다.
SUBSCRIBE로 시작하는 프레임을 찾으면 네 개가 나옵니다.
SUBSCRIBE
id:sub-0
destination:/topic/posts
SUBSCRIBE
id:sub-1
destination:/user/queue/dm
SUBSCRIBE
id:sub-2
destination:/user/queue/notifications
SUBSCRIBE
id:sub-3
destination:/user/queue/errors
id가 다르다는 것도 눈여겨보세요. 나중에 하나만 끊으려면 이 번호가 필요합니다. 쪽지 화면을 떠날 때 나가는 UNSUBSCRIBE에 이 번호가 실립니다.
로그인 전후 차이.
로그인 전에는 CONNECT 프레임에 login 헤더가 없습니다. 로그인하면 통로가 한 번 닫혔다 다시 열리고, 새 CONNECT에 login:jaehoon이 실려요.
목적지 문자열 자체는 안 바뀝니다. /user/queue/dm은 그대로예요. 달라지는 것은 서버가 그 구독을 누구에게 묶느냐라서, 화면 코드만 봐서는 차이가 안 보입니다.
/user/queue/errors는 쪽지 화면에 들어가야 생기는데, 그 구독은 화면이 하기 때문입니다.
연결은 몇 개인가요.
하나입니다. 네 줄을 듣는데 소켓은 하나예요. STOMP가 한 연결 위에 여러 줄을 얹을 수 있게 해주기 때문입니다. 각 프레임에 subscription 번호가 붙어 있어서, 도착한 것이 어느 줄로 온 것인지 구분됩니다.
이게 지난 시간에 날 통로에 없던 것 중 하나입니다. 규약이 없으면 한 연결로 여러 종류를 나르는 방법이 없어요.
자주 나오는 실수
서버가 답하는 프레임을 구독으로 착각하는 것. 서버에서 오는 것은 MESSAGE입니다. SUBSCRIBE는 우리가 보내는 쪽이에요.
연결이 네 개라고 답하는 것. 줄과 연결을 헷갈린 겁니다. 네트워크 탭에서 WS 항목이 몇 줄인지 세어보면 하나입니다.
🎯 [과제 4 예시답안] 표를 떼면 무슨 일이 생기나 (25점)
채점 포인트
| 항목 | 배점 | 설명 |
|---|---|---|
| 증상을 정확히 관찰 | 10 | "보내는 중" 이 안 사라진다는 것을 확인했는가 |
| 원인을 코드로 설명 | 10 | 확정 판정의 근거가 무엇이었는지 짚었는가 |
| 되돌림 | 5 | 확인 후 원래대로 되돌렸는가 |
풀이 예시
증상부터.
clientId를 빼고 쪽지를 보내면 이렇게 됩니다.
- 쪽지는 정상적으로 갑니다. 상대 화면에는 잘 떠요.
- 내 화면에도 쪽지가 뜹니다. 서버가 되돌려 보내주니까요.
- "보내는 중…"이 안 사라져서, 같은 쪽지가 두 줄로 보여요. 하나는 확정된 것, 하나는 영영 보내는 중인 것.
왜 그런지.
화면이 확정 판정을 하는 근거는 이 줄이었습니다.
const confirmed = new Set(messages?.map((it) => it.clientId).filter((it) => it !== undefined));
const stillOutgoing = outgoing.filter((it) => !confirmed.has(it.clientId));
캐시에 들어온 쪽지에서 clientId를 모아 집합을 만들고, 기다리던 것 중에 그 표가 있는 것을 뺍니다.
표를 안 실어 보내면 서버도 안 돌려주니까, confirmed는 비어 있어요. 뺄 것이 없으니 기다리던 것이 계속 남습니다.
여기서 중요한 건 글자를 비교하지 않았다는 겁니다. 내용이 같은 쪽지를 두 번 보낼 수도 있고 상대가 똑같은 글자를 보낼 수도 있어서, 겹치지 않는 표를 우리가 만들어 붙인 겁니다.
한 걸음 더.
이 실험이 보여주는 게 하나 더 있습니다. clientId는 서버가 해석하는 값이 아니에요. 서버는 그냥 받은 것을 그대로 실어 돌려줄 뿐이라서, 이 표는 우리끼리의 약속인 셈입니다. 서버는 뜻도 모르고 되돌려주기만 하고, 그것만으로 우리는 "내가 보낸 그것"을 알아볼 수 있어요.
자주 나오는 실수
쪽지가 안 간다고 답하는 것. 잘 갑니다. 상대 화면을 보면 정상이에요. 안 되는 것은 내 화면의 확정 판정뿐입니다.
내용으로 비교하면 되지 않냐고 하는 것. 대부분은 되지만, 같은 글자를 두 번 보내는 순간 깨집니다. 첫 번째가 돌아왔을 때 두 개가 다 확정돼버려요.
생각해볼 주제
🤔 [생각해볼 주제 1] 오늘 만든 쪽지에 아직 없는 것
문제 상황 요약
읽음 표시를 만들려면 무엇이 필요하고, 그 과정에서 오늘 배운 것 중 무엇을 다시 쓰게 될까요.
튜터의 가이드 및 해설
먼저 필요한 것부터 세어보는데, 읽음 표시는 생각보다 조각이 많습니다.
| 조각 | 어디서 | 오늘 배운 것 중 |
|---|---|---|
| "내가 읽었다" 를 서버에 알리기 | 클라이언트 → 서버 | /app으로 보내기 |
| 상대에게 "읽혔다" 를 전하기 | 서버 → 상대 | /user/queue로 나에게만 |
| 언제 읽은 것으로 볼지 정하기 | 클라이언트 | (새로 정해야 함) |
| 지나간 읽음 상태 받기 | 요청 | 이력을 REST로 |
보시면 셋은 오늘 만든 것 그대로입니다. 보내는 길도 있고, 나에게만 오는 줄도 있고, 지나간 것을 받는 요청도 있어요.
새로 정할 것은 하나뿐입니다. 언제 읽은 것으로 보느냐.
이게 생각보다 어렵습니다.
쪽지 화면에 들어가면 읽은 걸까요? 화면에 들어갔지만 스크롤을 안 내려서 그 쪽지가 안 보였다면요? 탭을 열어두고 다른 일을 하고 있다면요?
메신저마다 답이 다릅니다. 화면에 들어가면 읽음으로 치는 것도 있고, 그 쪽지가 화면에 실제로 보여야 읽음으로 치는 것도 있어요.
여기서 배울 점은 이겁니다. 기술이 어려운 게 아니라 정의가 어렵습니다. 통로도 있고 보내는 길도 있는데, "읽었다"가 무슨 뜻인지 정하는 게 남아요. 그리고 그건 제품 결정이지 기술 결정이 아닙니다.
양 문제도 하나 더 있습니다.
쪽지 하나마다 읽음을 따로 보내면, 대화방을 한 번 열 때 쪽지 개수만큼 보내게 됩니다. 100개가 쌓여 있으면 100번이에요.
그래서 실무에서는 대개 마지막으로 읽은 번호 하나만 보냅니다. "나는 47번까지 읽었어" 라고요. 그 앞은 전부 읽은 것으로 칩니다. 한 번 보내면 끝이에요.
🎯 면접관을 홀리는 핵심 멘트
"읽음 표시를 붙일 때 통신 구조는 이미 있는 것을 그대로 씁니다. 보내는 건
/app, 상대에게 알리는 건/user큐, 지나간 상태는 REST로요. 정작 어려운 건 '읽었다'의 정의였고, 화면 진입 기준으로 할지 실제 노출 기준으로 할지는 제품 결정이라 기획과 합의했습니다. 그리고 쪽지마다 보내지 않고 마지막으로 읽은 번호 하나만 보내서, 대화방을 열 때 나가는 요청을 개수에 비례하지 않게 만들었습니다."
🤔 [생각해볼 주제 2] 통로가 하나인 것이 늘 좋을까
문제 상황 요약
연결은 앱에 하나입니다. 그 하나가 좋아요도, 쪽지도, 알림도 나릅니다. 실시간으로 다루는 것이 스무 가지가 되면 이 구조는 어떻게 될까요.
튜터의 가이드 및 해설
먼저 하나로 둔 이유부터 확인합니다.
연결은 서버가 붙들고 있는 것이라서, 화면마다 열면 사용자 한 명이 연결 다섯 개를 쓰게 됩니다. 사용자 만 명이면 오만 개예요.
그래서 하나로 둡니다. 여기까지는 명확해요.
하나라서 생기는 위험도 있습니다.
첫째, 그 하나가 끊기면 전부 멈춥니다. 좋아요도, 쪽지도, 알림도 동시에 죽어요. 오늘 만든 재연결과 무효화가 그래서 중요합니다.
둘째, 한 줄이 시끄러우면 다른 줄이 밀립니다. 한 연결 위에 여러 줄이 얹혀 있으니, 어느 한 줄로 초당 수백 건이 쏟아지면 같은 연결의 다른 줄도 늦어져요. 우선순위 같은 건 없습니다.
셋째, 필요 없는 것까지 받습니다. 지금 우리는 붙자마자 네 줄을 다 구독해요. 홈만 보다 나가는 사용자도 쪽지 줄을 열어둡니다.
스무 가지가 되면 뭐가 달라질까요.
셋째 문제가 커지는데, 붙을 때마다 스무 번 구독하고 그중 실제로 쓰는 건 두세 개뿐이라서, 방향은 붙을 때 전부 구독하지 않고 필요할 때 열고 끝나면 닫는 쪽으로 갑니다.
우리는 이미 그 방법을 알고 있어요. 오늘 Step 3에서 화면이 구독하고 떠날 때 거두는 것을 만들었습니다. 그때는 그것을 앱으로 옮겼지만, 기준이 있었죠. 떠나 있는 동안에도 필요한가.
스무 가지 중에서 그 기준을 통과하는 것만 앱이 듣고, 나머지는 그 화면이 듣고 떠날 때 거두면 됩니다.
연결을 늘리는 선택도 있습니다.
드물지만 성격이 완전히 다른 실시간이 섞이면 연결을 나누기도 합니다. 예를 들어 초당 수백 건이 오가는 실시간 시세와, 어쩌다 한 번 오는 알림을 한 연결에 태우면 알림이 밀려요.
기준은 한쪽의 양이 다른 쪽의 지연을 만들 만큼 차이가 나는가입니다. 그런 게 아니라면 하나가 낫습니다.
🎯 면접관을 홀리는 핵심 멘트
"연결은 앱에 하나로 두되 구독은 필요한 것만 열도록 나눴습니다. 기준은 그 소식이 그 화면을 떠나 있는 동안에도 필요한가였고, 쪽지와 알림은 앱이, 그 화면에서만 쓰는 것은 화면이 열고 떠날 때 거뒀습니다. 연결을 나누는 건 한쪽의 트래픽이 다른 쪽 지연을 만들 정도로 성격이 다를 때만 고려했습니다. 연결 수는 서버 용량에 직결되니까요."
🤔 [생각해볼 주제 3] 화면에 안 뜨는 실패
문제 상황 요약
보내는 순간 연결이 끊겨서 프레임이 중간에 사라졌다면, 서버는 그 쪽지를 받은 적이 없으니 거절 답도 안 보냅니다. 우리 화면은 어떻게 될까요.
튜터의 가이드 및 해설
먼저 화면이 어떻게 되는지부터 봅니다.
"보내는 중…"이 영원히 남습니다.
확정도 안 오고 거절도 안 옵니다. 우리 화면은 계속 기다려요. 사용자는 보낸 줄 알고 화면을 닫습니다.
이게 과제 4에서 본 것과 증상은 같지만 원인이 다릅니다. 그때는 표를 안 붙여서 알아볼 수 없었던 것이고, 지금은 애초에 아무 답도 없는 겁니다.
여기서 알 수 있는 게 있습니다.
Step 5에서 만든 실패 처리는 서버가 답을 줄 수 있을 때만 동작합니다. 서버까지 가서 거절당한 경우죠.
서버까지 못 간 경우는 아무도 안 알려주니, 우리가 스스로 판단해야 해요.
그래서 시간을 단서로 씁니다.
방법은 이렇습니다. 보낼 때 시각을 함께 적어두고, 일정 시간이 지나도 확정이 안 오면 실패로 봅니다.
몇 초로 정할까요? 여기에 정답은 없지만 기준은 있는데, 정상적인 왕복보다 넉넉히 길고 사용자가 이상하다고 느끼기 전보다는 짧게 잡는 것입니다. 대개 5초에서 10초 사이로 잡습니다.
여기서 하나 더 조합할 수 있어요. 우리에겐 연결 상태가 있어서, 통로가 끊긴 것을 아는 순간 기다리던 것들을 전부 실패로 볼 수 있습니다. 시간을 다 기다릴 필요가 없어요.
표시는 "실패"가 아니라 "모름"이어야 합니다.
여기서 한 번 멈칫하게 됩니다. 시간이 지났다고 그 쪽지가 정말 안 간 걸까요?
아닙니다. 서버까지 갔는데 돌아오는 길에 끊겼을 수도 있어요. 그러면 상대는 받았는데 우리만 모르는 상태입니다.
그러니 "안 갔어요"라고 단정하면 안 됩니다. 사용자가 다시 보내면 상대에게 같은 쪽지가 두 번 가요.
실무의 답은 다시 보내기 버튼과 표를 함께 쓰는 것입니다. 다시 보낼 때 같은 표를 붙여 보내면 서버는 이미 받았던 것인지 알아보고 저장하지 않습니다. 그러면 두 번 보내도 하나만 남아요.
오늘 만든 clientId가 여기서 두 번째 쓸모를 얻습니다.
🎯 면접관을 홀리는 핵심 멘트
"STOMP의 SEND는 응답이 없어서 서버가 거절한 실패만 처리할 수 있고, 서버까지 못 간 경우는 아무도 안 알려줍니다. 그래서 보낼 때 시각을 남기고 일정 시간 안에 확인이 안 오면 미확인으로 표시했습니다. 실패로 단정하지 않은 이유는 돌아오는 길에 끊겼을 수도 있어서고, 그래서 재전송에는 처음과 같은 clientId를 붙여 서버가 중복을 걸러내게 했습니다. 같은 표가 확인용과 중복 방지용으로 두 번 쓰입니다."