네티는 TCP 위에서 바이트를 직접 다루는 프레임워크인데, HTTP 같은 고수준 프로토콜도 지원할까? 지원한다면 Spring MVC처럼 편하게 쓸 수 있는 걸까, 아니면 뭔가 다른 레벨의 작업이 필요한 걸까?

네티의 HTTP 지원 — HttpServerCodec과 HttpClientCodec

네티는 HTTP/1.1을 위한 전용 코덱을 내장 하고 있습니다. 이전 글에서 살펴본 코덱 패턴과 동일한 구조로, 파이프라인에 핸들러를 추가하기만 하면 HTTP 프로토콜 파싱을 네티가 알아서 처리합니다.

핵심 코덱은 두 가지입니다.

코덱방향역할
HttpServerCodec서버인바운드: HTTP 요청 디코딩 / 아웃바운드: HTTP 응답 인코딩
HttpClientCodec클라이언트아웃바운드: HTTP 요청 인코딩 / 인바운드: HTTP 응답 디코딩

HttpServerCodec은 내부적으로 HttpRequestDecoderHttpResponseEncoder를 합쳐 놓은 CombinedChannelDuplexHandler입니다. 이전 글에서 배운 패턴 그대로입니다.

JAVA
// 서버 파이프라인 — HTTP 코덱 추가
pipeline.addLast("httpCodec", new HttpServerCodec());
JAVA
// 클라이언트 파이프라인 — HTTP 코덱 추가
pipeline.addLast("httpCodec", new HttpClientCodec());

한 줄이면 HTTP 프로토콜 파싱이 끝납니다. 직접 바이트를 읽어서 GET /path HTTP/1.1\r\n을 파싱할 필요가 전혀 없습니다.


HTTP 요청/응답 객체 — 메시지의 구조

HTTP 코덱이 바이트를 디코딩하면 Java 객체로 변환해서 파이프라인에 전달합니다. 그런데 HTTP 메시지는 하나의 객체가 아니라 여러 조각 으로 나뉘어서 들어옵니다.

인바운드에서 받는 객체들

PLAINTEXT
[HttpRequest]        ← 시작 라인 + 헤더 (바디 없음)

[HttpContent]        ← 바디 청크 (0개 이상)

[LastHttpContent]    ← 마지막 바디 청크 + trailing 헤더
  • HttpRequest: HTTP 메서드(GET, POST), URI, 헤더 정보를 담고 있습니다. 바디는 포함하지 않습니다.
  • HttpContent: 바디 데이터의 한 조각입니다. ByteBuf를 감싸고 있습니다.
  • LastHttpContent: 마지막 바디 조각입니다. 이 객체가 도착해야 메시지가 완전히 수신된 것입니다.

왜 하나의 객체로 안 주는 걸까요? HTTP/1.1에는 ** 청크 전송 인코딩(Chunked Transfer Encoding)**이 있기 때문입니다. 대용량 파일 업로드처럼 바디가 클 때, 전체를 메모리에 올리지 않고 조각 단위로 처리할 수 있도록 이렇게 분리한 것입니다.

대부분의 경우 이 조각들을 직접 다루기보다는, HttpObjectAggregator를 써서 하나로 합치는 게 편합니다. 다만 파일 업로드처럼 스트리밍이 필요한 경우에는 조각 단위로 처리하는 게 메모리 효율이 좋습니다.

FullHttpRequest — 완전한 메시지

FullHttpRequestHttpRequest + HttpContent + LastHttpContent를 ** 모두 합친 완전한 HTTP 요청 객체 **입니다.

JAVA
// 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로 합쳐 주는 핸들러 **입니다.

JAVA
pipeline.addLast("httpCodec", new HttpServerCodec());
// 최대 64KB까지의 HTTP 메시지를 하나로 합침
pipeline.addLast("aggregator", new HttpObjectAggregator(65536));
pipeline.addLast("handler", new MyHttpHandler());

생성자의 maxContentLength 파라미터는 합칠 수 있는 ** 최대 바디 크기 **입니다.

  • 65536 → 최대 64KB
  • 1024 * 1024 → 최대 1MB
  • 10 * 1024 * 1024 → 최대 10MB

이 크기를 초과하는 요청이 들어오면 TooLongHttpContentException이 발생하고, 클라이언트에 413 Request Entity Too Large 응답이 자동으로 전송됩니다.

maxContentLength를 너무 크게 잡으면 악의적인 클라이언트가 거대한 요청을 보내서 서버 메모리를 고갈시킬 수 있습니다. 용도에 맞게 적절한 크기를 설정하는 게 중요합니다.


간단한 HTTP 서버 구현

이제 실제로 동작하는 HTTP 서버를 만들어 봅니다.

서버 부트스트랩

JAVA
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 핸들러 — 라우팅과 응답 생성

JAVA
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를 처리하려면 응답 후 채널을 닫을지 말지를 판단 해야 합니다.

JAVA
@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)가 내부적으로 확인하는 것은:

  1. HTTP/1.1: 기본이 Keep-Alive이므로, Connection: close 헤더가 ** 있을 때만** false
  2. HTTP/1.0: 기본이 close이므로, Connection: keep-alive 헤더가 ** 있을 때만** true

ChannelFutureListener.CLOSE는 응답 전송이 완료된 후에 채널을 닫는 리스너입니다. writeAndFlush()는 비동기이기 때문에, 바로 ctx.close()를 호출하면 응답이 다 전송되기 전에 연결이 끊길 수 있습니다.

Keep-Alive를 제대로 처리하지 않으면, 브라우저는 연결이 유지될 거라고 기대하는데 서버가 먼저 닫아 버려서 연결 에러가 발생할 수 있습니다. 반대로 항상 열어 두면 서버 리소스가 고갈됩니다. HttpUtil.isKeepAlive()를 빠뜨리지 않는 게 포인트입니다.


HTTP 클라이언트 구현

네티로 HTTP 클라이언트도 만들 수 있습니다. Bootstrap + HttpClientCodec 조합을 사용합니다.

클라이언트 부트스트랩

JAVA
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();
        }
    }
}

클라이언트 핸들러 — 요청 전송과 응답 수신

JAVA
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로 서빙하려면, 파일을 읽어서 응답 바디에 넣어야 합니다. 작은 파일이라면 메모리에 전부 올려도 괜찮지만, ** 큰 파일은 조각 단위로 비동기 전송 **해야 합니다.

파이프라인 구성

JAVA
@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 요청이니까), 응답은 청크 단위로 보내야 하기 때문입니다.

정적 파일 핸들러

JAVA
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";
    }
}

이 패턴의 핵심은 ChunkedFileChunkedWriteHandler의 조합입니다.

  • 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 연결에서 요청을 순서대로 보내고 응답도 순서대로 받아야 합니다.

PLAINTEXT
클라이언트                    서버
   |--- 요청 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 같은 고급 프로토콜로 넘어갈 때도 "왜 이렇게 바뀌었는지"를 자연스럽게 이해할 수 있습니다.

댓글 로딩 중...