이 문서는 의문을 가지고 있던 지점을 AI 에이전트와 대화하며 정리한 메모입니다. HTTP의 무상태성과 연결 재사용, gRPC의 HTTP/2 사용 이유, HTTP/2 멀티플렉싱과 Java NIO 멀티플렉싱의 차이를 함께 정리합니다.
HTTP / gRPC / 멀티플렉싱 정리#
1. HTTP 무상태성과 TCP 연결 관리의 관계#
HTTP의 무상태성이란?#
RFC 기준으로 HTTP의 핵심 성질은 무상태성(statelessness) 이다.
즉, 각 요청은 이전 요청과 독립적으로 이해될 수 있어야 하며, 서버가 이전 요청의 문맥을 반드시 기억할 필요는 없다.
이 말은 TCP 연결을 매번 끊는다는 뜻이 아니다.
HTTP의 무상태성은 애플리케이션 의미론이고, TCP 연결 재사용 여부는 연결 관리(connection management) 문제다.
TCP 연결 생명주기 — HTTP 버전별#
HTTP/1.0 (초기)#
클라이언트 서버
|-- TCP 3-way handshake -->|
|-- HTTP 요청 ------------>|
|<-- HTTP 응답 ------------|
|-- TCP 종료 (4-way) ----->| ← 매 요청마다 연결/해제요청/응답 1쌍마다 TCP를 새로 맺고 끊었다. 비효율적.
HTTP/1.1 — Persistent Connection이 기본값#
클라이언트 서버
|-- TCP 3-way handshake -->|
|-- 요청 1 --------------->|
|<-- 응답 1 ---------------|
|-- 요청 2 --------------->| ← 같은 TCP 연결 재사용
|<-- 응답 2 ---------------|
|-- 요청 3 --------------->|
|<-- 응답 3 ---------------|
|-- TCP 종료 -----------> | ← 일정 시간 후 또는 명시적 종료HTTP/1.1부터는 persistent connection 이 기본값이다.
별도로 Connection: close를 보내지 않으면 TCP 연결을 유지한다.
HTTP/2, HTTP/3#
- HTTP/2: 하나의 TCP 연결에서 멀티플렉싱 (여러 요청을 병렬로)
- HTTP/3: TCP 대신 QUIC(UDP 기반)을 사용, 연결 오버헤드 더 줄임
버전별 비교#
| HTTP/1.0 | HTTP/1.1 | HTTP/2 | |
|---|---|---|---|
| 기본 동작 | 요청마다 TCP 재생성 | TCP 재사용 (keep-alive 기본) | 하나의 TCP로 다중 요청 |
| Keep-Alive | 옵션 (헤더 명시 필요) | 기본값 | 별도 Connection: keep-alive 개념 아님, 지속 연결이 기본 |
핵심 포인트#
- 무상태성 = 서버가 각 요청을 독립적으로 처리한다 (stateless)
- Keep-Alive = TCP 물리 연결을 재사용한다 (persistent connection)
- 둘은 다른 차원의 개념이다. 무상태성(HTTP 의미론)과 Keep-Alive(TCP 연결 관리)는 서로 독립적이다.
2. gRPC의 통신 프로토콜#
레이어 구조#
┌─────────────────────────────┐
│ gRPC (애플리케이션) │ ← 서비스/메서드 정의 (proto)
├─────────────────────────────┤
│ HTTP/2 │ ← gRPC를 실어 나르는 애플리케이션 프로토콜
├─────────────────────────────┤
│ TLS (선택적, 보통 권장) │ ← 암호화
├─────────────────────────────┤
│ TCP │ ← 연결
└─────────────────────────────┘표준 gRPC는 HTTP/2 위에 매핑되는 RPC(Remote Procedure Call) 프레임워크다.
TLS는 실무에서 매우 흔하지만, gRPC 자체가 TLS를 강제하는 것은 아니다.
gRPC가 HTTP/2를 쓰는 이유#
① 멀티플렉싱
하나의 TCP 연결에서:
Stream 1: 요청A ─────────────► 응답A
Stream 3: 요청B ──────────────────────► 응답B
Stream 5: 요청C ───────────────────────────► 응답C여러 RPC 호출이 동시에 하나의 연결을 공유한다.
참고로 클라이언트 라이브러리의 channel 은 구현에 따라 여러 HTTP/2 연결을 관리할 수 있다.
다만 개별 RPC 호출은 HTTP/2 stream 으로 전달된다고 이해하면 된다.
② 스트리밍 지원
HTTP/2의 스트림 개념 덕분에 gRPC만의 4가지 통신 패턴이 가능하다.
gRPC의 4가지 통신 패턴#
1. Unary (일반 요청/응답)#
클라이언트 ──요청 1개──► 서버
◄─응답 1개──형태상 가장 익숙한 요청/응답 패턴이다.
2. Server Streaming#
클라이언트 ──요청 1개──► 서버
◄─응답 1──
◄─응답 2──
◄─응답 3──
◄─END──예: 실시간 주가, 로그 스트리밍
3. Client Streaming#
클라이언트 ──요청 1──► 서버
──요청 2──►
──요청 3──►
◄─응답 1개──예: 파일 업로드, 센서 데이터 배치 전송
4. Bidirectional Streaming#
클라이언트 ──요청 1──► 서버
◄─응답 1──
──요청 2──►
◄─응답 2──
(양방향 자유롭게)예: 채팅, 실시간 게임, 자율주행 텔레메트리
데이터 직렬화: Protocol Buffers#
실무에서 REST가 JSON과 함께 자주 쓰이듯, gRPC는 기본적으로 Protobuf를 사용한다.
다만 REST가 JSON에만 묶이지 않듯, gRPC도 다른 데이터 형식으로 확장될 수 있다.
service TelemetryService {
rpc SendData (stream TelemetryData) returns (Ack);
}
message TelemetryData {
float speed = 1;
float latitude = 2;
float longitude = 3;
}JSON 대비 일반적인 장점:
- 크기 가 더 작을 가능성이 큼 (바이너리)
- 파싱/직렬화 성능이 더 유리한 경우가 많음
- 스키마 기반이라 타입 안정성 과 코드 생성에 유리
REST vs gRPC 비교#
주의: REST는 프로토콜 이름이 아니라 아키텍처 스타일이다. 아래 표는 엄밀한 정의 비교라기보다, 실무에서 자주 만나는 조합을 비교한 것이다.
| REST | gRPC | |
|---|---|---|
| 프로토콜 | HTTP 의미론 위 (HTTP/1.1, HTTP/2, HTTP/3 등) | 표준 gRPC는 HTTP/2 |
| 데이터 형식 | JSON이 흔함 | Protobuf가 기본값 |
| 통신 패턴 | 요청/응답 중심 | 4가지 패턴 |
| 인터페이스 정의 | OpenAPI 등 선택 | .proto 사용이 일반적 |
| 브라우저 지원 | 넓음 | 원본 gRPC는 제한적, 보통 grpc-web 사용 |
| 사용처 | 공개 API, 범용 연동 | 서비스 간 통신, 저지연/스트리밍 |
3. HTTP/2 멀티플렉싱 상세#
HTTP/1.1의 문제: HOL Blocking#
HTTP/1.1에서는 같은 연결 위에서 응답을 요청 순서에 맞춰 돌려줘야 하므로, 앞선 요청이 지연되면 뒤 요청도 지연되기 쉽다.
과거의 pipelining 도 이 문제를 근본적으로 해결하지 못했다.
TCP 연결 1: 요청A ──► [응답A 기다리는 중...] ──► 요청B ──► 요청C요청A 응답이 늦으면 B, C가 전부 블로킹된다. 이게 HOL Blocking이다.
브라우저는 이를 우회하기 위해 보통 여러 개의 병렬 TCP 연결을 열어 완화해 왔다.
HTTP/2의 핵심 개념 3가지#
1. Stream (스트림)#
하나의 TCP 연결 안에 생기는 독립적인 가상 채널.
- 각 스트림은 고유한 Stream ID를 가진다 (홀수: 클라이언트 시작, 짝수: 서버 시작)
- 동시에 여러 스트림이 존재할 수 있다
2. Frame (프레임)#
HTTP/2의 최소 전송 단위. 모든 데이터는 프레임으로 쪼개져서 전송된다.
┌──────────────────────────────────────┐
│ Length (3B) │ Type (1B) │ Flags (1B)│
├──────────────────────────────────────┤
│ Stream ID (4B) │
├──────────────────────────────────────┤
│ Payload ... │
└──────────────────────────────────────┘주요 프레임 타입:
HEADERS: HTTP 헤더 전송DATA: 실제 body 데이터SETTINGS: 연결 설정WINDOW_UPDATE: 흐름 제어
3. Message (메시지)#
하나의 HTTP 요청 또는 응답. 여러 프레임의 묶음으로 표현된다.
요청 한 건 = HEADERS 프레임 + DATA 프레임(들) + 필요 시 trailing HEADERS멀티플렉싱 동작 원리#
하나의 TCP 연결에서 프레임들이 뒤섞여서 전송되고, Stream ID로 어느 요청의 프레임인지 구분한다.
시간 →
──[S1:HEADERS]──[S3:HEADERS]──[S1:DATA]──[S5:HEADERS]──[S3:DATA]──[S1:DATA]──►
↑ ↑ ↑ ↑
요청A 시작 요청B 시작 요청A 본문 요청C 시작수신 측은 Stream ID를 보고 각 요청으로 재조립한다.
HOL Blocking 해결#
HTTP/1.1:
요청A [==============느린 응답==============] 요청B는 대기...
HTTP/2:
Stream1(요청A): [==============느린 응답==============]
Stream3(요청B): [==빠른 응답==] ← 요청A 안 기다림!
Stream5(요청C): [====응답====] ← 독립적으로 처리추가 기능#
우선순위 신호: 과거에는 스트림마다 가중치와 의존 관계를 보낼 수 있었지만, 이 priority signaling은 최신 HTTP/2 RFC에서 deprecated 되었다.
흐름 제어 (Flow Control): 스트림 단위뿐 아니라 연결 전체 단위로도 수신 윈도우를 조절한다. WINDOW_UPDATE 프레임으로 제어한다.
한계: TCP 레벨 HOL Blocking은 여전히 존재#
TCP 패킷 손실 발생 시:
패킷 1 (Stream1 DATA) ✓
패킷 2 (Stream3 DATA) ✗ ← 손실!
패킷 3 (Stream5 DATA) ✓ ← 재전송 대기... 모든 스트림 블로킹- HTTP/2는 HTTP 레이어의 HOL Blocking은 해결
- TCP 패킷 손실 시 전체 스트림이 멈추는 문제는 여전히 존재
- 이걸 해결한 게 HTTP/3 + QUIC(UDP 기반)
4. HTTP/2 멀티플렉싱 vs Java NIO I/O 멀티플렉싱#
공통된 철학#
“하나의 주체가 여러 채널을 동시에 처리한다”
이름은 같지만 다른 레이어의 개념이다.
비교#
| HTTP/2 멀티플렉싱 | Java NIO I/O 멀티플렉싱 | |
|---|---|---|
| 레이어 | 애플리케이션 레이어 (HTTP/2가 TCP 위에서 동작) | OS/런타임 레이어 |
| 다중화 대상 | 하나의 TCP 연결 안의 스트림들 | 여러 TCP 연결(소켓)들 |
| 핵심 문제 | 여러 요청을 하나의 연결로 처리 | 여러 연결을 하나의 스레드로 처리 |
Java NIO I/O 멀티플렉싱#
기존 방식 (Blocking I/O)#
스레드1 ──────► 소켓A (연결 기다리는 중... blocked)
스레드2 ──────► 소켓B (데이터 기다리는 중... blocked)
스레드3 ──────► 소켓C (데이터 기다리는 중... blocked)연결 1개당 스레드 1개. 연결 1만 개면 스레드 1만 개 → 메모리/컨텍스트 스위칭 폭발
NIO 방식#
┌── 소켓A (이벤트 있을 때만 알림)
Selector(1개) ────┼── 소켓B (이벤트 있을 때만 알림)
스레드 1개 └── 소켓C (이벤트 있을 때만 알림)Selector는 SelectableChannel 을 다루는 Java의 멀티플렉서다. 내부적으로는 플랫폼별 I/O 멀티플렉싱 메커니즘(select, poll, epoll, kqueue 등)에 기대어 동작한다.
소켓들을 등록해두면, 이벤트가 생긴 소켓만 골라서 알려준다.
Selector selector = Selector.open();
socketA.register(selector, SelectionKey.OP_READ);
socketB.register(selector, SelectionKey.OP_READ);
socketC.register(selector, SelectionKey.OP_READ);
while (true) {
selector.select(); // 이벤트 있는 소켓이 생길 때까지 대기
for (SelectionKey key : selector.selectedKeys()) {
if (key.isReadable()) {
SocketChannel ch = (SocketChannel) key.channel();
ch.read(buffer);
}
}
}스레드 1개로 수천 개의 소켓을 처리할 수 있다.
두 개념의 위치 — 구체적인 시나리오#
클라이언트 3명이 서버에 접속해서 각각 여러 요청을 보내는 상황:
① TCP 연결(소켓)이 3개 생긴다
클라이언트1 ──────── 소켓A ──┐
클라이언트2 ──────── 소켓B ──┤── 서버
클라이언트3 ──────── 소켓C ──┘② NIO 멀티플렉싱 = 소켓 3개를 감시하는 문제
소켓A에 데이터 왔나?
소켓B에 데이터 왔나? ← 이걸 스레드 1개로 감시
소켓C에 데이터 왔나?NIO 멀티플렉싱의 관심사 = 소켓(연결) 여러 개를 어떻게 효율적으로 감시하나
③ HTTP/2 멀티플렉싱 = 각 소켓 안의 문제
소켓A (클라이언트1과의 TCP 연결) 안에:
├── Stream 1: GET /user
├── Stream 3: GET /posts
└── Stream 5: GET /imagesHTTP/2 멀티플렉싱의 관심사 = 하나의 소켓 안에서 요청 여러 개를 어떻게 동시에 처리하나
④ 전체 그림 (실제 프레임워크에서 두 개념이 함께 동작)
서버
Selector (스레드 1개로 소켓 3개 감시) ← NIO 멀티플렉싱
│
├── 소켓A 안 → Stream1, Stream3, Stream5 ← HTTP/2 멀티플렉싱
├── 소켓B 안 → Stream1, Stream3
└── 소켓C 안 → Stream1한 줄 정리#
| 대상 | 역할 | |
|---|---|---|
| NIO 멀티플렉싱 | 소켓(연결) 여러 개 | 스레드 1개로 여러 소켓을 바깥에서 감시 |
| HTTP/2 멀티플렉싱 | 스트림(요청) 여러 개 | 하나의 소켓 안을 여러 레인으로 나눔 |
Netty, Spring WebFlux 같은 프레임워크는 이 두 개념을 함께 활용한다.
참고 자료#
- RFC 9110 — HTTP Semantics: https://www.rfc-editor.org/rfc/rfc9110
- RFC 9112 — HTTP/1.1: https://www.rfc-editor.org/rfc/rfc9112.html
- RFC 9113 — HTTP/2: https://www.rfc-editor.org/rfc/rfc9113
- RFC 9114 — HTTP/3: https://www.rfc-editor.org/rfc/rfc9114
- gRPC Docs — Core concepts, architecture and lifecycle: https://grpc.io/docs/what-is-grpc/core-concepts/
- gRPC Blog — gRPC on HTTP/2: https://grpc.io/blog/grpc-on-http2/
- gRPC Docs — Basics tutorial for Web: https://grpc.io/docs/platforms/web/basics/
- gRPC Blog — The state of gRPC in the browser: https://grpc.io/blog/state-of-grpc-web/
- Oracle Java API —
SelectableChannel: https://docs.oracle.com/javase/8/docs/api/java/nio/channels/SelectableChannel.html
