본문으로 바로가기

Spring Boot에서 CPU를 과하게 사용한 이유와 해결 방법

4분 읽기

1. 들어가며

운영 중인 Java 서비스 중 일부의 CPU 사용량이 동일 클러스터 내 다른 서비스 대비 상대적으로 높은 현상이 있었다. 평상시에는 정상적으로 동작했지만, 스파이크성 트래픽이 발생하면 문제가 드러났다.

어떤 요인이 CPU를 많이 소모하는지 파악하는 것이 선행되어야 했다.

이 글에서는 다음 내용을 정리한다.

  • CPU 사용량 문제를 트래픽과 분리하여 접근한 과정
  • JFR(Java Flight Recorder)로 병목을 특정한 방법
  • jasypt-spring-boot와 Spring Session의 조합이 CPU 문제로 이어진 원인
  • ClassLoader의 캐싱 동작이 이 문제에서 어떤 역할을 하는가

2. 원인 분석

2.1 트래픽과 CPU의 상관관계

AWS API Gateway의 요청량과 pod CPU 사용량을 시간대별로 비교했다. 트래픽이 거의 없는 시간대에도 해당 서비스들의 CPU가 다른 서비스 대비 높게 유지되고 있었다.

이것이 핵심 단서였다. CPU 사용량이 트래픽에 비례한다면 서비스의 처리 특성에서 비롯된 문제로 볼 수 있다. 하지만 트래픽이 거의 없는 시간대에도 CPU가 높다면, 트래픽과 무관한 별도의 원인이 있다는 의미다.

2.2 JFR로 병목 특정

트래픽이 적은 시간대를 선택하여, CPU가 높은 서비스와 낮은 서비스 각각에서 JFR을 수집했다.

두 서비스의 JFR 결과를 비교하자 차이가 명확했다. CPU가 높은 서비스에서는 char[] 할당이 60초 동안 약 3GB에 달했다. call tree를 확인한 결과 다음 경로가 확인되었다.

Spring Session → RedisIndexedSessionRepository
  → 세션 만료 이벤트 핸들링
    → jasypt-spring-boot: RefreshScopeRefreshedEventListener.isAssignable()

세션 만료 시 이벤트가 발행되는 것 자체는 자연스럽다. 하지만 그 이벤트 처리 경로에서 Jasypt의 refresh 관련 리스너가 호출되는 것은 예상 밖이었다. 특히 RefreshScope는 Spring Cloud와 관련된 기능인데, 해당 서비스는 Spring Cloud를 사용하지 않고 있었다.

3. 코드 분석

3.1 왜 리스너가 등록되는가

jasypt-spring-boot 3.0.3은 Spring Cloud의 refresh 이벤트를 감지하여 암호화된 property source 캐시를 갱신하는 RefreshScopeRefreshedEventListener를, Spring Cloud 사용 유무와 관계없이 자동 등록한다.

이는 3.0.3에서 발생한 회귀(regression)였다. 3.0.2까지는 CachingConfiguration에 다음 조건이 있었다.

@ConditionalOnClass(name = REFRESHED_EVENT_CLASS)
public RefreshScopeRefreshedEventListener refreshScopeRefreshedEventListener(...) { ... }

이 어노테이션 덕분에 Spring Cloud 클래스가 classpath에 없으면 리스너 자체가 등록되지 않았다. 하지만 3.0.3에서 이 조건이 제거되면서, Spring Cloud를 사용하지 않는 환경에서도 리스너가 무조건 등록되게 되었다.

3.2 이벤트를 처리하는 방식

문제는 리스너가 등록되는 것에서 그치지 않는다. 이 리스너의 이벤트 필터링 방식을 살펴봐야 한다.

public class RefreshScopeRefreshedEventListener implements ApplicationListener<ApplicationEvent> {

    public static final String REFRESHED_EVENT_CLASS =
        "org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEvent";

    @Override
    public void onApplicationEvent(ApplicationEvent event) {
        if (isAssignable(event)) {
            // property source 캐시 갱신
        }
    }

    private boolean isAssignable(ApplicationEvent event) {
        try {
            Class<?> refreshEventClass = ClassUtils.forName(
                REFRESHED_EVENT_CLASS,
                this.getClass().getClassLoader()
            );
            return refreshEventClass.isAssignableFrom(event.getClass());
        } catch (ClassNotFoundException e) {
            return false;
        }
    }
}

ApplicationListener<ApplicationEvent>를 구현하기 때문에 모든 ApplicationEvent를 수신한다. 그리고 매 이벤트마다 ClassUtils.forName()으로 Spring Cloud의 RefreshScopeRefreshedEvent 클래스를 로드하여 처리 대상인지 판별한다.

3.3 ClassUtils.forName()의 캐싱 동작

ClassUtils.forName()은 내부적으로 Java ClassLoader를 사용한다. ClassLoader에는 중요한 특성이 있다.

성공적으로 로드된 클래스는 캐싱된다. 한 번 로드한 클래스는 ClassLoader 내부에 보관되어 이후 요청은 즉시 반환된다.

반면 로드에 실패한 클래스는 캐싱되지 않는다. ClassNotFoundException이 발생한 경우, ClassLoader는 그 사실을 기억하지 않는다. 다음에 동일한 클래스명으로 요청이 오면 처음부터 다시 classpath를 탐색한다. 이는 의도된 설계다. 실패한 로드 결과를 캐싱하면, 공격자가 존재하지 않는 클래스명을 대량으로 요청하여 캐시를 오염시키는 DoS 공격에 취약해질 수 있기 때문이다.

여기에 더해 클래스 로딩은 클래스명별로 동기화된다. 동일한 클래스명에 대해 한 번에 하나의 스레드만 로드를 시도할 수 있다.

이 두 특성을 조합하면 다음과 같은 차이가 생긴다.

환경ClassUtils.forName() 동작
Spring Cloud 있음첫 호출에 클래스 로드 성공 → ClassLoader 캐시에 저장 → 이후 호출은 캐시에서 즉시 반환
Spring Cloud 없음매 호출마다 classpath 전체 탐색 → ClassNotFoundException → 캐시에 저장되지 않음 → 다음 호출에도 동일 반복

즉, Spring Cloud를 사용하지 않아서 안전한 것이 아니라, 사용하지 않기 때문에 오히려 매 이벤트마다 slow path를 타게 된다.

3.4 Redis Session이 문제를 증폭시킨 이유

이 리스너가 단순히 등록만 되어 있었다면 비용이 크지 않았을 수 있다. Spring의 ApplicationEvent는 애플리케이션 라이프사이클 중 드물게 발행되기 때문이다.

하지만 Spring Session + Redis 조합을 사용하면 이야기가 달라진다. RedisIndexedSessionRepository는 세션 생성, 만료, 삭제 시마다 ApplicationEvent를 발행한다. 활성 사용자가 있는 서비스에서는 이 이벤트가 끊임없이 발생한다.

사용자 세션 만료
→ RedisIndexedSessionRepository → SessionDeletedEvent 발행
→ RefreshScopeRefreshedEventListener.onApplicationEvent() 호출
→ ClassUtils.forName("...RefreshScopeRefreshedEvent", ...) 시도
→ ClassNotFoundException (Spring Cloud 없음)
→ 캐시 없음, 다음 세션 이벤트에도 동일 반복

이 구조에서 세션 이벤트는 ClassNotFoundException 비용을 지속적으로 발생시키는 트리거가 된다. 트래픽이 적은 시간대에도 CPU가 높게 유지되었던 이유가 여기 있다. 세션은 트래픽이 없어도 자연적으로 만료되기 때문이다.

4. 마무리

"jasypt-spring-boot 3.0.3의 전역 이벤트 리스너가, Spring Cloud 없이 매 이벤트마다 ClassNotFoundException을 발생시키며 CPU를 소모하고 있었다."

이 결론에 도달하기까지 시간대별 트래픽-CPU 상관관계 분석, JFR call tree 비교, 라이브러리 소스코드 추적 등 여러 단계의 분석이 필요했다.

인상적이었던 점은 두 가지다. 첫째, ClassLoader의 실패 캐싱 미지원이라는 동작은 보안 설계상 의도된 것이지만, 이를 모르고 있으면 이런 종류의 성능 문제가 왜 발생하는지 이해하기 어렵다. 둘째, Spring Cloud를 사용하지 않는 것이 오히려 더 느린 경로를 타게 만들었다는 구조가 직관적이지 않다. 라이브러리를 도입할 때 해당 라이브러리가 어떤 전제 위에서 동작하는지, 현재 환경과 어떻게 맞물리는지를 살펴볼 필요가 있다.

이 문제는 jasypt-spring-boot GitHub의 issue #250으로 등록되어 있었고, 3.0.4에서 @ConditionalOnClass를 복원하는 방식으로 수정되었다.