[Netdrops] WebSocket 1002 Protocol Error 해결기

들어가며

Netdrops의 실제 배포 환경에서 새로운 사용자가 접속할 때 기존 파일을 보내는 송신자의 웹소켓이 끊어지는 문제를 발견하여 로그를 바탕으로 Tomcat과 Spring 내부 동작과정과 문제가 생기는 원인을 찾고 해결하는 과정을 기록합니다.

증상

userA가 userB에게 파일을 전송하는 도중 userC가 Netdrops에 접속하면, userB의 WebSocket 연결이 끊어지는 현상을 발견했습니다.

  • 송신자 A: 전송 완료로 표시됨
  • 수신자 B: 파일을 받지 못하고 연결이 끊어짐
  • C가 접속하지 않으면: 정상 전송

로그 분석

INFO  Text from ec8f5976: type=meta
INFO  Binary: sender=ec8f5976, target=7d693674, size=20971520
INFO  Text from ec8f5976: type=complete
INFO  Transfer complete, mapping removed: sender=ec8f5976
INFO  Disconnected: sessionId=7d693674, status=CloseStatus[code=1002, reason=null]

로그를 읽어보면 송신자 -> 서버 구간은 정상입니다. 바이너리 또한 정상적으로 보냈고 수신자도 찾았습니다. 하지만 수신자쪽에서 status 1002를 반환하고 Disconnected 되는 것을 확인할 수 있습니다. 이를 통해 웹소켓 릴레이 과정에서 문제가 발생하고 있습니다.

또한 찍혀있는 스택 트레이스를 확인해보면

at org.apache.tomcat.websocket.WsRemoteEndpointImplBase.writeMessagePart(...)
at org.apache.tomcat.websocket.WsRemoteEndpointImplBase.sendMessageBlockInternal(...)

예외가 발생한 위치가 Tomcat의 WebSocket 송신 메서드 내부임을 알 수 있고 Tomcat이 수신자에게 데이터를 보내는 과정에서 일어나는 것을 알 수 있습니다.

1002 Protocol Error

먼저 Websocket에서의 1002 에러가 무엇을 의미하는지 찾아봤습니다.

alt text

RFC 6455 Section 7.4.1에 따르면 protocol error로 인해 endpoint가 연결을 종료하는 경우를 의미합니다. 즉 애플리케이션 로직 문제가 아니라 WebSocket 프로토콜 규격 위반 시 발생하는 에러 코드입니다. 서버에서 유효하지 않은 프레임을 보내고있는 것을 확인할 수 있습니다.

기존 동작 방식

기존에 릴레이가 어떻게 동작하고 있는지 다시 보겠습니다.

//MainSocketHandler.java

// broadcastUserList() — 모든 세션에 전송
sessions.values().forEach(user -> {
    user.getSession().sendMessage(new TextMessage(message));
});

// handleBinaryMessage() — 타겟에 전송
targetUser.getSession().sendMessage(message);

C가 접속하면 afterConnectionEstablished()에서 broadcastUserList()가 호출됩니다. 이때 B의 세션에도 sendMessage()를 호출합니다. A가 B에게 바이너리를 전송하는 sendMessage()가 아직 진행 중인데 broadcastUserList()sendMessage()가 동시에 B의 세션에 접근하는 것입니다.

// org.apache.tomcat.websocket.WsRemoteEndpointImplBase

private void sendMessageBlock(byte opCode, ByteBuffer payload, boolean last)
        throws IOException {
    while (payload.hasRemaining()) { // 메시지 크기에 따라 반복
        outputBuffer.put(payload); // payload에서 outputBuffer 크기만큼 복사
        if (!outputBuffer.hasRemaining()) {
            flush(); // TCP 소켓으로 전송
        }
    }
}

이 while 루프가 도는 동안, 다른 스레드가 같은 세션에 sendMessage()를 호출하면 프레임이 섞입니다

RFC 6455 Section 5.4에 따르면, continuation 프레임 시퀀스 중간에 데이터 프레임(text/binary)이 끼어드는 것은 프로토콜 위반입니다. 허용되는 것은 control 프레임(ping/pong/close)인데 브라우저가 이를 감지하여 1002 Protocol Error로 연결을 종료하는 상황이었습니다.

또한 Spring 공식 문서에서도 JSR-356 스펙 자체가 동시 전송을 허용하지 않아 동기화가 필수적이다라고 경고하고있습니다.

Note: The underlying standard WebSocket session (JSR-356) does not allow concurrent sending. Therefore, sending must be synchronized. To ensure that, one option is to wrap the WebSocketSession with the ConcurrentWebSocketSessionDecorator.

alt text

해결: 세션별 락 도입

따라서 같은 Websocket 세션에 두개 이상의 쓰레드가 동시에 쓰기 작업을 하지 못하도록 하도록 세션 ID별로 락 객체를 만들어 synchronized로 동시 쓰기를 막는 메서드를 작성했습니다.

private final Map<String, Object> sessionLocks = new ConcurrentHashMap<>();

private void sendSafe(WebSocketSession session, WebSocketMessage<?> message) {
    Object lock = sessionLocks.computeIfAbsent(session.getId(), k -> new Object());
    synchronized (lock) {
        try {
            if (session.isOpen()) {
                session.sendMessage(message);
            }
        } catch (Exception e) {
            logger.error("Error sending to {}: {}", session.getId(), e.getMessage(), e);
        }
    }
}

같은 세션 ID에 대해 동일한 락 객체를 공유하므로, 한 스레드가 sendMessage()를 호출 중이면 다른 스레드는 synchronized 블록에서 대기합니다. 서로 다른 세션끼리는 락이 독립적이므로 병렬 전송에 영향을 주지 않습니다.

기존의 모든 session.sendMessage() 호출을 sendSafe()로 교체했습니다.

Before

// broadcastUserList()
sessions.values().forEach(user -> {
    user.getSession().sendMessage(new TextMessage(message));
});

// handleBinaryMessage()
targetUser.getSession().sendMessage(message);

After

// broadcastUserList()
sessions.values().forEach(user -> sendSafe(user.getSession(), msg));

// handleBinaryMessage()
sendSafe(target.getSession(), new BinaryMessage(data));

논의점

/**
 * Send a WebSocket message: either TextMessage or BinaryMessage.
 *
 * Note: The underlying standard WebSocket session (JSR-356) does
 * not allow concurrent sending. Therefore, sending must be synchronized.
 * To ensure that, one option is to wrap the WebSocketSession with the
 * ConcurrentWebSocketSessionDecorator.
 */
void sendMessage(WebSocketMessage<?> message) throws IOException;

Spring의 WebSocketSession.sendMessage() Javadoc을 보면, 동시 전송 문제에 대해 ConcurrentWebSocketSessionDecorator를 권장하고 있습니다.

현재 Netdrops에서는 동시 충돌이 파일 전송 중 userList 브로드캐스트 한 가지로 제한되고 동시 접속 규모도 소수이기 때문에 세션 래핑 없이 sendSafe() 메서드로 해결할 수 있었습니다. 하지만 동시 접속이 늘어나거나 브로드캐스트 빈도가 높아진다면 ConcurrentWebSocketSessionDecorator로의 전환을 검토해야 할 것 같습니다.

Ref

Websocket Spring docs