50.1 채팅, 쉬워 보이는데 왜 까다롭죠?

채팅은 겉보기엔 참 단순해요. 입력창에 글자 치고, 엔터 누르면, 밑에 뜬다. 그런데 막상 만들어 보면 자잘한 함정이 줄줄이 나와요. 내가 보낸 메시지는 언제 화면에 띄울지, 남의 메시지가 순서 뒤죽박죽으로 도착하면 어쩔지, 새 메시지가 올 때 스크롤을 맨 밑으로 내릴지 말지 같은 것들이요.


저는 첫 채팅을 "보내면 서버가 확인해주고, 그제야 화면에 띄우자"는 정직한 방식으로 만들었어요. 그랬더니 엔터를 치고 내 말이 화면에 뜨기까지 반 박자가 비더라고요. 그 짧은 공백이 어찌나 어색하던지요. 채팅이 채팅답게 착착 붙는 느낌을 내려면 몇 가지 요령이 필요해요. 하나씩 풀어볼게요.

50.2 메시지는 어떤 모양으로 주고받죠?

먼저 메시지의 봉투 모양부터 정해요. 채팅 메시지 하나엔 생각보다 챙길 게 많아요. 누가 보냈는지, 내용이 뭔지, 언제 보냈는지, 그리고 이 메시지를 구별할 고유 번호까지요.


{
type: "chat",
id: "a1b2c3",
user: "민지",
text: "점심 뭐 먹어요?",
ts: 1720000000000
}


칸을 하나씩 볼게요. type은 이게 채팅 메시지라는 표시예요. 입장 알림 같은 다른 종류와 섞이니까요. id는 이 메시지만의 고유 번호로, 나중에 같은 메시지가 두 번 그려지는 걸 막는 열쇠예요. user는 보낸 사람, text는 내용이죠. 마지막 ts(timestamp, 보낸 시각)는 순서를 맞추는 기준이 돼요. 이 다섯 칸이 앞으로 다룰 낙관적 렌더, 중복 제거, 순서 정렬의 재료가 돼요.

50.3 내가 보낸 메시지, 언제 화면에 띄우죠?

여기가 채팅 손맛의 핵심이에요. 정직한 방식은 "서버에 보내고, 서버가 잘 받았다고 답하면, 그때 화면에 그린다"예요. 안전하지만 아까 말한 그 반 박자 공백이 생겨요. 그래서 실무에선 낙관적 렌더(optimistic rendering, 성공을 미리 낙관하고 먼저 그리기)를 써요.


이름 그대로예요. 서버 답을 기다리지 않고, 엔터를 누른 그 순간 내 화면에 먼저 띄워 버려요. 서버로 보내는 건 그거대로 진행하고요.


function sendMessage(text) {
const tempId = "tmp-" + Date.now();

// 1) 서버 답을 안 기다리고 화면에 먼저 그림
addToScreen({
id: tempId,
user: "나",
text: text,
status: "pending"
});

// 2) 그와 동시에 서버로 전송
ws.send(JSON.stringify({
type: "chat",
tempId: tempId,
text: text
}));
}


포인트는 tempId(임시 번호)예요. 아직 서버가 진짜 id를 안 줬으니, 우선 내 쪽에서 만든 임시 번호를 붙여둬요. 그리고 status를 "pending"(전송 중)으로 표시해요. 이러면 화면엔 메시지가 즉시 떠서 착 붙는 느낌이 나고, 옆에 흐린 시계 아이콘 같은 걸로 "아직 확인 전"임을 살짝 보여줄 수 있어요. 사용자 체감 속도가 완전히 달라져요.

50.4 서버가 되돌려준 내 메시지는 어쩌죠?

내 메시지를 서버로 보내면, 서버는 그걸 저장하고 진짜 id를 붙여서 모두에게(나 포함) 되돌려 보내요. 이걸 그대로 또 그리면 내 메시지가 두 번 뜨겠죠. 하나는 아까 미리 그린 것, 하나는 서버가 돌려준 것. 그래서 맞춰주는 작업이 필요해요.


서버가 답할 때 아까 내가 보낸 tempId를 같이 실어 주는 게 요령이에요. 그럼 받는 쪽에서 "아, 이건 내가 pending으로 올려둔 그 메시지구나" 하고 알아봐요.


ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
if (msg.type !== "chat") return;

// 내가 미리 그린 임시 메시지가 있으면 진짜로 교체
const pending = findByTempId(msg.tempId);
if (pending) {
pending.id = msg.id;
pending.status = "sent";
return; // 새로 그리지 않고 상태만 갱신
}

// 남이 보낸 새 메시지면 화면에 추가
addToScreen(msg);
};


흐름이 딱 두 갈래예요. 돌려받은 메시지에 내가 아는 tempId가 있으면, 새로 그리지 않고 임시 메시지의 번호를 진짜 id로 바꾸고 status를 "sent"(전송됨)로 올려줘요. 흐린 시계가 체크 표시로 바뀌는 거죠. 반대로 남이 보낸 메시지면 화면에 새로 추가하고요. 이 tempId 맞추기가 낙관적 렌더의 짝꿍이에요. 이게 없으면 내 말이 매번 두 번씩 떠요.

50.5 순서는 어떻게 맞추죠?

실시간의 얄궂은 점은 보낸 순서대로 도착한다는 보장이 없다는 거예요. 특히 여러 사람이 거의 동시에 말하거나 네트워크가 출렁이면, 나중에 보낸 말이 먼저 도착하기도 해요. 도착하는 족족 화면 맨 밑에 붙이기만 하면 대화가 뒤엉켜요.


그래서 순서의 기준은 도착 시점이 아니라 봉투 안의 시각(ts)이어야 해요. 서버가 메시지를 받은 시각을 ts로 박아서 모두에게 보내주면, 받는 쪽은 그 ts를 기준으로 자리를 잡아요.


function addToScreen(msg) {
const list = messages; // 화면에 뿌릴 배열
list.push(msg);

// 시각(ts) 순서대로 다시 정렬
list.sort((a, b) => a.ts - b.ts);

render(list);
}


대부분은 순서대로 잘 오니 정렬이 티도 안 나요. 하지만 어쩌다 한 박자 늦게 도착한 메시지가 있으면, ts 기준 정렬이 그걸 제 자리에 슬쩍 끼워 넣어줘요. 참고로 낙관적 렌더로 미리 그린 내 메시지엔 아직 서버 ts가 없으니, 우선 지금 시각을 임시 ts로 넣어뒀다가 서버 답이 오면 진짜 ts로 바꿔 다시 정렬하면 돼요. 시계에 기대야지 도착 순서에 기대면 안 돼요.

50.6 새 메시지가 오면 스크롤은 어떻게 하죠?

메시지가 밑으로 쌓이는 채팅에선, 새 메시지가 오면 맨 밑으로 스르륵 내려 최신 대화를 보여줘야 해요. 이걸 해주는 게 scrollIntoView예요. 지정한 요소가 보이도록 화면을 이동시켜요. 맨 아래에 눈에 안 보이는 기준 요소를 하나 두고 거기로 내리는 방식을 많이 써요.


const bottom = document.querySelector("#bottom-anchor");
bottom.scrollIntoView({ behavior: "smooth" });


그런데 여기 함정이 있어요. 무조건 맨 밑으로 내리면 큰일 나요. 사용자가 한참 위로 올려 옛날 대화를 읽는 중인데, 새 메시지가 왔다고 화면을 확 끌어내리면 읽던 자리를 놓쳐서 엄청 짜증나거든요. 저는 이걸로 실제 항의를 받았어요. 그래서 규칙은 "사용자가 이미 맨 밑 근처에 있을 때만" 자동으로 내리는 거예요.


function onNewMessage(msg) {
const box = document.querySelector("#chat");

// 스크롤이 바닥에서 얼마나 떨어져 있나 계산
const distance = box.scrollHeight - box.scrollTop - box.clientHeight;
const nearBottom = distance < 100;

addToScreen(msg);

if (nearBottom) {
bottom.scrollIntoView({ behavior: "smooth" });
}
}


계산은 간단해요. 전체 높이에서 지금 스크롤 위치와 보이는 높이를 빼면, 바닥까지 남은 거리가 나와요. 이게 100픽셀보다 작으면 "거의 바닥에 있네" 하고 따라 내려가고, 위에서 옛 대화를 읽는 중이면 가만히 둬요. 대신 "새 메시지 있어요" 같은 작은 버튼을 띄워 누르면 그때 내려가게 하면 최고죠. 이 배려 하나가 채팅의 완성도를 확 올려요.

50.7 오늘 배운 걸 묶어볼게요

채팅 구현을 한 장으로 접어볼게요.


핵심
1. 메시지는 id, user, text, ts를 담은 봉투로 주고받아요.
2. 내 메시지는 낙관적 렌더로 먼저 띄우고, 서버 답으로 tempId를 맞춰 교체해요.
3. 순서는 도착 시점이 아니라 봉투의 ts 기준으로 정렬해요.
4. 스크롤은 사용자가 바닥 근처일 때만 자동으로 내려요.


채팅은 보내고 받는 기본기 위에, 사용자가 눈치채지 못하는 자잘한 배려가 잔뜩 얹혀야 비로소 자연스러워요. 미리 그려 착 붙게 하고, 두 번 안 뜨게 맞추고, 순서를 시계로 잡고, 스크롤로 읽는 걸 방해하지 않기. 코드 양은 얼마 안 되지만 이 네 가지가 "잘 만든 채팅"과 "어딘가 어색한 채팅"을 가르는 자리예요.