실시간 2편 — SSE로 실시간을 구현했다
토픽마다 EventSource를 하나씩 열던 초기 방식을 버리고, 프로젝트당 연결 1개로 멀티플렉싱하는 SSE를 설계했다. 도메인 무지 transport와 도메인 정책을 분리한 백엔드, 연결/해석 2계층으로 나눈 프론트엔드의 실제 코드.
노트 목록 화면을 열어두고 있는데, 다른 사람이 노트를 달아도 화면이 그대로였다. 새로고침을 해야 보였다. 사내 협업 피드백 툴을 만들면서 처음 마주친 실시간 요구사항이었다.
1편에서 SSE(Server-Sent Events)를 골랐다. 이번 글은 그걸 실제로 어떻게 구현했는지, 코드를 하나씩 열어보는 글이다.
토픽마다 연결을 하나씩 열었다가
초기 구현은 단순했다. 버전 토픽마다 EventSource를 하나씩 열었다. 버전 A를 보면 버전 A용 연결, 버전 B로 전환하면 끊고 다시 연결.
처음엔 이게 맞는 줄 알았다. 그런데 도메인이 늘어날 걸 생각하니 답이 안 나왔다. 노트 다음엔 댓글, 그다음엔 드로잉. 도메인마다, 토픽마다 연결을 열면 연결 수가 곱으로 늘어난다.
그래서 방향을 바꿨다. 프로젝트 하나당 EventSource 1개. 그 연결 위에 노트, 댓글, 드로잉 같은 도메인 이벤트를 전부 멀티플렉싱해서 태운다.
설계 원칙: transport는 도메인을 모른다
연결을 하나로 합치면 새 문제가 생긴다. 하나의 스트림에 여러 도메인 이벤트가 흐르는데, 그 조립을 누가 하느냐. SSE 모듈이 노트를 import하기 시작하면, 도메인이 늘 때마다 SSE 모듈이 계속 뚱뚱해진다.
그래서 세 계층으로 잘랐다.
libs/sse— 범용 transport. 어떤 도메인도 모른다.NoteProjectSseContributor— 노트 도메인의 정책(권한 필터, self-echo 차단, wire 변환)을 가둔다.NoteSseGateway— resolver가 호출하는 publish 진입점. resolver는 transport를 모른다.
전체 흐름은 이렇게 된다.
사용자 B mutation └→ resolver → NoteSseGateway.publish └→ SseBus.publish("project:{id}") └→ NoteProjectSseContributor (권한 필터 + self-echo 차단 + wire 변환) └→ SseService.merge(contributors + heartbeat) └→ SseController @Sse('project/:projectId') └→ 사용자 A EventSourcelibs/sse가 인터페이스(포트)를 소유하고, 구현은 각 도메인 모듈이 제공한다. 의존 방향이 도메인 → transport 한쪽으로만 흐른다. DIP(의존성 역전 원칙)를 그대로 코드에 옮긴 구조다.
백엔드 코드
엔드포인트는 하나뿐이다
@Controller('sse')@UseGuards(HttpSessionAuthGuard)export class SseController { constructor(private readonly sseService: SseService) {}
/** * project 단위 SSE 구독 엔드포인트. EventSource 1개당 1 project. * 인증·권한 필터·wire 변환은 SseService가 담당한다. */ @Sse('project/:projectId') stream( @Req() req: Request & { user: RequestUser }, @Param('projectId') projectId: string ): Observable<MessageEvent> { return this.sseService.buildProjectStream(projectId, req.user.userId); }}컨트롤러가 하는 일은 인증된 사용자와 projectId를 받아 스트림 빌더에 넘기는 것뿐이다. 도메인이 열 개가 되어도 엔드포인트는 이 하나로 끝이에요.
SseService — 기여자를 자동으로 수집한다
여기가 이 설계의 백미다.
const HEARTBEAT_INTERVAL_MS = 25_000;
/** * project 단위 SSE stream 빌더 — 도메인 무지(domain-agnostic) 범용 transport. * * 권한 필터·self-echo·wire 변환 같은 도메인 정책은 각 Contributor가 가진다. * 그래서 이 클래스는 note/project-member 등 어떤 도메인 모듈에도 의존하지 않는다(DIP). */@Injectable()export class SseService implements OnModuleInit { private contributors: ProjectSseContributor[] = [];
constructor(private readonly discovery: DiscoveryService) {}
/** * @SseProjectContributor() 표식이 달린 provider들을 부트스트랩 시점에 수집한다. * 새 도메인 이벤트는 Contributor를 추가 등록만 하면 합류한다 — 이 파일은 수정 불필요(OCP). */ onModuleInit(): void { this.contributors = this.discovery .getProviders() .filter( (wrapper) => wrapper.metatype != null && wrapper.instance != null && Reflect.getMetadata(SSE_PROJECT_CONTRIBUTOR_METADATA, wrapper.metatype) === true ) .map((wrapper) => wrapper.instance as ProjectSseContributor); }
buildProjectStream(projectId: string, userId: string): Observable<MessageEvent> { return merge( ...this.contributors.map((contributor) => contributor.buildStream(projectId, userId)), this.heartbeat$() ); }
/** * 25초마다 ping 신호를 보내 프록시·LB의 idle timeout으로 연결이 끊기는 걸 막는다. */ private heartbeat$(): Observable<MessageEvent> { return interval(HEARTBEAT_INTERVAL_MS).pipe( map<number, MessageEvent>(() => ({ type: 'ping', data: 'keepalive' })) ); }}두 가지를 짚고 넘어가고 싶다.
첫째, DiscoveryService. NestJS가 부트스트랩할 때 @SseProjectContributor() 표식이 달린 provider를 전부 훑어서 모은다. 댓글 실시간을 추가하고 싶으면 댓글 모듈에 Contributor를 하나 만들어 등록하면 끝이다. SseService는 한 줄도 안 고친다. 확장에는 열려 있고 수정에는 닫혀 있다 — OCP(개방-폐쇄 원칙)가 문장이 아니라 동작으로 존재하는 지점이다.
둘째, heartbeat 25초. SSE 연결은 이벤트가 없으면 침묵한다. 프록시나 로드밸런서는 침묵하는 연결을 idle로 판단해 끊어버리는데, 이 타임아웃이 대개 30초 또는 60초로 설정돼 있다. 그 최소값보다 짧은 25초마다 ping을 보내서 "이 연결은 살아 있다"는 걸 중간 장비에 계속 알린다. 데이터가 아니라 연결 유지를 위한 신호다.
SseBus — 40줄짜리 pub/sub
토픽 라우팅은 Map<string, Subject> 하나로 해결했다.
/** * SSE 토픽 → 구독자(Subject) 라우터. 도메인을 모르는 in-memory pub/sub helper. * * 의존성 0, pure transport. 채널 키 형식(`project:${projectUlid}` 등)은 호출자가 정의한다. */@Injectable()export class SseBus { private readonly channels = new Map<string, Subject<MessageEvent>>();
subscribe(topic: string): Observable<MessageEvent> { const subject = this.channels.get(topic) ?? new Subject<MessageEvent>(); this.channels.set(topic, subject);
return subject.asObservable().pipe( finalize(() => { if (!subject.observed) { subject.complete(); this.channels.delete(topic); } }) ); }
publish(topic: string, payload: Record<string, unknown>): void { this.channels.get(topic)?.next({ data: { ...payload, ts: Date.now() }, }); }}핵심은 finalize다. 구독자가 연결을 끊을 때 실행되는데, 그 토픽의 구독자가 0이 되면(!subject.observed) Subject를 complete하고 Map에서 지운다. 이걸 안 하면 프로젝트를 한 번이라도 열었던 채널이 Map에 영원히 남는다. 프로젝트가 쌓일수록 채널 Map이 무한히 자라는 메모리 누수다. pub/sub을 직접 만들 때 publish보다 먼저 챙겨야 하는 게 정리 경로라는 걸 여기서 배웠다.
포트와 표식 — DIP의 경계선
export const SSE_PROJECT_CONTRIBUTOR_METADATA = 'sse:project-contributor';
/** * project SSE 스트림에 "한 도메인의 이벤트"를 기여하는 포트(추상). * * 범용 SseService는 이 인터페이스만 알고, 구현은 각 도메인 모듈이 제공한다. */export interface ProjectSseContributor { /** * 주어진 사용자에게 보낼 이 도메인의 project 이벤트 스트림을 만든다. * 권한 필터·self-echo 차단·wire 변환은 각 구현이 책임진다. */ buildStream(projectId: string, userId: string): Observable<MessageEvent>;}
/** * DiscoveryService가 런타임에 수집할 수 있도록 다는 표식. * 이 데코레이터가 붙은 provider는 자동으로 project SSE 스트림에 합류한다(OCP). */export const SseProjectContributor = (): ClassDecorator => SetMetadata(SSE_PROJECT_CONTRIBUTOR_METADATA, true);인터페이스(추상)를 libs/sse가 소유한다는 게 포인트다. 도메인 모듈이 transport의 추상에 맞춰 구현을 제공하지, transport가 도메인을 알아가는 게 아니다. 의존성의 화살표를 어디로 향하게 할 것인가 — 이 파일 하나가 그 결정이다.
NoteProjectSseContributor — 도메인 정책 3종
/** * 역할 → 볼 수 있는 note scope. WORKER는 FINAL만, PM·REVIEWER는 둘 다. */const ALLOWED_NOTE_SCOPES_BY_ROLE: Record<ProjectRole, NoteScope[]> = { [ProjectRole.WORKER]: [NoteScope.FINAL], [ProjectRole.PM]: [NoteScope.GENERAL, NoteScope.FINAL], [ProjectRole.REVIEWER]: [NoteScope.GENERAL, NoteScope.FINAL], [ProjectRole.WATCHER]: [NoteScope.GENERAL, NoteScope.FINAL],};
/** * note 도메인의 project SSE 이벤트 기여자. * note 권한 정책(role → scope), self-echo 차단, wire 변환을 여기에 가둔다. */@Injectable()@SseProjectContributor()export class NoteProjectSseContributor implements ProjectSseContributor { constructor( private readonly bus: SseBus, private readonly projectMemberService: ProjectMemberService ) {}
buildStream(projectId: string, userId: string): Observable<MessageEvent> { return this.allowedScopes$(projectId, userId).pipe( switchMap((allowedScopes) => this.bus.subscribe(`project:${projectId}`).pipe( filter((ev) => this.isVisibleTo(ev.data as NoteSsePayload, userId, allowedScopes)), map((ev) => this.toNamedEvent(ev.data as NoteSsePayload)) ) ) ); }
/** * 사용자의 project role 조회 → 허용 scope 목록. defer로 subscribe(연결) 시점에 1회 실행. */ private allowedScopes$(projectId: string, userId: string): Observable<NoteScope[]> { return defer(async () => { const member = await this.projectMemberService.getProjectMemberByProjectIdAndUserId({ projectId, userId, }); return ALLOWED_NOTE_SCOPES_BY_ROLE[member.role] ?? []; }); }
/** * 자기 publish는 무시(self-echo), 허용 scope만 통과. */ private isVisibleTo( payload: NoteSsePayload, userId: string, allowedScopes: NoteScope[] ): boolean { if (payload.actorId === userId) return false; return allowedScopes.includes(payload.scope as NoteScope); }
/** * wire 변환. type을 `note.${scope}`로 매핑, actorId는 차단에만 쓰고 wire에서 제거. */ private toNamedEvent(payload: NoteSsePayload): MessageEvent { return { type: `${payload.domain}.${payload.scope.toLowerCase()}`, data: { ts: payload.ts }, }; }}도메인 정책 세 가지가 전부 여기 있다.
첫째, 역할 → scope 매핑. 이 서비스의 노트에는 GENERAL과 FINAL 두 scope가 있고, WORKER는 FINAL만 볼 수 있다. 클라이언트는 신뢰 경계가 아니므로, 볼 수 없는 scope의 이벤트는 wire로 애초에 내려보내지 않는다. 프론트에서 숨기는 게 아니라 서버가 필터한다.
둘째, self-echo 차단. payload.actorId === userId면 버린다. 내가 노트를 썼는데 내 화면에 "새 글이 왔어요" 배너가 뜨면 이상하잖아요. 내 행동의 반영은 mutation의 onSuccess invalidate가 이미 처리한다.
셋째, wire 변환. 이벤트 이름을 note.general / note.final로 매핑하고, actorId는 wire에서 뺀다. actorId는 서버 내부의 필터링용 정보다. 필터에만 쓰이는 내부 정보를 클라이언트에 흘릴 이유가 없다.
defer도 짚을 만하다. role 조회는 DB를 치는 비동기 작업인데, defer로 감싸서 subscribe — 즉 SSE 연결이 실제로 열리는 시점에 딱 1회 실행된다. 연결당 한 번 role을 확정하고, 이후 이벤트는 그 스냅샷으로 필터한다.
발행 쪽에는 NoteSseGateway를 하나 뒀다. resolver가 gateway.publishByNote(note, actorId)를 호출하면 게이트웨이가 "이 노트가 어느 프로젝트 채널인가"를 해석해 bus.publish('project:{id}', { domain: 'note', scope, actorId })를 방출한다. 채널 키 형식과 주소 해석을 게이트웨이에 가둬서, resolver는 SseBus의 존재 자체를 모른다.
HttpSessionAuthGuard — SSE의 제약이 코드가 된 지점
이 파일은 잘 봐두세요. 3편의 복선이다.
/** * HTTP 라우트용 Session 인증 Guard * * EventSource 같이 커스텀 헤더를 못 붙이는 클라이언트를 위해 sessionId 쿠키만 검증한다. * AccessToken 재발급은 본 가드에서 다루지 않으며, 만료 시 SSE 연결이 끊긴 후 * 클라이언트가 GraphQL 게이트웨이를 통해 재발급받는다. */@Injectable()export class HttpSessionAuthGuard implements CanActivate { constructor(private readonly authHelper: AuthHelper) {}
async canActivate(context: ExecutionContext): Promise<boolean> { if (context.getType() !== 'http') return true;
const req = context.switchToHttp().getRequest(); const sessionId: string | undefined = req.cookies?.[SESSION_COOKIE_NAME];
if (!sessionId) { throw new UnauthorizedException('인증이 필요합니다'); }
const session = await this.authHelper.validateSession(sessionId, getClientIp(req));
req.user = { userId: session.userId, email: '', role: session.role, };
return true; }}EventSource는 커스텀 헤더 를 못 붙인다. 생성자에 넣을 수 있는 옵션이 withCredentials 하나뿐이라 Authorization: Bearer ... 같은 헤더를 실을 방법이 없다. 기존 인증 체계는 액세스 토큰 헤더 기반이었는데, SSE만을 위해 세션 쿠키만 검증하는 가드를 따로 만들어야 했다.
동작은 잘했어요. 하지만 "transport의 제약 때문에 인증 경로가 하나 더 생겼다"는 사실 자체가 구조에 남긴 흔적이다. 이 가드는 3편에서 통째로 삭제된다.
프론트엔드: 연결과 해석을 나눈다
백엔드와 같은 원칙을 프론트에도 적용했다. 연결 계층은 도메인을 모르고, 해석 계층은 transport를 모른다.
ProjectSseConnection — React 밖의 순수 객체
type Handler = () => void;
/** * project 단위 SSE 연결의 구독 장부를 소유하는 도메인 무지 객체. React 밖에서 동작한다. */export class ProjectSseConnection { private es: EventSource | null = null; /** event type → 그 type을 구독한 핸들러들. fan-out 대상. */ private readonly handlers = new Map<string, Set<Handler>>(); /** 네이티브 리스너를 이미 부착한 event type. 현재 연결 1개당 type별 1번만 부착한다. */ private readonly attached = new Set<string>();
open(projectId: string): void { const url = `${config.API_URL}/sse/project/${encodeURIComponent(projectId)}`; this.es = new EventSource(url, { withCredentials: true }); this.attached.clear(); // open 전에 미리 subscribe된 type들을 일괄 부착한다. this.handlers.forEach((_handlers, eventType) => this.attachListener(eventType)); }
close(): void { if (this.es) { this.es.close(); this.es = null; } this.attached.clear(); }
subscribe = (eventType: string, onEvent: Handler): (() => void) => { const existing = this.handlers.get(eventType); const handlers = existing ?? new Set<Handler>(); if (!existing) this.handlers.set(eventType, handlers); handlers.add(onEvent); this.attachListener(eventType); return () => { handlers.delete(onEvent); }; };
/** eventType에 네이티브 리스너를 1번만 붙이고, 수신 시 등록된 핸들러 전부에 fan-out한다. */ private attachListener(eventType: string): void { if (!this.es) return; // 연결 전 — open()이 일괄 부착한다. if (this.attached.has(eventType)) return; this.attached.add(eventType); this.es.addEventListener(eventType, (event: MessageEvent) => { try { JSON.parse(event.data); } catch { return; // heartbeat 등 비-JSON 무시 } const handlers = this.handlers.get(eventType); if (handlers) handlers.forEach((handler) => handler()); }); }}설계 결정 두 가지.
event type당 네이티브 리스너는 1개만 붙인다. 같은 note.general을 컴포넌트 세 곳이 구독해도 addEventListener는 한 번만 호출되고, 이벤트가 오면 장부에 등록된 핸들러들에 fan-out한다. 구독할 때마다 리스너를 붙이면 리렌더·재구독이 반복되면서 같은 리스너가 중복 부착되고, 이벤트 하나에 핸들러가 여러 번 실행되는 버그로 이어진다. 장부(handlers)와 부착 기록(attached)을 분리한 이유다.
open() 전의 subscribe를 버리지 않는다. React에서 자식 컴포넌트의 effect가 부모보다 먼저 실행된다. 즉 도메인 Provider의 subscribe()가 연결이 열리기 전에 도착할 수 있다. 그래서 연결 전에는 장부에만 쌓아두고, open()이 장부를 훑어 일괄 부착한다. 타이밍 문제를 호출 순서 강제가 아니라 자료구조로 풀었다.
훅과 Provider — 얇게, 얇게
/** * project 단위 SSE 연결을 도메인 무지하게 소유한다. event type은 각 도메인 Provider가 subscribe로 등록한다(OCP). */export const useProjectSse = (projectId: string): UseProjectSseResult => { const [connection] = useState(() => new ProjectSseConnection());
useEffect(() => { if (!projectId) return; connection.open(projectId); return () => connection.close(); }, [projectId, connection]);
return { subscribe: connection.subscribe };};useState(() => new ProjectSseConnection()) — lazy initializer로 인스턴스를 컴포넌트 수명 동안 딱 하나로 고정한다. 훅이 하는 일 은 projectId 수명에 맞춘 open/close 연결뿐이다. 로직은 전부 순수 객체 쪽에 있으니 훅은 얇다.
ProjectSseProvider는 이 훅을 프로젝트 라우트 최상단에서 한 번 호출해서, 라우트 수명 동안 연결 1개를 Context로 공유한다. 어떤 event type이 존재하는지 모른다 — 도메인 무지.
NoteSseProvider — wire 이벤트를 신호로 해석한다
/** * scope ↔ wire event type 매핑. 서버가 발행하지 않는 scope(ALL)는 키가 없어 구독하지 않는다. */const EVENT_TYPE_BY_SCOPE: Partial<Record<NoteScope, string>> = { [NoteScope.GENERAL]: 'note.general', [NoteScope.FINAL]: 'note.final',};
/** * project SSE 연결에 note 이벤트를 등록하고, scope별 pending 신호를 소유한다. * * pending을 연결과 같은 라우트 수명에 두므로, 소비처(RefreshBar)가 탭 전환으로 * 마운트/언마운트돼도 신호를 흘리지 않는다. */export const NoteSseProvider = ({ children }: NoteSseProviderProps) => { const { subscribe } = useProjectSseContext(); const [pending, setPending] = useState<Record<NoteScope, boolean>>(createInitialPending);
useEffect(() => { const offs = Object.entries(EVENT_TYPE_BY_SCOPE).map(([scope, eventType]) => subscribe(eventType, () => setPending((prev) => ({ ...prev, [scope as NoteScope]: true })) ) ); return () => offs.forEach((off) => off()); }, [subscribe]);
const clearPending = (scope: NoteScope) => setPending((prev) => ({ ...prev, [scope]: false }));
return ( <NoteSseContext.Provider value={{ pending, clearPending }}> {children} </NoteSseContext.Provider> );};"어떤 wire 이벤트가 어떤 scope인가"라는 노트 도메인 지식은 EVENT_TYPE_BY_SCOPE 맵 하나에만 존재한다. 연결 계층은 이 매핑을 모르고, 이 Provider는 EventSource의 존재를 모른다.
pending을 라우트 수명에 둔 것도 의도적이다. 소비처인 RefreshBar는 탭 전환으로 수시로 언마운트된다. pending이 RefreshBar 안에 있었다면, GENERAL 탭을 보는 동안 FINAL에 새 글이 와도 탭을 돌아왔을 때 신호가 이미 사라져 있다. 신호의 수명은 소비자가 아니라 연결과 같아야 한다.
소비는 단순하다.
export const RefreshBar = ({ scope }: RefreshBarProps) => { const { pending, clearPending } = useNoteSse(); const invalidate = useInvalidateNotes();
if (!pending[scope]) return null;
const handleClick = () => { invalidate(); clearPending(scope); };
return ( <button type="button" onClick={handleClick} className="..."> <RefreshCw className="w-3 h-3" /> 새로운 메세지가 왔어요 · 새로고침 </button> );};새 글이 오면 목록 위에 배너가 뜨고, 누르면 기존 GraphQL 쿼리를 invalidate해서 다시 불러온다.
여기서 멈췄다
눈치채셨을지 모르겠는데, 이 구현이 보낸 건 데이터가 아니라 신호다. wire에 실리는 payload는 { ts } 하나뿐이다. 노트 본문도, 작성자도, 아무것도 안 실었다.
의도는 있었다. 실제 데이터는 기존 GraphQL 쿼리를 단일 진실원으로 재사용하고, 권한·페이지네이션 로직을 SSE 경로에 중복 구현하지 않겠다는 것. 그래서 UI는 "새 글이 있다"는 배너를 띄우고, 사용자가 누르면 그때 다시 조회한다.
정직하게 말하면 이건 실시간 반영이 아니라 실시간 알림이다. 화면이 저절로 갱신되는 게 아니라, 갱신하라는 초인종이 울리는 것에 가깝다.
구조적 제약도 하나 남아 있었다. SSE는 서버 → 클라이언트 단방향이라, 연결된 클라이언트가 서버에 아무 말도 할 수 없다. 구독 대상을 바꾸고 싶으면 — 다른 프로젝트로 이동하면 — EventSource를 닫고 새로 여는 것 말고는 방법이 없다. "지금 어떤 버전을 보고 있는지" 같은 상태를 서버에 알릴 통로 자체가 없는 거예요.
이 두 가지가 결국 3편의 이유가 된다.
정리
- 토픽마다
EventSource를 열던 방식을 버리고, 프로젝트당 연결 1개로 멀티플렉싱했다. 도메인이 늘수록 연결 수가 곱으로 늘어나는 구조가 직접적인 동기였다. - 도메인 무지 transport(
libs/sse)와 도메인 정책(Contributor)을 분리했다. 새 도메인 이벤트는 Contributor 추가만으로 합류하고(OCP), 권한 필터·self-echo·wire 변환은 각 도메인이 가둔다(DIP). - 다만 이 구현이 나른 건
{ ts }신호뿐이고, in-memory 버스는 scale-out에서 깨지며, 클라이언트는 서버에 말을 걸 수 없다.
SSE 자체는 잘 돌았다. 연결은 안정적이었고, heartbeat 덕에 프록시에서 끊기는 일도 없었고, 배너는 제때 떴다. 이 구조를 설계하면서 배운 것도 많았어요. 특히 "transport가 도메인을 모르 게 하라"는 원칙은 다음 transport로 갈아탈 때 그대로 살아남았다.
그런데도 몇 달 뒤 이 코드를 전부 걷어냈다. 왜 그랬는지는 3편에서.