해피뉴이어! 올해 새해가 밝았고, 2026년의 첫번째 글은... Netty가 차지하게 되었다 와.. 쩐다...
지난 시간에는 Protocol Support 섹션의 HTTP & WebSocket / SSL & StartTSL / Google Protobuf에 대해 알아보았다.
이번 시간에는 Protocol Support 섹션의 나머지를 알아보려고 한다.
zbli/gzip Compression, Large File Transfer, RSTP, Legacy Text & Binary Protocols with Unit Testability가 이에 해당한다.

그럼 다시 한 번 가... 보자고!
1. zlib/gzip Compression
해당 섹션의 Package Summary를 보면 다음과 같다.
Package io.netty.handler.codec.compression (바로가기 클릭)
zlib, gzip, Snappy와 같은 압축 형식으로 ByteBuf를 압축 및 압축 해제하는 인코더와 디코더입니다.
zlib, gzip, Snappy 가 우선 무엇인지 명확하게 집고가자.
- zlib (제트립) : 가장 표준적이고 범용적인 압축 라이브러리. PNG 이미지 파일, Git 내부 데이터 저장 등 수많은 소프트웨어의 기본 압축 방식
- gzip (지집) : zlib을 기반으로 하되, 파일 전송에 필요한 정보(헤더, 체크섬 등)를 더 붙여 만든 포맷. HTTP 웹 통신에서 가장 많이 쓰임.
- Snappy (스내피) : 구글(Google)이 만든 압축 기술. 실시간 데이터 처리가 중요한 데이터베이스(Cassandra, BigTable 등)나 내부 서버 간 통신(RPC)에 주로 쓰임.
정말 대단한게 구글은 뭐 어딜가나... 항상 무엇을 개발했다고 하는데 참 흥미롭다.
Snappy에 대해 더 알고싶다면 여기 링크를 달아놓겠다. (여기 클릭)
하여간 ByteBuf를 zlib, gzip, Snappy 이 3가지 형식으로 압축 및 압축 해제(Compression & Decompression) 을 도와준다는 pakcage다 이말이다!
이게 내가 프로젝트를 한다면 어느 상황에서 쓰일까? 직접적인 예시를 Gemini에게 물어보았다.
궁금하면 다음을 열어보시오~
오늘 나누었던 대화는 **백엔드 개발자가 실무에서 압축 기술을 어떻게, 언제, 왜 써야 하는지**에 대한 핵심 내용이었습니다.
질문하신 흐름대로 **4가지 핵심 포인트**로 요약해 드립니다.
---
### 1. 기술의 종류와 선택 기준 (What)
압축은 **용도(Web vs Internal)**에 따라 선택해야 합니다.
* **gzip (표준):** 압축률이 좋고 호환성이 완벽합니다. **웹 브라우저(프론트엔드)와의 통신**이나 **비용 절감**이 목표일 때 사용합니다.
* **Snappy (속도):** 압축률은 조금 낮지만 속도가 매우 빠릅니다. **DB, 내부 서버 간 통신(MSA)**, **실시간 데이터** 처리에 사용합니다.
* **zlib:** 가장 기본이 되는 라이브러리로 범용적인 데이터 저장에 쓰입니다.
### 2. JSON vs Protobuf (Container)
"압축"과 "그릇(직렬화)"은 다른 개념이며, 같이 쓰면 더 좋습니다.
* **JSON:** 사람이 읽기 편하지만 무겁습니다. **gzip**과 함께 써서 웹 통신 비용을 줄입니다.
* **Protobuf:** 기계 친화적인 이진(Binary) 포맷이라 압축 없이도 가볍고 **파싱 속도가 빠릅니다.** 대용량 트래픽이나 **gRPC(서버 간 통신)**에 적합합니다.
### 3. 프레임워크와 아키텍처 (How)
서버(Spring Boot)는 **"받을 땐 그냥 받고, 보낼 땐 압축해서"** 보냅니다.
* **Request (받을 때):** 클라이언트는 보통 압축 없이 작은 데이터를 보냅니다. 따라서 **Spring이 압축 해제를 할 일은 거의 없습니다.**
* **Response (보낼 때):** 큰 데이터를 보낼 땐 압축이 필수입니다. 단, Spring이 직접 하기보다 앞단에 있는 **Nginx나 CDN이 대신 압축(Offloading)**하게 하여 WAS의 CPU를 아끼는 것이 국룰입니다.
### 4. 파일 저장과 성능 최적화 (Optimization)
압축해서 저장하는 것이 **오히려 전체 속도를 빠르게** 할 수 있습니다.
* **I/O 병목 해결:** 디스크가 느리면 **빠른 압축(Snappy/LZ4)**을 해서 쓰는 게 생으로 쓰는 것보다 더 빠릅니다.
* **비동기 처리:** 사용자를 기다리게 하지 말고, 원본 저장 후 **백그라운드**에서 압축하세요.
* **메모리 관리:** 대용량 파일은 절대 통째로 메모리에 올리지 말고, **Stream** 방식으로 흘려보내며 처리해야 서버가 죽지 않습니다.
---
**한 줄 요약:**
> "프론트엔드와는 **JSON+gzip** (Nginx 위임), 내부 서버끼리는 **Protobuf+Snappy**, 파일 저장은 **Stream+비동기**로 처리하는 것이 중급 개발자의 정석입니다."
이제 개념 정리가 되셨으니, **"실제로 Spring Boot나 Nginx에 이 설정을 어떻게 적용하는지"** 코드로 확인해 보시겠습니까?
나 : 아 아니 거기까지는....
그러면 압축 및 압축해제를 어떻게 하는지 예시 코드를 통해서 알아보려고 한다.
NettyCompressionTest.java
package com.example.demo;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.handler.codec.compression.SnappyFrameDecoder;
import io.netty.handler.codec.compression.SnappyFrameEncoder;
import io.netty.handler.codec.compression.ZlibCodecFactory;
import io.netty.handler.codec.compression.ZlibWrapper;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
public class NettyCompressionTest {
// 테스트할 원본 데이터 (압축 효과를 보려면 반복되는 긴 문자열이 좋습니다)
private final String ORIGINAL_TEXT = "Hello Netty! This is compression test. " +
"Repeat this text to make it compressible. " +
"Repeat this text to make it compressible. " +
"Repeat this text to make it compressible.";
@Test
public void testGzipCompression() {
// 1. Gzip 설정 (ZlibWrapper.GZIP 사용)
testCompression(
ZlibCodecFactory.newZlibEncoder(ZlibWrapper.GZIP),
ZlibCodecFactory.newZlibDecoder(ZlibWrapper.GZIP)
);
}
@Test
public void testZlibCompression() {
// 2. Zlib 설정 (ZlibWrapper.ZLIB 사용 - 기본값)
testCompression(
ZlibCodecFactory.newZlibEncoder(ZlibWrapper.ZLIB),
ZlibCodecFactory.newZlibDecoder(ZlibWrapper.ZLIB)
);
}
@Test
public void testSnappyCompression() {
// 3. Snappy 설정
testCompression(
new SnappyFrameEncoder(),
new SnappyFrameDecoder()
);
}
// 공통 테스트 로직
private void testCompression(io.netty.channel.ChannelHandler encoder, io.netty.channel.ChannelHandler decoder) {
// --- [압축 과정] ---
EmbeddedChannel compressor = new EmbeddedChannel(encoder);
ByteBuf input = Unpooled.copiedBuffer(ORIGINAL_TEXT, StandardCharsets.UTF_8);
// writeOutbound: 데이터를 채널 밖으로 내보냄 (인코딩/압축 수행)
compressor.writeOutbound(input);
compressor.finish();
// 압축된 데이터 읽기
ByteBuf compressedData = compressor.readOutbound();
Assertions.assertNotNull(compressedData, "압축된 데이터가 null이면 안됩니다.");
// (선택) 압축된 데이터 크기 확인 - 텍스트가 길면 원본보다 작아야 함
System.out.println("[" + encoder.getClass().getSimpleName() + "] " +
"Original: " + ORIGINAL_TEXT.length() + "B -> Compressed: " + compressedData.readableBytes() + "B");
// --- [압축 해제 과정] ---
EmbeddedChannel decompressor = new EmbeddedChannel(decoder);
// writeInbound: 데이터를 채널 안으로 들여옴 (디코딩/압축해제 수행)
decompressor.writeInbound(compressedData);
decompressor.finish();
// 압축 해제된 데이터 읽기
ByteBuf decompressedData = decompressor.readInbound();
Assertions.assertNotNull(decompressedData, "해제된 데이터가 null이면 안됩니다.");
// 원본과 비교
String resultText = decompressedData.toString(StandardCharsets.UTF_8);
Assertions.assertEquals(ORIGINAL_TEXT, resultText, "원본과 복구된 데이터가 일치해야 합니다.");
// 메모리 해제
decompressedData.release();
}
}
여기서 EmbeddedChannel 클래스가 있는데, 이게 Netty에서 제공하는 테스트 전용 가상 채널 이라고 한다.
(나는 왜 이걸 이제알았지...?)
Class EmbeddedChannel (바로가기 클릭)
임베디드 방식(내장형)으로 사용되는 Channel 구현체들을 위한 기본 클래스
여기서 임베디드 방식란 실제 네트워크 연결 없이 내부에서 독립적으로 실행되는 방식을 의미한다.
즉, 네트워크 연결 없이 테스트 목적으로 채널을 흉내낼 때 사용하는 클래스라는 말이다.
전체 테스트를 돌려보면 결과는 다음과 같다.
> Task :extractIncludeProto UP-TO-DATE
> Task :extractProto UP-TO-DATE
> Task :generateProto UP-TO-DATE
> Task :compileJava UP-TO-DATE
> Task :processResources UP-TO-DATE
> Task :classes UP-TO-DATE
> Task :extractIncludeTestProto UP-TO-DATE
> Task :extractTestProto UP-TO-DATE
> Task :generateTestProto NO-SOURCE
> Task :compileTestJava UP-TO-DATE
> Task :processTestResources NO-SOURCE
> Task :testClasses UP-TO-DATE
[JdkZlibEncoder] Original: 164B -> Compressed: 77B
[JdkZlibEncoder] Original: 164B -> Compressed: 85B
[SnappyFrameEncoder] Original: 164B -> Compressed: 98B
> Task :test
BUILD SUCCESSFUL in 2s
9 actionable tasks: 1 executed, 8 up-to-date
오후 1:53:41: Execution finished ':test --tests "com.example.demo.NettyCompressionTest"'.
압축 방식에 따라서 압축된 결과의 용량이 얼마인지 확인할 수 있다.
테스트코드가 정말 인상적인게, 의존성 주입으로 여러개를 큰 수정 없이 확인할 수 있는 점이 아주 귀감이 되었다 으음...!!!
2. Large File Transfer
이것은 위처럼 별도로 Large File Transfer용입니다~ 하고 패키지가 있는게 아니고,
zero-copy 가 이에 해당한다고한다. zero-copy? 어디서 많이 들어봤는데...
Netty에 대해 본격적으로 알아보기 시작할 때 이에 대해 글을 정리했다.
Transparent zero-copy 라는 표현이 여기서 나왔었다. (바로가기 클릭)
해당 글에서는 불필요한 메모리 복사를 막는것으로 설명했지만, 내가 알아보고자 하는 Large File Transfer 에서는 이와 성격이 조금 다르다.
이전 글의 Zero-Copy가 메모리 안에서 데이터를 이리저리 옮기지 않는 것이었다면,
Large File Transfer의 Zero-Copy는 아예 CPU를 쓰지 않고 하드웨어(DMA)가 직접 데이터를 나르는 것을 의미하기 때문이다.
즉, 데이터를 JVM 위로 끌어올리는 과정조차 생략하고 운영체제의 힘을 빌려 파일(Disk)에서 네트워크(NIC)로 다이렉트로 쏘는 기술이다.
이렇게 되면 해당 기능은 고속으로 특정 파일을 전송할 때 쓰면 좋겠다.
Netty는 이 강력한 기능을 개발자가 쉽게 쓸 수 있도록 추상화해 두었는데, 그것이 바로 DefaultFileRegion이다.
이것의 출처가 어디냐고? 메소드를 타고 타고 올라가보면 이를 확인할 수 있다.
java.nio.channels.FileChannel의 transferTo()의 메소드를 호출하는데, 여기에 다음과 같은 메소드 주석이 있다.
이 메서드는 채널에서 데이터를 읽어서 대상(Target) 채널로 쓰는 단순한 반복문(Loop) 방식보다 잠재적으로 훨씬 더 효율적입니다.
많은 운영체제는 바이트(데이터)를 실제로 복사하지 않고, 파일 시스템 캐시에서 대상 채널로 직접 전송할 수 있습니다.
이 클래스를 사용하면 내부적으로 자바 NIO의 transferTo()를 호출하여 OS의 Zero-Copy 전송을 수행하게 된다.
Interface FileRegion (바로가기)
제로 카피(Zero-copy) 파일 전송을 지원하는 Channel 을 통해 전송되는 파일의 특정 영역
해당 interface에서 눈여겨 봐야 할 것은 transferTo() 메소드로 보인다.
FileRegion.transferTo (바로가기)
이 파일 영역의 내용을 지정된 채널로 전송
Class DefaultFileRegion (바로가기)
FileChannel 또는 File로부터 데이터를 전송하는 FileRegion의 기본 구현체.
AbstractReferenceCounted.refCnt()(참조 횟수)가 0을 반환하면 FileChannel이 자동으로 닫힌다는 점에 유의
머.. 그렇다는데 실제로 어떻게 사용하는지 Gemini에게 코드를 작성해달라고 했다.
import io.netty.bootstrap.Bootstrap;
import io.netty.bootstrap.ServerBootstrap;
import io.netty.buffer.ByteBuf;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import java.io.File;
import java.io.FileOutputStream;
import java.io.RandomAccessFile;
import java.nio.channels.FileChannel;
import java.util.Random;
public class ZeroCopyTest {
private static final int PORT = 8888;
// 테스트용 파일 크기 (100MB)
private static final int FILE_SIZE = 100 * 1024 * 1024;
public static void main(String[] args) throws Exception {
// 1. 테스트용 더미 파일 생성 (없으면 테스트 못하니까)
File tempFile = createDummyFile();
System.out.println("[준비] 100MB 테스트 파일 생성 완료: " + tempFile.getAbsolutePath());
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
EventLoopGroup clientGroup = new NioEventLoopGroup();
try {
// 2. 서버 설정 (Zero-Copy 전송 담당)
ServerBootstrap sb = new ServerBootstrap();
sb.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
// ★ 중요: 파이프라인에 SSL이나 압축(Gzip)이 없어야 Zero-Copy가 작동함
ch.pipeline().addLast(new FileSenderHandler(tempFile));
}
});
ChannelFuture serverFuture = sb.bind(PORT).sync();
System.out.println("[서버] 시작됨 (Port: " + PORT + ")");
// 3. 클라이언트 설정 (데이터 수신 담당)
Bootstrap cb = new Bootstrap();
cb.group(clientGroup)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ch.pipeline().addLast(new FileReceiverHandler(FILE_SIZE));
}
});
// 4. 연결 및 전송 시작
ChannelFuture clientFuture = cb.connect("localhost", PORT).sync();
// 클라이언트가 다 받을 때까지 대기
clientFuture.channel().closeFuture().sync();
serverFuture.channel().close().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
clientGroup.shutdownGracefully();
// 5. 뒷정리: 임시 파일 삭제
if (tempFile.delete()) {
System.out.println("[종료] 테스트 파일 삭제 완료");
}
}
}
// --- [서버 핸들러] 파일 전송 (Zero-Copy 핵심) ---
static class FileSenderHandler extends ChannelInboundHandlerAdapter {
private final File file;
public FileSenderHandler(File file) {
this.file = file;
}
@Override
public void channelActive(ChannelHandlerContext ctx) throws Exception {
System.out.println("[서버] 클라이언트 접속됨. Zero-Copy 전송 시작...");
RandomAccessFile raf = new RandomAccessFile(file, "r");
long fileLength = raf.length();
FileChannel fileChannel = raf.getChannel();
// ★★★ 여기가 질문하신 핵심 포인트 ★★★
// DefaultFileRegion을 쓰면 JVM 메모리 복사 없이 OS 커널이 직접 전송함
FileRegion region = new DefaultFileRegion(fileChannel, 0, fileLength);
// 전송 (다 보내면 채널 닫기 리스너 추가)
ctx.writeAndFlush(region).addListener(ChannelFutureListener.CLOSE);
}
}
// --- [클라이언트 핸들러] 데이터 수신 확인 ---
static class FileReceiverHandler extends SimpleChannelInboundHandler<ByteBuf> {
private final long expectedSize;
private long totalRead = 0;
private long startTime;
public FileReceiverHandler(long expectedSize) {
this.expectedSize = expectedSize;
}
@Override
public void channelActive(ChannelHandlerContext ctx) {
startTime = System.currentTimeMillis();
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, ByteBuf msg) {
// 데이터가 들어오면 바이트 수만 셉니다.
totalRead += msg.readableBytes();
// 진행률 표시 (너무 자주 찍히면 느리니까 10MB마다 찍기)
if (totalRead % (10 * 1024 * 1024) == 0) {
System.out.printf("[클라이언트] 수신 중... %.1f MB / %.1f MB\n",
totalRead / 1024.0 / 1024.0, expectedSize / 1024.0 / 1024.0);
}
}
@Override
public void channelInactive(ChannelHandlerContext ctx) {
long duration = System.currentTimeMillis() - startTime;
System.out.println("[클라이언트] 전송 완료!");
System.out.println("총 수신 크기: " + totalRead + " bytes");
System.out.println("걸린 시간: " + duration + "ms");
if (totalRead == expectedSize) {
System.out.println("결과: 성공 (데이터 손실 없음)");
} else {
System.err.println("결과: 실패 (크기 불일치)");
}
}
}
// --- [유틸] 100MB 더미 파일 생성기 ---
private static File createDummyFile() throws Exception {
File file = File.createTempFile("netty-zerocopy-test", ".dat");
file.deleteOnExit(); // 혹시 비정상 종료돼도 나중에 지워지게 설정
try (FileOutputStream fos = new FileOutputStream(file)) {
byte[] buffer = new byte[1024 * 1024]; // 1MB 버퍼
new Random().nextBytes(buffer); // 랜덤 데이터 채우기
for (int i = 0; i < 100; i++) { // 1MB * 100 = 100MB
fos.write(buffer);
}
}
return file;
}
}
프로세스 정리
앞서 드린 **Netty Zero-Copy 테스트 코드(ZeroCopyTest.java)**의 전체적인 실행 흐름을 단계별로 정리해 드립니다.
블로그에 "테스트 시나리오"나 "검증 과정"으로 넣기 좋은 구조입니다.
🧪 Zero-Copy 테스트 시나리오 흐름도
이 테스트는 **"서버가 100MB 파일을 Zero-Copy로 쏘고, 클라이언트가 온전히 다 받는지"**를 검증합니다.
1. 준비 단계 (Setup)
- 더미 파일 생성: 테스트를 위해 100MB 크기의 임시 파일(netty-zerocopy-test...dat)을 생성합니다. 안에 내용은 랜덤한 데이터로 채웁니다.
2. 서버 구동 (Sender)
- 서버 시작: 8888번 포트를 열고 클라이언트의 접속을 기다립니다.
- 파이프라인 구성: 가장 중요한 단계입니다. SSL이나 압축 핸들러 없이, 오직 파일 전송을 위한 핸들러(FileSenderHandler)만 장착합니다.
3. 클라이언트 접속 및 요청 (Trigger)
- 접속: 클라이언트가 로컬호스트(localhost:8888) 서버에 접속합니다.
- 이벤트 감지: 서버의 FileSenderHandler가 클라이언트 접속(channelActive)을 감지합니다.
4. ★ Zero-Copy 전송 수행 (The Core) ★
이 단계가 테스트의 핵심입니다.
- DefaultFileRegion 생성: 서버는 준비된 파일을 DefaultFileRegion 객체로 감쌉니다.
- 전송 (ctx.writeAndFlush):
- Netty는 이 객체를 보고 JVM 메모리에 데이터를 올리는 대신, OS의 transferTo (sendfile) 기능을 호출합니다.
- 데이터는 [디스크] → [OS 커널] → [네트워크 카드] 경로로 이동하며, 자바 힙 메모리를 전혀 쓰지 않고 전송됩니다.
5. 데이터 수신 및 검증 (Receiver)
- 수신: 클라이언트는 들어오는 바이트 데이터를 계속 읽어들입니다 (FileReceiverHandler).
- 카운팅: 데이터 내용을 저장하지 않고(메모리 절약), **받은 바이트 수(Total Bytes)**만 누적해서 셉니다.
- 진행률 로그: 10MB가 들어올 때마다 로그를 찍어 전송이 잘 되고 있는지 보여줍니다.
6. 종료 및 결과 확인 (Cleanup)
- 전송 완료: 서버가 파일을 다 보내면 연결을 끊습니다.
- 검증: 클라이언트는 연결이 끊기면 **"총 받은 크기"**와 **"원래 파일 크기(100MB)"**가 일치하는지 확인합니다.
- 삭제: 테스트가 끝나면 생성했던 임시 파일을 삭제하고 프로그램을 종료합니다.
실행 결과는 다음과 같다.
오후 3:13:35: Executing ':ZeroCopyTest.main()'...
> Task :extractIncludeProto UP-TO-DATE
> Task :extractProto UP-TO-DATE
> Task :generateProto UP-TO-DATE
> Task :compileJava
> Task :processResources UP-TO-DATE
> Task :classes
> Task :ZeroCopyTest.main()
[준비] 100MB 테스트 파일 생성 완료: C:\Users\MSG\AppData\Local\Temp\netty-zerocopy-test13081625136841588263.dat
[서버] 시작됨 (Port: 8888)
[서버] 클라이언트 접속됨. Zero-Copy 전송 시작...
[클라이언트] 수신 중... 100.0 MB / 100.0 MB
[클라이언트] 전송 완료!
총 수신 크기: 104857600 bytes
걸린 시간: 118ms
결과: 성공 (데이터 손실 없음)
[종료] 테스트 파일 삭제 완료
[Incubating] Problems report is available at: file:///C:/work/jdktest/demo/build/reports/problems/problems-report.html
Deprecated Gradle features were used in this build, making it incompatible with Gradle 9.0.
You can use '--warning-mode all' to show the individual deprecation warnings and determine if they come from your own scripts or plugins.
For more on this, please refer to https://docs.gradle.org/8.14.3/userguide/command_line_interface.html#sec:command_line_warnings in the Gradle documentation.
BUILD SUCCESSFUL in 5s
6 actionable tasks: 2 executed, 4 up-to-date
오후 3:13:40: Execution finished ':ZeroCopyTest.main()'.
3. RSTP
Package io.netty.handler.codec.rtsp
HTTP 코덱을 기반으로 하는 RTSP 확장(구현체)
여기서 RSTP는 Real Time Streaming Protocol의 약자이다. 실시간 스트리밍 규약으로 해석할 수 있다.
MDN에서는 이를 이렇게 정의하고 있다.
실시간 스트리밍 프로토콜 (Real-time streaming protocol, RTSP)은 서버와 클라이언트 사이에서 미디어 스트리밍이 발생하는 방식을 제어하는 네트워크 프로토콜입니다.
기본적으로, RTSP는 비디오를 스트리밍할 때 '일시 중지'/'재생'을 클릭하면 어떤 일이 발생하는지 설명하는 프로토콜입니다.
컴퓨터가 리모콘이고 스트리밍 서버가 텔레비전이라고 비유하면, RTSP는 리모콘 명령이 TV에 어떤 영향을 미치는지 설명합니다.
출처 : https://developer.mozilla.org/ko/docs/Glossary/RTSP
뭐... 그렇다는데 바로 테스트 코드로 확인해보자.
해당 코드의 작성은 Gemini에게 물어보았다. 물론! 코드를 확인하기 전에 보면 좋을 코드를 알아보고 가겠다.
Class RtspMethods
The request getMethod of RTSP.
- OPTIONS: 서버가 살아있는지 확인하고, 어떤 기능(PLAY, SETUP 등)을 지원하는지 문의하는 핸드쉐이크 단계.
- DESCRIBE: 미디어의 상세 스펙(해상도, 코덱 등)이 담긴 SDP 파일을 요청해, 클라이언트가 재생 가능한 파일인지 확인하는 단계.
- SETUP: 실제 영상 데이터(RTP)를 주고받을 전송 방식(UDP/TCP)과 포트 번호를 서버와 합의하는 가장 중요한 연결(배관 공사) 단계.
- PLAY: SETUP 단계에서 합의된 통로로 "지금부터 영상 데이터 전송을 시작하라"고 명령하는(수도꼭지를 트는) 단계.
- PAUSE: 데이터 전송을 일시 중지하고, 서버가 현재 재생 시점(Timecode)을 기억하도록(Stateful) 요청하는 단계.
- TEARDOWN: 스트리밍 세션을 완전히 종료하고, 서버와 클라이언트가 점유했던 네트워크 리소스를 해제하는 단계.
뭐... 그렇다고 한다.
RstpCodecTest.java
package com.example.demo;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.embedded.EmbeddedChannel;
import io.netty.handler.codec.http.DefaultFullHttpResponse;
import io.netty.handler.codec.http.HttpRequest;
import io.netty.handler.codec.http.HttpResponse;
import io.netty.handler.codec.http.HttpResponseStatus;
import io.netty.handler.codec.rtsp.RtspDecoder;
import io.netty.handler.codec.rtsp.RtspEncoder;
import io.netty.handler.codec.rtsp.RtspHeaderNames;
import io.netty.handler.codec.rtsp.RtspMethods;
import io.netty.handler.codec.rtsp.RtspVersions;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import java.nio.charset.StandardCharsets;
public class RtspCodecTest {
/**
* 상황: 클라이언트가 "SETUP" 명령을 보냈을 때
* 목표: Netty가 이를 파싱해서 RTSP 요청 객체로 잘 변환하는지 확인
*/
@Test
@DisplayName("클라이언트가 \"SETUP\" 명령을 보냈을 때 => Netty가 이를 파싱해서 RTSP 요청 객체로 잘 변환하는지 확인")
public void testRtspRequestDecoding() {
// 1. RTSP 디코더 준비 (들어오는 데이터를 해석)
EmbeddedChannel channel = new EmbeddedChannel(new RtspDecoder());
// 2. 가상의 클라이언트가 보낸 RTSP 원문 메시지
// (RTSP는 HTTP처럼 줄바꿈(\r\n)으로 구분됩니다)
String rtspCommand =
"SETUP rtsp://example.com/media.mp4 RTSP/1.0\r\n" +
"CSeq: 1\r\n" +
"Transport: RTP/AVP;unicast;client_port=8000-8001\r\n" +
"\r\n"; // 헤더 끝
// 3. 데이터 입력 (Inbound)
ByteBuf inputBytes = Unpooled.copiedBuffer(rtspCommand, StandardCharsets.UTF_8);
channel.writeInbound(inputBytes);
// 4. 결과 확인 (Netty가 만든 객체 꺼내기)
// RTSP는 HTTP 구조를 확장했으므로 HttpRequest 타입으로 나옵니다.
HttpRequest request = channel.readInbound();
Assertions.assertNotNull(request);
Assertions.assertEquals(RtspVersions.RTSP_1_0, request.protocolVersion());
Assertions.assertEquals(RtspMethods.SETUP, request.method());
Assertions.assertEquals("rtsp://example.com/media.mp4", request.uri());
Assertions.assertEquals("1", request.headers().get(RtspHeaderNames.CSEQ));
System.out.println("디코딩 성공! 메서드: " + request.method());
}
/**
* 상황: 서버가 "200 OK" 응답을 보낼 때
* 목표: Netty가 이를 RTSP 규격의 문자열(ByteBuf)로 잘 변환하는지 확인
*/
@Test
@DisplayName("서버가 \"200 OK\" 응답을 보낼 때 => Netty가 이를 RTSP 규격의 문자열(ByteBuf)로 잘 변환하는지 확인")
public void testRtspResponseEncoding() {
// 1. RTSP 인코더 준비 (나가는 데이터를 변환)
EmbeddedChannel channel = new EmbeddedChannel(new RtspEncoder());
// 2. 서버가 보낼 응답 객체 생성
// (HTTP 응답 객체를 그대로 사용하되, 버전을 RTSP/1.0으로 지정)
HttpResponse response = new DefaultFullHttpResponse(
RtspVersions.RTSP_1_0,
HttpResponseStatus.OK
);
response.headers().set(RtspHeaderNames.CSEQ, "1");
response.headers().set(RtspHeaderNames.SESSION, "12345678");
// 3. 데이터 출력 (Outbound)
channel.writeOutbound(response);
// 4. 결과 확인 (변환된 바이트 꺼내기)
ByteBuf outputBytes = channel.readOutbound();
String resultString = outputBytes.toString(StandardCharsets.UTF_8);
System.out.println("--- 인코딩 결과 ---");
System.out.println(resultString);
System.out.println("-------------------");
// 검증 1: 시작줄 확인
Assertions.assertTrue(resultString.startsWith("RTSP/1.0 200 OK"));
// 검증 2: 헤더 확인 (소문자로 변환해서 검사하는 것이 안전합니다)
String lowerResult = resultString.toLowerCase();
Assertions.assertTrue(lowerResult.contains("cseq: 1"), "CSeq 헤더가 있어야 합니다.");
Assertions.assertTrue(lowerResult.contains("session: 12345678"), "Session 헤더가 있어야 합니다.");
}
}


필자는 실시간 스트리밍 프로토콜을 지원한다니 그럼 이것으로 유튜브같은 서비스를 만들 수 있는건지 궁금했다.
그래서 그게 가능한지 물어보니 Gemini가 다음과 같은 답변을 내놓았다.
궁금하면 다음을 열어보고 보길 바란다. 결론적으로는 다른 방식을 써야한다고 한다.
결론부터 냉정하게 말씀드리면 **"만들 수는 있지만, 유튜브(YouTube)는 그 방식(RTSP)으로 돌아가지 않습니다."**
Netty의 **RTSP 기능**만으로는 우리가 아는 **현대적인 유튜브 같은 서비스(대규모 VOD/라이브)**를 만들기 어렵습니다.
이유는 **"시대가 변했기 때문"**입니다. RTSP가 어디에 쓰이고, 진짜 유튜브는 무엇을 쓰는지 명확하게 구분해 드릴게요.
---
### 1. RTSP로 유튜브를 만들면 망하는 이유
방금 배우신 `SETUP`, `PLAY` 방식(RTSP)은 **1990년대~2000년대 초반**의 기술입니다. 만약 지금 이걸로 유튜브를 만들면 다음과 같은 치명적인 문제가 터집니다.
1. **웹 브라우저에서 안 나옴:**
* 크롬, 사파리 등 현대 브라우저는 **RTSP를 지원하지 않습니다.** (별도 프로그램을 깔아야 함)
2. **서버 폭발 (Stateful):**
* RTSP는 `PAUSE` 상태를 서버가 기억해야 한다고 했죠?
* 사용자가 100만 명이면, 서버는 **100만 명의 "일시정지 위치"를 메모리에 다 들고 있어야 합니다.**
3. **캐싱 불가 (CDN 사용 못 함):**
* 유튜브의 핵심은 **CDN(콘텐츠 전송 네트워크)**입니다.
* 하지만 RTSP는 실시간 제어 통신이라, 미리 저장해두는 캐싱이 거의 불가능합니다.
### 2. 그럼 유튜브는 뭘 쓰나? (HLS / DASH)
유튜브나 넷플릭스는 **"그냥 HTTP"**를 씁니다.
* **기술명:** **HLS** (Apple) 또는 **MPEG-DASH**
* **원리:**
1. 긴 영상을 **10초짜리 작은 파일(Chunk)** 수천 개로 잘게 쪼갭니다 (`.ts` 파일).
2. 그리고 그냥 **HTTP GET**으로 이 작은 파일들을 하나씩 다운받아서 이어 붙여 보여줍니다.
* **장점:**
* **Stateless:** 서버는 "파일 줘" 하면 주고 잊어버리면 됩니다. (100만 명 접속해도 부담 없음)
* **CDN:** 그냥 파일 다운로드니까 전 세계 어디든 캐싱하기 쉽습니다.
* **화질 선택:** 인터넷이 느리면 "저화질 파일", 빠르면 "고화질 파일"을 골라 받습니다.
---
### 3. 그럼 Netty의 RTSP는 어디에 쓰나? (CCTV, Zoom)
Netty의 RTSP 기능이 쓸모없는 게 아닙니다. **"목적"**이 다릅니다. RTSP는 **"초저지연(Low Latency)"**이 필요한 곳에서 씁니다.
* **CCTV / 보안 카메라:** 0.1초의 지연도 허용하지 않는 실시간 감시.
* **화상 회의 (구형):** 줌이나 구글 미트 같은 서비스의 레거시 시스템.
* **드론 제어:** 실시간으로 영상을 보며 조종해야 할 때.
👉 **결론:** "내가 **CCTV 관제 서버**를 만들겠다" 하면 **Netty RTSP**가 정답입니다.
---
### 4. Netty로 '진짜 유튜브'를 만들려면?
유튜브 같은 서비스를 만들고 싶다면, 아까 배운 **RTSP(Setup, Play)**가 아니라, 그전에 배운 **Zero-Copy (`DefaultFileRegion`)**를 써야 합니다.
**[Netty 유튜브 서버 아키텍처]**
1. **프로토콜:** RTSP 코덱 버리고, **HTTP 코덱** 사용.
2. **방식:** 클라이언트가 "영상 10초~20초 구간 파일 줘(`GET segment_2.ts`)"라고 요청.
3. **처리:** Netty 서버가 디스크에 있는 조각 파일을 **`DefaultFileRegion` (Zero-Copy)**으로 쏴줌.
4. **결과:** CPU 사용률 0%에 가깝게 수만 명에게 고화질 영상 전송 가능.
---
### ✅ 요약
* **Netty RTSP:** CCTV, 드론, 사내 방송용 (브라우저 재생 안 됨).
* **Netty HTTP + Zero-Copy:** **이것이 바로 유튜브 방식.** (브라우저 재생 잘 됨, 대규모 접속 가능).
따라서 사용자님이 **"유튜브 같은 서비스"**를 만들고 싶다면, 방금 본 RTSP 코드는 **"아, 이런 게 있구나"** 하고 넘어가시고, 앞서 배운 **`DefaultFileRegion`과 HTTP 서버 구현**에 집중하시는 게 맞습니다.
4. Legacy Text & Binary Protocols with Unit Testability
이 문구는 도대체 무슨 의미일까?
직역하자면 "유닛 테스트가 가능한 바이너리 프로토콜" 정도가 되겠다.
지금까지의 글을 쭉 읽어왔다면, 우리는 이미 정답을 알고 있다.
바로 Netty의 강력한 테스트 도구인 EmbeddedChannel 덕분이다.
TCP 통신에서 바이너리 데이터를 다루다 보면 패킷이 잘리거나 뭉치는 등 복잡한 상황이 발생한다.
Netty를 사용하면 이런 복잡한 바이너리 처리 로직(Decoder)을 쉽게 구현할 수 있을 뿐만 아니라, 실제 네트워크 연결 없이도 EmbeddedChannel을 통해 내 로직이 정상 동작하는지 완벽하게 단위 테스트(Unit Test)를 할 수 있다는 뜻이다.
이 부분은 앞선 예제들에서 EmbeddedChannel을 통해 충분히 검증해 보았으니, "아, 그 말이구나!" 하고 가볍게 이해하고 넘어가도 좋다.
생각보다 오랜 시간이 걸렸다. 하나하나 직접 찾아보고 정리하는것도 그렇지만
무엇보다도 이것만 하는것이 아니라 다른 학습거리도 넘치고, 연말 연초에 몸도 아파서 개고생을 했으니 말이다.
이전에도 말했지만, 정말 요즘은 공부하기 쉬운 세상이다. 꼬리에 꼬리를 물면 AI가 다 찾아주고 설명해준다.
내가 직접적으로 검색하고 정리하는 과정이 삭제된 것이다.
덕분에 빠르게 Netty의 구조에 대해서 어느정도 파악을 했다고 할 수 있다.
좀 더 흥미를 위해서는 이것을 통해서 실제로 무언가를 간단히 만드는것도 좋을 것이다.
아 근데 귀찮은데... 누가 등떠밀고 시켜줬으면 좋겠다 싶은 생각도 든다. 하지만 어쩌랴! 안하면 실력이 안느는것을...
나름 잘 정리했다고 생각했는데 다시 돌아보면 그 당시에는 다 이해되던 부분이 잊혀지고
여기저기서 이해가안되는 부분이 생겨난다.
정리를 하자면 여태 적은 글을 다시 점검하고, 다시 보기에 이해하기 좋고 정리가 잘 됬는지 점검하고
이것을 남은 Sample 들을 다시 보고 정리한 다음 실제로 성능 테스트를 할 계획이다.
결론적으로는 Netty에 대해서 알아보았지만, 모든 class들을 다 들어다 볼 수는 없었다.
그때 그때 큰 흐름을 파악하고, 원하는 기능이 있다면 적절한 기능을 찾아서 이를 잘 활용해야 한다는 점이다.
이러한 적응 능력을 기르기 위해서 이 이후에는 "Netty는 정말 고성능인가?"
라는 시리즈로 Spring Framework에서 제공하는 기능들과 비교를 해가며
실제 미니프로젝트를 만들어보던지 할 계획이고, 그 과정에서 나오는 부산물들을 정리할 예정이다.
또 글을 작성하면서 재미있는 점은 지나치게 AI에게 의존하는게 아닌가 하는 생각도 들었다.
직접 찾는 수고로움을 없애준 것은 너무 고마우나, 실제 코드도 이렇게 날로먹게 되는 세상이 되니... 디테일한 문제들에 대해
고민하지 않으면 정말 나중에는 프로젝트 완성! 했는데 버그를 못잡을거 같다는 생각이 든다.
출처:
https://netty.io/4.1/api/io/netty/handler/codec/compression/package-summary.html
https://github.com/google/snappy
https://www.pynetlabs.com/rstp-rapid-spanning-tree-protocol/
https://netty.io/4.1/api/index.html