외부 클라이언트의 요청을 받아서 내부 서버에 전달하고 응답을 돌려주는 프록시 서버 — Netty로 어떻게 구현할까요?

개념 정의 — 프록시/브릿지 패턴

프록시 서버 는 클라이언트와 백엔드 서버 사이에 위치해서, 클라이언트의 요청을 대신 받아 백엔드에 전달하고 응답을 돌려주는 중계 서버입니다. Netty에서는 두 개의 채널(프론트엔드, 백엔드)을 하나의 핸들러 안에서 연결하는 방식으로 이를 구현합니다.

브릿지 패턴 은 여기서 한 발 더 나아가서, 프론트엔드와 백엔드가 서로 다른 프로토콜을 사용할 때 중간에서 변환까지 해주는 패턴입니다. 예를 들어 클라이언트는 HTTP로 요청하는데, 백엔드는 TCP 바이너리 프로토콜을 쓴다면 프록시가 중간에서 변환해주는 거죠.

프록시 아키텍처

전체 구조를 그림으로 보면 이렇습니다.

PLAINTEXT
                         ┌──────────────── Proxy Server ────────────────┐
                         │                                              │
  Client ◄──────────►  Frontend Channel  ──►  Proxy Handler  ──►  Backend Channel  ◄──────────►  Backend Server
          (inbound)      │  (ServerChannel)     (데이터 중계)     (Bootstrap)       (outbound)     │
                         │                                              │
                         └──────────────────────────────────────────────┘

각 구성 요소의 역할을 정리하면:

  • **프론트엔드 채널 **: ServerBootstrap이 생성한 채널로, 클라이언트의 연결을 받습니다
  • ** 백엔드 채널 **: Bootstrap으로 백엔드 서버에 연결한 채널입니다
  • ** 프록시 핸들러 **: 프론트엔드에서 읽은 데이터를 백엔드로, 백엔드에서 읽은 데이터를 프론트엔드로 전달합니다

핵심은 ** 두 채널이 서로의 참조를 들고 있다 **는 점입니다. 프론트엔드 핸들러는 백엔드 채널을 알고 있고, 백엔드 핸들러는 프론트엔드 채널을 알고 있어서 양방향 데이터 전달이 가능합니다.

기본 TCP 프록시 구현

서버 부트스트랩

프록시 서버의 진입점입니다. 일반적인 Netty 서버와 동일하게 ServerBootstrap으로 시작합니다.

JAVA
public class ProxyServer {

    private final int localPort;
    private final String remoteHost;
    private final int remotePort;

    public ProxyServer(int localPort, String remoteHost, int remotePort) {
        this.localPort = localPort;
        this.remoteHost = remoteHost;
        this.remotePort = remotePort;
    }

    public void start() throws InterruptedException {
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();

        try {
            ServerBootstrap b = new ServerBootstrap();
            b.group(bossGroup, workerGroup)
             .channel(NioServerSocketChannel.class)
             .childHandler(new ChannelInitializer<SocketChannel>() {
                 @Override
                 protected void initChannel(SocketChannel ch) {
                     ch.pipeline().addLast(
                         // 프론트엔드 핸들러 — 백엔드 서버 정보를 넘겨준다
                         new FrontendHandler(remoteHost, remotePort)
                     );
                 }
             })
             // 백엔드 연결이 완료될 때까지 클라이언트 데이터를 읽지 않는다
             .childOption(ChannelOption.AUTO_READ, false);

            ChannelFuture f = b.bind(localPort).sync();
            System.out.println("프록시 서버 시작: " + localPort + " → " + remoteHost + ":" + remotePort);
            f.channel().closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

여기서 AUTO_READfalse로 설정한 부분이 중요합니다. 백엔드 연결이 준비되기 전에 클라이언트 데이터를 읽으면 전달할 곳이 없으니까요. 백엔드 연결이 완료된 후에 수동으로 read()를 호출합니다.

FrontendHandler

클라이언트 연결을 담당하는 핸들러입니다. 가장 핵심적인 역할을 합니다.

JAVA
public class FrontendHandler extends ChannelInboundHandlerAdapter {

    private final String remoteHost;
    private final int remotePort;

    // 백엔드 채널 참조 — channelActive()에서 생성된다
    private Channel backendChannel;

    public FrontendHandler(String remoteHost, int remotePort) {
        this.remoteHost = remoteHost;
        this.remotePort = remotePort;
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        // 클라이언트가 연결되면, 백엔드 서버에도 연결을 생성한다
        final Channel frontendChannel = ctx.channel();

        Bootstrap b = new Bootstrap();
        b.group(frontendChannel.eventLoop())  // 같은 EventLoop를 공유한다!
         .channel(ctx.channel().getClass())
         .handler(new ChannelInitializer<SocketChannel>() {
             @Override
             protected void initChannel(SocketChannel ch) {
                 // 백엔드 핸들러에 프론트엔드 채널 참조를 넘긴다
                 ch.pipeline().addLast(new BackendHandler(frontendChannel));
             }
         })
         .option(ChannelOption.AUTO_READ, false);

        // 백엔드 서버에 비동기 연결
        ChannelFuture connectFuture = b.connect(remoteHost, remotePort);
        backendChannel = connectFuture.channel();

        connectFuture.addListener((ChannelFutureListener) future -> {
            if (future.isSuccess()) {
                // 백엔드 연결 성공 → 프론트엔드에서 데이터 읽기 시작
                frontendChannel.read();
            } else {
                // 백엔드 연결 실패 → 프론트엔드도 닫는다
                frontendChannel.close();
            }
        });
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        // 클라이언트에서 받은 데이터를 백엔드로 전달
        if (backendChannel.isActive()) {
            backendChannel.writeAndFlush(msg).addListener((ChannelFutureListener) future -> {
                if (future.isSuccess()) {
                    // 전달 성공하면 다음 데이터를 읽는다
                    ctx.channel().read();
                } else {
                    future.channel().close();
                }
            });
        }
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        // 클라이언트 연결이 끊어지면 백엔드도 닫는다
        if (backendChannel != null) {
            closeOnFlush(backendChannel);
        }
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        closeOnFlush(ctx.channel());
    }

    // 남은 데이터를 flush한 후 채널을 닫는 유틸리티
    static void closeOnFlush(Channel ch) {
        if (ch.isActive()) {
            ch.writeAndFlush(Unpooled.EMPTY_BUFFER)
              .addListener(ChannelFutureListener.CLOSE);
        }
    }
}

frontendChannel.eventLoop()를 백엔드 Bootstrapgroup()에 넘기는 부분을 주목해주세요. 같은 EventLoop를 공유하면 두 채널이 같은 스레드에서 처리되기 때문에 별도의 동기화 없이도 스레드 안전성이 보장됩니다. 컨텍스트 스위칭 비용도 없어서 지연 시간도 줄어듭니다.

BackendHandler

백엔드 서버의 응답을 프론트엔드로 돌려주는 핸들러입니다. 구조는 FrontendHandler와 대칭입니다.

JAVA
public class BackendHandler extends ChannelInboundHandlerAdapter {

    private final Channel frontendChannel;

    public BackendHandler(Channel frontendChannel) {
        this.frontendChannel = frontendChannel;
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        // 백엔드 연결이 활성화되면 데이터 읽기 시작
        ctx.read();
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        // 백엔드에서 받은 응답을 프론트엔드(클라이언트)로 전달
        frontendChannel.writeAndFlush(msg).addListener((ChannelFutureListener) future -> {
            if (future.isSuccess()) {
                ctx.channel().read();
            } else {
                future.channel().close();
            }
        });
    }

    @Override
    public void channelInactive(ChannelHandlerContext ctx) {
        // 백엔드 연결이 끊어지면 프론트엔드도 닫는다
        FrontendHandler.closeOnFlush(frontendChannel);
    }

    @Override
    public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) {
        cause.printStackTrace();
        FrontendHandler.closeOnFlush(ctx.channel());
    }
}

백엔드가 끊어졌는데 프론트엔드를 열어두면 클라이언트는 응답이 오지 않는 연결을 붙잡고 있게 됩니다. 그래서 한쪽이 끊어지면 반드시 반대쪽도 닫아줘야 합니다.

백프레셔 처리

기본 구현에서는 writeAndFlush 성공 후에 다음 read()를 호출하는 방식으로 간단한 흐름 제어를 하고 있습니다. 하지만 고트래픽 환경에서는 이것만으로 부족할 수 있습니다.

문제 상황을 그림으로 보면:

PLAINTEXT
  Client (빠름)                    Proxy                     Backend (느림)
  ───────────►  데이터 10MB/s  ──────────►  데이터 2MB/s  ──────────►

                               │ 프록시 내부 버퍼에 데이터가 쌓인다
                               │ → OOM 위험!

isWritable()channelWritabilityChanged()를 활용하면 이 문제를 해결할 수 있습니다.

JAVA
public class BackpressureAwareFrontendHandler extends ChannelInboundHandlerAdapter {

    private Channel backendChannel;

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if (backendChannel.isActive()) {
            backendChannel.writeAndFlush(msg).addListener((ChannelFutureListener) future -> {
                if (future.isSuccess()) {
                    // 백엔드 채널이 쓰기 가능한 상태인지 확인
                    if (backendChannel.isWritable()) {
                        ctx.channel().read();
                    }
                    // isWritable()이 false면 read()를 호출하지 않는다
                    // → 클라이언트 데이터 수신이 자동으로 멈춘다
                } else {
                    future.channel().close();
                }
            });
        }
    }

    @Override
    public void channelWritabilityChanged(ChannelHandlerContext ctx) {
        // 백엔드 채널의 쓰기 가능 상태가 변경되면 호출된다
        // 이 핸들러는 프론트엔드 채널에 있으므로,
        // 백엔드 핸들러에서 프론트엔드의 auto-read를 제어하는 방식으로 구현한다
    }
}

BackendHandler 쪽에서는 반대 방향의 흐름 제어를 합니다.

JAVA
public class BackpressureAwareBackendHandler extends ChannelInboundHandlerAdapter {

    private final Channel frontendChannel;

    public BackpressureAwareBackendHandler(Channel frontendChannel) {
        this.frontendChannel = frontendChannel;
    }

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if (frontendChannel.isActive()) {
            frontendChannel.writeAndFlush(msg).addListener((ChannelFutureListener) future -> {
                if (future.isSuccess()) {
                    // 프론트엔드 채널이 쓰기 가능할 때만 다음 데이터를 읽는다
                    if (frontendChannel.isWritable()) {
                        ctx.channel().read();
                    }
                } else {
                    future.channel().close();
                }
            });
        }
    }

    @Override
    public void channelWritabilityChanged(ChannelHandlerContext ctx) {
        // 프론트엔드 채널이 다시 쓸 수 있게 되면 백엔드 읽기를 재개한다
        if (frontendChannel.isWritable()) {
            ctx.channel().read();
        }
    }
}

흐름을 정리하면:

  1. 백엔드 쓰기 버퍼가 가득 차면 → isWritable()false 반환
  2. 프론트엔드에서 read()를 호출하지 않음 → 클라이언트 데이터 수신 중단
  3. 백엔드 버퍼가 비워지면 → channelWritabilityChanged() 이벤트 발생
  4. 다시 read() 호출 → 데이터 흐름 재개

이 패턴으로 프록시가 아무리 많은 트래픽을 받아도 메모리가 터지지 않습니다.

프로토콜 변환 프록시

지금까지는 바이트를 그대로 전달하는 투명 프록시(transparent proxy)를 봤습니다. 실무에서는 프론트엔드와 백엔드가 서로 다른 프로토콜을 사용하는 경우가 많습니다. 이럴 때 ** 프로토콜 변환 프록시 **(또는 브릿지)를 사용합니다.

프론트엔드와 백엔드에 서로 다른 코덱 파이프라인을 구성하면 됩니다.

JAVA
// 프론트엔드 파이프라인 — HTTP 코덱
public class HttpFrontendInitializer extends ChannelInitializer<SocketChannel> {
    @Override
    protected void initChannel(SocketChannel ch) {
        ch.pipeline().addLast(
            new HttpServerCodec(),
            new HttpObjectAggregator(1048576),
            new HttpToBackendHandler()  // HTTP → 내부 프로토콜 변환
        );
    }
}

// 백엔드 파이프라인 — 커스텀 바이너리 프로토콜
public class BackendInitializer extends ChannelInitializer<SocketChannel> {
    private final Channel frontendChannel;

    public BackendInitializer(Channel frontendChannel) {
        this.frontendChannel = frontendChannel;
    }

    @Override
    protected void initChannel(SocketChannel ch) {
        ch.pipeline().addLast(
            new LengthFieldBasedFrameDecoder(65535, 0, 4, 0, 4),
            new LengthFieldPrepender(4),
            new BackendToHttpHandler(frontendChannel)  // 내부 프로토콜 → HTTP 변환
        );
    }
}

변환 핸들러의 핵심 로직은 이런 형태입니다.

JAVA
public class HttpToBackendHandler extends ChannelInboundHandlerAdapter {

    private Channel backendChannel;

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if (msg instanceof FullHttpRequest request) {
            // HTTP 요청을 내부 프로토콜 메시지로 변환
            ByteBuf backendMsg = convertToBackendProtocol(request);

            backendChannel.writeAndFlush(backendMsg)
                .addListener((ChannelFutureListener) future -> {
                    if (future.isSuccess()) {
                        ctx.channel().read();
                    }
                });

            // HTTP 요청 객체의 참조 카운트를 관리한다
            ReferenceCountUtil.release(request);
        }
    }

    private ByteBuf convertToBackendProtocol(FullHttpRequest request) {
        // URI, 헤더, 바디를 파싱해서 백엔드가 이해하는 바이너리 포맷으로 변환
        ByteBuf buf = Unpooled.buffer();
        // 메시지 타입
        buf.writeByte(getMessageType(request.uri()));
        // 바디 길이 + 바디
        ByteBuf content = request.content();
        buf.writeInt(content.readableBytes());
        buf.writeBytes(content);
        return buf;
    }

    private byte getMessageType(String uri) {
        return switch (uri) {
            case "/api/query" -> (byte) 0x01;
            case "/api/command" -> (byte) 0x02;
            default -> (byte) 0x00;
        };
    }
}

이 패턴이 실제로 쓰이는 대표적인 사례:

  • API Gateway: 외부 HTTP/REST 요청을 내부 gRPC나 Thrift로 변환
  • ** 레거시 시스템 연동 **: 신규 시스템은 JSON/HTTP, 레거시는 바이너리 TCP인 경우
  • ** 프로토콜 업그레이드 **: HTTP/1.1 클라이언트의 요청을 HTTP/2 백엔드로 전달
  • ** 보안 프록시 **: 외부 연결은 TLS, 내부 통신은 평문으로 처리

연결 풀링

지금까지 본 구현은 클라이언트 연결이 올 때마다 백엔드 연결을 새로 생성합니다. 트래픽이 적으면 괜찮지만, 요청이 많아지면 TCP 핸드셰이크 비용이 누적됩니다.

Netty는 ChannelPool 인터페이스와 구현체를 제공합니다.

JAVA
// 고정 크기 연결 풀 생성
EventLoopGroup group = new NioEventLoopGroup();
FixedChannelPool pool = new FixedChannelPool(
    new Bootstrap()
        .group(group)
        .channel(NioSocketChannel.class)
        .remoteAddress(remoteHost, remotePort),
    new AbstractChannelPoolHandler() {
        @Override
        public void channelCreated(Channel ch) {
            // 새 채널이 생성될 때 파이프라인 설정
            ch.pipeline().addLast(new BackendHandler(/* ... */));
        }
    },
    10  // 최대 연결 수
);

// 풀에서 채널을 빌려 사용
pool.acquire().addListener((FutureListener<Channel>) future -> {
    if (future.isSuccess()) {
        Channel backendChannel = future.getNow();

        // 데이터 전송 후 반납
        backendChannel.writeAndFlush(data).addListener(f -> {
            pool.release(backendChannel);
        });
    }
});
  • SimpleChannelPool: 크기 제한 없이 연결을 관리합니다. 연결이 없으면 새로 만들고, 다 쓰면 풀에 반납합니다.
  • FixedChannelPool: 최대 연결 수를 제한합니다. 모든 연결이 사용 중이면 대기열에 들어갑니다.

실무에서는 보통 FixedChannelPool을 사용합니다. 백엔드 서버에 무한히 연결을 맺는 걸 방지하면서도, idle 연결을 재활용해서 성능을 높일 수 있습니다.

풀링을 사용할 때 주의할 점이 있습니다. 풀에서 꺼낸 채널의 파이프라인 상태가 이전 요청의 것일 수 있기 때문에, 채널을 꺼낸 후에 핸들러를 교체하거나 상태를 초기화하는 로직이 필요합니다.

정리

Netty 프록시 패턴의 핵심을 다시 짚어보면:

  • ** 두 채널 연결 **: 프론트엔드와 백엔드 채널이 서로의 참조를 들고 있어서 양방향 데이터 전달이 가능합니다
  • **EventLoop 공유 **: 같은 EventLoop를 사용하면 동기화 비용 없이 스레드 안전하게 동작합니다
  • **AUTO_READ 제어 **: 백엔드 연결이 준비되기 전에 클라이언트 데이터를 읽지 않도록 흐름을 제어합니다
  • ** 백프레셔 **: isWritable()channelWritabilityChanged()로 속도 차이에 의한 메모리 문제를 방지합니다
  • ** 양쪽 정리 **: 한쪽 연결이 끊어지면 반대쪽도 반드시 닫아줘야 합니다
  • ** 프로토콜 변환 **: 프론트/백엔드에 서로 다른 코덱 파이프라인을 구성하면 프로토콜 브릿지가 됩니다
  • ** 연결 풀링 **: 고트래픽 환경에서는 FixedChannelPool로 백엔드 연결을 재활용합니다

공부하다 보니 프록시 패턴은 Netty의 거의 모든 개념이 한꺼번에 등장하는 종합 예제 같았습니다. Channel, EventLoop, Pipeline, 백프레셔까지 — 이 패턴을 이해하면 Netty의 전체적인 흐름이 잡힙니다.

댓글 로딩 중...