Spring Security SQL 튜닝: 반복 User SELECT 제거

임동혁 Ldhbenecia·2025년 11월 30일

SpringBoot

목록 보기
28/28
post-thumbnail

개요

채팅 시스템의 SQL 튜닝을 진행하던 중, 로그인 후 채팅방 목록을 조회하는 단순한 과정에서 User 테이블을 중복 조회하는 쿼리가 다수 발생함을 확인했다.
이는 트래픽이 증가할 경우 DB 부하를 가중시키는 주원인이 될 수 있으므로, 인증 로직을 전면적으로 리팩토링하여 불필요한 I/O를 제거하기로 결정했다.

문제 상황 (AS-IS)

API 요청이 들어올 때마다 JwtAuthenticationFilter가 동작하는데, 이때 토큰 검증 과정에서 매번 DB를 조회하는 현상이 발생했다. 단순히 채팅방 목록만 불러오면 되는데, 인증을 위해 User 테이블을 3~4회 연속으로 조회하고 있었다.

[최적화 전 로그]

Hibernate: 
    select
        ue1_0.id,
        ue1_0.created_at,
        ue1_0.display_name,
        ue1_0.email,
        ue1_0.profile_image_url,
        ue1_0.provider,
        ue1_0.updated_at 
    from
        user ue1_0 
    where
        ue1_0.provider=? 
        and ue1_0.email=?
Hibernate: 
    select
        ue1_0.id,
        ue1_0.created_at,
        ue1_0.display_name,
        ue1_0.email,
        ue1_0.profile_image_url,
        ue1_0.provider,
        ue1_0.updated_at 
    from
        user ue1_0 
    where
        ue1_0.id=?
17:33:17.958| INFO|                                ,                |o.s.w.s.c.WebSocketMessageBrokerStats   |WebSocketSession[0 current WS(0)-HttpStream(0)-HttpPoll(0), 1 total, 0 closed abnormally (0 connect failure, 0 send limit, 0 transport error)], stompSubProtocol[processed CONNECT(1)-CONNECTED(0)-DISCONNECT(0)], stompBrokerRelay[1 sessions, ReactorNettyTcpClient[reactor.netty.tcp.TcpClientConnect@9f36eeb] (available), processed CONNECT(1)-CONNECTED(1)-DISCONNECT(0)], inboundChannel[pool size = 3, active threads = 0, queued tasks = 0, completed tasks = 3], outboundChannel[pool size = 0, active threads = 0, queued tasks = 0, completed tasks = 0], sockJsScheduler[pool size = 10, active threads = 1, queued tasks = 1, completed tasks = 9]
Hibernate: 
    select
        ue1_0.id,
        ue1_0.created_at,
        ue1_0.display_name,
        ue1_0.email,
        ue1_0.profile_image_url,
        ue1_0.provider,
        ue1_0.updated_at 
    from
        user ue1_0 
    where
        ue1_0.id=?
Hibernate: 
    select
        ue1_0.id,
        ue1_0.created_at,
        ue1_0.display_name,
        ue1_0.email,
        ue1_0.profile_image_url,
        ue1_0.provider,
        ue1_0.updated_at 
    from
        user ue1_0 
    where
        ue1_0.id=?
Hibernate: 
    select
        cre1_0.id,
        cre1_0.last_message,
        cre1_0.last_message_time,
        ue1_0.id,
        ue1_0.display_name,
        ue1_0.profile_image_url 
    from
        chat_room cre1_0 
    join
        chat_room_user crue1_0 
            on cre1_0.id=crue1_0.room_id 
            and crue1_0.user_id=? 
            and crue1_0.visible=1 
    join
        chat_room_user crue2_0 
            on cre1_0.id=crue2_0.room_id 
            and crue2_0.user_id<>? 
    join
        user ue1_0 
            on crue2_0.user_id=ue1_0.id 
    order by
        cre1_0.last_message_time desc

원인 분석

  • Security Filter의 DB 의존성: 기존 JwtAuthenticationFilter에서는 토큰의 유효성을 검증하고 Authentication 객체를 생성하기 위해 UserDetailsService.loadUserByUsername()을 호출했다.
  • Stateless 장점 미활용: JWT는 토큰 자체에 사용자 정보(Payload)를 포함하고 있어 DB 조회 없이도 인증이 가능한데, 습관적으로 DB를 거쳐 사용자 정보를 가져오는 방식(Stateful)으로 구현되어 있었다.

@Component
class JwtAuthenticationFilter(
    private val jwtUtil: JwtUtil,
    private val userDetailsService: LoginUserDetailsService // ⚠️ DB 의존성 존재
) : OncePerRequestFilter() {

    override fun doFilterInternal(...) {
        // ... 토큰 추출 ...
        
        // 1. 토큰에서 ID만 추출
        val userId = jwtUtil.extractUserId(token)

        // 2. ❌ [문제의 구간] ID를 기반으로 매번 DB를 조회하여 유저 정보를 가져옴
        // (이미 인증된 유저임에도 불구하고 불필요한 I/O 발생)
        val userDetails = userDetailsService.loadUserByUsername(userId)

        if (jwtUtil.validateToken(token, userDetails)) {
            // ... 인증 객체 생성 ...
        }
    }
}

기존 JWT 인증 필터에서는 payload에 userId만 넣어서 토큰을 생성하였기 때문에 해당 userId를 추출 후 DB에서 조회 쿼리를 날려서 사용자를 찾았다.

하지만 우리는 그럴 필요 없이 필요한 정보들을 jwt payload에 넣어두고 토큰 복호화 이후 나온 데이터를 바로 LoginUser 객체에 주입 시켜주면 굳이 DB에서 해당 userId를 통해 LoginUser 객체에 주입시켜줄 필요가 없어진다.

이는 Payload에 지금 주입시킬 데이터는 이메일, 이름, 프로필 사진 주소 정도기 때문에 공개되어도 무관한 데이터이다.

그리고 DB 조회해서도 이 데이터들을 찾아서 넣어줬으니 가능한 것이다.

해결 과정 (TO-BE)

  • "DB 조회 없는(Stateless) 검증 구조"로 변경하기 위해 다음 3단계를 수행한다.
  1. JWT Payload 확장: 토큰 생성 시 userId뿐만 아니라 인증 객체 생성에 필요한 email, displayName 등의 정보를 Claims에 포함시킨다.
  2. Authentication 객체 생성 로직 변경:
    • DB에서 유저 정보를 조회하는 UserDetailsService 의존성을 제거한다.
    • 대신, JWT의 Claims에서 정보를 파싱하여 즉시 LoginUser 객체를 생성하고 Authentication에 주입하도록 변경한다.
  3. Principal 타입 일치: 컨트롤러에서 @AuthenticationPrincipal로 받는 타입(LoginUser)과 필터에서 넣어주는 타입이 일치하도록 래퍼 클래스(LoginUserPrincipal)를 제거하고 직관적으로 구조를 단순화한다.
fun generateToken(userId: UUID, email: String, displayName: String, profileImageUrl: String?): String {
        val builder =  Jwts.builder()
            .setSubject(userId.toString())
            .claim("email", email)
            .claim("displayName", displayName)
            .setIssuedAt(Date())
            .setExpiration(Date(System.currentTimeMillis() + expiration))
            .signWith(key, SignatureAlgorithm.HS512)

        if (profileImageUrl != null) {
            builder.claim("profileImageUrl", profileImageUrl)
        }

        return builder.compact()
    }

    fun getAuthentication(token: String): UsernamePasswordAuthenticationToken {
        val claims = extractAllClaims(token)

        val userId = UUID.fromString(claims.subject)
        val email = claims.get("email", String::class.java) ?: ""
        val displayName = claims.get("displayName", String::class.java) ?: ""
        val profileImageUrl = claims.get("profileImageUrl", String::class.java) ?: ""

        val loginUser = LoginUser(
            id = userId,
            email = email,
            displayName = displayName,
            profileImageUrl = profileImageUrl
        )

        return UsernamePasswordAuthenticationToken(
            loginUser,
            null,
            listOf(SimpleGrantedAuthority("ROLE_USER")) // 기본 권한 부여 (필요시 claims에서 꺼냄)
        )
    }
    
    private fun extractAllClaims(token: String): Claims {
        return Jwts.parserBuilder()
            .setSigningKey(key)
            .build()
            .parseClaimsJws(token)
            .body
    }

jwtUtil 부분에서 토큰을 생성할 때 충분한 데이터를 넣어두고 Authentication 정보를 구해서 주입 시켜줄 때 DB 접근이 아닌 바로 꺼내서 준다.

[최적화 후 로그]

18:38:07.469| INFO|                                ,                |o.s.web.servlet.DispatcherServlet       |Completed initialization in 2 ms
Hibernate: 
    select
        ue1_0.id,
        ue1_0.created_at,
        ue1_0.display_name,
        ue1_0.email,
        ue1_0.profile_image_url,
        ue1_0.provider,
        ue1_0.updated_at 
    from
        user ue1_0 
    where
        ue1_0.id=?
Hibernate: 
    select
        cre1_0.id,
        cre1_0.last_message,
        cre1_0.last_message_time,
        ue1_0.id,
        ue1_0.display_name,
        ue1_0.profile_image_url 
    from
        chat_room cre1_0 
    join
        chat_room_user crue1_0 
            on cre1_0.id=crue1_0.room_id 
            and crue1_0.user_id=? 
            and crue1_0.visible=1 
    join
        chat_room_user crue2_0 
            on cre1_0.id=crue2_0.room_id 
            and crue2_0.user_id<>? 
    join
        user ue1_0 
            on crue2_0.user_id=ue1_0.id 
    order by
        cre1_0.last_message_time desc

결론

  • 쿼리 수 감소: API 요청당 평균 3~4회 발생하던 불필요한 User 조회를 0회로 줄였다.
  • 성능 향상: DB I/O 대기 시간이 사라져 API 응답 속도가 개선되었으며, DB 커넥션 풀의 여유 자원을 확보했다.
  • 구조 개선: 인증 로직이 DB에 의존하지 않게 되어, 추후 MSA 등으로 서비스가 분리되더라도 인증 서버 부하 없이 독립적인 토큰 검증이 가능해졌다.

0개의 댓글