SSE 동작흐름 1


1. 그림의 전체 구조 이해
그림은 크게 왼쪽과 오른쪽 두 영역으로 나뉘어 있습니다. 왼쪽은 프론트엔드 영역으로 두 명의 사용자 A와 B가 각자의 브라우저에서 Context Provider를 통해 알림 상태를 관리하는 모습입니다. 오른쪽은 백엔드 영역으로 각 사용자마다 별도의 ReadableStream(RS)이 만들어져 알림 허브에 등록되어 있는 모습입니다. 가운데 노란색으로 강조된 api/notifications/stream 엔드포인트가 두 영역을 연결하는 통로입니다.
2. 왼쪽 영역, 클라이언트의 Context Provider
사용자 A의 브라우저를 보면 Context Provider가 가장 바깥을 감싸고 있고, 그 안에 컴포넌트들이 들어있습니다. 이것은 첨부 문서의 NotificationProvider 구조를 그린 것입니다. NotificationProvider는 useAuth로 현재 로그인한 사용자를 구독하고, 사용자가 있으면 EventSource를 통해 서버에 SSE 연결을 엽니다.
여기서 중요한 점은 Context Provider가 단순히 데이터를 전달하는 통이 아니라는 사실입니다. 이 Provider 안에서 useEffect가 돌아가면서 EventSource를 생성하고, 받은 알림을 useState로 보관하며, 하위 컴포넌트들이 useNotifications 훅으로 그 값에 접근할 수 있게 합니다. 그림에서 Context Provider가 화면 전체를 감싸고 있는 큰 박스로 그려진 이유가 여기 있습니다. NotificationBell 같은 하위 컴포넌트가 어디에 있든 같은 알림 상태를 공유할 수 있어야 하기 때문입니다.
사용자 B도 똑같은 구조를 가집니다. 다만 B의 Context Provider는 B의 브라우저 안에서만 동작하고, B 자신의 EventSource 연결을 가집니다. 두 사용자의 Context는 서로 완전히 격리되어 있습니다.
3. 가운데 영역, GET 요청과 스트림 연결
그림 가운데에 api/notifications/stream이 있고 GET 화살표가 그쪽으로 향합니다. 사용자가 로그인하는 순간 브라우저에서 new EventSource("/api/notifications/stream")이 실행되고, 이것은 내부적으로 그 URL에 GET 요청을 보내는 것과 같습니다.
일반 GET 요청과 다른 점은 응답이 끝나지 않는다는 것입니다. 보통의 GET 요청은 서버가 데이터를 보내고 연결을 닫지만, SSE 요청은 서버가 응답 헤더를 보낸 다음 본문을 조금씩 흘려보내면서 연결을 계속 열어둡니다. 그림에서 가운데를 가로지르는 긴 선이 바로 이 열린 연결입니다.
연결이 열릴 때 쿠키가 자동으로 함께 전송됩니다. EventSource는 헤더를 직접 붙일 수 없지만 같은 출처라면 httpOnly 쿠키가 자동으로 따라가기 때문에 서버는 getCurrentUser로 누구의 연결인지 알 수 있습니다. 그림에서 화살표가 사용자 영역에서 백엔드 영역으로 넘어가는 모습이 이 인증 과정을 보여줍니다.
4. 오른쪽 영역, 백엔드의 ReadableStream과 허브
오른쪽 위에 RS(ReadableStream)와 start(controller)가 보입니다. 사용자 A가 SSE 연결을 열면 서버는 그 요청을 처리하기 위해 새로운 ReadableStream을 만듭니다. 그 안에서 start 함수가 실행되고, 노란색으로 강조된 enqueue 함수가 정의됩니다. enqueue는 이 특정 연결로 데이터를 흘려보낼 수 있는 함수입니다.
여기서 핵심은 enqueue가 클로저라는 점입니다. enqueue는 자신을 만든 ReadableStream의 controller를 기억하고 있습니다. 그래서 나중에 이 enqueue 함수만 어디론가 들고 가도 그 함수를 호출하면 정확히 사용자 A의 연결로 데이터가 흘러갑니다.
이 enqueue 함수가 사용자 ID와 함께 알림 허브의 명단(Set)에 등록됩니다. 그림 아래쪽의 userId와 enqueue가 묶여있는 부분이 이것을 표현합니다. 사용자 B도 같은 방식으로 자기만의 enqueue를 가지고 명단에 등록됩니다. 결과적으로 허브에는 다음과 같은 항목들이 쌓입니다.
{ userId: "alice", enqueue: alice의_연결로_쏘는_함수 } { userId: "bob", enqueue: bob의_연결로_쏘는_함수 }
5. 알림이 흐르는 순간
이제 사용자 B가 사용자 A의 글에 댓글을 다는 상황을 그림과 함께 따라가 보겠습니다. B의 브라우저에서 댓글 POST 요청이 서버로 갑니다. 댓글 API는 댓글을 DB에 저장한 다음 글쓴이가 A라는 것을 확인합니다.
그다음 notifyUser("A", "comment", 알림데이터)를 호출합니다. 이 함수는 허브의 명단을 순회하면서 userId가 A인 항목을 찾고, 그 항목에 들어있는 enqueue 함수를 호출합니다. 그 enqueue는 A의 ReadableStream으로 데이터를 밀어넣는 함수이므로 데이터는 A의 열린 SSE 연결을 통해 A의 브라우저로 흘러갑니다.
A의 브라우저에서는 NotificationProvider 안의 EventSource가 그 데이터를 받아 comment 이벤트 리스너를 발동시킵니다. 리스너는 setNotifications와 setUnread를 호출해서 Context의 상태를 갱신합니다. Context를 구독하던 NotificationBell 컴포넌트가 자동으로 다시 렌더링되고 빨간 배지가 화면에 나타납니다.
그림에서 분홍색 화살표들이 이 데이터 흐름을 강조합니다. 백엔드 허브에서 시작해 RS의 controller를 거쳐 stream을 타고 클라이언트의 Context로 들어가는 경로입니다.
6. 두 사용자가 격리되는 이유
그림에서 A와 B가 위아래로 따로 그려진 이유는 두 사람의 연결과 enqueue가 완전히 분리되어 있기 때문입니다. 같은 Node 프로세스의 같은 허브에 등록되어 있지만, 각자 자기만의 ReadableStream과 자기만의 enqueue 함수를 가집니다.
notifyUser가 A에게 알림을 보낼 때 허브를 순회하면서 userId 비교를 합니다. B의 항목은 userId가 다르므로 건너뛰고, A의 항목에서만 enqueue가 호출됩니다. 그래서 B는 A에게 가는 알림을 절대 받지 못합니다. 그림에서 분홍색 화살표가 A의 RS로만 향하고 B의 RS로는 가지 않는 것이 이 선택적 전송을 보여줍니다.
7. 연결이 끊어졌을 때
그림 왼쪽 아래 파란색으로 표시된 부분이 정리 과정을 나타냅니다. 사용자가 탭을 닫거나 로그아웃하면 EventSource가 닫히고, 그 신호가 서버의 req.signal에 도달해 abort 이벤트가 발생합니다. cleanup 함수가 실행되어 하트비트 타이머를 멈추고, 허브의 명단에서 자신의 enqueue 항목을 제거하고, ReadableStream을 닫습니다.
이 정리가 제대로 되어야 다음에 notifyUser가 호출됐을 때 죽은 연결로 데이터를 보내려 하지 않습니다. 그림에서 명단 제거 부분이 강조된 이유가 여기 있습니다.
8. 그림이 보여주는 핵심 통찰
이 그림이 전달하려는 큰 개념은 세 가지로 정리할 수 있습니다. 첫째, 프론트엔드의 Context Provider는 SSE 연결의 수명을 관리하는 컨테이너이자 받은 알림을 하위 컴포넌트와 공유하는 저장소입니다. 둘째, 백엔드의 ReadableStream은 사용자마다 하나씩 만들어지는 데이터 통로이며, 그 통로에 데이터를 넣는 enqueue 함수가 허브에 등록되어 외부에서 호출 가능한 상태가 됩니다. 셋째, 댓글 API 같은 다른 요청 핸들러는 허브를 통해 특정 사용자의 enqueue를 찾아내어 그 사용자에게만 데이터를 흘려보낼 수 있습니다.
이 세 가지가 맞물려서 서버가 클라이언트에게 먼저 말을 거는 실시간 푸시가 완성됩니다. 평범한 HTTP 요청과 응답만으로는 불가능했던 일을, 연결을 계속 열어두고 enqueue 함수를 메모리에 보관하는 단순한 아이디어로 해결한 것입니다.


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