콘텐츠로 이동

WebSocket & gRPC basics

분류: Layer 1 - 백엔드 기초 | 작성일: 2026-04-02

WebSocket은 HTTP 요청으로 시작해 101 Switching Protocols 응답을 받은 뒤, 같은 TCP 연결을 지속 연결로 바꾸어 클라이언트와 서버가 서로 먼저 메시지를 보낼 수 있게 하는 양방향 통신 프로토콜이다.

gRPC는 Protobuf(Protocol Buffers) 스키마로 요청과 응답 타입을 먼저 정하고, HTTP/2 스트림 위에서 원격 프로시저 호출(RPC, Remote Procedure Call)을 수행하는 서버 간 통신 프레임워크다.

둘 다 HTTP를 완전히 버리는 기술은 아니다. WebSocket은 HTTP 핸드셰이크로 시작해 WebSocket 프레임으로 전환하고, gRPC는 HTTP/2의 연결·스트림·메타데이터를 사용한다. 차이는 HTTP의 요청-응답 모델만으로 부족한 문제를 어디서 푸는가다.


HTTP 문서에서 본 기본 모델은 “클라이언트가 요청하고 서버가 응답한다”이다. 이 모델은 공개 API, 브라우저 요청, 단순 CRUD에는 강하다. 하지만 다음 두 가지 상황에서는 부담이 커진다.

  1. 서버가 먼저 알려야 하는 이벤트가 많다.
  2. 서버 간 호출이 많고, 타입 계약과 전송 효율이 중요하다.

예를 들어 주문 상태를 화면에 보여준다고 하자.

HTTP polling:
브라우저 -> GET /orders/7/status
브라우저 -> GET /orders/7/status
브라우저 -> GET /orders/7/status
...

주문 상태는 10분 동안 한 번만 바뀔 수 있다. 그런데 클라이언트가 5초마다 묻는다면 한 사용자당 분당 12번의 요청이 생긴다. 10,000명이 같은 방식으로 접속하면 상태가 바뀌지 않아도 분당 120,000번의 HTTP 요청이 발생한다. 응답 대부분은 “아직 그대로”라는 빈 확인이다.

WebSocket은 이 문제를 지속 연결 + 서버 push로 푼다. 연결을 한 번 열어두고, 상태가 바뀔 때 서버가 먼저 메시지를 보낸다.

WebSocket:
브라우저 -> GET /orders/7/stream, Upgrade: websocket
서버 -> 101 Switching Protocols
서버 -> {"orderId":7,"status":"DELIVERED"} # 바뀐 순간에만 전송

gRPC가 풀려는 문제는 조금 다르다. 내부 서비스가 REST/JSON으로 서로 호출하면 사람은 읽기 쉽지만, 고빈도 호출에서는 JSON 파싱·필드명 반복·스키마 불일치가 비용이 된다.

REST/JSON:
POST /inventory/reserve
{ "orderId": "o-7", "sku": "keyboard", "quantity": 1 }
gRPC:
InventoryService.Reserve(ReserveRequest)

gRPC는 “HTTP 경로와 JSON 모양을 호출마다 맞추자”가 아니라, .proto 파일에 함수처럼 보이는 RPC와 메시지 타입을 먼저 정의한다. 호출자는 언어가 달라도 같은 스키마에서 생성된 코드를 사용하므로 타입 불일치를 더 빨리 발견할 수 있고, 전송은 HTTP/2 스트림과 Protobuf 이진 포맷을 사용해 작아진다.

첫 회독의 목표는 “NestJS에서 실행하는 법”이나 운영 세부를 모두 익히는 것이 아니라, 다음 질문에 답할 수 있게 되는 것이다.

  • WebSocket 연결은 HTTP 요청과 언제 달라지는가?
  • 표준 WebSocket과 Socket.IO는 어떤 관계이며, frame·message·event는 어떻게 다른가?
  • 지속 연결은 polling의 어떤 비용을 줄이고 어떤 연결 운영 비용을 새로 만드는가?
  • gRPC에서 Protobuf 스키마는 호출 양쪽의 타입 계약으로 어떻게 동작하는가?
  • Unary와 streaming은 요청·응답 방향에서 어떻게 다른가?
  • REST, SSE, WebSocket, gRPC 중 어떤 조건에서 무엇을 고를 것인가?

필드 번호 호환성, 네 가지 streaming의 종료 규칙, heartbeat·backpressure·재연결·fan-out, deadline·status·retry는 본문에 보존한 선택 심화다. 처음에는 이름과 실패 신호를 알아보고, 실제로 해당 통신 방식을 설계하거나 운영할 때 세부 메커니즘을 다시 확인하면 된다.

첫 회독에서는 3절, 4.14.3절, 5.15.3절, 5.4절의 비교표와 .proto 예시까지, 78절, 1011절과 13절의 첫 번째 체크리스트를 읽는다. 5.4절의 패턴별 상세 흐름부터는 4.44.8절, 5.55.7절, 6절, 9절, 12절과 함께 설계·구현·운영이 필요할 때 돌아오는 선택 심화다. 이 경로는 심화 내용을 삭제하지 않으면서도, 지속 연결과 typed RPC의 핵심 모델을 먼저 완성하게 한다.


기술 이름보다 먼저 통신 방향과 계약의 성격을 본다.

통신 방식의 첫 분기

REST

클라이언트가 요청하고 서버가 응답한다. HTTP 메서드, 상태 코드, 캐시, 브라우저 도구를 그대로 쓴다.

공개 API, 단순 조회/수정, 외부 파트너 연동

SSE

Server-Sent Events. 일반 HTTP 연결로 서버가 클라이언트에게만 이벤트를 스트리밍한다.

진행률, 로그, 알림처럼 단방향 push면 충분할 때

WebSocket

하나의 지속 연결에서 양쪽이 동시에 메시지를 주고받는다.

채팅, 협업 편집, 위치 추적처럼 양방향 실시간성이 필요할 때

gRPC

Protobuf 계약과 HTTP/2 스트림으로 서버 간 RPC를 수행한다.

내부 마이크로서비스, 고빈도 호출, 스트리밍, 다언어 팀

짧은 결정 기준은 다음과 같다.

브라우저나 외부 파트너가 직접 호출하는 공개 API인가?
├── 예: REST가 기본값
└── 아니오:
서버가 클라이언트에게 먼저 자주 알려야 하는가?
├── 예, 단방향이면 SSE
├── 예, 양방향이면 WebSocket
└── 아니오:
서버 간 고빈도 호출과 타입 계약이 중요한가?
├── 예: gRPC
└── 아니오: REST

이 흐름도는 절대 규칙이 아니다. 브라우저에서 gRPC를 직접 쓰기 어렵고, WebSocket은 연결 운영 비용이 크며, REST는 성능이 가장 낮다는 뜻이 아니다. 첫 판단을 좁힌 뒤 실제 제약을 확인하는 출발점이다.


4. WebSocket: HTTP를 지속 양방향 연결로 바꾸기

섹션 제목: “4. WebSocket: HTTP를 지속 양방향 연결로 바꾸기”

HTTP polling은 클라이언트가 계속 묻는다. 구현은 단순하지만 이벤트가 드문데 클라이언트가 많으면 빈 요청이 급증한다.

10,000 clients
poll interval = 5s
requests per minute = 10,000 * 60 / 5 = 120,000

상태 변경이 실제로는 분당 200번뿐이라면 119,800번에 가까운 요청은 최신 상태를 다시 확인하는 비용이다. WebSocket은 이 비용을 “열린 연결을 유지하는 비용”으로 바꾼다. 요청 수는 줄지만 서버는 연결 객체, 파일 디스크립터, 메모리, heartbeat, 재연결 폭풍을 관리해야 한다.

따라서 WebSocket의 핵심 철학은 “모든 HTTP 요청을 없애자”가 아니다. 변경이 발생했을 때 즉시 알려야 하고, 양쪽이 계속 대화해야 하는 구간만 지속 연결로 바꾸자이다.

WebSocket은 처음부터 별도 포트의 낯선 프로토콜로 시작하지 않는다. 클라이언트가 HTTP 요청에 Upgrade: websocket 헤더를 실어 보내고, 서버가 받아들이면 101 Switching Protocols를 반환한다.

WebSocket HTTP Upgrade 핸드셰이크
sequenceDiagram
participant C as Client
participant S as Server

C->>S: GET /chat HTTP/1.1
C->>S: Upgrade: websocket
C->>S: Connection: Upgrade
C->>S: Sec-WebSocket-Key: random nonce
S-->>C: 101 Switching Protocols
S-->>C: Sec-WebSocket-Accept: derived value
C->>S: WebSocket frame
S-->>C: WebSocket frame

101 이후에도 TCP 연결은 그대로다. 달라지는 것은 그 연결 위에 실리는 메시지 형식이다. HTTP 요청/응답 메시지를 더 보내는 것이 아니라 WebSocket 프레임을 주고받는다.

이 경계는 장애 해석에서 중요하다.

관찰의미먼저 볼 것
101이 오지 않는다WebSocket으로 전환되기 전 실패HTTP 경로, 인증, proxy upgrade 헤더
101은 왔지만 곧 끊긴다연결 유지 단계의 실패idle timeout, heartbeat, 서버 disconnect
특정 메시지만 처리 안 된다연결보다 애플리케이션 메시지 계약 문제event name, payload shape, authorization
로드밸런서 뒤에서만 실패한다중간 계층이 Upgrade나 idle 연결을 막을 수ALB/Nginx 설정, timeout, sticky session

WebSocket은 전이중(Full-Duplex)이다. 전이중은 양쪽이 독립적으로 송신과 수신을 할 수 있다는 뜻이다. 클라이언트가 메시지를 보내는 중에도 서버가 별도 메시지를 보낼 수 있고, 서버가 먼저 알림을 밀어 넣을 수도 있다.

주의할 점은 WebSocket이 TCP 위에 올라간다는 것이다. TCP는 바이트 스트림이고, 자체적으로 “여기까지가 채팅 메시지 하나”라는 의미를 모른다. WebSocket 프레임은 이 바이트 스트림 위에 메시지 경계를 만들어준다.

애플리케이션 메시지:
{ "type": "chat", "text": "hello" }
WebSocket frame:
FIN=1, opcode=text, payload_length=32, payload=...
TCP:
bytes, bytes, bytes ...

프레임과 메시지는 같은 말이 아니다.

개념의미학습 포인트
FrameWebSocket 프로토콜이 전송하는 최소 단위opcode, FIN, payload length 같은 메타데이터를 가진다
Message애플리케이션이 의미를 부여하는 단위큰 메시지는 여러 frame으로 쪼개질 수 있다
EventSocket.IO 같은 라이브러리가 얹는 애플리케이션 추상sendMessage, receiveMessage 같은 이름은 표준 자체가 아니다

표준 WebSocket과 Nest adapter, Socket.IO의 계층도 구분해야 한다.

계층메시지를 다루는 방식제공하지 않는 것
표준 WebSocket 프로토콜text/binary message와 Ping/Pong 같은 control frame을 주고받는다named event, room, namespace
Nest WsAdapter{ event, data } 형태의 메시지를 파싱해 @SubscribeMessage() handler로 라우팅한다room, namespace
Nest IoAdapter / Socket.IOSocket.IO의 named event·namespace와 서버 측 room API를 사용한다표준 WebSocket client와의 직접 호환

WsAdapterevent는 표준 WebSocket 기능이 아니라 Nest가 메시지 payload에 정한 라우팅 규약이다. 반면 IoAdapter의 event·room·namespace는 Socket.IO 프로토콜과 서버 API가 제공한다.

작은 반례를 보자. “TCP에서 한 번 send()하면 상대도 한 번 message 이벤트를 받는다”라고 생각하면 틀린다. TCP는 중간에서 나뉘거나 합쳐질 수 있다. WebSocket은 프레임 규칙으로 메시지 경계를 복원해 주지만, 그 안의 JSON 필드 의미는 애플리케이션이 직접 정해야 한다.

좋은 메시지 설계:
{ "type": "order.status.changed", "orderId": 7, "status": "DELIVERED" }
나쁜 메시지 설계:
{ "data": "done" }

두 메시지 모두 전송은 가능하다. 하지만 두 번째는 수신자가 무엇이 완료됐는지, 멱등하게 다시 처리할 수 있는지, 사용자에게 어떤 상태를 보여줘야 하는지 알기 어렵다. WebSocket은 전송 채널을 주지만, 메시지 계약을 대신 설계해 주지는 않는다.

4.4. Backpressure: 받을 수 있는 속도보다 빨리 보내는 문제

섹션 제목: “4.4. Backpressure: 받을 수 있는 속도보다 빨리 보내는 문제”

Backpressure는 생산자가 보내는 속도가 소비자가 처리하는 속도보다 빨라서 버퍼가 쌓이는 현상이다. WebSocket은 연결을 오래 유지하므로 이 문제가 HTTP 단건 요청보다 오래 숨어 있을 수 있다.

예를 들어 서버가 한 클라이언트에게 초당 1,000개의 가격 업데이트를 보내는데, 모바일 클라이언트가 초당 100개만 처리한다고 하자.

server send rate = 1,000 msg/s
client process rate = 100 msg/s
buffer growth = 900 msg/s
1분 후 backlog = 54,000 messages

이 상황에서 “연결은 살아 있다”는 관측만으로는 충분하지 않다. 연결은 살아 있지만 사용자는 1분 전 가격을 볼 수 있다. 그래서 실시간 시스템은 연결 생존과 데이터 최신성을 나눠서 본다.

Backpressure를 줄이는 방법은 메시지를 더 빨리 보내는 것이 아니라 덜 보내거나, 합치거나, 늦은 메시지를 버릴 수 있는 의미를 설계하는 것이다.

방법언제 맞는가경계
sampling모든 중간값보다 최신 상태가 중요할 때감사 로그처럼 모든 이벤트가 필요하면 부적합
coalescing같은 key의 최신 값만 의미 있을 때중간 상태 전이가 비즈니스 의미를 가지면 위험
per-client buffer짧은 네트워크 흔들림을 흡수해야 할 때한도 없이 키우면 메모리 장애가 된다
drop policy오래된 메시지를 버려도 되는 스트림일 때주문/결제 이벤트처럼 손실 불가면 부적합
ack 기반 흐름 제어수신자가 처리 완료를 알려야 할 때latency와 구현 복잡도가 늘어난다

실패 신호는 다음처럼 보인다.

  • WebSocket 연결 수는 정상인데 클라이언트 화면이 늦게 따라온다.
  • 서버의 per-connection send buffer가 계속 증가한다.
  • 특정 느린 클라이언트 때문에 이벤트 루프 지연이나 메모리 사용량이 늘어난다.
  • 재연결 후 한꺼번에 오래된 메시지가 쏟아진다.

오래 열린 연결은 “아무 데이터도 안 오지만 실제로 살아 있는 상태”와 “중간 네트워크 장비가 조용히 끊은 상태”를 구분해야 한다. Heartbeat는 이 구분을 위해 주기적으로 작은 생존 신호를 보내는 방식이다.

WebSocket 표준은 제어 프레임으로 Ping과 Pong을 정의한다.

  • Ping frame: 상대가 살아 있는지 묻는 제어 프레임이다.
  • Pong frame: Ping을 받았다는 응답이다. 보통 같은 payload를 돌려준다.
  • Control frame: 애플리케이션 데이터와 별도인 프로토콜 제어 메시지다.
WebSocket Ping-Pong과 timeout
sequenceDiagram
participant S as Server
participant C as Client

S->>C: Ping opcode 0x9
C-->>S: Pong opcode 0xA
S->>C: Ping
Note over C: NAT or mobile sleep
S-->>S: pingTimeout 동안 Pong 없음
S-->>C: close

수치 감각은 timeout의 상대값에서 나온다. AWS ALB의 기본 idle timeout은 60초다. 서버가 60초마다 Ping을 보내면 ALB가 연결을 끊는 시점과 Ping이 겹쳐 간헐적인 끊김이 생길 수 있다.

위험한 설정:
ALB idle timeout = 60s
ping interval = 60s
더 안전한 설정:
ALB idle timeout = 60s
ping interval = 25s
ping timeout = 20s

여기서 외울 것은 25초라는 숫자가 아니라 원리다. 중간 장비가 idle로 판단하기 전에 애플리케이션 또는 프로토콜 레벨 heartbeat가 먼저 지나가야 한다.

4.6. 재연결 전략: 즉시 재시도가 항상 좋은 것은 아니다

섹션 제목: “4.6. 재연결 전략: 즉시 재시도가 항상 좋은 것은 아니다”

WebSocket 연결은 끊길 수 있다. 모바일 네트워크 전환, 서버 배포, ALB idle timeout, 탭 sleep, NAT timeout 때문에 정상 운영 중에도 끊어진다. 그래서 재연결은 기능의 일부다.

나쁜 재연결 전략은 모든 클라이언트가 즉시 다시 붙는 것이다.

서버 재시작
10,000 clients disconnected
모든 client가 즉시 reconnect
-> handshake 폭증
-> 서버가 다시 느려짐
-> 재연결 실패
-> 다시 즉시 reconnect

이 현상을 재연결 폭풍(Reconnection Storm)이라고 한다. 흔한 완화책은 exponential backoff와 jitter다.

delay(attempt) = min(base * 2^attempt + random_jitter, max_delay)
attempt 0: 약 1초
attempt 1: 약 2초
attempt 2: 약 4초
attempt 3: 약 8초
attempt 4 이후: 상한에 수렴

Jitter는 랜덤 지연이다. 모든 클라이언트가 같은 수식으로 같은 시각에 붙는 것을 막는다. 재연결은 사용자 경험과 서버 보호의 균형이다. 채팅 입력창은 빠르게 회복해야 하지만, 백그라운드 가격 스트림은 몇 초 늦게 붙어도 서버를 보호하는 편이 낫다.

재연결 설계에서 함께 정해야 하는 것은 상태 복원이다.

  • 마지막으로 받은 이벤트 ID가 있는가?
  • 재연결 후 놓친 이벤트를 다시 받을 수 있는가?
  • 최신 상태만 다시 조회하면 충분한가?
  • 중복 수신돼도 안전하게 처리되는가?

WebSocket은 연결을 다시 열 수 있게 해주지만, 끊긴 동안의 비즈니스 의미를 자동으로 복구해 주지는 않는다.

WebSocket 인증은 보통 Upgrade 핸드셰이크 단계에서 시작한다. 브라우저는 쿠키를 자동으로 붙일 수 있고, 클라이언트는 쿼리 파라미터나 Sec-WebSocket-Protocol 같은 방식으로 토큰을 전달하기도 한다. 하지만 핸드셰이크가 성공했다고 해서 이후 모든 메시지가 영원히 권한을 갖는 것은 아니다.

HTTP와 WebSocket의 인증 경계는 다르다.

항목HTTP 요청WebSocket 연결
인증 시점요청마다 헤더나 쿠키를 확인하기 쉽다연결 시점에 확인하고 연결 상태에 붙여두기 쉽다
권한 변경다음 요청부터 새 정책을 적용하기 쉽다연결 중 권한 변경을 별도로 반영해야 한다
토큰 만료만료된 요청만 실패시키면 된다장시간 연결에서 재인증 또는 연결 종료가 필요하다
로그 추적request id 단위가 자연스럽다connection id와 message id를 설계해야 한다

실패 예시는 다음과 같다.

1. 사용자가 관리자 권한으로 WebSocket 연결
2. 운영자가 관리자 권한 회수
3. 기존 WebSocket 연결은 여전히 admin 이벤트를 수신

이 문제는 “인증을 했는가”가 아니라 “장시간 연결에서 권한 변경을 어떻게 반영하는가”의 문제다. 해결책은 서비스마다 다르지만, 최소한 다음 중 하나를 선택해야 한다.

  • 짧은 세션 TTL을 두고 재인증을 요구한다.
  • 권한 변경 이벤트가 발생하면 해당 사용자의 연결을 끊는다.
  • 민감한 메시지 처리 전 현재 권한을 다시 확인한다.
  • connection id, user id, auth version을 로그에 남긴다.

4.8. 로드밸런싱, Sticky Session, 상태 fan-out

섹션 제목: “4.8. 로드밸런싱, Sticky Session, 상태 fan-out”

HTTP 요청은 무상태(stateless)로 설계하면 어느 인스턴스가 받아도 된다. WebSocket 연결은 다르다. 연결 객체는 특정 서버 프로세스 메모리에 있다.

Client A -> ALB -> Instance 1 # socket object exists here
Client B -> ALB -> Instance 2 # socket object exists here

이 상태에서 Instance 1이 order:7 방에 메시지를 emit하면, Instance 2에 연결된 클라이언트는 그 메시지를 모를 수 있다. 두 가지 문제가 섞여 있다.

Socket.IO의 room은 클라이언트가 연결 URL에서 직접 선택하는 프로토콜 기능이 아니라, 서버가 연결을 그룹화하는 API다. 서버가 인증과 권한을 확인한 뒤 socket.join("user:" + userId)처럼 가입시킨다. 클라이언트가 room 가입을 요청하는 event를 보낼 수는 있지만, 어느 room에 넣을지는 서버가 검증하고 결정해야 한다.

  1. 연결 수립 과정이나 재연결에 같은 인스턴스가 필요한가?
  2. 여러 인스턴스에 흩어진 연결에게 같은 이벤트를 어떻게 fan-out할 것인가?

여기서 fan-out은 하나의 이벤트를 여러 연결이나 여러 서버 인스턴스로 분배하는 일이다. 주문 상태 변경 이벤트 하나가 웹, 모바일, 관리자 화면의 여러 연결로 퍼지는 것을 떠올리면 된다.

WebSocket 연결은 Upgrade가 끝난 뒤 그 TCP 연결이 닫힐 때까지 이미 한 backend에 붙어 있다. 따라서 표준 WebSocket의 established connection을 유지하려고 sticky session이 반드시 필요한 것은 아니다.

Sticky Session은 Socket.IO가 HTTP long-polling 요청 여러 개를 거쳐 WebSocket으로 upgrade할 때처럼 연결 수립이 여러 HTTP 요청에 걸치거나, 재연결 후에도 서버 로컬 세션을 찾아야 할 때 첫 번째 문제를 완화한다. 로드밸런서가 쿠키나 소스 IP 기준으로 같은 클라이언트를 같은 인스턴스에 붙이는 방식이다. WebSocket-only transport와 외부 세션 저장소를 사용하면 이 affinity가 필요하지 않을 수 있다. 어느 경우든 sticky session만으로 두 번째 문제는 해결되지 않는다.

Sticky Session:
Client A -> Instance 1 고정
Client B -> Instance 2 고정
문제:
Instance 1에서 발생한 broadcast를 Instance 2의 Client B가 어떻게 받을까?

상태 fan-out에는 보통 Redis Pub/Sub 같은 backplane을 둔다.

Instance 1 -- publish order:7 changed --> Redis
Instance 2 -- subscribe order:7 --------> Redis
Instance 3 -- subscribe order:7 --------> Redis

Socket.IO의 Redis adapter는 이 패턴을 라이브러리로 감싼 것이다. 핵심은 “sticky session이 scale-out의 끝”이 아니라는 점이다. sticky session은 연결 라우팅 문제를 줄이고, Redis adapter 같은 backplane은 인스턴스 간 메시지 공유 문제를 줄인다.

선택 기준은 다음과 같다.

상황우선 볼 선택지이유
단일 인스턴스 개발 환경별도 sticky/backplane 불필요연결이 한 프로세스에만 있다
2개 이상, 표준 WebSocket/WS-only연결 registry + backplane 검토열린 연결은 이미 고정되며 사용자 위치 공유가 핵심이다
Socket.IO long-polling 사용sticky session + backplanehandshake·polling 요청 affinity가 필요할 수 있다
여러 인스턴스에 broadcast 필요Redis adapter/backplane한 인스턴스의 emit을 전체로 전파해야 한다
인스턴스 장애가 잦다재연결 + 상태 복원 설계sticky만 있으면 해당 인스턴스 장애에 취약

실패 신호는 명확하다.

  • 단일 서버에서는 채팅이 되는데 배포 후 일부 사용자만 메시지를 못 받는다.
  • broadcast 로그는 찍히지만 다른 인스턴스의 클라이언트에는 도착하지 않는다.
  • 특정 인스턴스 CPU만 높고 연결 수가 쏠린다.
  • 배포나 scale-in 직후 대량 재연결이 발생한다.

5. gRPC: HTTP/2 위의 RPC와 Protobuf 계약

섹션 제목: “5. gRPC: HTTP/2 위의 RPC와 Protobuf 계약”

REST는 HTTP 리소스와 JSON 표현을 중심으로 설계한다. 외부 공개 API에는 이 점이 장점이다. 사람이 curl로 읽을 수 있고, 브라우저와 프록시 생태계가 잘 맞는다.

서버 내부 통신에서는 다른 문제가 커진다.

  • 호출 빈도가 높아 JSON 직렬화와 파싱 비용이 누적된다.
  • 서비스가 여러 언어로 나뉘면 요청/응답 타입을 맞추기 어렵다.
  • streaming, deadline, cancellation, status code 같은 호출 제어가 필요하다.
  • API 문서와 실제 구현이 어긋나기 쉽다.

gRPC는 이 문제를 RPC 모델로 푼다. RPC는 원격 프로시저 호출이다. 네트워크 너머의 함수를 로컬 함수처럼 호출한다는 추상화다.

service InventoryService {
rpc Reserve (ReserveRequest) returns (ReserveReply);
}

이 정의는 “HTTP POST 경로가 무엇인가”보다 먼저 “어떤 서비스에 어떤 메서드가 있고, 입력과 출력 타입이 무엇인가”를 말한다. 실제 전송은 HTTP/2 위에서 이뤄지지만, 개발자가 보는 표면은 메서드 호출에 가깝다.

5.2. Protobuf: 필드명 대신 필드 번호를 보내는 계약

섹션 제목: “5.2. Protobuf: 필드명 대신 필드 번호를 보내는 계약”

Protobuf는 스키마 정의 언어이자 이진 직렬화 포맷이다. 직렬화는 메모리의 값을 네트워크나 파일로 보낼 수 있는 바이트 형태로 바꾸는 일이다.

syntax = "proto3";
package user;
message User {
string name = 1;
int32 age = 2;
}

여기서 1, 2는 기본값이 아니다. 필드 번호(field number)다. Protobuf 와이어 포맷은 name, age라는 문자열을 매번 보내지 않고, 필드 번호와 타입 정보를 조합해 값을 보낸다.

JSON:
{ "name": "Alice", "age": 30 }
대략 28 bytes 이상. 필드명 문자열도 전송된다.
Protobuf:
field 1 -> "Alice"
field 2 -> 30
대략 9 bytes 수준의 compact binary로 표현 가능하다.

정확한 크기는 값, 타입, 라이브러리, framing에 따라 달라진다. 중요한 감각은 “필드명이 매번 전송되지 않는다”와 “스키마가 있어야 해석할 수 있다”이다.

필드 번호는 호환성 계약이므로 함부로 재사용하면 안 된다.

// v1
message User {
string id = 1;
int32 age = 2;
}
// 나쁜 v2: field 2를 다른 의미로 재사용
message User {
string id = 1;
string grade = 2;
}

v1 클라이언트는 field 2를 age로 알고 있고, v2 서버는 field 2를 grade로 보낼 수 있다. 같은 번호에 다른 의미가 붙으면 양쪽이 서로 다른 세계를 본다. 삭제한 필드 번호는 reserved로 막아두는 것이 안전하다.

message User {
reserved 2;
reserved "age";
string id = 1;
string grade = 3;
}

이 한 가지 규칙만 기억해도 Protobuf 스키마 변경 사고의 큰 부분을 피할 수 있다. 필드명은 사람이 읽는 이름이고, 필드 번호는 네트워크 계약이다.

gRPC는 HTTP/2를 사용한다. HTTP/2는 하나의 TCP 연결 위에 여러 stream을 동시에 싣는다. 그래서 gRPC 클라이언트는 보통 channel을 재사용하고, 그 channel 안에서 여러 RPC stream을 만든다.

TCP connection
└── HTTP/2 connection
├── stream 1: InventoryService.Reserve
├── stream 3: UserService.GetUser
└── stream 5: PaymentService.Authorize

HTTP/1.1에서는 연결 하나에서 요청/응답 순서가 성능 병목이 되기 쉽다. HTTP/2 stream multiplexing은 여러 요청이 같은 연결을 공유하면서도 독립된 stream id를 갖게 한다. 다만 TCP 한 연결 위에 올라가므로 패킷 손실 같은 하위 계층 문제의 영향은 공유할 수 있다.

gRPC를 “Protobuf라서 빠르다”로만 이해하면 절반만 본 것이다. 성능과 사용성은 다음 네 가지가 같이 만든다.

  • Protobuf compact binary: payload 크기와 파싱 비용을 줄인다.
  • HTTP/2 multiplexing: 한 연결에서 여러 RPC를 동시에 처리한다.
  • schema-first code generation: 클라이언트/서버 타입을 맞춘다.
  • RPC-level status/deadline/cancellation: 호출 실패를 HTTP 상태 코드와 별도로 다룬다.

gRPC는 요청과 응답이 각각 한 번인지, 여러 번인지에 따라 네 가지 패턴을 제공한다.

gRPC 통신 패턴

Unary

요청 1개에 응답 1개를 돌려준다. 일반 함수 호출과 가장 비슷하다.

단건 조회, 재고 예약, 권한 검증

Server Streaming

클라이언트 요청 1개에 서버가 여러 응답을 순차적으로 보낸다.

로그 tail, 검색 결과 스트림, 진행률

Client Streaming

클라이언트가 여러 메시지를 보낸 뒤 서버가 최종 응답 1개를 반환한다.

파일 청크 업로드, 배치 집계, 센서 샘플 업로드

Bidirectional Streaming

클라이언트와 서버가 각자 여러 메시지를 독립적으로 주고받는다.

실시간 음성, 협상 프로토콜, 양방향 제어

작은 .proto로 네 패턴을 함께 보면 경계가 선명해진다.

service OrderService {
rpc GetOrder (GetOrderRequest) returns (Order); // Unary
rpc WatchOrder (WatchOrderRequest) returns (stream OrderEvent); // Server streaming
rpc UploadEvents (stream OrderEvent) returns (UploadSummary); // Client streaming
rpc SyncOrders (stream ClientEvent) returns (stream ServerEvent); // Bidirectional
}

첫 회독은 위 비교표와 .proto 예시에서 네 패턴의 방향을 구분하면 끝난다. 아래 패턴별 흐름과 client streaming의 half-close는 실제 streaming RPC를 설계하거나 디버깅할 때 돌아오는 선택 심화다.

Unary는 HTTP 요청-응답과 가장 비슷하다.

client -> GetOrder({ id: "o-7" })
server -> Order({ id: "o-7", status: "PAID" })

단건 조회나 검증처럼 입력 하나와 결과 하나가 자연스러울 때 쓴다. REST로도 충분한 상황이 많지만, 내부 서비스 간 타입 생성과 deadline 정책을 통일하고 싶다면 gRPC Unary가 맞을 수 있다.

Server streaming은 클라이언트가 한 번 구독하고 서버가 여러 메시지를 보낸다.

client -> WatchOrder({ id: "o-7" })
server -> OrderEvent({ status: "PAID" })
server -> OrderEvent({ status: "PACKING" })
server -> OrderEvent({ status: "SHIPPED" })
server -> complete

SSE와 비슷하게 보일 수 있지만 주 사용처가 다르다. SSE는 브라우저 친화적인 서버->클라이언트 이벤트 스트림이고, gRPC server streaming은 서버 간 typed stream에 강하다.

Client streaming은 클라이언트가 여러 조각을 보내고 마지막에 서버 응답 하나를 받는다.

client -> OrderEvent(...)
client -> OrderEvent(...)
client -> OrderEvent(...)
client -> half-close
server -> UploadSummary({ count: 3 })

여기서 half-close는 “나는 더 보낼 메시지가 없다”는 신호다. 이 신호가 없으면 서버는 최종 집계를 언제 계산해야 할지 모른다. 예를 들어 서버 구현이 stream completion을 기다려 집계를 반환하는 구조라면, 클라이언트가 stream을 닫지 않는 순간 호출은 deadline까지 대기하다 실패할 수 있다.

Bidirectional streaming은 양쪽이 독립적으로 여러 메시지를 보낸다.

client -> ClientEvent(seq=1)
server -> ServerEvent(ack=1)
server -> ServerEvent(config_version=4)
client -> ClientEvent(seq=2)

WebSocket과 비슷해 보이지만 중요한 차이가 있다. gRPC bidi stream은 Protobuf schema, HTTP/2 stream, deadline, status code, service/method 계약 안에서 움직인다. WebSocket은 더 범용적인 양방향 메시지 채널이고, 메시지 계약과 상태 코드는 애플리케이션이 더 많이 설계한다.

5.5. Deadline, timeout, retry, status code

섹션 제목: “5.5. Deadline, timeout, retry, status code”

gRPC에서는 모든 호출에 deadline을 붙인다고 생각하는 편이 안전하다. Deadline은 “이 시각 이후에는 이 RPC 결과가 더 이상 필요 없다”는 호출 계약이다. Timeout은 흔히 현재 시점부터 얼마 동안 기다릴지를 말한다. 많은 라이브러리에서 timeout을 설정하면 deadline으로 변환된다.

OrderService -> InventoryService.Reserve
client deadline = now + 300ms
100ms: request sent
250ms: server still waiting for DB
300ms: client cancels
server should stop unnecessary work if cancellation is observed

deadline이 없으면 느린 하위 서비스 하나가 호출자를 계속 붙잡아 thread, connection, memory를 소모한다. deadline이 너무 짧으면 정상 요청도 실패한다. 그래서 timeout은 “성능 튜닝 숫자”가 아니라 장애 전파를 제한하는 계약이다.

gRPC status code는 HTTP 상태 코드와 다르다. 서버가 HTTP/2 위에서 통신하더라도 RPC 결과는 gRPC status로 표현된다.

gRPC code의미재시도 감각
OK성공불필요
INVALID_ARGUMENT요청 값이 계약과 맞지 않음같은 요청 재시도 불필요
NOT_FOUND대상 리소스 없음보통 재시도 불필요
UNAUTHENTICATED인증 실패토큰 갱신 후 가능
PERMISSION_DENIED권한 없음권한 변경 전까지 불필요
DEADLINE_EXCEEDEDdeadline 안에 끝나지 않음멱등 호출이면 제한적으로 가능
UNAVAILABLE서버/네트워크/로드밸런서 일시 실패backoff + jitter로 재시도 가능
RESOURCE_EXHAUSTEDquota, rate limit, 리소스 부족Retry-After 성격의 정책 확인 후 재시도
INTERNAL서버 내부 오류원인 확인 전 무작정 재시도하면 증폭 위험

재시도는 “실패했으니 다시”가 아니다. 멱등성과 상태 변경 여부를 먼저 봐야 한다.

안전한 후보:
GetUser, ListOrders, ReadMetrics
위험한 후보:
CreatePayment, ReserveInventory, SendEmail

상태를 바꾸는 RPC에 재시도를 적용하려면 idempotency key나 request id로 중복 처리를 막아야 한다. 그렇지 않으면 DEADLINE_EXCEEDED 후 재시도한 결제가 실제로는 두 번 처리될 수 있다.

재시도가 오히려 장애를 키울 때는 빠르게 실패시키는 경계도 필요하다. 예를 들어 하위 재고 서비스의 UNAVAILABLE이 계속 증가하고 큐가 쌓이면, 모든 주문 요청을 계속 재시도하기보다 잠시 실패를 빠르게 반환하고 fallback 또는 대기열로 넘기는 편이 전체 시스템을 보호한다.

Backoff와 jitter는 WebSocket 재연결과 같은 이유로 필요하다. 하위 서비스가 잠깐 죽었을 때 모든 클라이언트가 즉시 재시도하면 장애가 더 커진다.

gRPC는 서버 간 통신에는 강하지만, 브라우저에서 직접 쓰기에는 경계가 있다. 브라우저 JavaScript는 일반적으로 HTTP/2 raw frame을 직접 제어하지 못한다. 그래서 브라우저에서 gRPC를 쓰려면 gRPC-Web과 Envoy 같은 프록시가 필요하다.

경계의미
브라우저 직접 호출일반 gRPC가 아니라 gRPC-Web 프록시가 필요할 수 있다
Client streaminggRPC-Web에서는 제약이 있다
디버깅curl만으로 payload를 읽기 어렵고 proto가 필요하다
외부 파트너 연동JSON REST가 훨씬 보편적이다
프록시 timeout/body limitHTTP/2와 gRPC trailer를 이해하는 프록시 설정이 필요하다

따라서 “gRPC가 빠르니까 모든 API를 gRPC로 바꾸자”는 결론은 위험하다. 브라우저와 외부 파트너가 직접 쓰는 API는 REST가 여전히 좋은 기본값이다. gRPC는 내부 서비스 간 고빈도 호출, streaming, 강한 타입 계약이 필요한 곳에서 빛난다.

gRPC 실패를 HTTP 실패처럼만 보면 원인을 놓칠 수 있다.

관찰의미먼저 확인할 개념
모든 에러가 INTERNAL로 보인다서버가 구체적 gRPC status를 매핑하지 않음RpcException, status code mapping
DEADLINE_EXCEEDED가 늘어난다하위 작업이 timeout budget 안에 안 끝남deadline, DB/API latency, cancellation
UNAVAILABLE이 배포 직후 증가한다서버/프록시/channel 연결이 흔들림readiness, connection backoff, LB config
proto 변경 후 일부 클라이언트만 깨진다생성 코드나 field number 계약이 불일치proto package version, reserved fields
streaming RPC가 끝나지 않는다half-close 또는 completion 신호 누락client stream close, deadline
브라우저에서만 호출이 어렵다일반 gRPC와 브라우저 런타임 경계gRPC-Web, Envoy, REST 대안
retry 후 중복 처리가 발생한다멱등성 없이 상태 변경 RPC를 재시도idempotency key, retry policy

6. 선택 심화 대비 — Webhook은 어디에 놓이는가

섹션 제목: “6. 선택 심화 대비 — Webhook은 어디에 놓이는가”

이 절은 첫 회독 완료 조건이 아니다. 지속 연결인 WebSocket과 외부 시스템이 보내는 단발성 HTTP push를 대비하고 싶을 때 읽는다.

Webhook은 WebSocket이나 gRPC의 경쟁자가 아니라, 외부 시스템이 내 HTTP 엔드포인트를 호출하는 push 방식이다.

Polling:
우리 서버 -> 결제사: 결제 끝났나요?
우리 서버 -> 결제사: 결제 끝났나요?
Webhook:
결제사 -> 우리 서버: 결제 완료 이벤트입니다.

Webhook은 일반 HTTP 요청이므로 연결을 오래 유지하지 않는다. 외부 시스템이 이벤트를 보낼 때마다 POST /webhooks/payment 같은 엔드포인트를 호출한다. 그래서 WebSocket의 heartbeat나 sticky session 문제와는 다르다.

Webhook에서 중요한 학습 포인트는 신뢰 경계다. 신뢰 경계는 통제할 수 없는 외부 입력이 내부 처리로 넘어오는 지점이며, 이 경계를 넘을 때 발신자와 메시지 무결성을 검증해야 한다. Webhook은 외부에서 온 HTTP 요청이므로 “정말 결제사가 보냈고 전송 중 내용이 바뀌지 않았는가”를 확인해야 한다.

HMAC(Hash-based Message Authentication Code)은 송신자와 수신자가 공유한 비밀키와 원본 메시지로 인증 코드를 계산해, 발신자와 메시지 무결성을 확인하는 방식이다.

sender:
signature = HMAC_SHA256(secret, raw_request_body)
receiver:
expected = HMAC_SHA256(secret, raw_request_body)
signature == expected 인지 비교

여기서 raw request body가 중요하다. JSON을 파싱한 뒤 다시 JSON.stringify()하면 공백과 키 순서가 바뀔 수 있다. 서명은 원본 바이트에 대해 계산되므로, 프레임워크에서 raw body를 보존해야 한다.

이 문서에서는 Webhook을 push 통신의 대비용으로만 둔다. 핵심은 WebSocket의 지속 양방향 연결과 gRPC의 typed RPC다.


7. REST, SSE, WebSocket, gRPC 선택 기준

섹션 제목: “7. REST, SSE, WebSocket, gRPC 선택 기준”

선택 기준은 “실시간이면 WebSocket”, “빠르면 gRPC”처럼 단순하지 않다. 통신 방향, 호출 주체, 브라우저 경계, 상태 보유 비용, 계약 강도를 함께 본다.

질문RESTSSEWebSocketgRPC
브라우저 직접 사용매우 좋음좋음좋음일반 gRPC는 어려움, gRPC-Web 필요
외부 공개 API좋음제한적제한적파트너 지원 낮음
서버가 먼저 알림polling 필요단방향 가능양방향 가능server streaming은 서버 간에 적합
클라이언트도 자주 말함요청 반복부적합적합bidi streaming은 서버 간에 적합
타입 계약OpenAPI 등 별도 관리이벤트 포맷 별도 관리메시지 포맷 별도 관리Protobuf가 기본 계약
디버깅curl/Postman 친화적브라우저 Network 친화적프레임/이벤트 도구 필요proto/grpcurl 같은 도구 필요
운영 부담낮음중간heartbeat/scale-out 부담 높음proto 배포/proxy/deadline 설계 필요
대표 사용처CRUD, 공개 API진행률, 로그, 알림채팅, 협업, 위치, 게임내부 서비스, 스트리밍, 고빈도 RPC

7.1. WebSocket이 REST polling보다 나은 조건

섹션 제목: “7.1. WebSocket이 REST polling보다 나은 조건”

다음 조건이 함께 있으면 WebSocket을 검토한다.

  • 클라이언트 수가 많다.
  • 이벤트 발생 시점이 예측하기 어렵다.
  • 사용자가 변경을 빠르게 봐야 한다.
  • 서버도 클라이언트로 먼저 메시지를 보내야 한다.
  • 연결 유지 비용을 감당할 수 있다.

반대로 이벤트가 드물고 사용자가 몇 분 늦게 알아도 되는 백오피스 화면이라면 polling이 충분할 수 있다. 예를 들어 하루에 몇 번 바뀌는 정산 상태를 5분마다 조회하는 것은 단순하고 안정적이다. WebSocket을 붙이면 heartbeat, 재연결, fan-out, 권한 갱신까지 책임져야 한다.

SSE는 서버에서 클라이언트로만 이벤트를 보낸다. 브라우저에는 EventSource가 있고 자동 재연결도 지원한다.

서버 -> 브라우저: build progress 10%
서버 -> 브라우저: build progress 40%
서버 -> 브라우저: build progress 100%

클라이언트가 같은 연결로 서버에게 자주 메시지를 보낼 필요가 없다면 SSE가 단순하다. 진행률 표시, 로그 tail, 알림 feed는 SSE 후보가 된다. 양방향 채팅, 협업 편집, 실시간 입력 전송은 WebSocket 후보가 된다.

gRPC는 다음 조건에서 강하다.

  • 서버 간 호출이 많고 latency budget이 작다.
  • 요청/응답 타입을 여러 언어에서 일관되게 생성하고 싶다.
  • streaming이 자연스러운 문제다.
  • deadline, cancellation, status code, retry 정책을 호출 단위로 통일하고 싶다.
  • API 소비자가 내부 팀이고 proto 배포 체계를 운영할 수 있다.

정량 감각도 함께 봐야 한다. Protobuf payload는 JSON보다 작을 수 있고, HTTP/2 multiplexing은 동시 호출에서 유리하다. Uber가 M3 메트릭 인제스트를 gRPC로 전환하며 p99 쓰기 레이턴시를 34.1ms에서 13.6ms로 줄였다는 사례처럼, 고빈도·고차원·대용량 요청에서는 이득이 커질 수 있다.

하지만 작은 단건 조회에서는 JSON/REST의 단순성이 더 큰 가치일 수 있다. 수치 사례는 “gRPC가 항상 빠르다”는 결론이 아니라, 어떤 비용이 병목인지 찾는 힌트로 읽어야 한다.

Uber 사례를 조금 더 쪼개면 이 경계가 보인다. M3 메트릭 인제스트는 p99 쓰기 레이턴시가 34.1ms에서 13.6ms로 줄었지만, Eats 추천 검색의 p99는 205ms에서 176ms로 줄어 개선폭이 훨씬 작았다. 고차원 벡터 검색 payload는 약 88.7% 줄었지만, tail latency는 GC, DB, 네트워크 같은 다른 병목을 함께 받는다. 따라서 후보를 고를 때는 “요청 빈도 × payload 크기 × p95/p99 병목 × 계약 안정성”을 함께 본다.

관찰값gRPC 후보 신호해석
payload가 크고 필드명이 반복된다Protobuf로 줄일 여지가 있다JSON 파싱과 네트워크 비용이 함께 줄 수 있다
p95보다 p99가 크게 튄다deadline·streaming·connection reuse를 함께 본다DB tail latency가 원인이면 gRPC만으로 해결되지 않는다
호출 수가 많고 schema drift가 잦다generated client와 proto review 가치가 커진다성능보다 계약 안정성이 주된 이득일 수 있다
워크로드gRPC 기대 이득REST가 나을 수 있는 이유
내부 메트릭 인제스트payload 크기, streaming, p99 latency운영팀이 proto/tooling을 감당해야 한다
고차원 벡터 검색큰 payload에서 binary encoding 이득외부 공개 API라면 JSON 생태계가 중요하다
사용자 프로필 단건 조회타입 생성 정도의 이득latency 대부분이 DB라면 체감 이득이 작다
파트너사 결제 API이론상 타입 계약 가능파트너가 REST/JSON만 지원하면 REST가 맞다
선택 실수나타나는 증상더 나은 질문
단방향 알림에 WebSocket 도입연결 운영 부담이 큰데 클라이언트 송신은 거의 없음SSE로 충분한가?
브라우저 공개 API에 일반 gRPC 도입gRPC-Web 프록시와 tooling 문제가 먼저 터짐REST API가 더 자연스러운가?
고빈도 내부 JSON 호출을 계속 REST로 유지CPU가 JSON 파싱에 쓰이고 schema drift가 잦음gRPC 후보를 p95/p99와 payload 크기로 정렬했나?
WebSocket scale-out에 sticky만 적용일부 인스턴스의 클라이언트만 broadcast 누락backplane/fan-out이 필요한가?
상태 변경 RPC에 무제한 retry결제/예약/이메일이 중복 처리됨멱등성 키와 retry 가능 code를 분리했나?

퀴즈

서버가 브라우저로 진행률만 보내고, 브라우저가 같은 연결로 말할 일은 없다. WebSocket이 항상 최선인가?

힌트: 먼저 통신 방향을 본다.

정답 보기

아니다. 단방향 서버 push면 SSE가 더 단순할 수 있다. WebSocket은 heartbeat, 재연결, sticky session, fan-out 같은 운영 부담을 함께 가져온다.


8. Worked Example: 주문 상태 시스템으로 연결하기

섹션 제목: “8. Worked Example: 주문 상태 시스템으로 연결하기”

주문 상태 화면을 예로 전체 흐름을 묶어보자.

1. 결제사 -> 우리 서버: Webhook으로 결제 완료 통보
2. Order API -> Payment Event 저장
3. Order Service -> Inventory Service: gRPC로 재고 차감 요청
4. Order Service -> WebSocket gateway: 주문 상태 변경 이벤트 발행
5. WebSocket gateway -> 고객 브라우저: order.status.changed push

각 기술은 같은 “push”라는 단어 아래에 있지만 역할이 다르다.

단계기술이유
결제사 이벤트 수신Webhook외부 시스템이 이벤트 발생 시 우리 HTTP endpoint 호출
재고 차감gRPC내부 서비스 간 typed RPC와 deadline/retry 정책 필요
고객 화면 갱신WebSocket서버가 브라우저에 상태 변경을 즉시 알려야 함
주문 상세 재조회REST화면 진입 시 현재 상태를 한 번 조회하면 충분

이 예시에서 중요한 경계는 상태 복원이다. 사용자가 WebSocket 메시지를 놓쳤더라도 REST GET /orders/{id}로 현재 상태를 다시 가져올 수 있어야 한다. WebSocket 메시지는 빠른 알림이고, 영구 상태의 원본은 DB와 API다.

사례 A: 결제는 됐는데 화면이 갱신되지 않는다

Payment provider -> Webhook 200 OK
Order DB status = PAID
Browser WebSocket connected
UI still shows PENDING

이때 결제사 Webhook만 보면 부족하다. DB 상태는 바뀌었으므로 Webhook 수신은 성공했다. 다음으로 Order Service가 WebSocket gateway에 이벤트를 발행했는지, gateway가 올바른 room/user connection을 찾았는지, 해당 사용자가 다른 인스턴스에 연결되어 있는데 fan-out이 빠졌는지를 본다.

사례 B: 배포 후 모든 클라이언트가 동시에 재연결한다

deployment restart
active websocket connections drop
reconnect attempts spike
CPU and handshake latency spike

이건 비즈니스 메시지 오류가 아니라 재연결 전략 문제다. backoff와 jitter가 없거나, 서버 readiness 전에 트래픽이 몰렸을 수 있다. 재연결 폭풍을 줄이고, 클라이언트가 최신 상태를 REST로 재조회하게 만들면 사용자 경험과 서버 보호를 함께 잡을 수 있다.

사례 C: 재고 차감 gRPC가 가끔 중복된다

Order Service -> ReserveInventory
client deadline exceeded
client retries
Inventory Service processed both requests

DEADLINE_EXCEEDED는 “서버가 처리하지 않았다”가 아니다. 클라이언트가 기다리기를 포기했다는 뜻일 수 있다. 하위 서비스가 실제로 처리했을 가능성이 있으므로 상태 변경 RPC에는 idempotency key가 필요하다.


긴 NestJS 프로젝트 생성 절차보다 먼저, 각 개념이 코드에서 어디에 드러나는지만 확인한다. 전체 실행 환경은 프레임워크 문서를 보고 맞추면 된다.

9.1. Nest IoAdapter(Socket.IO) gateway에서 보이는 핵심 경계

섹션 제목: “9.1. Nest IoAdapter(Socket.IO) gateway에서 보이는 핵심 경계”

아래 코드는 Nest의 기본 IoAdapter를 통해 Socket.IO를 사용하는 예제다. Socket, named event, room, namespace, pingInterval·pingTimeout은 Socket.IO API와 옵션이다. Socket.IO는 WebSocket을 전송 방식으로 사용할 수 있지만 표준 WebSocket과 같은 프로토콜은 아니다. Nest WsAdapter{ event, data } 메시지를 파싱해 @SubscribeMessage()로 라우팅하지만 room과 namespace는 제공하지 않는다. 따라서 이 예제를 표준 WebSocket이나 WsAdapter API 예제로 읽으면 안 된다.

@WebSocketGateway({
namespace: "/orders",
pingInterval: 25000,
pingTimeout: 20000,
})
export class OrderGateway {
@WebSocketServer()
server: Server;
handleConnection(client: Socket) {
const userId = authenticateHandshake(client.handshake);
client.join("user:" + userId);
}
pushOrderStatus(userId: string, event: OrderStatusChanged) {
this.server.to("user:" + userId).emit("order.status.changed", event);
}
}

이 예제에서 봐야 할 것은 데코레이터 이름보다 Socket.IO adapter가 추가한 경계다.

  • handleConnection: Socket.IO handshake를 마친 연결을 사용자와 연결한다.
  • pingInterval, pingTimeout: Socket.IO heartbeat로 idle 연결과 dead connection을 구분한다.
  • namespace: Socket.IO 연결 경로를 논리적으로 분리한다.
  • room: 서버가 인증된 연결을 그룹화하는 Socket.IO API다. 클라이언트가 room 이름을 직접 선택해 가입하는 구조가 아니다.
  • named event와 payload: Socket.IO event API로 표현하지만 이름과 payload 의미는 애플리케이션이 정의하는 계약이다.

9.2. gRPC proto에서 보이는 핵심 계약

섹션 제목: “9.2. gRPC proto에서 보이는 핵심 계약”
syntax = "proto3";
package inventory;
service InventoryService {
rpc Reserve (ReserveRequest) returns (ReserveReply);
rpc WatchReservation (WatchRequest) returns (stream ReservationEvent);
}
message ReserveRequest {
string request_id = 1;
string order_id = 2;
string sku = 3;
int32 quantity = 4;
}
message ReserveReply {
bool accepted = 1;
string reason = 2;
}
message WatchRequest {
string order_id = 1;
}
message ReservationEvent {
string order_id = 1;
string status = 2;
}

학습 포인트는 네 가지다.

  • request_id는 retry 중복 처리를 위한 멱등성 키가 될 수 있다.
  • 필드 번호는 wire contract이므로 재사용하지 않는다.
  • WatchReservationstream은 server streaming이다.
  • accepted=false 같은 비즈니스 거절과 UNAVAILABLE 같은 RPC 실패는 다른 층이다.
if (!isValidSku(input.sku)) {
throw new RpcException({
code: status.INVALID_ARGUMENT,
message: "sku is invalid",
});
}
if (!inventory) {
throw new RpcException({
code: status.NOT_FOUND,
message: "inventory not found",
});
}

일반 Error를 던지면 클라이언트가 대부분 INTERNAL만 볼 수 있다. 그러면 요청이 잘못됐는지, 리소스가 없는지, 서버가 죽었는지를 구분하기 어렵다. gRPC에서는 HTTP status가 아니라 RPC status를 의식적으로 매핑해야 한다.

9.4. 관찰 도구는 개념을 확인하는 용도다

섹션 제목: “9.4. 관찰 도구는 개념을 확인하는 용도다”

socket.io-clientgrpcurl은 유용하지만, 이 문서의 본문 목적은 설치 절차가 아니다. 도구로 확인할 때는 다음만 보면 된다.

Socket.IO:
- `/orders` namespace에 Socket.IO protocol로 연결했는가?
- WebSocket transport를 강제했다면 handshake가 101로 끝났는가?
- `order.status.changed` named event를 Socket.IO client가 수신하는가?
gRPC:
- proto를 기준으로 호출했는가?
- 응답 payload와 status code를 함께 봤는가?
- deadline을 걸었을 때 어떤 code가 오는가?

운영 절차를 외우기보다 증상, 의미, 먼저 볼 개념을 연결한다.

증상의미먼저 볼 개념
101 Switching Protocols가 없다Upgrade 전 HTTP 단계에서 실패proxy header, auth, route
60초 안팎으로 간헐적 disconnectidle timeout과 heartbeat 간격 충돌 가능ALB/Nginx timeout, ping interval
단일 서버는 정상, 다중 서버에서 일부 누락인스턴스 간 fan-out 부재 가능Redis adapter, room, sticky session
연결 수는 정상인데 화면이 늦다backpressure 또는 stale event 처리 문제buffer, drop/coalesce policy
배포 직후 재연결 시도가 폭증backoff/jitter 또는 readiness 문제reconnect strategy, server capacity
권한 회수 후에도 이벤트를 받는다장시간 연결의 auth/session 갱신 누락session TTL, auth version, disconnect
증상의미먼저 볼 개념
UNAVAILABLE이 특정 배포 후 증가연결 대상, readiness, 프록시 문제가 흔함service discovery, LB, backoff
DEADLINE_EXCEEDED가 tail에서 증가timeout budget 초과deadline, DB/API latency
RESOURCE_EXHAUSTED가 burst 때 증가quota, rate limit, connection pool 부족concurrency limit, retry policy
INTERNAL만 보인다status mapping이 뭉개졌을 수 있음RpcException, server interceptor
proto 변경 뒤 일부 클라이언트만 실패생성 코드 버전이나 field number 불일치proto package version, reserved
streaming 결과가 끝나지 않는다stream completion/half-close 누락client close, server complete

10.3. 선택 심화: Webhook 실패 신호

섹션 제목: “10.3. 선택 심화: Webhook 실패 신호”
증상의미먼저 볼 개념
서명 검증이 항상 실패raw body가 아니라 파싱 후 body로 검증raw request body, HMAC
같은 이벤트가 여러 번 처리됨외부 시스템 retry와 멱등성 누락event id, idempotency
Webhook은 성공인데 화면 미갱신push 수신 이후 내부 이벤트 전파가 끊김DB update, WebSocket fan-out

헷갈리는 쌍구분
WebSocket vs Socket.IOWebSocket은 표준 프로토콜이고, Socket.IO는 재연결·room·fallback 등을 얹은 라이브러리다
WebSocket frame vs app messageframe은 프로토콜 전송 단위이고, app message는 서비스가 의미를 붙인 payload다
heartbeat vs business eventheartbeat는 연결 생존 확인이고, business event는 사용자 상태 변경이다
sticky session vs Redis adaptersticky는 연결 라우팅, Redis adapter는 인스턴스 간 메시지 fan-out이다
deadline vs timeoutdeadline은 끝나야 하는 시각, timeout은 대기 기간으로 표현되는 경우가 많다
gRPC status vs HTTP statusRPC 결과는 gRPC status로 읽고, HTTP/2는 전송 계층에 가깝다
Protobuf field name vs field number이름은 사람이 읽는 계약, 번호는 wire format의 핵심 계약이다
REST streaming vs gRPC streamingREST도 chunk/SSE가 가능하지만 gRPC는 typed RPC stream을 기본 모델로 제공한다
Webhook vs WebSocketWebhook은 이벤트마다 들어오는 HTTP 요청, WebSocket은 지속 양방향 연결이다

12. 선택 부록 — 짧은 실습 체크

섹션 제목: “12. 선택 부록 — 짧은 실습 체크”

이 부록은 본문 이해에 필수는 아니다. 개념을 손으로 확인하고 싶을 때만 사용한다.

12.1. Socket.IO namespace 연결과 서버 emit 확인

섹션 제목: “12.1. Socket.IO namespace 연결과 서버 emit 확인”
import { io } from "socket.io-client";
const socket = io("http://localhost:3000/orders", {
transports: ["websocket"],
});
socket.on("connect", () => {
console.log("connected", socket.id);
});
socket.on("order.status.changed", (event) => {
console.log(event);
});
socket.on("connect_error", (error) => {
console.error(error.message);
});

확인할 것:

  • io("http://localhost:3000/orders")/orders는 raw WebSocket 경로가 아니라 Socket.IO namespace다.
  • transports: ["websocket"]을 지정하면 polling 없이 WebSocket transport로 연결하며, 실제 HTTP Upgrade 요청은 기본 /socket.io/ path로 간다.
  • 서버의 pushOrderStatus()order.status.changed를 emit하면 해당 사용자 room에 가입된 클라이언트의 listener가 event를 받는다.
  • 인증이 필요한 서버라면 authenticateHandshake() 계약에 맞는 cookie나 auth 값을 client 옵션에 추가한다.

이 예제에서 클라이언트는 room 이름을 보내지 않는다. 서버의 handleConnection()이 인증 결과로 room을 정하고 client.join()을 호출한다. 브라우저 개발자 도구 Network 탭에서는 /socket.io/?EIO=...&transport=websocket 요청의 101과 이후 frame을 확인할 수 있다.

Terminal window
grpcurl -plaintext \
-proto inventory.proto \
-d '{"requestId":"r-1","orderId":"o-7","sku":"keyboard","quantity":1}' \
localhost:5000 \
inventory.InventoryService/Reserve

확인할 것:

  • 요청 JSON은 도구 입력일 뿐 실제 전송은 proto schema 기준으로 직렬화된다.
  • 실패 시 payload뿐 아니라 gRPC status code를 같이 본다.
  • 같은 요청을 재시도해도 안전하려면 request_id 같은 멱등성 키가 필요하다.
client deadline: 300ms
server DB call: p95 250ms, p99 600ms

이 설정에서는 p99 요청 일부가 정상적으로 DEADLINE_EXCEEDED가 된다. 해결은 무조건 deadline을 늘리는 것이 아니다. 호출자의 전체 latency budget, DB tail latency, fallback 가능성, retry가 중복 처리를 만들지 않는지를 함께 봐야 한다.


첫 회독은 아래 완료 체크만 사용한다. 이어지는 두 체크리스트는 설계·운영 시 다시 보는 선택 심화이며, 모두 답하지 못해도 이 토픽의 첫 회독을 마칠 수 있다.

첫 회독 완료 체크

  • HTTP 요청-응답과 WebSocket 지속 양방향 연결의 차이를 설명할 수 있다.
  • 표준 WebSocket, Nest WsAdapter, Socket.IO와 frame·message·event의 경계를 구분할 수 있다.
  • 지속 연결이 polling의 어떤 비용을 줄이고 어떤 연결 운영 비용을 새로 만드는지 말할 수 있다.
  • gRPC를 Protobuf 타입 계약을 사용하는 RPC로 설명하고, unary와 streaming의 방향 차이를 말할 수 있다.
  • REST, SSE, WebSocket, gRPC 중 통신 방향과 계약 성격에 맞는 후보를 고를 수 있다.

WebSocket 선택 심화 복습 체크

  • HTTP Upgrade 핸드셰이크와 101 Switching Protocols 이후의 프로토콜 전환을 설명할 수 있다.
  • 지속 양방향 연결이 HTTP polling의 어떤 비용을 줄이고 어떤 운영 비용을 새로 만드는지 말할 수 있다.
  • WebSocket frame, message, Socket.IO event의 경계를 구분할 수 있다.
  • backpressure가 연결 생존과 별개의 최신성 문제임을 수치 예시로 설명할 수 있다.
  • Ping-Pong heartbeat와 ALB idle timeout의 상대값을 비교할 수 있다.
  • 재연결에 backoff와 jitter가 필요한 이유를 설명할 수 있다.
  • 장시간 연결에서 인증/권한 변경을 어떻게 반영할지 질문할 수 있다.
  • sticky session과 Redis adapter/backplane의 역할 차이를 설명할 수 있다.

gRPC 선택 심화 복습 체크

  • gRPC를 Protobuf schema와 HTTP/2 stream 위의 RPC로 설명할 수 있다.
  • Protobuf field number가 값이 아니라 wire contract임을 설명할 수 있다.
  • Unary, server streaming, client streaming, bidirectional streaming을 예시로 구분할 수 있다.
  • Client streaming에서 half-close가 없으면 서버가 왜 기다릴 수 있는지 설명할 수 있다.
  • deadline/timeout/retry/status code의 관계를 장애 전파 관점으로 설명할 수 있다.
  • 상태 변경 RPC 재시도에 idempotency key가 필요한 이유를 말할 수 있다.
  • 브라우저 직접 호출, gRPC-Web, 프록시 경계를 설명할 수 있다.
  • REST, SSE, WebSocket, gRPC 중 상황별 선택 기준을 적용할 수 있다.

WebSocket, HTTP Upgrade, 101 Switching Protocols, Persistent Connection, Full-Duplex, Frame, Message Boundary, Backpressure, Ping, Pong, Heartbeat, Reconnect, Exponential Backoff, Jitter, Sticky Session, Redis Adapter, Backplane, Fan-out, gRPC, RPC, Protocol Buffers, Protobuf, Field Number, reserved, HTTP/2, Stream Multiplexing, Unary, Server Streaming, Client Streaming, Bidirectional Streaming, Deadline, Timeout, Retry, gRPC Status Code, gRPC-Web, Webhook, HMAC, rawBody, SSE, Polling



기술핵심 원리가장 자연스러운 곳실패 신호
RESTHTTP 요청-응답, 리소스, 상태 코드공개 API, 단순 조회/수정, 브라우저/파트너polling 폭증, schema drift
SSEHTTP 지속 연결의 단방향 서버 이벤트진행률, 로그, 알림양방향 요구가 생기면 한계
WebSocketUpgrade 이후 지속 양방향 frame 통신채팅, 협업, 위치, 실시간 상태idle disconnect, backpressure, fan-out 누락
gRPCProtobuf schema + HTTP/2 RPC내부 서비스 간 고빈도 호출과 streamingdeadline 초과, proto 불일치, status mapping 누락
Webhook외부 시스템이 내 HTTP endpoint 호출결제/배송/CI 이벤트 수신raw body 서명 실패, 중복 이벤트

WebSocket은 요청 수를 줄이는 대신 열린 연결의 생명주기를 책임지게 한다. 그래서 heartbeat, backpressure, 재연결, 인증 갱신, load balancing을 함께 설계해야 한다.

gRPC는 JSON REST보다 빠른 도구 하나가 아니라, Protobuf schema와 HTTP/2 stream을 기반으로 한 RPC 계약이다. field number, streaming 종류, deadline, status code, retry 가능성을 함께 이해해야 안전하게 쓸 수 있다.

첫 판단은 단순하다. 공개 API와 브라우저 기본값은 REST, 단방향 서버 push는 SSE, 양방향 실시간은 WebSocket, 내부 typed 고빈도 RPC는 gRPC다. 그다음 실제 선택은 운영 부담, 브라우저/프록시 경계, 상태 복원, 실패 신호까지 포함해 결정한다.