스프링 사전과제 가이드 5편: Security & Authentication — Spring Boot 4 · Kotlin 2.3 · Spring Security 7, JWT(oauth2-resource-server), BCrypt·Argon2, RBAC

스프링 사전과제 가이드 5편: Security & Authentication — Spring Boot 4 · Kotlin 2.3 · Spring Security 7, JWT(oauth2-resource-server), BCrypt·Argon2, RBAC


서론

“보안 설정, 어디까지 해야 평가자가 고개를 끄덕일까?”

사전과제에서 Security 영역은 두 갈래다. JJWT를 직접 다루며 OncePerRequestFilter를 손으로 짠 것과, Spring Security가 제공하는 oauth2-resource-server 추상화를 쓴 것. 평가자는 후자를 보면 “Spring Security를 알고 있구나”라고 읽는다. 전자는 “동작하지만 표준과 다르다”는 인상을 남긴다.

4편에서 N+1·캐시·페이지네이션을 다뤘다. 5편은 그 위에서 인증·인가 레이어를 쌓는다. 핵심은 세 가지다.

  • spring-boot-starter-oauth2-resource-server로 JWT 검증을 Spring Security에게 위임하는 방법
  • JwtDecoder·JwtEncoder Bean 한 짝으로 검증·발급을 표준 API로 처리하는 패턴
  • @AuthenticationPrincipal Jwt로 Controller에서 현재 사용자를 받는 방법

대상 독자는 Spring Security는 아는데 사전과제 평가자가 어디를 보는지 모르는 주니어 백엔드 개발자다.

이전 글에서 Performance & Optimization을 다뤘다.


TL;DR

  • oauth2-resource-server가 Spring Security 7의 표준BearerTokenAuthenticationFilter가 토큰 추출·검증·SecurityContext 설정을 자동으로 처리한다. 직접 OncePerRequestFilter를 만들 필요 없다.
  • JwtDecoder + JwtEncoder를 Bean으로 등록NimbusJwtDecoder(검증)와 NimbusJwtEncoder(발급)를 Spring Security 추상화로 다룬다. HMAC과 RSA 모두 같은 API를 쓴다.
  • JwtAuthenticationConverter로 role claim → ROLE_ 매핑 — JWT의 role claim을 Spring Security GrantedAuthority로 변환한다. @PreAuthorize("hasRole('SELLER')") 가 이 매핑에 의존한다.
  • @AuthenticationPrincipal Jwt로 Controller에서 현재 사용자jwt.getSubject()로 userId, jwt.getClaim("email")로 임의 claim을 꺼낸다. 타입 캐스팅 없이 깔끔하다.
  • BCrypt는 기본, Argon2는 옵션PasswordEncoderFactories.createDelegatingPasswordEncoder()가 기본값으로 BCrypt를 쓴다. 보안 요구사항이 높으면 Argon2로 교체한다.

1. Spring Security 7 — 평가의 출발선

1.1 의존성과 Spring Security 6 → 7 주요 변경

참고: Spring Boot 4 + Kotlin 2.3 프로젝트 셋업(kotlin-spring·kotlin-jpa plugin 등) 자체는 1편 1.1절에서 다뤘다. 5편은 그 위에서 도는 Security 영역에 집중한다. Kotlin 2.x 시리즈는 백워드 호환이라 같은 코드가 2.0~2.3 모두 작동한다.

Spring Security 7에서는 spring-boot-starter-oauth2-resource-server를 함께 추가하는 것이 표준이다. 이 스타터는 Nimbus JOSE+JWT를 transitive 의존성으로 가져오기 때문에 JJWT 라이브러리를 별도로 추가할 필요가 없다.

// settings.gradle.kts
dependencyResolutionManagement {
    versionCatalogs {
        create("libs") {
            library("spring-boot-starter-security", "org.springframework.boot:spring-boot-starter-security:3.4.0")
            library("spring-boot-starter-oauth2-resource-server", "org.springframework.boot:spring-boot-starter-oauth2-resource-server:3.4.0")
            library("spring-security-test", "org.springframework.security:spring-security-test:6.4.0")
        }
    }
}
// build.gradle.kts
dependencies {
    implementation(libs.spring.boot.starter.security)
    implementation(libs.spring.boot.starter.oauth2.resource.server)
    // JJWT 라이브러리 불필요 — Nimbus JOSE+JWT가 transitive로 포함됨
    testImplementation(libs.spring.security.test)
}

Spring Security 6에서 7로 올라오면서 평가자가 자주 확인하는 변경 포인트는 다음과 같다.

항목Spring Security 6Spring Security 7
URL 권한 APIantMatchers() 제거requestMatchers()만 사용
HTTP DSL일부 구 API 병존Lambda DSL 전면 표준
메서드 보안@EnableGlobalMethodSecurity@EnableMethodSecurity
JWT 필터직접 OncePerRequestFilter 구현oauth2ResourceServer 설정 권장
@EnableWebSecurity명시 선언 필요Spring Boot 자동 설정, 생략 가능

1.2 SecurityFilterChain 한 장으로 보기

Spring Security 7에서 oauth2ResourceServer를 설정하면 BearerTokenAuthenticationFilter가 자동으로 필터 체인에 등록된다. 이 필터가 Authorization: Bearer ... 헤더를 추출하고, JwtDecoder에게 검증을 위임하고, JwtAuthenticationConverter로 Authority를 변환한 뒤 SecurityContext에 저장한다.

sequenceDiagram
    participant C as Client
    participant F as BearerTokenAuthenticationFilter
    participant D as JwtDecoder
    participant V as JwtAuthenticationConverter
    participant SC as SecurityContext
    participant Ctrl as Controller

    C->>F: GET /api/v1/me (Authorization: Bearer ...)
    F->>D: decode(token)
    D->>D: 서명·만료 검증
    D-->>F: Jwt 객체
    F->>V: convert(jwt)
    V->>V: role claim → ROLE_ Authority
    V-->>F: JwtAuthenticationToken
    F->>SC: 저장
    F->>Ctrl: @AuthenticationPrincipal Jwt 주입

SecurityConfig 전체 코드다. JJWT 기반 구현과의 차이는 addFilterBefore(JwtAuthenticationFilter(...)) 줄이 oauth2ResourceServer(...) 한 줄로 대체된다는 점이다.

@Configuration
@EnableMethodSecurity
class SecurityConfig(
    @Value("\${jwt.secret}") private val secret: String
) {

    @Bean
    fun filterChain(http: HttpSecurity): SecurityFilterChain =
        http
            .csrf { it.disable() }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .authorizeHttpRequests { auth ->
                auth
                    .requestMatchers("/api/v1/auth/**").permitAll()
                    .requestMatchers(HttpMethod.GET, "/api/v1/products/**").permitAll()
                    .requestMatchers("/swagger-ui/**", "/v3/api-docs/**").permitAll()
                    .anyRequest().authenticated()
            }
            .oauth2ResourceServer { oauth2 ->
                oauth2.jwt { jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()) }
            }
            .build()

    @Bean
    fun jwtDecoder(): JwtDecoder {
        val key = SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")
        return NimbusJwtDecoder.withSecretKey(key)
            .macAlgorithm(MacAlgorithm.HS256)
            .build()
    }

    @Bean
    fun jwtEncoder(): JwtEncoder {
        val key = SecretKeySpec(secret.toByteArray(StandardCharsets.UTF_8), "HmacSHA256")
        val jwks: JWKSource<SecurityContext> = ImmutableSecret(key)
        return NimbusJwtEncoder(jwks)
    }

    @Bean
    fun passwordEncoder(): PasswordEncoder =
        PasswordEncoderFactories.createDelegatingPasswordEncoder()

    private fun jwtAuthenticationConverter(): JwtAuthenticationConverter {
        val authoritiesConverter = JwtGrantedAuthoritiesConverter().apply {
            setAuthoritiesClaimName("role")
            setAuthorityPrefix("ROLE_")
        }
        return JwtAuthenticationConverter().apply {
            setJwtGrantedAuthoritiesConverter(authoritiesConverter)
        }
    }
}

1.3 @EnableWebSecurity vs @EnableMethodSecurity

두 어노테이션을 혼동하는 경우가 많다.

어노테이션역할Spring Boot 3.x에서
@EnableWebSecurity웹 보안 자동 설정 활성화Spring Boot가 자동 적용 → 생략 가능
@EnableMethodSecurity@PreAuthorize·@PostAuthorize 활성화명시적으로 선언 필요

@EnableMethodSecurity가 없으면 @PreAuthorize("hasRole('SELLER')")를 붙여도 무시된다. @Configuration 클래스 어딘가에 반드시 선언해야 한다.


2. JWT 인증 — oauth2-resource-server로 검증·발급

2.1 의존성과 secret 관리

application.yml에 secret을 환경변수로 받아 설정한다. 소스 코드에 평문으로 넣으면 감점이다.

# application.yml
jwt:
  secret: ${JWT_SECRET:your-256-bit-secret-key-must-be-at-least-32-characters}

Secret은 256비트(32바이트) 이상이어야 HMAC-SHA256 서명 검증 시 오류가 발생하지 않는다.

참고: 프로덕션에서는 AWS Secrets Manager 또는 Vault 같은 시크릿 관리 시스템을 쓴다. 과제에서는 .env 파일 + .gitignore 처리로 충분하다.

2.2 JwtDecoder · JwtEncoder Bean — 검증·발급의 한 짝

JwtDecoder는 수신한 토큰을 검증하고 파싱하는 Bean이고, JwtEncoder는 새 토큰을 발급하는 Bean이다. oauth2-resource-server 스타터를 추가하면 JwtDecoder Bean을 컨테이너에서 자동으로 찾아 BearerTokenAuthenticationFilter에 연결한다.

SecurityConfig에 이미 두 Bean을 등록했다(1.2절 참조). HMAC 방식의 경우 SecretKeySpec을 공유해 쓴다. RSA 방식이 필요한 경우는 아래를 참고한다.

RSA 비대칭 키 방식 (더 자세히)

RSA 키 쌍을 사용하면 공개키 배포만으로 외부 서비스가 토큰을 검증할 수 있다. 인증 서버가 분리된 MSA 구조에서 주로 쓴다.

@Bean
fun jwtDecoder(publicKey: RSAPublicKey): JwtDecoder =
    NimbusJwtDecoder.withPublicKey(publicKey).build()

@Bean
fun jwtEncoder(privateKey: RSAPrivateKey, publicKey: RSAPublicKey): JwtEncoder {
    val rsaKey = RSAKey.Builder(publicKey).privateKey(privateKey).build()
    val jwks: JWKSource<SecurityContext> = ImmutableJWKSet(JWKSet(rsaKey))
    return NimbusJwtEncoder(jwks)
}

과제에서는 HMAC이 충분하다. RSA는 “MSA에서 공개키만 배포해 검증한다”는 개념만 알면 된다.

2.3 JwtAuthenticationConverter — role claim → ROLE_ Authority 매핑

Spring Security의 hasRole('SELLER') SpEL은 내부적으로 ROLE_SELLER라는 이름의 GrantedAuthority를 찾는다. JWT의 role claim 값이 SELLER라면 자동으로 ROLE_SELLER로 변환해 주는 것이 JwtAuthenticationConverter다.

// SecurityConfig 내부 private 메서드 (1.2절에 이미 포함)
private fun jwtAuthenticationConverter(): JwtAuthenticationConverter {
    val authoritiesConverter = JwtGrantedAuthoritiesConverter().apply {
        setAuthoritiesClaimName("role")  // JWT claim 이름
        setAuthorityPrefix("ROLE_")      // prefix 추가
    }
    // principal name = sub claim (기본값) → userId가 들어있음
    return JwtAuthenticationConverter().apply {
        setJwtGrantedAuthoritiesConverter(authoritiesConverter)
    }
}

setAuthoritiesClaimName("role")을 빠뜨리면 기본값 scope claim을 바라봐서 @PreAuthorize가 항상 실패한다. 과제에서 자주 빠지는 부분이다.

2.4 인증 API — signup · login · refresh 흐름

인증 흐름 세 가지를 시퀀스로 먼저 보고 코드로 들어간다.

sequenceDiagram
    participant C as Client
    participant AC as AuthController
    participant AS as AuthService
    participant TS as TokenService
    participant E as JwtEncoder

    Note over C,E: 1. Login 흐름
    C->>AC: POST /api/v1/auth/login
    AC->>AS: login(command)
    AS->>TS: createAccessToken(userId, email, role)
    TS->>E: encode(JwtClaimsSet)
    E-->>TS: accessToken
    AS->>TS: createRefreshToken(userId)
    TS->>E: encode(JwtClaimsSet)
    E-->>TS: refreshToken
    AS-->>AC: TokenResponse
    AC-->>C: { accessToken, refreshToken }

    Note over C,E: 2. 인증 요청 흐름 (자동 검증)
    C->>AC: GET /api/v1/me (Bearer accessToken)
    Note right of AC: BearerTokenAuthenticationFilter가<br/>JwtDecoder로 자동 검증

    Note over C,E: 3. Refresh 흐름
    C->>AC: POST /api/v1/auth/refresh
    AC->>AS: refresh(refreshToken)
    AS->>TS: parseUserId(refreshToken)
    TS-->>AS: userId
    AS->>TS: createAccessToken(userId, email, role)
    TS-->>AS: newAccessToken
    AS-->>AC: TokenResponse
    AC-->>C: { newAccessToken, refreshToken }

TokenServiceJwtEncoder로 발급, JwtDecoder로 파싱한다. JJWT의 JwtTokenProvider가 하던 역할을 Spring Security 추상화로 대체한다.

@Service
class TokenService(
    private val jwtEncoder: JwtEncoder,
    private val jwtDecoder: JwtDecoder
) {
    companion object {
        private val ACCESS_TOKEN_TTL: Duration = Duration.ofHours(1)
        private val REFRESH_TOKEN_TTL: Duration = Duration.ofDays(7)
    }

    fun createAccessToken(userId: Long, email: String, role: String): String {
        val now = Instant.now()
        val claims = JwtClaimsSet.builder()
            .issuer("self")
            .issuedAt(now)
            .expiresAt(now.plus(ACCESS_TOKEN_TTL))
            .subject(userId.toString())
            .claim("email", email)
            .claim("role", role)
            .build()
        return jwtEncoder.encode(JwtEncoderParameters.from(claims)).tokenValue
    }

    fun createRefreshToken(userId: Long): String {
        val now = Instant.now()
        val claims = JwtClaimsSet.builder()
            .issuer("self")
            .issuedAt(now)
            .expiresAt(now.plus(REFRESH_TOKEN_TTL))
            .subject(userId.toString())
            .claim("token_type", "refresh")
            .build()
        return jwtEncoder.encode(JwtEncoderParameters.from(claims)).tokenValue
    }

    fun parseUserId(token: String): Long {
        val jwt = jwtDecoder.decode(token)  // 검증 + 파싱 한 번에
        return jwt.subject.toLong()
    }
}

AuthController + AuthServiceTokenService를 주입해서 쓴다.

@RestController
@RequestMapping("/api/v1/auth")
class AuthController(private val authService: AuthService) {

    @PostMapping("/signup")
    fun signup(@Valid @RequestBody request: SignupRequest): ResponseEntity<Void> {
        authService.signup(request.toCommand())
        return ResponseEntity.status(HttpStatus.CREATED).build()
    }

    @PostMapping("/login")
    fun login(@Valid @RequestBody request: LoginRequest): ResponseEntity<TokenResponse> =
        ResponseEntity.ok(authService.login(request.toCommand()))

    @PostMapping("/refresh")
    fun refresh(@RequestBody request: RefreshTokenRequest): ResponseEntity<TokenResponse> =
        ResponseEntity.ok(authService.refresh(request.refreshToken))
}
@Service
@Transactional(readOnly = true)
class AuthService(
    private val memberRepository: MemberRepository,
    private val passwordEncoder: PasswordEncoder,
    private val tokenService: TokenService
) {

    @Transactional
    fun signup(command: SignupCommand) {
        if (memberRepository.existsByEmail(command.email)) {
            throw DuplicateEmailException(command.email)
        }
        val member = Member(
            email = command.email,
            password = passwordEncoder.encode(command.password),
            name = command.name,
            role = MemberRole.USER
        )
        memberRepository.save(member)
    }

    fun login(command: LoginCommand): TokenResponse {
        val member = memberRepository.findByEmail(command.email)
            ?: throw InvalidCredentialsException()

        if (!passwordEncoder.matches(command.password, member.password)) {
            throw InvalidCredentialsException()
        }

        val accessToken = tokenService.createAccessToken(
            member.id, member.email, member.role.name
        )
        val refreshToken = tokenService.createRefreshToken(member.id)
        return TokenResponse(accessToken, refreshToken)
    }

    fun refresh(refreshToken: String): TokenResponse {
        val userId = tokenService.parseUserId(refreshToken)  // 만료 시 JwtException
        val member = memberRepository.findById(userId)
            .orElseThrow { MemberNotFoundException(userId) }

        val newAccessToken = tokenService.createAccessToken(
            member.id, member.email, member.role.name
        )
        return TokenResponse(newAccessToken, refreshToken)
    }
}

2.5 Controller에서 현재 사용자 — @AuthenticationPrincipal Jwt

BearerTokenAuthenticationFilter가 SecurityContext에 저장한 JwtAuthenticationToken의 principal은 Jwt 객체다. Controller에서 @AuthenticationPrincipal Jwt jwt로 직접 받으면 타입 캐스팅 없이 claim을 꺼낼 수 있다.

@GetMapping("/me")
fun getMyProfile(@AuthenticationPrincipal jwt: Jwt): MemberResponse {
    val userId = jwt.subject.toLong()
    return memberService.getMember(userId)
}

@PostMapping
@PreAuthorize("hasRole('SELLER')")
fun createProduct(
    @AuthenticationPrincipal jwt: Jwt,
    @Valid @RequestBody request: CreateProductRequest
): ProductResponse {
    val sellerId = jwt.subject.toLong()
    return productService.createProduct(sellerId, request)
}

jwt.subject — userId(sub claim), jwt.getClaim<String>("email") — 임의 claim, jwt.getClaim<String>("role") — role claim이다.

2.6 참고: JJWT 직접 구현 vs oauth2-resource-server

두 방식의 차이 — 더 자세히
비교 항목JJWT 직접 구현oauth2-resource-server
의존성jjwt-api + jjwt-impl + jjwt-jacksonoauth2-resource-server 하나
JWT 필터OncePerRequestFilter 직접 작성BearerTokenAuthenticationFilter 자동 등록
토큰 검증validateToken() 직접 구현JwtDecoder Bean이 처리
Authority 매핑필터에서 SimpleGrantedAuthority 수동 생성JwtAuthenticationConverter
OAuth2 표준 호환없음RFC 6750 Bearer Token 표준 준수
Spring Security 7 권장도비표준표준

oauth2-resource-server 방식의 실질적 장점은 두 가지다.

  • 검증과 파싱이 분리되지 않는다decode() 한 번에 서명 검증 + 파싱을 같이 한다. JJWT 방식은 validateToken() + getClaims() 두 번 호출하는 패턴이 자주 나온다.
  • Filter 보일러플레이트가 없다 — 200줄짜리 JwtAuthenticationFilter가 SecurityConfig 설정 몇 줄로 대체된다.

2.7 참고: Session vs JWT, 토큰 저장 위치

Session vs JWT, 저장 위치 비교
구분SessionJWT
저장 위치서버(메모리/Redis)클라이언트
확장성서버 간 세션 공유 필요Stateless, 수평 확장 용이
로그아웃서버에서 즉시 무효화블랙리스트 관리 필요
과제 권장불필요REST API 과제의 사실상 표준

토큰 저장 위치:

위치장점단점
LocalStorage구현 간단XSS 취약
SessionStorage탭 닫으면 삭제XSS 취약
HttpOnly CookieXSS 방어CSRF 대응 필요
메모리가장 안전새로고침 시 소멸

프로덕션 권장: Access Token → 메모리, Refresh Token → HttpOnly + Secure + SameSite Cookie. 백엔드만 있는 과제라면 응답 Body로 반환해도 무방하다.


3. 비밀번호 관리 — BCrypt와 Argon2

3.1 PasswordEncoderFactories.createDelegatingPasswordEncoder() — 기본

PasswordEncoderFactories.createDelegatingPasswordEncoder()는 저장된 해시의 알고리즘 prefix({bcrypt}, {argon2} 등)를 보고 자동으로 적절한 인코더를 선택하는 팩토리다.

Spring Security 7의 기본 알고리즘은 {bcrypt}다. SecurityConfig에서 이미 등록했다.

@Bean
fun passwordEncoder(): PasswordEncoder =
    PasswordEncoderFactories.createDelegatingPasswordEncoder()
    // 저장 형식: {bcrypt}$2a$10$...

비밀번호 변경 로직의 올바른 패턴은 다음과 같다.

@Transactional
fun changePassword(memberId: Long, currentPassword: String, newPassword: String) {
    val member = memberRepository.findById(memberId)
        .orElseThrow { MemberNotFoundException(memberId) }

    if (!passwordEncoder.matches(currentPassword, member.password)) {
        throw InvalidPasswordException()
    }

    member.changePassword(passwordEncoder.encode(newPassword))
}

3.2 비밀번호 정책 Validation

Request DTO에서 @Pattern으로 입력 정책을 강제한다. Kotlin에서 Bean Validation 어노테이션은 @field: site target으로 붙여야 underlying field에 적용된다.

data class SignupRequest(
    @field:NotBlank
    @field:Email
    val email: String,

    @field:NotBlank
    @field:Pattern(
        regexp = "^(?=.*[A-Za-z])(?=.*\\d)(?=.*[@\$!%*#?&])[A-Za-z\\d@\$!%*#?&]{8,20}\$",
        message = "비밀번호는 8~20자, 영문·숫자·특수문자를 포함해야 합니다"
    )
    val password: String,

    @field:NotBlank
    @field:Size(min = 2, max = 20)
    val name: String
) {
    fun toCommand(): SignupCommand = SignupCommand(email, password, name)
}

3.3 참고: BCrypt vs Argon2 비교 표 + Spring Security 7에서의 권장도

알고리즘특징권장 상황
BCrypt1999년부터 검증된 알고리즘, 광범위 사용일반 웹 애플리케이션, 사전과제
Argon22015 PHC 우승, 메모리 비용 조절 가능, GPU 공격에 강함높은 보안 요구사항
scrypt메모리 집약적, 병렬 공격 방어일부 금융 서비스

Spring Security 7에서 Argon2 사용:

@Bean
fun passwordEncoder(): PasswordEncoder =
    // saltLength=16, hashLength=32, parallelism=1, memory=65536KB, iterations=3
    Argon2PasswordEncoder(16, 32, 1, 65536, 3)

참고: 과제에서는 BCrypt가 사실상 표준이다. 위 예시처럼 단일 Argon2PasswordEncoder Bean으로 통째 교체하면 기존 {bcrypt} prefix 해시는 검증 실패한다. 점진 마이그레이션이 필요하면 DelegatingPasswordEncoder 구조를 유지하면서 인코더 맵에 Argon2를 추가하고 idForEncode"argon2"로 바꾸면 된다 — 새 해시는 {argon2}로 저장되고 기존 {bcrypt} 해시는 prefix 기반으로 BCrypt에서 계속 검증된다.


4. API 권한 관리 — RBAC와 리소스 소유자 검증

4.1 역할 정의 (Role enum + Member entity)

과제 요구사항에 맞게 역할을 정의한다. @Enumerated(EnumType.STRING)으로 저장하면 DB에 USER, SELLER, ADMIN 문자열이 들어가 쿼리가 읽기 편하다.

enum class MemberRole {
    USER,    // 일반 사용자
    SELLER,  // 판매자
    ADMIN    // 관리자
}
@Entity
class Member(
    @Enumerated(EnumType.STRING)
    @Column(nullable = false)
    var role: MemberRole
)

4.2 메서드 수준 보안 @PreAuthorize

@EnableMethodSecurity를 선언하면 Controller 메서드에 @PreAuthorize를 붙일 수 있다. JWT의 role claim이 JwtAuthenticationConverter를 거쳐 ROLE_SELLER로 변환되기 때문에 hasRole('SELLER')가 작동한다.

@RestController
@RequestMapping("/api/v1/products")
class ProductController(private val productService: ProductService) {

    // SecurityConfig에서 permitAll 설정 → 누구나 조회
    @GetMapping("/{productId}")
    fun getProduct(@PathVariable productId: Long): ProductResponse =
        productService.getProduct(productId)

    // SELLER 권한만 상품 등록
    @PostMapping
    @PreAuthorize("hasRole('SELLER')")
    fun createProduct(
        @AuthenticationPrincipal jwt: Jwt,
        @Valid @RequestBody request: CreateProductRequest
    ): ProductResponse {
        val sellerId = jwt.subject.toLong()
        return productService.createProduct(sellerId, request)
    }

    // SELLER 권한만 상품 수정
    @PatchMapping("/{productId}")
    @PreAuthorize("hasRole('SELLER')")
    fun updateProduct(
        @AuthenticationPrincipal jwt: Jwt,
        @PathVariable productId: Long,
        @RequestBody request: UpdateProductRequest
    ): ProductResponse {
        val sellerId = jwt.subject.toLong()
        return productService.updateProduct(sellerId, productId, request)
    }

    // ADMIN 권한만 전체 조회
    @GetMapping("/admin/all")
    @PreAuthorize("hasRole('ADMIN')")
    fun getAllProductsForAdmin(): List<ProductResponse> =
        productService.getAllProductsForAdmin()
}

4.3 리소스 소유자 검증 — Service vs @PreAuthorize 두 방식

role 검증은 @PreAuthorize로 충분하지만, “본인 것인가”를 확인하는 소유자 검증은 별도로 필요하다.

방식 1: Service에서 직접 검증 (과제 권장)

@Service
@Transactional(readOnly = true)
class ProductService(private val productRepository: ProductRepository) {

    @Transactional
    fun updateProduct(
        sellerId: Long,
        productId: Long,
        request: UpdateProductRequest
    ): ProductResponse {
        val product = productRepository.findById(productId)
            .orElseThrow { BusinessException(ErrorCode.PRODUCT_NOT_FOUND) }

        if (product.sellerId != sellerId) {
            throw BusinessException(ErrorCode.PRODUCT_NOT_OWNED)
        }

        product.update(request.name, request.price)
        return ProductResponse.from(product)
    }
}

방식 2: @PreAuthorize + SpEL 커스텀 서비스

@GetMapping("/{orderId}")
@PreAuthorize("@orderAuthorizationService.isOwner(#orderId, authentication.principal)")
fun getOrder(@PathVariable orderId: Long): OrderResponse =
    orderService.getOrder(orderId)
@Service
class OrderAuthorizationService(private val orderRepository: OrderRepository) {

    // authentication.principal은 Jwt 객체
    fun isOwner(orderId: Long, jwt: Jwt): Boolean {
        val userId = jwt.subject.toLong()
        return orderRepository.findById(orderId)
            .map { it.buyerId == userId }
            .orElse(false)
    }
}

두 방식의 비교:

구분Service 검증@PreAuthorize + SpEL
가독성로직이 코드에 명시적어노테이션으로 간결
단위 테스트Service 테스트로 커버SpEL 테스트 복잡
디버깅일반 예외 추적SpEL 실패 메시지 불명확
과제 권장권장심화 옵션

4.4 현재 사용자 정보 접근 — @AuthenticationPrincipal Jwt

@AuthenticationPrincipal Jwt jwt 패턴으로 Controller에서 현재 사용자의 정보를 꺼낸다.

@RestController
@RequestMapping("/api/v1/members")
class MemberController(private val memberService: MemberService) {

    @GetMapping("/me")
    fun getCurrentMember(@AuthenticationPrincipal jwt: Jwt): MemberResponse {
        val userId = jwt.subject.toLong()
        return memberService.getMember(userId)
    }

    @PatchMapping("/me")
    fun updateProfile(
        @AuthenticationPrincipal jwt: Jwt,
        @Valid @RequestBody request: UpdateMemberRequest
    ): MemberResponse {
        val userId = jwt.subject.toLong()
        return memberService.updateMember(userId, request)
    }
}
커스텀 어노테이션 방식 (선택사항)

@AuthenticationPrincipal이 너무 길거나, userId 추출 코드가 반복된다면 커스텀 어노테이션으로 감쌀 수 있다.

@Target(AnnotationTarget.VALUE_PARAMETER)
@Retention(AnnotationRetention.RUNTIME)
@AuthenticationPrincipal
annotation class CurrentUser

@AuthenticationPrincipal을 메타 어노테이션으로 붙이면 Spring이 동일하게 처리한다. Controller에서는 @CurrentUser jwt: Jwt로 쓰면 된다.

4.5 권한 체크 위치 — Filter / @PreAuthorize / Service

권한 검사는 세 곳에서 할 수 있다. 각각 역할이 다르다.

flowchart TD
    Start([요청]) --> Q1{URL 패턴 단위 권한?}
    Q1 -->|Yes| A1[SecurityFilterChain<br/>requestMatchers + .hasRole]
    Q1 -->|No| Q2{메서드 단위 + 단순 role?}
    Q2 -->|Yes| A2["@PreAuthorize<br/>hasRole('SELLER')"]
    Q2 -->|No| Q3{리소스 소유자 검증<br/>또는 복잡한 비즈니스 조건?}
    Q3 -->|Yes| A3[Service 메서드<br/>소유자 ID 비교]
    Q3 -->|No| A4[설계 재검토]
위치역할예시
SecurityFilterChainURL 그룹 단위 진입 제어/admin/** → ADMIN만 허용
@PreAuthorize메서드 단위 role 검사hasRole('SELLER')
Service데이터 기반 소유자 검증product.getSellerId().equals(sellerId)

5. CORS 설정

5.1 전역 CORS — CorsConfigurationSource + SecurityConfig 통합

Spring Security를 쓸 때는 반드시 SecurityConfig에서 .cors(cors -> cors.configurationSource(...))로 CORS 설정을 연결해야 한다. CorsConfig만 만들고 Security에 연결하지 않으면 preflight(OPTIONS) 요청이 401을 반환한다.

@Configuration
class CorsConfig {

    @Bean
    fun corsConfigurationSource(): CorsConfigurationSource {
        val configuration = CorsConfiguration().apply {
            allowedOrigins = listOf(
                "http://localhost:3000",
                "https://your-frontend-domain.com"
            )
            allowedMethods = listOf("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
            allowedHeaders = listOf("*")
            exposedHeaders = listOf("Authorization")
            allowCredentials = true
            maxAge = 3600L
        }
        return UrlBasedCorsConfigurationSource().apply {
            registerCorsConfiguration("/**", configuration)
        }
    }
}

SecurityConfig의 filterChain.cors(...) 추가:

@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain =
    http
        .cors { it.configurationSource(corsConfigurationSource()) }
        .csrf { it.disable() }
        // ... 나머지 설정
        .build()

참고: corsConfigurationSource를 같은 @Configuration 클래스에 두거나, @Autowired로 주입해서 SecurityConfig에서 참조하면 된다.

5.2 Controller 수준 — @CrossOrigin

특정 Controller에만 CORS를 다르게 적용할 때 쓴다.

@RestController
@RequestMapping("/api/v1/public")
@CrossOrigin(origins = ["http://localhost:3000"])
class PublicController {
    // 이 Controller에만 Origin 제한 적용
}

전역 설정과 Controller 수준 설정이 동시에 있으면 Spring은 두 설정을 병합한다.

5.3 흔한 실수 — allowedOrigins(”*”) + allowCredentials(true) 충돌

allowedOrigins("*")allowCredentials(true)를 함께 쓰면 브라우저가 CORS 오류를 내고, Spring도 시작 시 경고를 출력한다.

상황올바른 설정
개발 환경, 인증 필요setAllowedOrigins(List.of("http://localhost:3000")) + setAllowCredentials(true)
공개 API, 인증 불필요setAllowedOriginPatterns(List.of("*")) + setAllowCredentials(false)

와일드카드를 쓰면서 쿠키·Authorization 헤더를 보내야 한다면 allowedOriginPatterns("*")를 사용한다. 이 설정은 allowedOrigins("*")와 달리 allowCredentials(true)와 공존할 수 있다.


정리

  • oauth2-resource-server가 Spring Security 7 표준JwtDecoder Bean 하나로 BearerTokenAuthenticationFilter에서 자동 검증이 돌아간다. 필터를 직접 만들 필요 없다.
  • JwtDecoder + JwtEncoder를 Bean으로 관리 — 검증(decode)과 발급(encode)이 같은 API 레벨에서 움직인다. HMAC과 RSA 모두 설정 차이만 있을 뿐 사용 방법이 같다.
  • JwtAuthenticationConverter로 role claim → ROLE_ 변환setAuthoritiesClaimName("role") 한 줄이 없으면 @PreAuthorize가 전부 실패한다. 빠뜨리기 쉬운 부분이다.
  • @AuthenticationPrincipal Jwt가 표준 패턴jwt.getSubject()로 userId, jwt.getClaim()으로 임의 claim. 타입 캐스팅 없이 깔끔하다.
  • BCrypt 기본, CORS는 Security와 반드시 연결PasswordEncoderFactories.createDelegatingPasswordEncoder()가 알고리즘 전환을 하위 호환으로 처리한다. CORS는 Security에 연결하지 않으면 preflight가 401이다.

체크리스트:

항목확인
spring-boot-starter-oauth2-resource-server 의존성 추가
JwtDecoder + JwtEncoder Bean 등록
JwtAuthenticationConvertersetAuthoritiesClaimName("role") 설정
oauth2ResourceServer 설정으로 BearerTokenAuthenticationFilter 활성화
@EnableMethodSecurity 선언
JWT secret을 환경변수로 분리 (${JWT_SECRET})
비밀번호 BCrypt 암호화 확인
리소스 소유자 검증 (Service 내 ID 비교)
CORS를 SecurityConfig에 연결 (cors.configurationSource(...))
allowedOrigins("*") + allowCredentials(true) 혼용 확인

다음 편에서는 Docker · Docker Compose · GitHub Actions CI/CD를 다룬다. 6편에서는 사전과제 제출 전에 꼭 넣어야 할 Dockerfile 패턴과 배포 파이프라인을 평가자 시점으로 정리한다.


부록

흔한 실수 5종

실수증상올바른 처리
JWT secret 하드코딩GitHub에 노출, 보안 취약점${JWT_SECRET} 환경변수 + .gitignore
토큰 만료 처리 누락만료 토큰으로 계속 요청 성공JwtDecoder가 만료 시 JwtException 발생 → 401 응답 처리
비밀번호 평문 노출Response DTO에 password 필드 포함, 로그 출력DTO에서 password 제거, 로그 필터 적용
권한 검사 누락다른 사용자 리소스 접근 가능Service에서 sellerId.equals(product.getSellerId()) 검증
CORS allowedOrigins(”*”) + allowCredentials(true)브라우저 CORS 오류특정 Origin 명시 또는 allowedOriginPatterns("*") 사용

Refresh Token Rotation

Refresh Token Rotation이란 Refresh Token 사용 시 새 Refresh Token도 함께 발급하고 기존 것을 무효화하는 패턴이다. 탈취된 Refresh Token의 재사용을 감지할 수 있다.

fun refresh(refreshToken: String): TokenResponse {
    val userId = tokenService.parseUserId(refreshToken)  // 만료 시 JwtException → 401
    val member = memberRepository.findById(userId)
        .orElseThrow { MemberNotFoundException(userId) }

    // Access Token + 새 Refresh Token 둘 다 발급
    val newAccessToken = tokenService.createAccessToken(
        member.id, member.email, member.role.name
    )
    val newRefreshToken = tokenService.createRefreshToken(member.id)

    // 기존 Refresh Token 무효화 (DB 저장 시)
    // refreshTokenRepository.delete(refreshToken)

    return TokenResponse(newAccessToken, newRefreshToken)
}

과제에서 구현하면 가산점, 구현하지 않아도 감점은 아니다.

외부 참조

이 포스팅은 쿠팡 파트너스 활동의 일환으로, 이에 따른 일정액의 수수료를 제공받습니다.