문제 개요
우리 서비스는 jwt와 함께 보내는 요청을 모두 JwtAuthenticationFilter로 검증하도록 하고 있는데, 코드 리뷰 중 DB 조회가 너무 잦은 것 같다는 피드백이 나왔다.
필터에서는 jwt를 검증 후 사용자 정보를 추출하여 사용자를 조회하고 이를 컨텍스트에 저장하고 있다. 사용자 인증에 UserDetailsService.loadUserByUsername()를 사용하는 Spring Security의 구조를 그대로 활용한 것이다.
- 기존 JwtAuthenticationFilter의 doFilterInternal()
// loadUserByUsername이 사용되는 곳
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
// 헤더에서 토큰 추출
String headerToken = extractTokenFromHeader(request);
// 쿠키에서 토큰 추출
String cookieToken = extractTokenFromCookie(request);
// 둘 중 하나라도 있으면 있증 처리
String token = headerToken != null ? headerToken : cookieToken;
if (token != null) {
try {
// 블랙리스트 확인
if (isTokenBlacklisted(token)) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "로그아웃된 토큰입니다.");
return;
}
Claims claims = jwtUtils.getClaims(token);
String email = claims.getSubject();
String role = claims.get("role", String.class);
Boolean isVerified = claims.get("isEmailVerified", Boolean.class);
if (email != null && Boolean.TRUE.equals(isVerified)) {
// UserDetails 객체 생성 (DB에서 사용자 정보 조회)
UserDetails userDetails = userDetailsService.loadUserByUsername(email);
List<GrantedAuthority> authorities = List.of(new SimpleGrantedAuthority("ROLE_" + role));
// UserDetails 기반으로 Authentication 객체 생성
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, authorities);
// SecurityContextHolder에 인증 정보 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "이메일 인증이 완료되지 않았습니다.");
return;
}
} catch (ExpiredJwtException e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "토큰이 만료되었습니다.");
return;
} catch (JwtException e) {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "유효하지 않은 토큰입니다.");
return;
}
}
// 검증 후 다음 필터로
filterChain.doFilter(request, response);
}
오류 메시지 및 원인 분석
public class CustomUserDetails implements UserDetails {
private final Member member;
public CustomUserDetails(Member member) {
this.member = member;
}
...(생략)
}
// CustomUserDetailsService.java
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
Member member = memberRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("사용자를 찾을 수 없습니다."));
return new CustomUserDetails(member);
}
문제가 되었던 부분은 위 코드다.
우리가 구현한 CustomUserDetails는 Member를 필드로 가지기 때문에 loadUserByUsername에서는 DB를 조회해 사용자를 찾는다. 문제는 이 loadUserByUsername()이 필터에서 불리고, 필터는 토큰을 가진 모든 요청을 검증한다는 것이다.
다시 말해, 로그인한 사용자가 요청을 할 때마다 DB 조회를 하게 된다.
- Spring Security와 UserDetails
Spring Security에 의해 인증된 사용자의 정보는 SecurityContext에 저장된다. 요청마다 독립된 SecurityContext를 가지며, 우리 서비스에서는 주로 컨트롤러의 파라미터에서 @AuthenticationPrincipal으로 저장된 인증 객체를 꺼내 쓴다. (*SecurityContextHolder는 SecurityContext를 보관하는 전역 저장소이다.)
엄밀히 말해 SecurityContext에 꼭 UserDetails를 담아야 하는 것은 아니다. Authentication 객체이기만 하면 되고, 일반적으로 Authentication의 principal 필드에 UserDetails 구현체를 담는 것 뿐이다. principal 자체는 Object이기 때문에 이메일이나 ID 같이 사용자 정보에 해당하는 아무 값이나 넣어도 된다.
다만 Spring Security에서 관련 기능을 아주 잘 제공하고 있기 때문에 확장성이나 호환성을 위해 우리 서비스에서는 UserDetails를 CustomUserDetails로 구현해 사용했다.
// 기존 JwtAuthenticationFilter의 일부
// UserDetails 기반으로 Authentication 객체 생성
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
// SecurityContextHolder에 인증 정보 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
https://docs.spring.io/spring-security/reference/servlet/authentication/passwords/user-details.html
- Authentication Filter: JwtAuthenticationFilter
- Authentication Manager : SecurityConfig에 빈으로 등록된 authenticationManager
- AuthenticationProvider: SecurityConfig에 빈으로 등록된 authenticationProvider
- UserDetailsService: CustomUserDetailsService
- PasswordEncoder: SecurityConfig에 빈으로 등록된 passwordEncoder
- SecurityContext: JwtAuthenticationFilter에서 저장
해결 및 고민 과정
총 네가지 선택지를 고려했다.
- 그대로 두기
- 기존 방법은 Spring Security와 가장 유사한 방법이다. 매번 DB 조회를 한다는 점에서 매우 비효율적이지만 한편으로는 매번 jwt를 기반으로 사용자 존재 여부를 확인하기 때문에 보안이 확실하다.
- 캐싱하기당당히 캐시를 사용하겠다는 포부를 밝히니 팀원에게 적절하지 않을 것 같다는 피드백을 받았다. 맞는 말이다. 서비스를 사용 중인 모든 사용자를 캐싱할 수도 없는 일이고 어떤 데이터를 넣을 지도 모호하다.
- 이젠 내게 만능해결책으로 느껴진다..뭐든 일단 Redis에 박으면 해결될 것만 같다.
- 토큰에 정보를 더 많이 넣기
- 토큰 생성 코드
// JWT 생성 메서드 public String createToken(String email, Map<String, Object> claims) { SecretKey key = getSecretKey(); Date issuedAt = new Date(); return Jwts.builder() .subject(email) // 사용자 식별자(email) .claims(claims) // 사용자 정보 포함 .issuedAt(issuedAt) // 발급 시간 .expiration(new Date(issuedAt.getTime() + expiration)) // 만료 시간 .signWith(key) // 알고리즘 자동 인식 (HS256) .compact(); }
- Map<String, Object> claims = new HashMap<>(); claims.put("isEmailVerified", member.isEmailVerified()); claims.put("role", member.getRole()); // token 생성 String accessToken = jwtUtils.createToken(member.getEmail(), claims);
- 토큰 생성 코드
- JWT를 믿기 🙏🙏🙏
- 사용자를 확인하지 않고 jwt의 정보를 신뢰하는 방법이다. DB 조회를 생략하고 토큰 유효성 검증만 한 뒤 jwt에 담긴 정보를 그대로 SecurityContext에 저장한다. 다만 로그인한 상태에서 사용자의 정보가 변경되면 재로그인 전까지 반영되지 않는다.
최종 해결책 및 구현
CustomUserDetails를 아래와 같이 수정했다.
public class CustomUserDetails implements UserDetails {
private final String email;
private final String role;
private final Boolean isVerified;
public CustomUserDetails(String email, String role, Boolean isVerified) {
this.email = email;
this.role = role;
this.isVerified = isVerified;
}
...(생략)
}
Member를 그대로 저장하는 것이 아닌 jwt에 담긴 email, role, isVerified를 가지도록 했다.
이에 따라 JwtAuthenticationFilter에서 loadUserByUsername()을 호출할 이유도 없어졌다. 최종적으로 필터는 아래와 같이 수정되었다.
- 수정된 JwtAuthenticationFilter
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtUtils jwtUtils;
private final RedisTemplate<String, String> redisTemplate;
@Override
protected void doFilterInternal(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull FilterChain filterChain) throws ServletException, IOException {
// 쿠키에서 토큰 추출
String token = extractTokenFromCookie(request);
try {
// 토큰 유효성 검사
if (token != null && jwtUtils.isValidToken(token)) {
// 블랙리스트 확인
if (isTokenBlacklisted(token)) {
setErrorResponse(response, HttpStatus.UNAUTHORIZED.value(), "로그아웃된 토큰입니다.");
return;
}
// 정보 추출
Claims claims = jwtUtils.getClaims(token);
String email = claims.getSubject();
String role = claims.get("role", String.class);
Boolean isVerified = claims.get("isEmailVerified", Boolean.class);
if (email != null && Boolean.TRUE.equals(isVerified)) {
// UserDetails 객체 생성 (DB에서 사용자 정보 조회)
UserDetails userDetails = new CustomUserDetails(email, role, isVerified);
// UserDetails 기반으로 Authentication 객체 생성
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
// SecurityContextHolder에 인증 정보 설정
SecurityContextHolder.getContext().setAuthentication(authentication);
} else {
setErrorResponse(response, HttpStatus.UNAUTHORIZED.value(), "이메일 인증이 완료되지 않았습니다.");
return;
}
}
} catch (CustomAuthException e) {
setErrorResponse(response,
Integer.parseInt(e.getAuthErrorType().getHttpStatus()), e.getAuthErrorType().getDefaultMessage());
return;
} catch (ExpiredJwtException e) {
setErrorResponse(response, HttpStatus.UNAUTHORIZED.value(), "만료된 토큰입니다.");
return;
} catch (JwtException e) {
setErrorResponse(response, HttpStatus.UNAUTHORIZED.value(), "유효하지 않은 토큰입니다.");
return;
} catch (UsernameNotFoundException e) {
setErrorResponse(response, HttpStatus.UNAUTHORIZED.value(), "사용자를 찾을 수 없습니다.");
return;
}
// 검증 후 다음 필터로
filterChain.doFilter(request, response);
}
// 쿠키에서 토큰 추출하는 메서드
private String extractTokenFromCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies != null) {
for (Cookie cookie : cookies) {
if ("accessToken".equals(cookie.getName())) {
return cookie.getValue();
}
}
}
return null;
}
// Redis에서 토큰 블랙리스트 확인
private boolean isTokenBlacklisted(String token) {
return Boolean.TRUE.equals(redisTemplate.hasKey("blackList:" + token));
}
// 에러 핸들링
private void setErrorResponse(HttpServletResponse response, Integer status, String message) throws IOException {
response.setCharacterEncoding("UTF-8");
response.setStatus(status);
response.setContentType(MediaType.APPLICATION_JSON_VALUE + ";charset=UTF-8");
RsData<Void> errorResponse = new RsData<>(status.toString(), message);
response.getWriter().write(new ObjectMapper().writeValueAsString(errorResponse));
}
}
결과 및 성능 분석
- 요청마다 member 조회 쿼리를 날리던 기존 로그
2025-05-16T16:31:28.574+09:00 TRACE 16580 --- [kkokkio] [nio-8080-exec-3] o.s.t.i.TransactionInterceptor : No need to create transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findByEmail]: This method is not transactional.
2025-05-16 16:31:28.574 [http-nio-8080-exec-3] TRACE o.s.t.i.TransactionInterceptor - No need to create transaction for [org.springframework.data.jpa.repository.support.SimpleJpaRepository.findByEmail]: This method is not transactional.
2025-05-16T16:31:28.616+09:00 DEBUG 16580 --- [kkokkio] [nio-8080-exec-3] org.hibernate.SQL :
/* <criteria> */ select
m1_0.member_id,
m1_0.birth_date,
m1_0.created_at,
m1_0.deleted_at,
m1_0.email,
m1_0.email_verified,
m1_0.nickname,
m1_0.password_hash,
m1_0.role,
m1_0.updated_at
from
member m1_0
where
m1_0.email=?
필터에서 DB 조회를 하지 않기 때문에 더 이상 위 쿼리가 발생하지 않는다.
추가 개선점
JWT를 믿고 사용자 조회를 하지 않는 방법을 선택하면서 사용자 정보 변경이 즉각 반영되지 않게 되었다. JWT에 들어가는 정보는 로그인 이후로 갱신되지 않기 때문이다. 토큰 갱신쪽에서 사용자를 다시 조회해서 claims를 업데이트한다면 해소할 수 있는 부분인 것 같다/auth/refresh. 다만 그렇게 되면 access token이 만료될 때마다 또 DB 조회를 하게 되기도 하고 특별히 민감한 부분이 아니라고 생각해 감안하고 구현했다.
Spring Security는 레퍼런스가 많은데, 그게 정말 옳은 구현인지, 우리 서비스에 맞는 방법인지는 계속해서 고민이 필요한 것 같다. 복붙하다 보면 모순되는 설정을 가져오게 되기도 하고 보통 많이 올라와 있는 방법들은 보안이나 성능 둘 중에 하나를 포기한 느낌이다(..) 근데 또 공부는 싫고 그냥 코딩 잘 하는 사람을 커비처럼 삼키고 싶다.
'kkokkio - 프로젝트 > 트러블슈팅' 카테고리의 다른 글
모니터링 대시보드 설정 (0) | 2025.06.16 |
---|---|
Query에서 정렬을 사용하기 (0) | 2025.06.16 |
[Spring 영속성] don't flush the Session after an exception occurs 오류 - 트랜잭션 안에서 JPA 엔티티 재사용 시 문제 (0) | 2025.06.16 |
MySQL에서 중복 데이터를 관리하는 방법 (0) | 2025.06.16 |
Prometheus + Grafana 모니터링 도입기 (0) | 2025.06.16 |