Notice
Recent Posts
Recent Comments
Link
«   2026/06   »
1 2 3 4 5 6
7 8 9 10 11 12 13
14 15 16 17 18 19 20
21 22 23 24 25 26 27
28 29 30
Archives
Today
Total
관리 메뉴

빠르게 학습하고 빠르게 적용하자

Spring WebFlux에서 Http-Only Cookie + Custom User 객체 관리 고민과 해결 전략 본문

카테고리 없음

Spring WebFlux에서 Http-Only Cookie + Custom User 객체 관리 고민과 해결 전략

osoohynn 2025. 8. 21. 18:33

안녕하세요! 오늘은 Kotlin Coroutine과 Spring WebFlux/Security 환경에서 Http-Only Cookie를 이용한 JWT 인증을 구현하며 겪었던 깊은 고민과 해결 과정을 공유하려 합니다.

MSA 환경에서 CSRF 공격에 안전한 Http-Only 쿠키를 사용하여 인증 상태를 관리하기로 했습니다. JWT 토큰을 쿠키에 담아 SameSite=Lax 옵션으로 설정하고, 서버에서는 JwtAuthenticationFilter를 통해 토큰을 검증하고 사용자의 Security Context를 설정하는, 비교적 표준적인 아키텍처였습니다.

하지만, 단순해 보였던 이 과정에서 Kotlin Coroutine의 suspend 함수Spring Security Filter의 동기적(synchronous) 특성이 충돌하며 예상치 못한 난관에 부딪혔습니다.

문제의 발단: Filter와 suspend 함수의 만남

저희의 인증 로직은 다음과 같았습니다.

  1. 클라이언트의 요청이 들어오면 JwtAuthenticationFilter가 Http-Only 쿠키에서 JWT를 꺼낸다.
  2. JWT의 유효성을 검증하고, 토큰의 subject에 담긴 userId를 추출한다.
  3. 추출한 userId로 데이터베이스에서 사용자 정보(User)를 조회한다. (UserRepository.findById(userId))
  4. 조회된 User 정보를 기반으로 CustomOauth2User 같은 커스텀 Principal 객체를 생성한다.
  5. 생성된 Principal 객체를 SecurityContextHolder에 저장하여 인증을 완료한다.

여기서 핵심적인 문제는 3번 과정, 즉 UserRepository가 R2DBC와 Coroutine을 사용하고 있어 findById와 같은 함수가 suspend 함수였다는 점입니다.

// UserRepository.kt
interface UserRepository : CoroutineCrudRepository<User, Long> {
    suspend fun findById(id: Long): User? // suspend!
}

Spring Security의 표준 필터(Servlet 기반이든 WebFlux의 WebFilter든)는 suspend 키워드를 지원하지 않습니다. 필터의 doFilter 또는 filter 메소드 시그니처를 마음대로 바꿀 수 없기 때문이죠. 일반 함수에서 suspend 함수를 직접 호출할 수 없다는 언어적 제약이 발목을 잡았습니다.

시도 1: Filter 내에서 Reactor 컨텍스트 열기 (mono)

첫 번째 시도는 suspend 함수를 호출하기 위해 필터 내에서 Reactor의 mono 블록을 사용하는 것이었습니다. kotlinx-coroutines-reactor 라이브러리를 사용하면 mono 블록 안에서 코루틴 코드를 실행할 수 있습니다.

 
// 개념적인 코드입니다.
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
    val jwt = resolveToken(exchange.request)
    if (jwt != null && jwtValidator.validateToken(jwt)) {
        val userId = jwtValidator.getUserIdFromToken(jwt)

        return mono { // Coroutine Context를 열기 위해 mono 블록 사용
            val user = userRepository.findById(userId) // suspend 함수 호출
            if (user != null) {
                val customPrincipal = CustomOauth2User(user)
                val authentication = UsernamePasswordAuthenticationToken(customPrincipal, null, customPrincipal.authorities)
                // SecurityContext에 저장
                // ...
            }
        }.then(chain.filter(exchange)) // mono가 끝난 후 체인 계속
    }
    return chain.filter(exchange)
}

문제점:

  • 가독성 저하: 필터라는 단순해야 할 공간에 mono 블록이 들어가면서 코드가 복잡해지고 흐름을 파악하기 어려워졌습니다.
  • 컨텍스트 전환 비용: 불필요한 리액티브-코루틴 컨텍스트 전환으로 인해 미세하지만 성능 저하가 발생할 수 있습니다. 특히 매 요청마다 실행되는 필터에서는 무시할 수 없는 비용입니다.

이 방식은 기술적으로 가능했지만, "이게 최선인가?"라는 의문을 남겼습니다.

시도 2: 전면적인 코루틴 아키텍처로의 전환

"이왕 이렇게 된 거, Spring Security 설정 전체를 코루틴 기반으로 바꾸자!"

Spring Security 5.2부터 코루틴 DSL을 지원하므로, SecurityWebFilterChain을 설정하는 부분부터 완전히 suspend 기반으로 바꿀 수 있습니다.

 
// 개념적인 코드입니다.
@Bean
fun springSecurityFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
    return http {
        // ...
        addFilterAt(myCoroutineAwareFilter(), SecurityWebFiltersOrder.AUTHENTICATION)
    }.build()
}

문제점:

  • 거대한 리팩토링 범위: 기존에 Reactor와 Coroutine이 혼용되던 코드 베이스에서 이 방식을 도입하려면 변경해야 할 코드가 너무 많았습니다.
  • 배포 지연의 위험: 인증/인가라는 핵심 로직을 대대적으로 수정하는 것은 안정성 문제를 야기할 수 있고, 결국 서비스 배포 일정에 차질을 줄 것이 뻔했습니다.

사용자에게 빠르게 가치를 전달하는 것을 최우선으로 생각했기에, 이 시도는 몇 시간 만에 철회되었습니다.

시도 3 (최종 해결책): 책임의 분리와 지연 실행

수많은 고민 끝에 우리는 문제의 본질을 다시 생각했습니다.

"필터가 정말 사용자의 모든 정보를 다 알아야 할까?"

필터의 핵심 책임은 **"이 요청이 인증되었는가?"**를 판단하는 것입니다. 사용자의 상세 정보(이름, 이메일 등)는 그 이후, 즉 컨트롤러나 서비스 계층에서 필요합니다.

여기서 아이디어를 얻어 다음과 같은 방식으로 접근했습니다.

  1. 필터는 최소한의 역할만 수행한다.
    • JwtAuthenticationFilter는 JWT를 검증하고, userId만 추출합니다.
    • Authentication 객체를 만들되, Principal에는 CustomOauth2User 같은 무거운 객체가 아닌, userId(Long 또는 String 타입) 자체를 넣습니다.
    • 이 가벼운 Authentication 객체를 SecurityContext에 저장합니다.
    // JwtAuthenticationFilter.kt (단순화된 버전)
    // ...
    val userId = jwtValidator.getUserIdFromToken(jwt)
    val authorities = ... // 필요 시 기본 권한 설정
    val auth = UsernamePasswordAuthenticationToken(userId, null, authorities)
    SecurityContextHolder.getContext().authentication = auth
    // ...
    
  2. 실제 사용자 정보가 필요할 때 suspend 함수로 조회한다.
    • 컨트롤러나 서비스단에서 @AuthenticationPrincipal 등을 통해 사용자 정보가 필요해질 때가 문제입니다. 현재 Principal에는 userId만 들어있습니다.
    • SecurityContextHolder에서 직접 Principal(userId)을 꺼내 DB를 조회하는 suspend 확장 함수 또는 유틸리티 함수를 만듭니다.
     
    // SecurityContextUtils.kt
    suspend fun getCurrentUser(): User? {
        val authentication = SecurityContextHolder.getContext().authentication
        val userId = authentication.principal as? Long
        return userId?.let { userRepository.findById(it) }
    }
    

이 해결책의 장점

  • 필터의 단순화: 필터는 복잡한 DB I/O 없이 동기적으로 빠르게 실행될 수 있는 작업(JWT 검증 및 파싱)만 처리하므로 매우 가볍고 빨라졌습니다.
  • 최소한의 코드 변경: 기존 아키텍처를 거의 변경하지 않고, 유틸리티 함수 하나만 추가하여 문제를 해결했습니다.
  • 성능 및 가독성 향상: 필터에서 불필요한 mono 컨텍스트를 열지 않으므로 성능과 가독성 모두 개선되었습니다.
  • 관심사의 분리: 인증(Authentication)의 확인은 필터가, 사용자 정보 조회(Principal-Details)는 서비스/컨트롤러 계층이 책임지게 되어 역할이 명확해졌습니다.

결론

기술적으로 가장 '순수한' 방법이 항상 정답은 아니라는 것을 다시 한번 깨달았습니다. 때로는 아키텍처의 책임을 분리하고, 필요한 시점까지 작업을 '지연'시키는 간단한 아이디어가 복잡한 기술적 문제를 가장 우아하게 해결할 수 있습니다.

Spring WebFlux와 Kotlin Coroutine 환경에서 저와 비슷한 고민을 하고 계신 분이 있다면, 필터의 책임을 최소화하고 실제 데이터 조회를 필요한 시점으로 미루는 이 방식이 훌륭한 해결책이 될 수 있을 것입니다.