- WebSocket 위에서 동작하는 STOMP(Simple Text Oriented Messaging Protocol)를 이용하여 실시간 채팅방을 구현해보려 한다.
- Spring Security와 Json Web Token을 이용하여 REST API 및 STOMP 인증 처리를 구현해보려 한다.
Spring Boot + React 프로젝트
현재
Back-End와Front-End는 다른 환경에서 개발하고 있음
- 사용자는 회원가입 후 로그인을 진행하여 인증된 사용자인 경우에만 채팅방 생성 및 입장할 수 있으며, 생성된 채팅방은 채팅방 목록에 뜨게 되어 여러 사용자들이 채팅방에 입장하여 실시간 채팅을 할 수 있게 한다.
- 자신이 생성한 채팅방은 자신만이 제거할 수 있다.
| 기능 | URL |
|---|---|
| 회원가입 | [POST] /api/users/signup |
| 로그인 | [GET] /api/users/login |
| 사용자 아이디 중복 체크 | [GET] /api/users/duplicheck?userId=사용자아이디 |
| 기능 | URL |
|---|---|
| 로그아웃 | [GET] /api/users/logout |
| 메세지 구독 | [SockJS] /ws/sub/chat |
| 메세지 발행 | [SockJS] /ws/pub/chat |
| 채팅방 생성 | [POST] /api/chatroom |
| 채팅방 조회 | [GET] /api/chatroom |
| 채팅방 삭제 | [DELETE] /api/chatroom |
| 기능 | URL |
|---|---|
| 재발급 | [GET] /api/users/reissue |
- STOMP를 참고한 사이트 출처
- Project : Gradle
- SpringBoot 버전 : 2.7.12
- Java 버전 : 11
- 초기 Dependencies
- Spring Web, Websocket : 5.3.27
- Lombok : 1.18.26
- 추가된 Dependencies
- Spring Security : 5.7.8
- Mybatis : 3.5.11
- H2 Database : 2.1.214
- Redis : 2.7.11
- Jwt : 0.9.1
- Jaxb-Runtime(DataTypeConverter) : 2.3.2
- Json-Simple : 1.1.1
- STOMP를 이용하여 간단하게 구독 및 발행 과 MessageMapping을 이용하여 Front와 연결 및 실시간 채팅이 가능한지 코드 작성 및 확인
- 로그 출력
logging.level.hello.chat=trace
@Configuration
@EnableWebSocketMessageBroker
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
/**
* 엔드 포인트를 등록하기 위해 registerStompEndpoints 를 override 한다.
* @param registry
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 앞으로 웹 소켓 서버의 엔드포인트는 /ws 이다.
registry.addEndpoint("/ws")
.setAllowedOrigins("http://localhost:3000")
.withSockJS();
}
/**
* Message Broker 를 설정하기 위해 configureMessageBroker 를 override 한다.
* @param registry
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// enableSimpleBroker() 를 사용해서 /sub 가 prefix 로 붙은 destination 의 클라이언트에게
// 메세지를 보낼 수 있도록 Simple Broker 를 등록한다.
registry.enableSimpleBroker("/sub"); // 구독
// setApplicationDestinationPrefixes() 를 사용해서 /pub 가 prefix 로 붙은 메시지들은
// @MessageMapping 이 붙은 method 로 바운드된다.
registry.setApplicationDestinationPrefixes("/pub"); // 발행
}
}/**
* 통신시에 주고 받을 메시지 형식을 작성
* RestController 의 경우 @RequestBody 가 쓰일 Dto 는 Setter 가 필요 없다.
* (ObjectMapper 를 통해 변환이 이루어지기 때문)
*/
@Getter
@Builder
public class ChatDto {
private String channelId;
private String writerNm;
private String message;
}@RestController
@RequiredArgsConstructor
@Slf4j
public class WebSocketController {
private final SimpMessagingTemplate simpleMessagingTemplate;
/**
* @MessageMapping annotation 은 메시지의 destination 이 /hello 였다면 해당 sendMessage() method 가 불리도록 해준다.
* - sendMessage() 에서는 simpMessagingTemplate.convertAndSend 를 통해
* /sub/chat/{channelId} 채널을 구독 중인 클라이언트에게 메시지를 전송한다.
* - SimpMessagingTemplate 는 특정 브로커로 메시지를 전달한다.
* - 현재는 외부 브로커를 붙이지 않았으므로 인메모리에 정보를 저장한다.
* 메시지의 payload 는 인자(chatDto)로 들어온다.
* @param chatDto
* @param accessor
*/
@MessageMapping("/chat")
public void sendMessage(@RequestBody ChatDto chatDto, SimpMessageHeaderAccessor accessor) {
log.info("Channel : {}, getWriterNm : {}, sendMessage : {}", chatDto.getChannelId(), chatDto.getWriterNm(), chatDto.getMessage());
simpleMessagingTemplate.convertAndSend("/sub/chat/" + chatDto.getChannelId(), chatDto);
}
}- Front 이미지
- 사이드 프로젝트 BE-Login에서 진행했던 JWT 인증 가져오기(Board 관련 부분 제거)
- 기능 : 회원가입, 로그인, 로그아웃, 토큰 재발급
- 상세 정보 BE-Login 20230603까지 참고
- 코드 작성 리스트
- build.gradle dependency 추가
- application.properties 코드 추가
- TB_USER.sql 작성
- UserDto 작성
- UserMapper 작성
- UserMapper XML 작성 (mybatis)
- UserMapperTest 작성
- UserService 인터페이스 작성
- UserServiceImpl 작성
- RedisConfig 작성
- RedisRepository 작성
- RefreshToken 작성
- ErrorCode 작성
- SuccessCode 작성
- BusinessExceptionHandler 작성
- UserDetailsDto 작성
- UserDetailsServiceImpl 작성
- CustomAuthenticationFilter 작성
- CustomAuthenticationProvider 작성
- CustomAuthFailureHandler 작성
- AuthConstants 작성
- NetUtils 작성
- TokenUtils 작성
- JwtAuthorziationFilter 작성
- CustomAuthSuccessHandler 작성
- JwtToken 작성
- WebSecurityConfig 작성
- ApiResponse 작성 (result 타입 String -> Object 형식으로 변경)
- ErrorResponse 작성
- UserController 작성
- STOMP 연결시 요청 방식이 다르기 때문에 JwtAuthorizationFilter에서 토큰 확인 및 인증이 안되는 현상이 발생하여
- STOMP 전용 Jwt 인증 인터셉터를 만들어서 연결 커맨드가 Connect시에 인증 절차를 밟게한다.
- STOMP 전용 Jwt 인증 인터셉터에 보내야 하기 때문에 JwtAuthroziationFilter에서 /ws 엔드포인트로 된 URI 요청시 doFilter와 함께 인증 로직 없이 다음 필터로 이동하게 해야한다.
- JWT 만료 및 인증이 불가능하게 되어 Exception 발생시 Exception과 함께 Error 전용 핸들러에 보내게 하여 STOMP 연결이 불가능하게 에러 메세지와 함께 커맨드를 ERROR로 바꾸어준다.
- UNAUTHORIZED_ERROR 추가
/**
* [공통 코드] API 통신에 대한 '에러 코드'를 Enum 형태로 관리를 한다.
* Global Error CodeList : 전역으로 발생하는 에러코드를 관리한다.
* custom Error CodeList : 업무 페이지에서 발생하는 에러코드를 관리한다.
* Error Code Constructor : 에러코드를 직접적으로 사용하기 위한 생성자를 구성한다.
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public enum ErrorCode {
BUSINESS_EXCEPTION_ERROR(200, "B999", "Business Exception Error"),
/**
* *********************************** custom Error CodeList ********************************************
*/
// Transaction Insert Error
INSERT_ERROR(200, "9999", "Insert Transaction Error Exception"),
// Transaction Update Error
UPDATE_ERROR(200, "9999", "Update Transaction Error Exception"),
// Transaction Delete Error
DELETE_ERROR(200, "9999", "Delete Transaction Error Exception"),
// Authorization 관련 Error
UNAUTHORIZED_ERROR(200, "7777", "Unauthenticated User"), // 코드 추가
; // End
/**
* *********************************** Error Code Constructor ********************************************
*/
// 에러 코드의 '코드 상태'을 반환한다.
private int status;
// 에러 코드의 '코드간 구분 값'을 반환한다.
private String divisionCode;
// 에러코드의 '코드 메시지'을 반환한다.
private String message;
// 생성자 구성
ErrorCode(final int status, final String divisionCode, final String message) {
this.status = status;
this.divisionCode = divisionCode;
this.message = message;
}
}- isValidAccessToken 메서드 변경
/**
* JWT 관련된 토큰 Util
*/
@Slf4j
@Component
public class TokenUtils {
private static String accessSecretKey;
private static String refreshSecretKey;
// ... 기존 코드
/**
* 유효한 엑세스 토큰인지 확인 해주는 메서드
* @param token String : 토큰
* @return boolean : 유효한지 여부 반환
*/
public static boolean isValidAccessToken(String token) {
try {
Claims claims = getAccessTokenToClaimsFormToken(token);
log.info("expireTime : {}", claims.getExpiration());
log.info("userId : {}", claims.get("uid"));
log.info("userNm : {}", claims.get("unm"));
return true;
} catch (ExpiredJwtException exception) {
log.error("Token Expired");
throw exception;
} catch (JwtException exception) {
log.error("Token Tampered", exception);
throw exception;
} catch(NullPointerException exception) {
throw exception;
}
}
// ... 기존 코드
}- 추가 부분 : 2-1
- 변경 부분 : throw new BusinessExceptionHandler("에러 내용", ErrorCode.UNAUTHORIZED_ERROR)
/**
* 지정한 URL 별 JWT 유효성 검증을 수행하며 직접적인 사용자 '인증'을 확인합니다.
*/
@Slf4j
@RequiredArgsConstructor
public class JwtAuthorizationFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
// 1. 토큰이 필요하지 않은 API URL 에 대해서 배열로 구성합니다.
List<String> list = Arrays.asList(
"/api/users/login", // 로그인
"/api/users/reissue", // 리프레쉬 토큰으로 재발급
"/api/users/signup", // 회원가입
"/api/users/duplicheck" // 회원가입 하위 사용 가능 ID 확인
);
// 2. 토큰이 필요하지 않은 API URL 의 경우 => 로직 처리 없이 다음 필터로 이동
if(list.contains(request.getRequestURI())) {
filterChain.doFilter(request, response);
return;
}
log.debug("[] header URI : {}", request.getRequestURI());
// --- 코드 추가 ---
// 2-1. 첫 /ws 엔드포인트가 붙은 URL 일 경우 로직 처리 없이 다음 필터로 이동 (preHandler 로 JWT 인증 처리) 코드 추가
if(request.getRequestURI().startsWith("/ws")) {
filterChain.doFilter(request, response);
return;
}
// ----------------
// 3. OPTIONS 요청일 경우 => 로직 처리 없이 다음 필터로 이동
if (request.getMethod().equalsIgnoreCase("OPTIONS")) {
filterChain.doFilter(request, response);
return;
}
// [STEP1] Client 에서 API 를 요청할 때 Header 를 확인합니다.
String header = request.getHeader(AuthConstants.AUTH_HEADER);
log.debug("[+] header Check: {}", header);
try {
// [STEP2-1] Header 내에 토큰이 존재하는 경우
if(header != null && !header.equalsIgnoreCase("")) {
// [STEP2] Header 내에 토큰을 추출합니다.
String token = TokenUtils.getTokenFormHeader(header);
// [STEP3] 추출한 엑세스 토큰이 유효한지 여부를 체크합니다.
if(token != null && TokenUtils.isValidAccessToken(token)) {
// [STEP 3-1] Redis 에 해당 Access-Token 로그아웃 확인
String isLogout = redisTemplate.opsForValue().get(token);
// 로그아웃이 되어 있지 않은 경우 해당 토큰은 정상적으로 작동
if(ObjectUtils.isEmpty(isLogout)){
// [STEP4] 토큰을 기반으로 사용자 아이디를 반환 받는 메서드
String userId = TokenUtils.getUserIdFormAccessToken(token);
log.debug("[+] userId Check: {}", userId);
// [STEP5] 사용자 아이디가 존재하는지 여부 체크
if(userId != null && !userId.equalsIgnoreCase("")) {
filterChain.doFilter(request, response);
} else {
// 사용자 아이디가 존재 하지 않을 경우
throw new BusinessExceptionHandler("Token isn't userId", ErrorCode.UNAUTHORIZED_ERROR); // 변경
}
} else {
// 현재 토큰이 로그아웃 되어 있는 경우
throw new BusinessExceptionHandler("Token is logged out", ErrorCode.UNAUTHORIZED_ERROR); // 변경
}
} else {
// 토큰이 유효하지 않은 경우
throw new BusinessExceptionHandler("Token is invalid", ErrorCode.UNAUTHORIZED_ERROR); // 변경
}
}
else {
// [STEP2-1] 토큰이 존재하지 않는 경우
throw new BusinessExceptionHandler("Token is null", ErrorCode.UNAUTHORIZED_ERROR); // 변경
}
} catch (Exception e) {
// Token 내에 Exception 이 발생 하였을 경우 => 클라이언트에 응답값을 반환하고 종료합니다.
response.setCharacterEncoding("UTF-8");
response.setContentType("application/json");
PrintWriter printWriter = response.getWriter();
JSONObject jsonObject = jsonResponseWrapper(e);
printWriter.print(jsonObject);
printWriter.flush();
printWriter.close();
}
}
// ... 기존 코드
}@RequiredArgsConstructor
@Component
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE + 99)
public class ChatPreHandler implements ChannelInterceptor {
private final RedisTemplate<String, String> redisTemplate;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
// 연결 요청일 경우
if(StompCommand.CONNECT.equals(headerAccessor != null ? headerAccessor.getCommand() : null)) {
String authorizationHeader = String.valueOf(headerAccessor.getNativeHeader(AuthConstants.AUTH_HEADER));
String token = "";
// Header 에 Authorization 값 추출 (대괄호 제거)
String authorizationHeaderStr = authorizationHeader.replace("[","").replace("]","");
log.info("authorization Header String : {}", authorizationHeaderStr);
// Bearer 형식으로 되어있는지 검증
if (authorizationHeaderStr.startsWith("Bearer ")) {
// Bearer 형식일 경우 token 추출
token = authorizationHeaderStr.replace("Bearer ", "");
log.info("token : {}", token);
} else {
log.error("Authorization 헤더 형식이 틀립니다. : {}", authorizationHeader);
throw new MalformedJwtException("Token is Invalid");
}
try{
// 토큰 값이 유효한지 검증
if(TokenUtils.isValidAccessToken(token)) {
// 토큰으로부터 userId 값 추출
String userId = TokenUtils.getUserIdFormAccessToken(token);
if(userId.isEmpty()) { // 토큰에 userId 값이 없을 경우
throw new MalformedJwtException("Token is Invalid");
}
}
} catch (ExpiredJwtException exception) {
throw new MalformedJwtException("Token Expired");
} catch (Exception exception) {
throw new MalformedJwtException("Token is Invalid");
}
}
else if (StompCommand.ERROR.equals(headerAccessor.getCommand())) {
throw new MessageDeliveryException("error");
}
return message;
}
}@Component
@Slf4j
public class ChatErrorHandler extends StompSubProtocolErrorHandler {
public ChatErrorHandler() {
super();
}
@Override
public Message<byte[]> handleClientMessageProcessingError(Message<byte[]>clientMessage, Throwable ex) {
Throwable exception = ex;
// exception 타입이 MessageDeliveryException일 경우
if (exception instanceof MessageDeliveryException) {
log.info("메세지 예외 : {}", exception.getMessage());
return handleUnauthorizedException(clientMessage, ex.getMessage());
}
// exception 타입이 MalformedJwtException 경우
if(exception instanceof MalformedJwtException) {
log.info("멀폼 예외 : {}", exception.getMessage());
return handleUnauthorizedException(clientMessage, ex.getMessage());
}
return super.handleClientMessageProcessingError(clientMessage, ex);
}
private Message<byte[]> handleUnauthorizedException(Message<byte[]> clientMessage, String message) {
ErrorResponse errorResponse = ErrorResponse.builder()
.result(message)
.resultCode(ErrorCode.UNAUTHORIZED_ERROR.hashCode())
.resultMsg(ErrorCode.UNAUTHORIZED_ERROR.getDivisionCode())
.build();
return prepareErrorMessage(clientMessage, errorResponse, ErrorCode.UNAUTHORIZED_ERROR.getMessage());
}
private Message<byte[]> prepareErrorMessage(Message<byte[]> clientMessage, ErrorResponse errorResponse, String message) {
// Command를 ERROR로 변경
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
accessor.setMessage(message);
accessor.setLeaveMutable(true);
return MessageBuilder.createMessage(message.getBytes(StandardCharsets.UTF_8), accessor.getMessageHeaders());
}
}- setErrorHandler로 chatErrorHandler 추가
- interceptor에 chatPreHandler 추가
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final ChatPreHandler chatPreHandler; // 추가
private final ChatErrorHandler chatErrorHandler; // 추가
/**
* 엔드 포인트를 등록하기 위해 registerStompEndpoints 를 override 한다.
* @param registry
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 앞으로 웹 소켓 서버의 엔드포인트는 /ws 이다.
registry.addEndpoint("/ws")
.setAllowedOrigins("http://localhost:3000")
.withSockJS();
registry.setErrorHandler(chatErrorHandler); // 추가
}
/**
* Message Broker 를 설정하기 위해 configureMessageBroker 를 override 한다.
* @param registry
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// enableSimpleBroker() 를 사용해서 /sub 가 prefix 로 붙은 destination 의 클라이언트에게
// 메세지를 보낼 수 있도록 Simple Broker 를 등록한다.
registry.enableSimpleBroker("/sub"); // 구독
// setApplicationDestinationPrefixes() 를 사용해서 /pub 가 prefix 로 붙은 메시지들은
// @MessageMapping 이 붙은 method 로 바운드된다.
registry.setApplicationDestinationPrefixes("/pub"); // 발행
}
@Override // 추가
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(chatPreHandler);
}
}- 25일에 찍은 ChatPreHandler Log 이미지
- JwtAuthorizationPreHandler -> ChatPreHandler
- 인증된 사용자가 채팅방 생성 및 제거를 할 수 있으며, 채팅방을 생성 후 입장해야지만 실시간 채팅을 할 수 있게 한다. (채팅방 생성시 UUID를 부여한다)
- 인증된 사용자는 자신이 생성한 채팅방만 제거를 할 수 있다.
- subscribe(구독)의 경우 ChatRoomPreHandler 를 이용하여 생성 된 채팅방인지 체크한다.
- publish(발행)의 경우 WebSocketController에서 MessageMapping을 이용한 메서드에서 구독한 클라이언트에게 발행한 메세지를 보내기 전에 생성 된 채팅방인지 체크한다.
- CHANNEL_ID는 UUID를 사용할 계획이므로 VARCHAR(36)을 부여한다.
create table tb_chat_room(
chat_room_sq INT AUTO_INCREMENT PRIMARY KEY,
channel_id VARCHAR(36) NOT NULL,
title VARCHAR(30) NOT NULL,
writer_id VARCHAR(20) NOT NULL,
writer_nm VARCHAR(20) NOT NULL,
date_time TIMESTAMP NOT NULL
);- 성공 코드의 '코드 값'을 반환하는 code 제거
@Getter
public enum SuccessCode {
/**
* ******************************* Success CodeList ***************************************
*/
// 조회 성공 코드 (HTTP Response: 200 OK)
SELECT_SUCCESS(200, "SELECT SUCCESS"),
// 삭제 성공 코드 (HTTP Response: 200 OK)
DELETE_SUCCESS(200, "DELETE SUCCESS"),
// 삽입 성공 코드 (HTTP Response: 201 Created)
INSERT_SUCCESS(201, "INSERT SUCCESS"),
// 수정 성공 코드 (HTTP Response: 201 Created)
UPDATE_SUCCESS(204, "UPDATE SUCCESS"),
; // End
/**
* ******************************* Success Code Constructor ***************************************
*/
// 성공 코드의 '코드 상태'를 반환한다.
private final int status;
// 성공 코드의 '코드 값'을 반환한다.
//private final String code;
// 성공 코드의 '코드 메시지'를 반환한다.s
private final String message;
// 생성자 구성
SuccessCode(final int status, final String message) {
this.status = status;
this.message = message;
}
}@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class ChatRoomDto {
private int chatRoomSq; // 기본키
private String channelId;
private String title;
private String writerId;
private String writerNm;
private LocalDateTime dateTime;// 생성 날짜
@Builder
public ChatRoomDto(int chatRoomSq, String channelId, String title, String writerId, String writerNm, LocalDateTime dateTime) {
this.chatRoomSq = chatRoomSq;
this.channelId = channelId;
this.title = title;
this.writerId = writerId;
this.writerNm = writerNm;
this.dateTime = dateTime;
}
}@Mapper
public interface ChatRoomMapper {
void save(ChatRoomDto chatRoomDto); // 저장
Optional<ChatRoomDto> findByChannelId(String chanelId); // 채널 아이디로 조회
List<ChatRoomDto> findAll(); // 모두 조회
Optional<ChatRoomDto> findByWriterIdAndChannelId(@Param("writerId") String writerId, @Param("channelId") String channelId); // 새성 아이디 AND 채널 아이디로 조회
void deleteByWriterIdAndChannelId(@Param("writerId") String writerId, @Param("channelId") String channelId); // 생성 아이디 AND 채널 아이디로 삭제
}<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
<mapper namespace="hello.chat.mapper.ChatRoomMapper">
<!-- 방 생성 -->
<insert id="save" useGeneratedKeys="true" keyProperty="chatRoomSq">
INSERT INTO TB_CHAT_ROOM
(CHANNEL_ID, TITLE, WRITER_ID, WRITER_NM, DATE_TIME)
VALUES (#{channelId}, #{title}, #{writerId}, #{writerNm}, #{dateTime})
</insert>
<!-- 채널 ID 로 조회 -->
<select id="findByChannelId" resultType="hello.chat.model.ChatRoomDto">
SELECT t1.*
FROM TB_CHAT_ROOM t1
WHERE CHANNEL_ID = #{channelId}
</select>
<!-- 채널 모두 조회 -->
<select id="findAll" resultType="hello.chat.model.ChatRoomDto">
SELECT t1.*
FROM TB_CHAT_ROOM t1
</select>
<!-- writerId 와 channelId 로 ChatRoom 조회 -->
<select id="findByWriterIdAndChannelId" resultType="hello.chat.model.ChatRoomDto">
SELECT t1.*
FROM TB_CHAT_ROOM t1
WHERE WRITER_ID = #{writerId} AND CHANNEL_ID = #{channelId}
</select>
<!-- writerId 와 channelId 로 ChatRoom 삭제 -->
<delete id="deleteByWriterIdAndChannelId">
DELETE FROM TB_CHAT_ROOM
WHERE WRITER_ID = #{writerId} AND CHANNEL_ID = #{channelId}
</delete>
</mapper>- chat\src\main\resources\application.properties 에 local 전용 프로필 설정
# ADD profile
spring.profiles.active=local
# h2 database
spring.datasource.url=jdbc:h2:tcp://localhost/~/test
spring.datasource.username=sa
spring.datasource.password=
#MyBatis log
mybatis.configuration.map-underscore-to-camel-case=true
logging.level.hello.chat.mapper.mybatis=trace
# Log
logging.level.hello.chat=trace
# Secret Key
custom.jwt-access-secret-key=accessSecretKey
custom.jwt-refresh-secret-key=refreshSecretKey
# Redis
spring.redis.host=localhost
spring.redis.port=6379
- chat\src\test\resources\application.properties 에 test 전용 프로필 설정
# ADD profile
spring.profiles.active=test
# h2 database (testcase)
spring.datasource.url=jdbc:h2:tcp://localhost/~/testcase
spring.datasource.username=sa
spring.datasource.password=
#MyBatis log
mybatis.configuration.map-underscore-to-camel-case=true
logging.level.hello.chat.mapper.mybatis=trace
# Log
logging.level.hello.chat=trace
# Secret Key
custom.jwt-access-secret-key=accessSecretKey
custom.jwt-refresh-secret-key=refreshSecretKey
# Redis
spring.redis.host=localhost
spring.redis.port=6379
@SpringBootTest
@Transactional
public class ChatRoomMapperTest {
@Autowired
ChatRoomMapper chatRoomMapper;
@Test
@DisplayName("ChatRoom 저장 테스트")
void save() {
// given
ChatRoomDto chatRoom = ChatRoomDto.builder()
.channelId("저장 테스트")
.title("아무나")
.writerId("asd123")
.writerNm("대한민국")
.dateTime(LocalDateTime.now())
.build();
// when
chatRoomMapper.save(chatRoom);
// then
Optional<ChatRoomDto> selectedChatRoom = chatRoomMapper.findByChannelId("저장 테스트");
assertThat(selectedChatRoom.get().getChannelId()).isEqualTo("저장 테스트");
}
@Test
@DisplayName("ChatRoom 채널 아이디로 조회 테스트")
void findByChanelId() {
// given
ChatRoomDto chatRoom = ChatRoomDto.builder()
.channelId("조회 테스트")
.title("아무나123")
.writerId("asd123")
.writerNm("고기")
.dateTime(LocalDateTime.now())
.build();
chatRoomMapper.save(chatRoom);
// when
Optional<ChatRoomDto> selectedChatRoom = chatRoomMapper.findByChannelId("조회 테스트");
// then
assertThat(selectedChatRoom.get().getChannelId()).isEqualTo("조회 테스트");
}
@Test
@DisplayName("ChatRoom 모두 조회 테스트")
void findAll() {
// given
ChatRoomDto chatRoom1 = ChatRoomDto.builder()
.channelId("12345")
.title("채팅할사람~")
.writerId("asd123")
.writerNm("리듬")
.dateTime(LocalDateTime.now())
.build();
ChatRoomDto chatRoom2 = ChatRoomDto.builder()
.channelId("678910")
.title("채팅만")
.writerId("qwe456")
.writerNm("소리")
.dateTime(LocalDateTime.now())
.build();
chatRoomMapper.save(chatRoom1);
chatRoomMapper.save(chatRoom2);
// when
List<ChatRoomDto> chatRoomMapperAll = chatRoomMapper.findAll();
// then
assertThat(chatRoomMapperAll.size()).isEqualTo(2);
}
@Test
@DisplayName("ChatRoom ChannelId AND writerId로 삭제 테스트")
void deleteByChannelId() {
// given
ChatRoomDto chatRoom1 = ChatRoomDto.builder()
.channelId("123123")
.title("아무나1")
.writerId("asd123")
.writerNm("한국1")
.dateTime(LocalDateTime.now())
.build();
ChatRoomDto chatRoom2 = ChatRoomDto.builder()
.channelId("456456")
.title("아무나2")
.writerId("qwe456")
.writerNm("한국2")
.dateTime(LocalDateTime.now())
.build();
chatRoomMapper.save(chatRoom1);
chatRoomMapper.save(chatRoom2);
// when
chatRoomMapper.deleteByWriterIdAndChannelId("asd123", "123123");
// then
List<ChatRoomDto> chatRoomMapperAll = chatRoomMapper.findAll();
assertThat(chatRoomMapperAll.size()).isEqualTo(1);
assertThat(chatRoomMapperAll.get(0).getChannelId()).isEqualTo("456456");
}
}public interface ChatRoomService {
void create(ChatRoomDto chatRoomDto);
Optional<ChatRoomDto> join(String chanelId);
List<ChatRoomDto> findAll();
void delete(String writerId, String channelId);
}@Service
@Slf4j
@RequiredArgsConstructor
public class ChatRoomServiceImpl implements ChatRoomService {
private final ChatRoomMapper chatRoomMapper;
@Override
@Transactional
public void create(ChatRoomDto chatRoomDto) {
chatRoomMapper.save(chatRoomDto);
}
@Override
@Transactional(readOnly = true)
public Optional<ChatRoomDto> join(String chanelId) {
return chatRoomMapper.findByChannelId(chanelId);
}
@Override
@Transactional(readOnly = true)
public List<ChatRoomDto> findAll() {
return chatRoomMapper.findAll();
}
@Override
@Transactional
public void delete(String writerId, String channelId) {
Optional<ChatRoomDto> byChannelId = chatRoomMapper.findByChannelId(channelId);
// 채팅방이 존재하지 않을 경우 예외 throw
if(byChannelId.isEmpty()) {
throw new BusinessExceptionHandler("chat room does not exist", ErrorCode.BUSINESS_EXCEPTION_ERROR);
}
Optional<ChatRoomDto> byWriterIdAndChannelId = chatRoomMapper.findByWriterIdAndChannelId(writerId, channelId);
// 사용자 정보로 된 채팅방이 아닐경우 예외 throw
if(byWriterIdAndChannelId.isEmpty()) {
throw new BusinessExceptionHandler("not your chat room", ErrorCode.BUSINESS_EXCEPTION_ERROR);
}
// 존재할 경우 삭제
chatRoomMapper.deleteByWriterIdAndChannelId(writerId, channelId);
}
}
}@NoArgsConstructor(access = AccessLevel.PROTECTED)
public enum ErrorCode {
BUSINESS_EXCEPTION_ERROR(200, "B999", "Business Exception Error"),
GLOBAL_EXCEPTION_ERROR(200, "C999", "Global Exception Error"),
/**
* *********************************** custom Error CodeList ********************************************
*/
/**
* Transaction Insert Error
*/
INSERT_ERROR(200, "9999", "Insert Transaction Error Exception"),
/**
* Transaction Update Error
*/
UPDATE_ERROR(200, "9999", "Update Transaction Error Exception"),
/**
* Transaction Delete Error
*/
DELETE_ERROR(200, "9999", "Delete Transaction Error Exception"),
/**
* Authorization 관련 Error
*/
UNAUTHORIZED_ERROR(200, "7777", "Unauthenticated User"),
/**
* 400 BAD_REQUEST: 잘못된 요청
*/
BAD_REQUEST(HttpStatus.BAD_REQUEST.value(), "6666", "Bad Request"),
/**
* 404 NOT_FOUND: 리소스를 찾을 수 없음
*/
NOT_FOUND(HttpStatus.NOT_FOUND.value(), "6666", "Information not found")
; // End
/**
* *********************************** Error Code Constructor ********************************************
*/
// 에러 코드의 '코드 상태'을 반환한다.
private int status;
// 에러 코드의 '코드간 구분 값'을 반환한다.
private String divisionCode;
// 에러코드의 '코드 메시지'을 반환한다.
private String message;
// 생성자 구성
ErrorCode(final int status, final String divisionCode, final String message) {
this.status = status;
this.divisionCode = divisionCode;
this.message = message;
}
}@RestController
@RequestMapping("/api/chatroom")
@RequiredArgsConstructor
public class ChatRoomController {
private final ChatRoomService chatRoomService;
@PostMapping
public ResponseEntity<ApiResponse> createChatRoom(HttpServletRequest request, @RequestBody Map<String, String> titleMap) {
Map<String, String> userIdAndUserNmMap = getUserIdAndUserNmInMap(request);
ChatRoomDto chatRoomDto = ChatRoomDto.builder()
.channelId(UUID.randomUUID().toString())
.title(titleMap.get("title"))
.writerId(userIdAndUserNmMap.get("writerId"))
.writerNm(userIdAndUserNmMap.get("writerNm"))
.dateTime(LocalDateTime.now())
.build();
chatRoomService.create(chatRoomDto);
ApiResponse ar = ApiResponse.builder()
.result("")
.resultMsg(SuccessCode.INSERT_SUCCESS.getMessage())
.resultCode(SuccessCode.INSERT_SUCCESS.getStatus())
.build();
return ResponseEntity.ok().body(ar);
}
@GetMapping
public ResponseEntity<ApiResponse> findAllChatRoom() {
List<ChatRoomDto> chatRoomDtoList = chatRoomService.findAll();
ApiResponse ar = ApiResponse.builder()
.result(chatRoomDtoList)
.resultMsg(SuccessCode.SELECT_SUCCESS.getMessage())
.resultCode(SuccessCode.SELECT_SUCCESS.getStatus())
.build();
return ResponseEntity.ok().body(ar);
}
@DeleteMapping
public ResponseEntity<ApiResponse> deleteChatRoom(HttpServletRequest request, @RequestBody Map<String, String> channelIdMap) {
Map<String, String> userIdAndUserNmInMap = getUserIdAndUserNmInMap(request);
chatRoomService.delete(userIdAndUserNmInMap.get("writerId"), channelIdMap.get("channelId"));
ApiResponse ar = ApiResponse.builder()
.result("")
.resultMsg(SuccessCode.DELETE_SUCCESS.getMessage())
.resultCode(SuccessCode.DELETE_SUCCESS.getStatus())
.build();
return ResponseEntity.ok().body(ar);
}
/**
* Request 안에 존재하는 JWT token 정보를 기반으로 userId, userNm 을 Map 으로 반환하는 메서드
* @param request
* @return
*/
private static Map<String, String> getUserIdAndUserNmInMap(HttpServletRequest request) {
// 1. Request 에서 Header 추출
String header = request.getHeader(AuthConstants.AUTH_HEADER);
// 2. Header 에서 JWT Refresh Token 추출
String token = TokenUtils.getTokenFormHeader(header);
// 3. token 으로부터 userId, userNm 추출
String userId = TokenUtils.getUserIdFormAccessToken(token);
String userNm = TokenUtils.getUserNmFormAccessToken(token);
Map<String, String> chatRoomIdAndNmMap = new HashMap<>();
chatRoomIdAndNmMap.put("writerId", userId);
chatRoomIdAndNmMap.put("writerNm", userNm);
return chatRoomIdAndNmMap;
}
}@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessExceptionHandler.class)
public ResponseEntity<ApiResponse> handleBusinessException(final RuntimeException e) {
log.info("Business Exception Handling Stack Trace : ", e);
ApiResponse ar = ApiResponse.builder()
.result(e.getMessage())
.resultCode(ErrorCode.BUSINESS_EXCEPTION_ERROR.getStatus())
.resultMsg(ErrorCode.BUSINESS_EXCEPTION_ERROR.getMessage())
.build();
return ResponseEntity.ok().body(ar);
}
@ExceptionHandler(RuntimeException.class)
public ResponseEntity<ApiResponse> handleRuntimeException(final RuntimeException e) {
log.info("Global Exception Handling Stack Trace : ", e);
ApiResponse ar = ApiResponse.builder()
.result("")
.resultCode(ErrorCode.BAD_REQUEST.getStatus())
.resultMsg(ErrorCode.BAD_REQUEST.getMessage())
.build();
return ResponseEntity.ok().body(ar);
}
}@Component
@Slf4j
public class ChatErrorHandler extends StompSubProtocolErrorHandler {
public ChatErrorHandler() {
super();
}
@Override
public Message<byte[]> handleClientMessageProcessingError(Message<byte[]>clientMessage, Throwable ex) {
Throwable exception = ex;
if (exception instanceof MessageDeliveryException) {
log.info("메세지 예외 : {}", exception.getMessage(), exception); // 변경 부분
return handleMessageDeliveryException(clientMessage, ex.getMessage(), exception); // 변경 부분
}
if(exception instanceof MalformedJwtException) {
log.info("멀폼 예외 : {}", exception.getMessage(), exception); // 변경 부분
return handleUnauthorizedException(clientMessage, ex.getMessage(), exception); // 변경 부분
}
return super.handleClientMessageProcessingError(clientMessage, ex);
}
private Message<byte[]> handleUnauthorizedException(Message<byte[]> clientMessage, String message, Throwable ex) { // 변경 부분
ErrorResponse errorResponse = ErrorResponse.builder()
.result(message)
.resultCode(ErrorCode.UNAUTHORIZED_ERROR.getStatus()) // 변경 부분
.resultMsg(ErrorCode.UNAUTHORIZED_ERROR.getDivisionCode())
.build();
return prepareErrorMessage(clientMessage, errorResponse, ErrorCode.UNAUTHORIZED_ERROR.getMessage());
}
// 변경 부분
private Message<byte[]> handleMessageDeliveryException(Message<byte[]> clientMessage, String message, Throwable ex) {
ErrorResponse errorResponse = ErrorResponse.builder()
.result(message)
.resultCode(ErrorCode.BUSINESS_EXCEPTION_ERROR.getStatus())
.resultMsg(ErrorCode.BUSINESS_EXCEPTION_ERROR.getDivisionCode())
.build();
return prepareErrorMessage(clientMessage, errorResponse, ErrorCode.UNAUTHORIZED_ERROR.getMessage());
}
private Message<byte[]> prepareErrorMessage(Message<byte[]> clientMessage, ErrorResponse errorResponse, String message) {
StompHeaderAccessor accessor = StompHeaderAccessor.create(StompCommand.ERROR);
accessor.setMessage(message);
accessor.setLeaveMutable(true);
return MessageBuilder.createMessage(message.getBytes(StandardCharsets.UTF_8), accessor.getMessageHeaders());
}
}@RequiredArgsConstructor
@Component
@Slf4j
@Order(Ordered.HIGHEST_PRECEDENCE + 98)
public class ChatRoomPreHandler implements ChannelInterceptor {
private final ChatRoomService chatRoomService;
@Override
public Message<?> preSend(Message<?> message, MessageChannel channel) {
StompHeaderAccessor headerAccessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);
StompCommand stompCommand = headerAccessor != null ? headerAccessor.getCommand() : null;
// SUBSCRIBE(구독) 일 때 만 ChatRoom 에 등록한 채팅방이 있을 경우 로직
if(StompCommand.SUBSCRIBE.equals(stompCommand)) {
log.info("Destination : {}", headerAccessor.getDestination());
String destination = headerAccessor.getDestination() != null ? headerAccessor.getDestination() : null;
String channelId = null;
if(destination == null) {
throw new MessageDeliveryException("Invalid payload");
}
String[] split = headerAccessor.getDestination().split("/sub/chat/");
channelId = split[split.length - 1];
log.info("channelId : {}", channelId);
// ChatRoomService 의 join 후 채팅방이 있을 경우 연결 성공
Optional<ChatRoomDto> join = chatRoomService.join(channelId);
if(join.isEmpty()) {
// 값이 존재하지 않을 경우 연결 실패
throw new MessageDeliveryException("ChatRoom does not exist");
}
}
return message;
}
}- JwtAuthorizationPreHandler 추가
- ChatRoomPreHandler 추가
@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final JwtAuthorizationPreHandler jwtAuthorizationPreHandler; // 변경 부분
private final ChatRoomPreHandler chatRoomPreHandler; // 변경 부분
private final ChatErrorHandler chatErrorHandler;
/**
* 엔드 포인트를 등록하기 위해 registerStompEndpoints 를 override 한다.
* @param registry
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
// 앞으로 웹 소켓 서버의 엔드포인트는 /ws 이다.
registry.addEndpoint("/ws")
.setAllowedOrigins("http://localhost:3000")
.withSockJS();
registry.setErrorHandler(chatErrorHandler);
}
/**
* Message Broker 를 설정하기 위해 configureMessageBroker 를 override 한다.
* @param registry
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
// enableSimpleBroker() 를 사용해서 /sub 가 prefix 로 붙은 destination 의 클라이언트에게
// 메세지를 보낼 수 있도록 Simple Broker 를 등록한다.
registry.enableSimpleBroker("/sub"); // 구독
// setApplicationDestinationPrefixes() 를 사용해서 /pub 가 prefix 로 붙은 메시지들은
// @MessageMapping 이 붙은 method 로 바운드된다.
registry.setApplicationDestinationPrefixes("/pub"); // 발행
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(jwtAuthorizationPreHandler); // 변경 부분
registration.interceptors(chatRoomPreHandler); // 변경 부분
}
}@RestController
@RequiredArgsConstructor
@Slf4j
public class WebSocketController {
private final SimpMessagingTemplate simpleMessagingTemplate;
private final ChatRoomService chatRoomService;
/**
* @MessageMapping annotation 은 메시지의 destination 이 /hello 였다면 해당 sendMessage() method 가 불리도록 해준다.
* - sendMessage() 에서는 simpMessagingTemplate.convertAndSend 를 통해
* /sub/chat/{channelId} 채널을 구독 중인 클라이언트에게 메시지를 전송한다.
* - SimpMessagingTemplate 는 특정 브로커로 메시지를 전달한다.
* - 현재는 외부 브로커를 붙이지 않았으므로 인메모리에 정보를 저장한다.
* 메시지의 payload 는 인자(chatDto)로 들어온다.
* @param chatDto
* @param accessor
*/
@MessageMapping("/chat") // Publish
public void sendMessage(@RequestBody ChatDto chatDto, SimpMessageHeaderAccessor accessor) {
log.info("Channel : {}, getWriterNm : {}, sendMessage : {}", chatDto.getChannelId(), chatDto.getWriterNm(), chatDto.getMessage());
// publish 요청시 channelId 로 조회 후 값이 없을 경우 Exception Throw, 있을 경우 구독한 클라이언트에게 Send
Optional<ChatRoomDto> join = chatRoomService.join(chatDto.getChannelId());
if(join.isEmpty()) {
throw new BusinessExceptionHandler("ChatRoom does not exist", ErrorCode.BUSINESS_EXCEPTION_ERROR);
}
simpleMessagingTemplate.convertAndSend("/sub/chat/" + chatDto.getChannelId(), chatDto);
}
}-
채팅방 생성
-
채팅방 조회
-
채팅방 삭제 성공
-
채팅방 삭제 시 채팅방이 존재하지 않을경우
-
채팅방 삭제 시 사용자의 채팅방이 아닐 경우
-
STOMP 요청 처리 로그 이미지
- Spring Validation을 이용하여 JSON -> Object로 바인딩시 검사
- UserRequest 객체를 만들어서 바인딩 시 검사 조건 정의
- 바인딩에 실패시 Exception을 처리 할 ExceptionHandler 추가
- 사용자의 연결 및 연결 해제 로그를 출력하기 위해 ChatLogHandler를 만들어서WebSocketConfig에 인터셉터 등록
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class UserRequest {
// 사용자 아이디 4 ~ 20자
@Pattern(regexp = "^[a-z0-9]{4,20}$")
@NotBlank
private String userId;
// 사용자 패스워드 "8 ~ 20자 영문 대 소문자, 숫자, 특수문자를 사용
@Pattern(regexp = "(?=.*[0-9])(?=.*[a-zA-Z])(?=.*[#?!@$%^&*-]).{8,20}$")
@NotBlank
private String userPw;
// 사용자 이름
@NotBlank
@Pattern(regexp = "[가-힣]{2,5}")
private String userNm;
@Builder
public UserRequest(String userId, String userPw, String userNm) {
this.userId = userId;
this.userPw = userPw;
this.userNm = userNm;
}
}@RestController
@RequestMapping("/api/chatroom")
@RequiredArgsConstructor
public class ChatRoomController {
private final ChatRoomService chatRoomService;
@PostMapping
public ResponseEntity<ApiResponse> createChatRoom(HttpServletRequest request, @RequestBody Map<String, String> titleMap) {
// ******************* 추가 부분 *******************
// null 또는 공백인지 확인
if(titleMap.get("title").isBlank()) {
throw new BusinessExceptionHandler("Please rewrite the title value", ErrorCode.BUSINESS_EXCEPTION_ERROR);
}
// *************************************************
Map<String, String> userIdAndUserNmMap = getUserIdAndUserNmInMap(request);
ChatRoomDto chatRoomDto = ChatRoomDto.builder()
.channelId(UUID.randomUUID().toString())
.title(titleMap.get("title"))
.writerId(userIdAndUserNmMap.get("writerId"))
.writerNm(userIdAndUserNmMap.get("writerNm"))
.dateTime(LocalDateTime.now())
.build();
chatRoomService.create(chatRoomDto);
ApiResponse ar = ApiResponse.builder()
.result("")
.resultMsg(SuccessCode.INSERT_SUCCESS.getMessage())
.resultCode(SuccessCode.INSERT_SUCCESS.getStatus())
.build();
return ResponseEntity.ok().body(ar);
}
@GetMapping
public ResponseEntity<ApiResponse> findAllChatRoom() {
List<ChatRoomDto> chatRoomDtoList = chatRoomService.findAll();
ApiResponse ar = ApiResponse.builder()
.result(chatRoomDtoList)
.resultMsg(SuccessCode.SELECT_SUCCESS.getMessage())
.resultCode(SuccessCode.SELECT_SUCCESS.getStatus())
.build();
return ResponseEntity.ok().body(ar);
}
@DeleteMapping
public ResponseEntity<ApiResponse> deleteChatRoom(HttpServletRequest request, @RequestBody Map<String, String> channelIdMap) {
// ******************* 추가 부분 *******************
if(channelIdMap.get("channelId").isBlank()) {
throw new BusinessExceptionHandler("Please rewrite the channelId value", ErrorCode.BUSINESS_EXCEPTION_ERROR);
}
// *************************************************
Map<String, String> userIdAndUserNmInMap = getUserIdAndUserNmInMap(request);
chatRoomService.delete(userIdAndUserNmInMap.get("writerId"), channelIdMap.get("channelId"));
ApiResponse ar = ApiResponse.builder()
.result("")
.resultMsg(SuccessCode.DELETE_SUCCESS.getMessage())
.resultCode(SuccessCode.DELETE_SUCCESS.getStatus())
.build();
return ResponseEntity.ok().body(ar);
}
/**
* Request 안에 존재하는 JWT token 정보를 기반으로 userId, userNm 을 Map 으로 반환하는 메서드
* @param request
* @return
*/
private static Map<String, String> getUserIdAndUserNmInMap(HttpServletRequest request) {
// 1. Request 에서 Header 추출
String header = request.getHeader(AuthConstants.AUTH_HEADER);
// 2. Header 에서 JWT Refresh Token 추출
String token = TokenUtils.getTokenFormHeader(header);
// 3. token 으로부터 userId, userNm 추출
String userId = TokenUtils.getUserIdFormAccessToken(token);
String userNm = TokenUtils.getUserNmFormAccessToken(token);
Map<String, String> chatRoomIdAndNmMap = new HashMap<>();
chatRoomIdAndNmMap.put("writerId", userId);
chatRoomIdAndNmMap.put("writerNm", userNm);
return chatRoomIdAndNmMap;
}
}@RestController
@RequiredArgsConstructor
@RequestMapping("/api/users")
@Slf4j
public class UserController {
private final RedisRepository refreshTokenRedisRepository;
private final RedisTemplate<String, String> redisTemplate;
private final UserService userService;
/**
* UserId, UserPw, UserNm 을 받아서 회원가입 (JwtAuthorizationFilter 인증 X)
* @param userDto
* @return ResponseEntity
* 언체크 예외
* @throws BusinessExceptionHandler
*/
@PostMapping("/signup")
public ResponseEntity<ApiResponse> signUp(@Validated @RequestBody UserRequest userRequest) {
UserDto user = UserDto.builder()
.userId(userDto.getUserId())
.userPw(userDto.getUserPw())
.userNm(userDto.getUserNm())
// **** 코드 변경 ****
.userId(userRequest.getUserId())
.userPw(userRequest.getUserPw())
.userNm(userRequest.getUserNm())
// ******************
.userSt("X") // 유저 상태
.build();
userService.signUp(user);
ApiResponse success = ApiResponse.builder()
.result("")
.resultCode(SuccessCode.INSERT_SUCCESS.getStatus())
.resultMsg(SuccessCode.INSERT_SUCCESS.getMessage())
.build();
return new ResponseEntity<>(success, HttpStatus.OK);
}
// ... 기존 코드 생략
}@Component
@Slf4j
public class ChatLogHandler extends ChannelInterceptorAdapter {
@Override
public void postSend(Message<?> message, MessageChannel channel, boolean sent) {
StompHeaderAccessor accessor = StompHeaderAccessor.wrap(message);
switch(Objects.requireNonNull(accessor.getCommand())) {
case CONNECT :
log.info("웹소켓 연결");
break;
case DISCONNECT :
log.info("웹소켓 연결 해제");
break;
default:
break;
}
}
}@Configuration
@EnableWebSocketMessageBroker
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
private final JwtAuthorizationPreHandler jwtAuthorizationPreHandler;
private final ChatRoomPreHandler chatRoomPreHandler;
private final ChatErrorHandler chatErrorHandler;
private final ChatLogHandler chatLogHandler; // 추가
/**
* 엔드 포인트를 등록하기 위해 registerStompEndpoints 를 override 한다.
* @param registry
*/
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws")
.setAllowedOrigins("http://localhost:3000")
.withSockJS();
registry.setErrorHandler(chatErrorHandler);
}
/**
* Message Broker 를 설정하기 위해 configureMessageBroker 를 override 한다.
* @param registry
*/
@Override
public void configureMessageBroker(MessageBrokerRegistry registry) {
registry.enableSimpleBroker("/sub");
registry.setApplicationDestinationPrefixes("/pub");
}
@Override
public void configureClientInboundChannel(ChannelRegistration registration) {
registration.interceptors(jwtAuthorizationPreHandler);
registration.interceptors(chatRoomPreHandler);
registration.interceptors(chatLogHandler); // 추가
}
}@RestControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
/**
* validation 전용 ExceptionHandler
* JSON -> 객체로 바인딩 시 Validation 을 진행 후 바인딩 실패 시 반환되는 Exception
* @param e
* @return ResponseEntity<ApiResponse>
*/
@ExceptionHandler({MethodArgumentNotValidException.class})
public ResponseEntity<ApiResponse> validException(final MethodArgumentNotValidException e) {
log.info("valid Exception Handling Stack Trace : ", e);
ApiResponse ar = ApiResponse.builder()
.result("")
.resultCode(ErrorCode.BAD_REQUEST.getStatus())
.resultMsg(ErrorCode.BAD_REQUEST.getMessage())
.build();
return ResponseEntity.ok().body(ar);
}
// ... 기존 코드 생략
}-
바인딩 시도 중에 Validation 실패 시 ExceptionHandler로 인한 BAD_REQUEST(400) 응답 이미지

-
STOMP 연결 중에 COMMAND가 CONNECT일 경우 로그 이미지(연결 성공)
-
STOMP 연결 중에 COMMAND가 DISCONNECT일 경우 로그 이미지(연결 해제)















