BFF 서버에 SSE를 도입한 이유: 전시 서버의 통신 구조 재설계
들어가며
배달의민족에서 음식을 주문할 때 카테고리를 누르면 만나는 화면, 바로 가게목록입니다. 사용자가 가게를 탐색하는 첫 관문이자, 가장 트래픽이 많은 지면 중 하나입니다.
이 글은 그 가게목록 지면의 통신 구조를 재설계한 이야기입니다. 클라이언트가 여러 서버에 각각 요청을 보내던 구조를 하나의 API로 통합하고(1지면 1API), 통합이 만들어낼 병목을 SSE(Server-Sent Events) 스트리밍으로 풀어낸 두 번의 의사결정 과정을 소개합니다.
‘가게목록’이 가게목록이 아니게 되기까지
배달의민족 초기 가게목록 지면은 담백했습니다. 화면에는 말 그대로 가게 리스트만 있었습니다. 이름 그대로의 ‘가게목록’이었습니다.
지금의 가게목록은 어떨까요? 한 지면을 위에서 아래까지 펼쳐 보았습니다.

같은 지면이 맞나 싶을 정도로 많은 것이 생겼습니다.
- 상단 광고배너
- 상단 큐레이션
- 가게 리스트 사이사이에 들어가는 큐레이션
- 가게 리스트 사이사이에 들어가는 광고배너
- 다른 가게목록 주제로 넘어가는 테마배너 영역
처음에는 큐레이션 하나가 추가된 정도였습니다. 그런데 서비스가 성장하면서 큐레이션은 점점 늘었고, 극단적인 지면 기준으로는 한 화면에 들어가는 구좌(지면 안의 전시 영역 단위)가 12개에 이릅니다.
지면의 이름은 여전히 ‘가게목록’이었습니다. 하지만 실체는 더 이상 가게목록이 아니었습니다. 가게 리스트에 테마배너, 큐레이션, 광고배너가 섞여 편성되는 하나의 통합 전시 지면이 되어 있었습니다.
문제는 이름과 실체의 괴리가 통신 구조에도 그대로 남아 있었다는 점입니다.
하나의 지면을 그리기 위해 N개의 서버와 통신하다
구좌가 하나 추가될 때마다 클라이언트는 그 구좌의 담당 서버에 요청을 하나씩 추가해 왔습니다. 그 결과, 사용자가 가게목록에 진입하는 순간 클라이언트는 여러 서버에 각각 요청을 보내야 하는 구조가 되었습니다. 일부 구좌는 같은 서버를 바라보기도 하지만, 하나의 지면에 클라이언트가 통신해야 하는 여러 서버가 얽혀 있다는 사실은 변하지 않습니다.
호출 플로우도 단순하지 않았습니다. 먼저 지면 편성을 조회하는 API를 호출하고, 그 편성 결과에 따라 각 구좌의 서버들을 병렬로 호출하는 두 단계의 연쇄 구조였습니다.

이 구조가 만드는 고통은 입장마다 달랐습니다.
클라이언트 입장. 하나의 지면을 그리기 위해 다수의 서버와 통신해야 했습니다. 지면에 새로운 구좌가 하나 생길 때마다, 클라이언트는 그 구좌의 담당 서버와의 통신을 새로 붙여야 했습니다. 요청 코드가 추가되고, 그 서버에 대한 에러 처리와 타임아웃 대응이 추가됩니다. 구좌가 늘어나는 속도만큼 클라이언트의 통신 코드도 함께 불어났습니다.
백엔드 입장. 지면을 온전히 컨트롤할 수 없다는 점이 답답했습니다. 각 구좌를 실제로 조회하고 조립하는 것은 클라이언트였습니다. 그래서 특정 구좌에서 에러나 타임아웃이 발생해도 서버는 그 사실을 알 수 없었고, fallback으로 다른 구좌를 대신 편성하는 것 같은 유연한 대응도 불가능했습니다. 서버는 편성 정보를 한 번 내려줄 수 있을 뿐, 상황에 따른 실행에는 관여할 수 없었습니다.
1지면 1API
그래서 결정했습니다.
가게목록이 표기되는 지면에 클라이언트가 진입할 때, 지면과 관련한 모든 정보를 서버가 제어하고, 1번의 API 호출과 응답으로 단순화한다.
클라이언트와 여러 구좌 서버 사이에 BFF(Backend For Frontend, 특정 클라이언트 화면을 위해 데이터를 조합해 주는 백엔드 계층) 전시 서버를 두고, 지면에 필요한 모든 구좌의 데이터를 이 서버가 모아서 내려주는 구조입니다. 이때 BFF는 구좌 응답을 해석하거나 변환하지 않고 그대로 전달합니다. 구좌의 응답 스펙은 각 구좌 서버와 웹이 직접 합의하고, BFF는 편성과 전달만 책임지기에 구좌의 스펙이 바뀌어도 BFF가 함께 배포될 일이 없습니다.
마침 시점도 좋았습니다. 전면적인 앱 개편이 준비되면서 가게목록 지면은 웹 프론트엔드 기반으로 다시 만들어질 예정이었고, 어차피 지면을 새로 그린다면 통신 구조도 처음부터 다시 설계할 수 있는 기회였습니다. 조직적인 부담도 크지 않았습니다. 클라이언트가 각 구좌 서버를 직접 호출하던 것을 가게목록을 담당하던 서버가 대신 호출해 주는 구조로 바꾸는 일이었기에, 가게목록을 관리하는 웹과 서버 두 주체의 합의만으로 진행할 수 있었습니다.

통합으로 각 주체는 잃었던 것을 돌려받았습니다.
클라이언트는 렌더링에 집중할 수 있게 되었습니다. 여러 서버와 각각 하던 통신이 하나로 통일되면서, 서버별로 흩어져 있던 에러 처리·타임아웃 같은 대처 로직이 BFF 한곳으로 모였습니다. 새로운 구좌가 생겨도 클라이언트가 새 서버와의 통신을 붙일 필요가 없습니다. 클라이언트는 이제 "어느 서버가 늦나, 어느 서버가 죽었나"를 신경 쓰는 대신, 받은 데이터를 어떻게 그릴지에만 집중합니다.
백엔드는 지면 컨트롤을 가져왔습니다. 편성 계획만 내리는 것이 아니라 각 구좌의 조회와 조립까지 서버가 수행하게 되면서, 특정 구좌에서 에러나 타임아웃이 발생하면 fallback으로 대체 영역을 편성하는 유연한 대응이 가능해졌습니다. 계획과 실행이 한곳에 모인 것입니다.
여기까지만 보면 통합은 깔끔한 해법처럼 보입니다. 하지만 이 구조에는 치러야 할 대가가 있었습니다.
통합을 결정하는 순간, 다음 병목이 보였다
단일 API로 모든 응답을 통합해 한꺼번에 내릴 경우, 두 가지 성능 문제가 생길 것으로 예상되었습니다.
첫째, 응답 데이터의 비대화. 지면의 모든 정보를 하나의 JSON으로 통합하면 응답 크기가 매우 커집니다. 지면에서 가장 무거운 것은 역시 가게목록으로 약 280KB인데, 큐레이션 하나하나가 이와 비슷한 크기입니다. 큐레이션 5개가 편성된 지면이라면 이 여섯 덩어리만으로 약 1.7MB에 이릅니다. 이를 하나의 JSON으로 묶어 내리면 클라이언트는 마지막 바이트까지 받아야 비로소 파싱과 렌더링을 시작할 수 있습니다.
둘째, 지연 시간의 하향 평준화. BFF는 내부에서 여러 영역의 데이터를 병렬로 조회합니다. 그런데 응답을 하나로 묶어 내리면, 전체 응답 속도는 가장 느린 작업에 맞춰집니다. 서브카테고리 조회가 15ms 만에 끝나더라도, 가장 무거운 구좌가 300ms 걸린다면 사용자는 300ms 동안 빈 화면을 보며 기다려야 합니다.
통신 횟수를 줄이려고 응답을 합쳤더니, 이번에는 합쳐진 응답 자체가 병목이 될 상황이었습니다. 통합의 이점은 지키면서 이 두 가지를 해결할 방법이 필요했습니다.
왜 SSE였나
핵심 아이디어는 단순합니다. 요청은 한 번만 받되, 응답은 한 번에 내리지 않는다. BFF 내부에서 먼저 완료된 영역의 데이터부터 순서대로 클라이언트에 흘려보내는 것입니다.
이를 구현할 프로토콜로 네 가지 대안을 검토했습니다.
| 대안 | 장점 | 판단 |
|---|---|---|
| Standard REST API | 구현이 가장 단순 | 가장 느린 작업에 전체 속도가 하향 평준화되는 문제를 해결할 수 없어 제외 |
| ResponseBodyEmitter (HTTP Streaming) | 일회성 응답에 최적화된 가벼운 방식, 응답 후 커넥션이 깔끔하게 종료 | Raw Stream 방식이라 브라우저 내장 EventSource API를 쓸 수 없음. 메시지 경계 파싱 등 통신 스펙을 직접 정의해야 해 웹 클라이언트 생산성 저하 |
| WebSocket | 실시간 양방향 통신 | 전시 정보 ‘조회’ 시나리오에 양방향성은 불필요. 프로토콜 복잡도만 높아져 제외 |
| SSE | 점진적 push + 표준화된 메시지 포맷 | 채택 |
SSE(Server-Sent Events)는 하나의 HTTP 커넥션 위에서 서버가 클라이언트로 이벤트를 단방향 스트리밍하는 웹 표준입니다. 사실 ResponseBodyEmitter가 설계한 시나리오의 ‘일회성 조회’라는 성격에는 더 가벼운 선택지였습니다. 그럼에도 SSE를 택한 결정의 기준은 이것이었습니다.
"가벼운 모델링"보다 "데이터 전송의 안정성과 클라이언트 개발 생산성"을 우선한다.
메시지 단위 파싱을 브라우저가 보장해 준다는 것, 그래서 웹 클라이언트가 별도의 스트림 파싱 로직 없이 EventSource API만으로 안정적으로 수신할 수 있으리라는 것. 현재 상황에서 이것이 프로토콜의 가벼움보다 중요했습니다. 논의는 길지 않았고, 기준이 정해지자 선택은 자연스럽게 SSE로 모였습니다. 다만 이 기대는 구현 단계에서 한 번 꺾이는데, 그 이야기는 뒤의 프론트엔드 이야기에서 이어집니다.
정리하면 SSE 채택 사유는 세 가지입니다.
1. 전시 영역별 점진적 렌더링. 서버 내부에서 조회가 완료된 영역부터 즉시 클라이언트로 전송합니다. 클라이언트는 모든 데이터를 기다릴 필요 없이 도착하는 순서대로 화면을 구성해, 사용자가 체감하는 초기 로딩 속도를 극대화합니다.
2. 네트워크·서버 자원 효율화. 커넥션을 1회로 단일화해 지면 하나를 그리기 위한 다중 요청 관리 비용을 줄입니다. 서버는 전체 응답 객체를 메모리에 모두 적재해 직렬화할 필요 없이, 논리적 단위로 즉시 송출해 메모리 효율을 높입니다. 클라이언트도 거대한 JSON 하나를 한 번에 파싱하는 대신 구좌 단위로 나눠 처리하게 되어, 응답 비대화의 부담이 자연스럽게 분산됩니다.
3. 표준 포맷과 구현 편의성. SSE는 메시지 형식이 웹 표준으로 정해져 있어 클라이언트 파싱이 가볍고, 이를 지원하는 도구 생태계도 넓습니다.
어떻게 동작하는가
사용자가 가게목록에 진입하면, 클라이언트는 BFF 전시 서버와 SSE로 통신을 시작합니다. 연결은 이 한 번뿐입니다. BFF가 가장 먼저 내려보내는 것은 구좌 데이터가 아니라 지면의 편성 정보입니다. 클라이언트는 이 편성 정보로 화면의 뼈대, 즉 스켈레톤을 먼저 그립니다. 이어서 BFF가 각 구좌를 병렬로 조회해 완료되는 순서대로 이벤트를 push하면, 클라이언트는 스켈레톤을 실제 데이터로 하나씩 채워 갑니다.

응답은 이런 형태로 흘러갑니다. (페이로드 구조는 이해를 돕기 위해 단순화한 예시입니다)
event: DISPLAY_GROUP
data: {"sections": ["SUBCATEGORY", "CURATION", "DA_BANNER", "THEME_BANNER", "SHOP_LIST", ...]}
event: SUBCATEGORY
data: {"items": [...]}
event: CURATION
data: {"title": "...", "shops": [...]}
event: SHOP_LIST
data: {"shops": [...]}
event: DONE
숫자로 보면 이렇습니다. 이전에는 모든 구좌의 응답이 완성되어야 렌더링을 시작했기 때문에, 렌더링 시작까지 약 300ms가 걸렸습니다. SSE 도입 이후에는 서브카테고리처럼 가벼운 구좌의 응답이 약 15ms 만에 먼저 나갑니다.
물론 지면의 핵심인 가게 리스트는 여전히 약 300ms에 도착합니다. 데이터가 모두 도착하는 시점이 빨라진 것이 아니라, 첫 반응까지의 시간이 달라졌습니다. 진입하자마자 스켈레톤이 잡히고 화면이 차오르기 시작하면서, 빈 화면을 보는 시간이 사라졌습니다. 사용자가 체감하는 것은 바로 이 지점입니다.
프론트엔드는 이 스트림을 어떻게 그리는가
서버가 준비된 것부터 흘려보낸다면, 클라이언트는 그 조각들을 받아 화면을 채울 수 있어야 합니다. 개편된 가게목록 웹은 fetch 기반 SSE 연결로 받은 이벤트를 React Query 캐시에 흘려 넣고, 각 컴포넌트가 그 캐시를 구독해 스스로 그려집니다. 필요한 이벤트를 모두 받으면 연결을 곧바로 닫습니다. 계속 열어 두는 스트림이 아니라, 지면 하나를 그리기 위한 일회성 연결입니다.

EventSource가 아니라 fetch 기반 SSE
앞서 SSE를 선택한 근거 중 하나는 브라우저 표준 EventSource였습니다. 그런데 구현에서 제약을 만났습니다. EventSource는 요청에 커스텀 헤더를 붙일 수 없는데, 가게목록 요청에는 Authorization을 비롯한 공통 헤더들이 반드시 실려야 했습니다.
그래서 fetch + ReadableStream으로 스트림을 직접 읽는 방식을 택했습니다. 덕분에 일반 REST와 똑같은 헤더 규칙을 공유하고, response.ok · Content-Type 선검증, AbortSignal 취소까지 기존 요청 패턴을 그대로 가져갈 수 있었습니다.
| 구분 | EventSource | fetch + ReadableStream (채택) |
|---|---|---|
| 커스텀 헤더 | 불가 | REST와 동일하게 부착 |
| 취소 | close() | AbortSignal로 통일 |
| 선검증 | 제한적 | ok·Content-Type 직접 검증 |
| 파싱 | 브라우저 내장 | 얇은 파서 한 겹 |
메시지 경계 파싱 한 겹만 직접 두면 되는 비용으로, 인증·취소·에러 처리 전부를 REST 스택과 하나로 유지할 수 있었습니다. SSE의 메시지 형식이 표준이라 그 파서 한 겹도 얇고 단순했습니다. 처음 기대했던 구현 형태(EventSource)와는 달라졌지만, 표준 포맷이라는 본질적 이점은 그대로 유효했던 셈입니다.
먼저 뼈대를 잡고, 도착하는 순서대로 칠한다
서버가 가장 먼저 응답하는 이벤트는 구좌 데이터가 아니라 편성 정보, DISPLAY_GROUP입니다. 클라이언트는 이 편성 정보로 어떤 영역이 몇 개, 어느 자리에 놓일지를 먼저 확정하고 그 자리에 스켈레톤을 노출합니다. 이후 각 구좌 이벤트가 도착하는 순서대로 해당 자리를 실제 데이터로 렌더링 합니다.
편성이 자리를 미리 잡아 두므로, 일부 구좌가 늦게 도착해도 화면이 덜컥 밀리지 않고 차오르듯 채워집니다. 또한 특정 구좌가 에러나 타임아웃으로 끝내 도착하지 못하면, 서버는 그 구좌의 이벤트 없이 DONE으로 스트림을 마무리하고, 클라이언트는 남은 스켈레톤을 접어 노출하지 않습니다. 이때 여파는 해당 자리 하나에 그칠 뿐, 나머지 구좌는 정상적으로 도착해 그려지고 지면 전체가 멈추거나 함께 무너지는 일은 없습니다.

컴포넌트는 캐시를 보고, 요청은 커넥션 하나
핵심은 데이터 소비 인터페이스를 바꾸지 않는 것이었습니다. 각 컴포넌트는 여전히 각자의 React Query 캐시 키를 구독할 뿐입니다. 컴포넌트 입장에선 REST를 쓰던 때와 똑같이 "내 쿼리 캐시를 읽는다"가 전부입니다.
달라진 건 그 캐시를 누가 채우느냐입니다. 실제 네트워크 요청은 앱 루트에서 한 번 여는 SSE 단일 커넥션뿐이고, 도착한 이벤트를 이벤트명·슬롯 단위 키로 setQueryData하면 그 키를 구독하던 컴포넌트가 알아서 리렌더됩니다.
event: DISPLAY_GROUP → 편성 정보로 자리·스켈레톤 확정
event: SUBCATEGORY → setQueryData(key.subCategory(categoryId), data)
event: SHOP_LIST → setQueryData(key.shopList(categoryId), data)
event: CURATION → setQueryData(key.curationSlot(categoryId, position, priority), data)
event: DA_BANNER → setQueryData(key.daBannerSlot(categoryId, position, priority), data)
이렇게 카테고리별로 채워진 캐시는 그대로 캐시 스토어가 됩니다. 그래서 재요청 정책이 단순해집니다.
이미 한 번 SSE로 받은 카테고리는 다시 열지 않는다. 캐시에 답이 있기 때문이다.
| 상황 | 동작 |
|---|---|
| 처음 방문한 카테고리 | SSE 연결을 열고 스트림으로 캐시를 채움 |
| 이미 받은 카테고리로 전환 | SSE 생략 — 쿼리 캐시로 즉시 렌더 |
| PTR(당겨서 새로고침) | 캐시·세션 초기화 후 재연결 |
정리하면, 카테고리를 오갈 때 발생하던 반복 요청은 캐시 스토어가 흡수하고, 데이터를 새로 받아야 하는 순간(첫 방문·PTR)에만 단일 커넥션을 엽니다. 컴포넌트는 끝까지 "캐시를 읽는다"만 알면 되고, 통신의 복잡성은 SSE 초기화 훅 한곳에 갇혀 있습니다.
도입하며 만난 고민들
도입은 프로토콜 선택으로 끝나지 않았습니다. 논의와 운영 과정에서 마주친 고민들을 남깁니다.
SSE 커넥션이 서버에 부담은 아닐까?
SSE 하면 오래 유지되는 커넥션부터 떠오르니 자연스러운 걱정입니다. 하지만 가게목록 스트림은 모든 구좌 전송이 끝나면 닫힙니다. 커넥션 수명은 가장 오래 걸리는 구좌 조회 시간(약 300ms)까지입니다. 이는 단일 REST 응답이었어도 마찬가지입니다. 어느 쪽이든 가장 느린 작업이 끝나야 응답이 끝납니다. 오히려 지면 하나를 그리기 위해 여러 서버에 요청을 보내던 기존 구조와 비교하면, 필요한 요청 자체가 하나로 줄었습니다.
SSE가 어울리지 않는 곳
모든 API를 SSE로 바꾼 것은 아닙니다. 단순한 지면이나 구좌 하나만 내려주는 API처럼 응답을 나눠 보낼 이유가 없는 경우에는 Standard REST API를 유지했습니다. 잘게 나눌 것이 없는 응답에 스트리밍은 복잡도만 더할 뿐입니다. 기술은 시나리오에 맞을 때만 채택한다는 원칙을 지키려 했습니다.
QA는 REST에 익숙한데
SSE 응답은 기존 REST API처럼 한눈에 확인하기 어렵지 않을까 하는 우려가 있었습니다. 다행히 Postman같은 툴이 SSE 응답을 이벤트 단위로 보기 좋게 표시해 주어, QA 프로세스는 큰 변화 없이 이어갈 수 있었습니다.
"nginx 버퍼링 이슈는 없었나요?"
베타 배포를 마치고 리뷰를 받던 중, 동료 개발자 태엽님이 물었습니다. "SSE로 내리는데, nginx 버퍼링 이슈는 없었나요?"
처음에는 질문이 의아했습니다. 베타 환경에서 스트리밍은 잘 동작하고 있었고, 버퍼링 관련 설정을 만진 적도 이슈를 겪은 적도 없었기 때문입니다. 그런데 찾아보니 이것은 SSE 도입기에 종종 등장하는 주제였습니다. "SSE를 쓰려면 프록시 버퍼링을 꺼야 한다"는 조언이 반복되고 있었습니다. 프록시가 응답을 버퍼에 모았다가 내보내면 스트리밍의 이점이 사라진다는 이유였습니다.
그렇다면 아무것도 끄지 않은 가게목록 지면에서는 왜 스트리밍이 잘 되고 있었을까요? 조언과 현실의 간극이 궁금해서 리서치를 시작했습니다.
먼저 nginx 공식 문서입니다. 널리 퍼진 설명은 "nginx는 전체 응답이 버퍼링될 때까지 기다렸다가 내려준다"인데, 정작 문서는 이렇게 말합니다.
When buffering is enabled, nginx receives a response from the proxied server as soon as possible, saving it into the buffers (…)
When buffering is disabled, the response is passed to a client synchronously, immediately as it is received.
– nginx 공식 문서,proxy_buffering
문서 어디에도 "버퍼가 찰 때까지, 혹은 응답이 완성될 때까지 클라이언트로 보내지 않는다"는 말은 없습니다. 이 스위치가 정하는 것은 애플리케이션 서버와 클라이언트 사이의 속도 차이를 nginx가 완충할 것인가입니다. 켜면 nginx가 클라이언트 속도와 무관하게 애플리케이션 서버에서 최대한 빨리 응답을 받아 버퍼에 담아 두고, 클라이언트에게는 클라이언트의 속도로 전달합니다. 끄면 완충 없이, 클라이언트가 소화하는 만큼만 애플리케이션 서버에서 읽어 옵니다. 어느 쪽이든 받아 둔 데이터를 클라이언트로 보내는 일을 미루지는 않습니다. 버퍼 크기 역시 "최대 얼마까지 담아둘 수 있나"의 상한이지, "이만큼 모아야 보낸다"는 하한이 아닙니다.
실험으로 확인하기
"잘 되는 것 같다"와 "확인했다"는 다르기에, 직접 확인해 봤습니다. 기준점이 될 nginx의 응답 버퍼 기본값은 플랫폼에 따라 4KB 또는 8KB이고(proxy_buffer_size), 커넥션당 이런 버퍼를 8개까지 씁니다(proxy_buffers). 그래서 운영과 동일한 조합(Spring Boot MVC + 코틀린 + 가상 스레드 + SseEmitter)으로, 버퍼 하나보다 작은 이벤트부터 버퍼 하나보다 큰 이벤트까지 순차 송출하는 서버를 만들고, 그 앞에 세 가지 경로를 세웠습니다.
- 프록시 없이 직접 연결 (대조군)
- nginx
proxy_buffering on(조언이 위험하다고 말하는 구성) - nginx
proxy_buffering off(조언이 권장하는 구성)
2번과 3번의 설정 차이는 proxy_buffering 지시어 한 줄뿐입니다.
검증 클라이언트는 스트림을 읽으면서 이벤트별 도착 시각을 기록합니다. 핵심은 서버 로그가 아니라 클라이언트 쪽에서 시각을 찍는다는 점입니다.
결과
클라이언트가 기록한 이벤트별 도착 시각입니다(3회 중앙값).
| 이벤트 크기 (송출 시점) | 프록시 없음 | nginx 버퍼링 ON | nginx 버퍼링 OFF |
|---|---|---|---|
| 200B (0ms) | 5ms | 5ms | 5ms |
| 1.2KB (15ms) | 19ms | 20ms | 20ms |
| 20KB (100ms) | 108ms | 109ms | 108ms |
| 280KB (200ms) | 207ms | 209ms | 207ms |
| 280KB (300ms) | 313ms | 310ms | 313ms |
결과는 명확했습니다. nginx 버퍼링 ON은 프록시가 없을 때와 구분되지 않았습니다. 버퍼(4~8KB)보다 훨씬 작은 200B짜리 첫 이벤트조차, 버퍼가 차기를 기다리지 않고 5ms에 그대로 도착했습니다.
극단 조건도 함께 확인했습니다. 20바이트 청크를 100ms 간격으로 흘리는 토큰 스트리밍 형태에서도, 버퍼링 ON의 추가 지연은 프록시가 없을 때와 비교해 수 ms 이내였습니다.
그렇다면 "이벤트가 한꺼번에 도착했고 버퍼링을 끄니 해결됐다"는 사례들은 왜 존재할까요? 모든 사례의 원인을 알 수는 없지만, 직접 재현할 수 있었던 조건이 하나 있습니다. nginx의 gzip을 text/event-stream에 켜자, 버퍼링 ON에서는 모든 이벤트가 응답이 끝난 뒤에야 한꺼번에 도착했습니다. 그리고 버퍼링을 끄자 다시 흘렀습니다. off는 nginx가 받은 조각을 그때그때 내보내게 만드는데, 이 "즉시 내보내라"는 신호(flush)가 내부의 gzip 필터에까지 전파되기 때문입니다. 이처럼 범인이 nginx 안쪽(flush 없이 모으는 압축 필터, 구버전 동작 등)에 있는 환경이라면, 버퍼링을 껐을 때 증상이 사라지는 것이 맞습니다.
그래서 결론은 "끄지 않는다"였습니다. 끈다고 얻는 것이 없고, 기본값이 아닌 설정은 그 이유를 아는 사람이 있어야 유지되는 관리 대상이 됩니다.
proxy_buffering off는 언제 필요한 걸까요? 알림이나 실시간 로그처럼 끝나지 않는 스트림을 다룰 때입니다. 응답에 끝이 없으면 빠른 애플리케이션 서버와 느린 클라이언트의 속도 차이를 nginx가 무한정 흡수해야 하고, 메모리 버퍼를 넘으면 디스크 임시파일까지 갑니다. 버퍼링을 끄면 이 속도 차이가 애플리케이션 서버까지 전파되어(backpressure), 애플리케이션이 소비되지 않는 생성을 감지하고 멈출 수 있게 됩니다. 즉 off는 스트리밍을 "되게 만드는" 스위치가 아니라, 무한 스트림에서 자원과 흐름을 제어하는 스위치입니다. 300ms면 끝나는 가게목록 지면에는 해당하지 않는 이야기였습니다.
베타에서 스트리밍이 잘 되고 있었던 것은 경로상 응답을 붙들고 있는 계층이 없었기 때문입니다. 다만 태엽님의 질문 덕분에 "잘 되고 있다"를 "왜 잘 되는지 안다"로 바꿀 수 있었습니다. 널리 알려진 조언을 만나면 그 조언이 어떤 환경을 가정하고 있는지 확인해야 한다는 것이 이 검증에서 얻은 교훈이었습니다.
마무리
가게목록 지면의 복잡도는 앞으로도 줄어들지 않을 것입니다. 서비스가 성장하는 한, 지면에는 더 많은 영역과 더 많은 실험이 얹힐 것입니다. 이번에 한 일은 그 복잡도를 되돌리는 것이 아니라, 통신 구조를 지면의 실체에 맞게 재설계하는 것이었습니다.
- 하나의 지면은 하나의 API로 응답한다 (1지면 1API)
- 응답은 완성을 기다리지 않고, 준비된 것부터 전송한다 (SSE)
전시 지면의 다음 여정도 기대해 주세요.