Home > AI > Backend > 인증 필터가 매 요청 DB를 네 번 때리고 있었다

인증 필터가 매 요청 DB를 네 번 때리고 있었다
AI Claude Code Spring 성능

문제: TPS가 안 나온다

부하 테스트에서 목표 TPS에 못 미쳤다. 애플리케이션 로직 자체는 무겁지 않았다. 그래서 비즈니스 코드가 아니라 모든 요청이 반드시 지나가는 경로를 먼저 뜯어봤다.

이 프로젝트는 Spring Security + JWT 구성이다. 요청 하나가 컨트롤러에 닿기까지 지나는 필터 체인을 따라가 보니 DB 접근이 겹겹이 쌓여 있었다.

지점 하는 일
JwtFilter 토큰에서 username 꺼내 User 조회
ResourceAuthorizationFilter User를 다시 조회
ResourceAuthorizationFilter 역할별 메뉴 권한 목록 조회 (findByRoleIn)
HttpReqResLoggingFilter 감사 로그 INSERT

리소스 하나 읽는 GET 요청도 인증·인가·로깅만으로 DB를 네 번 건드린다. 그리고 이건 캐시가 없으니 100% 매 요청 발생한다.

어떻게 고쳤나 — 하루에 커밋 다섯 개

2026년 5월 7일 하루 동안 커밋 다섯 개가 순서대로 올라갔다. 순서 자체가 작업 방식을 보여준다. 하나 없애고, 다시 재고, 다음으로 넘어간다.

1. 감사 로그 INSERT를 요청 경로에서 빼냈다

가장 명백한 건 감사 로그였다. 응답을 내보내는 데 전혀 필요 없는 쓰기 작업이 응답 경로 안에 있었다. @Async를 붙여 분리했다.

여기서 예상 못 한 문제가 나왔다. saveAuditLog 안에서 SecurityContext로 현재 사용자를 가져오고 있었는데, SecurityContext는 기본적으로 비동기 스레드에 전파되지 않는다. ThreadLocal 기반이기 때문이다.

전파 설정(DelegatingSecurityContextAsyncTaskExecutor 같은)을 켜는 방법도 있었지만, 여기서는 더 단순한 쪽을 택했다. 필터에서 username을 미리 꺼내 인자로 넘기고, 비동기 쪽은 SecurityContext를 아예 안 쓰게 만들었다.

// SecurityContext 가 불필요한 비동기 조회용
User findByUsernameForAudit(String username);

메서드 하나가 늘었지만 비동기 경계에서 암묵적 컨텍스트 의존이 사라졌다. 로깅 필터가 이미 알고 있는 값을 넘기는 것뿐이라, 전파 설정을 끼워 넣는 것보다 의존 관계가 명확하다.

2. 중복 조회를 request attribute로 제거

JwtFilter가 조회한 User를 ResourceAuthorizationFilter가 똑같이 다시 조회하고 있었다. 두 필터는 같은 요청 스레드에서 순서대로 실행되므로, 앞에서 request attribute에 넣고 뒤에서 꺼내면 된다. 커밋 diff는 6줄 추가·2줄 삭제. 이런 게 제일 싸다.

3. 인메모리 캐시 — JwtFilter와 메뉴 권한

남은 두 건은 값 자체가 요청마다 바뀌지 않는 조회였다. ConcurrentHashMap을 빈으로 올려 캐시로 썼다.

@Bean
public ConcurrentHashMap<String, User> userAuthCache() {
    return new ConcurrentHashMap<>();
}

메뉴 권한(findByRoleIn)도 같은 방식으로 캐싱했다.

4. HikariCP 풀 20 → 50

앞의 네 건으로 요청당 DB 접근을 줄이고 나서, 마지막으로 커넥션 풀을 20에서 50으로 올렸다. 커밋 메시지에 “TPS 100 달성”이 붙어 있다. 한 줄짜리 설정 변경이다.

순서가 중요하다. 풀부터 늘렸다면 불필요한 쿼리 네 개를 그대로 둔 채 커넥션만 더 태우는 꼴이 됐을 것이다. 쿼리를 없앤 다음에 남은 쿼리를 위해 풀을 늘리는 것과 순서가 반대다.

솔직히 써야 할 트레이드오프

ConcurrentHashMap 캐시에는 무효화 전략이 없다. TTL도, 최대 크기도, 변경 시 evict도 없다. 이건 다음을 의미한다.

  • 사용자의 권한이나 역할이 바뀌어도 애플리케이션을 재시작하기 전까지 반영되지 않는다
  • 계정을 비활성화해도 캐시에 남아 있으면 계속 통과한다
  • 사용자 수가 늘면 맵이 계속 커진다 — 이 플랫폼은 사용자 규모가 제한적이라 문제가 안 됐을 뿐이다

지금 상태는 “부하 테스트 목표를 맞추기 위한 최소 조치”에 가깝다. 권한 변경이 즉시 반영돼야 하는 요구가 생기면, Caffeine의 TTL 캐시나 Redis(이미 이 프로젝트가 쓰고 있다)로 옮기고 변경 시 evict를 붙여야 한다. 여기서 ConcurrentHashMap을 고른 이유는 그게 옳아서가 아니라 그 시점에 충분했기 때문이고, 이 구분은 기록해둘 가치가 있다.

곁다리: 로깅 필터가 메모리를 먹고 있었다

같은 시기에 로깅 필터에서 다른 종류의 문제도 정리했다. 응답 바디를 로그에 남기려고 무조건 byte[]로 읽고 있었는데, 이미지나 파일 다운로드 응답도 예외가 아니었다. 큰 응답이 몇 개 겹치면 힙을 그대로 밀어낸다.

크기와 Content-Type을 먼저 확인하고 읽도록 바꿨다. 이미지 타입이거나 50KB를 넘으면 바디를 읽지 않고 조기 반환한다. 로그에서 얻는 정보의 가치가 그 지점부터는 비용을 못 따라가기 때문이다.

남는 교훈

측정 → 병목 하나 제거 → 재측정. 커밋이 시간순으로 다섯 개 남아 있다는 건 다섯 번 나눠서 확인했다는 뜻이다. 한 커밋에 몰아넣었다면 어느 변경이 얼마나 기여했는지 알 수 없었을 것이고, 문제가 생겼을 때 되돌릴 단위도 없었을 것이다.

모든 요청이 지나가는 경로부터 본다. 필터 체인은 요청 수만큼 곱해지는 곳이라, 여기 있는 쿼리 하나는 컨트롤러 안의 쿼리 하나와 무게가 다르다.

설정값 조정은 마지막이다. 풀 크기를 늘리는 건 병목을 없애는 게 아니라 병목을 더 많이 감당하게 만드는 일이다. 먼저 없앨 수 있는 걸 없앤 뒤에 손대는 게 맞다.