반응형

들어가며

레거시 시스템을 MSA로 전환하면서 지도 도메인 마이크로서비스를 맡았다. 이 서비스의 핵심 기능 중 하나는 "이 좌표가 어느 영역에 속하는가" 를 판정하는 것이다.

  • 유저가 지금 서비스 영역 안에 있는가?
  • 반납하려는 위치가 주차금지구역인가?

공유 모빌리티 특성상 이 판정은 거의 모든 요청 경로에 끼어 있다. 라이드 시작, 반납, 지도 조회… 호출 빈도가 매우 높은데, 레거시는 매 요청마다 DB에 공간 쿼리(ST_Contains)를 날리는 구조였다.

-- 레거시 방식 (개념 코드)
SELECT * FROM service_area
WHERE ST_Contains(polygon, ST_GeomFromText('POINT(:lng :lat)'))

전환하는 김에 "이게 최선인가?"를 검증하고 싶었다. 감이 아니라 숫자로.

설계 후보

떠올릴 수 있는 전략은 크게 세 가지였다.

전략 1. DB 직접 조회 (현행)
매 요청마다 ST_Contains 쿼리. 공간 인덱스가 있어도 네트워크 왕복 + DB 부하가 매 요청에 발생한다.

전략 2. Redis 캐싱 — 조회 시점 GET
영역 데이터 전체를 Redis에 blob으로 넣어두고, 요청마다 GET해서 애플리케이션에서 판정. DB 부하는 사라지지만, 요청마다 blob 전체가 네트워크를 타야 한다.

전략 3. 로컬 적재 + R-Tree 인메모리 인덱스 (최종 선택)
서버 기동 시(그리고 주기적으로) 영역 데이터를 로컬 메모리에 적재하고, R-Tree 공간 인덱스를 구축한다. 조회는 네트워크 없이 순수 인메모리 연산.

여기에 대조군으로 전략 2.5 (로컬 적재 + 선형 스캔) 를 추가했다. "로컬 적재가 빠른 건 당연한데, R-Tree까지 필요한가? 그냥 전부 순회해도 되지 않나?"라는 질문에 답하기 위해서다.

벤치마크 설계

측정 조건을 명시하지 않은 벤치마크는 의미가 없으므로, 조건부터.

  • 데이터: 운영 환경과 동일한 실데이터. 두 가지 규모로 측정
    • 데이터셋 A: 서비스 영역 705건 (Redis blob 약 1.1MB)
    • 데이터셋 B: 주차금지구역 15,605건 (Redis blob 약 5.3MB) — A의 약 22배
  • 부하: 좌표 조회 500회, 영역 밖 좌표 20% 포함
  • 환경: 단일 스레드 기준 측정 + 별도 동시성 테스트(동시성 1/10/50), 로컬 Docker Redis, DB는 VPN 너머 원격 인스턴스
  • 정합성 검증: 네 전략 모두 매칭 결과 건수가 동일한지 먼저 확인 (같은 답을 내는지 검증 안 된 성능 비교는 무의미)

⚠️ DB 평균 지연에는 로컬 → 원격 DB의 VPN 왕복이 포함되어 있다. 절대값보다는 전략 간 배율규모 변화에 따른 추이를 봐야 한다.

결과

데이터셋 A: 서비스 영역 705건

전략 평균 지연 처리량 배율
1. DB 직접 조회 50.50ms 20 QPS 1x
2. Redis 조회 시점 GET 5.83ms 172 QPS 8.7x
2.5 로컬 캐시 + 선형 스캔 17.0µs 58,815 QPS 2,970x
3. 로컬 캐시 + R-Tree 16.9µs 59,207 QPS 2,990x

데이터셋 B: 주차금지구역 15,605건 (22배 규모)

전략 평균 지연 처리량 배율
1. DB 직접 조회 53.49ms 19 QPS 1x
2. Redis 조회 시점 GET 39.38ms 25 QPS 1.4x
2.5 로컬 캐시 + 선형 스캔 82.3µs 12,156 QPS 650x
3. 로컬 캐시 + R-Tree 16.0µs 62,331 QPS 3,334x

결과 해석 — 표가 말해주는 세 가지

1. Redis 캐싱이 항상 답은 아니다

데이터셋 A에서 8.7배 개선이던 Redis 전략이, 데이터셋 B에서는 1.4배로 주저앉았다. blob이 1.1MB → 5.3MB로 커지자 조회 시점 GET의 네트워크 전송 비용이 DB 쿼리와 맞먹게 된 것이다.

"캐싱했으니 빠르겠지"는 착각이었다. 조회 시점에 큰 payload를 옮기는 구조라면, 캐시는 데이터가 커질수록 원본 조회를 닮아간다. 이 결과가 "조회 시점 GET이 아니라 로컬 적재 + 인덱스"로 가야 한다는 설계 판단의 결정적 근거가 됐다.

2. R-Tree의 O(log n)은 교과서가 아니라 실측이었다

데이터가 22배 늘었을 때:

  • 선형 스캔: 17.0µs → 82.3µs — 데이터량에 거의 비례해서 느려짐 (O(n))
  • R-Tree: 16.9µs → 16.0µs — 변화 없음 (O(log n))

705건일 때는 둘의 차이가 사실상 없었다. 그 시점만 보면 "R-Tree 괜히 넣었나?" 싶을 수 있다. 하지만 15,605건에서 5.1배 차이가 벌어졌고, 데이터는 앞으로도 늘어난다. "지금 빠른 구조"가 아니라 "커져도 빠른 구조"를 선택한 것이고, 그 차이를 실측으로 확인했다.

3. 동시성에서 격차는 더 벌어진다

단일 스레드 수치는 시작일 뿐이다. 실서비스는 동시 요청 환경이니까. 데이터셋 B로 동시성 1/10/50 테스트를 돌렸다 (HikariCP 풀 10 기준).

전략 동시 요청 환경 결과
DB 직접 조회 약 650 QPS에서 포화. 동시성 50에서 커넥션 풀 병목으로 p99가 34ms → 420ms (12배 악화)
Redis 조회 시점 GET 5.3MB blob 전송이 직렬화 병목 — 동시성 50에서 평균 445ms, 109 QPS. DB보다 나쁨
로컬 R-Tree 동시성 10에서 294,710 QPS, p99 161µs — 같은 조건 DB 대비 약 470배

DB는 커넥션 풀이라는 공유 자원의 한계에 부딪히고, Redis blob GET은 네트워크 대역폭에 부딪힌다. 반면 읽기 전용 인메모리 R-Tree는 락이 없어서 코어 수만큼 병렬로 확장된다. (동시성 50에서 처리량이 186k QPS로 내려간 건 측정 머신의 코어 수 한계다.)

트레이드오프 — 공짜는 없다

인메모리 방식이 압승처럼 보이지만, 대가가 없는 건 아니다.

1. 데이터 신선도
로컬 캐시는 주기 갱신 구조(예: 5분)라, 영역 변경이 즉시 반영되지 않는다. 우리 도메인에서 서비스 영역·주차금지구역은 운영자가 가끔 수정하는 데이터고, 몇 분의 지연은 허용 가능하다고 판단했다. 반영 지연이 치명적인 도메인이라면 이 구조는 답이 아니다.

2. 인덱스 구축 비용
15,605건 기준 R-Tree 구축에 1회 약 101ms. 5분 주기 갱신에서 101ms는 무시 가능한 수준이지만, 갱신 주기가 초 단위로 짧거나 데이터가 수백만 건이라면 다시 계산해봐야 한다.

3. 메모리 점유
영역 데이터 전체가 각 인스턴스의 힙에 올라간다. 수 MB 수준이라 문제없었지만, 스케일아웃 시 인스턴스마다 중복 적재된다는 점은 인지하고 있어야 한다.

마치며

  • 캐싱 전략은 "캐시를 쓰냐 마냐"가 아니라 "조회 시점에 무엇이 네트워크를 타느냐" 로 갈린다.
  • 자료구조 선택(선형 vs R-Tree)은 현재 데이터량이 아니라 데이터 증가 추이를 놓고 해야 한다.
  • 그리고 이 모든 판단은 벤치마크 코드 하나면 감이 아닌 숫자로 검증할 수 있다. 반나절 투자로 아키텍처 결정의 근거가 생겼다.

 

반응형
반응형

> 이전글 : [Kotlin / Spring] Spring Security 도입기 [2] : Custom Filter 구현하기

spring security 를 사용하면서 이것이 동작할 조건을 설정하기 위해 `Security Config` 를 작성한다.

 

작업하는 프로젝트에서는 spring security 를 통해 검증할 토큰의 종류가 크게 2가지 이다.

- Basic token

- Bearer token

 

기본적으로 Spring Security 는 Basic Auth 를 지원하기 때문에 만약 프로젝트가 다른형식의 인증방식을 따른다면 이를 커스텀 해주어야 한다. 이전에 작성한 CustomFilter 가 이를 위한 것이다.

 

[1] Security Config 작성하기

Basic token 검증과 CustomFilter 검증을 위해 두개의 Bean 을 등록해야 한다.

@Configuration
class SecurityConfig(
    private val jwtAdminTokenFilter: JwtAdminTokenFilter, // 직접 구현한 Custom Filter
) {
    @Bean
    @Order(1)
    fun adminBasicSecurityFilterChain(
        http: HttpSecurity,
        customAuthenticationEntryPoint: CustomAuthenticationEntryPoint,
    ): SecurityFilterChain {
        http
            .securityMatcher("/api/admin/token")
            .csrf { it.disable() }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .authorizeHttpRequests {
                it.anyRequest().authenticated()
            }.httpBasic {
                it.authenticationEntryPoint(customAuthenticationEntryPoint) // 커스텀 EntryPoint 적용
            }

        return http.build()
    }

    @Bean
    @Order(2)
    fun adminAccessSecurityFilterChain(http: HttpSecurity): SecurityFilterChain {
        http
            .csrf { it.disable() }
            .sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
            .securityMatcher("/api/user/token")
            .authorizeHttpRequests {
                it
                    .anyRequest()
                    .authenticated() // 나머지는 모두 인증 필요
            }.addFilterBefore(jwtAdminTokenFilter, UsernamePasswordAuthenticationFilter::class.java)
            .formLogin { it.disable() }
            .httpBasic { it.disable() } // Basic Auth 완전 제거

        return http.build()
    }

class 위에 어노테이션으로 @Configuration 과 @EnableWebSecurity 를 달아주어 해당 클래스가 스프링 설정 클래스여서 내부의 @Bean 메서드를 스프링 컨테이너에 빈으로 등록하겠다는것과, Spring Security 설정을 활성화 하겠다는것을 의미한다.

메소드로 SecurityFilterChain을 반환하는 함수를 선언한다. 각 줄에 대한 의미는 주석으로 달아두었다.

 


+) Order 어노테이션으로 순서 결정하기

여러 개의 SecurityFilterChain을 Bean 으로 등록하면, Spring Security는 가장 낮은 @Order 값을 가진 FilterChain부터 순서대로 경로 매칭(securityMatcher)을 수행한다.
즉, 숫자가 낮을수록 먼저 검사된다.

securityMatcher 로 경로를 지정해두었기 때문에 큰 문제는 없지만, 여러 FilterChain 이 공존할 때 어느 체인이 먼저 평가될지 명시적으로 설정해두는 것이 가장 안전한 방식이다.

 

[2] Security Config : Basic Token 검증

spring security 는 기본적으로 Basic Token 을 지원하고 있어 Security Config 에서 Baisc token 에 대한 검증시에는 크게 작성할 부분이 없다. 다만, 어떤 경로에 대해서 Basic Token 을 검증할 것인지 정도는 설정해 주어야 한다.

 

1. AuthenticationProvider 구현

위 코드에서는 `/api/admin/token` 경로일때에 검증하도록 설정해 두었다.

Basic token 에 대한 검증은 spring security의 AuthenticationProvider 에 의해서 감지되고 검증이 진행된다. 해당 프로바이더를 통해 spring security의 `UserDetailsService` 인터페이스의 함수를 호출하게 되는데 `AuthenticationProvider` 를 통해 `UserDetailsService` 로부터 반환된 UsernamePasswordAuthenticationToken 객체를 다시한번 검증하여 최종 리턴하도록 한다.

@Component
class CustomAuthenticationProvider(
    private val customUserDetailsService: CustomUserDetailsService,
    private val cryptoService: CryptoService,
) : AuthenticationProvider {
    override fun authenticate(authentication: Authentication): Authentication {
        val adminId = authentication.name
        val encryptedPassword =
            authentication.credentials as? String
                ?: throw CustomCredentialException(ErrorCodeType.NOT_EXISTENCE_PASSWORD)

        val userDetails = customUserDetailsService.loadUserByUsername(adminId)

        // 비밀번호 복호화 하여 검증하기
        try {
            if (!cryptoService.validateEncryptedPassword(encryptedPassword, userDetails.password)) {
                throw CustomCredentialException(ErrorCodeType.INVALID_PASSWORD)
            }
        } catch (ce: CustomCredentialException) {
            throw ce
        } catch (ie: IndexOutOfBoundsException) {
            throw CustomCredentialException(ErrorCodeType.INVALID_PASSWORD)
        } catch (e: Exception) {
            throw CustomCredentialException(errorCode = ErrorCodeType.FAIL_SECURITY, exception = e)
        }
        // return
        return UsernamePasswordAuthenticationToken(userDetails, null, userDetails.authorities)
    }

    override fun supports(authentication: Class<*>): Boolean =
        UsernamePasswordAuthenticationToken::class.java.isAssignableFrom(authentication)
}

 

Basic Token 은 간단하게 표현하면 아이디-비밀번호 형태로 2개값이 존재한다. 위 코드에서는 아이디로 관리자 ID 값을 지정하고, 비밀번호는 암호화된 키를 사용하도록 했다.

 

관리자 ID 에대해서는 `UserDetailsService` 구현체 클래스에서 인증된 관리자인지 검증하도록 하고, 이후 암호화된 비밀번호에 대해서 `AuthenticationProvider` 구현체 클래서에서 검증 후 최종적으로 `UsernamePasswordAuthenticationToken` 객체로 반환해준다.

 

2. UserDetailsService 구현

`UserDetailsService` 인터페이스의 구현체클래스를 작성한다.

@Service
@Transactional(readOnly = true)
class CustomUserDetailsService(
    @Value("\${spring.security.user.password}")
    private val password: String,
    private val adminService: AdminService,
) : UserDetailsService {
    private val log = logger()
    private val passwordEncoder = BCryptPasswordEncoder()
    private val mdcLogging = MdcLogging()

    override fun loadUserByUsername(username: String?): UserDetails {
        if (username.isNullOrEmpty()) {
            throw BException.of(ErrorCodeType.NOT_EXISTENCE_ADMIN)
        }
        // password parsing 하여 유효성 확인
        try {
            val adminId = username.toInt()
            val admin =
                adminService.findByIdOrNull(adminId)
                    ?: throw CustomCredentialException.of(ErrorCodeType.NOT_EXISTENCE_ADMIN)
            return BasicAuth(
                adminId = adminId,
                password = passwordEncoder.encode(password),
                authorities = mutableListOf(SimpleGrantedAuthority("ROLE_${admin.roleId}")),
            )
        } catch (ne: NumberFormatException) {
            log.error(ne) { "관리자 Id 타입 변환 오류" }
            throw BadCredentialsException("관리자 Id 타입 변환 오류", ne)
        } catch (be: BException) {
            log.error(be) { "관리자 basic 검증 실패" }
            throw BException.of(ge.errorCode, ge)
        } catch (e: InternalAuthenticationServiceException) {
            log.error(e) { "시스템 내부 오류" }
            throw InternalAuthenticationServiceException("시스템 내부 오류", e)
        }
    }
}

 

`loadUserByUsername` 이라는 함수를 오버라이드 하여 그 안에 우리 프로젝트에 맞게 로직을 작성해준다. 여기서 반환한 값의 클래스가 controllere 에서 인자로 받게된 클래스 임을 유의하자!!

+) Basic token 검증대상 controller

@RestController
class AdminController(
    private val adminAuthService: AdminAuthService,
) {
    @PostMapping("/api/admin/token")
    fun generateAdminToken(
        @AuthenticationPrincipal admin: BasicAuth,
    ): ResponseEntity<CommonResponse> {
        val res = adminAuthService.generateAdminToken(authAdmin = admin)
        return ResponseEntity.ok().body(CommonResponse.Success(response = res))
    }
}

 

[3] Security Config : Bearer Token 검증

bearer token 검증을 위해 작성한 Custom Filter 클래스 !!

class JwtAdminTokenFilter(
    private val jwtConfig: JwtConfig,
) : AbstractJwtTokenFilter() {
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain,
    ) {
        try {
            if (adminAccessFilterPath.contains(request.requestURI)) {
                val adminTokenInfo =
                    JwtTokenValidator(
                        encodedToken = this.processBearerToken(request.getHeader(RequestHeaderType.AUTHORIZATION)),
                        authTokenType = AuthTokenType.ADMIN.value,
                        jwtSecretKey = jwtConfig.secretKey,
                    ).verifyAdminToken()
                if (adminTokenInfo.payload.userType != UserType.ADMIN.value) {
                    throw JwtCustomException.of(ErrorCodeType.ACCESS_DENIED)
                }
                this.setMdcLogTokenInfo(
                    tokenType = AuthTokenType.ADMIN.value,
                    tokenJti = adminTokenInfo.jti,
                    userType = adminTokenInfo.payload.userType,
                )
                val authInfo =
                    AuthAdminInfo(adminId = adminTokenInfo.payload.adminId, jti = adminTokenInfo.jti)
                // SecurityContext 에 사용자 설정
                SecurityContextHolder.getContext().authentication =
                    UsernamePasswordAuthenticationToken(authInfo, null, authInfo.authorities)
            }
            filterChain.doFilter(request, response)
        } catch (e: BException) {
            Sentry.captureException(e)
            this.processFilterError(response, e.errorCode, e.message)
            return
        } catch (e: JwtCustomException) {
            Sentry.captureException(e)
            this.processFilterError(response, e.errorCode, e.message)
            return
        } catch (e: Exception) {
            Sentry.captureException(e)
            response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.message ?: "Unauthorized")
            return
        }
    }
}

 

+) 경로로 한번더 체크 ?!

security config 에서 `securityMatchers` 를 통해 경로를 이미 지정했는데 filter 안에서 다시 체크하는 이유는,,

커스텀 필터들은 모두 공통된 JwtConfig 값을 사용해야 하는데 Filter 는 스프링 영역 밖에 위치해 있기 때문에 class body 에서 지연로딩으로 @Value 를 통해 값을 주입할수도 없고, 생성자 주입도 받지 못한다 !!

 

그래서 아래처럼 SecurityFilterConfig 클래스를 만들어 Filter 들을 Bean 으로 등록하고 JwtConfig 을 전달하도록 하였다.

이렇게 Filter 를 Bean 으로 등록하니 경로와 상관없이 전역필터로 동작해버리는것,,, ㅜㅜ

@Configuration
class SecurityFilterConfig(
    private val jwtConfig: JwtConfig,
) {
    @Bean
    fun jwtAdminAccessTokenFilter(): JwtAdminTokenFilter = JwtAdminTokenFilter(jwtConfig = jwtConfig)

    @Bean
    fun jwtUserAccessTokenFilter(): JwtUserAccessTokenFilter = JwtUserAccessTokenFilter(jwtConfig = jwtConfig)

    @Bean
    fun jwtUserRefreshTokenFilter(): JwtUserRefreshTokenFilter = JwtUserRefreshTokenFilter(jwtConfig = jwtConfig)

    @Bean
    fun jwtTripAccessTokenFilter(): JwtTripTokenFilter = JwtTripTokenFilter(jwtConfig = jwtConfig)
}

 

그래서 이중으로 경로를 확인하고 있다!

 

> 일반적인 Spring Security Filter 동작 흐름도

 

> Bean 으로 등록된 후 Spring Security Filter 동작 흐름도

 

+) Bearer Token 파싱하여 검증하기

spring security 에서는 Bearer Token 형식에 대해서 자동으로 검증해주는 기능이 없다. 따라서 문자열을 확인하여 개발자가 직접 검증을 해주어야 한다.

fun processBearerToken(authHeader: String?): String {
    if (authHeader?.startsWith("Bearer ") == true) {
        val token = authHeader.removePrefix("Bearer ").trim()
        if (token.isBlank()) {
            throw BException.of(ErrorCodeType.NOT_EXISTENCE_BEARER_TOKEN)
        }
        return token
    }
    throw BException.of(ErrorCodeType.NOT_EXISTENCE_BEARER_TOKEN)
}

 

 

1. Bearer Token 검증 : SecurityContextHoldere 에 저장

토큰을 검증하여 인증된 사용자의 접근인지 확인한다. 검증이 완료되었다면 이후 Controller 에서 인자로 받을 클래스와 동일한 클래스로 SecurityContextHolder 의 authentication 필드에 저장해준다!

그리고 doFilter 로 이후 프로세스가 진행되도록 한다.

 

2. Bearer Token 검증 : 인증된 사용자의 접근

인증된 사용자라면 이후 요청은 Controller 에 도착한다.

@RestController
class UserAuthController(
    private val authService: AuthService,
) {
    @PostMapping("/api/user/token")
    fun generateNewUserToken(
        @RequestBody requestData: GenerateTokenRequestDto,
        @AuthAdmin authAdmin: AuthAdminInfo,
    ): ResponseEntity<CommonResponse> {
        val res =
            authService.generateNewToken(requestData = requestData, authAdmin = authAdmin)
        return ResponseEntity.ok().body(CommonResponse.Success(response = res))
    }
}

 

3. Bearer Token 검증 : Annotation 활용

controller 에 보면 AuthAdminInfo 타입 파라미터에 대해 어노테이션이 걸려있다. 해당 어노테이션의 Resolver 는 아래와 같다.

@Component
class AuthAdminArgumentResolver(
    private val adminRepository: AdminRepository,
    private val jwtTokenInfoService: JwtTokenInfoService,
) : HandlerMethodArgumentResolver {
    override fun supportsParameter(parameter: MethodParameter): Boolean =
        parameter.hasParameterAnnotation(AuthAdmin::class.java) && parameter.parameterType == AuthAdminInfo::class.java

    override fun resolveArgument(
        parameter: MethodParameter,
        mavContainer: ModelAndViewContainer?,
        webRequest: NativeWebRequest,
        binderFactory: WebDataBinderFactory?,
    ): Any? {
        val authentication = SecurityContextHolder.getContext().authentication
        val principal = authentication?.principal ?: throw BException.of(ErrorCodeType.NON_AUTHENTICATION)
        if (principal !is AuthAdminInfo) {
            throw BException.of(ErrorCodeType.PRINCIPLE_TYPE_ERROR)
        }
        // 관리자 토큰의 상태 확인
        jwtTokenInfoService.validateTokenJti(
            userId = principal.adminId,
            userType = JwtConst.UserType.ADMIN,
            jti = principal.jti,
        )
        // 관리자 존재 확인
        adminRepository.findByIdOrNull(id = principal.adminId)
            ?: throw BException.of(ErrorCodeType.NOT_EXISTENCE_ADMIN)
        return principal
    }
}

 

아까 CustomFilter 에서 SecurityContextHodler 의 authentiation 필드에 저장해둔 AuthAdminInfo 인스턴스값을 꺼내서 다시 확인해본다! 최종적으로 리턴할 prinicipal 에서는 이후 Service 단에서 필요한 값들이 들어있다.

AuthAdminInfo(adminId = adminTokenInfo.payload.adminId, jti = adminTokenInfo.jti)

 

이처럼, Spring Security 를 활용하여 Basic token 과 Bearer token을 검증하는 클래스를 구현하고, 최종적으로 인증된 사용자에 대한 요청이 Controller 로 전달되는것 까지 확인할 수 있었다.

 

 

 

다음번에는 여러 유형의 사용자에 대해서 다양한 커스텀 Filter class 를 구현하고 이를 활용하는 결과물에 대해서 공유해 보도록 하겠다.

반응형
반응형
이전글 : [Kotlin / Spring] Spring Security 도입기 [1] : 의존성 설치하기

 

[1] CustomFilter 구현

Custom Filter를 구현하고자 하는 이유는 다음과 같다.

1. Basic 이 아닌 JWT 기반 Bearer 토큰을 사용할 것이다.

2. token에 대한 검증 이후 서비스이용 가능 여부를 검증하기 위한 밑작업을 할 것이다.

 

이를 위해서 한개 호출당 1회 실행되는 설정으로 OncePerRequestFilter() 를 상속받아 Filter class 를 작성하였다.

package project.config.filter

import project.model.AuthUserInfo
import project.model.JwtToken
import jakarta.servlet.FilterChain
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.web.filter.OncePerRequestFilter

class CustomFilter : OncePerRequestFilter() {
    override fun doFilterInternal(
        request: HttpServletRequest,
        response: HttpServletResponse,
        filterChain: FilterChain,
    ) {
        try {
            val authHeader = request.getHeader("Authorization")
            if (authHeader?.startsWith("Bearer ") == true) {
                val token = authHeader.removePrefix("Bearer ").trim()
                val jwtToken = JwtToken(accessToken = token)
                val tokenPayload = jwtToken.verifyAccessToken()
                val authUser = AuthUserInfo.of(userId = tokenPayload["userId"], userType = tokenPayload["userType"])
                val auth = UsernamePasswordAuthenticationToken(authUser, null, authUser.authorities)
                SecurityContextHolder.getContext().authentication = auth
                filterChain.doFilter(request, response)
            } else {
                throw Exception("wrong authorization")
            }
        } catch (e: Exception) {
            throw Exception(e)
        }
    }
}

 

1. 비즈니스 로직

1) Authorization 형식 확인

`token`

먼저 header 의 Authorization 에 담긴 값이 Bearer 로 시작하는지 검증한다. 안타깝게도 spring security 에서는 `Bearer ` 에 대한 내용을 자동으로 검증해주는 함수가 없어서 문자열로 직접 검증 해야 한다고 한다.

 

2) accessToken 검증

`jwtToken` & `tokenPayload`

이후로 accessToken 에 대한 검증을 진행한다. 이때 내가 만든 함수를 호출하여 검증하도록 한다.

해당 함수에는

- 만료된 토큰 여부

- 토큰의 payload 에 필수정보가 포함되어 있는지

등등에 대해서 검증하고 있다.

 

3) 인증된 object 생성

`authUser`

data class로 `AuthUserInfo` 를 생성하여 해당 클래스에 인증된 유저의 정보를 담아 객체를 생성한다.

 

4) SecurityContext 에 사용자 설정

`auth`

authUser 로 Authentication 객체를 만들고 이를 SecurityContextHolder에 authentication 값으로 갱신한다.

 

5) filter 통과

해당 필터 로직을 종료한다. (다음 필터가 있다면 그것진행. 없으면 이후 프로세스 진행됨)

 

2. Filter 에서의 Exception 처리

먼저 spring 으로 들어온 요청에 대한 영역을 구분하면 아래와 같다.

그림에서처럼 Filter 는 스프링 영역 밖에 존재한다. 이 점으로 인해서 Filter 에서 발생된 Exception 은 Interceptor나 Controller, Service 등과 달리 @ControllerAdvice 에 도달하지 않고 Servlet 수준에서 처리 된다.

 

위의 코드처럼 exception을 처리하면 Web Application 에 정의된 폼 대로 응답이 출력된다.

 

statusCode는 500이다.

{
    "timestamp": "2025-05-08T06:20:20.443+00:00",
    "status": 500,
    "error": "Internal Server Error",
    "path": "/api/auth/token/verify"
}

 

만약, 이미 가공한 형태의 에러응답이 있고 이를 사용하고 싶다면 직접 문자열로 작성하면 된다. 로깅도 잊지말자!

 

private fun processError(
    response: HttpServletResponse,
    errorCode: String,
    message: String,
    exceptionMessage: String,
) {
    response.status = 400
    response.contentType = "application/json"
    response.characterEncoding = "UTF-8"
    val body =
        """
        {
            "success": false,
            "response": null,
            "message":  "$message",
            "error": {
                        "code": "$errorCode",
                        "message": "$exceptionMessage"
                    },
        }
        """.trimIndent()
    response.writer.write(body)
}

해당 함수를 Filter class 안에 작성하고 에러발생시 해당 함수를 호출하도록 한다. 

catch (e: JwtCustomException) {
        this.processError(response, "EXPIRED_TOKEN", "만료된 토큰" e.message)
        return
}

 

그럼 응답이 아래와 같이 나오며 statusCode는 400으로 출력된다.

{
    "success": false,
    "response": null,
    "message": "만료된 토큰",
    "error": {
        "code": "EXPIRED_TOKEN",
        "message": "JWT expired 72022520 milliseconds ago at 2025-05-07T10:28:21.000Z. Current time: 2025-05-08T06:28:43.520Z. Allowed clock skew: 0 milliseconds."
    },
}

 


다음으로는 이렇게 만든 CustomFilter 를 사용해 SecurityConfig 를 작성하고 인증시킬 조건에 대해 설정해보자

반응형
반응형
language : kotlin "2.1.20"
framework : spring boot "3.4.4"
Build tool : Gradle "8.13"

[Spring Security]

Spring Security는 Spring Framework 기반 애플리케이션에 보안 기능을 추가하기 위한 프레임워크주로 인증(Authentication)인가(Authorization) 기능을 제공하며, 세션 관리, CSRF 보호, 암호화, HTTP 요청 보안 설정 등 다양한 보안 관련 기능을 제공한다.

 

- 인증 (Authentication)

> 접근하는 자가 "인증" 된 사용자인가?

사용자가 누구인지 확인하는 과정이다. 인증되지 않는 사용자의 접근을 제한한다.

 

- 인가 (Authorization)

> 접근한 자가 "인가" 된 사용자인가?

접근한 사용자가 해당 기능에 권한이 있는지 확인하는 과정이다. 인증된 사용자이나 인가되지않은 사용자일 경우 접근이 제한된다.

 


[적용하기]

1. 의존성 설치하기 (Gradle)

https://docs.spring.io/spring-security/reference/getting-spring-security.html#getting-gradle-boot

 

Getting Spring Security :: Spring Security

As most open source projects, Spring Security deploys its dependencies as Maven artifacts, which makes them compatible with both Maven and Gradle. The following sections demonstrate how to integrate Spring Security with these build tools, with examples for

docs.spring.io

위 공식문서를 참고한다. 

dependencies {
	implementation "org.springframework.boot:spring-boot-starter-security"
    	testImplementation("org.springframework.security:spring-security-test")
}

 

이상태에서 application 을 실행하면 콘솔에 아래와 같은 문구가 나온다.

[2025-05-02 16:27:18.174] [] 23605 [main] [WARN ] UserDetailsServiceAutoConfiguration [getOrDeducePassword:90] 

Using generated security password: 97a0****-****-****-****-************

This generated password is for development use only. Your security configuration must be updated before running your application in production.
   
[2025-05-02 16:27:18.391] [] 23605 [main] [INFO ] MyProjectApplicationKt [logStarted:59] Started MyProjectApplicationKt in 6.119 seconds (process running for 6.422)

 

security password 를 따로 설정하지 않아 임의로 생성이 되었고, 이것은 개발용으로만 사용해야한다는 경고성 문구가 확인되며 운영환경에서는 UserDetailsService 또는 SecurityFilterChain으로 명시적 설정이 필요함을 알려준다.

 

2. Basic Auth 확인해보기

spring security 가 적용된 상태에서 아무런 인증값 없이 호출을 하면 401 에러가 발생한다.

이때, 콘솔에 찍힌 security password 를 가지고 Basic Auth 설정 후 통신을 하면 정상적으로 호출이 된다.

 

위 과정을 통해 spring security 에서는 기본적으로 Basic Auth를 제공하는것을 알 수 있다.

하지만, 프로젝트에서는 Bearer token을 사용할것이기 때문에 커스텀이 필요하다.

 

다음 장에서 Filter 를 이용해 Custom 인증 필터를 만드는 것을 진행해 보도록 하겠다.

반응형
반응형

배경

application.yml 에 작성한 설정값을 `@Value` 어노테이션으로 클래스의 필드에 주입시켜 값을 설정하도록 하였다.

그리고 application 을 실행시켜서 ApplicationRunner의 run 메소드에 따라 값이 출력되는것을 확인하였다.

package io.project

import org.springframework.stereotype.Component
import org.springframework.beans.factory.annotation.Value
import org.springframework.boot.ApplicationArguments
import org.springframework.boot.ApplicationRunner


@Component
class BValidator : ApplicationRunner {

    @Value("\${spring.aaa}")
    private lateinit var someValue: String

    override fun run(args: ApplicationArguments) {
        println("check some value: $someValue")
    }


    fun doValidate(accessToken: String) {
        println(someValue)
    }
}

 

> application 실행 시

[2025-04-25 14:28:53.341] [] 5372 [main] [INFO ] ProjectApplicationKt [logStarted:59] Started GbikeAuthServerApplicationKt in 5.616 seconds (process running for 5.88)   
check some value: hello@123

 

하지만, 그 값을 사용하려는 함수 `doValidate` 를 호출하니 값이 설정되지 않는 오류가 발생하였다.

 

someValue = null

 

원인

원인은, BValidator 클래스를 AService에서 새로운 인스턴스로 생성하였기 때문에 있었기 때문에 Spring 컨테이너 밖의 객체가 되어서 였다.

Spring 컨테이너는 빈을 다음과 같은 순서로 처리한다.

  • 빈 인스턴스 생성
  • 의존성 주입 (@Value 포함)
  • 초기화 메서드 실행

하지만 AService에서 `BValidator()` 를 호출함에 따라 직접 인스턴스를 생성하여 Spring 컨테이너의 관리 밖에 놓이게 되었고, Spring은 이런 인스턴스에 대해 `@Value` 어노테이션을 처리하지 않아 값이 지정되지 않은 것이다.

 

해결

BValidator 인스턴스를 Spring 컨테이너 관리 빈으로써 처리하도록 한다. 그러기 위해서 AService의 생성자에 BValidator를 주입받아 Spring Container에 등록시키고 등록된 인스턴스로 함수를 호출하게 수정하였다.

 

반응형

+ Recent posts