Skip to content

실시간 채팅 테스트 토이 프로젝트 (JSON Web Token, Simple Text Oriented Messaging Protocol)

Notifications You must be signed in to change notification settings

nineto6/BE-Chat

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

62 Commits
 
 
 
 
 
 

Repository files navigation

🛠️NINETO6 사이드프로젝트 만들기

시작하기 전에...

  1. WebSocket 위에서 동작하는 STOMP(Simple Text Oriented Messaging Protocol)를 이용하여 실시간 채팅방을 구현해보려 한다.
  2. Spring Security와 Json Web Token을 이용하여 REST API 및 STOMP 인증 처리를 구현해보려 한다.

Spring Boot + React 프로젝트

현재 Back-EndFront-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

Refresh-Token을 가지고 Access-Token을 재발급

기능 URL
재발급 [GET] /api/users/reissue

출처

개발 환경

  • 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


20230608

계획

  • STOMP를 이용하여 간단하게 구독 및 발행 과 MessageMapping을 이용하여 Front와 연결 및 실시간 채팅이 가능한지 코드 작성 및 확인

application.properties 코드 추가

  • 로그 출력
logging.level.hello.chat=trace

WebSocketConfig 작성

@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"); // 발행
    }
}

ChatDto 작성

/**
 * 통신시에 주고 받을 메시지 형식을 작성
 * RestController 의 경우 @RequestBody 가 쓰일 Dto 는 Setter 가 필요 없다.
 * (ObjectMapper 를 통해 변환이 이루어지기 때문)
 */
@Getter
@Builder
public class ChatDto {
    private String channelId;
    private String writerNm;
    private String message;
}

WebSocketController 작성

@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 이미지



20230616

계획

  • 사이드 프로젝트 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로 바꾸어준다.

ErrorCode 코드 추가

  • 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;
    }
}

TokenUtils 코드 변경

  • 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;
        }
    }

    // ... 기존 코드
}

JwtAuthorizationFilter 코드 변경 및 추가

  • 추가 부분 : 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();
        }
    }
    // ... 기존 코드
}

ChatPreHandler 작성

@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;
    }
}

ChatErrorHandler 작성

@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());
    }
}

WebSocketConfig 코드 추가

  • 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



20230620

계획

  • 인증된 사용자가 채팅방 생성 및 제거를 할 수 있으며, 채팅방을 생성 후 입장해야지만 실시간 채팅을 할 수 있게 한다. (채팅방 생성시 UUID를 부여한다)
  • 인증된 사용자는 자신이 생성한 채팅방만 제거를 할 수 있다.
  • subscribe(구독)의 경우 ChatRoomPreHandler 를 이용하여 생성 된 채팅방인지 체크한다.
  • publish(발행)의 경우 WebSocketController에서 MessageMapping을 이용한 메서드에서 구독한 클라이언트에게 발행한 메세지를 보내기 전에 생성 된 채팅방인지 체크한다.

TB_CHAT_ROOM.sql 추가

  • 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
);

SuccessCode 코드 변경

  • 성공 코드의 '코드 값'을 반환하는 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;
    }
}

ChatPreHandler를 JwtAuthorizationPreHandler로 이름 변경

ChatRoomDto 작성

@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;
    }
}

ChatRoomMapper 작성

@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 채널 아이디로 삭제
}

ChatRoomMapper.xml 작성

<?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>

테스트 케이스 전용 H2 Database 생성 및 Spring Profile 설정

  • 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

ChatRoomMapperTest 작성 및 테스트

@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");
    }
}


20230625

ChatRoomService 작성

public interface ChatRoomService {
    void create(ChatRoomDto chatRoomDto);
    Optional<ChatRoomDto> join(String chanelId);
    List<ChatRoomDto> findAll();
    void delete(String writerId, String channelId);
}

ChatRoomServiceImpl 작성

@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);
    }
}
}

ErrorCode 코드 변경

@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;
    }
}

ChatRoomController 작성

@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;
    }
}

GlobalExceptionHandler 작성

@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);
    }
}

ChatErrorHandler 코드 변경

@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());
    }
}

ChatRoomPreHandler 작성

@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;
    }
}

WebSocketConfig 코드 변경

  • 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); // 변경 부분
    }
}

WebSocketController 코드 변경

@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);
    }
}

실행 결과

  • 채팅방 생성

    • 로그 이미지
    • 응답 이미지
  • 채팅방 조회

    • 로그 이미지
    • 응답 이미지
  • 채팅방 삭제 성공

    • 로그 이미지
    • 응답 이미지
  • 채팅방 삭제 시 채팅방이 존재하지 않을경우

    • 로그 이미지
    • 응답 이미지
  • 채팅방 삭제 시 사용자의 채팅방이 아닐 경우

    • 로그 이미지
    • 응답 이미지
  • 클라이언트 채팅방 이미지(로그인 O)

  • STOMP 요청 처리 로그 이미지

    • publish(발행)
    • subscribe(구독)


20230709

계획

  • Spring Validation을 이용하여 JSON -> Object로 바인딩시 검사
  • UserRequest 객체를 만들어서 바인딩 시 검사 조건 정의
  • 바인딩에 실패시 Exception을 처리 할 ExceptionHandler 추가
  • 사용자의 연결 및 연결 해제 로그를 출력하기 위해 ChatLogHandler를 만들어서WebSocketConfig에 인터셉터 등록

UserRequest 작성

@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;
    }
}

ChatRoomController 코드 변경

@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;
    }
}

UserController 코드 변경

@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);
    }

    // ... 기존 코드 생략
}

ChatLogHandler 작성

@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;
        }
    }
}

WebSocketConfig 코드 추가

@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); // 추가
    }
}

GlobalExceptionHandler에 ExceptionHandler 메서드 추가

@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) 응답 이미지

  • 바인딩 시도 중에 Validation 성공 응답 이미지

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

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



About

실시간 채팅 테스트 토이 프로젝트 (JSON Web Token, Simple Text Oriented Messaging Protocol)

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages