이전 편: 4 설정 관리와 프로파일 전략
Spring Security는 Spring 생태계에서 가장 복잡한 모듈 중 하나다. Java에서도 설정이 장황한데, Kotlin으로 오면 DSL 덕분에 간결해지는 부분이 있고, 어노테이션 use-site target 때문에 주의해야 하는 부분도 있다.
이 문서에서는 Kotlin + Spring Security 6.x (Spring Boot 3.x)의 설정 패턴, JWT 인증 구현, 메서드 보안, 그리고 테스트 전략을 다룬다.
Security DSL 설정
Spring Security 6.x에서는 Kotlin DSL로 보안 설정을 작성한다. DSL의 원리를 이해하고 있다면, 이 설정이 내부적으로 어떻게 동작하는지 감이 올 것이다.
@Configuration
@EnableWebSecurity
class SecurityConfig {
@Bean
fun securityFilterChain(http: HttpSecurity): SecurityFilterChain {
http {
csrf { disable() }
sessionManagement {
sessionCreationPolicy = SessionCreationPolicy.STATELESS
}
authorizeHttpRequests {
authorize("/api/auth/**", permitAll)
authorize("/api/posts", permitAll)
authorize("/api/posts/{id}", permitAll)
authorize("/api/admin/**", hasRole("ADMIN"))
authorize(anyRequest, authenticated)
}
exceptionHandling {
authenticationEntryPoint = CustomAuthenticationEntryPoint()
accessDeniedHandler = CustomAccessDeniedHandler()
}
}
return http.build()
}
}
각 블록의 역할을 정리하면 이렇다.
csrf { disable() }— REST API는 보통 CSRF 보호가 필요 없다. 세션이 아닌 JWT 토큰 기반 인증을 사용하기 때문이다.sessionManagement—STATELESS로 설정하면 서버가 세션을 생성하지 않는다. JWT 인증에서 필수적인 설정이다.authorizeHttpRequests— URL 패턴별 접근 권한을 설정한다. 위에서 아래로 순서대로 매칭되므로, 구체적인 규칙을 먼저 배치해야 한다.exceptionHandling— 인증/인가 실패 시 반환할 응답을 커스터마이즈한다.
Java의 메서드 체이닝과 비교하면 가독성이 훨씬 좋다. 중괄호의 들여쓰기가 설정의 계층 구조를 그대로 반영한다.
JWT 인증 구현
블로그 프로젝트에서 가장 일반적인 인증 방식인 JWT를 구현해보자. 전체 흐름은 이렇다.
JwtProvider — 토큰 생성과 검증
핵심 기능은 토큰 생성, 클레임 추출, 검증 세 가지다. 먼저 시크릿 키 초기화와 토큰 생성 부분을 보자.
@Component
class JwtProvider(
private val securityProperties: SecurityProperties
) {
private val secretKey: SecretKey by lazy {
Keys.hmacShaKeyFor(securityProperties.jwtSecret.toByteArray())
}
fun createToken(userId: Long, role: String): String {
val now = Date()
val expiration = Date(now.time + securityProperties.jwtExpirationMs)
return Jwts.builder()
.subject(userId.toString())
.claim("role", role)
.issuedAt(now)
.expiration(expiration)
.signWith(secretKey)
.compact()
}
by lazy—secretKey를 최초 사용 시에 한 번만 생성한다. 프로퍼티가 주입된 후에 키를 생성해야 하므로 지연 초기화가 필요하다.createToken— 사용자 ID와 역할을 클레임에 넣고 JWT를 생성한다.subject에 userId를,claim에 역할을 저장한다.
토큰 검증과 클레임 추출 부분이다.
fun getUserId(token: String): Long {
return getClaims(token).subject.toLong()
}
fun getRole(token: String): String {
return getClaims(token)["role"] as String
}
fun validateToken(token: String): Boolean {
return try {
getClaims(token)
true
} catch (e: JwtException) {
false
}
}
private fun getClaims(token: String): Claims {
return Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.payload
}
}
validateToken— 토큰 파싱을 시도하고, 실패하면(만료, 서명 불일치 등)JwtException이 발생해서false를 반환한다. Kotlin의try표현식을 활용해 간결하게 작성할 수 있다.getClaims— 모든 추출/검증 메서드가 공유하는 내부 함수. 한 번만 정의하면 된다.
JwtAuthenticationFilter — 필터 구현
모든 요청에서 JWT 토큰을 추출하고, 유효하면 SecurityContext에 인증 정보를 설정하는 필터다.
@Component
class JwtAuthenticationFilter(
private val jwtProvider: JwtProvider
) : OncePerRequestFilter() {
override fun doFilterInternal(
request: HttpServletRequest,
response: HttpServletResponse,
filterChain: FilterChain
) {
val token = resolveToken(request)
if (token != null && jwtProvider.validateToken(token)) {
val userId = jwtProvider.getUserId(token)
val role = jwtProvider.getRole(token)
val authentication = UsernamePasswordAuthenticationToken(
userId, // principal
null, // credentials
listOf(SimpleGrantedAuthority("ROLE_$role")) // authorities
)
SecurityContextHolder.getContext().authentication = authentication
}
filterChain.doFilter(request, response)
}
private fun resolveToken(request: HttpServletRequest): String? {
val bearer = request.getHeader("Authorization") ?: return null
return bearer.takeIf { it.startsWith("Bearer ") }?.substring(7)
}
}
OncePerRequestFilter— 요청당 한 번만 실행되는 것을 보장하는 필터. Forward나 Include가 있어도 중복 실행되지 않는다.resolveToken—Authorization: Bearer {token}헤더에서 토큰 부분만 추출한다. Kotlin의?.체이닝과takeIf로 null 안전하게 처리한다.SecurityContextHolder— 현재 스레드의 보안 컨텍스트에 인증 정보를 저장한다. 이후 컨트롤러에서 이 정보를 꺼내 쓸 수 있다.
필터를 Security 설정에 등록해야 한다.
@Bean
fun securityFilterChain(
http: HttpSecurity,
jwtFilter: JwtAuthenticationFilter
): SecurityFilterChain {
http {
// ... 기존 설정 ...
addFilterBefore<UsernamePasswordAuthenticationFilter>(jwtFilter)
}
return http.build()
}
addFilterBefore는 지정한 필터 앞에 JWT 필터를 배치한다. UsernamePasswordAuthenticationFilter 앞에 넣으면, Spring Security의 기본 인증 처리보다 JWT 인증이 먼저 실행된다.
인증/인가 예외 처리
Security의 예외 처리는 GlobalExceptionHandler와 별도로 동작한다. Security 필터 체인에서 발생하는 예외는 @ControllerAdvice에 도달하지 않기 때문이다.
class CustomAuthenticationEntryPoint : AuthenticationEntryPoint {
private val objectMapper = ObjectMapper()
override fun commence(
request: HttpServletRequest,
response: HttpServletResponse,
authException: AuthenticationException
) {
response.status = HttpStatus.UNAUTHORIZED.value()
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.characterEncoding = "UTF-8"
val errorResponse = ErrorResponse(
code = "UNAUTHORIZED",
message = "인증이 필요합니다"
)
response.writer.write(objectMapper.writeValueAsString(errorResponse))
}
}
AuthenticationEntryPoint는 인증되지 않은 요청(401)을 처리한다. 토큰이 없거나 유효하지 않을 때 호출된다. 인가 실패(권한 부족)는 별도의 AccessDeniedHandler가 담당한다.
class CustomAccessDeniedHandler : AccessDeniedHandler {
private val objectMapper = ObjectMapper()
override fun handle(
request: HttpServletRequest,
response: HttpServletResponse,
accessDeniedException: AccessDeniedException
) {
response.status = HttpStatus.FORBIDDEN.value()
response.contentType = MediaType.APPLICATION_JSON_VALUE
response.characterEncoding = "UTF-8"
val errorResponse = ErrorResponse(
code = "FORBIDDEN",
message = "접근 권한이 없습니다"
)
response.writer.write(objectMapper.writeValueAsString(errorResponse))
}
}
AccessDeniedHandler는 인가되지 않은 요청(403)을 처리한다. 인증은 됐지만 권한이 부족할 때 호출된다. 두 핸들러 모두 응답을 직접 HttpServletResponse에 쓰는 방식이다. 커스텀 핸들러 없이 기본 동작을 쓰면 HTML 에러 페이지가 반환되므로, REST API에서는 반드시 커스텀 핸들러를 만들어야 한다.
컨트롤러에서 인증 정보 사용
인증이 완료된 후 컨트롤러에서 현재 사용자 정보를 가져오는 방법이 여러 가지 있다.
@AuthenticationPrincipal
가장 직접적인 방법이다.
@GetMapping("/api/me")
fun getMyInfo(
@AuthenticationPrincipal userId: Long
): ResponseEntity<UserResponse> {
val user = userService.findById(userId)
return ResponseEntity.ok(UserResponse.from(user))
}
@AuthenticationPrincipal은 SecurityContext에서 principal 객체를 꺼내준다. 앞에서 JWT 필터에서 userId를 principal에 넣었으므로, 여기서 Long으로 받을 수 있다.
커스텀 어노테이션
매번 @AuthenticationPrincipal을 쓰는 것이 번거로우면, 커스텀 어노테이션을 만들 수 있다.
@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@AuthenticationPrincipal
annotation class CurrentUser
@CurrentUser는 @AuthenticationPrincipal을 감싼 메타 어노테이션이다. 컨트롤러에서 이렇게 사용한다.
@GetMapping("/api/me")
fun getMyInfo(@CurrentUser userId: Long): ResponseEntity<UserResponse> {
// ...
}
의미가 명확해지고, 나중에 principal 타입이 바뀔 때 @CurrentUser 정의만 수정하면 모든 컨트롤러에 반영된다.
메서드 보안
URL 패턴 기반 보안 외에, 메서드 레벨에서 권한을 검사할 수 있다.
@Configuration
@EnableMethodSecurity
class MethodSecurityConfig
@EnableMethodSecurity를 추가하면 @PreAuthorize, @PostAuthorize 등의 어노테이션을 사용할 수 있다.
@Service
class PostService(
private val postRepository: PostRepository
) {
@PreAuthorize("hasRole('ADMIN')")
fun deletePost(id: Long) {
postRepository.deleteById(id)
}
@PreAuthorize("#authorId == authentication.principal")
fun updatePost(authorId: Long, request: UpdatePostRequest): Post {
// 작성자 본인만 수정 가능
val post = postRepository.findByIdOrNull(authorId)
?: throw EntityNotFoundException("Post", authorId)
post.update(request.title, request.content)
return post
}
}
hasRole('ADMIN')— ADMIN 역할을 가진 사용자만 호출할 수 있다.#authorId == authentication.principal— 메서드 파라미터authorId가 현재 인증된 사용자의 ID와 같은지 확인한다. SpEL(Spring Expression Language) 표현식이다.
메서드 보안은 URL 패턴으로 표현하기 어려운 세밀한 권한 제어에 유용하다. 다만 SpEL 표현식이 문자열이므로 타입 안전하지 않다는 점은 주의해야 한다.
PasswordEncoder 설정
사용자 비밀번호를 저장할 때는 반드시 해시해야 한다. Spring Security의 BCryptPasswordEncoder를 빈으로 등록한다.
@Bean
fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()
회원가입과 로그인에서 사용하는 패턴은 이렇다.
@Service
class AuthService(
private val userRepository: UserRepository,
private val passwordEncoder: PasswordEncoder,
private val jwtProvider: JwtProvider
) {
@Transactional
fun register(request: RegisterRequest): User {
if (userRepository.existsByEmail(request.email)) {
throw DuplicateException("이메일", request.email)
}
val user = User(
email = request.email,
password = passwordEncoder.encode(request.password),
name = request.name
)
return userRepository.save(user)
}
fun login(request: LoginRequest): TokenResponse {
val user = userRepository.findByEmail(request.email)
?: throw EntityNotFoundException("User", request.email)
if (!passwordEncoder.matches(request.password, user.password)) {
throw BusinessException(ErrorCode.INVALID_STATE, "비밀번호가 일치하지 않습니다")
}
val token = jwtProvider.createToken(user.id, user.role.name)
return TokenResponse(token)
}
}
passwordEncoder.encode()— 비밀번호를 BCrypt 해시로 변환. 같은 비밀번호라도 매번 다른 해시값이 생성된다(salt 포함).passwordEncoder.matches()— 입력된 비밀번호와 저장된 해시를 비교. 직접 해시를 비교하는 것이 아니라 BCrypt의 검증 로직을 사용한다.
Security 테스트
Security가 적용된 API를 테스트하려면 인증 정보를 설정해야 한다.
@WithMockUser
가장 간단한 방법이다. 가짜 인증 정보를 주입한다.
@WebMvcTest(PostController::class)
class PostControllerTest {
@Autowired
lateinit var mockMvc: MockMvc
@Test
fun `인증 없이 공개 API 호출 가능`() {
mockMvc.get("/api/posts")
.andExpect { status { isOk() } }
}
@Test
@WithMockUser(roles = ["ADMIN"])
fun `ADMIN 역할로 삭제 가능`() {
mockMvc.delete("/api/admin/posts/1")
.andExpect { status { isOk() } }
}
@Test
@WithMockUser(roles = ["USER"])
fun `USER 역할로 삭제 불가`() {
mockMvc.delete("/api/admin/posts/1")
.andExpect { status { isForbidden() } }
}
}
@WithMockUser는 테스트 실행 시 SecurityContext에 가짜 Authentication을 설정한다. roles 파라미터로 역할을 지정할 수 있다.
커스텀 SecurityContext로 테스트
JWT 기반 인증에서 principal이 userId(Long)인 경우, @WithMockUser만으로는 부족할 수 있다. 커스텀 SecurityContext를 설정하는 유틸리티를 만들면 편하다.
fun MockMvc.getWithAuth(url: String, userId: Long = 1L, role: String = "USER") =
get(url) {
with(SecurityMockMvcRequestPostProcessors.user(userId.toString()).roles(role))
}
또는 테스트용 JWT 토큰을 직접 생성해서 헤더에 넣는 방법도 있다.
@SpringBootTest
@AutoConfigureMockMvc
class PostIntegrationTest {
@Autowired
lateinit var mockMvc: MockMvc
@Autowired
lateinit var jwtProvider: JwtProvider
@Test
fun `JWT 토큰으로 인증`() {
val token = jwtProvider.createToken(userId = 1L, role = "USER")
mockMvc.get("/api/me") {
header("Authorization", "Bearer $token")
}.andExpect {
status { isOk() }
}
}
}
통합 테스트에서는 실제 JWT를 사용하는 것이 더 현실적인 테스트가 된다.
심화 분석
CORS 설정
프론트엔드와 백엔드가 다른 도메인에서 동작하면 CORS(Cross-Origin Resource Sharing) 설정이 필요하다.
http {
cors {
configurationSource = UrlBasedCorsConfigurationSource().apply {
registerCorsConfiguration("/**", CorsConfiguration().apply {
allowedOrigins = listOf("https://myblog.com", "http://localhost:3000")
allowedMethods = listOf("GET", "POST", "PUT", "DELETE", "OPTIONS")
allowedHeaders = listOf("*")
allowCredentials = true
maxAge = 3600
})
}
}
}
Kotlin의 apply 스코프 함수 덕분에 CORS 설정이 선언적으로 읽힌다. Java의 빌더 패턴보다 들여쓰기가 곧 구조라서 이해하기 쉽다.
로컬 개발 환경(http://localhost:3000)과 운영 환경(https://myblog.com)의 도메인이 다르므로, 설정 파일에서 프로파일별로 관리하는 것이 좋다.
필터 체인의 순서
Spring Security의 필터 체인은 정해진 순서대로 실행된다. JWT 필터의 위치가 중요하다.
addFilterBefore<UsernamePasswordAuthenticationFilter>로 JWT 필터를 등록하면, 기본 인증 필터보다 앞에서 JWT를 처리한다. 토큰이 유효하면 SecurityContext에 인증 정보가 설정되고, 이후 AuthorizationFilter에서 URL 기반 권한 검사가 수행된다.
Role과 Authority의 차이
Spring Security에서 ROLE_ADMIN과 ADMIN은 다르다.
hasRole("ADMIN")— 내부적으로ROLE_접두사를 붙여서ROLE_ADMIN권한을 찾는다.hasAuthority("ADMIN")— 정확히ADMIN권한을 찾는다.
JWT 필터에서 SimpleGrantedAuthority("ROLE_$role")로 설정했으므로 hasRole("ADMIN")이 매칭된다. ROLE_ 접두사를 빼먹으면 권한이 맞지 않아 403이 반환된다.
자주 하는 실수
Security 필터에서 발생한 예외를 @ControllerAdvice가 못 잡음
// @ControllerAdvice에서 AuthenticationException을 잡으려 해도 안 잡힌다
@ExceptionHandler(AuthenticationException::class)
fun handleAuth(e: AuthenticationException) = /* ... */
Security 필터는 Spring MVC의 DispatcherServlet보다 앞에서 실행된다. @ControllerAdvice는 DispatcherServlet 이후에 동작하므로, 필터에서 발생한 예외는 도달하지 않는다. AuthenticationEntryPoint와 AccessDeniedHandler로 처리해야 한다.
JWT 시크릿을 코드에 하드코딩
// 나쁜 예
private val secretKey = Keys.hmacShaKeyFor("my-super-secret-key-1234567890".toByteArray())
// 좋은 예 — 설정에서 주입
private val secretKey: SecretKey by lazy {
Keys.hmacShaKeyFor(securityProperties.jwtSecret.toByteArray())
}
JWT 시크릿이 코드에 있으면 Git에 올라가고, 유출 시 모든 토큰이 위조될 수 있다. 반드시 환경 변수로 관리해야 한다.
permitAll 순서 실수
authorizeHttpRequests {
authorize(anyRequest, authenticated) // 모든 요청에 인증 필요
authorize("/api/auth/**", permitAll) // 이 규칙은 도달하지 않음!
}
authorizeHttpRequests의 규칙은 위에서 아래로 매칭된다. anyRequest가 먼저 있으면 모든 요청이 여기서 잡히고, 아래의 permitAll 규칙은 무의미해진다. 구체적인 규칙을 위에, 포괄적인 규칙을 아래에 배치해야 한다.
Q. Kotlin + Spring Boot 프로젝트에서 반드시 적용해야 하는 컴파일러 플러그인은?
kotlin-jpa와 kotlin-spring이다. kotlin-jpa는 JPA 엔티티에 기본 생성자를 자동 생성하고, kotlin-spring은 Spring 어노테이션이 붙은 클래스에 open을 자동 추가한다. Kotlin 클래스는 기본이 final이라 프록시 생성이 불가능한데, 이 플러그인이 해결한다.
Q. JPA 엔티티에 data class를 쓰면 안 되는 이유는?
data class의 equals/hashCode는 모든 프로퍼티를 비교하지만, JPA 엔티티의 동등성은 ID로 판단해야 한다. toString은 지연 로딩된 연관관계를 트리거하고, copy는 영속성 컨텍스트와 충돌한다. @Embeddable 값 객체에는 data class가 적합하지만, @Entity에는 일반 class를 써야 한다.
Q. Kotlin에서 Bean Validation 어노테이션 사용 시 @field: 접두사가 필요한 이유는?
Kotlin 생성자 프로퍼티에 어노테이션을 붙이면 기본적으로 생성자 파라미터에 적용된다. Bean Validation은 필드의 어노테이션을 읽으므로, @field:NotBlank처럼 명시해야 Validation이 동작한다. 빼먹으면 에러 없이 검증이 무시되어 발견하기 어렵다.
Q. @ConfigurationProperties에서 data class를 쓸 수 있는 이유는?
설정 객체는 JPA 엔티티와 달리 영속성 컨텍스트에 관리되지 않는다. data class의 equals/hashCode/copy가 문제를 일으키지 않으며, 불변 설정 객체를 만드는 데 오히려 적합하다. Spring Boot 3.x에서는 생성자 바인딩이 기본이므로 val 프로퍼티에 직접 바인딩된다.
Q. Spring Security 필터에서 발생한 예외를 @ControllerAdvice에서 처리할 수 없는 이유는?
Security 필터 체인은 Spring MVC의 DispatcherServlet보다 앞에서 실행된다. @ControllerAdvice는 DispatcherServlet 이후에 동작하므로, 필터에서 발생한 예외는 도달하지 않는다. AuthenticationEntryPoint(401)와 AccessDeniedHandler(403)로 별도 처리해야 한다.
Q. QueryDSL에서 동적 쿼리를 처리하는 핵심 패턴은?
조건별로 BooleanExpression?을 반환하는 메서드를 만들고, where에 넘긴다. QueryDSL은 where에 null이 들어오면 해당 조건을 무시하므로, 파라미터가 없으면 null을 반환하고 있으면 조건을 반환하는 패턴으로 동적 쿼리를 깔끔하게 구현할 수 있다.