웹소켓
팀 프로젝트 중 채팅 기능을 넣자는 이야기가 나왔다.
전에 프로젝트 할때는 웹소켓이 별로 끌리지 않았고 힘들어보여서 다른 팀원에게 넘겼었다.
하지만 이번엔 머리에 든게 꽤 있으니 그리 어렵게 하지 않을거라는 생각이 들었다.
하다가 정 안되면 저번 프로젝트의 코드를 참고하면 되겠지 싶어서 해보았다.
이번 글에서는 메시지를 저장하지는 않고 웹소켓을 통해 채팅하기 까지만 구현한다.
사용 기술
프론트엔드는 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);
}
}
}