Frontend

프론트엔드 시스템 디자인 - 6. 통신 프로토콜 설계

haeunkim.on 2026. 8. 29. 20:14

인프런 강의인 미국 빅테크 프론트엔드 시스템 디자인 실전: 단순 구현자로 남지 않기 위한 프론트엔드 개발자를 위해 를 참고하여 공부하며 포스트로 정리하고 있습니다. 처음부터 보시려면 프론트엔드 시스템 디자인 - 1. RADIO 접근법 부터..!

 

 

RADIO의 Interface 단계에서 다루는 부분이다. 화면과 서버가 어떤 방식으로 데이터를 주고받을지를 정한다.

핵심은 프로토콜을 외우는 것이 아니라, 화면의 사용자 경험과 데이터 흐름을 보고 어떤 통신 방식이 맞는지 판단하고 그 근거를 설명하는 것이다.

각 섹션 아래의 "추가 정리"는 강의 내용에서 이어서 따로 찾아본 내용이다.


통신 방식을 잘못 고르면 생기는 일

  1. 불필요하게 API 호출이 많아진다
  2. 실시간 기능이 느리거나 불안정해진다
  3. 클라이언트 상태 관리가 복잡해진다
  4. 캐싱이 어려워진다
  5. 장애 상황에서 UX가 무너진다

프로토콜 선택은 성능 문제로만 오지 않고 상태 관리와 실패 처리 전체를 끌고 온다.


같은 기능이라도 결과물이 다르면 통신 방식이 달라진다

메시지 기능 하나를 놓고 봐도 이렇게 갈린다.

케이스 결과물 데이터 이동 통신 방식
A 메시지 목록을 열면 과거 메시지를 가져온다 클라이언트가 요청할 때만 REST 또는 GraphQL
B 새 메시지가 오면 즉시 화면에 나타난다 서버 → 클라이언트 SSE 또는 WebSocket
C 메시지를 입력하면 상대방에게 즉시 전달된다 양방향 WebSocket
D 영상통화와 화면 공유가 필요하다 브라우저 간 미디어 WebRTC

 

"채팅 기능"이라는 말로 뭉뚱그리면 답이 나오지 않고, 결과물이 요구하는 데이터 이동 패턴까지 내려가야 한다.


프로토콜을 선택할 때 던질 질문

  1. 데이터는 한 번만 가져오면 되는가, 계속 업데이트되는가 (pull)
  2. 클라이언트가 요청할 때만 데이터가 필요한가 (push)
  3. 서버가 먼저 클라이언트에게 알려줘야 하는가 (one-way)
  4. 클라이언트와 서버가 양방향으로 계속 대화해야 하는가 (two-way)
  5. 데이터 shape이 화면마다 자주 달라지는가 
  6. 캐싱이 중요한가
  7. 네트워크 실패 시 재시도할 수 있는가
  8. 순서 보장이 중요한가
  9. 메시지 유실을 허용할 수 있는가
  10. 브라우저에서 직접 지원되는가

축으로 압축하면 pull인가 push인가, one-way인가 two-way인가, request/response인가 streaming인가, 그리고 계약을 어디서 강제하는가로 정리된다.

7번부터 9번은 프로토콜을 고른 뒤에도 남는 문제이고, 글 뒷부분에서 따로 다룬다.


결정 트리

사용자가 요청할 때만 데이터가 필요한가?
├─ YES → REST / GraphQL
└─ NO  → 실시간 업데이트가 필요하다
          └─ 서버 → 클라이언트 방향만 필요한가?
                    ├─ YES → SSE
                    └─ NO  → WebSocket

브라우저 간 실시간 미디어 P2P가 필요한가?
└─ YES → WebRTC

 

기본값은 HTTP Request / Response다.


기본값은 HTTP

대부분의 프론트엔드 기능은 HTTP 요청과 응답으로 충분하다. 상품 목록, 사용자 프로필, 게시글 작성, 댓글, 결제 정보, 설정 저장, 검색 결과가 전부 여기에 들어간다.

HTTP로 충분한데 WebSocket을 쓰는 것은 설계가 아니라 과설계다.

상품 페이지를 예로 들면 이렇게 된다.

사용자 행동  요청
상품 페이지를 연다 GET /products/:id
리뷰를 가져온다 GET /products/:id/reviews
추천 상품을 가져온다 GET /products/:id/recommendations
장바구니에 담는다 POST /cart/items

 

사용자가 서버와 계속 대화해야 하는 기능이 아니기 때문에 WebSocket같은 건 과설계다. 


REST

가장 기본적인 선택지고, 아래 조건이면 다른 방식을 쓸 이유가 없다.

  • 데이터가 리소스 단위로 명확하다
  • CRUD 흐름이 중심이다
  • 캐싱이 중요하다
  • URL로 리소스를 표현하기 쉽다
  • 팀에서 이해하기 쉽고 운영하기 쉽다

HTTP 메서드

  • PUT, DELETE는 idempotent (멱등성)를 보장한다
  • POST, PATCH는 보장하지 않는다

이 차이가 프론트엔드의 retry 전략을 결정한다.

좋아요 버튼은 설계에 따라 메서드가 갈린다.

 

의미 메서드
좋아요를 생성한다 POST /likes
이 사용자의 이 게시글 좋아요 상태를 true로 설정한다 PUT

 

이 차이가 중요한 이유는 Retry, Idempotency, Optimistic Update, Rollback 설계가 달라지기 때문이다.

장점

  • 이해하기 쉽다
  • 브라우저, Proxy, CDN, Cache와 잘 맞는다
  • 디버깅이 쉽다
  • HTTP 메서드와 상태 코드를 자연스럽게 쓸 수 있다

약점

  • 화면 하나를 만들기 위해 여러 엔드포인트를 호출해야 한다
  • Overfetching과 Underfetching이 생긴다
  • 클라이언트 요구사항이 바뀌면 엔드포인트가 늘어난다
  • 복잡한 nested data 조합에 불편하다

추가 정리 1: HTTP 버전에 따라 약점의 크기가 달라짐

  HTTP/1.1 HTTP/2 HTTP/3
연결당 요청 순차 처리 멀티플렉싱 멀티플렉싱
브라우저 동시 연결 도메인당 약 6개 1개 연결로 다중 스트림 QUIC 기반
Head-of-line blocking TCP와 HTTP 양쪽 TCP 계층에 남음 대부분 해소

 

HTTP/2 이후로는 요청 수 자체의 비용이 크게 줄었다. GraphQL 도입 근거를 요청 수 감소로만 잡으면 근거가 약해지고, 데이터 shape을 클라이언트가 결정한다는 쪽이 더 정확한 이유가 된다.

이 차이는 뒤의 SSE에도 영향을 준다.

추가 정리 2: 캐싱 헤더

  • Cache-Control: max-age, s-maxage, public/private, no-store, immutable
  • 조건부 요청: ETag + If-None-Match → 304 Not Modified (본문 재전송 없음)
  • stale-while-revalidate: 만료된 캐시를 먼저 보여주고 백그라운드에서 갱신
  • Vary: 같은 URL을 헤더별로 다른 캐시 키로 취급

프론트엔드의 캐시 계층은 최소 세 겹이다.

  1. HTTP 캐시 (브라우저, CDN, 프록시)
  2. 클라이언트 데이터 캐시 (React Query, SWR, Apollo, Relay)
  3. 영속 캐시 (Service Worker, IndexedDB)

React Query의 staleTime과 HTTP max-age는 이름과 개념이 비슷해 보이지만 서로 다른 계층에서 동작한다.

추가 정리 3: POST의 재시도와 Idempotency-Key

POST가 idempotent하지 않아도 재시도를 안전하게 만드는 관례가 Idempotency-Key다.

  • 클라이언트가 요청마다 UUID를 만들어 헤더에 넣는다
  • 서버는 그 키로 첫 응답의 상태 코드와 본문을 저장해둔다
  • 같은 키로 재시도가 오면 저장된 결과를 그대로 돌려준다

결과를 모르는 상태에서도 클라이언트가 재시도할 수 있게 된다. Stripe가 대중화시킨 방식이고, IETF 초안이 RFC가 되지 못한 채 만료되어 정확히는 표준이 아니라 업계 관례다. 헤더 이름은 대체로 통일되어 있지만 동작은 제공자마다 다르다.

재시도 관련해서 같이 정리되는 항목들:

  • 지수 백오프에 jitter를 섞어 재시도가 한꺼번에 몰리는 것을 막는다
  • 429, 503이면 Retry-After를 존중한다
  • 400, 401, 403, 422는 재시도해도 결과가 같다
  • AbortController로 타임아웃과 취소를 건다
  • 화면 전환 시 진행 중이던 요청을 취소한다. 취소하지 않으면 이전 응답이 나중에 도착해 화면을 덮어쓴다

추가 정리 4: 페이지네이션 방식

  • Offset (?page=2&limit=20): 새 데이터가 앞에 추가되면 페이지가 밀려 항목이 중복되거나 건너뛰어진다
  • Cursor (?before=<id>&limit=20): 기준점이 데이터 자체라 밀리지 않는다

실시간으로 계속 추가되는 목록에서는 cursor가 사실상 필수다. GraphQL Relay의 Connection 스펙이 cursor 기반인 이유이기도 하다.


GraphQL

화면마다 필요한 데이터 shape이 다를 때 쓴다. 공식 문서는 장점으로 필요한 것을 요청하고 정확히 그것을 받는다는 점, 여러 리소스를 한 요청으로 가져올 수 있다는 점을 설명한다.

REST에서 여러 요청으로 만들던 상품 페이지를 한 번에 가져올 수 있다.

query ProductPage($id: ID!) {
  product(id: $id) {
    id
    name
    price
    images
    seller { name rating }
    reviews { rating content }
    recommendations { id name price }
  }
}

 

REST는 서버가 리소스를 정해주고, GraphQL은 클라이언트가 화면에 필요한 데이터 shape을 직접 표현한다.

언제 쓰는가

  • 화면별 데이터 조합이 복잡하다
  • 클라이언트 요구사항 변화가 빠르다
  • Schema Governance (데이터 구조, 필드 의미, 명명 규칙을 조직 전체에서 일관되게 관리하고 지속적으로 통제하는 체계)를 할 수 있다

장점

  • 화면이 필요한 데이터만 요청할 수 있다
  • 여러 리소스를 한 요청으로 가져온다
  • 타입 시스템과 스키마 기반 개발이 가능하다
  • 프론트엔드와 백엔드의 계약이 명확해진다
  • 모바일과 웹처럼 화면별 요구가 다른 환경에 유리하다

약점

  • HTTP 캐싱이 REST보다 단순하지 않다
  • Query Complexity 제한이 필요하다
  • 잘못 설계하면 서버 부하가 커진다
  • Authorization이 Field 단위로 복잡해진다
  • Observability(시스템이 외부로 출력하는 데이터를 통해 내부 상태를 얼마나 잘 추론하고 이해할 수 있는지를 나타내는 척도이자 능력)가 필요하다

유연성은 장점이지만 복잡한 Query는 비용을 예측하기 어렵다. 도입할 때 Query Depth 제한, Persisted Query, Query Cost 분석 같은 운영 전략까지 함께 고려해야 한다.

추가 정리: 약점들이 가리키는 것

  • 서버 부하의 실체는 N+1이다. 중첩 필드를 해석하면서 하위 데이터 소스 호출이 폭발한다. DataLoader로 요청 단위 배칭과 캐싱을 해서 완화한다
  • HTTP 캐싱 문제의 실무 답은 Persisted Query다. 쿼리를 해시로 대체하면 POST 대신 GET을 쓸 수 있고, CDN 캐싱이 가능해진다. 페이로드도 작아진다
  • Normalized Cache: Apollo와 Relay는 응답을 엔티티 단위로 정규화해 저장한다. 한 화면에서 갱신한 데이터가 다른 화면에도 반영되는 이유다
  • @defer / @stream: 느린 필드를 나중에 흘려보내는 지시어
  • Subscription: GraphQL의 실시간 축. 보통 WebSocket 위에서 돌고 SSE 기반 구현도 있다. 앞의 결정 트리에는 이 자리가 비어 있다
  • Fragment Colocation: 컴포넌트가 필요한 데이터를 스스로 선언하는 패턴

SSE

서버가 클라이언트로 계속 알려주는 단방향 이벤트 스트림이다. One-way connection이라 클라이언트는 이 연결로 서버에 이벤트를 보낼 수 없다.

적합한 경우는 알림 피드, 실시간 점수, 주식 가격, 빌드 로그, AI 응답 스트리밍, 배송 상태, 서버 상태 모니터링이다.

기준은 서버가 계속 알려줘야 하지만 클라이언트가 계속 말할 필요는 없을 때다.

AI 응답 스트리밍

Client                          Server
  │  POST /chat                    │
  ├───────────────────────────────►│
  │                                │ token 단위로 생성
  │  text/event-stream             │
  │◄───────────────────────────────┤
  │  receive chunks                │
  │                                │
  └─ Progressive UI rendering

채팅 앱에는 맞는가

경우에 따라 다르다.

  • AI 답변 스트리밍: 서버가 토큰을 계속 보내면 되는 단방향이라 적합하다
  • 사용자 간 채팅: 양방향이 필요하므로 WebSocket이 맞다
  • 알림 시스템: 클라이언트가 자주 보낼 일이 없다면 SSE가 단순하다

장점

  • HTTP 기반이라 상대적으로 단순하다
  • 서버에서 클라이언트로의 단방향 스트리밍에 적합하다
  • 자동 재연결 패턴을 만들기 쉽다
  • 텍스트 이벤트 스트리밍에 좋다

약점

  • 양방향이 아니다
  • 바이너리에 적합하지 않다
  • 연결 수 관리가 필요하다
  • 오래 열린 연결에 대한 서버 리소스 고려가 필요하다

추가 정리: EventSource의 제약

EventSource API에는 두 가지 제약이 있다.

  • GET만 가능하다
  • 커스텀 헤더를 붙일 수 없다. Authorization: Bearer 토큰을 보낼 수 없다

AI 응답 스트리밍은 프롬프트를 POST로 보내야 하므로, 실제 구현은 EventSource 대신 fetch + ReadableStream으로 SSE 포맷을 직접 파싱하는 경우가 많다. Microsoft의 fetch-event-source 라이브러리가 이 패턴을 퍼뜨렸다.

이 경우 "자동 재연결이 쉽다"는 장점은 적용되지 않는다. 그 장점은 EventSource를 쓸 때의 것이고, fetch 기반에서는 재연결을 직접 구현해야 한다.

프로토콜과 운영 관련해서 알아둘 것들:

  • 필드는 data:, event:, id:, retry: 네 개
  • 이벤트에 id:를 붙이면 재연결 시 브라우저가 Last-Event-ID 헤더를 자동으로 보낸다. 서버는 그 지점부터 재전송하면 된다
  • Nginx 같은 리버스 프록시가 응답을 버퍼링해 스트림이 나가지 않는 문제가 흔하다 (X-Accel-Buffering: no)
  • 응답 압축도 버퍼링을 유발할 수 있다
  • 서버리스 환경은 함수 실행 시간 제한에 걸린다

앞의 HTTP 버전 이야기가 여기로 이어진다. HTTP/1.1에서 SSE는 도메인당 연결 6개 제한에 걸려 탭을 여러 개 열면 다른 요청이 막힌다. HTTP/2 위에서는 이 제한이 사실상 없어지고, WebSocket은 연결마다 별도 소켓이라 멀티플렉싱 이득을 받지 못한다. 대규모 fan-out에서 SSE over HTTP/2가 더 저렴해질 수 있는 이유다.


WebSocket

클라이언트와 서버가 하나의 연결을 열고 양방향으로 메시지를 주고받는다. 서버 응답을 받기 위해 폴링할 필요가 없다.

적합한 경우는 실시간 채팅, 멀티플레이어 게임, 협업 편집, 실시간 대시보드, 라이브 커서, 실시간 배달 위치, 거래와 경매다.

User A ──send──► WebSocket Server ──broadcast──► User B (즉시 표시)

채팅을 REST로 만들 수 있는가

가능하다. 폴링을 쓰면 된다. 다만 지연이 생기고 불필요한 요청이 많아진다.

SSE는 서버 푸시에는 좋지만 클라이언트에서 서버로 보내는 메시지에 별도 HTTP 요청이 필요하다. WebSocket은 send와 receive가 같은 연결에서 일어난다.

WebSocket을 고르면 끝인가

아니다. reconnect, 메시지 ordering, 오프라인 큐, backpressure를 직접 설계해야 한다.

장점

  • 낮은 지연의 양방향 통신
  • 폴링 제거
  • event driven UI와 잘 맞음
  • 실시간 협업 기능에 적합

약점

  • 연결 상태 관리가 필요하다
  • 재연결 로직이 필요하다
  • 스케일 아웃 시 pub/sub 인프라가 필요하다
  • HTTP caching과 맞지 않는다
  • 인증과 권한 갱신 설계가 필요하다
  • backpressure 문제가 생긴다

WebSocket 인터페이스는 backpressure(데이터 스트림 처리 시스템에서 생산자(Producer) 소비자(Consumer) 간의 처리 속도 차이로 인해 발생하는 과부하 상태를 관리하는 개념)를 지원하지 않아서, 메시지가 애플리케이션 처리 속도보다 빠르게 도착하면 메모리 버퍼가 늘어나거나 CPU 사용이 높아진다. 메시지 속도 제한, 드랍 전략, 배칭, reconnect까지 함께 설계해야 한다.

추가 정리: 약점을 구현 수준으로

인증. 브라우저 WebSocket API도 커스텀 헤더를 붙일 수 없다. 선택지는 세 가지다.

  • 쿼리스트링에 토큰을 넣는다 (로그와 리퍼러에 남아 권장되지 않는다)
  • 짧게 만료되는 일회용 티켓을 REST로 발급받아 연결 시 사용한다
  • 연결한 뒤 첫 메시지로 인증하고, 그 전에는 데이터를 보내지 않는다

보안. 쿠키 기반 인증이면 WebSocket은 CORS의 보호를 받지 않는다. 서버가 Origin 헤더를 직접 검사해야 한다. Cross-Site WebSocket Hijacking이라고 부른다.

연결 유지. 프록시와 로드밸런서는 유휴 연결을 끊는다. ping/pong 하트비트가 필요하다. TCP가 끊겨도 이벤트가 오지 않는 경우가 있어서, 애플리케이션 레벨 하트비트로 죽은 연결을 감지한다.

Backpressure. socket.bufferedAmount로 아직 보내지 못한 양을 확인해 전송을 늦추거나 배칭한다.

스케일 아웃. 서버가 여러 대면 사용자 A와 B가 다른 인스턴스에 붙는다. sticky session과 Redis pub/sub 같은 인스턴스 간 브로드캐스트 계층이 필요하다.

토큰 만료. 연결이 몇 시간 유지되는 동안 액세스 토큰이 만료된다. 재인증 메시지를 주고받을지, 끊고 다시 맺을지 정해야 한다.


WebRTC

브라우저 간 실시간 미디어 P2P 통신이다. 영상통화, 음성통화, 화면 공유, 브라우저 간 P2P 데이터 전송, 아주 낮은 지연이 필요한 미디어 기능에 쓴다.

일반 프론트엔드 시스템 디자인에서 자주 나오지는 않지만 Zoom, Google Meet, 실시간 협업 화이트보드를 다룰 때 필요하다.

반드시 구분해야 하는 두 가지가 있다.

구분 내용
Signaling 연결을 시작하기 위한 메타데이터 교환. 보통 WebSocket이나 HTTP를 쓴다
Media / Data Path 실제 오디오, 비디오, 데이터 전송. WebRTC PeerConnection
Client A ──signaling──► Signaling Server ◄──signaling── Client B
    │                                                       │
    └──────────── WebRTC media & data (양방향) ─────────────┘

 

WebRTC는 서버가 필요 없는 기술이 아니다. 실제 시스템에는 STUN/TURN, Signaling, NAT traversal, media server가 들어간다.

설계에 반영할 것은 두 가지다. WebRTC를 선택한 이유가 실시간 미디어 P2P라는 것, 그리고 Signaling은 별도 프로토콜로 처리한다는 것.


gRPC와 BFF

gRPC는 내부 서비스 계약과 고성능 RPC다. 브라우저에서 직접 쓰는 경우는 제한적이지만 시스템 구조를 설명할 때 필요하다.

다른 머신의 서버 메서드를 로컬 객체처럼 호출하게 해주고, 서비스와 메서드, 파라미터, 반환 타입을 정의한다. Protocol Buffers를 IDL이자 메시지 교환 형식으로 사용한다.

적합한 경우는 내부 마이크로서비스 간 통신, 강한 타입 계약이 필요한 서비스, 저지연 고성능 backend-to-backend 통신, 스트리밍 RPC가 필요한 내부 시스템, 모바일 앱 <-> api gateway 일부 구조이다.

Browser ──HTTP (REST / GraphQL / gRPC-Web)──► API Gateway ──gRPC──► Internal Services

 

gRPC를 직접 선택하는 것보다 Gateway나 BFF의 내부 서비스 통신으로 언급하는 경우가 많다.
REST는 Resource 중심이고 gRPC는 Service Method 중심이다.

BFF

브라우저가 여러 내부 서비스를 직접 호출하지 않도록 프론트엔드 전용 백엔드 계층을 하나 두는 구조다.

아마존 상품 상세 페이지를 예로 들면, 브라우저는 Product Page BFF만 호출하고 BFF가 상품 정보, 가격, 재고, 배송, 리뷰를 내부 서비스에서 가져와 화면에 필요한 View 모델로 조합한다.

Browser
   │  GET /api/product-page/:id
   ▼
Product Page BFF
   ├─gRPC─► Product Service
   ├─gRPC─► Pricing Service
   ├─gRPC─► Review Service
   └─gRPC─► Shipping Service
   │
   ▼
Response optimized for frontend

 

구간 프로토콜
브라우저 ~ BFF REST 또는 GraphQL
BFF ~ 내부 마이크로서비스 gRPC

추가 정리: 브라우저에서의 gRPC와 타입 계약

  • gRPC-Web: 브라우저는 HTTP/2 프레임을 직접 제어할 수 없어 표준 gRPC를 쓸 수 없다. Envoy 같은 프록시가 변환한다. 양방향 스트리밍은 제한된다
  • Connect: gRPC와 호환되면서 브라우저에서 프록시 없이 쓸 수 있게 만든 프로토콜
  • tRPC: TypeScript 모노레포에서 코드 생성 없이 타입을 공유한다. 프로토콜이라기보다 타입 계약 도구다

앞의 축이 데이터 흐름이었다면, 여기서는 계약을 어디서 강제하는가라는 축이 나온다. REST는 문서와 OpenAPI, GraphQL은 스키마, gRPC는 protobuf, tRPC는 타입스크립트 타입이다.


폴링과 롱폴링

방식 동작
폴링 클라이언트가 일정 주기로 서버에 물어본다
롱폴링 서버가 즉시 응답하지 않고 새 데이터가 생기거나 타임아웃될 때까지 연결을 잡고 있는다

 

괜찮은 경우는 실시간성이 아주 강하지 않고, 구현 단순성이 중요하고, 트래픽 규모가 작고, 서버 푸시 인프라를 만들기 어렵고, 몇 초 지연이 허용될 때다.

피해야 하는 경우는 사용자가 많고, 업데이트 빈도가 높고, 낮은 지연 시간이 중요하고, 모바일 배터리와 네트워크 비용이 중요할 때다.

폴링은 나쁜 기술은 아니지만, 실시간성이 강하고 규모가 커질수록 비용이 빠르게 커지는 선택이다.


프로토콜을 골라도 남는 문제들

앞의 질문 목록에 있던 순서 보장과 메시지 유실은 프로토콜 선택으로 해결되지 않고, 선택 이후에 설계해야 하는 영역이다.

스냅샷과 스트림을 합치기

실시간 화면의 실제 순서는 이렇다.

  1. 초기 데이터를 REST로 가져온다 (스냅샷)
  2. WebSocket이나 SSE로 이후 이벤트를 받는다

스냅샷을 찍은 시점과 스트림을 구독한 시점 사이에 발생한 이벤트가 유실되는 구간이 생긴다. 해결 패턴은 이렇다.

  • 스냅샷 응답에 버전이나 시퀀스 번호를 실어 보낸다
  • 스트림 이벤트에도 시퀀스를 붙이고, 클라이언트가 빈 구간을 감지하면 그 지점부터 다시 fetch한다
  • SSE라면 id: 필드와 Last-Event-ID로 같은 일을 할 수 있다

대부분의 실시간 시스템은 at-least-once다. 같은 이벤트가 두 번 올 수 있으므로 클라이언트는 이벤트 ID 기준으로 멱등하게 처리해야 한다.

Optimistic Update

  • 로컬에 임시 ID로 항목을 먼저 넣는다
  • 서버 응답이 오면 임시 ID를 실제 ID로 교체한다
  • 실패하면 롤백하고, 재시도할 수 있는 실패 상태로 남긴다
  • 메시지에 상태를 둔다: sending → sent → delivered → read, 그리고 failed
  • 서버 브로드캐스트로 같은 메시지가 돌아올 때 임시 항목과 중복되지 않도록, 클라이언트가 만든 ID를 서버가 그대로 되돌려주게 한다

마지막 항목이 채팅 UI에서 메시지가 두 번 보이는 문제의 흔한 원인이다.

협업 편집은 전송 방식보다 데이터 모델 문제다

WebSocket은 전송 수단이고, 실제 난이도는 충돌 해결에 있다.

  • OT (Operational Transformation): 연산을 변환해 순서를 맞춘다. Google Docs 계열
  • CRDT: 충돌 없이 수렴하는 자료구조. Yjs, Automerge
  • Figma: 순수 CRDT도 OT도 아니고, 중앙 서버가 순서를 정하고 속성 단위로 last-writer-wins를 적용한다

최근에는 직접 구현하는 대신 sync engine을 쓰는 흐름이 있다.

실패 UX와 관측성

  • 연결 상태를 UI에 노출한다 (연결 중, 재연결 중, 오프라인)
  • navigator.onLine과 online/offline 이벤트를 쓰되, 완전히 신뢰할 수는 없다
  • 탭이 백그라운드면 폴링과 스트림을 멈춘다 (Page Visibility API). 배터리와 서버 비용에 직결된다
  • 로딩, 빈 상태, 에러, 부분 실패를 각각 다르게 처리한다
  • 요청에 traceparent를 실어 서버 트레이스와 연결하고, 실패율과 재연결 횟수를 추적한다

서버 컴포넌트 이후의 통신 계층

React Server Components와 Server Actions를 쓰면 데이터 fetch가 서버에서 일어나고 클라이언트는 직렬화된 결과를 받는다. 프론트엔드가 어떤 프로토콜로 통신하는가라는 질문의 상당 부분이 서버로 넘어간다.

다만 실시간 push, 낙관적 업데이트, 오프라인, 재시도는 그대로 남는다.


정리

방식 데이터 흐름 대표 사례
HTTP / REST 요청할 때만 pull 상품 목록, 게시글 작성, 검색
GraphQL pull, shape은 클라이언트가 결정 화면별 데이터 조합이 복잡한 페이지
SSE 서버 → 클라이언트 단방향 push AI 응답 스트리밍, 알림, 배송 상태
WebSocket 양방향 push 실시간 채팅, 협업 편집, 라이브 대시보드
WebRTC 브라우저 간 P2P 미디어 영상통화, 화면 공유
gRPC 내부 서비스 간 RPC BFF와 마이크로서비스 사이
폴링 / 롱폴링 주기적 pull 실시간성이 약하고 규모가 작을 때

 

시스템 디자인에서 프로토콜 선택을 설명할 때는 선택, 근거, 버린 대안, 실패했을 때의 동작이 함께 들어가야 한다.

이 화면은 서버에서 클라이언트로 가는 단방향이고 초당 수 건 수준이라 SSE를 선택한다. 클라이언트가 보내는 액션은 별도 REST POST로 처리한다. 재연결은 Last-Event-ID 기반으로 빈 구간을 복구한다. 트래픽이 늘면 HTTP/2 위에서 연결당 비용을 다시 검토한다.


참고한 글

프로토콜 비교

재시도와 멱등성

HTTP 캐싱

GraphQL

실시간 협업과 동기화

명세