49.1 실시간 알림은 어떤 흐름이에요?
실시간으로 만드는 첫 실전 기능은 실시간 알림이에요. 누가 내 글에 댓글을 달거나, 나를 언급하거나, 주문 상태가 바뀌면 새로고침 없이 종 모양 아이콘에 빨간 점이 톡 뜨는 그거요.
흐름은 단순해요. 서버에서 나와 관련된 사건이 생기면, 서버가 그 통로로 알림 봉투를 밀어넣어요. 클라이언트는 그걸 받아서 세 가지를 해요. 안 읽은 개수를 하나 늘리고, 화면 구석에 잠깐 토스트(toast, 잠깐 떴다 사라지는 알림 띠)를 띄우고, 알림 목록에 새 줄을 꽂아요. 사용자 입장에선 "가만히 있었는데 뭔가 떴다"가 전부지만, 그 뒤엔 이 흐름이 돌고 있어요. 폴링으로 이걸 흉내 내면 반응이 굼뜨고 서버도 힘든데, 웹소켓이면 사건이 나는 즉시 도착해요.
49.2 알림 봉투는 어떻게 생겼나요?
알림도 결국 type이 notification인 봉투 하나일 뿐이에요. payload 안에 알림을 그리는 데 필요한 것들을 담아요.
{
"type": "notification",
"id": "n-8842",
"ts": 1720000000000,
"payload": {
"kind": "comment",
"title": "새 댓글이 달렸어요",
"body": "이혼음료님이 회원님 글에 댓글을 남겼어요",
"link": "/post/512#c-99"
}
}
여기서 id가 왜 중요한지 곧 드러나요. 이게 알림 하나하나의 주민번호 역할을 해서, 중복을 걸러내고 "읽음" 표시를 붙일 때 기준이 돼요. kind는 댓글인지 좋아요인지 같은 세부 종류라서, 아이콘을 다르게 그리거나 소리를 다르게 낼 때 써요. link는 사용자가 알림을 눌렀을 때 어디로 데려갈지예요. 이 네 칸만 있으면 알림 UI는 거의 다 그릴 수 있어요.
49.3 받은 알림을 화면에 어떻게 띄우나요?
route 함수가 type을 보고 알림을 이 처리로 넘겨줬다고 해봐요. 여기선 세 가지를 한꺼번에 해요.
function showNotification(payload, meta) {
// 1. 목록 맨 위에 새 줄 추가
notifications.unshift({ id: meta.id, ...payload, read: false });
// 2. 안 읽은 개수 다시 계산
unreadCount = notifications.filter(n => !n.read).length;
renderBadge(unreadCount);
// 3. 잠깐 뜨는 토스트
showToast(payload.title);
}
unshift로 목록 맨 앞에 꽂는 게 포인트예요. 알림은 최신이 위로 와야 자연스러우니까요. 그리고 개수를 다시 세서 배지를 갱신하고, 토스트로 방금 온 걸 살짝 알려요. 여기서 흔한 실수 하나. 토스트를 모든 알림마다 띄우면, 한꺼번에 열 개가 쏟아질 때 화면이 토스트로 도배돼요. 저는 이걸 겪고 나서 "최근 3초 안에 이미 토스트를 띄웠으면 배지만 갱신" 같은 완충을 넣었어요. 알림은 반가워야지 성가시면 사용자가 알림 자체를 꺼버리거든요.
49.4 안 읽은 개수는 어떻게 관리하죠?
안 읽은 개수는 알림 기능의 얼굴이에요. 종 아이콘 위의 그 빨간 숫자요. 원칙은 하나예요. 목록에서 매번 다시 세라는 거예요. "새 알림 오면 +1, 읽으면 -1" 식으로 숫자만 따로 굴리면 반드시 어긋나요.
왜냐면 알림은 여러 경로로 상태가 바뀌거든요. 새로 오기도 하고, 사용자가 목록을 열어 한꺼번에 읽음 처리하기도 하고, 다른 기기에서 읽어서 그 사실이 다시 넘어오기도 해요. 이 모든 경우에 +1 -1을 정확히 맞추는 건 사실상 불가능해요. 그래서 진짜 정보는 목록에 두고, 개수는 언제나 목록을 필터링해서 파생시켜요. 누가 읽음으로 바뀌든, 개수 계산은 딱 한 줄이에요.
unreadCount = notifications.filter(n => !n.read).length;
이 한 곳에서만 센다는 원칙을 지키면, 배지 숫자가 실제 목록과 어긋날 일이 없어요. 실시간에서 상태가 여기저기서 바뀔수록 이 원칙의 값어치가 커져요.
49.5 같은 알림이 두 번 뜨면요?
실시간 알림에서 가장 자주 나오는 민원이 "같은 알림이 두 번 떠요"예요. 왜 이런 일이 생기냐면요. 연결이 잠깐 끊겼다 붙는 사이 서버가 재전송을 하거나, 탭을 두 개 열어놔서 양쪽으로 오거나, 놓친 알림을 다시 받아오는 과정에서 이미 가진 것과 겹치기 때문이에요.
해결은 아까 강조한 id예요. 목록에 넣기 전에 같은 id가 이미 있는지 확인하고, 있으면 버려요.
function addNotification(item) {
const exists = notifications.some(n => n.id === item.id);
if (exists) return; // 중복이면 무시
notifications.unshift(item);
unreadCount = notifications.filter(n => !n.read).length;
renderBadge(unreadCount);
}
목록이 아주 길어지면 some으로 매번 훑는 게 느려질 수 있어서, 그럴 땐 이미 본 id들의 집합(Set)을 따로 두고 거기서 확인해요. 어느 쪽이든 원리는 같아요. id를 문지기로 세워 같은 알림은 한 번만 통과시키는 거죠. 이 문지기 하나가 그 성가신 중복 민원을 통째로 없애줘요.
49.6 끊긴 사이 놓친 알림은 어떡하죠?
웹소켓은 연결돼 있을 때만 메시지를 받아요. 사용자가 지하철에 있던 그 3분 동안 온 알림들은 허공으로 사라져요. 다시 연결됐다고 저절로 돌아오지 않아요. 그래서 실시간 알림은 웹소켓 하나만으로는 구멍이 나요.
메우는 방법은 이래요. 재연결에 성공하는 순간, "내가 마지막으로 받은 알림 이후의 것들 다 줘"라고 일반 요청으로 서버에 물어봐요. 기준점은 마지막 알림의 id나 시각이에요.
ws.onopen = async () => {
const last = notifications[0]?.id ?? null;
const res = await fetch("/api/notifications?since=" + last);
const missed = await res.json();
// 온 순서대로 넣되, 중복은 id가 걸러줌
for (const item of missed.reverse()) {
addNotification(item);
}
};
여기서 앞에서 만든 중복 방지가 진가를 발휘해요. since 기준이 살짝 겹쳐서 이미 가진 알림이 딸려 와도, addNotification의 id 검사가 알아서 걸러주거든요. 그래서 겹치는 건 무서워하지 말고, 빠지는 것만 조심하면 돼요. 넉넉하게 받아오고 중복은 문지기에게 맡기는 거예요. 이 실시간(웹소켓) + 놓친 것 메우기(요청) 조합이 알림을 구멍 없이 만드는 핵심이에요. 저는 이걸 안 넣었다가 "밤새 온 알림이 아침에 하나도 없어요"라는 제보를 받고 나서야 부랴부랴 붙였어요.
49.7 알림 구현을 정리하면요
실시간 알림은 간단해 보이지만, 안 읽은 개수와 중복과 놓친 것까지 챙기면 은근히 신경 쓸 게 많아요.
1. 서버 사건 발생 → notification 봉투 전송 → 목록·배지·토스트 갱신이 기본 흐름이에요.
2. 안 읽은 개수는 따로 굴리지 말고 언제나 목록을 필터링해 파생시키세요.
3. id를 문지기로 세워 같은 알림이 두 번 뜨지 않게 하세요.
4. 재연결 시 since로 놓친 알림을 받아와 웹소켓의 구멍을 메우세요.
알림은 사용자가 서비스를 다시 찾게 만드는 갈고리예요. 그래서 정확하게, 그리고 성가시지 않게 만드는 게 기능 구현만큼 중요해요. 두 번 뜨는 알림, 안 오는 알림 하나가 신뢰를 갉아먹으니까요.