pile·
아키텍처·우아한형제들우아한형제들·

BFF 서버에 SSE를 도입한 이유: 전시 서버의 통신 구조 재설계

배달의민족 가게목록은 광고배너·큐레이션·가게리스트 등 한 화면에 최대 12개 구좌가 들어가는 통합 전시 지면으로 커졌다. 클라이언트가 구좌마다 담당 서버를 직접 호출하던 구조를 BFF 한 곳으로 모았더니 이번엔 응답이 1.7MB로 비대해지고 가장 느린 구좌(300ms)에 전체가 묶이는 하향 평준화가 발생했다. SSE로 완료된 영역부터 흘려보내 렌더링 시작 시점을 300ms에서 15ms로 당긴 과정을 다룬다.

핵심 포인트
  • 1지면 1API(BFF)로 클라이언트 통신은 단순해졌지만, 단일 응답이라 데이터 비대화와 지연 하향 평준화라는 새 병목이 생겼다.
  • REST·ResponseBodyEmitter·WebSocket·SSE 넷을 비교해 SSE 채택 — 표준 메시지 포맷이라 클라이언트 파싱이 쉽고 양방향이 불필요했다.
  • 클라이언트는 EventSource 대신 fetch + ReadableStream을 골랐다. 커스텀 헤더(인증)와 AbortSignal 취소를 기존 REST 스택과 통일하기 위해서다.
  • 렌더링 시작까지 300ms → 15ms. 가장 느린 구좌 도착 시각은 그대로지만 빈 화면 구간이 사라졌다.
  • "SSE엔 nginx proxy_buffering off가 필수"라는 통설을 직접 측정해 반증했다 — 280KB까지 버퍼링 ON/OFF 차이가 없었다.
상세 정리
  • 문제의 출발점: 클라이언트가 편성 API를 부른 뒤 각 구좌 서버에 병렬 요청하는 구조였다. 구좌가 늘 때마다 통신·에러·타임아웃 코드가 함께 늘고, 서버는 편성만 제시할 뿐 실행을 클라이언트가 쥐고 있어 특정 구좌 장애 시 대체 편성을 할 수 없었다.
  • 1단계 BFF 도입: 전시 서버가 모든 구좌를 조회·조립해 하나의 API로 내려준다. 클라이언트 호출이 하나로 줄고 fallback 편성이 가능해졌다.
  • 통합의 대가 ①: 가게목록 약 280KB에 큐레이션 5개 약 1.4MB가 붙어 응답이 1.7MB를 넘겼다. 클라이언트는 마지막 바이트까지 받아야 파싱을 시작한다.
  • 통합의 대가 ②: 서브카테고리는 15ms에 끝나는데 가장 무거운 구좌가 300ms라, 전체 응답이 300ms로 결정됐다. 빠른 데이터가 느린 데이터를 기다리는 하향 평준화다.
  • 프로토콜 선택: REST는 응답 시간을 못 줄여 제외, ResponseBodyEmitter는 메시지 경계를 직접 정의해야 해 웹 생산성이 떨어져 제외, WebSocket은 양방향이 불필요해 제외. "가벼운 모델링보다 전송 안정성과 클라이언트 생산성"을 기준으로 SSE를 골랐다.
  • 서버 이벤트 설계: DISPLAY_GROUP(편성 정보)을 먼저 보내 뼈대를 확정하고, SUBCATEGORY·CURATION·SHOP_LIST·DA_BANNER를 완료 순서대로 전송한 뒤 DONE으로 닫는다.
  • 클라이언트 선택: EventSource는 커스텀 헤더를 못 실어 Authorization을 보낼 수 없고 취소·선검증도 제한적이었다. fetch + ReadableStream으로 가면 파싱을 한 겹 직접 짜야 하지만 인증·취소·에러 처리가 REST와 같은 규칙을 공유한다.
  • 캐시 통합: SSE 이벤트가 도착하면 React Query의 setQueryData로 캐시에 밀어 넣고, 컴포넌트는 평소처럼 캐시를 구독만 한다. 통신 복잡성이 SSE 초기화 훅 한 곳에 갇힌다.
  • 렌더링 전략: 편성 정보로 스켈레톤을 먼저 그리고 도착 순서대로 채우며, 한 구좌가 실패하면 그 자리만 숨기고 나머지는 정상 노출한다.
  • 재방문 최적화: 이미 받은 카테고리는 SSE 연결 자체를 생략하고 캐시를 즉시 렌더하며, PTR 새로고침에서만 캐시를 비우고 재연결한다.
  • nginx 버퍼링 실측: 200B/1.2KB/20KB/280KB 네 구간에서 직접 연결·버퍼링 ON·OFF를 비교했는데 차이가 없었다. 다만 text/event-stream에 gzip을 켜면 버퍼링 ON에서 지연이 생겨 OFF가 필요했다.
  • 통설의 조건: 버퍼링 끄기는 알림·실시간 로그처럼 끝나지 않는 스트림에서 의미가 있고, 300ms에 끝나는 가게목록에는 해당하지 않는다는 결론이다.
  • 적용 범위 제한: 구좌 하나만 내려주는 단순 지면에는 SSE를 넣지 않았다. 나눠 보낼 이유가 없으면 스트리밍은 복잡도만 늘린다.
우아한형제들
우아한형제들 블로그
원문은 여기서 이어서 읽을 수 있어요
원문 읽기
읽음 (0)

이 글과 비슷한

  1. 아키텍처·dropbox-techDropbox Tech·

    Dropbox의 통합 콘텐츠 처리 플랫폼 Riviera, AI 시대로의 진화

    Dropbox가 파일 미리보기 생성용 내부 서비스로 출발한 콘텐츠 처리 플랫폼 Riviera가 약 10년에 걸쳐 Search·Replay·Sign·Dash가 공유하는 플랫폼으로, 나아가 AI 시대의 문서 처리 인프라로 진화한 과정을 다룬다. 미리보기를 '재사용 가능한 변환들의 조합'으로 재정의한 설계가 확장의 핵심이었고, 지금은 100개 넘는 변환 능력으로 초당 수십만 건을 처리한다. 최근엔 공개 API와 MCP로 외부 개발자에게도 열렸다.

  2. 아키텍처·github-engGitHub Engineering·

    AI 시대에 달라진 '예스'의 비용

    GitHub Engineering 에서 AI 코드 생성 도구가 보편화되면서 소프트웨어 엔지니어링 경제학이 어떻게 달라졌는지를 다룬다. 코드를 작성하는 비용은 낮아졌지만 그 코드를 소유하고 유지보수하는 비용은 그대로라는 점을 핵심 논지로, 엔지니어링 팀의 의사결정 방식도 달라져야 한다고 주장한다.

    #engineering-process#ai-code-generation#code-ownership+1
  3. 아키텍처·당근당근 테크블로그·

    천만 MAU를 지탱하는 커뮤니티 시스템 아키텍처

    당근 커뮤니티실이 2021년 약 100 RPS에서 현재 약 20,000 RPS를 처리하는 규모로 성장한 과정과, 이를 가능하게 한 "여러 웹앱 + 하나의 서버앱" 모듈형 모놀리스 아키텍처를 다룬다. 동네생활, 모임, 카페, 아파트 서비스를 소규모 팀이 장기 운영 가능한 구조로 설계한 원칙과, 장애 경험에서 도출한 resilience 패턴까지 단계별로 정리했다.

    #event-driven#kafka#postgres+2