TIL(Today I Learned)/트러블슈팅

[트러블 슈팅 / ~ 최종발표] 최종 프로젝트 : 9kcal. 오늘 뭐 먹지?

jiyoon0000 2025. 4. 19. 02:21

1. WebSocket, StompHandler 인증 방식 변경 - 메시지 단위 JWT 검증 적용

1) 개요

WebSocket 기반의 채팅 기능을 구현하면서, 기존에는 연결 시점(CONNECT)에만 토큰 검증이 이루어져 이후의 메시지 송신에는 보안 취약점이 존재

따라서 메시지 단위에도 JWT를 직접 검증하는 로직을 추가해 보안

 

2) 문제상황

  • 기존 구조(StompHandler)는 StompCommand.CONNECT에서만 JWT 인증 수행
  • 이후 메시지(SEND)에서는 SecurityContext를 활용해 인증된 상태로 처리됨
  • 하지만, 사용자가 WebSocket 연결 이후 로그아웃하거나 탈퇴 또는 추방된 경우에도 여전히 메시지를 보낼 수 있음

결론 : 따라서 메시지 전송 시점마다 JWT 재검증이 필요

 

3) 해결

  • ChatMessageDto에 token 필드를 포함시켜, 메시지 본문에 JWT를 함께 전달하도록 구성
  • WebSocketChatController의 @MessageMapping("/chat") 메서드 내에서 전달된 JWT 토큰을 파싱 및 검증, 이메일 추출 후 실제 사용자 정보와 sender 정보 일치 여부를 확인 후 유효하지 않거나 위조된 토큰, 발신자 불일치 시 예외 발생
package com.example.tastefulai.domain.chatting.dto;

import com.example.tastefulai.domain.chatting.entity.ChattingMessage;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.EqualsAndHashCode;
import lombok.Getter;

@Getter
@EqualsAndHashCode
public class ChattingMessageResponseDto {

    private final Long senderId;
    private final String senderNickname;
    private final String message;
    private final Long chattingroomId;

    @JsonCreator
    public ChattingMessageResponseDto(
            @JsonProperty("senderId") Long senderId,
            @JsonProperty("senderNickname") String senderNickname,
            @JsonProperty("message") String message,
            @JsonProperty("chattingroomId") Long chattingroomId) {
        this.senderId = senderId;
        this.senderNickname = senderNickname;
        this.message = message;
        this.chattingroomId = chattingroomId;
    }

    public static ChattingMessageResponseDto fromEntity(ChattingMessage chattingMessage) {
        return new ChattingMessageResponseDto(
                chattingMessage.getMember().getId(),
                chattingMessage.getMember().getNickname(),
                chattingMessage.getMessage(),
                chattingMessage.getChattingroom().getId()
        );
    }
}
package com.example.tastefulai.domain.chatting.websocket.controller;

import com.example.tastefulai.domain.chatting.dto.ChattingMessageResponseDto;
import com.example.tastefulai.domain.chatting.redis.RedisPublisher;
import com.example.tastefulai.domain.chatting.websocket.dto.ChatMessageDto;
import com.example.tastefulai.domain.member.entity.Member;
import com.example.tastefulai.domain.member.service.MemberService;
import com.example.tastefulai.global.error.errorcode.ErrorCode;
import com.example.tastefulai.global.error.exception.CustomException;
import com.example.tastefulai.global.util.JwtProvider;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.stereotype.Controller;

@Slf4j
@Controller
@RequiredArgsConstructor
public class WebSocketChatController {

    private final RedisPublisher redisPublisher;
    private final JwtProvider jwtProvider;
    private final MemberService memberService;

    @MessageMapping("/chat")
    public void publishMessage(ChatMessageDto chatMessageDto) {

        String token = chatMessageDto.getToken();
        if (token == null || !jwtProvider.validateToken(token)) {
            throw new CustomException(ErrorCode.INVALID_TOKEN);
        }

        String email = jwtProvider.getEmailFromToken(token);
        log.info("토큰에서 추출된 이메일: {}", email);

        Member sender = memberService.findByEmail(email);

        if (!sender.getNickname().equals(chatMessageDto.getSender())) {
            throw new CustomException(ErrorCode.UNAUTHORIZED_MEMBER);
        }

        log.info("메시지 발신자: {}", chatMessageDto.getSender());

        ChattingMessageResponseDto chattingMessageResponseDto = new ChattingMessageResponseDto(
                sender.getId(),
                sender.getNickname(),
                chatMessageDto.getMessage(),
                chatMessageDto.getChattingroomId());

        redisPublisher.publishMessage(chatMessageDto.getChattingroomId(), chattingMessageResponseDto);
        log.info("Redis 메시지 전송 완료");
    }
}

 

결론 : 메시지를 보낼 때마다 토큰 유효성을 직접 확인하는 구조로 변경됨

즉, 메시지 단위에서 인증 로직을 별도로 수행하여 보안성 강화

 

4) 결론

StompHandler에서는 여전히 초기 연결 시 인증을 수행하지만, 이후 메시지 전송 단계에서도 추가적인 인증이 필요하다는 판단에 따라 메시지 레벨에서 JWT를 직접 검증하는 구조를 적용

즉, WebSocketChatController에서 메시지마다 토큰을 직접 검증하여 보안성을 강화

이를 통해 연결 이후 사용자 상태가 변경되더라도 보안이 유지되고, 사용자 인증의 일관성과 채팅 기능의 안전성을 확보

 

2. 테스트 코드 build 실패 - H2 설정 누락으로 인한 DataIntegrityViolationException 발생

1) 개요

단위 테스트를 위해 @DataJpaTest를 사용했지만, MySQL Dialect가 유지된 상태에서 테스트 실행을 시도하면서 테스트 코드 빌드가 실패함

이는 H2를 테스트용 DB로 사용하는 Spring Boot 기본 설정과 충돌한 것이 원인

 

2) 문제상황

  • 테스트 클래스에 @DataJpaTest 를 사용하여 Repository 테스트를 진행 -> application.properties에 운영용 설정이 포함
spring.jpa.database-platform=org.hibernate.dialect.MySQL8Dialect
  • 이로 인해, 테스트는 MySQL Dialect를 기준으로 실행되었고, H2에서 MySQL 고유 제약조건인 UNIQUE 인덱스가 제대로 반영되지 않아 오류 발생
org.springframework.dao.DataIntegrityViolationException: could not execute statement 
[Unique index or primary key violation: "PUBLIC.UKMBMCQELTY0FBRVXP1Q58DN57T_INDEX_8 
ON PUBLIC.MEMBER(EMAIL NULLS FIRST) VALUES ( /* 2 */ 'testUser@example.com' )"; 
SQL statement:

 

3) 해결

  • 테스트 환경에서는 H2에 맞는 Dialect를 사용해야 함
  • 테스트 전용 설정 파일 application.properties 수정
spring.jpa.database-platform=org.hibernate.dialect.H2Dialect
  • H2는 메모리 기반이기 때문에 빠르고, 테스트 간 간섭이 없으며, 테스트가 끝나면 DB가 자동으로 초기화되어 신뢰성이 높음
  • 대부분의 Spring Boot 프로젝트에서는 기본 테스트 DB로 H2를 사용

 

4) 결론

  • 단위 테스트에서는 운영 DB(MySQL)가 아닌 H2 환경에서 실행되는 점을 고려하여 Dialect를 맞춰야 함
  • @DataJpaTest는 기본적으로 H2를 사용하므로, H2Dialect로 설정을 전환해야 오류를 방지할 수 있음
  • 이 과정을 통해 테스트 환경과 운영 환경 설정 분리의 중요성을 다시 한번 깨달았으며, 테스트 코드를 작성할 때는 환경 설정도 신경써야 한다는 점을 배움