코딩과 결혼합니다

[Game_Crew] WebSocket으로 1대1 채팅기능 구현하기(2) 본문

코딩과 매일매일♥/Game_Crew

[Game_Crew] WebSocket으로 1대1 채팅기능 구현하기(2)

코딩러버 2023. 11. 12. 15:41
728x90

✏️WebSocket

서버와 클라이언트 사이에 소켓 커넥션을 유지하면서 양방향 통신이 가능한 기술이다.

 

동작

  1. 클라이언트에서 서버에 HTTP 프로토콜로 핸드셰이크 요청을 한다. 
  2. 서버에서는 Status Code 101로 응답해 준다.
  3. 초기 통신을 시작한 후, 웹소켓 프로토콜로 변환하여 데이터를 전송한다.

✏️STOMP

Simple Text Oriented Messaging Prorocol의 약자로, 메시지 전송을 효율적으로 하기 위한 프로토콜로, pub/sub 기반으로 동작한다.  메시지 송신, 수신에 대한 처리를 명확하게 정의할 수 있고, WebsocketHandler를 직접 구현할 필요 없이@MessageMapping 어노테이션을 사용해서 메시지 발행 시 엔드포인트를 별도로 분리해서 관리할 수 있다.


실시간 채팅 기능 도입
함께 게임을 하고 싶은 사람과 실시간으로 메시지를 주고받을 수 있어 빠른 의사소통이 가능해져 사용자 경험을 향상한다. 또한 사용자들이 실시간으로 소통하고 정보를 공유할 수 있어 사용자의 참여도를 높일 수 있다.

WebSocket 사용 이유
WebSocket은 HTTP와 달리 양방향 통신을 지원하여 실시간 채팅과 같은 상호작용이 필요한 기능을 구현하는데 적합하다. WebSocket은 연결이 유지되는 동안 실시간으로 데이터를 주고받을 수 있으며, 한 번의 핸드셰이크로 연결을 유지하므로 HTTP와 달리 반복적인 요청에 대한 오버헤드가 없다. 따라서 대량의 실시간 트래픽을 처리하는데 더욱 효율적이다.

STOMP를 사용한 이유
1. 메시지 브로커 기능 

publish/ subscribe 패턴 즉, 특정 주제를 구독하고 있는 모든 클라이언트에게 메시지를 전달하는 기능을 제공한다. 이를 통해 서버와 클라이언트 간의 메시지 라우팅을 쉽게 처리할 수 있다.
2. 프로토콜의 단순성
STOMP는 텍스트 기반의 프로토콜로, 헤더와 바디로 구성된 간단한 구조를 가진다. 이로 인해 개발자가 프로토콜을 이해하고 사용하는 데 있어 낮은 진입 장벽을 가진다.
3. Spring 지원
@MessageMapping과 같은 어노테이션을 통해 쉽게 메시지 핸들링을 구현할 수 있다.

🔸bulid.gradle

//websocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'
implementation 'org.webjars:webjars-locator-core'
implementation 'org.webjars:sockjs-client:1.5.1'
implementation 'org.webjars:stomp-websocket:2.3.4'

🔸WebSocketConfig

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

    private final WebSocketInterceptor webSocketInterceptor;

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/chat")
                .setAllowedOriginPatterns("*")
                .withSockJS();
    }

    @Override
    public void configureMessageBroker(MessageBrokerRegistry registry) {
        registry.enableSimpleBroker("/sub"); //구독
        registry.setApplicationDestinationPrefixes("/pub"); //메세지
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(webSocketInterceptor);
    }
}
  • registerStompEndpoints : 클라이언트가 웹소켓 서버에 연결하는 데 사용할 앤드포인트( "/chat" )를 등록 
  • configureMessageBroker 
    • "/sub" : 클라이언트가 메시지를 구독할 수 있는 목적지 정의
    • "/pub" : 클라이언트가 메시지를 보낼 수 있는 목적지 정의
  • configureClientInboundChannel : 클라이언트에서 서버로 오는 메시지를 처리하는 데 사용되는 채널에 인터셉터 등록
    • 메시지를 전처리하거나 후처리 할 수 있다.

🔸WebSocketInterceptor

@Component
@RequiredArgsConstructor
public class WebSocketInterceptor implements ChannelInterceptor {
    @Override
    public Message<?> preSend(Message<?> message, MessageChannel channel) {

        StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);

        if (StompCommand.CONNECT == accessor.getCommand()) {
            System.out.println("웹 소켓 접속함.");
        }
        return message;
    }
}

 


웹소켓 연결이 시작되었을 때 로그를 남기는 역할을 하며, 필요한 경우 preSend 메서드에서 추가적인 로직을 구현하여 메시지 전송 전에 다른 작업을 수행할 수 있다.


🔸ChatController

@Controller
@RequiredArgsConstructor
public class ChatController {

    private final SimpMessageSendingOperations template;

    @MessageMapping("/send/message")
    public void sendMessage(@Payload ChatMessage message) {
        template.convertAndSend("/sub/" + message.roomKey(), message);
    }
}
  • SimpMessageSendingOperations 인터페이스는 메시지를 전송하는 데 사용된다.
  • @Payload로 메시지 본문을 ChatMessage 객체로 변환하여 메서드의 매개변수로 받아온다.
  • 메시지는 "/sub/" +  방의 키 값을 목적지로 하여 전송되며, 메시지의 내용은 message 객체이다.

🔸ChatMessage

public record ChatMessage(String roomKey, String nickname, String msg){}

웹소켓 연결과 메시지를 주고받는 것은 구현하였고, 이제 chatRoom을 생성하고 자신이 참가하고 있는 방 목록을 조회할 수 있도록 한다. 나는 chatRoom을 DB에 저장하고 조회하려는 user의 Id값이 포함된 것들을 찾아서 List로 반환하는 방식으로 만들었다.

 

🔸ChatRoom

@Entity
@AllArgsConstructor
@NoArgsConstructor
@Builder
@Getter
public class ChatRoom extends Auditing {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column
    private String roomKey;

    @Column
    private Long sender;

    @Column
    private Long receiver;
}

 

+ Auditing 클래스를 extends 하여 생성된 시간과 수정된 시간도 포함해 주었다.

 

🔸ChatRoomController

@RestController
@RequiredArgsConstructor
@RequestMapping("/chatroom")
public class ChatRoomController {
    private final ChatRoomService chatRoomService;

    @PostMapping("/{receiverId}")
    public MessageResponseDto createChatRoom(@AuthenticationPrincipal UserDetailsImpl userDetails,
                                             @PathVariable Long receiverId){

        Long senderId = userDetails.getUser().getUserId();
        return chatRoomService.createChatRoom(senderId, receiverId);
    }

    @GetMapping("")
    public ChatRoomsResponseDto getChatRooms(@AuthenticationPrincipal UserDetailsImpl userDetails,
                                             @RequestParam int page,
                                             @RequestParam int size){
        Long userId = userDetails.getUser().getUserId();
        return chatRoomService.getChatRooms(userId, page-1, size);
    }
}

 

  • 채팅방을 생성할 때 그 채팅방을 생성하는 user(senderId)의 정보와, 채팅방에 초대되는 user(receiverId)를 받는다.
  • 채팅방을 조회할 때에는 조회하려는 user의 정보를 가져오고 페이징 처리도 해준다.

🔸ChatRoomService(createChatRoom)

@Service
@RequiredArgsConstructor
public class ChatRoomService {

    private final ChatRoomRepository chatRoomRepository;
    private final UserRepository userRepository;

    public MessageResponseDto createChatRoom(Long senderId, Long receiverId) {
        String roomId = generateRoomId(senderId, receiverId);

        Optional<User> existingUser = userRepository.findById(receiverId);
        if (existingUser.isEmpty()){
            throw new CustomException(ErrorMessage.NON_EXISTENT_USER, HttpStatus.BAD_REQUEST);
        }

        Optional<ChatRoom> existingRoom = chatRoomRepository.findByRoomKey(roomId);
        if (existingRoom.isPresent()) {
            throw new CustomException(ErrorMessage.DUPLICATE_CHATROOM_EXISTS, HttpStatus.CONFLICT);
        }

        if (senderId.equals(receiverId)){
            throw new CustomException(ErrorMessage.CANNOT_CHOOSE_YOURSELF, HttpStatus.UNPROCESSABLE_ENTITY);
        }

        ChatRoom newRoom = ChatRoom.builder()
                .roomKey(roomId)
                .sender(senderId)
                .receiver(receiverId)
                .build();

        chatRoomRepository.save(newRoom);

        return new MessageResponseDto(Message.CREATE_CHAT_ROOM + " : " + roomId, HttpStatus.OK);
    }

    private String generateRoomId(Long senderId, Long receiverId) {
        if (senderId.compareTo(receiverId) > 0) {
            return receiverId + "-" + senderId;
        } else {
            return senderId + "-" + receiverId;
        }
    }

 

  • generateRoomId : senderId와 receiverId로 unique한 roomId를 만들어준다. (ex 1 - 21)
  • 상대 유저가 존재하지 않거나, 같은 이름의 room이 존재할 때, 그리고 자기 자신과 채팅방을 만들려 할 때 예외처리한다.
  • 채팅룸이 성공적으로 만들어지면 200의 상태코드와 채팅방이 잘 만들어졌다는 메시지 + 채팅방 이름을 반환한다.

🔸ChatRoom(getChatRooms)

    public ChatRoomsResponseDto getChatRooms(Long userId, int page, int size) {
        String userIdStr = String.valueOf(userId);
        Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "modifiedAt"));
        Page<ChatRoom> pageResult = chatRoomRepository.findByRoomKeyContaining(userIdStr, pageable);


        List<ChatRoomsResponseDto.ChatRoomsResultDto> result = pageResult.getContent().stream()
                .map(chatRoom -> {
                    Long otherUserId = chatRoom.getSender().equals(userId) ? chatRoom.getReceiver() : chatRoom.getSender();
                    Optional<User> otherUserOpt = userRepository.findByUserId(otherUserId);

                    if (!otherUserOpt.isPresent()) {
                        throw new CustomException(ErrorMessage.NOT_FOUND_USERS, HttpStatus.NOT_FOUND);
                    }

                    User otherUser = otherUserOpt.get();
                    return new ChatRoomsResponseDto.ChatRoomsResultDto(chatRoom.getRoomKey(), otherUser.getNickname(),otherUser.getUserImg(), chatRoom.getModifiedAt());
                })
                .collect(Collectors.toList());

        return new ChatRoomsResponseDto(
                Message.SEARCH_CHAT_ROOM,
                pageResult.getTotalPages(),
                pageResult.getTotalElements(),
                pageResult.getSize(),
                result
        );
    }
}

 

  • Page<ChatRoom> pageResult : Pageble 객체를 이용해 사용자 ID가 포함된 채팅방을 찾아 그 목록을 조회한다.
  • 스트림을 통해 상대방의 사용자 ID를 찾는다. 만약 채팅방의 발신자 ID가 사용자의 ID와 같다면, 수신자 ID가 상대방의 ID가 된다. 
  • 상대방 사용자의 정보를 조회하여 만약 그 정보다 없다면, 사용자를 찾을 수 없다는 예외를 발생시킨다.

 

🔸ChatRoomResponseDto

@Getter
@Setter
public class ChatRoomsResponseDto {
    private String msg;
    private ChatRoomPageable pageable;
    private List<ChatRoomsResultDto> result;

    public ChatRoomsResponseDto(
            String msg,
            int totalPages,
            long totalElements,
            int size,
            List<ChatRoomsResultDto> chatRooms
    ){
        this.msg = msg;
        this.pageable = new ChatRoomPageable(totalPages, totalElements, size);
        this.result = chatRooms;
    }

    @Getter
    public static class ChatRoomsResultDto {
            private String roomKey;
            private String nickname; //상대방 닉네임
            private String userImg; //상대방 프로필 사진
            @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
            private LocalDateTime modifiedAt;
        public ChatRoomsResultDto(String roomKey, String nickname, String userImg, LocalDateTime modifiedAt){
            this.roomKey = roomKey;
            this.nickname = nickname;
            this.userImg = userImg;
            this.modifiedAt = modifiedAt;
        }
    }

    @Getter
    private class ChatRoomPageable {
        private int totalPages;
        private long totalElements;
        private int size;

        public ChatRoomPageable(int totalPages, long totalElements, int size) {
            this.totalPages = totalPages;
            this.totalElements = totalElements;
            this.size = size;
        }
    }
}

 

내부클래스를 사용하여 ResponstDto를 만들었다. 메시지와, 페이징 정보, 그리고 채팅 목록 조회에 보이는 room정보를 담아서 반환한다. 채팅룸 정보에 수정된 시간을 넣어서 최신순으로 정렬할 수 있게 하였다.

 

🔸ChatRoomRepository

public interface ChatRoomRepository extends JpaRepository<ChatRoom, Long> {
    Optional<ChatRoom> findByRoomKey(String roomId);
    Page<ChatRoom> findByRoomKeyContaining(String userId, Pageable pageable);
}

Apic으로 WebSocket 연결과 메시지 송/수신을 테스트하는 사진

Postman으로 채팅룸 생성 후에 조회