인증 서버 응답 속도 최적화

인증 서버 응답 속도 최적화

들어가며

이번 글에서는 Airbridge 인증 서버에서 간헐적으로 응답 속도가 느려지는 현상을 분석하고 개선한 경험을 공유합니다. 문제 해결 과정에서 구조 개선을 함께 진행하여 간헐적인 응답 지연 문제를 해결했을 뿐만 아니라 트래픽 급증 상황에도 안정적으로 작동하는 인증 시스템을 구축할 수 있었습니다.
Airbridge의 모든 API는 요청 처리 전 토큰 검증과 권한 확인 등 인증 처리를 먼저 수행합니다. 서비스 도메인별로 API 서버가 분리되어 있는데, 복잡한 인증/인가 처리 로직을 간단하게 가져다 쓸 수 있도록 인증/인가 API를 제공하는 인증 서버를 플랫폼 팀에서 개발하여 운영하고 있습니다.
많은 서버들이 인증/인가 API를 활용하고 있다보니 인증 서버의 응답이 느려지면 대부분의 서비스들의 응답 속도와 안정성에 영향을 미칩니다. 그렇기 때문에 인증 서버의 성능과 안정성은 매우 중요합니다.

문제 인식 및 원인 분석

인증 서버의 성능이 전반적인 서비스의 안정성에 영향을 주기 때문에 응답 속도를 모니터링하고 문제가 있을시 알림을 받도록 해뒀습니다. 응답 속도가 1초를 넘을 경우 알림을 받도록 했는데, 서비스 운영 과정에서 응답 지연에 의한 알림이 점점 더 자주 오기 시작했습니다.
간헐적으로 인증 서버의 응답 시간 지표가 크게 튀는 현상
점점 더 알림 빈도가 잦아지면서 이 문제에 대한 우선순위를 높여 원인 분석 및 최적화를 하기로 결정했습니다.
먼저, 모니터링 데이터를 확인해봤을 때 인증 서버에서 사용하고 있는 캐시, DB, EKS 노드와 같은 인프라와 JVM 등에서는 이상 지표가 확인되지 않았습니다.
그래서 인증 서버 내부를 더 깊게 분석한 결과 여러 문제가 있는 것을 알게 됐습니다.
1.
과다한 DB query를 유발하는 아키텍처
권한 확인 과정에서 요청의 복잡도에 따라 DB query가 100회 이상 실행되곤 했습니다. 이렇게 DB query가 과다하게 실행될 때는 DB connection pool이 빠르게 소진되며 응답 지연을 유발했습니다.
2.
HikariCP Connection 포화
New Relic과 CloudWatch 로그 분석 결과, DB query가 과다하게 실행되는 시점에 HikariCP connection pool이 포화되며 스레드가 30초 이상 응답 대기 상태에 빠지는 현상을 확인했습니다.
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30000ms.
Plain Text
복사
3.
낮은 캐시 효율
캐싱을 30초 정도로 짧게 할 뿐만 아니라 캐싱이 잘 되기 어려운 방식으로 캐싱하고 있어서 결국 과다한 DB query 현상이 재발하기 쉬웠습니다.

개선 계획 수립

기존 구조의 가장 큰 문제는 권한 확인 과정에서 여러 권한들을 하나하나씩 확인하느라 과다하게 DB query를 하게 된다는 것이었습니다. 그러다보니 DB connection pool도 빠르게 소진되기 쉬웠습니다.
성능을 개선하기 위해서는 DB query를 최소화해야 했습니다. 또한, 캐싱 방식을 개선하면 더욱 DB query를 줄일 수 있을거라 생각했습니다. 캐싱 방식을 개선하는 것 외에도 DB 접근에 의한 성능 저하를 줄이기 위해 HikariCP의 connection pool 옵션을 튜닝하는게 좋아 보였습니다.

개선 작업

권한 확인과 캐싱 방식 변경

먼저, 여러 권한을 확인할 때 확인하려는 권한마다 DB query를 하지 않아도 되도록 DB에서는 한 번에 일괄 조회(findAllBy~)하도록 변경했습니다.
AS-IS: 매 연산마다 DB hit TO-BE: groupBy → 1회 조회
그리고 DB에서 불러온 데이터는 애플리케이션의 메모리에 mutableMap으로 캐싱하고, 캐시된 데이터로 권한을 확인하도록 변경했습니다.
AS-IS: 모든 매핑에 대해 DSL 파싱 로직 수행 TO-BE: mutableMap 기반의 인메모리 캐시를 적용하여 최적화
또한, 과도한 책임을 지니고 있던 findPermission() 메서드의 기존 구조를 단일 책임 원칙에 따라 분리하여 각 하위 로직이 독립적으로 캐싱 전략을 선택할 수 있도록 개선했습니다.
결과적으로 DB query 횟수가 최대 134회에서 4회(-97%)로 대폭 감소하였고, 세밀하게 캐싱을 적용할 수 있는 기반도 마련됐습니다.

캐싱 정책 개선 및 구현

기존에는 Redis 단일 계층에서 모든 캐시 대상에 동일한 TTL을 적용하고 있었습니다. 그로 인해 Redis I/O 부하 및 네트워크 지연, 캐시 효율 저하 문제가 발생했습니다.
저희는 Local Cache(Caffeine)와 Remote Cache(Redis)를 결합한 2-Layer Cache 구조를 도입하여 속도와 정합성의 균형을 맞추는 개선 작업을 하기로 결정했습니다.
L1: Local Cache (Caffeine) → 속도 향상 L2: Remote Cache (Redis) → 정합성 보장 Hybrid Layering 적용으로 두 계층의 장점 결합
캐시 전략은 대상별로 L1-Only / L2-Only / Hybrid 중 하나로 분류할 수 있으며, 대상의 특성에 따라 캐싱 전략을 쉽게 설정할 수 있게 하여 운영 효율성을 높이기로 했습니다.
캐싱 전략을 결정하는 기준은 아래와 같습니다. 각 캐시의 Cardinality, TTL, 접근 빈도, 메모리 사용량 등을 기준으로 캐싱 전략을 분기했습니다.
성능이 중요한 대상은 Hybrid 전략
성능이 중요하지만 데이터 수가 적고 갱신 빈도가 낮은 대상은 L1 Only 전략
정합성이 중요한 대상은 L2 Only 전략

코드 구현

캐싱 정책은 CacheType Enum에 size, TTL, 전략을 선언적으로 정의하고, 이를 기반으로 CompositeCacheManager에 자동 등록되도록 구성했습니다.
CacheType에 선언된 전략(L1_ONLY / L2_ONLY / HYBRID)을 기반으로, Caffeine과 Redis를 자동으로 분기하여 CacheManager에 등록하는 구조를 구현했습니다.
각 캐시 타입은 메모리 사용량, TTL, 접근 빈도 등을 고려해 선언적으로 설정되며, 이를 기반으로 layeredCacheManager는 전략에 따라 다음과 같이 동작합니다:
L1_ONLY → Caffeine Cache만 사용
L2_ONLY → Redis Cache만 사용
HYBRID → Caffeine + Redis 병합 캐시(L1L2Cache) 사용
L1L2Cache 클래스는 Look-Aside 기반의 Read-Through 패턴과 Write-Through 패턴을 결합하여 작동하는 저희가 직접 구현한 구현체입니다. Spring의 @Cacheable 어노테이션만으로는 L1-L2 계층 간 연동이나 동시 저장 로직을 제어하기 어려워, 직접 L1L2Cache 클래스를 구현해 두 계층을 통합 관리하는 구조로 설계했습니다.
말이 어렵지, 실제 코드는 간단합니다.
사용하는 코드 예시는 아래와 같습니다.

Eviction 정책

L1 Cache는 최대 개수를 제한하여 크기 기반(LRU)의 Eviction을 적용했고, TTL로 데이터 신선도를 관리합니다.
L2 Cache는 상대적으로 더 긴 TTL을 적용하여 L1 Cache 만료 이후에도 데이터 재사용이 가능하도록 구성했습니다.

Replacement 정책 (Cardinality & Memory)

운영 환경에서 캐시를 안정적으로 유지하기 위해, Redis의 key cardinality 및 객체별 메모리 사용량을 기반으로 전체 메모리 요구량을 사전에 산출했습니다.
약 2.0GB의 JVM 힙을 사용하는 환경에서, 총 50만 개의 캐시 키(평균 400 byte/key)가 예상되어 약 190.73 MB의 메모리가 필요했습니다. 운영 중 발생할 수 있는 메모리 파편화 및 TTL 오버랩을 고려해 최소 2배의 버퍼(약 400MB 이상)를 확보하는 전략을 세웠고, 이는 전체 JVM 힙의 약 24%에 해당하는 수준으로 안전한 범위 내에서 충분히 운영 가능하다고 판단했습니다.
Caffeine은 기본적으로 LRU (Least Recently Used) 정책을 사용하여 maximumSize를 초과할 경우 가장 오랫동안 사용되지 않은 항목부터 제거합니다. 이는 캐시 히트율을 높이고 메모리 효율성을 유지하는 데 효과적입니다.

Redis Pub/Sub을 이용한 캐시 무효화(Invalidation)

TTL 기반 캐시 만료는 방식이 간단하다는 장점이 있지만 데이터 변경이 발생했을 때 즉시 캐시를 무효화하기 어렵다는 한계가 있습니다. 특히, 권한 정보처럼 정합성이 중요한 데이터는 짧은 TTL로도 충분하지 않습니다. TTL을 짧게 설정하더라도 권한 변경 후 즉시 페이지를 이동하면 정합성이 맞지 않는 문제 상황이 발생하기 때문입니다. 게다가, TTL을 짧게 설정하면 캐싱을 하는 효용이 적어지기도 합니다.
처음에는 짧은 TTL로 데이터 신선도를 확보하려고 했지만, TTL을 짧게 설정했더니 Redis를 너무 빈번하게 접근하는 문제가 발생했습니다. 따라서, TTL을 길게 가져가면서도 정합성을 보장할 수 있도록 캐시 무효화도 개선을 하기로 했습니다.
권한 정보와 같은 데이터는 여러 서버마다 메모리에 갖고 있는 Local Cache에 저장될 수 있습니다. 그리고 한 서버에서 데이터가 변경되면 다른 모든 서버의 캐시도 동시에 무효화되어야 정합성이 보장됩니다.
특정 서버의 데이터 변경을 다른 서버들도 전파시키기 위해 Redis Pub/Sub(Publish/Subscribe) 패턴을 활용하기로 했습니다. 동작 방식은 아래와 같습니다.
1.
특정 권한이 DB에서 업데이트됩니다.
2.
변경을 감지한 서버(위 다이어그램에서 Dashboad API 인스턴스)는 Redis의 채널(예: evict-cache)에 무효화할 캐시 키(cacheName::cacheKey)를 메시지로 발행합니다.
3.
Redis는 해당 채널을 구독 중인 모든 클라이언트(다른 서버들)에게 전파합니다.
4.
메시지를 수신한 각 서버(위 다이어그램에서 Auth API 인스턴스)는 해당 메시지에 포함된 캐시 키를 바탕으로 자신의 Local Cache (L1)에서 해당 항목을 삭제(Evict)합니다.
이 방식은 애플리케이션 코드 내에서 명시적으로 publish를 호출해야한다는 단점이 있지만, 권한 변경 로직이 여러 군데 산재되어 있는 상태에서 가장 빠르고 쉽게 적용할 수 있는 방법이었습니다.
이러한 구조를 통해 캐시 무효화 이후에도 사용자는 일관된 응답을 받을 수 있고, 시스템은 자동으로 최신 데이터를 복구할 수 있도록 했습니다.

HikariCP 옵션 튜닝

JVM 스레드 수를 관측한 결과, 이 중 대부분이 Tomcat Worker 또는 Hikari 커넥션 풀이 사용 중인 것으로 확인되었습니다(약 69~71개 스레드가 활성). 동시에 DB query가 몰릴 때, 기본 HikariCP 커넥션 풀 크기(10개)로 인해 요청이 대기 상태에 빠지면서 RPS가 급격히 저하되는 현상을 확인했습니다. Tomcat Worker 수에 비해 DB 커넥션 수가 부족해 blocking이 발생한 것으로 판단했고, maximum-pool-size를 30으로 확장하여 병렬 처리 여유를 확보하기로 했습니다.
Spring Boot 환경에서는 HikariCP 설정을 아래와 같이 조정했습니다. 기본 커넥션 풀 크기인 10개에서 30개로 확장하고 커넥션의 생명주기와 유휴 전략까지 고려하여, DB I/O 경합을 방지하고 RPS를 확보할 수 있도록 했습니다.
spring: datasource: hikari: maximum-pool-size: 30 # 최대 커넥션 수 (default 10) minimum-idle: 10 # 최소 유휴 커넥션 수 (default 10) idle-timeout: 300000 # 커넥션 유휴 상태 유지 시간 (5분, default 10분) max-lifetime: 1800000 # 커넥션 최대 수명 (30분, default값 유지) — DB 커넥션 max age보다 짧게 유지 connection-timeout: 5000 # 커넥션 풀에서 가져올 때 최대 대기 시간 (5초, default 30초) — 서버 부하 높거나 pool 가득 찼을 때 timeout 에러 발생 가능
YAML
복사

성능 개선 결과 확인

변인 통제 & 테스트 환경 세팅

1.
Redis와 Application을 동일한 가용 영역(Availability Zone)에 위치시켰습니다.
2.
테스트 시 사용한 payload는 Slow reponse 혹은 장애를 일으킨 요청의 payload로 고정했습니다.
3.
로컬에서 성능 개선 효과를 확인하기 위한 가시성을 확보해뒀습니다.

Locust 고부하 테스트

최대 13,000 유저, 200 RPS 테스트 시나리오에서 측정했으며, Warm-up → Peak → Stress 단계로 구성된 시나리오를 통해 실제 트래픽 병목 구간을 시뮬레이션 하였습니다.
AS-IS: 사용자 수가 증가할수록 응답 시간이 급격히 증가하고, RPS가 제한됨을 확인할 수 있습니다. TO-BE: 요청 처리량이 일정하게 유지되며, 지연 없이 처리됨을 보여줍니다.
주요 지표 변화를 확인해보면 아래와 같습니다.
항목
개선 전
개선 후
Response Time (Avg)
2.89s
1.78s 38%
Response Time (P99)
7.4s
5.3s  28%
처리량 (RPS)
628.3
832.5 32%
DB Call
최대 134회
4  97%
대규모 부하 테스트 결과, 평균 응답 시간은 약 65% 단축(2.89s → 1.78s), 처리량(RPS)은 약 32% 증가(628 → 832 RPS)하여 트래픽 피크 상황에서도 안정적인 처리가 가능해졌습니다.
테스트 당시 발생하던 10% 수준의 실패율도 0%로 줄어들면서, 안정성 측면에서도 유의미한 개선이 이루어졌습니다.

운영 환경 배포 후 모니터링

운영 환경 배포 후, 배포 직후 자주 발생하던 느린 응답 시간 현상이 사라졌으며, 전체 응답 시간도 안정적으로 유지되는 것을 확인할 수 있었습니다.

P99.9를 줄이자! - JVM Warm-Up 적용

배포 직후, 특정 인스턴스에서 1초 이상의 응답 지연이 주기적으로 발생했습니다. 원인 분석 결과 구조적 병목 외에도 JVM의 특성상 JIT(Just-In-Time) 컴파일 및 클래스 로딩이 지연되며 초기 요청에 대해 성능 저하가 발생하는 것으로 추정했습니다.
문제 현상: 배포 직후 응답 시간 Spike
이 문제를 해결하기 위해 Warm-up runner를 도입하기로 했습니다. Spring Boot의 ApplicationReadyEvent를 활용하면 실제 트래픽과 유사한 dummy 요청을 실행하는 Warm-up Runner를 추가할 수 있습니다. 이를 활용하여 K8s pod 교체 및 유저의 트래픽을 받는 시점이 JIT 컴파일 이후에 이루어지도록 초기 응답 지연을 방지했습니다.
개선 전/후의 지표를 비교하면 아래와 같습니다.
개선 전 초기 응답: 1.07s
Warm-up 적용 후: 94ms로 안정화
위 그래프처럼, 배포 직후의 순간적인 응답 지연 현상을 완화했으며, 전체 서비스 안정성이 향상되었습니다.

마치며

이번 인증 서버 성능 최적화 작업을 통해 Airbridge의 전반적인 서비스들의 서비스 신뢰도와 운영 안정성을 높일 수 있었습니다.
아직 Airbridge의 모든 서버에서 인증 서버를 활용하고 있진 않습니다. 하지만 이번 최적화를 통해 안정성을 높여 앞으로 대용량 트래픽을 처리하는 다른 서버들에도 인증 서버 활용을 고려해볼 수 있게 됐습니다. 동료들이 복잡한 인증/인가 처리보다 도메인 서비스에 더 집중하여 생산성을 높일 수 있게 된 것입니다.
또한, 개인적으로도 여러 지표들을 확인하고 원인을 분석하는 과정에서 문제 해결 능력이 많이 성장했다고 느꼈습니다.
긴 글 읽어주셔서 감사합니다. 혹시 궁금한 점이나 피드백이 있다면 언제든지 댓글로 남겨주세요!
ᴡʀɪᴛᴇʀ
Yejun Park @jun02160 Backend Engineer @AB180
유니콘부터 대기업까지 쓰는 제품. 같이 만들어볼래요? 에이비일팔공에서 함께 성장할 다양한 직군의 동료들을 찾고 있어요! → 더 알아보기