Spring Security를 프로젝트에 적용해보자. 최대한 JWT의 장점을 잘 살릴 수 있는 방법은 무엇일까?
JWT
JWT가 무엇이고, 어플리케이션이 어떤 구조를 띄고 있을 때 가장 효율적일까?
JWT는 무상태 인증 방식으로, 토큰의 Payload에 사용자 정보를 담아 인증을 수행하는 방식이다.
클라이언트가 JWT를 요청 헤더 또는 쿠키에 포함하여 서버에 전달하고, 서버는 이를 검증하여 사용자를 인증한다.
JWT에는 사용자 정보가 포함되어 있기 때문에, 컨트롤러에서 ArgumentResolver등을 활용하여 DB와 추가적인 통신 없이 사용자 객체를 추출할 수 있다.
이러한 특성 덕분에 JWT는 MSA구조에서 유리하다.
각 서비스가 독립적으로 동작하며 인증 상태를 공유할 필요가 없는 구조에서는, 중앙 인증 서버에서 발급한 JWT로 개별 서비스가 사용자 인증을 수행할 수 있기 때문이다.
이를 통해 서비스 간 인증 로직을 간소화하고 확장성을 높일 수 있다.
프로젝트의 인증은 "완벽한 무상태" 가 맞았을까?
전반적으로 잘 지켜졌다.
@Component
public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver {
/**
* 컨트롤러 메서드의 파라미터가 AuthUser 와 일치하는지 확인
* @param parameter 컨트롤러 메서드 파라미터 정보
* @return @AuthUser 일 경우 ture 반환, 아니면 false 반환
*/
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterType().equals(AuthUser.class);
}
/**
* 요청을 보낸 사용자 정보를 JWT 에서 추출해 AuthUser 객체로 변환
* @param parameter 현재 메서드 파라미터 정보
* @param mavContainer 컨트롤러에서 뷰 반환할 때 사용
* @param webRequest http 요청 정보(JWT 에서 사용자 정보를 가져오기 위함)
* @param binderFactory 요청 데이터를 객체에 바인딩
* @return 인증된 사용자의 ID와 역할을 포함한 AuthUser 객체
* @throws ForbiddenException 사용자가 인증되지 않은 경우
*/
@Override
public Object resolveArgument(
@Nullable MethodParameter parameter,
@Nullable ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
@Nullable WebDataBinderFactory binderFactory
) {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
Object userIdObj = request.getAttribute("userId");
Object roleObj = request.getAttribute("userRole");
if (userIdObj == null || roleObj == null) {
throw new ForbiddenException(AuthErrorCode.LOGIN_REQUIRED);
}
Long userId = Long.parseLong(userIdObj.toString());
Role userRole = Role.valueOf(roleObj.toString());
return new AuthUser(userId, userRole);
}
}
ArgumentResolver를 이용해 토큰에서 사용자 정보를 추출하여, AuthUser객체에 userId, userRole을 담아 모든 서비스에서 잘 사용할 수 있었다.
다만 조금 아쉬운 점은,
이렇게 toEntity에 user객체가 그대로 삽입될 때 findById로 DB와 통신할 수 밖에 없었다는 점이다.
lazy로딩을 사용하는 연관관계는 사실 필요없는 것이 아닐까? id만 있으면 모두 저장 가능한데 말이다.
다음에 프로젝트를 진행할 기회가 된다면 연관관계를 삭제해보고싶다. 그리고 해당 구조가 가장 잘 어울리는 아키텍처가 MSA 일 것이다.
Spring Security UserDetails
Spring Security의 구조를 살펴보자.
Spring Security는 외부 요청을 받으면 제일 filterchain을 거치고, authentication manager에서 security context를 만들어낸다.
이 security context에는 userDetails같은 유저 정보가 저장된다.
그럼 이 userDetails는 어떻게 생성할 수 있을까? 아래는 잘못된 예시이다.
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findById(Long.valueOf(username))
.orElseThrow(() -> new BusinessException(MEMBER_NOT_FOUND));
return new UserPrincipal(user);
}
}
중간 과정이 많이 생략되었지만, 토큰에 담긴 id값을 기반으로 DB와 통신하여 context(user객체)를 생성한다.
jwt는 무상태 인증이다. 그런데 spring security에서, 이 코드의 userDetails는 db와 통신한다.
이는 세션 인증 방식에 맞춰진 spring security의 특성 때문이며, 몇몇의 구현체들은 jwt와는 궁합이 맞지 않고 비효율적이다.
header에 들어가는 정보도 세션보다 길고(네트워크 통신 +),
jwt filter에서 유저 정보를 추출하며(어플리케이션 자원 소모 +),
db와 통신하며 유저 객체를 생성한다(네트워크 통신 +).
그야말로 비효율의 총집합이라고 볼 수 있다.
그렇다면 어떻게 해결할 수 있을까?
@Component
@RequiredArgsConstructor
public class JwtAuthenticationProvider implements AuthenticationProvider {
private final JwtUtil jwtUtil;
@Override
public Authentication authenticate(Authentication authentication)
throws AuthenticationException {
String token = (String) authentication.getCredentials();
Claims claims = jwtUtil.extractClaims(token);
if (claims == null) {
throw new RuntimeException("Invalid JWT token");
}
Long userId = Long.parseLong(claims.getSubject());
String email = claims.get("email", String.class);
UserRole userRole = UserRole.valueOf(claims.get("userRole", String.class));
String nickname = claims.get("nickname", String.class);
AuthUser authUser = new AuthUser(userId, email, userRole, nickname);
UserDetails userDetails = User.builder()
.username(authUser.email())
.password("")
.roles(authUser.userRole().name())
.build();
return new UsernamePasswordAuthenticationToken(
userDetails, token, userDetails.getAuthorities());
}
}
UserDetails는 builder패턴을 지원하기 때문에,
이처럼 AuthenticationProvider의 authenticate메서드에서 userDetails 객체를 직접 생성 가능하다.(물론 다른 곳에서도 생성할 수 있다.)
또한 AuthUser같은 새로운 유저 객체를 생성해 사용할 수도 있다.
이제 DB와의 통신 없이 JWT로 spring security의 이점을 누릴 수 있다.
Spring Security의 세션 정책
Spring Security는 기본적으로 세션을 사용하여 인증 정보를 저장한다.
하지만 JWT는 세션을 사용하지 않고, 요청마다 인증을 수행하는 방식이다. 따라서 세션을 끄지 않으면 JWT인증 후에도 SecurityContextHolder가 세션을 사용하려 할 수 있다.
http.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
해당 문제는, SecurityConfig에서 세션 정책을 STATELESS로 변경하면 해결 가능하다.
JwtAuthenticationFilter
Spring Security는 기본적으로 UsernamePasswordAuthenticationFilter로 로그인 요청을 처리한다.
JWT인증은 폼 로그인 방식이 아니라 매 요청마다 토큰을 검증하는 방식이므로, 따로 다른 필터를 추가해야 한다.
이 때 사용할 수 있는 게 OncePerRequestFilter이다.
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtil jwtUtil;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String token = jwtUtil.resolveToken(request);
if (token != null && jwtUtil.validateToken(token)) {
Authentication auth = jwtUtil.getAuthentication(token);
SecurityContextHolder.getContext().setAuthentication(auth);
}
filterChain.doFilter(request, response);
}
}
요청마다 한 번 JWT를 검증하고, 검증이 완료되면 SecurityContextHolder에 인증 정보를 저장한다.
SecurityConfig에 필터로 등록할 때 제일 앞단에 두면 된다.
정리
Spring Security의 많은 구현체들은 세션 기반 인증을 좀 더 편히 지원하고있다.
그렇다고 JWT를 사용하지 못하는가? 그것도 아니다. JWT의 장점을 충분히 살리면서, Spring security가 제공하는 편리한 필터체인, 권한저장, OAuth2.0 등의 기능을 사용할 수 있다.
프로젝트 코드에도 잘 적용해보자!
'팀 프로젝트 > cheerha.project' 카테고리의 다른 글
트러블슈팅: 인증과 인가 (0) | 2025.03.19 |
---|---|
최종 프로젝트 KPT 회고 (0) | 2025.03.17 |
사용자가 엔드포인트를 잘못 입력했을 때, 404를 반환시키자! (0) | 2025.03.15 |