웹소켓

팀 프로젝트 중 채팅 기능을 넣자는 이야기가 나왔다.

전에 프로젝트 할때는 웹소켓이 별로 끌리지 않았고 힘들어보여서 다른 팀원에게 넘겼었다.

하지만 이번엔 머리에 든게 꽤 있으니 그리 어렵게 하지 않을거라는 생각이 들었다.

하다가 정 안되면 저번 프로젝트의 코드를 참고하면 되겠지 싶어서 해보았다.

이번 글에서는 메시지를 저장하지는 않고 웹소켓을 통해 채팅하기 까지만 구현한다.


사용 기술

프론트엔드는 Next.js / 백엔드는 스프링을 이용하여 채팅 기능을 구현해볼 것이다.

채팅 기능에만 집중해서 만들것이며 기본적인 CORS같은건 굳이 설명하지 않는다.

어차피 설정해줄 것도 origin, credential 설정 뿐이기도 하다.

이 글에는 백엔드 사이드 내용만 적고 프론트엔드 쪽은 이 글로 분리하려고 한다.

웹 소켓 통신은 프론트/백엔드 모두 알아야 연동하기 쉬우므로 프론트쪽 글과 함께 읽는걸 추천한다.

  • Spring Boot 3.2.4 / gradle-kotlin
  • Java 17
  • STOMP(프론트엔드에서는 구현체를 SockJS로 쓰긴 했음)

1. 웹소켓 관련 설정 해주기

WebSocket 의존성 추가

// build.gradle.kts
implementation("org.springframework.boot:spring-boot-starter-websocket")

WebSocketConfig 설정

채팅 기능을 구현하려면 MessageBroker가 필요하다.

웹소켓을 사용하기로 했으므로 WebSocketMessageBrokerConfigurer 설정을 해준다.

프론트엔드에서 구현체를 SockJS로 써서 아래처럼 설정한다.

+ 추가 ) 만약 STOMP Client를 사용한다면 withSockJS() 부분을 제거해야한다.

@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {

    private final AppConfig appConfig;

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/topic"); // "/topic"을 구독하는 클라이언트에게 메시지 전송
        registry.setApplicationDestinationPrefixes("/app"); // 프론트에서 붙여줄 엔드포인트의 prefix
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws") // 백엔드 URL의 엔드포인트
            .setAllowedOrigins(appConfig.getFrontUrl())  // 허용할 origin
            .withSockJS();  // SockJS로 통신함.STOMP Client를 사용하면 이 부분을 제거한다.
    }
}

2. 웹소켓을 이용한 채팅 구현

2-1. 도메인 객체 구현

연습용으로는 DTO로 만들어도 되지만 아마 저장까지 해야할것 같아서 엔티티로 만들었다.

단순하게 발송자, 내용, 메시지 타입만 설정해두었다.

ChatMessage

package com.pawland.chat.domain;

import com.pawland.user.domain.User;
import jakarta.persistence.*;
import lombok.*;

@Getter
@Entity
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatMessage {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String content;

    private String sender;

    private MessageType type;

    @Builder
    public ChatMessage(String content, String sender, MessageType type) {
        this.content = content;
        this.sender = sender;
        this.type = type;
    }
}

MessageType

아래는 프론트와 주고 받을 메시지 타입이다.

public enum MessageType {
    CHAT,
    JOIN,
    LEAVE
}

2-2. 컨트롤러 구현

컨트롤러 설정은 아래와 같다.

@Payload 애노테이션으로 프론트에서 JSON으로 받을 데이터를 DTO로 역직렬화 할 수 있다.

@DestinationVariable 애노테이션으로 HTTP요청의 @PathVariable 처럼 사용할 수 있다.

@Slf4j
@RestController
@RequiredArgsConstructor
public class ChatController {

    @MessageMapping("/chat.addUser/{roomId}") // 프론트가 publish 할 때 사용할 백엔드 엔드포인트
    @SendTo("/topic/{roomId}") // 백엔드에서 웹소켓으로 프론트에 메시지를 보내주는 구독 url
    public ChatMessage addUser(@Payload ChatMessage chatMessage, @DestinationVariable String roomId, SimpMessageHeaderAccessor headerAccessor) {
        // Add username in websocket session
        log.info("[유저 입장] chatMessage = {}, roomId = {}", chatMessage, roomId);
        headerAccessor.getSessionAttributes().put("username", chatMessage.getSender());
        headerAccessor.getSessionAttributes().put("roomId", roomId);
        return chatMessage;
    }

    @MessageMapping("/chat.sendMessage/{roomId}") // 프론트가 publish 할 때 사용할 백엔드 엔드포인트
    @SendTo("/topic/{roomId}")   // 백엔드에서 웹소켓으로 프론트에 메시지를 보내주는 구독 url
    public ChatMessage sendMessageToOne(@Payload ChatMessage chatMessage, @DestinationVariable String roomId) {
        log.info("[메시지 전송 to {}번 방] chatMessage = {}", roomId, chatMessage);
        log.info("id = {}", roomId);
        return chatMessage;
    }
}

 

본인이 작성한 엔드포인트에 대한 설명은 아래와 같다.

/app/chat.addUser/{roomId}

프론트에서는 위 엔드포인트로 입장 알림을 보낸다.

이 때 headerAccessor에 username과 roomId 값을 넣는다.

이는 이후 한쪽이 연결을 종료했을 때 상대방에게 퇴장 메시지를 보내기 위한 EventListener에서 사용한다.

/app/chat.addUser/{roomId}

프론트에서 입력한 채팅 메시지를 /topic/{roomId} 토픽을 구독하고 있는 모든 사용자에게 전송한다.

2-3. WebSocketListener 구현

한쪽이 연결을 종료했을 때 상대방에게 퇴장 메시지를 보내는 EventListener를 작성한다.

SessionDisConnectEvent를 이용하여 구현하였다.

프론트에서는 남은 한 쪽이 이걸 받고 메시지를 확인할 수 있게 출력하면 된다.

@Component
@RequiredArgsConstructor
@Slf4j
public class WebSocketListener {

    private final SimpMessageSendingOperations messageTemplate;

    @EventListener
    public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
        StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
        String username = (String) headerAccessor.getSessionAttributes().get("username");
        String roomId = (String) headerAccessor.getSessionAttributes().get("roomId");
        if (username != null) {
            log.info("User disconnected");
            ChatMessage chatMessage = ChatMessage.builder()
                .type(MessageType.LEAVE)
                .sender(username)
                .build();
            messageTemplate.convertAndSend("/topic/" + roomId, chatMessage);
        }
    }
}

+ Recent posts