Netty에서 메시지를 바로 보내지 않고 잠시 모아 뒀다가 보내려니 WebSocket 연결 자체가 안 된다는 이슈를 봤습니다. 연결은 결국 타임아웃으로 끝났는데요. 이슈를 올린 mega12345mega가 응답이 파이프라인 맨 뒤에서 출발하는 코드까지 짚어 둔 덕분에, 그 코드부터 응답이 나가는 경로를 따라가 볼 수 있었습니다.
메시지를 모아 뒀을 뿐인데
WebSocket으로 메시지를 주고받으려면 먼저 핸드셰이크를 마쳐야 합니다. 클라이언트가 HTTP 요청을 보내고 서버가 101 Switching Protocols로 응답하면, 그때부터 같은 TCP 연결로 WebSocket 메시지를 보낼 수 있습니다.
이슈를 올린 분은 핸드셰이크가 끝나기 전에도 보낼 메시지를 미리 쌓아 두고 싶었습니다. 그래서 나가는 메시지를 큐에 넣었다가 HandshakeComplete 이벤트가 오면 꺼내 보내도록 했는데요. 핸드셰이크를 끝내려면 먼저 보내야 하는 101 응답까지 이 큐에 들어가 버렸습니다.
101 응답을 보내야 핸드셰이크가 끝나는데, 큐는 핸드셰이크가 끝나야 응답을 꺼내 주니 서로 기다릴 수밖에 없었죠. 결국 연결은 타임아웃으로 끝났습니다. 큐 핸들러는 핸드셰이크 핸들러 뒤에 있었는데, 응답은 왜 그곳까지 가고 있었을까요?
응답이 맨 뒤에서 출발하고 있었습니다
Netty는 연결 하나에 여러 핸들러를 순서대로 붙여 데이터를 처리하는데, 이 구조를 파이프라인이라고 합니다. 이슈에 나온 배치를 그려 보면 아래와 같습니다. 소켓에 가까운 왼쪽 끝이 head, 오른쪽 끝이 tail입니다.
Pipeline in the reported issue
head (socket) → HTTP codec → handshake handler → protocol handler → queue handler → tail요청은 왼쪽에서 들어와 HTTP 코덱을 지나 핸드셰이크 핸들러에서 처리되므로 뒤의 큐까지 가지 않습니다. 그런데 응답은 달랐는데요. channel.writeAndFlush()는 요청을 처리한 위치와 상관없이 맨 뒤인 tail부터 소켓 쪽으로 올라갑니다. 응답이 큐 핸들러를 먼저 만난 것도 이 때문이죠.
핸들러마다 갖고 있는 ctx로 보내면 경로가 달라집니다. ctx는 ChannelHandlerContext이고, 해당 핸들러가 파이프라인 어디에 있는지 알고 있습니다. 핸드셰이크 핸들러에서 ctx.writeAndFlush()를 호출하면 그 위치부터 소켓 쪽으로 나가므로 뒤의 큐를 지나지 않습니다.
실제 코드에서는 핸드셰이크 핸들러를 먼저 파이프라인에서 제거합니다. 그래도 그 핸들러의 ctx로 보내면 제거되기 전 위치를 기준으로 응답이 나갑니다. 아래는 이때의 응답 경로입니다.
channel.writeAndFlush() → starts at the tail:
queue handler → protocol handler → HTTP codec → socket
ctx.writeAndFlush() → starts at the removed handshake handler's former position:
HTTP codec → socket (skips the queue handler)핸드셰이크 핸들러에서 ctx로 보내면 해결될 것 같지만, 실제로 응답을 만들고 보내는 쪽은 WebSocketServerHandshaker였습니다. 당시 handshake()는 Channel만 받았기 때문에 핸들러에 ctx가 있어도 ctx.channel()을 넘길 수밖에 없었는데요. handshaker는 그 채널로 응답을 보내니, 결국 다시 맨 뒤의 큐를 거치게 됩니다.
ctx도 넘길 수 있게 만들기
handshaker에 ctx를 넘길 수 있게 하되, 기존 handshake(Channel, ...)의 동작은 유지해야 했습니다. 외부에서도 호출하는 메서드라 응답이 tail부터 나갈 것으로 기대하는 코드가 있을 수 있기 때문입니다.
같은 클래스의 close()를 보니 Channel과 ChannelHandlerContext를 모두 받을 수 있었습니다. 둘 다 ChannelOutboundInvoker를 구현하니, 내부의 close0()는 이 인터페이스로 받아 연결을 닫는 메시지를 보내는 방식입니다.
WebSocketServerHandshaker.close (excerpt)
public ChannelFuture close(Channel channel, CloseWebSocketFrame frame) { ... }
public ChannelFuture close(ChannelHandlerContext ctx, CloseWebSocketFrame frame) { ... }
private ChannelFuture close0(ChannelOutboundInvoker invoker, CloseWebSocketFrame frame,
ChannelPromise promise) {
return invoker.writeAndFlush(frame, promise).addListener(ChannelFutureListener.CLOSE);
}handshake()에도 같은 방식을 적용해 기존 메서드 옆에 ChannelHandlerContext를 받는 메서드를 추가했습니다. 두 메서드는 내부에서 ChannelOutboundInvoker를 받는 handshake0()를 호출합니다. 채널을 넘기면 기존처럼 tail부터, ctx를 넘기면 해당 핸들러 위치부터 응답이 나가는 구조죠.
Netty 내부 핸들러에서 ctx.channel() 대신 ctx를 넘기도록 바꿨습니다. 101 응답은 핸드셰이크 핸들러가 있던 위치에서 나가므로 뒤의 큐에 들어가지 않습니다.
final ChannelFuture handshakeFuture = handshaker.handshake(ctx.channel(), req);final ChannelFuture handshakeFuture = handshaker.handshake(ctx, req);큐가 있던 자리에 기록을 남겨 보기
테스트에서는 큐 핸들러가 있던 자리에 나가는 메시지를 기록하는 핸들러를 붙이고, 핸드셰이크가 끝나도 기록이 비어 있는지 확인했습니다. 이전처럼 tail부터 보내면 101 응답이 기록에 남아 테스트가 실패하므로, 응답 경로가 바뀌었는지 확인할 수 있습니다.
요청을 받는 방식도 두 가지여서 테스트를 각각 추가했습니다. 요청 전체를 모은 FullHttpRequest를 받는 경우와, 모으지 않은 HttpRequest를 받는 경우입니다. 기존 테스트에서도 뒤쪽 핸들러에 응답이 도착할 것으로 가정한 부분을 고쳐야 했습니다. 이제 응답은 그곳을 지나지 않으니, 테스트 채널의 readOutbound()로 꺼내 확인하도록 바꿨습니다.