안녕하세요 왕라니입니다! 오늘은 어린이날이라 월요일인데 즐거운 휴무입니다!
몸 컨디션도 좋지않고 날도 흐린 휴일인데 집에서 개발하면서 스트레스를 풀기 좋은날인것 같습니다..🤣
토요일부터 집에서 유튜브보면서 개발만 하고있는데 생업과 취미가 같은건 좋은일인것 같습니다.
아무튼 각설하고 오늘 해보려고 하는건 Spring Security 를 사용하여 인증/인가를 진행하고 있는 프로젝트의 UserDetails 의 정보를 캐싱처리하여 DB에 접근하는 횟수를 줄이는것을 목표로 하는 것입니다.
(일단 배포하는 서버가 아니기에 Hibernate 통계를 보는 내용도 추가하여 개선이 되는지도 확인을 해보았습니다!)
문제인식
지난 포스팅에서 API 요청을 보내는데 한번에 세개의 쿼리를 보내는 재밌는(?) 현상이 발견되었습니다.
우선 이 상황의 이유를 살펴보았습니다. 현재 Spring Security 를 사용하고 있고, `UserDetailsService`를 구현하여 사용하고 있는 상황입니다.
그리고 AuthenticationFilter 와 AuthorizationFilter 를 따로 작성하여 로그인 시 Authentication 과정을 진행하고 로그인 이후 API 요청에서 인증된 유저인지 확인 후 Authorization 을 진행합니다. 인가를 진행할 때 필터에서 아래 메서드를 실행합니다.
private fun createAuthentication(username: String): Authentication {
val userDetails: UserDetails = userDetailsService.loadUserByUsername(username)
return UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
}
특히 구현해 놓은 `userDetailsService` 에서 loadUserByUsername 메서드를 실행할 때 UserRepository 를 접근하여 DB에 등록된 유저인지 확인하는 로직을 거칩니다. 아래의 로직입니다.
@Service
class UserDetailsServiceImpl(
private val userRepository: UserRepository
) : UserDetailsService {
@Transactional
override fun loadUserByUsername(email: String): UserDetails {
val user = userRepository.findByEmail(email)
.orElseThrow { UsernameNotFoundException("존재하지 않는 유저 이메일입니다.") }
if (user.deletedAt != null) { // 삭제된 유저가 다시 접속하면 null 로 바뀌고 스케줄러에서 벗어남
user.deletedAt = null
userRepository.save(user)
}
return UserDetailsImpl(user)
}
}
다양한 인증/인가 방식이 있겠지만, 일단 지금의 로직에선 인증된 유저에게 인가를 진행하기 때문에 메인이 되는 비즈니스 로직에서 DB에 접근을 하면 최소 2번이상 쿼리를 보내게 됩니다. 아주 바보같은 설계인 것 같습니다.
그래서 이 `loadUserByUsername`을 수정하여 이 현상을 해결하고자 합니다.
해결 과정
우선 UserDetails 정보를 저장할 캐시를 고민하다가 Redis 서버에 저장해보자고 결정했습니다. 2차 캐시에 저장할까 고민이 되었지만 Redis 서버를 다양하게 개선할 계획이 있기때문에 선택하게 되었습니다.
조금 늦게 알았지만 Spring Security 공식문서를 참고하니 CachingUserDetailsService 를 지원하고있더군요. 우선 오늘 적용한 내용을 여러 부분으로 비교하면서 추후에 Caching UserDetails 가 효율적이라면 적용하는 것도 고려중입니다.
우선 Redis 설정 및 Cache 사용설정부터 보겠습니다.
build.gradle.kts
dependencies {
...
implementation("org.springframework.boot:spring-boot-starter-data-redis")
}
Redis 의존성을 추가해줍니다.
인증/인가 로직 흐름을 설명해보겠습니다.
로그인 할 때 AuthenticationFilter에 접근합니다. 이때 Spring Security 는 제가 오버라이드 해놓은 attemptAuthentication 메서드에서 인증을 시도하게 되는데, 이때 SecurityConfig 에 정의해놓은 `DaoAuthenticationProvider()`가 UserDetailsService 에서 loadUserByUsername 메서드를 호출하며 처음 DB에 접근합니다. DB의 유저정보와 비교한 뒤 로그인에 성공하면 `UserDetailsImpl` 로 반환되고 `UsernamePasswordAuthenticationToken`을 생성하여 SecurityContext 에 UserDetails 를 저장합니다.
인가과정은 UserDetailsService 를 구현한 클래스에 기존의 `loadUserByUsername` 메서드는 DB에서 유저 정보를 조회하는 방식을 유지했습니다. 그리고 `loadUserForAuthorization` 메서드를 새로 정의한 뒤 인가를 처리할 떈 Redis 에 접근하여 유저 정보를 가져오도록 했고, 정보를 가져오는데 실패하면(Redis 서버에 유저 정보가 없을 때) `loadUserByUsername`메서드로 fallback 하도록 했습니다.
좀 복잡해 보이는데 전체 인증 흐름을 요약해서 보겠습니다.
인증과정(Authentication)
[클라이언트 요청]
↓
[UsernamePasswordAuthenticationFilter]
↓
attemptAuthentication()
↓
[AuthenticationManager]
↓
[DaoAuthenticationProvider]
↓
loadUserByUsername() → 로그인 시에는 DB에 접근
↓
passwordEncoder.matches()
↓
성공: UsernamePasswordAuthenticationToken 반환
↓
successfulAuthentication() → JWT 생성 + 응답 헤더 설정
↓
redisTemplate 에 cachedUser를 저장
인가과정(Authorization)
[클라이언트 요청]
↓
[AuthorizationFilter]
↓
Authorization Header 에서 토큰 추출
↓
토큰 정보와 새로 정의된 loadUserForAuthorization() 으로 검증
↓
Redis 에 저장된 cachedUser 와 비교
↓
SecurityContext 에 authentication 저장
이렇게 처리하도록 목표했습니다. 따라서 코드는 다음과 같이 작성됩니다.
// AuthenticationFilter.kt
override fun successfulAuthentication(
request: HttpServletRequest,
response: HttpServletResponse,
chain: FilterChain,
authResult: Authentication
) {
log.info("로그인 성공 및 JWT 생성")
... 토큰에 저장 생략
// 여기서 유저 캐싱
val cachedUser = CachedUser(
id = userId,
email = email,
password = userDetails.password,
userRole = role,
deletedAt = null
)
redisTemplate.opsForValue().set("user:$email", cachedUser)
// 응답 헤더에 Authorization 설정
response.setHeader("Authorization", token)
}
인증 후 Redis 에 cachedUser 를 저장하게 설정했습니다.
그리고 인가를 진행하는 과정엔 Redis 서버에 먼저 조회하게 합니다.
// AuthorizationFilter.kt
private fun createAuthentication(username: String): Authentication {
val userDetails: UserDetails = userDetailsService.loadUserForAuthorization(username)
return UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
}
구현한 UserDetailsServiceImpl 을 다음과같이 수정했습니다.
@Service
class UserDetailsServiceImpl(
private val userRepository: UserRepository,
private val redisTemplate: RedisTemplate<String, Any>,
private val objectMapper: ObjectMapper
) : UserDetailsService {
private val log = LoggerFactory.getLogger("UserDetailsImpl")
// 로그인 인증 시 직접 DB에서 조회
@Transactional(readOnly = true)
override fun loadUserByUsername(email: String): UserDetails {
val user = userRepository.findByEmail(email)
.orElseThrow { UsernameNotFoundException("존재하지 않는 유저입니다.") }
if (user.deletedAt != null) {
throw IllegalStateException("삭제된 유저입니다.")
}
return UserDetailsImpl(
id = user.id!!,
email = user.email,
password = user.password,
role = user.userRole
)
}
// JWT 검증 시 Redis → fallback DB
fun loadUserForAuthorization(email: String): UserDetails {
val key = "user:$email"
val rawUser = redisTemplate.opsForValue().get(key)
if (rawUser != null) {
try {
val cachedUser = objectMapper.convertValue(rawUser, CachedUser::class.java)
if (cachedUser.deletedAt != null) {
throw IllegalStateException("삭제된 유저입니다.")
}
return UserDetailsImpl(
id = cachedUser.id,
email = cachedUser.email,
password = cachedUser.password,
role = cachedUser.userRole
)
} catch (e: Exception) {
log.warn("Redis 캐시 역직렬화 실패 → DB fallback 시도: ${e.message}")
}
}
log.info("Redis 캐시 없음 → DB 조회로 fallback")
return loadUserByUsername(email)
}
}
인가를 진행할 때는 Redis 서버를 먼저 조회하고, 서버에 문제가 발생해서 cachedUser가 없거나 하는 문제가 있으면 기존의 loadUserByUsername 메서드를 fallback 하도록 설정했습니다.
이렇게 한 이유는 로그인 이외의 모든 요청이 인가필터를 거치는데 반드시 Redis 만 확인하여 하는 것이 아닌 상황에 따라 유연하게 DB도 조회는 가능하게 하되, DB접근을 최소한으로 하는 방식이 Redis 를 우선으로 접근하게 하기라고 생각했기 때문입니다.
아직 개선하거나 리팩토링 할 내용이 많이 필요할 것 같긴하지만, 에러 로깅을 위해 로깅필터도 추가하고, Redis 에 객체를 저장하고 꺼내면서 직렬화 역직렬화를 거치면서 다양한 문제가 생겨서 지금과 같은 방식으로 작성하였습니다.
성능 평가 및 결과
성능이 달라졌는지 확인하기 위해 요청-응답에 걸린 시간과 조회한 엔티티 수, 보낸 쿼리 수를 로그로 찍어보았습니다.같은 상황으로 요청을 수작업으로 50번 정도 테스트 해보았습니다.
Redis 서버의 DB에 접근하는 방식을 적용하기전엔 처음 API 요청을 보낼 때 TTFB(Time To First Byte) 80~90ms 가 나오고 이후 요청은 10~15ms 의 성능이 나왔었습니다.
이후 Redis 에 접근하는 방식을 적용하고 나서는 TTFB 기준 30~40ms 가 나오고 이후 요청은 10~15ms 의 성능이 나왔었습니다.
정확한 성능 개선의 평가가 어렵긴 하지만 우선 확실한 것은 요청 쿼리 수가 확실히 1번으로 줄었다는 것 입니다.
또한 User 테이블이 매우 커지고, 풀 스캔을 진행하여 DB에서 조회하는 로직이었다면 차이는 드라마틱 해졌을 것이라고 생각됩니다.
오늘의 UserDetails 캐싱 처리는 여기까지 하고, 여러 테스트나 상황을 살펴본 뒤 Spring Security 의 공식문서에서 정의된 캐싱방식도 적용해보겠습니다.
읽어주셔서 감사합니다!
'Project' 카테고리의 다른 글
[트러블 슈팅] 인기게시글 Cache, DB접근 줄이기 ver.2 (13) | 2025.05.25 |
---|---|
[트러블 슈팅]백엔드개발자가 프론트엔드와 협업하기 (0) | 2025.05.11 |
Join Fetch 를 사용하여 N+1 문제 해결하기 (1) | 2025.05.03 |
현재 프로젝트에 CI 파이프라인 적용하기! (1) | 2025.05.03 |
[코틀린 프로젝트] - 새로운 프로젝트 구상(4/13~4/26) (2) | 2025.04.27 |