채팅 기능이 각종 앱에서 다양하게 쓰이기 때문에, 스프링 + 웹소켓을 활용하여 채팅 기능을 만들어보면 좋을 것 같아 공부중이다.
그 과정에서 왜 이렇게 만들었고, 어떤 개념들이 있는지 정리해보고자 한다.
채팅기능을 만들면서 어떤 기능들을 넣을 것인가 생각나는대로 다 적어보았다.
채팅을 구현할 때는 당연히 유저 인증이 필요하지만, 빠르게 채팅만 공부해보고 싶어서 시큐리티는 건너뛰고 간단한 유저 도메인만 구현할 것이다.
// WebSocketConfig.java
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override
public void configureMessageBroker(MessageBrokerRegistry config) {
// 서버 -> 클라이언트
// 구독자가 해당 주소를 구독하고 있다면, 발행자가 해당 경로로 메세지를 보내면 메세지 브로커가 메세지를 구독자들에게 전달한다.
config.enableSimpleBroker("/sub"); // 구독 주소 prefix
// 클라이언트 -> 서버
// 클라이언트가 해당 경로로 메세지를 보내면, 메세지는 @MessageMapping 으로 이동해 가공할 수 있게 한다.
config.setApplicationDestinationPrefixes("/pub"); // 메시지 보낼 prefix
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 웹소켓 연결을 위한 엔드포인트
registry.addEndpoint("/ws-chat")
.setAllowedOriginPatterns("*")//cors 허용
.withSockJS(); // websocket을 지원하지 않는 브라우저를 위해 SockJS 활성화
}
}
STOMP를 활용하기 때문에 WebSocketMessageBrokerConfigurer 인터페이스를 사용해서 설정파일을 구성했다 (@EnableWebSocketMessageBroker도 사용해야함 !) .
여기서 가장 중요한 것은 다음과 같다.

추후 수정할 수도 있겠지만 초기에는 이렇게 구성했다. 디비에 저장하기 위해서 jpa를 사용하였다.
한 유저가 여러개의 채팅방에 들어갈 수 있고, 또 한 채팅방에 여러 명의 유저가 있을 수 있기 때문에 다대다 관계를 ChatRoomUser라는 중간 테이블을 넣어서 해결하였다.
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Chat {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "chat_id")
private Long id;
@Enumerated(EnumType.STRING)
private MessageType messageType;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "room_id")
private ChatRoom chatRoom;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "sender_id")
private User sender;
private String content;
private LocalDateTime sentAt;
@Builder
public Chat(MessageType messageType, ChatRoom chatRoom, User sender, String content, LocalDateTime sentAt){
this.messageType = messageType;
this.chatRoom = chatRoom;
this.sender = sender;
this.content = content;
this.sentAt = sentAt;
}
// private boolean isRead;
}
@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatRoom {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "chatRoom_id")
private Long id;
@Column(nullable = false)
private String roomName;
/*
cascade를 사용해서 저장하는게 적절해보이는데, cascade를 사용하면 chatRoomUser 테이블에 roomId가 null인 컬럼들이 들어가서
임시적으로 ChatService에서 직접 일일이 저장하는 식으로 구현
// @OneToMany(mappedBy = "room", cascade = CascadeType.ALL)
*/
@OneToMany(mappedBy = "room")
private List<ChatRoomUser> userList;
@Builder(access = AccessLevel.PRIVATE)
private ChatRoom(List<ChatRoomUser> userList, String roomName){
this.userList = userList;
this.roomName = roomName;
}
public static ChatRoom createChatRoom(List<ChatRoomUser> userList, String roomName){
return ChatRoom.builder()
.userList(userList)
.roomName(roomName)
.build();
}
}
@Entity
@Getter
@NoArgsConstructor
public class ChatRoomUser {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "room_id")
private ChatRoom room;
public ChatRoomUser(User user) {
this.user = user;
} // id list에서 ChatRoomUser로 매핑하기 위한 메소드
public static ChatRoomUser of(ChatRoom room, User user){
return new ChatRoomUser(room, user);
}
private ChatRoomUser(ChatRoom room, User user){
this.room = room;
this.user = user;
}
}
또 dto와 유저엔티티도 구현하였다
// ChatController
@PostMapping("/create")
public ResponseEntity<ChatRoomDto> createChatRoom(@RequestBody CreateChatRoomRequestDto request){
return ResponseEntity.status(HttpStatus.OK).body(chatService.createChatRoom(request.participantsId(), request.roomName()));
}
// ChatService
public ChatRoomDto createChatRoom(List<Long> participantsId, String roomName) {
List<User> userList = userRepository.findAllById(participantsId);
// 채팅방 생성
ChatRoom chatRoom = ChatRoom.createChatRoom(userListToChatRoomUserlist(userList), roomName);
for(User user : userList){
ChatRoomUser chatRoomUser = ChatRoomUser.of(chatRoom, user);
chatRoomUserRepository.save(chatRoomUser);
}
chatRoomRepository.save(chatRoom);
return ChatRoomDto.from(chatRoom);
}
private List<ChatRoomUser> userListToChatRoomUserlist(List<User> users){
return users.stream()
.map(ChatRoomUser::new)
.toList();
}
"/create" 엔드포인트로 요청이 오면 채팅방을 만들어 디비에 저장하도록 하였다.
ChatRoom, ChatRoomUser에 각각 저장하도록 하였으며, 만들때 참가자 리스트를 받아서 만들기 때문에 for문을 돌려 ChatRoomUser를 유저당 각각 저장하도록 하였다 .
(Cascade를 활용해서 엔티티에서 처리할 수 있을 것 같은데 생각하는대로 잘 안되서 우선 이렇게 구현하였다..)
// ChatController
@MessageMapping("/chat/send")
public void sendMessage(@RequestBody ChatMessageDto chatMessageDto){
chatService.sendMessage(chatMessageDto);
}
// ChatService
public void sendMessage(ChatMessageDto chatMessageDto){
// db 저장
ChatRoom room = chatRoomRepository.findById(chatMessageDto.chatRoomId())
.orElseThrow(() -> new CustomException(ErrorCode.CHATROOM_NOT_FOUND));
User sender = userRepository.findById(chatMessageDto.senderId())
.orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND));
Chat message = Chat.builder(
.chatRoom(room)
.sender(sender)
.content(chatMessageDto.content())
.sentAt(chatMessageDto.sentAt()) // front에서 sentAt 보내준다고 가정
.build();
chatRepository.save(message);
// 전송
template.convertAndSend("/topic/chat/room" + chatMessageDto.chatRoomId(), chatMessageDto);
}
메세지 전송을 테스트 해보기 위해 다음 페이지를 활용했다.
https://jiangxy.github.io/websocket-debug-tool/
다음과 같이 작성하면 테스트가 가능하다.
왜 websocket을 끝에 붙여야하는지는 다음 문서에 적혀있다 spring SockJS Fallback
이후 connect를 눌러 다음과 같이 작성해준다.
메세지 보내기
보낼때는 dto에 맞게끔 json을 보내주면 된다.
만약 security 설정을 했다면 헤더에서 토큰 설정을 활용하면 될 것 같다
메세지 받기
이렇게 메세지가 온 것을 확인할 수 있다.

db에도 저장된 것을 확인할 수 있다.