FireDrago

[치지직 채팅] 2. 치지직 채팅 웹소켓 접속하기 본문

프로젝트

[치지직 채팅] 2. 치지직 채팅 웹소켓 접속하기

화이용 2025. 12. 9. 16:45

본 포스팅은 개인적인 학습 목적으로 작성되었으며, 분석된 내용은 서비스 업데이트에 따라 언제든 변경될 수 있습니다

침착맨 방송 채팅창 접속한 모습


1.  `Java Websocket` vs `Spring Websocket`

  • Java 기본 웹소켓 라이브러리
  • 직접 구현해야 할 점이 많고 스프링 통합 기능 지원되지 않음
  • 로우레벨에서 ‘웹소켓’ 자체를 학습하는데 유리하기 때문에 선택함

2. `onOpen` 이벤트 처리

2.1 WebSocket 핸드셰이크 (HTTP -> WS 전환)

  • HTTP 형식으로 Websocket 연결요청 전송 특정 헤더를 포함한다.
  • Websocket 전환이 승인되면, 서버는 다음과 같은 응답을 반환한다.

2.2 onOpen 콜백 호출 및 프로토콜 전환 완료

  1. 클라이언트(java-websocket)는 서버로부터 상태 코드 `101 Switching Protocols`와
    유효한 `Sec-WebSocket-Accept` 헤더를 포함한 응답을 수신한다.
  2. 클라이언트는 이 응답을 확인하고 핸드셰이크가 성공적으로 완료되었다고 판단한다.
  3. 이 시점에서 HTTP 연결은 종료되고, 동일한 TCP/IP 소켓 연결이 WebSocket 프로토콜을 사용하는
    영구적인 양방향 통신 채널로 전환된다.
  4. `onOpen(ServerHandshake handshakeData)` 메소드가 호출된다.
  5. 이 모든 과정을 `java-websocket` 라이브러리는 위의 과정을 자동으로 처리해준다.
    우리는 onOpen메서드만 정의하면 된다.

2.3 코드로 구현하기

public class ChzzkWebsocketClient extends WebSocketClient {

    private final String chatChannelId;
    private final String accessToken;
    // 무거운 ObjectMapper는 한 번만 생성해서 재사용 (메모리 절약)
    private final ObjectMapper objectMapper = new ObjectMapper();

    private ScheduledExecutorService pingScheduler;

    public ChzzkWebsocketClient(URI serverUri, String chatChannelId, String accessToken) {
		    // URI를 외부에서 주입받아야 한다.
        super(serverUri);
        this.chatChannelId = chatChannelId;
        this.accessToken = accessToken;
    }

    @Override
    public void onOpen(ServerHandshake handshakeData) {
        // 1. 인증 패킷 전송
        String authPacket = createAuthPacket(chatChannelId, accessToken);
        if (authPacket != null) {
            this.send(authPacket);
        }

        // 2. 스케줄러 시작 (20초마다 능동적으로 생존 신고)
        pingScheduler = Executors.newSingleThreadScheduledExecutor();
        // [수정 2] 메서드 레퍼런스 활용
        pingScheduler.scheduleAtFixedRate(this::sendActivePing, 20, 20, TimeUnit.SECONDS);
    }
    
    private String createAuthPacket(String chatChannelId, String accessToken) {
        try {
            ChzzkAuthRequest.AuthRequestBody body = new ChzzkAuthRequest.AuthRequestBody(
                    null,
                    2001,
                    "Google Chrome/142.0.0.0",
                    accessToken,
                    "4.9.3",
                    "ko",
                    "macOS/10.15.7",
                    "Asia/Seoul",
                    "READ"
            );
            ChzzkAuthRequest request = new ChzzkAuthRequest(
                    "3", 100, "game", chatChannelId, 1, body
            );
            return this.objectMapper.writeValueAsString(request);
        } catch (JsonProcessingException e) {
            throw new RuntimeException("JSON 변환 실패", e);
        }
    }
    
    private void sendActivePing() {
        // 만약 치지직이 cmd: 0을 무시한다면 cmd: 10000으로 변경해서 테스트 필요.
        String pingPacket = "{\"cmd\": 0, \"ver\": 2}";
        try {
            this.send(pingPacket);
        } catch (Exception e) {
            System.err.println("Heartbeat 전송 실패");
        }
    }
 }
  • `onOpen` 메서드는 WebSocket 통신 채널이 확립된 직후에 호출되는 라이프사이클의 시작점이다.
    따라서 이 메서드에서는 연결을 유효하게 만들고 유지하기 위한 필수 작업을 한다
  • `org.java_websocket.client.WebSocketClient`를 상속받아 구현한다.
  1. 치지직 인증 형식에 맞춰 dto를 생성하고 `createAuthPacket` 인증패킷을 전송한다.
    `ObjectMapper` 객체는 생성 부담이 큰 편이므로 한번만 생성해서 재사용 한다.
  2. `pingScheduler = Executors.newSingleThreadScheduledExecutor()`
    별도의 하트비트 전송을 위한 백그라운드 스레드를 생성한다.
    메시지를 수신 하고 파싱하는 메인 스레드가 블로킹되는 상황을 방지하기 위함이다.
  3. `pingScheduler.scheduleAtFixedRate(this::sendActivePing, 20, 20, TimeUnit.SECONDS)`
    핑을 위한 별도의 스레드가 onOpen이 호출되고 20초 뒤에 20초의 간격으로 매번 실행하도록 설정했다.

3. `onMessage` 이벤트 처리

  • `onMessage` 메서드는 웹소켓 이벤트 처리 스레드에 의해 호출되며, 서버가 보낸 메시지를 처리하는 역할을 한다.
    이 단계의 핵심은 수신된 일반 문자열(JSON)을 해석하여 메시지 유형에 따라 적절히 분기하고 처리하는 것이다.
@Override
public void onMessage(String message) {
    try {
        JsonNode node = objectMapper.readTree(message);
        int cmd = node.get("cmd").asInt();

        switch (cmd) {
            case 0: // 서버가 "살아있니?" (Ping) 물어봄
                System.out.println("<<< 서버 Ping 수신 (cmd: 0)");
                sendPong(); // "응 살아있어" (Pong) 대답
                break;

            case 10000: // [서버 -> 나] ㅇㅇ 너 살아있네 (내 핑에 대한 대답)
                // 20초마다 보낸 sendActivePing()에 대해 서버가 응답.
                System.out.println("<<< [수신] 서버 Pong(cmd: 10000) - 내 핑에 대답함");
                break;

            case 93101: // 채팅 메시지
                JsonNode bdy = node.get("bdy");

                // 치지직은 한 패킷에 여러 채팅이 묶여서 올 수 있다 (Array 구조)
                if (bdy != null && bdy.isArray()) {
                    for (JsonNode chatItem : bdy) {
                        // 1. 채팅 내용 추출
                        String messageType = chatItem.path("msgTypeCode").asText();
                        String content = chatItem.path("msg").asText();

                        // (1번: 일반 채팅, 10번: 후원 채팅 등. 일단 출력)
                        System.out.println("[****] " + content);
                    }
                }
                break;

            default:
                break;
        }
    } catch (Exception e) {
        System.err.println("메시지 파싱 실패: " + e.getMessage());
    }
}
  • `objectMapper.readTree(message)`: 수신된 일반 String 메시지를 `JsonNode` 객체로 즉시 파싱한다.
    이 작업은 웹소켓 메인 스레드에서 수행되므로, 파싱이 느려지면 다른 이벤트 처리가 지연될 수 있다.
  • `cmd = node.get("cmd").asInt()`: 치지직 서버는 cmd 필드를 사용하여 메시지 유형을 정의한다.
    우리는 이 값을 기준으로 switch 문을 사용해 Heartbeat 응답, 자체 핑 확인, 채팅 메시지 처리 등
    모든 비즈니스 로직을 수동으로 분기해야 한다.
  • `if (bdy.isArray())`: 치지직 서버는 한 번의 웹소켓 메시지에 여러 개의 채팅 메시지를 묶어서 보낼 수 있다.
    `bdy`가 배열인지 확인하고 `for`루프를 돌면서 각 채팅 아이템을 개별적으로 처리해야 한다.

4. `onClose`이벤트 처리: 자원 해제 및 정리

  • `onClose` 메서드는 웹소켓 연결이 정상적이든 비정상적이든 종료되었을 때 호출되는 콜백이다.
    `onOpen`에서 생성했던 백그라운드 스레드 자원을 깨끗하게 해제해야 한다.
@Override
public void onClose(int code, String reason, boolean remote) {
    System.out.println(">>> 연결 끊김: " + reason);
    // 스케줄러 종료 (자원 정리)
    if (pingScheduler != null && !pingScheduler.isShutdown()) {
        pingScheduler.shutdown();
    }
}
  • `if (pingScheduler != null && !pingScheduler.isShutdown())`:
    스케줄러 객체가 null이 아니고, 이미 종료되지 않은 경우에만 shutdown()을 호출하여
    NullPointerException이나 불필요한 호출을 방지한다.
  • 메모리 누수 방지: 이 작업을 생략하면 백그라운드 스레드가 계속 메모리에 남아
    메모리 누수 및 불필요한 자원 점유를 유발한다.