레거시 시스템을 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배 악화)
동시성 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)은 현재 데이터량이 아니라 데이터 증가 추이를 놓고 해야 한다.
그리고 이 모든 판단은 벤치마크 코드 하나면 감이 아닌 숫자로 검증할 수 있다. 반나절 투자로 아키텍처 결정의 근거가 생겼다.
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))
}
}
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 단에서 필요한 값들이 들어있다.
먼저 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 에 정의된 폼 대로 응답이 출력된다.
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)
> 접근한 자가 "인가" 된 사용자인가?
접근한 사용자가 해당 기능에 권한이 있는지 확인하는 과정이다. 인증된 사용자이나 인가되지않은 사용자일 경우 접근이 제한된다.
[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 인증 필터를 만드는 것을 진행해 보도록 하겠다.