Flow Control & Backpressure
네티로 데이터를 처리하다 보면 "보내는 쪽이 너무 빠르면 어떻게 되지?"라는 의문이 생긴다. write()를 아무리 호출해도 에러는 안 나는데, 그 사이 메모리는 어디까지 올라가는 걸까?
배압(Backpressure)이 필요한 이유
배압은 생산 속도가 소비 속도를 초과할 때 생산자를 늦추는 메커니즘 입니다. 네티에서는 write()가 비동기이기 때문에, 호출 즉시 데이터가 소켓으로 나가는 것이 아니라 아웃바운드 버퍼(ChannelOutboundBuffer) 에 쌓입니다. 상대방이 데이터를 느리게 읽거나 네트워크가 혼잡하면, 이 버퍼가 끝없이 커집니다.
OOM이 발생하는 시나리오
// 위험한 코드 — 배압 제어 없이 무한으로 write
public class DangerousHandler extends ChannelInboundHandlerAdapter {
@Override
public void channelActive(ChannelHandlerContext ctx) {
// 파일이나 DB에서 대량 데이터를 읽어 전송하는 상황
while (hasMoreData()) {
ByteBuf data = fetchNextChunk();
ctx.write(data); // 버퍼에 계속 쌓임
}
ctx.flush();
}
}
이 코드의 문제점을 정리하면 이렇습니다.
write()는 논블로킹이라 즉시 반환된다- 소켓으로 실제 전송되기 전에 다음
write()가 호출된다 - ChannelOutboundBuffer에 데이터가 무한히 쌓인다
- 힙 메모리가 고갈되어 OutOfMemoryError 발생
네티의 write()가 "성공"했다는 건 소켓에 전달됐다는 뜻이 아니라 ** 아웃바운드 버퍼에 들어갔다 **는 뜻이다. 이 차이를 모르면 OOM을 피할 수 없다.
Channel.isWritable() — 버퍼 상태 확인
isWritable()은 아웃바운드 버퍼에 여유가 있는지 확인하는 메서드 입니다. 내부적으로 ChannelOutboundBuffer의 누적 바이트 수가 high watermark 이하인지를 판단합니다.
// 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)까지 빠지면 해제하는 것과 같은 원리입니다.
// 워터마크 설정
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 watermark | 32KB |
| high watermark | 64KB |
동작 흐름은 다음과 같습니다.
- write buffer 누적 크기가 64KB 초과 →
isWritable()= false - flush로 데이터가 소켓에 전달되어 32KB 이하 로 내려감 →
isWritable()= true - 상태가 바뀔 때마다
channelWritabilityChanged()콜백 호출
low와 high 사이에 간격을 두는 이유는 진동(thrashing)을 방지 하기 위해서다. 만약 둘이 같은 값이면, 1바이트만 써도 false↔true를 반복하며 성능이 떨어진다.
channelWritabilityChanged() 콜백
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를 요청할지 결정하는 설정 입니다.
// auto-read 비활성화
channel.config().setAutoRead(false);
auto-read의 동작 차이
auto-read = true (기본값)
- channelReadComplete() 후 자동으로 Selector에 OP_READ 등록
- 데이터가 들어오는 대로 계속 읽음
- 별도 제어 없이 편리하지만, 수신 속도를 조절할 수 없음
auto-read = false
- channelReadComplete() 후 OP_READ를 등록하지 않음
- 개발자가 명시적으로
ctx.read()를 호출해야 다음 데이터를 수신 - 수신 속도를 직접 제어 가능
// 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단계입니다.
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 사이에서 배압을 전파해야 합니다.
문제 상황
[클라이언트] --빠르게 전송--> [프록시 프론트엔드] ---> [프록시 백엔드] --느린 서버--> [서버]
- 클라이언트가 빠르게 보낸다
- 서버가 느리게 받는다
- 프록시의 백엔드 write buffer가 무한히 쌓인다 → OOM
해결: 양방향 배압 전파
// 프론트엔드 핸들러 — 클라이언트 데이터를 백엔드로 전달
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();
}
}
이 패턴의 핵심 원리를 단계별로 보면 다음과 같습니다.
- ** 백엔드가 느려짐** → 백엔드 write buffer가 high watermark 초과
- ** 프론트엔드 auto-read OFF** → 클라이언트로부터 데이터 수신 중단
- TCP 수신 버퍼가 가득 차면 TCP flow control 이 클라이언트에게 전파
- 백엔드 버퍼가 빠지면 → 프론트엔드 auto-read ON → 수신 재개
반대 방향(서버→클라이언트)도 동일한 원리로 동작합니다. BackendHandler에서 frontendChannel의 writable을 체크하고, FrontendHandler의 channelWritabilityChanged()에서 backendChannel의 auto-read를 조절합니다.
프록시 배압의 핵심은 "상대 Channel의 isWritable() 상태를 내 Channel의 auto-read에 연결하는 것" 이다. 이 한 줄이 프록시 OOM을 막는 열쇠다.
OOM 방지 체크리스트
마지막으로, 네티 애플리케이션에서 write 관련 OOM을 방지하기 위한 체크리스트를 정리합니다.
1. WriteBufferWaterMark 설정
// 서비스 특성에 맞게 워터마크 조정
bootstrap.childOption(
ChannelOption.WRITE_BUFFER_WATER_MARK,
new WriteBufferWaterMark(16 * 1024, 32 * 1024) // 트래픽이 적은 서비스
);
- 기본값(32KB/64KB)이 대부분 적절하지만, 대용량 전송 서비스는 높일 수 있다
- 너무 낮으면 잦은 상태 전환으로 성능 저하, 너무 높으면 메모리 낭비
2. isWritable() 체크 필수
// 모든 대량 write 루프에서 반드시 체크
if (!channel.isWritable()) {
// write 일시 중단
return;
}
write()호출 전에 반드시 확인- 단발성 응답(HTTP response 하나)은 괜찮지만, 루프/스트리밍은 필수
3. auto-read 활용
// 프록시, 게이트웨이 등 중계 서버에서 필수
if (!targetChannel.isWritable()) {
sourceChannel.config().setAutoRead(false);
}
- 데이터를 받아서 다른 곳으로 전달하는 구조에서 특히 중요
- auto-read를 끈 후 반드시 어딘가에서 다시 켜는 로직이 있어야 함
4. ChannelFuture 모니터링
// write 실패 시 리소스 정리
ctx.writeAndFlush(data).addListener(future -> {
if (!future.isSuccess()) {
future.cause().printStackTrace();
ctx.close(); // 실패 시 연결 종료
}
});
- write 결과를 무시하면 문제를 발견하기 어려움
- 특히 연결이 끊어진 상태에서 계속 write하는 상황 방지
5. 전체 흐름 요약
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 설정 — 이 세 가지를 항상 함께 고려하면 대부분의 배압 문제를 해결할 수 있습니다.