SSE 동작흐름 #2



1. 다이어그램이 보여주는 네 개의 영역
다이어그램은 크게 네 박스로 구성되어 있습니다. 왼쪽 위는 사용자 A의 브라우저에서 동작하는 NotificationContext의 내부 구조입니다. 오른쪽 위는 서버의 SSE 엔드포인트와 알림 허브입니다. 왼쪽 아래는 댓글을 다는 사용자 B의 브라우저입니다. 오른쪽 아래는 댓글 API 서버입니다. 그리고 가장 아래에는 이 모든 것을 만드는 파일 생성 순서가 있습니다.
분홍색 점선은 알림이 흐르는 결정적인 경로입니다. 사용자 B가 댓글을 달면, 댓글 API가 notifyUser를 호출하고, 허브가 사용자 A의 enqueue를 찾아내어, A의 ReadableStream을 거쳐 A의 NotificationContext까지 알림이 도달합니다.
2. 프론트엔드의 NotificationContext 파일 내부 동작
NotificationContext.tsx 한 파일 안에서 네 가지 일이 순서대로 일어납니다. 먼저 NotificationProvider 컴포넌트가 useAuth 훅으로 현재 로그인된 사용자 정보를 가져옵니다. 그다음 useEffect가 user 값을 의존성으로 두고 실행되는데, user가 존재하면 new EventSource("/api/notifications/stream")을 실행해서 서버에 SSE 연결을 엽니다. EventSource 객체에 addEventListener로 "comment" 이벤트 리스너를 붙여놓으면 서버에서 데이터가 도착할 때마다 그 함수가 실행됩니다. 리스너 안에서는 setNotifications와 setUnread로 Context의 상태를 갱신합니다.
이 Provider 하위에 있는 NotificationBell 컴포넌트는 useNotifications 훅으로 Context 값을 구독합니다. Context의 상태가 바뀌면 Bell이 자동으로 다시 렌더링되어 빨간 배지가 화면에 나타납니다. 이 모든 동작이 NotificationContext.tsx 한 파일과 NotificationBell.tsx 한 파일의 협업으로 이루어집니다.
3. 백엔드 SSE 엔드포인트의 두 가지 책임
app/api/notifications/stream/route.ts 파일은 두 가지 책임을 가집니다. 첫째, 들어온 요청의 쿠키를 읽어 사용자를 인증합니다. EventSource는 헤더를 붙일 수 없지만 같은 출처라 쿠키는 자동으로 따라오므로 getCurrentUser가 정상 작동합니다. 둘째, 인증된 사용자를 위한 ReadableStream을 만들고 start 콜백 안에서 enqueue 함수를 정의합니다.
여기서 enqueue 함수가 클로저로 controller를 캡처한다는 점이 핵심입니다. 이 enqueue는 자기를 만든 ReadableStream의 controller를 평생 기억합니다. 그래서 나중에 이 함수만 들고 다녀도 호출하면 정확히 사용자 A의 연결로 데이터가 흘러갑니다. 이 함수와 userId를 묶어서 허브의 명단에 등록하는 순간, 외부에서 사용자 A에게 데이터를 보낼 수 있는 통로가 열립니다.
4. 알림 허브 파일의 역할
lib/notificationHub.ts 파일은 서버 메모리에 사는 명단입니다. globalThis에 Set을 하나 만들어서 모든 요청 핸들러가 같은 Set을 공유합니다. registerClient 함수는 enqueue와 userId를 받아 Set에 추가하고, 제거하는 함수를 반환합니다. notifyUser 함수는 Set을 순회하면서 userId가 일치하는 항목을 찾아 그 항목의 enqueue를 호출합니다.
이 파일이 왜 따로 분리되어야 하는지 생각해보면, 댓글 API 라우트와 SSE 스트림 라우트는 서로 다른 요청을 처리하는 별개의 함수입니다. 두 함수가 직접 서로를 호출할 방법이 없고, 같은 프로세스의 메모리에 있는 공유 자료구조를 통해서만 소통할 수 있습니다. 허브는 그 공유 자료구조 역할을 합니다.
5. 댓글 API의 알림 발신
app/api/posts/[id]/comments/route.ts의 POST 핸들러는 평범하게 댓글을 DB에 저장한 다음, 마지막에 한 줄이 더 있습니다. notifyUser(post.authorId, "comment", noti) 호출입니다. 이 한 줄이 허브의 명단에서 글쓴이의 enqueue를 찾아 SSE 프레임을 흘려보냅니다.
만약 댓글을 단 사람이 글쓴이 본인이라면 알림을 보내지 않도록 post.authorId !== user.id 체크를 먼저 합니다. 내가 내 글에 단 댓글에 알림이 오면 어색하기 때문입니다.
6. 파일을 만들어야 하는 순서
다이어그램 아래쪽 다섯 단계가 실제 작업 순서입니다. 의존성이 있는 파일부터 만들어야 컴파일 오류가 안 납니다.
첫째 단계는 types.ts에 Notification, Comment 타입을 정의하고 lib/notificationHub.ts를 만드는 것입니다. 허브는 다른 어떤 파일에도 의존하지 않고, 반대로 거의 모든 파일이 허브에 의존합니다. 가장 먼저 만들어야 할 토대입니다.
둘째 단계는 app/api/notifications/stream/route.ts입니다. 이 파일은 허브의 registerClient와 lib/auth의 getCurrentUser에 의존합니다. 허브가 만들어진 뒤에 작성해야 import가 깨지지 않습니다. 이 시점에서 브라우저에서 직접 /api/notifications/stream을 호출해보면 연결이 열리고 ready 이벤트가 도착하는 것을 확인할 수 있습니다.
셋째 단계는 app/api/posts/[id]/comments/route.ts입니다. 댓글 저장 로직과 함께 notifyUser 호출을 추가합니다. 이 시점에서는 아직 클라이언트가 SSE를 듣고 있지 않지만, 서버 콘솔에 로그를 찍어두면 notifyUser가 호출되는 것을 확인할 수 있습니다.
넷째 단계는 contexts/NotificationContext.tsx입니다. EventSource로 서버에 연결하고 받은 데이터를 Context 상태에 보관합니다. 이 시점부터 클라이언트와 서버가 진짜로 연결됩니다.
다섯째 단계는 UI를 붙이는 작업입니다. components/NotificationBell.tsx로 벨 아이콘과 드롭다운을 만들고, app/layout.tsx의 Provider 트리에 NotificationProvider를 AuthProvider 안쪽에 넣고, components/Header.tsx에 NotificationBell을 배치하고, components/PostDetail.tsx로 글 상세 페이지를 완성합니다. NotificationProvider가 AuthProvider 안쪽에 있어야 useAuth가 동작한다는 점이 중요합니다.
7. 의존성 방향을 보는 또 다른 시각
화살표 방향을 따라가면 의존성이 한쪽으로만 흐른다는 것을 알 수 있습니다. UI 컴포넌트는 Context에 의존하고, Context는 EventSource API와 lib/auth에 의존합니다. 서버 쪽에서는 SSE 라우트와 댓글 라우트가 모두 허브에 의존하지만 허브는 누구에게도 의존하지 않습니다. 허브는 단순한 Map과 함수 몇 개로 끝나는 가장 가벼운 파일이고, 그래서 가장 먼저 만들 수 있고 가장 안정적입니다.
이 의존성 구조 덕분에 나중에 알림 종류를 추가하기 쉽습니다. "좋아요" 알림을 추가하려면 좋아요 API에서 notifyUser(authorId, "like", data)를 호출하고, NotificationContext에서 addEventListener("like", ...)를 추가하면 끝입니다. 허브와 SSE 엔드포인트는 손대지 않아도 됩니다.


댓글
댓글을 작성하려면 이 필요합니다.