HTTP·1.1 서버 & 클라이언트
네티는 TCP 위에서 바이트를 직접 다루는 프레임워크인데, HTTP 같은 고수준 프로토콜도 지원할까? 지원한다면 Spring MVC처럼 편하게 쓸 수 있는 걸까, 아니면 뭔가 다른 레벨의 작업이 필요한 걸까?
네티의 HTTP 지원 — HttpServerCodec과 HttpClientCodec
네티는 HTTP/1.1을 위한 전용 코덱을 내장 하고 있습니다. 이전 글에서 살펴본 코덱 패턴과 동일한 구조로, 파이프라인에 핸들러를 추가하기만 하면 HTTP 프로토콜 파싱을 네티가 알아서 처리합니다.
핵심 코덱은 두 가지입니다.
| 코덱 | 방향 | 역할 |
|---|---|---|
HttpServerCodec | 서버 | 인바운드: HTTP 요청 디코딩 / 아웃바운드: HTTP 응답 인코딩 |
HttpClientCodec | 클라이언트 | 아웃바운드: HTTP 요청 인코딩 / 인바운드: HTTP 응답 디코딩 |
HttpServerCodec은 내부적으로 HttpRequestDecoder와 HttpResponseEncoder를 합쳐 놓은 CombinedChannelDuplexHandler입니다. 이전 글에서 배운 패턴 그대로입니다.
// 서버 파이프라인 — HTTP 코덱 추가
pipeline.addLast("httpCodec", new HttpServerCodec());
// 클라이언트 파이프라인 — HTTP 코덱 추가
pipeline.addLast("httpCodec", new HttpClientCodec());
한 줄이면 HTTP 프로토콜 파싱이 끝납니다. 직접 바이트를 읽어서 GET /path HTTP/1.1\r\n을 파싱할 필요가 전혀 없습니다.
HTTP 요청/응답 객체 — 메시지의 구조
HTTP 코덱이 바이트를 디코딩하면 Java 객체로 변환해서 파이프라인에 전달합니다. 그런데 HTTP 메시지는 하나의 객체가 아니라 여러 조각 으로 나뉘어서 들어옵니다.
인바운드에서 받는 객체들
[HttpRequest] ← 시작 라인 + 헤더 (바디 없음)
↓
[HttpContent] ← 바디 청크 (0개 이상)
↓
[LastHttpContent] ← 마지막 바디 청크 + trailing 헤더
HttpRequest: HTTP 메서드(GET, POST), URI, 헤더 정보를 담고 있습니다. 바디는 포함하지 않습니다.HttpContent: 바디 데이터의 한 조각입니다.ByteBuf를 감싸고 있습니다.LastHttpContent: 마지막 바디 조각입니다. 이 객체가 도착해야 메시지가 완전히 수신된 것입니다.
왜 하나의 객체로 안 주는 걸까요? HTTP/1.1에는 ** 청크 전송 인코딩(Chunked Transfer Encoding)**이 있기 때문입니다. 대용량 파일 업로드처럼 바디가 클 때, 전체를 메모리에 올리지 않고 조각 단위로 처리할 수 있도록 이렇게 분리한 것입니다.
대부분의 경우 이 조각들을 직접 다루기보다는,
HttpObjectAggregator를 써서 하나로 합치는 게 편합니다. 다만 파일 업로드처럼 스트리밍이 필요한 경우에는 조각 단위로 처리하는 게 메모리 효율이 좋습니다.
FullHttpRequest — 완전한 메시지
FullHttpRequest는 HttpRequest + HttpContent + LastHttpContent를 ** 모두 합친 완전한 HTTP 요청 객체 **입니다.
// FullHttpRequest에서 필요한 정보 꺼내기
HttpMethod method = request.method(); // GET, POST, ...
String uri = request.uri(); // /api/users?id=1
HttpHeaders headers = request.headers(); // 헤더 맵
ByteBuf content = request.content(); // 바디 전체
응답 쪽도 마찬가지로 FullHttpResponse가 있습니다.
HttpObjectAggregator — 청크를 하나로 합치기
HttpObjectAggregator는 앞에서 설명한 조각들(HttpRequest, HttpContent, LastHttpContent)을 모아서 ** 하나의 FullHttpRequest로 합쳐 주는 핸들러 **입니다.
pipeline.addLast("httpCodec", new HttpServerCodec());
// 최대 64KB까지의 HTTP 메시지를 하나로 합침
pipeline.addLast("aggregator", new HttpObjectAggregator(65536));
pipeline.addLast("handler", new MyHttpHandler());
생성자의 maxContentLength 파라미터는 합칠 수 있는 ** 최대 바디 크기 **입니다.
65536→ 최대 64KB1024 * 1024→ 최대 1MB10 * 1024 * 1024→ 최대 10MB
이 크기를 초과하는 요청이 들어오면 TooLongHttpContentException이 발생하고, 클라이언트에 413 Request Entity Too Large 응답이 자동으로 전송됩니다.
maxContentLength를 너무 크게 잡으면 악의적인 클라이언트가 거대한 요청을 보내서 서버 메모리를 고갈시킬 수 있습니다. 용도에 맞게 적절한 크기를 설정하는 게 중요합니다.
간단한 HTTP 서버 구현
이제 실제로 동작하는 HTTP 서버를 만들어 봅니다.
서버 부트스트랩
import io.netty.bootstrap.ServerBootstrap;
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.handler.codec.http.HttpObjectAggregator;
import io.netty.handler.codec.http.HttpServerCodec;
public class SimpleHttpServer {
private static final int PORT = 8080;
public static void main(String[] args) throws InterruptedException {
EventLoopGroup bossGroup = new NioEventLoopGroup(1);
EventLoopGroup workerGroup = new NioEventLoopGroup();
try {
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.group(bossGroup, workerGroup)
.channel(NioServerSocketChannel.class)
.childHandler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
// HTTP 코덱 — 바이트 ↔ HTTP 객체 변환
pipeline.addLast("httpCodec", new HttpServerCodec());
// 청크 합치기 — 최대 1MB
pipeline.addLast("aggregator", new HttpObjectAggregator(1024 * 1024));
// 비즈니스 로직
pipeline.addLast("handler", new SimpleHttpHandler());
}
});
ChannelFuture future = bootstrap.bind(PORT).sync();
System.out.println("HTTP 서버 시작 — http://localhost:" + PORT);
future.channel().closeFuture().sync();
} finally {
bossGroup.shutdownGracefully();
workerGroup.shutdownGracefully();
}
}
}
HTTP 핸들러 — 라우팅과 응답 생성
import io.netty.buffer.Unpooled;
import io.netty.channel.ChannelFutureListener;
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.*;
import io.netty.util.CharsetUtil;
// HTTP 요청을 처리하는 핸들러
public class SimpleHttpHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) {
// URI 기반 간단한 라우팅
String uri = request.uri();
String responseBody;
HttpResponseStatus status;
if ("/".equals(uri)) {
status = HttpResponseStatus.OK;
responseBody = "<h1>네티 HTTP 서버</h1><p>정상 동작 중입니다.</p>";
} else if ("/api/hello".equals(uri)) {
status = HttpResponseStatus.OK;
responseBody = "{\"message\": \"안녕하세요!\"}";
} else {
status = HttpResponseStatus.NOT_FOUND;
responseBody = "<h1>404 Not Found</h1>";
}
// 응답 생성
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
status,
Unpooled.copiedBuffer(responseBody, CharsetUtil.UTF_8)
);
// Content-Type 설정
if (uri.startsWith("/api/")) {
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=UTF-8");
} else {
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/html; charset=UTF-8");
}
// Content-Length 설정 — 반드시 바이트 수를 기준으로
response.headers().setInt(
HttpHeaderNames.CONTENT_LENGTH,
response.content().readableBytes()
);
// 응답 전송
ctx.writeAndFlush(response);
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
응답을 만들 때 주의할 점이 몇 가지 있습니다.
Content-Type: 클라이언트가 응답을 올바르게 해석하려면 반드시 설정해야 합니다Content-Length:readableBytes()로 ** 바이트 수 **를 설정합니다. 한글 문자열의length()와 바이트 수는 다르기 때문에 반드시 ByteBuf 기준으로 계산해야 합니다DefaultFullHttpResponse: 프로토콜 버전, 상태 코드, 바디를 한 번에 담는 응답 객체입니다
Keep-Alive 처리 — 연결 재사용
HTTP/1.1에서는 Keep-Alive가 기본 입니다. 하나의 TCP 연결로 여러 요청/응답을 주고받을 수 있다는 뜻입니다. HTTP/1.0에서는 요청마다 새 TCP 연결을 맺었는데, 이건 3-way handshake 비용이 반복되므로 비효율적이었습니다.
네티에서 Keep-Alive를 처리하려면 응답 후 채널을 닫을지 말지를 판단 해야 합니다.
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) {
// Keep-Alive 여부 확인
boolean keepAlive = HttpUtil.isKeepAlive(request);
FullHttpResponse response = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1,
HttpResponseStatus.OK,
Unpooled.copiedBuffer("Hello, Netty!", CharsetUtil.UTF_8)
);
response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8");
response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, response.content().readableBytes());
if (keepAlive) {
// HTTP/1.0 클라이언트에는 명시적으로 Keep-Alive 헤더 추가
if (!request.protocolVersion().isKeepAliveDefault()) {
response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE);
}
// 채널을 닫지 않고 응답만 전송
ctx.writeAndFlush(response);
} else {
// Connection: close → 응답 전송 후 채널 닫기
ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE);
}
}
HttpUtil.isKeepAlive(request)가 내부적으로 확인하는 것은:
- HTTP/1.1: 기본이 Keep-Alive이므로,
Connection: close헤더가 ** 있을 때만** false - HTTP/1.0: 기본이 close이므로,
Connection: keep-alive헤더가 ** 있을 때만** true
ChannelFutureListener.CLOSE는 응답 전송이 완료된 후에 채널을 닫는 리스너입니다. writeAndFlush()는 비동기이기 때문에, 바로 ctx.close()를 호출하면 응답이 다 전송되기 전에 연결이 끊길 수 있습니다.
Keep-Alive를 제대로 처리하지 않으면, 브라우저는 연결이 유지될 거라고 기대하는데 서버가 먼저 닫아 버려서 연결 에러가 발생할 수 있습니다. 반대로 항상 열어 두면 서버 리소스가 고갈됩니다.
HttpUtil.isKeepAlive()를 빠뜨리지 않는 게 포인트입니다.
HTTP 클라이언트 구현
네티로 HTTP 클라이언트도 만들 수 있습니다. Bootstrap + HttpClientCodec 조합을 사용합니다.
클라이언트 부트스트랩
import io.netty.bootstrap.Bootstrap;
import io.netty.channel.*;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.SocketChannel;
import io.netty.channel.socket.nio.NioSocketChannel;
import io.netty.handler.codec.http.HttpClientCodec;
import io.netty.handler.codec.http.HttpObjectAggregator;
public class SimpleHttpClient {
public static void main(String[] args) throws InterruptedException {
EventLoopGroup group = new NioEventLoopGroup();
try {
Bootstrap bootstrap = new Bootstrap();
bootstrap.group(group)
.channel(NioSocketChannel.class)
.handler(new ChannelInitializer<SocketChannel>() {
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
// HTTP 클라이언트 코덱 — 요청 인코딩 + 응답 디코딩
pipeline.addLast("httpCodec", new HttpClientCodec());
// 응답 청크를 하나로 합침
pipeline.addLast("aggregator", new HttpObjectAggregator(1024 * 1024));
// 응답 처리 핸들러
pipeline.addLast("handler", new HttpClientHandler());
}
});
// 서버에 연결
ChannelFuture future = bootstrap.connect("localhost", 8080).sync();
future.channel().closeFuture().sync();
} finally {
group.shutdownGracefully();
}
}
}
클라이언트 핸들러 — 요청 전송과 응답 수신
import io.netty.channel.ChannelHandlerContext;
import io.netty.channel.SimpleChannelInboundHandler;
import io.netty.handler.codec.http.*;
import io.netty.util.CharsetUtil;
// 연결이 완료되면 요청을 보내고, 응답을 처리하는 핸들러
public class HttpClientHandler extends SimpleChannelInboundHandler<FullHttpResponse> {
@Override
public void channelActive(ChannelHandlerContext ctx) {
// 연결이 맺어지면 GET 요청을 전송
DefaultFullHttpRequest request = new DefaultFullHttpRequest(
HttpVersion.HTTP_1_1,
HttpMethod.GET,
"/api/hello"
);
// Host 헤더는 HTTP/1.1 필수
request.headers().set(HttpHeaderNames.HOST, "localhost");
request.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE);
ctx.writeAndFlush(request);
System.out.println("클라이언트: GET /api/hello 요청 전송");
}
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpResponse response) {
System.out.println("응답 상태: " + response.status());
System.out.println("응답 바디: " + response.content().toString(CharsetUtil.UTF_8));
}
@Override
public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
cause.printStackTrace();
ctx.close();
}
}
서버 코덱이 HttpServerCodec이었다면, 클라이언트는 HttpClientCodec을 사용한다는 점만 다릅니다. 방향이 반대이기 때문입니다.
- **서버 **: 요청을 디코딩하고, 응답을 인코딩
- ** 클라이언트 **: 요청을 인코딩하고, 응답을 디코딩
요청을 만들 때 Host 헤더를 설정하는 건 HTTP/1.1 스펙의 필수 사항입니다. 빠뜨리면 서버에 따라 400 Bad Request가 반환될 수 있습니다.
정적 파일 서빙 — ChunkedWriteHandler 패턴
HTML, CSS, 이미지 같은 정적 파일을 HTTP로 서빙하려면, 파일을 읽어서 응답 바디에 넣어야 합니다. 작은 파일이라면 메모리에 전부 올려도 괜찮지만, ** 큰 파일은 조각 단위로 비동기 전송 **해야 합니다.
파이프라인 구성
@Override
protected void initChannel(SocketChannel ch) {
ChannelPipeline pipeline = ch.pipeline();
pipeline.addLast("httpCodec", new HttpServerCodec());
// HttpObjectAggregator 없이 — 파일 스트리밍이므로 조각 단위 처리
pipeline.addLast("chunkedWriter", new ChunkedWriteHandler());
pipeline.addLast("handler", new StaticFileHandler());
}
여기서 HttpObjectAggregator를 빼는 것에 주목합니다. 정적 파일 서빙에서는 요청 바디가 필요 없고(GET 요청이니까), 응답은 청크 단위로 보내야 하기 때문입니다.
정적 파일 핸들러
import io.netty.channel.*;
import io.netty.handler.codec.http.*;
import io.netty.handler.stream.ChunkedFile;
import java.io.File;
import java.io.RandomAccessFile;
// 정적 파일 요청을 처리하는 핸들러
public class StaticFileHandler extends SimpleChannelInboundHandler<HttpRequest> {
private static final String BASE_DIR = "/var/www/static";
@Override
protected void channelRead0(ChannelHandlerContext ctx, HttpRequest request) throws Exception {
// URI에서 파일 경로 추출
String path = request.uri();
if ("/".equals(path)) {
path = "/index.html";
}
File file = new File(BASE_DIR + path);
if (!file.exists() || !file.isFile()) {
// 404 응답
DefaultFullHttpResponse notFound = new DefaultFullHttpResponse(
HttpVersion.HTTP_1_1, HttpResponseStatus.NOT_FOUND
);
ctx.writeAndFlush(notFound).addListener(ChannelFutureListener.CLOSE);
return;
}
RandomAccessFile raf = new RandomAccessFile(file, "r");
long fileLength = raf.length();
// 응답 헤더만 먼저 전송
DefaultHttpResponse response = new DefaultHttpResponse(
HttpVersion.HTTP_1_1, HttpResponseStatus.OK
);
response.headers().set(HttpHeaderNames.CONTENT_LENGTH, fileLength);
response.headers().set(HttpHeaderNames.CONTENT_TYPE, guessContentType(path));
ctx.write(response);
// 파일을 청크 단위로 전송 — ChunkedWriteHandler가 처리
ctx.write(new ChunkedFile(raf, 0, fileLength, 8192));
// 마지막 청크 전송 후 flush
ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT)
.addListener(ChannelFutureListener.CLOSE);
}
// 확장자로 Content-Type 추론
private String guessContentType(String path) {
if (path.endsWith(".html")) return "text/html; charset=UTF-8";
if (path.endsWith(".css")) return "text/css";
if (path.endsWith(".js")) return "application/javascript";
if (path.endsWith(".png")) return "image/png";
if (path.endsWith(".jpg")) return "image/jpeg";
return "application/octet-stream";
}
}
이 패턴의 핵심은 ChunkedFile과 ChunkedWriteHandler의 조합입니다.
ChunkedFile:RandomAccessFile을 감싸서 지정된 크기(위 예제에서는 8192바이트)씩 읽어주는 객체ChunkedWriteHandler:ChunkedFile에서 조각을 읽어 비동기로 채널에 쓰는 핸들러. 메모리에 파일 전체를 올리지 않습니다
정적 파일 서빙에서
DefaultFullHttpResponse대신DefaultHttpResponse(바디 없는 버전)를 쓰는 이유가 여기 있습니다. 헤더는 먼저 보내고, 바디는ChunkedFile로 스트리밍하는 구조입니다.
HTTP/1.1의 한계 — Head-of-Line Blocking
네티의 HTTP 지원은 편리하지만, HTTP/1.1 프로토콜 자체의 구조적 한계는 네티가 해결해 줄 수 없습니다.
Head-of-Line Blocking 문제
HTTP/1.1은 하나의 TCP 연결에서 요청을 순서대로 보내고 응답도 순서대로 받아야 합니다.
클라이언트 서버
|--- 요청 1 (CSS) ------->|
|--- 요청 2 (JS) ------->|
|--- 요청 3 (IMG) ------->|
| | ← 요청 1 처리 중... (느림)
|<-- 응답 1 (CSS) ---------| ← 응답 1이 끝나야
|<-- 응답 2 (JS) ---------| ← 응답 2를 보낼 수 있고
|<-- 응답 3 (IMG) ---------| ← 응답 3도 그 후에야 가능
요청 1의 처리가 느리면, 이미 준비된 요청 2, 3의 응답까지 ** 줄 서서 대기 **하게 됩니다. 이것이 Head-of-Line Blocking 입니다.
브라우저의 우회 전략
브라우저는 이 문제를 회피하기 위해 같은 도메인에 동시에 6개 정도의 TCP 연결 을 엽니다. 하지만 이건 근본적인 해결이 아니라 우회책일 뿐입니다.
- TCP 연결마다 3-way handshake + TLS handshake 비용 발생
- 서버 측 소켓 리소스 소모 증가
- 연결 수가 제한되어 있으므로 리소스가 많으면 여전히 병목
HTTP/1.1의 추가 한계
| 한계 | 설명 |
|---|---|
| Head-of-Line Blocking | 앞선 응답이 완료될 때까지 뒤 응답 대기 |
| 텍스트 기반 헤더 | 헤더가 매 요청마다 중복 전송, 압축 없음 |
| 단방향 통신 | 서버가 먼저 데이터를 보낼 수 없음 (Server Push 불가) |
| 우선순위 부재 | CSS와 이미지를 동등하게 취급, 중요한 리소스 우선 전송 불가 |
이런 한계를 극복하기 위해 HTTP/2 가 등장했습니다. HTTP/2는 하나의 TCP 연결에서 여러 요청/응답을 동시에 처리하는 멀티플렉싱 을 지원하고, 헤더 압축(HPACK), 서버 푸시, 스트림 우선순위 기능을 제공합니다. 네티는 HTTP/2도 지원하는데, 다음 편에서 다루겠습니다.
정리
네티의 HTTP/1.1 지원을 한 문장으로 요약하면, "HTTP 프로토콜 파싱을 코덱으로 추상화해서 파이프라인에 끼우면, 비즈니스 핸들러에서는 Java 객체로만 HTTP를 다룰 수 있다" 입니다.
HttpServerCodec/HttpClientCodec: HTTP 바이트 ↔ 객체 변환의 핵심 코덱HttpObjectAggregator: 청크 조각을FullHttpRequest/FullHttpResponse로 합침- Keep-Alive:
HttpUtil.isKeepAlive()로 확인, 응답 후 채널 닫기 여부 결정 - 정적 파일:
ChunkedWriteHandler+ChunkedFile로 메모리 효율적 전송 - HTTP/1.1의 Head-of-Line Blocking은 프로토콜 구조적 한계 → HTTP/2에서 해결
HTTP/1.1은 여전히 많은 시스템에서 사용되고 있고, 네티의 HTTP 코덱은 그 기반 위에서 동작합니다. 이 구조를 이해하고 있어야 HTTP/2나 WebSocket 같은 고급 프로토콜로 넘어갈 때도 "왜 이렇게 바뀌었는지"를 자연스럽게 이해할 수 있습니다.