네티로 데이터를 처리하다 보면 "보내는 쪽이 너무 빠르면 어떻게 되지?"라는 의문이 생긴다. write()를 아무리 호출해도 에러는 안 나는데, 그 사이 메모리는 어디까지 올라가는 걸까?

배압(Backpressure)이 필요한 이유

배압은 생산 속도가 소비 속도를 초과할 때 생산자를 늦추는 메커니즘 입니다. 네티에서는 write()가 비동기이기 때문에, 호출 즉시 데이터가 소켓으로 나가는 것이 아니라 아웃바운드 버퍼(ChannelOutboundBuffer) 에 쌓입니다. 상대방이 데이터를 느리게 읽거나 네트워크가 혼잡하면, 이 버퍼가 끝없이 커집니다.

OOM이 발생하는 시나리오

JAVA
// 위험한 코드 — 배압 제어 없이 무한으로 write
public class DangerousHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        // 파일이나 DB에서 대량 데이터를 읽어 전송하는 상황
        while (hasMoreData()) {
            ByteBuf data = fetchNextChunk();
            ctx.write(data); // 버퍼에 계속 쌓임
        }
        ctx.flush();
    }
}

이 코드의 문제점을 정리하면 이렇습니다.

  1. write()는 논블로킹이라 즉시 반환된다
  2. 소켓으로 실제 전송되기 전에 다음 write()가 호출된다
  3. ChannelOutboundBuffer에 데이터가 무한히 쌓인다
  4. 힙 메모리가 고갈되어 OutOfMemoryError 발생

네티의 write()가 "성공"했다는 건 소켓에 전달됐다는 뜻이 아니라 ** 아웃바운드 버퍼에 들어갔다 **는 뜻이다. 이 차이를 모르면 OOM을 피할 수 없다.

Channel.isWritable() — 버퍼 상태 확인

isWritable()은 아웃바운드 버퍼에 여유가 있는지 확인하는 메서드 입니다. 내부적으로 ChannelOutboundBuffer의 누적 바이트 수가 high watermark 이하인지를 판단합니다.

JAVA
// isWritable()로 배압 제어
public class SafeHandler extends ChannelInboundHandlerAdapter {
    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        writeIfPossible(ctx);
    }

    private void writeIfPossible(ChannelHandlerContext ctx) {
        while (hasMoreData() && ctx.channel().isWritable()) {
            ByteBuf data = fetchNextChunk();
            ctx.writeAndFlush(data);
        }
        // isWritable()이 false면 여기서 멈춤
        // → channelWritabilityChanged()에서 재개
    }
}

핵심은 write 전에 반드시 isWritable()을 체크 하는 것입니다. false면 쓰기를 멈추고, 나중에 버퍼가 빠져서 다시 writable 상태가 되면 재개합니다.

WriteBufferWaterMark — 워터마크 설정

WriteBufferWaterMark는 write buffer의 high/low 임계값을 정의하는 설정 입니다. 수도 탱크의 수위 센서와 비슷합니다. 물이 상한선(high)까지 차면 경고를 울리고, 하한선(low)까지 빠지면 해제하는 것과 같은 원리입니다.

JAVA
// 워터마크 설정
ServerBootstrap bootstrap = new ServerBootstrap();
bootstrap.childOption(
    ChannelOption.WRITE_BUFFER_WATER_MARK,
    new WriteBufferWaterMark(
        32 * 1024,  // low watermark: 32KB — 이 이하로 내려가면 writable = true
        64 * 1024   // high watermark: 64KB — 이 이상 쌓이면 writable = false
    )
);

기본값과 동작 흐름

항목기본값
low watermark32KB
high watermark64KB

동작 흐름은 다음과 같습니다.

  1. write buffer 누적 크기가 64KB 초과isWritable() = false
  2. flush로 데이터가 소켓에 전달되어 32KB 이하 로 내려감 → isWritable() = true
  3. 상태가 바뀔 때마다 channelWritabilityChanged() 콜백 호출

low와 high 사이에 간격을 두는 이유는 진동(thrashing)을 방지 하기 위해서다. 만약 둘이 같은 값이면, 1바이트만 써도 false↔true를 반복하며 성능이 떨어진다.

channelWritabilityChanged() 콜백

JAVA
public class BackpressureHandler extends ChannelInboundHandlerAdapter {

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        // 초기 쓰기 시작
        writeIfPossible(ctx);
    }

    @Override
    public void channelWritabilityChanged(ChannelHandlerContext ctx) {
        // writable 상태가 바뀔 때 호출됨
        if (ctx.channel().isWritable()) {
            // 버퍼가 빠졌으니 쓰기 재개
            writeIfPossible(ctx);
        }
        ctx.fireChannelWritabilityChanged(); // 다음 핸들러에 전파
    }

    private void writeIfPossible(ChannelHandlerContext ctx) {
        while (hasMoreData() && ctx.channel().isWritable()) {
            ByteBuf data = fetchNextChunk();
            ctx.writeAndFlush(data);
        }
    }
}

channelWritabilityChanged()는 writable 상태가 전환될 때만 호출됩니다. high를 넘어 false가 될 때 한 번, low 이하로 내려와 true가 될 때 한 번. 매 write()마다 호출되는 것이 아닙니다.

auto-read 설정 — 수신 측 배압

지금까지는 ** 쓰기(아웃바운드) 측 배압 **이었습니다. 그런데 반대쪽도 생각해야 합니다. 상대방이 너무 빠르게 데이터를 보내면, 내 인바운드 버퍼도 넘칠 수 있습니다. 이때 사용하는 것이 auto-read 설정입니다.

auto-read는 channelRead() 처리가 끝난 후 자동으로 다음 read를 요청할지 결정하는 설정 입니다.

JAVA
// auto-read 비활성화
channel.config().setAutoRead(false);

auto-read의 동작 차이

auto-read = true (기본값)

  • channelReadComplete() 후 자동으로 Selector에 OP_READ 등록
  • 데이터가 들어오는 대로 계속 읽음
  • 별도 제어 없이 편리하지만, 수신 속도를 조절할 수 없음

auto-read = false

  • channelReadComplete() 후 OP_READ를 등록하지 않음
  • 개발자가 명시적으로 ctx.read()를 호출해야 다음 데이터를 수신
  • 수신 속도를 직접 제어 가능
JAVA
// auto-read = false로 수신 속도 제어
public class ThrottledReader extends ChannelInboundHandlerAdapter {

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        ByteBuf buf = (ByteBuf) msg;
        try {
            processData(buf); // 데이터 처리
        } finally {
            buf.release();
        }
        // 처리가 끝난 후에만 다음 데이터를 요청
        ctx.read();
    }

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        // auto-read를 끄고 첫 번째 read만 수동으로 트리거
        ctx.channel().config().setAutoRead(false);
        ctx.read();
    }
}

auto-read를 false로 설정할 때는 반드시 어딘가에서 ctx.read()를 호출해야 한다. 안 그러면 데이터를 영원히 수신하지 않는다.

실전 패턴: 느린 소비자 보호

여기까지 배운 내용을 종합하면, 느린 소비자를 보호하는 패턴이 완성됩니다. 핵심은 isWritable() 체크 → write 일시 중단 → channelWritabilityChanged()에서 재개 의 3단계입니다.

JAVA
public class SlowConsumerProtectionHandler extends ChannelInboundHandlerAdapter {

    private boolean writing = false;

    @Override
    public void channelActive(ChannelHandlerContext ctx) {
        // 대량 데이터 전송 시작
        writing = true;
        doWrite(ctx);
    }

    private void doWrite(ChannelHandlerContext ctx) {
        while (writing && hasMoreData()) {
            // 1단계: writable 체크
            if (!ctx.channel().isWritable()) {
                // 버퍼가 가득 참 → 일시 중단
                return; // channelWritabilityChanged()에서 재개
            }

            ByteBuf chunk = fetchNextChunk();

            // flush 없이 write만 하면 버퍼에만 쌓임
            // writeAndFlush로 소켓 전송까지 트리거
            ChannelFuture future = ctx.writeAndFlush(chunk);

            if (!ctx.channel().isWritable()) {
                // write 직후에도 체크 — 방금 write로 high를 넘었을 수 있음
                return;
            }
        }

        if (!hasMoreData()) {
            writing = false;
            // 모든 데이터 전송 완료 처리
        }
    }

    @Override
    public void channelWritabilityChanged(ChannelHandlerContext ctx) {
        // 2단계: writable이 다시 true가 되면 재개
        if (ctx.channel().isWritable() && writing) {
            doWrite(ctx);
        }
        ctx.fireChannelWritabilityChanged();
    }
}

이 패턴에서 주의할 점을 정리하면 다음과 같습니다.

  • writeAndFlush()를 사용해야 실제 소켓 전송이 일어난다. write()만 하고 flush()를 안 하면 버퍼에만 쌓인다.
  • channelWritabilityChanged()에서 fireChannelWritabilityChanged()를 호출해 다음 핸들러에도 이벤트를 전파해야 한다.
  • 모든 로직은 EventLoop 스레드에서 실행되므로 동기화 걱정은 없다.

프록시 서버에서의 배압 전파

프록시 서버는 배압 제어가 가장 중요한 케이스입니다. 프론트엔드 채널(클라이언트↔프록시)과 백엔드 채널(프록시↔서버) 두 개의 Channel 사이에서 배압을 전파해야 합니다.

문제 상황

PLAINTEXT
[클라이언트] --빠르게 전송--> [프록시 프론트엔드] ---> [프록시 백엔드] --느린 서버--> [서버]
  • 클라이언트가 빠르게 보낸다
  • 서버가 느리게 받는다
  • 프록시의 백엔드 write buffer가 무한히 쌓인다 → OOM

해결: 양방향 배압 전파

JAVA
// 프론트엔드 핸들러 — 클라이언트 데이터를 백엔드로 전달
public class FrontendHandler extends ChannelInboundHandlerAdapter {

    private Channel backendChannel;

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if (backendChannel.isActive()) {
            // 백엔드로 데이터 전달
            backendChannel.writeAndFlush(msg).addListener(future -> {
                if (future.isSuccess()) {
                    // 백엔드 write 성공 후 프론트엔드 read 재개
                    if (!ctx.channel().config().isAutoRead()) {
                        ctx.channel().config().setAutoRead(true);
                    }
                }
            });

            // 백엔드 write buffer가 가득 찼으면 프론트엔드 수신 중단
            if (!backendChannel.isWritable()) {
                ctx.channel().config().setAutoRead(false);
            }
        } else {
            ReferenceCountUtil.release(msg);
        }
    }

    @Override
    public void channelWritabilityChanged(ChannelHandlerContext ctx) {
        // 프론트엔드 writable 상태가 변하면 백엔드 auto-read 조절
        backendChannel.config().setAutoRead(ctx.channel().isWritable());
        ctx.fireChannelWritabilityChanged();
    }
}

// 백엔드 핸들러 — 서버 응답을 프론트엔드로 전달
public class BackendHandler extends ChannelInboundHandlerAdapter {

    private Channel frontendChannel;

    @Override
    public void channelRead(ChannelHandlerContext ctx, Object msg) {
        if (frontendChannel.isActive()) {
            frontendChannel.writeAndFlush(msg);

            // 프론트엔드 write buffer가 가득 찼으면 백엔드 수신 중단
            if (!frontendChannel.isWritable()) {
                ctx.channel().config().setAutoRead(false);
            }
        } else {
            ReferenceCountUtil.release(msg);
        }
    }

    @Override
    public void channelWritabilityChanged(ChannelHandlerContext ctx) {
        // 백엔드 writable 상태가 변하면 프론트엔드 auto-read 조절
        frontendChannel.config().setAutoRead(ctx.channel().isWritable());
        ctx.fireChannelWritabilityChanged();
    }
}

이 패턴의 핵심 원리를 단계별로 보면 다음과 같습니다.

  1. ** 백엔드가 느려짐** → 백엔드 write buffer가 high watermark 초과
  2. ** 프론트엔드 auto-read OFF** → 클라이언트로부터 데이터 수신 중단
  3. TCP 수신 버퍼가 가득 차면 TCP flow control 이 클라이언트에게 전파
  4. 백엔드 버퍼가 빠지면 → 프론트엔드 auto-read ON → 수신 재개

반대 방향(서버→클라이언트)도 동일한 원리로 동작합니다. BackendHandler에서 frontendChannel의 writable을 체크하고, FrontendHandler의 channelWritabilityChanged()에서 backendChannel의 auto-read를 조절합니다.

프록시 배압의 핵심은 "상대 Channel의 isWritable() 상태를 내 Channel의 auto-read에 연결하는 것" 이다. 이 한 줄이 프록시 OOM을 막는 열쇠다.

OOM 방지 체크리스트

마지막으로, 네티 애플리케이션에서 write 관련 OOM을 방지하기 위한 체크리스트를 정리합니다.

1. WriteBufferWaterMark 설정

JAVA
// 서비스 특성에 맞게 워터마크 조정
bootstrap.childOption(
    ChannelOption.WRITE_BUFFER_WATER_MARK,
    new WriteBufferWaterMark(16 * 1024, 32 * 1024) // 트래픽이 적은 서비스
);
  • 기본값(32KB/64KB)이 대부분 적절하지만, 대용량 전송 서비스는 높일 수 있다
  • 너무 낮으면 잦은 상태 전환으로 성능 저하, 너무 높으면 메모리 낭비

2. isWritable() 체크 필수

JAVA
// 모든 대량 write 루프에서 반드시 체크
if (!channel.isWritable()) {
    // write 일시 중단
    return;
}
  • write() 호출 전에 반드시 확인
  • 단발성 응답(HTTP response 하나)은 괜찮지만, 루프/스트리밍은 필수

3. auto-read 활용

JAVA
// 프록시, 게이트웨이 등 중계 서버에서 필수
if (!targetChannel.isWritable()) {
    sourceChannel.config().setAutoRead(false);
}
  • 데이터를 받아서 다른 곳으로 전달하는 구조에서 특히 중요
  • auto-read를 끈 후 반드시 어딘가에서 다시 켜는 로직이 있어야 함

4. ChannelFuture 모니터링

JAVA
// write 실패 시 리소스 정리
ctx.writeAndFlush(data).addListener(future -> {
    if (!future.isSuccess()) {
        future.cause().printStackTrace();
        ctx.close(); // 실패 시 연결 종료
    }
});
  • write 결과를 무시하면 문제를 발견하기 어려움
  • 특히 연결이 끊어진 상태에서 계속 write하는 상황 방지

5. 전체 흐름 요약

PLAINTEXT
write() 호출

ChannelOutboundBuffer에 데이터 추가

누적 크기 > high watermark?
    ├── YES → isWritable() = false
    │         channelWritabilityChanged() 호출
    │         → write 일시 중단 또는 상대 auto-read OFF

    └── NO → 계속 write 가능

flush() → 소켓으로 전송

누적 크기 ≤ low watermark?
    ├── YES → isWritable() = true
    │         channelWritabilityChanged() 호출
    │         → write 재개 또는 상대 auto-read ON

    └── NO → 아직 writable = false 유지

배압 제어는 네티에서 안정적인 서비스를 만들기 위한 핵심 메커니즘입니다. "write()가 성공했으니 괜찮겠지"라고 넘기면 프로덕션에서 OOM을 만나게 됩니다. isWritable() 체크와 channelWritabilityChanged() 콜백, 그리고 auto-read 설정 — 이 세 가지를 항상 함께 고려하면 대부분의 배압 문제를 해결할 수 있습니다.

댓글 로딩 중...