[kotlog] 코프링으로 블로그 만들기 - 7 (Filter / Interceptor)

dustle·2025년 6월 29일
1

kotlog

목록 보기
7/9

이번에는 JWT 토큰을 검증하기 위해 Interceptor 를 적용해보았습니다.
Spring 에서 제공하는 Filter 와 Interceptor는 요청을 중간에 가로채서 무언가 처리할 수 있는 수단입니다.

Spring MVC 의 요청 방식은 다음과 같습니다.

요청은 Client → Filter → Servlet → Interceptor → Spring 순서로 전달되며
DispatcherServlet 앞뒤로 Filter 와 Interceptor 가 동작하게 됩니다.


Filter

  • 서블릿 스펙 레벨
  • DispatcherServlet 진입 전에 동작
  • 웹 컨테이너가 관리
  • 모든 요청(정적 리소스 포함)에 대해 작동
  • 주로 인코딩, CORS, 보안 필터링 같은 전역 설정에 적합
  • Spring 과의 의존성 주입 연동이 불편함

Interceptor

  • Spring MVC 레벨
  • DispatcherServlet 진입 후, 컨트롤러 전/후에 동작
  • 스프링 컨테이너가 관리
  • 오직 스프링 컨트롤러로 매핑되는 요청만 처리
  • 인증/인가, 사용자 정보 주입, 접근 제한에 적합
  • HandlerMethod 에 접근할 수 있고 DI 도 자유로움

그래서 저는 Interceptor 를 사용해서 JWT 인증을 구현하였습니다.

JwtUtil

토큰 생성, 검증, userId 추출 기능을 구현했습니다.

@Component
class JwtUtil {
    private val secret: String = "mysecretmysecretmysecretmysecretmysecretmysecret"
    private val key: SecretKey = Keys.hmacShaKeyFor(secret.toByteArray())

    fun generateToken(userId: Long, expirationMs: Long = ONE_HOURS_MS): String {
        val now = Date()
        return Jwts.builder()
            .subject(userId.toString())
            .issuedAt(now)
            .expiration(Date(now.time + expirationMs))
            .signWith(key)
            .compact()
    }

    fun validateToken(token: String): Boolean = try {
        Jwts.parser().verifyWith(key).build().parseSignedClaims(token)
        true
    } catch (e: Exception) {
        LOG.error("Invalid JWT token", e)
        false
    }

    fun extractUserId(token: String): Long {
        val claims = Jwts.parser().verifyWith(key).build().parseSignedClaims(token).payload
        return claims.subject.toLong()
    }

    companion object {
        private const val ONE_HOURS_MS = 60 * 60 * 1000L
        private val LOG = LoggerFactory.getLogger(this::class.java)
    }
}

AuthInterceptor

JWT 토큰을 꺼내서 검증하고, 성공하면 request 에 userId 를 넣습니다.

@Component
class AuthInterceptor(
    private val jwtUtil: JwtUtil,
) : HandlerInterceptor {

    override fun preHandle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any,
    ): Boolean {
        val token = request.getHeader("Authorization")

        if (token.isNullOrEmpty() || !token.startsWith("Bearer ")) {
            response.status = HttpServletResponse.SC_UNAUTHORIZED
            return false
        }

        val jwt = token.removePrefix("Bearer ").trim()

        if (!jwtUtil.validateToken(jwt)) {
            response.status = HttpServletResponse.SC_UNAUTHORIZED
            return false
        }

        val userId = jwtUtil.extractUserId(jwt)
        request.setAttribute("userId", userId)

        return true
    }
}

UserIdArgumentResolver

파라미터 이름이 "userId" 이고 타입이 Long 이면 자동으로 넣어줍니다.

@Component
class UserIdArgumentResolver : HandlerMethodArgumentResolver {
    override fun supportsParameter(parameter: MethodParameter): Boolean {
        return parameter.parameterType == Long::class.java && parameter.parameterName == "userId"
    }

    override fun resolveArgument(
        parameter: MethodParameter,
        mavContainer: ModelAndViewContainer?,
        webRequest: NativeWebRequest,
        binderFactory: WebDataBinderFactory?,
    ): Any {
        val userId = webRequest.getAttribute("userId", RequestAttributes.SCOPE_REQUEST) as? Long
        return userId ?: throw IllegalStateException("userId not found in request")
    }
}

WebConfig

InterceptorArgumentResolvers 를 스프링 MVC 설정에 등록합니다.

@Configuration
class WebConfig(
    private val authInterceptor: AuthInterceptor,
    private val userIdArgumentResolver: UserIdArgumentResolver,
) : WebMvcConfigurer {

    override fun addInterceptors(registry: InterceptorRegistry) {
        registry.addInterceptor(authInterceptor)
            .addPathPatterns("/api/**")
    }

    override fun addArgumentResolvers(resolvers: MutableList<HandlerMethodArgumentResolver>) {
        resolvers.add(userIdArgumentResolver)
    }
}

UserController

이제 컨트롤러는 그냥 userId 를 파라미터로 받을 수 있습니다.
JWT 검증은 Interceptor 에서 처리하고 userIdArgumentResolvers 가 넣어줍니다.

@RestController
@RequestMapping("/api/v1/users")
class UserController(
    val userRepository: UserRepository,
) {

    @GetMapping("/me")
    fun me(userId: Long): ResponseEntity<UserResponse> {
        val user = userRepository.findById(userId)
            .orElseThrow { IllegalArgumentException("유저를 찾을 수 없습니다. id=$userId") }

        return ResponseEntity.ok(UserResponse.from(user))
    }
}

UserControllerTest

MockMvc 를 통해 내 정보를 가져오는 API 를 테스트해보았습니다.

@SpringBootTest
@AutoConfigureMockMvc
class UserControllerTest(
    val mockMvc: MockMvc,
    val jwtUtil: JwtUtil,
    val userRepository: UserRepository,
) : FunSpec({

    test("GET /api/v1/users/me 요청이 들어왔을 때 200 OK 반환해야 함") {
        val user = User(
            username = "dustle",
            password = "111",
            email = "111",
            nickname = "111"
        ).also {
            userRepository.saveAndFlush(it)
        }

        val token = jwtUtil.generateToken(user.id)

        mockMvc.get("/api/v1/users/me") {
            header("Authorization", "Bearer $token")
        }.andDo { print() }
            .andExpect {
                status { isOk() }
                jsonPath("$.username") { value(user.username) }
                jsonPath("$.email") { value(user.email) }
                jsonPath("$.nickname") { value(user.nickname) }
            }
    }
})

마무리

Spring Security 없이 JWT 인증을 처리해보았습니다.
나중에는 ArgumentResolver 을 커스텀 어노테이션으로 추가하거나
예외 처리/에러 포맷 개선도 해보면 좋을 것 같습니다.

0개의 댓글