TCP 프레이밍 — 점착과 분할 패킷
TCP로 데이터를 주고받는 건 쉬운데, "보낸 그대로" 도착하리라 기대하면 왜 문제가 생기는 걸까?
TCP는 스트림 프로토콜 — 메시지 경계가 없다
TCP를 처음 공부할 때 가장 놓치기 쉬운 부분이 TCP에는 메시지 경계라는 개념이 없다 는 점입니다. UDP는 데이터그램 단위로 전송하기 때문에 보낸 단위 그대로 도착하지만, TCP는 연속된 바이트 스트림입니다.
송신 측에서 "Hello"와 "World"를 두 번 나눠 보내도, 수신 측에서는 아래 세 가지 중 어떤 형태로든 도착할 수 있습니다.
경우 1: [Hello][World] ← 두 번에 걸쳐 각각 도착 (운 좋은 경우)
경우 2: [HelloWorld] ← 하나로 합쳐져 도착 (점착 패킷)
경우 3: [Hel][loWorld] ← 엉뚱한 위치에서 잘려서 도착 (분할 패킷)
Nagle 알고리즘의 영향
이 현상을 더 악화시키는 요소가 Nagle 알고리즘 입니다. Nagle 알고리즘은 네트워크 효율을 높이기 위해 작은 패킷을 모아서 한꺼번에 전송 하는 TCP 최적화 기법입니다.
- 보낼 데이터가 작으면 즉시 전송하지 않고 잠깐 대기
- 대기 중에 추가 데이터가 들어오면 합쳐서 전송
- ACK가 돌아오면 모아둔 데이터를 한꺼번에 전송
Nagle 알고리즘 덕분에 네트워크 대역폭은 효율적으로 사용되지만, 애플리케이션 레벨에서는 여러 메시지가 하나로 합쳐지는 현상 이 더 자주 발생합니다.
네티에서
childOption(ChannelOption.TCP_NODELAY, true)를 설정하면 Nagle 알고리즘을 비활성화할 수 있지만, 이것만으로 점착/분할 문제가 완전히 해결되는 건 아닙니다. OS 수신 버퍼, 네트워크 경로 등 다른 요인도 있기 때문입니다.
점착 패킷(Sticky Packet) — 두 메시지가 하나로 합쳐지는 현상
점착 패킷은 송신 측이 여러 번 보낸 메시지가 수신 측에서 하나로 합쳐져 도착하는 현상 입니다.
송신 측: write("AAA") → write("BBB") → write("CCC")
수신 측: read() → "AAABBBCCC" (한 번에 도착)
주요 발생 원인:
- **Nagle 알고리즘 **: 작은 패킷을 모아서 전송
- ** 수신 측 버퍼 **: 애플리케이션이 버퍼를 읽기 전에 여러 세그먼트가 쌓임
- ** 송신 측 버퍼 **: OS가 여러
write()호출을 하나의 TCP 세그먼트로 합침
프레이밍 처리 없이 이 데이터를 읽으면, 수신 측은 "AAA", "BBB", "CCC"가 각각 별개의 메시지라는 걸 알 수 없습니다. 그냥 "AAABBBCCC"라는 하나의 바이트 덩어리로 보일 뿐입니다.
분할 패킷(Half Packet) — 하나의 메시지가 나뉘어 도착하는 현상
분할 패킷은 점착의 반대입니다. ** 하나의 메시지가 여러 번에 걸쳐 도착 **합니다.
송신 측: write("HELLO_WORLD")
수신 측: read() → "HELLO" (첫 번째 수신)
read() → "_WORLD" (두 번째 수신)
주요 발생 원인:
- **MTU(Maximum Transmission Unit) 초과 **: 메시지가 MTU보다 크면 IP 레이어에서 분할
- **TCP 윈도우 크기 제한 **: 수신 측 윈도우가 작으면 데이터를 나눠서 전송
- ** 네트워크 경로의 단편화 **: 라우터나 중간 장비에서 패킷 분할
분할 패킷 상황에서 첫 번째 read()만으로 비즈니스 로직을 처리하면, 불완전한 데이터로 파싱을 시도하게 되어 오류가 발생합니다.
실무에서는 점착과 분할이 동시에 일어나기도 합니다. 예를 들어 메시지 A의 뒷부분과 메시지 B의 앞부분이 하나의 패킷으로 합쳐져 도착하는 식입니다. 그래서 프레이밍 처리는 선택이 아니라 필수입니다.
해결 전략 3가지
TCP 프레이밍의 핵심은 ** 바이트 스트림에서 메시지의 시작과 끝을 식별하는 것 **입니다. 널리 쓰이는 전략은 세 가지입니다.
| 전략 | 원리 | 대표 디코더 |
|---|---|---|
| 고정 길이 | 모든 메시지가 동일한 크기 | FixedLengthFrameDecoder |
| 구분자 기반 | 특정 바이트 패턴으로 메시지 끝을 표시 | DelimiterBasedFrameDecoder |
| 길이 필드 기반 | 메시지 헤더에 페이로드 길이를 명시 | LengthFieldBasedFrameDecoder |
어떤 전략을 선택할지는 프로토콜 설계에 따라 달라집니다. 직접 프로토콜을 설계한다면, ** 길이 필드 기반 **이 가장 유연하고 실무에서 가장 많이 사용됩니다.
FixedLengthFrameDecoder — 고정 크기 메시지
가장 단순한 전략입니다. ** 모든 메시지가 정확히 같은 바이트 수 **라는 전제하에 동작합니다.
// 모든 메시지가 정확히 32바이트인 프로토콜
pipeline.addLast(new FixedLengthFrameDecoder(32));
pipeline.addLast(new StringDecoder(CharsetUtil.UTF_8));
pipeline.addLast(new MyBusinessHandler());
동작 방식:
- 바이트가 도착하면 누적 버퍼에 쌓음
- 누적 버퍼에 지정한 크기(32바이트) 이상이 쌓이면 정확히 그만큼 잘라서 다음 핸들러에 전달
- 나머지는 버퍼에 남겨 두고 다음 데이터를 기다림
수신 스트림: [AAAA AAAA ... 32바이트][BBBB BBBB ... 32바이트]
디코딩 결과: Frame1 = 32바이트, Frame2 = 32바이트
장점은 구현이 극도로 간단하다는 것이고, 단점은 ** 메시지 크기가 가변적이면 사용할 수 없다 **는 점입니다. 짧은 메시지도 패딩을 채워서 고정 크기로 맞춰야 하므로 대역폭 낭비가 생깁니다.
센서 데이터 수집처럼 모든 메시지가 동일한 구조인 경우에 유용합니다.
DelimiterBasedFrameDecoder — 구분자 기반 프레이밍
** 특정 바이트 패턴(구분자)으로 메시지의 끝을 표시 **하는 방식입니다. 텍스트 프로토콜에서 줄바꿈(\n)으로 메시지를 구분하는 것이 대표적인 예입니다.
LineBasedFrameDecoder — 줄바꿈 전용
줄바꿈만 구분자로 사용한다면 LineBasedFrameDecoder가 더 간편합니다.
// 줄바꿈(\n 또는 \r\n)으로 메시지를 구분
pipeline.addLast(new LineBasedFrameDecoder(1024)); // 최대 1024바이트
pipeline.addLast(new StringDecoder(CharsetUtil.UTF_8));
pipeline.addLast(new MyBusinessHandler());
커스텀 구분자
줄바꿈이 아닌 다른 구분자를 사용하려면 DelimiterBasedFrameDecoder를 씁니다.
// "$$" 를 구분자로 사용하는 프로토콜
ByteBuf delimiter = Unpooled.copiedBuffer("$$", CharsetUtil.UTF_8);
pipeline.addLast(new DelimiterBasedFrameDecoder(1024, delimiter));
pipeline.addLast(new StringDecoder(CharsetUtil.UTF_8));
pipeline.addLast(new MyBusinessHandler());
수신 스트림: Hello$$World$$Netty$$
디코딩 결과: "Hello", "World", "Netty"
생성자의 주요 파라미터:
maxFrameLength: 구분자 없이 이 크기를 초과하면TooLongFrameException발생stripDelimiter:true면 구분자를 제거하고 전달 (기본값true)delimiter: 구분자ByteBuf(여러 개 지정 가능)
구분자 기반의 약점은 ** 메시지 본문에 구분자와 같은 바이트가 포함될 수 있다 **는 점입니다. 바이너리 데이터를 다루는 프로토콜에서는 이 방식보다 길이 필드 기반이 안전합니다.
LengthFieldBasedFrameDecoder — 가장 실무적인 방식
실무에서 가장 많이 사용되는 프레이밍 방식입니다. ** 메시지 헤더에 페이로드의 길이를 명시 **하고, 디코더가 그 길이만큼 읽어서 하나의 프레임으로 잘라줍니다.
기본 구조
+--------+----------------+
| Length | Payload |
| (4byte) | (가변) |
+--------+----------------+
가장 단순한 사용법부터 보겠습니다.
// 길이 필드 4바이트, 오프셋 0, 보정값 0, 헤더 제거 4바이트
pipeline.addLast(new LengthFieldBasedFrameDecoder(
65535, // maxFrameLength: 최대 프레임 크기
0, // lengthFieldOffset: 길이 필드 시작 위치
4, // lengthFieldLength: 길이 필드 크기 (바이트)
0, // lengthAdjustment: 길이 보정값
4 // initialBytesToStrip: 디코딩 후 건너뛸 바이트 수
));
4가지 핵심 파라미터 상세
이 디코더의 핵심은 4개의 파라미터를 정확히 이해하는 것입니다.
1. lengthFieldOffset — 길이 필드의 시작 위치
길이 필드가 메시지의 ** 몇 번째 바이트부터 시작하는지** 지정합니다.
offset = 0인 경우:
+--------+----------------+
| Length | Payload |
+--------+----------------+
^-- 여기서부터 길이 필드
offset = 2인 경우 (앞에 매직 넘버 등 헤더가 있을 때):
+------+--------+----------------+
| Magic | Length | Payload |
| (2B) | (4B) | |
+------+--------+----------------+
^-- 여기서부터 길이 필드
2. lengthFieldLength — 길이 필드의 크기
길이 필드 자체가 ** 몇 바이트인지** 지정합니다. 보통 1, 2, 4, 8바이트 중 하나입니다.
1: 최대 255바이트 메시지2: 최대 65,535바이트 메시지4: 최대 약 2GB 메시지8: 사실상 무제한
3. lengthAdjustment — 길이 값 보정
길이 필드 값이 ** 실제 페이로드 길이와 다를 때** 보정합니다. 이 부분이 가장 헷갈리는 포인트입니다.
케이스 A: 길이 필드 = 페이로드만의 길이 → adjustment = 0
+--------+----------------+
| Len=10 | Payload(10B) |
+--------+----------------+
케이스 B: 길이 필드 = 전체 메시지 길이(헤더 포함) → adjustment = -4
+--------+----------------+
| Len=14 | Payload(10B) |
+--------+----------------+
길이 필드에 14가 적혀 있지만 페이로드는 10바이트.
14 + (-4) = 10 → 실제 읽어야 할 바이트 수
케이스 C: 길이 필드 뒤에 추가 헤더가 있는 경우 → adjustment = 추가 헤더 크기
+--------+--------+----------------+
| Len=10 | Header | Payload(10B) |
| | (2B) | |
+--------+--------+----------------+
길이 필드 값 10은 페이로드만의 길이지만, 실제로는 2바이트 헤더도 읽어야 함.
adjustment = 2 → 10 + 2 = 12바이트를 읽어서 프레임으로 만듦
4. initialBytesToStrip — 디코딩 후 제거할 바이트 수
프레임을 다음 핸들러에 전달할 때 ** 앞에서 몇 바이트를 잘라낼지** 지정합니다.
0: 길이 필드를 포함한 전체를 전달4: 4바이트 길이 필드를 제거하고 페이로드만 전달
strip = 0: [Length][Payload] → 다음 핸들러에 전체 전달
strip = 4: [Length][Payload] → 다음 핸들러에 Payload만 전달
실전 예제 — 복합 헤더 프로토콜
실무에서 자주 볼 수 있는 패턴입니다. 매직 넘버 + 버전 + 길이 필드 + 페이로드 구조를 가진 프로토콜입니다.
+--------+---------+--------+----------------+
| Magic | Version | Length | Payload |
| (2B) | (1B) | (4B) | |
+--------+---------+--------+----------------+
pipeline.addLast(new LengthFieldBasedFrameDecoder(
65535, // maxFrameLength
3, // lengthFieldOffset: Magic(2) + Version(1) = 3
4, // lengthFieldLength: 4바이트 int
0, // lengthAdjustment: Length 값 = 페이로드만의 길이
7 // initialBytesToStrip: 헤더 전체(2+1+4) 제거, 페이로드만 전달
));
공부하다 보니
lengthAdjustment가 가장 헷갈렸는데, "길이 필드 값 + lengthAdjustment = 길이 필드 뒤에서 실제로 읽어야 할 바이트 수"로 외우니 깔끔했습니다.
커스텀 프레임 디코더 — ByteToMessageDecoder 직접 구현
네티 내장 디코더로 커버가 안 되는 복잡한 프로토콜이라면, ByteToMessageDecoder를 직접 상속해서 구현합니다.
기본 패턴
public class MyProtocolDecoder extends ByteToMessageDecoder {
// 헤더 크기: 매직(2) + 타입(1) + 길이(4) = 7바이트
private static final int HEADER_SIZE = 7;
private static final short MAGIC = (short) 0xCAFE;
@Override
protected void decode(ChannelHandlerContext ctx, ByteBuf in, List<Object> out) {
// 1단계: 헤더를 읽을 수 있을 만큼 데이터가 쌓였는지 확인
if (in.readableBytes() < HEADER_SIZE) {
return; // 아직 부족하면 그냥 리턴
}
// 2단계: readerIndex를 기억해 둠 (롤백 대비)
in.markReaderIndex();
// 3단계: 매직 넘버 검증
short magic = in.readShort();
if (magic != MAGIC) {
// 잘못된 데이터 — 연결 종료
ctx.close();
return;
}
// 4단계: 타입과 길이 필드 읽기
byte type = in.readByte();
int length = in.readInt();
// 5단계: 페이로드가 아직 다 도착하지 않았으면 롤백
if (in.readableBytes() < length) {
in.resetReaderIndex(); // readerIndex를 mark 위치로 되돌림
return;
}
// 6단계: 페이로드 읽기
byte[] payload = new byte[length];
in.readBytes(payload);
// 7단계: 메시지 객체를 만들어서 out에 추가
out.add(new MyProtocolMessage(type, payload));
}
}
핵심 포인트
커스텀 디코더를 구현할 때 반드시 지켜야 할 패턴이 있습니다.
- ** 충분한 데이터 확인 **: 읽기 전에 항상
readableBytes()로 데이터가 충분한지 체크 - **readerIndex 마킹 **: 헤더를 읽은 뒤 페이로드가 부족할 수 있으니, 읽기 전에
markReaderIndex()호출 - ** 롤백 **: 데이터가 부족하면
resetReaderIndex()로 읽은 위치를 되돌려서 다음 호출 때 다시 처리 - **out에 추가 **: 완전한 메시지가 만들어지면
List<Object> out에 추가하여 다음 핸들러로 전달
[데이터 도착] → readableBytes 확인
↓ 부족하면
return (다음 도착 때 재시도)
↓ 충분하면
markReaderIndex()
↓
헤더 읽기 + 검증
↓
페이로드 길이 확인
↓ 부족하면
resetReaderIndex() → return
↓ 충분하면
페이로드 읽기 → out.add()
커스텀 디코더에서 가장 흔한 실수는
markReaderIndex()/resetReaderIndex()쌍을 빼먹는 것입니다. 이걸 빼먹으면 데이터가 부족할 때 헤더 바이트가 이미 소비된 상태로 남아서, 다음 호출 때 엉뚱한 위치부터 읽게 됩니다.
대응하는 인코더 — LengthFieldPrepender
디코딩이 있으면 인코딩도 필요합니다. LengthFieldBasedFrameDecoder와 짝을 이루는 인코더가 LengthFieldPrepender입니다.
// 디코더 + 인코더 조합
pipeline.addLast(new LengthFieldBasedFrameDecoder(65535, 0, 4, 0, 4));
pipeline.addLast(new LengthFieldPrepender(4)); // 페이로드 앞에 4바이트 길이 필드 추가
pipeline.addLast(new StringDecoder(CharsetUtil.UTF_8));
pipeline.addLast(new StringEncoder(CharsetUtil.UTF_8));
pipeline.addLast(new MyBusinessHandler());
LengthFieldPrepender는 아웃바운드 메시지 앞에 ** 길이 필드를 자동으로 추가 **해 줍니다. 비즈니스 핸들러에서는 페이로드만 write하면 됩니다.
// 비즈니스 핸들러에서는 길이 필드를 신경 쓰지 않아도 됨
ctx.writeAndFlush("Hello, Netty!");
// 실제 전송: [00 00 00 0D][Hello, Netty!] ← 길이(13) + 페이로드
정리
TCP 프레이밍 문제를 한 문장으로 요약하면, "TCP는 바이트 스트림이므로 애플리케이션 레벨에서 메시지 경계를 직접 정의해야 한다" 는 것입니다.
- **점착 패킷 **: 여러 메시지가 하나로 합쳐져 도착 — Nagle 알고리즘, OS 버퍼가 주요 원인
- ** 분할 패킷 **: 하나의 메시지가 나뉘어 도착 — MTU 초과, TCP 윈도우 크기 제한이 원인
- FixedLengthFrameDecoder: 고정 크기 메시지에 적합, 가장 단순
- DelimiterBasedFrameDecoder: 구분자로 메시지 끝을 식별, 텍스트 프로토콜에 유용
- LengthFieldBasedFrameDecoder: 헤더에 길이 정보를 포함, 가장 범용적이고 실무에서 가장 많이 사용
- ** 커스텀 디코더 **:
ByteToMessageDecoder를 상속하여markReaderIndex()/resetReaderIndex()패턴으로 구현
프레이밍은 네티뿐만 아니라 TCP 기반 통신을 하는 모든 곳에서 필요한 개념입니다. 네티가 이미 잘 만들어진 디코더들을 제공하고 있으니, 직접 구현하기 전에 내장 디코더부터 확인하는 습관이 좋습니다.