[Hoops PJ] 트러블 슈팅 & TIL (6)
JWT 토큰 난 좀 더 편하게 쓰고 싶은데
jwt 토큰을 통해 사용자를 인증하는 방법은 여러가지가 있다.
1. SecurityContextHolder에서 직접 가져오는 방법
2. @AuthenticationPrincipal
사용해서 가져오는 방법
3. 그냥 내 상황에 맞게 만들어서 사용하는 방법
나는 3번, 내 상황에 맞게 커스텀을 하여 따로 컴포넌트를 만들어서 사용했는데 이렇게 만든 이유는 확장성 및 유지보수를 고려 했을 때 더 나은 방법이라고 생각했기 때문이다.
이렇게 만들어둔 컴포넌트는 정말 신기하게도 나중에 확장하게 되는데 해당 트러블 슈팅은 컴포넌트를 확정하고 활용하게 된 과정을 기록하고자 쓰게 되었다.
우선 jwt 토큰을 가져와서 활용하는 방법 몇 가지를 간단히 기록하고 트러블 슈팅까지의 과정을 기록…
1. SecurityContextHolder
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
UserDetails userDetails = (UserDetails)principal;
String username = principal.getUsername();
String password = principal.getPassword();
첫 번째는 가장 직접적인 방법으로 ‘SecurityContextHolder’에 직접 접근하여 로그인 객체를 가져오는 방법이 있다.
https://wildeveloperetrain.tistory.com/324#google_vignette
해당 글에서는 이 코드를 static 메서드로 만들어서 로그인 객체가 필요한 요청마다 호출하게 된다면 코드가 반복적으로 사용되기 때문에 비효율적이라고 설명하는 걸 봤었는데…
개인적인 생각으로는 컨트롤러 단에서 @AuthenticationPrincipal
또한 그렇게 다르다고 생각하지 않았다.
오히려 @AuthenticationPrincipal
또한 타입을 신경쓰지 않는다면 문제가 생길 수 있다.
예시 상황
- 사용자 정보를 담고 있는 CustomUserDetails 클래스가 있다고 가정해보자.
- 이 CustomUserDetails 클래스는 UserDetails 인터페이스를 구현하고 있다.
@AuthenticationPrincipal 사용 시 문제점
- 직접 캐스팅 필요:
- @AuthenticationPrincipal 어노테이션을 사용하면 반환되는 객체는 Object 타입이다.
- 따라서 컨트롤러 메서드에서 해당 객체를 사용하려면 직접 CustomUserDetails 타입으로 캐스팅해야 한다.
- 예시 코드:
@GetMapping("/profile") public String getProfile(@AuthenticationPrincipal Object principal) { CustomUserDetails userDetails = (CustomUserDetails) principal; // userDetails 객체를 사용하여 프로필 정보 처리 }
- 런타임 오류 발생 가능:
- 만약 인증 과정에서 principal 객체가 CustomUserDetails 타입이 아닌 다른 타입으로 반환된다면, 캐스팅 과정에서 ClassCastException이 발생할 수 있다.
그럼 @AuthenticationPrincipal
가 어떤 특징을 갖고 있는지 간단하게 살펴보자
2. @AuthenticationPrincipal
Spring Security의 @AuthenticationPrincipal
애노테이션을 사용하면 컨트롤러 메서드의 인자로 현재 인증된 사용자의 주요 정보를 직접 주입받을 수 있다.
장점
- 간결성: 컨트롤러 메서드에서 직접 인증된 사용자의 정보를 인자로 받을 수 있으므로, 별도의 컴포넌트를 호출하거나 인증 객체를 수동으로 추출하는 등의 추가적인 작업이 필요 없어 코드가 간결해진다.
- 유연성:
@AuthenticationPrincipal
은 커스텀UserDetailsService
에서 반환하는 사용자 정의 타입으로도 사용할 수 있어, 다양한 타입의 사용자 정보에 쉽게 접근할 수 있다. - Spring Security 통합: Spring Security를 통해 Security 컨텍스트에 접근하는 표준화된 방법을 제공한다.
단점
- 유연성의 제한: 특정 메서드에서만 사용자 정보가 필요한 경우에 유용하지만, 애플리케이션의 여러 부분에서 사용자 정보에 접근해야 하는 경우,
@AuthenticationPrincipal
을 사용하는 것이 반복적이고 비효율적일 수 있다.
3. JwtTokenExtract
@AuthenticationPrincipal
를 사용하지 않고 따로 JwtTokenExtract 컴포넌트를 새로 만들어서 사용하게 된 이유는 처음에 @AuthenticationPrincipal
의 존재 여부를 몰랐었다;;
그래서 당시에는 static으로 선언해서 아래와 같이 필요한 곳에서 호출해서 사용했었다.
public class JwtTokenExtract {
public static UserEntity currentUser() {
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
return (UserEntity) authentication.getPrincipal();
}
}
로컬 API 테스트를 확인하고 마무리로 테스트코드를 작성하는데 static 으로 선언된 메서드를 테스트 하는 게 생각보다 어려웠다.
인터넷을 뒤져서 찾은 정보로는 static으로 선언된 메서드도 테스트를 할 수는 있지만 안티 패턴이라고 추천되지 않는다는 글들을 많이 봤었다.
그렇다면 static 으로 불러서 사용하는 게 아니라 bean으로 만들어서 주입하면 된다고 생각 했다.
오히려 bean으로 만들어서 관리한다면 의존성 주입도 그렇고 여러가지로 훨씬 더 편해질 것 같아서 다음과 같이 수정했다.
@Component
public class JwtTokenExtract {
public UserEntity currentUser() {
Authentication authentication =
SecurityContextHolder.getContext().getAuthentication();
return (UserEntity) authentication.getPrincipal();
}
}
사실 여기까지는 @AuthenticationPrincipal
와 비교했을 때 장점이 따로 없다.
그나마 좀 장점을 갖는건 상황에 맞게 아래와 같이 좀 바꿀 수 있는정도
@Component
@RequiredArgsConstructor
public class JwtTokenExtract {
private final JwtParser jwtParser;
private final UserRepository userRepository;
public UserEntity currentUser() {
Authentication authentication = SecurityContextHolder.getContext()
.getAuthentication();
if (authentication == null || !authentication.isAuthenticated()
|| authentication.getPrincipal() == null) {
throw new CustomException(ErrorCode.EXPIRED_TOKEN);
}
if (authentication.getPrincipal() instanceof UserEntity) {
return (UserEntity) authentication.getPrincipal();
} else {
throw new CustomException(ErrorCode.EXPIRED_TOKEN);
}
}
하지만! JwtTokenExtract
를 확장하게 될 일이 생겼는데 그건 바로 웹소켓에서도 사용하게 된 점이다.
4. JwtTokenExtract 웹소켓 확장
조금 간략하게 이야기 하자면 팀프로젝트 기간 한 달 동안 채팅을 맡은 팀원 A 가 있었는데 알고보니 아주 큰 문제가 있었다.
그 때 당시 프로젝트 종료일까지는 5일이 남은 상태였는데도 결과물이 계속 나오지 않아서 추궁? 하다가 결국은 문제가 많았던 걸 알게 되었다.
추궁을 시작하게 된 계기도 결과물이 하나도 없으니 현재 어디까지 개발 되었고 테스트를 어떻게 하고 있냐고 물어보다가 알게 되었는데 채팅 테스트를 로그인 없이 한다는 것이였다…
그래서 해당 팀원 A가 작성한 코드를 살펴보니 채팅방에서 메세지를 보낼 때 누가누군지 제대로 식별을 할 수 있는 코드가 없었다.
팀원 A도 잘못이 있었지만 당시에 팀원A를 너무 믿었던 것 또한 내가 잘못한 부분 이였다…
결과물이 없으니 PR도 없었고 그러다 보니 너무 안일하게 생각했었던 것 같다…
또한 채팅기능의 또 다른 문제점은 채팅방을 개설하게 되면 채팅방을 만든 사람이 승인해준 사람만 해당 채팅룸에 들어가야 하는 데 그러한 로직도 없었다.
(로그인으로 사용자 식별 과정이 없으니 어떻게 보면 당연한 결과였다 ㅜ)
이 때 당시 나는 개인적으로 컨트롤러 단에서 @AuthenticationPrincipal
사용하지 않고 JwtTokenExtract
를 필요한 곳에서 호출해서 사용하고 있어서 이걸 웹소켓에서도 사용 가능하게 확장하면 된다고 생각해서 다음과 같이 적용해봤다.
웹소켓에서의 인증방식
웹소켓을 사용하지 않고 채팅을 구현한다면 단방향 request, response로 서버와 유저들간의 불필요한 핑퐁핑퐁을 해야한다.
한마디로 HTTP로는 실시간 통신에 적합하지 않으며 다수가 이야기하는 그룹채팅에서 해당 방식으로 구현한다면 불필요한 리소스가 많이 발생한다.
하지만 웹소켓에서는 처음에 한 번 요청을 보낼 때 연결이 된다면 서버의 응답을 기다릴 필요가 없어진다.
그래서 컨트롤러단이 아닌 필터단에서 사용자의 유효성 여부를 판단하고 조건이 맞지 않는 애들은 그냥 통신을 끊어주면 된다.
JwtTokenExtract + 웹소켓
@Component
@RequiredArgsConstructor
public class JwtTokenExtract {
private final JwtParser jwtParser;
private final UserRepository userRepository;
public UserEntity currentUser() {
// 생략
}
// 웹소켓 부분
public UserEntity getUserFromToken(String authorizationHeader) {
if (authorizationHeader == null || !authorizationHeader.startsWith(
"Bearer ")) {
throw new CustomException(ErrorCode.INVALID_TOKEN);
}
String token = authorizationHeader.substring(7);
try {
Jws<Claims> claimsJws = jwtParser.parseClaimsJws(token);
String userLoginId = claimsJws.getBody().getSubject();
UserEntity userEntity = userRepository.findByLoginIdAndDeletedDateTimeNull(
userLoginId)
.orElseThrow(
() -> new CustomException(ErrorCode.USER_NOT_FOUND));
if (userEntity == null) {
throw new CustomException(ErrorCode.EXPIRED_TOKEN);
}
return userEntity;
} catch (JwtException e) {
throw new CustomException(ErrorCode.INVALID_TOKEN);
}
}
}
컴포넌트로 관리하는 JwtTokenExtract
에다가 프론트에서 넘겨 받을 json 토큰을 파싱해 주는 로직을 추가해줬다.
이제 이걸 웹소켓의 환경 설정하는 부분에서 방장이 만든 채팅룸과 해당 채팅룸에 초대가 되어있는지의 유효성을 JwtTokenExtract
로 뽑아서 확인한 후 조건에 맞지 않을 경우 채팅방 입장이 불가하게 만들어주면 된다.
@Configuration
@RequiredArgsConstructor
@EnableWebSocketMessageBroker
public class WebSocketConnectionConfig implements WebSocketMessageBrokerConfigurer {
private final JwtTokenExtract jwtTokenExtract;
private final ParticipantGameRepository participantGameRepository;
//(생략)
@Override
public void configureClientInboundChannel(
ChannelRegistration registration) {
registration.interceptors(new ChannelInterceptor() {
@Override
public Message<?> preSend(Message<?> message,
MessageChannel channel) {
StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(
message, StompHeaderAccessor.class);
if (StompCommand.CONNECT.equals(accessor.getCommand())) {
String authToken = accessor.getFirstNativeHeader(
"Authorization");
String gameId = accessor.getFirstNativeHeader("gameId");
if (!isValidUser(authToken, gameId)) {
throw new CustomException(ErrorCode.NOT_ACCEPT_USER_FOR_GAME);
}
}
return message;
}
});
}
private boolean isValidUser(String token, String gameId) {
Long gameIdNumber = Long.parseLong(gameId);
UserEntity user = jwtTokenExtract.getUserFromToken(token);
if (user == null) {
return false;
}
return participantGameRepository.existsByGame_IdAndUser_IdAndStatus(
gameIdNumber, user.getId(), ParticipantGameStatus.ACCEPT);
}
}
@Service
@RequiredArgsConstructor
public class ChatService {
private final ChatRoomRepository chatRoomRepository;
private final SimpMessagingTemplate messagingTemplate;
private final JwtTokenExtract jwtTokenExtract;
private final MessageRepository messageRepository;
public void sendMessage(ChatMessage chatMessage, String gameId,String token) {
Long gameIdNumber = Long.parseLong(gameId);
UserEntity user = jwtTokenExtract.getUserFromToken(token);
ChatRoomEntity chatRoom = chatRoomRepository.findByGameEntity_Id(gameIdNumber).orElseThrow(() -> new CustomException(ErrorCode.NOT_EXIST_CHATROOM));
Long sessionId = chatRoom.getSessionId();
if (sessionId == null) {
throw new CustomException(ErrorCode.NOT_EXIST_CHATROOM_SESSION);
}
MessageDto message = MessageDto.builder()
.content(chatMessage.getContent())
.sessionId(sessionId)
.build();
messageRepository.save(message.toEntity(user, chatRoom));
chatMessage.changeNewSessionId(sessionId);
messagingTemplate.convertAndSend("/topic/" + gameId, chatMessage);
}
위와 같이 JwtTokenExtract
를 만들어서 필터단과 서비스단등 필요한 곳에서 적절하게 사용 가능하게 만들었다.
웹소켓을 연결했다가 끊어졌더라도, 예를 들어 인터넷 창을 종료했다가 다시 접속했을 때, 대화내용이 그대로 유지할 수 있도록 하면서 누가 해당 메세지를 언제 보냈는지 기록을 하기 위해서도JwtTokenExtract
를 사용해주면 된다.
저장된 대화내용들은 기존 유저가 다시 웹소켓에 연결할 때 db에 저장된 이전 대화내용들을 불러올 수 있다.
하지만 여기에는 치명적인 버그가 있었는데 다음글에 이어서 작성….
댓글남기기