티스토리 뷰

배경

기능을 구현한 뒤 로그인 API의 부하 테스트를 진행했습니다. 이 과정에서 Connection time out 에러가 발생하는 문제를 파악했습니다. 이 글은 해당 에러를 해결해 나가는 과정을 정리한 글입니다.

원인 파악

로그인은 OIDC 방식으로 외부 API(카카오)를 호출하여 OIDC 공개키 목록 조회하고 있었습니다. 이 API를 호출하고 응답을 기다리는 과정에서 시간이 지나 에러가 발생하고 있었죠.

API 문서를 살펴보면 빈번한 요청시 차단될 수 있다고 나와있었습니다. 부하 테스트 과정에서 지나치게 많은 API를 요청해서 차단이 되었고 그렇기에 Connection time out이 발생한 것이죠.

캐싱하고 있었는데?

그런데 이상한 점은 저희 서버에서는 해당 API 요청을 캐싱하고 있었다는 것입니다.

 

@Cacheable(cacheNames = [CacheNames.API_RESPONSE], key = "'kakaoPublicKeyResponse'")
fun getProperties(): OidcProperties {
    val jwkSet = kakaoFeignClient.fetchJwks() // 카카오 API 호출
    return OidcProperties(
        keySet = jwkSet,
        issuer = kakaoProperties.url,
        clientId = kakaoProperties.clientId,
    )
}

서버에서는 @Cacheable 를 활용하여 캐싱된 값을 응답하도록 구현되어 있었습니다. 하지만 실제 부하 테스트를 통해 알게 되었듯이 캐싱된 결과를 사용하지 않고 매번 API를 호출하고 있었습니다.

 

왜 캐싱하지 않았을까?

이를 이해하기 위해선 우선 AOP에 대한 이해가 필요합니다.

 

프록시 객체

AOP는 스프링의 핵심 기술로 Aspect Oriented Programing입니다.

AOP 자체에 개념보다는 어떻게 동작하는지에 대해서 좀 더 살펴보겠습니다.

스프링 컨테이너가 띄워질 때 컨테이너는 빈들을 컨테이너에 등록하게 됩니다.

이때 @Aspect 애노테이션이 붙어있는 객체의 빈들도 등록하게 됩니다.

(@Aspect 가 있어야 AOP가 동작하구나 정도로만 이해하시면 됩니다.)

 

@EnableAspectJAutoProxy가 로드되면 BeanPostProcessor가 컨테이너에 등록이 되는데 이 BeanPostProcessor는 SmartInitializingSingleton라는 인터페이스를 구현하고 있어서 모든 빈들이 컨테이너에 등록 된 이후에 BeanFactoryAspectJAdvisorsBuilder를 호출합니다.

 

BeanFactoryAspectJAdvisorsBuilder는 컨테이너에 등록된 모든 @Aspect 빈을 찾아내고 그 안에 @Before , @After , @Around 등의 메서드마다 Pointcut + Advice를 합쳐 Advisor를 만들어냅니다.

이렇게 만들어진 Advisor 리스트들은 AnnotationAwareAspectJAutoProxyCreator 내부에 저장(전달)됩니다.

 

💡Pointcut, Advice, Advisor는 AOP 개념입니다.

간단하게 설명하자면 Pointcut은 “어디에 적용할 수 있는지”, Advice는 “어떤 작업을 할 것인지”, Advisor는 Pointcut + Advice가 합쳐진 것을 의미합니다.

자세한 설명은 다른 블로그나 글을 참고해주세요.

 

 

모든 빈들은 postProcessAfterInitialization 라는 단계에서 앞서 만든 Advisor들을 조회하면서 자신이 적용될 수 있는 Advisor를 필터링하게 됩니다. 그리고 적용할 수 있는 Advisor가 있다면 프록시 래핑을하게 되어 프록시 객체가 되는 것입니다.

(프록시 객체를 만들 때 필터링한 Advisor들도 넣어줍니다)

제 코드에서도 카카오 API를 호출하는 객체(KakaoAuthProvider)에는 @Cacheable 애노테이션이 붙어있습니다.

@Cacheable 도 @Aspect 와 마찬가지로 빈으로 등록되고 BeanPostProcessor에 의해 프록시 작업이 진행되게 됩니다.

이때 @Cacheable 애노테이션을 처리할 수 있는 Advisor(ProxyCachingConfiguration)가 필터링되어 프록시로 래핑됩니다.

즉, 스프링 컨테이너에 등록되는 것은 프록시 객체인 것입니다.

 

프록시 객체가 되면 어떻게 되는거지?

그렇다면 프록시 객체로 감싸지면 어떻게 되는 것일까요?

프록시로 감싸지게 되면 실제 호출 시, CglibAopProxy.intercept 가 호출됩니다.

이는 실제로 디버깅 모드로 확인해볼 수 있습니다.

intercept 메서드에서는 getInterceptorsAndDynamicInterceptionAdvice 를 호출하여 비즈니스 로직을 처리할 수 있는 Advisor를 조회하게 됩니다.

 

? 왜 또 Advisor를 조회하는거지?

위에서 설명했듯이 postProcessAfterInitialization 단계에서 프록시 객체를 만들 때 Advisor를 필터링해서 같이 넣어주었습니다.

실제 프록시 객체 필드를 타고 들어가보면 Advisor가 리스트로 존재합니다.

그런데 왜 또 프록시 객체 내부에서 Advisor를 조회하는 것일까요?

이는 호출된 비즈니스 로직이 어드바이저를 적용할 수 있는지 판단해야하기 때문입니다.

이해가 안될 수 있으니 좀 더 자세히 설명해보겠습니다.

 

제 코드에서 외부 API를 호출하는 객체 KakaoAuthProvider를 살펴보겠습니다.

@Component
class KakaoAuthProvider(
    ...
) : AuthProvider {
  
    override fun authenticate(){
        ...
    }

    override fun supports():  {
        ...
    }

    @Cacheable(cacheNames = [CacheNames.API_RESPONSE], key = "'kakaoPublicKeyResponse'")
    fun getProperties(): OidcProperties {
        // 외부 API 호출 (캐싱 되어야 하는 작업)
    }
}

KakaoAuthProvider의 모든 메서드가 캐싱이 되어야하는 것은 아닙니다.

제가 @Cacheable 를 선언한 메서드(getProperties)만 캐싱 작업이 일어나야하죠.

 

다시 말해서, Advice(부가 기능)에 의해 처리되어야 하는 메서드는 @Cacheable 가 붙은 메서드로 한정됩니다.

다른 메서드들은 적용되면 안됩니다.

 

즉, 비즈니스 로직이 호출될 때 호출된 메서드가 Advisor로 처리할 수 있는 메서드인지 판단하는 작업이 필요합니다.

그렇기에 Advisor 리스트에서 현재 호출된 메서드를 처리할 수 있는 Advisor를 필터링하는 작업이 한 번 더 일어납니다.

더 정확히는 getInterceptorsAndDynamicInterceptionAdvice 라는 메서드를 호출해서 처리할 수 있는 Advisor를 한 번 더 조회하게 됩니다.

이 부분에서 문제의 원인이 될 만한 곳을 확인했습니다.

 

이상하게 내부 로직 호출 시 조회되는 Advisor가 없었습니다.

 

제 KakaoAuthProvider 코드를 다시 살펴보겠습니다.

@Component
class KakaoAuthProvider(
    ...
) : AuthProvider {
  
    override fun authenticate(){
        getProperties() // 문제 지점
    }

    override fun supports():  {
        ...
    }

    @Cacheable(cacheNames = [CacheNames.API_RESPONSE], key = "'kakaoPublicKeyResponse'")
    fun getProperties(): OidcProperties {
        // 외부 API 호출 (캐싱 되어야 하는 작업)
    }
}

캐싱되어야 하는 메서드 getProperties는 사실 내부에 다른 메서드 authenticate를 통해 호출되고 있었습니다.

authenticate 메서드가 호출될 때는 프록시 객체의 intercept를 통해 Advisor를 조회하게 되고 이때는 조회되는 Advisor가 없습니다.

왜냐하면 authenticate 메서드에는 @Cacheable 애노테이션이 없기 때문입니다.

 

조회되는 Advisor가 없게되면 그냥 비즈니스 로직을 호출하게 됩니다.

@Cacheable 애노테이션이 없는 authenticate 내부에서 getProperties(캐싱 처리되어야 하는) 메서드를 호출하기 때문에 Advisor 처리가 되지 않고 getProperties를 호출하게 되는 것이였습니다.

 

해치웠나??

원인을 파악했습니다. 그러면 어떻게 해결하면 될까요?

해결책은 생각보다 간단합니다.

비즈니스 로직 호출 시 내부 로직을 통해 호출되지 않게 분리하면 됩니다.

그러면 Advisor를 조회하는 작업이 일어날 것이고 정상적으로 캐싱 작업이 처리될 것입니다.

 

저희는 캐싱 되어야하는 외부 API 호출 작업을 별도의 객체로 분리하여서 문제를 해결했습니다.

관련 작업은 팀원분이 열심히 해주셨습니다!

 

 

그리고 다시 비즈니스 로직을 호출해보면 의도한대로 캐싱 처리하는 Advisor가 조회되는 것을 확인할 수 있었습니다.

이를 통해 문제를 해결할 수 있었습니다.

 

그렇게 문제가 해결되었다고 생각했습니다. 실제로 며칠동안 전혀 문제가 발생하지 않았습니다. 하지만 부하 테스트를 진행하는 과정에서 갑자기 동일한 오류가 발생했습니다.

 

 

처음에는 캐싱이 제대로 동작하지 않은 줄 알았습니다. 그래서 모니터링을 확인해보았는데 캐싱이 제대로 동작하고 있었습니다. 하지만 오류 발생 시점에 캐시 Misses가 미세하게 증가한 것을 확인할 수 있었습니다.

 

만료된 키에 대한 메트릭을 확인해보니 에러 발생 시점에 키가 만료된 것을 볼 수 있습니다.

 

즉, 부하 테스트를 진행하던 중 캐시가 만료되면서 카카오 API를 호출하게 되었던 것입니다. 이전 에러 발생 원인과 동일합니다. API 요청이 너무 많아 요청이 차단되면서 Connection timeout 이 발생한 것입니다. 찾아보니 이러한 문제를 Cache Stampede라고 합니다(관련 위키문서).

Cache Stampede 문제 해결하기

그렇다면 이 Cache Stampede 문제를 어떻게 해결할 수 있을까요? 여러가지 해결책을 고려해보고 저에게 맞는 해결책을 찾아보려고 합니다.

방법 1. 락 사용하기

첫 번째 해결 방법은 락을 사용하는 방법입니다. 레디스의 락을 활용하여서 외부 API를 호출하고 캐싱 해주는 동안 다른 요청을 제어합니다. 이렇게 되면 외부 API를 호출하는 하나의 요청만 발생하게 되고 응답값을 캐시에 저장하게 됩니다. 이러면 다른 요청들은 캐시에서 정상 조회가 가능하여 Cache Stampede 문제를 해결할 수 있습니다.

 

방법 2. PER 알고리즘

PER 알고리즘은 Probabilistic Early Recomputation을 뜻하는 알고리즘으로 캐시 유효 기간 만료 이전에 일정 확률로 캐시를 갱신하는 연산입니다. VLDB라는 학술대회에서 발표된 방법이라고 합니다(관련 논문).

function xFetch(key, ttl, β=1):
  value, Δ, expiry ← cache.read(key)        // Δ는 이전 재계산에 걸린 시간
  if (!value) or (currentTime - Δ * β * log(rand()) ≥ expiry) then
    start ← currentTime
    value ← recomputeValue()
    Δ ← currentTime - start
    cache.write(key, (value, Δ), ttl)
  return value

캐시를 조회할 때마다 캐시의 만료 시간을 조회하고 일정 확률로 조건에 만족이 되면 재연산을 수행하여 캐시를 갱신해줍니다. 이때 갱신 확률은 만료 시간에 가까워질수록 높아져서 한꺼번에 재요청이 몰리는 현상을 자연스럽게 분산시킬 수 있습니다.

 

방법 3. 스케줄러를 활용하여 갱신하기

만료 기간을 알고 있다면 만료 기간이 되기전에 미리 갱신해주는 방법입니다.

 

@Scheduled(
    initialDelayString = "\\${oidc.initial-refresh-delay}",
    fixedDelayString = "\\${oidc.response-refresh-millis}",
)
fun refreshOidcProperties() {
    공개키_갱신_작업()
}

 

 

스케줄러는 백그라운드에서 정해진 주기로 돌아가면서 이벤트에 따라 캐시를 미리 재계산 및 갱신해주죠. 이러면 캐시가 만료되기 전에 새롭게 갱신되기 때문에 캐시 Miss가 발생하지 않습니다.

 

그래서 어떤 방법을 선택했나??

결론부터 말하는 저는 3번 방법인 스케줄러를 활용하는 방법을 채택했습니다.

락을 사용하게 되면 락이 걸려있는 동안 요청 지연이 발생하게 됩니다. 이는 사용자 경험에 좋지 않다고 생각했습니다. 반면 PER 알고리즘을 활용하면 지연이 발생하지는 않아 좋은 선택지가 될 수 있죠. 하지만 제 상황의 경우 모든 사용자가 동일한 데이터(카카오 OIDC 공개키)를 필요로 합니다. 따라서 매번 사용자가 로그인 요청을 보낼 때 마다 갱신 가능성이 있는 PER 알고리즘을 수행할 필요성이 크게 느껴지지 않았습니다. 따라서 스케줄러를 활용하여 TTL 이전에 갱신해주는 방법을 채택했습니다.