❄️ 트러블 슈팅

[JWT 트러블슈팅] 매 요청마다 DB 조회가 발생하는 문제와 해결 방법

le2donguk 2026. 3. 22. 21:03

문제 상황

JWT 기반 인증을 구현하는 과정에서 모든 요청마다 DB 조회가 발생하는 구조로 구현되어 있었습니다.

기존 인증 로직은 JWT 토큰에서 email을 추출한 뒤 UserDetailsService를 호출하여 사용자 정보를 다시 DB에서 조회하는 방식이었습니다. JWT를 사용하면서도 매 요청마다 DB 조회가 발생하여 JWT의 핵심 장점인 Stateless 구조를 전혀 활용하지 못하는 문제가 있었습니다.


기존 코드

 
 
java
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 부하로 이어집니다.

1100 요청 → 100번 DB 조회

 

2. JWT Stateless 구조 훼손

JWT의 핵심 설계 철학은 서버가 상태를 저장하지 않는 것입니다.

그런데 매 요청마다 DB를 조회한다면 사실상 세션(Session) 기반 인증과 다를 바가 없습니다.

 

3. 그로 인한 성능 저하 

DB I/O가 증가하면서 전체 API 응답 속도가 느려지고, 서버 확장성도 저하됩니다.

 

해결법

JWT payload에 사용자 권한 정보를 포함하여, DB 조회 없이 인증 객체를 생성하는 방식으로 구조를 변경했습니다.

JWT payload → 권한 정보 추출 → Authentication 생성


1. 로그인 시 JWT에 권한 정보 저장

로그인 성공 시 CustomUser 객체에서 권한 정보를 추출하여 JWT claim에 저장하도록 구현했습니다.

 
 
java
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 조회가 발생하고 있지는 않은지 점검해보는 것이 중요합니다.