| 일 | 월 | 화 | 수 | 목 | 금 | 토 |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | 6 | |
| 7 | 8 | 9 | 10 | 11 | 12 | 13 |
| 14 | 15 | 16 | 17 | 18 | 19 | 20 |
| 21 | 22 | 23 | 24 | 25 | 26 | 27 |
| 28 | 29 | 30 | 31 |
- java map 출력
- 격파르타후기
- 인터프린터언어
- java최솟값구하기
- 항해15기
- java list 출력
- java알고리즘
- javaJVM
- 격파르타장점
- java set 출력
- java map 저장
- 항해99후기
- java map
- 프로그래머스제일작은수
- sqld자격증합격
- 작은수제거하기
- 코딩부트캠프후기
- 노베이스부트캠프
- java set 저장
- 격파르타비전공자
- java list 저장
- javaJRE
- java기본자료형
- java알고리즘문제풀이
- java참조자료형
- java 자료구조 활용
- 비전공자sqld
- 컴파일
- 격파르타합격후기
- 프로그래머스
- Today
- Total
코딩과 결혼합니다
[Game_Crew] WebSocket으로 1대1 채팅기능 구현하기(2) 본문
✏️WebSocket
서버와 클라이언트 사이에 소켓 커넥션을 유지하면서 양방향 통신이 가능한 기술이다.
동작
- 클라이언트에서 서버에 HTTP 프로토콜로 핸드셰이크 요청을 한다.
- 서버에서는 Status Code 101로 응답해 준다.
- 초기 통신을 시작한 후, 웹소켓 프로토콜로 변환하여 데이터를 전송한다.

✏️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으로 채팅룸 생성 후에 조회
'코딩과 매일매일♥ > Game_Crew' 카테고리의 다른 글
| [Game_Crew] 리팩토링 : Post 전체조회 리팩토링 (0) | 2023.11.14 |
|---|---|
| [Game_Crew] 리팩토링 : S3 + 이미지 리사이징 적용하기 (0) | 2023.11.12 |
| [Game_Crew] 트러블슈팅 : S3 + 이미지 리사이징 적용하기 (0) | 2023.11.11 |
| [Game_Crew] WebSocket으로 1대1 채팅기능 구현하기(1) - 적용x (0) | 2023.11.01 |
| [Game_Crew] 리팩토링 + 트러블슈팅 : 이메일 인증 코드 리팩토링 (0) | 2023.10.31 |