코딩과 매일매일♥/Game_Crew
[Game_Crew] WebSocket으로 1대1 채팅기능 구현하기(1) - 적용x
코딩러버
2023. 11. 1. 20:34
728x90
게임 경험의 향상을 위하여 채팅기능을 구현하려 한다. 채팅을 통해서 대화를 나눠봄으로 자신과 잘 맞는 플레이어인지 또 한번 검증을 할 수 있다. 카카오톡과 같이 다중 채팅방이 가능한 1:1 채팅 서비스를 만들것이다.
- Spring WebSocket Stomp로 채팅 구현하기
- WebSocket 연결전 tcp handshake 과정에서 JWT 인증하기
- Spring WebSocket Exception 에러 핸들링
Spring WebSocket Stomp로 채팅 구현하기
📌의존성 추가
//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'
//mongo
implementation 'org.springframework.boot:spring-boot-starter-data-mongodb'
📌프로퍼티즈 설정
#MongoDB
spring.data.mongodb.uri=mongodb://<호스트>:<포트>/<데이터베이스>
명령 프롬프트(Windows) 또는 터미널(Mac/Linux)에서 mongo 명령어를 실행하여 MongoDB 쉘에 접속하여 use 명령어를 사용하여 데이터베이스를 생성
📌 WebSocketConfig
- WebSocket을 구현하기 위한 설정 클래스
- @ Annotation
- @Configuration : 이 클래스가 스프링의 설정 클래스임을 나타냄
- @EnableWebSocketMessageBroke : WebSocket 메시지 브로커의 구성을 설정할 수 있다.
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocket implements WebSocketMessageBrokerConfigurer {
private final StompHandler stompHandler;
private final StompExceptionHandler stompExceptionHandler;
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/sub");
registry.setApplicationDestinationPrefixes("/pub");
}
@Override
public void registerStompEndpoints(StompEndpointRegistry registry){
registry
.setErrorHandler(stompExceptionHandler)
.addEndpoint("/websocket-endpoint")
.addInterceptors()
.setAllowedOriginPatterns("*");
}
//tcp handshake시 jwt 인증
@Override
public void configureClientInboundChannel(ChannelRegistration registration){
registration.interceptors(stompHandler);
}
}
- configureMessageBroker
브로커의 구성. "/sub" 접두사를 가진 목적지(구독자)에 메세지를 보낼 수 있도록 설정하고, "/pub"접두사를 가진 목적지(발행자)로부터의 메시지를 처리할 수 있도록 설정한다. - setApplicationDestinationPrefixes
Stomp 엔드포인트를 등록하는 메서드이다. "/websocket-endpoint" 경로의 엔드포인트를 추가로 등록하고, setErrorHandler 메서드를 통해 Stomp 예외 처리 핸들러를 설정한다. setAllowedOriginPatterns 메서드를 통해 모든 원본 주소에서의 접근을 허용한다. - configureClientInboundChannel
클라이언트의 인바운드 채널 구성을 설정한다. interceptors 메서드를 통해 stompHandler를 인터셉터로 등록 한다.
✏️메시지 브로커
프로듀서(생산자)로부터 메시지를 받아서 컨슈머(소비자)에게 전달하는 역할을 한다. 메시지 브로커는 메시지를 전달하는 동안 메시지를 보관, 라우팅, 변환 및 관리하는 기능을 수행한다. 아래의 방식을 통해 시스템간의 비동기 메시징을 가능하게 한다.
- 프로듀서는 메시지를 생성하고 메시지 브로커에게 전달한다.
- 메시지 브로커는 이메시지를 큐에 저장한다.
- 컨슈머가 메시지를 요청핳면, 메시지 브로커는 큐에서 메시지를 꺼내 컨슈머에게 전달한다.
✏️인바운드 채널
클라이언트에서 서버로 데이터를 전송하는 경로를 의미한다. WebSocket에서는 클라이언트로부터 들어오는 연결을 인바운드 채널이라고 부른다. 이 채널을 통해 들어오는 메시지는 서버에서 처리되고, 필요한 경우 다른 클라이언트에게 전달된다.
📌 Controller
- WebSocket을 사용한 실시간 채팅을 처리하는 컨트롤러
- @ Annotation
- @MessageMapping : WebSocket 요청을 처리하는 메서드임을 나타낸다.
- @SendTo : 해당 경로로 메시지가 브로드캐스트된다.
@RestController
@Slf4j
@RequiredArgsConstructor
@RequestMapping("/chat")
public class ChatController {
private final ChatService chatService;
@MessageMapping("/{roomNo}")
@SendTo("/sub/{roomNo}")
public ChatResponse broadcasting(final ChatRequest request,
@DestinationVariable(value = "roomNo") final String chatRoomNo){
log.info("{roomNo : {}, request : {}}", chatRoomNo, request);
return chatService.recordHistory(chatRoomNo, request);
}
}
- ChatResponse broadcasting
MessageMapping 애너테이션에 의해 WebSocket 요청이 들어오면 호출되는 메서드이다.
ChatService의 recordHistory() 메서드를 호출하여 채팅기록을 저장한 후, ChatResponse 객체를 반환한다.
WebSocket 연결전 tcp handshake 과정에서 JWT 인증하기
📌 StompHandler
- Stomp(WebSocket 하위 프로토콜) 메시지가 전송되기 전에 처리하는 역할을 한다.
- @Order(Ordered.HIGEST_PRECEDENCE + 99) : 해당 클래스의 우선순위를 설정한다. HIGEST_PRECEDENCE는 가장 높은 우선순위를 나타내며, + 99는 추가적인 우선순위를 설정한다.
@Component
@RequiredArgsConstructor
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class StompHandler implements ChannelInterceptor {
private final JwtUtil jwtUtil;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel){
final StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
if (StompCommand.CONNECTED == accessor.getCommand()){
final String authorization = jwtUtil.extractJwt(accessor);
jwtUtil.validateToken(authorization);
}
return message;
}
}
- preSend
STOMP 메시지가 전송되기 전에 호출됩니다. message와 channel을 파라미터로 받는다. - accessor
message를 StompHeaderAccessor로 감싸고, accessor 변수에 할당한다. - if()~
메서드를 사용하여 STOMP 메시지의 커맨드를 가져온다. 이 조건문은 STOMP 메시지의 커맨드가 CONNEXTED인지 확인한다. - authorization
accessor에서 JWT(Jason Web Token)를 추출한 다음 유효성을 검사한다.
📌 JwtUtil
public String extractJwt(StompHeaderAccessor accessor){
return accessor.getFirstNativeHeader("Authorization");
}
// 토큰 검증
public boolean validateToken(String token) {
try {
Jwts.parserBuilder().setSigningKey(key).build().parseClaimsJws(token);
return true;
} catch (SecurityException | MalformedJwtException | SignatureException e) {
log.error("Invalid JWT signature, 유효하지 않는 JWT 서명 입니다.");
} catch (ExpiredJwtException e) {
log.error("Expired JWT token, 만료된 JWT token 입니다.");
} catch (UnsupportedJwtException e) {
log.error("Unsupported JWT token, 지원되지 않는 JWT 토큰 입니다.");
} catch (IllegalArgumentException e) {
log.error("JWT claims is empty, 잘못된 JWT 토큰 입니다.");
}
return false;
}
- extractJwt
Stomp 프로토콜 메시지의 헤더에서 "Authorization"이라는 key 값을 가진 JWT 토큰을 추출 - validateToken
전달받은 JWT 토큰이 유효한지 검증
Spring WebSocket Exception 에러 핸들링
📌 StompExceptionHandler
STOMP 프로토콜에서 발생하는 다양한 예외 사항을 처리하여 클라이언트에게 적절한 에러 메시지를 전달하도록 설계
@Component
public class StompExceptionHandler extends StompSubProtocolErrorHandler {
private static final byte[] EMPTY_PAYLOAD = new byte[0];
public StompExceptionHandler() {
super();
}
@Override
public Message<byte[]> handleClientMessageProcessingError(Message<byte[]> clientMessage,
Throwable ex) {
final Throwable exception = converterTrowException(ex);
if (exception instanceof UnauthorizedException) {
return handleUnauthorizedException(clientMessage, exception);
}
return super.handleClientMessageProcessingError(clientMessage, ex);
}
private Throwable converterTrowException(final Throwable exception) {
if (exception instanceof MessageDeliveryException) {
return exception.getCause();
}
return exception;
}
private Message<byte[]> handleUnauthorizedException(Message<byte[]> clientMessage,
Throwable ex) {
return prepareErrorMessage(clientMessage, ex.getMessage(), HttpStatus.UNAUTHORIZED.name());
}
private Message<byte[]> prepareErrorMessage(final Message<byte[]> clientMessage,
final String message, final String errorCode) {
final StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
accessor.setMessage(errorCode);
accessor.setLeaveMutable(true);
setReceiptIdForClient(clientMessage, accessor);
return MessageBuilder.createMessage(
message != null ? message.getBytes(StandardCharsets.UTF_8) : EMPTY_PAYLOAD,
accessor.getMessageHeaders()
);
}
private void setReceiptIdForClient(final Message<byte[]> clientMessage,
final StompHeaderAccessor accessor) {
if (Objects.isNull(clientMessage)) {
return;
}
final StompHeaderAccessor clientHeaderAccessor = MessageHeaderAccessor.getAccessor(
clientMessage, StompHeaderAccessor.class);
final String receiptId =
Objects.isNull(clientHeaderAccessor) ? null : clientHeaderAccessor.getReceipt();
if (receiptId != null) {
accessor.setReceiptId(receiptId);
}
}
//2
@Override
protected Message<byte[]> handleInternal(StompHeaderAccessor errorHeaderAccessor,
byte[] errorPayload, Throwable cause, StompHeaderAccessor clientHeaderAccessor) {
return MessageBuilder.createMessage(errorPayload, errorHeaderAccessor.getMessageHeaders());
}
}