
채팅 시스템의 SQL 튜닝을 진행하던 중, 로그인 후 채팅방 목록을 조회하는 단순한 과정에서
User테이블을 중복 조회하는 쿼리가 다수 발생함을 확인했다.
이는 트래픽이 증가할 경우 DB 부하를 가중시키는 주원인이 될 수 있으므로, 인증 로직을 전면적으로 리팩토링하여 불필요한 I/O를 제거하기로 결정했다.
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
JwtAuthenticationFilter에서는 토큰의 유효성을 검증하고 Authentication 객체를 생성하기 위해 UserDetailsService.loadUserByUsername()을 호출했다.
@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 조회해서도 이 데이터들을 찾아서 넣어줬으니 가능한 것이다.
userId뿐만 아니라 인증 객체 생성에 필요한 email, displayName 등의 정보를 Claims에 포함시킨다.UserDetailsService 의존성을 제거한다.LoginUser 객체를 생성하고 Authentication에 주입하도록 변경한다.@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
User 조회를 0회로 줄였다.