문제 상황
JWT 기반 인증을 구현하는 과정에서 모든 요청마다 DB 조회가 발생하는 구조로 구현되어 있었습니다.
기존 인증 로직은 JWT 토큰에서 email을 추출한 뒤 UserDetailsService를 호출하여 사용자 정보를 다시 DB에서 조회하는 방식이었습니다. JWT를 사용하면서도 매 요청마다 DB 조회가 발생하여 JWT의 핵심 장점인 Stateless 구조를 전혀 활용하지 못하는 문제가 있었습니다.
기존 코드
if (jwtService.validate(token)) {
String email = jwtService.extractSubject(token);
UserDetails user = userDetailsService.loadUserByUsername(email);
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
user,
null,
user.getAuthorities()
);
SecurityContextHolder.getContext().setAuthentication(authentication);
}
동작 흐름은 다음과 같았습니다.
Request → JWT 검증 → email 추출 → UserDetailsService 호출 → DB 조회 → UserDetails 생성 → Authentication 생성
이러한 동작 흐름을 가져가고 있었는데, 발생하는 쿼리를 확인하다 보니 다음과 같은 문제점을 확인할 수 있었습니다
1. 매 요청마다 DB 조회 발생
JWT를 검증한 뒤에도 반드시 DB를 한 번 더 조회해야 했습니다.
API 호출이 많은 환경에서는 이 구조가 그대로 DB 부하로 이어집니다.
1초 100 요청 → 100번 DB 조회
2. JWT Stateless 구조 훼손
JWT의 핵심 설계 철학은 서버가 상태를 저장하지 않는 것입니다.
그런데 매 요청마다 DB를 조회한다면 사실상 세션(Session) 기반 인증과 다를 바가 없습니다.
3. 그로 인한 성능 저하
DB I/O가 증가하면서 전체 API 응답 속도가 느려지고, 서버 확장성도 저하됩니다.
해결법
JWT payload에 사용자 권한 정보를 포함하여, DB 조회 없이 인증 객체를 생성하는 방식으로 구조를 변경했습니다.
JWT payload → 권한 정보 추출 → Authentication 생성
1. 로그인 시 JWT에 권한 정보 저장
로그인 성공 시 CustomUser 객체에서 권한 정보를 추출하여 JWT claim에 저장하도록 구현했습니다.
CustomUser user = (CustomUser) authentication.getPrincipal();
String accessToken = jwtService.createAccessToken(
user.getUsername(),
user.getAuthStatuses()
);
String refreshToken = jwtService.createRefreshToken(
user.getUsername(),
user.getAuthStatuses()
);
2. JWT payload에 roles claim 포함
private String createToken(String subject, long expire, List<AuthStatus> roles) {
return Jwts.builder()
.setSubject(subject)
.claim("roles", roles)
.setIssuedAt(new Date())
.setExpiration(new Date(System.currentTimeMillis() + expire))
.signWith(key)
.compact();
}
생성되는 JWT payload 구조입니다.
{
"sub": "test@email.com",
"roles": ["USER"],
"iat": 1710000000,
"exp": 1710086400
}
3. 인증 필터 개선
DB를 조회하는 대신 JWT에서 권한 정보를 직접 추출하여 Authentication 객체를 생성합니다.
Claims body = jwtService.extractBody(token);
String email = body.getSubject();
List<String> roles = body.get("roles", List.class);
List<SimpleGrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
UsernamePasswordAuthenticationToken authentication =
new UsernamePasswordAuthenticationToken(
email,
null,
authorities
);
SecurityContextHolder.getContext().setAuthentication(authentication);
개선 결과
변경 전후 동작 흐름을 비교하면 다음과 같습니다.
| 흐름 | Request → JWT 검증 → DB 조회 → Authentication 생성 | Request → JWT 검증 → payload 파싱 → Authentication 생성 |
| DB 조회 | 매 요청마다 발생 | 없음 |
| 인증 구조 | 사실상 Stateful | 완전한 Stateless |
| 확장성 | 낮음 | 높음 |
DB 조회가 완전히 제거되어 응답 속도가 개선되었고, 트래픽이 많은 환경일수록 효과가 더 크게 나타납니다.
추가로 고려할 수 있는 개선 사항
실무에서는 JWT payload에 memberId도 함께 저장하는 방식이 많이 사용됩니다.
{
"memberId": 1,
"email": "test@email.com",
"roles": ["USER"]
}
이 방식을 사용하면 email 변경이 발생하더라도 사용자를 식별할 수 있고, 이후 비즈니스 로직에서 DB 조회 없이 memberId를 바로 활용할 수 있습니다.
다만 payload에 민감한 정보를 담을 경우 JWT는 기본적으로 서명(Signature)만 있고 암호화는 되지 않으므로, payload에 포함할 정보의 범위는 신중하게 결정해야 합니다.
정리
JWT를 사용하면서도 매 요청마다 DB를 조회하던 구조를 개선했습니다. JWT claim에 권한 정보를 포함시키고, 인증 필터에서 이를 직접 파싱 하여 Authentication 객체를 생성하는 방식으로 변경하면서 완전한 Stateless 인증 구조를 구현할 수 있었습니다.
JWT를 도입하는 목적 중 하나가 DB 부하 분산이라는 점을 고려하면, 인증 과정에서 불필요한 DB 조회가 발생하고 있지는 않은지 점검해보는 것이 중요합니다.
'❄️ 트러블 슈팅' 카테고리의 다른 글
| [트러블 슈팅] SOS 상세 조회 API의 Row Explosion 문제 해결 (0) | 2026.03.22 |
|---|---|
| [트러블슈팅] getReferenceById를 이용한 연관 관계 저장 최적화 (0) | 2026.03.22 |
| [트러블 슈팅]공고 상세 조회 API의 불필요한 DB 쿼리 줄이기 (0) | 2026.03.22 |
| [트러블슈팅] @OneToOne(mappedBy) LAZY 로딩이 동작하지 않는 이유와 해결 방법 (0) | 2026.03.22 |
| [트러블슈팅] JPA Cascade와 Aggregate Root 적용하기 (0) | 2026.03.22 |