⛔ Spring Security @AuthenticationPrincipal이 null이라고? JWT 환경에서 커스텀 사용자 정보 주입 시 겪었던 삽질과 해결 과정!

⛔ Spring Security @AuthenticationPrincipal이 null이라고? JWT 환경에서 커스텀 사용자 정보 주입 시 겪었던 삽질과 해결 과정!

📌 문제 발단: "분명 로그인했는데… user가 왜 null이지?"

안녕하세요! 오늘은 Spring Boot와 Spring Security, JWT 기반의 인증 시스템을 구축하면서 겪었던 흥미로운(?) 문제와 그 해결 과정을 공유하려 합니다.

한창 개발에 몰두하고 있을 때였습니다.

특정 API에서 로그인한 사용자의 정보를 받아와 DB에 기록해야 하는 로직이 있었죠.

별생각 없이 컨트롤러 파라미터에 @AuthenticationPrincipal AuthenticatedUser user를 선언하고 사용했습니다.

AuthenticatedUser는 제가 시스템 내에서 사용할 커스텀 사용자 정보 DTO였습니다.

그런데 테스트를 해보니 예상치 못한 문제가 발생했습니다.

분명 로그인은 성공했고 토큰도 정상적으로 발급받았는데, 컨트롤러에서 user 객체가 항상 null로 주입되는 것이었습니다! 이 때문에 DB에는 사용자 이름이 anonymous로만 기록되었고, 로그에도 user: null, username: anonymous가 찍히는 것을 확인했습니다.
"아니, 로그인까지 성공했는데 왜 anonymous만 찍히는 거야?" 답답함이 밀려왔죠.

🤔 원인 분석: SecurityContext에 무엇이 들어있나?

처음에는 제가 JWT 필터나 토큰 생성 부분에서 뭔가 잘못했나 싶어 관련 코드를 샅샅이 뒤졌습니다.

하지만 토큰은 정상적으로 파싱되고, SecurityContextHolder.getContext().setAuthentication(auth) 부분도 문제가 없어 보였습니다.

잠시 멍하니 화면을 바라보다 문득 @AuthenticationPrincipal 어노테이션의 본질에 대해 다시 생각해보게 되었습니다.

@AuthenticationPrincipal은 Spring Security의 SecurityContext에 저장된 principal 객체를 주입해주는 역할을 합니다.

이 지점에서 핵심적인 의문이 들었습니다.

"과연 SecurityContext에는 내가 원하는 AuthenticatedUser 객체가 들어있을까?"

저의 JWT 인증 필터 로직을 다시 살펴보니, 다음과 같이 UserDetails 객체를 principal로 설정하고 있었습니다.

// JWT 인증 필터 내부
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);

아하! 여기에 문제가 있었습니다.

SecurityContext에는 제가 만든 AuthenticatedUser가 아니라, Spring Security의 기본 UserDetails 인터페이스를 구현한 객체가 principal로 들어가고 있었던 것입니다.

@AuthenticationPrincipal은 기본적으로 SecurityContext에서 principal을 꺼내와 파라미터 타입에 맞게 주입하려고 시도하는데, UserDetails 타입의 객체를 AuthenticatedUser 타입으로 바로 주입할 수는 없었던 것이죠.

그러니 null이 될 수밖에요!

결론적으로, SecurityContext에는 UserDetails가 있고, AuthenticatedUser는 없었기 때문에 @AuthenticationPrincipal로는 null이 들어왔던 것입니다.

💡 해결의 실마리: 이미 존재하는 커스텀 어노테이션 @CurrentUser

원인을 파악하고 나니 해결책이 보이기 시작했습니다.
다행히도 프로젝트에는 이미 @CurrentUser라는 커스텀 어노테이션과 이를 처리하는 CurrentUserArgumentResolver가 구현되어 있었습니다.
이 ArgumentResolver는 SecurityContext에서 Authentication 객체를 가져와 그 안에 있는 UserDetails에서 필요한 정보를 추출, AuthenticatedUser 객체를 직접 생성하여 주입해주는 역할을 수행합니다.

이전에 비슷한 문제를 겪고 이미 해결해둔 코드였는데, 제가 미처 파악하지 못했던 부분이었죠. AuthenticationPrincipal만 생각하고 무심코 사용했던 제 불찰이었습니다.

해결 방법은 간단했습니다.
컨트롤러 파라미터를 @AuthenticationPrincipal AuthenticatedUser user에서 @CurrentUser AuthenticatedUser user로 변경하는 것이었습니다!

✅ 수정 전 (문제 코드)

@PostMapping("/save")
public ApiResponse<Integer> saveCoSeller(
        @Valid @RequestBody RegionRequestDto regionRequestDto,
        @AuthenticationPrincipal AuthenticatedUser user) { // ❌ 항상 user가 null
    String username = user != null ? user.getUsername() : "anonymous";
    // ...
}

✅ 수정 후 (정상 동작)

import com.antock.global.security.annotation.CurrentUser;
import com.antock.global.security.dto.AuthenticatedUser;

@PostMapping("/save")
public ApiResponse<Integer> saveCoSeller(
        @Valid @RequestBody RegionRequestDto regionRequestDto,
        @CurrentUser AuthenticatedUser user) { // ✅ 이제 user에 로그인한 사용자 정보 정상 주입!
    String username = user != null ? user.getUsername() : "anonymous";
    // ...
}

변경 후 다시 테스트해보니, 로그인한 사용자의 실제 username이 정상적으로 DB에 기록되는 것을 확인할 수 있었습니다.

드디어 anonymous 지옥에서 벗어났습니다!

💫 깊이 있는 이해의 중요성과 재사용성

이번 문제는 저에게 @AuthenticationPrincipal과 SecurityContext의 동작 방식에 대해 더 깊이 이해할 수 있는 계기가 되었습니다.
단순히 "인증된 사용자 정보를 가져오는 어노테이션"이라고만 알고 있었는데, 그 내부에서 어떤 타입의 객체가 어떻게 처리되는지를 명확하게 알게 된 것이죠.

또한, 이미 구현되어 있던 CurrentUserArgumentResolver와 @CurrentUser 어노테이션의 중요성을 다시 한번 깨달았습니다.
특정 요구사항을 위한 커스텀 로직이 미리 준비되어 있었음에도 불구하고, 제가 이를 제대로 파악하지 못해 불필요한 시간을 낭비했습니다.
이는 기존 코드 베이스를 더 꼼꼼히 리뷰하고 활용하는 습관의 중요성을 일깨워주었습니다. 잘 만들어진 코드는 재사용성을 높여 개발 효율을 극대화한다는 것을 몸소 체험했습니다.

결론적으로, Spring Security에서 UserDetails 타입이 아닌 커스텀 인증 객체를 컨트롤러에서 편리하게 사용하고 싶다면, ArgumentResolver와 커스텀 어노테이션 패턴을 적극적으로 활용해야 한다는 점을 명심해야겠습니다.
앞으로는 이런 작은 실수로 시간을 낭비하지 않도록 더욱 주의하고, 코드의 동작 원리를 더 깊이 파고드는 개발자가 되겠습니다!

한 줄 요약

Spring Security에서 커스텀 인증 객체를 컨트롤러에서 받으려면 @AuthenticationPrincipal 대신 @CurrentUser를 써야 한다!

profile
꾸준히, 의미있는 사이드 프로젝트 경험과 문제해결 과정을 기록하기 위한 공간입니다.

0개의 댓글