EhCache Self-Invocation 문제 (Feat. nGrinder)

WIZ·2023년 9월 22일

TroubleShooting

목록 보기
3/7

회사에서 캐시 구조에 대해서 리팩토링을 진행하는 과정에서 EhCache Self-Invocation 문제로 인해 캐시가 정상적으로 동작하지 않았던 사례에 대해서 포스팅하려고 한다.

어떤 문제가 있었는지 예제를 통해서 간단하게 살펴보자.

@Cacheable(value = "testCache")
fun cache(): String {
	log.info("[Info] Create Data!!")
    return "Hello, ch4njun"
}

fun test(): String {
	log.info("[Info] test() Call!!")
    return cache()
}

EhCache 는 그냥 로컬 캐시 라이브러리라고 생각하면 될 것 같다.
@Cacheable 어노테이션을 붙이면 해당 메소드의 응답 값이 캐싱된다.

즉, cache() 메소드는 여러번 호출하더라도 내부에 있는 로그는 최초 1회 호출시에만 찍히고, 이후에는 캐시에 저장되어 있는 값이 반환되기 때문에 로그가 찍히지 않는다.

위 코드에서 test() 는 같은 클래스에 있는 cache() 라는 메소드를 호출하고 있다. 위에서 말했듯이 캐시가 정상적으로 동작한다면 반복해서 test() 를 호출하더라도 [Info] Create Data!! 로그는 최초 1회만 찍혀야 정상적으로 캐싱처리가 되었다고 볼 수 있다.

하지만, 막상 실행해보면 계속해서 로그가 찍히는 것을 확인할 수 있다.
즉, 캐싱처리가 정상적으로 되지 않은 것이다.


Self-Invocation 문제


그렇다면 왜 이런 문제가 발생한걸까?
정답은 Spring AOP 의 Self-Invocation 문제에 있다!

Self-Invocation 문제는 EhCache 의 문제가 아니라 Spring AOP 자체의 문제다.
즉, @Transactional 과 같은 Spring AOP 를 사용하는 모든 곳에서 발생할 수 있다.

Spring AOP 에서 Self-Invocation 문제가 발생하는 이유에 대해서 살펴보자.
출처: https://gmoon92.github.io/spring/aop/2019/04/01/spring-aop-mechanism-with-self-invocation.html

Spring AOP 는 인터페이스 구현 여부에 따라서 JDK Dynamic ProxyCGLIB 을 사용해 AOP 를 적용한다. (Spring Boot 에서는 기본적으로 모든 상황에서 CGLIB 을 사용한다)

Spring AOP 는 Proxy 객체가 해당 메소드의 앞뒤에 부가적인 기능(횡단 관심사)를 실행한 후 기존 객체의 메소드를 호출하는 매커니즘으로 동작한다. 이때 이미 Proxy 객체의 호출에 의해 실제 내부 객체로 들어와서 호출되는 경우 Proxy 객체를 거치지 않게된다.

이러한 이유로 Self-Invocation 방식으로 호출된 메소드는 Spring AOP 가 정상적으로 적용되지 않는 것이다.


그럼 어떻게 해결할 수 있을까?


1. AopContext

@Cacheable(value = "testCache")
fun cache(): String {
	log.info("[Info] Create Data!!")
    return "Hello, ch4njun"
}

fun test(): String {
	log.info("[Info] test() Call!!")
    return (AopContext.currentProxy() as AspectTestService).cache()
}

Self-Invocation 방식의 호출에서 해당 요청이 AOP Proxy 객체를 거치지 않기 때문에 발생하는 문제임으로, 가장 단순하게 AopContext.currentProxy() 를 통해 Proxy 객체를 가져오고 그 객체를 통해 메소드를 호출하면 문제는 해결된다.

이러한 AopContext 를 사용하기 위해서는 EnableAspectJAutoProxy(exposeProxy = true) 를 통해서 옵션을 활성화해야 하는데.... 그다지 내키는 방법은 아니긴하다.

2. IoC 컨테이너의 Bean 활용

IoC 컨테이너에 등록된 자기 자신의 Bean 을 가져와 호출하는 방식으로도 해결할 수 있다.

@Resource(name="aspectTestService")
val self: AspectTestService

@Cacheable(value = "testCache")
fun cache(): String {
	log.info("[Info] Create Data!!")
    return "Hello, ch4njun"
}

fun test(): String {
	log.info("[Info] test() Call!!")
    return self.cache()
}

첫 번째 방법보단 나아진 것 같긴 하지만 여전히 좋은 방법이라고 느껴지지 않는다.

3. AspectJ Weaving

마지막으로 Spring AOP 의 Weaving 방식을 AspectJ Weaving 방식으로 변경하는 것이다. Aspect Weaving 은 바이트 코드를 직접 조작하는 방식이기 때문에 Proxy 객체로 인한 Self-Invocation 문제가 발생하지 않는다.

하지만.. 다른 바이트 코드를 조작하는 라이브러리들과의 충돌 가능성이 높고, 여러모로 위험부담이 높아 선뜻 손이 가진 않는다.


그래서 어떻게 하자는걸까?


세 가지 방법 모두 그다지 좋은 방법은 아닌 것 같다.
그러면 어떻게 해결해야 할까?

사실, Self-Invocation 이 발생하지 않도록 설계하는게 가장 좋다고 생각한다.

실제 회사 프로젝트에서 종종 해당 문제를 만나는데, 대부분 객체의 책임분리가 명확하지 않아서 발생했었고 이번 경우도 마찬가지로 객체의 책임분리를 통해서 Self-Invocation 문제를 해결했다.

어쩔 수 없는 상황에서는 위 세 가지 방법중에 해결방법을 찾아야겠지만, 적어도 객체의 책임분리에 대한 고민은 한번 하고 넘어가는게 좋을 것 같다.


개선 후 이야기


EhCache 에 대한 Self-Invocation 문제를 해결한 후 nGrinder 를 이용한 성능테스트를 통해서 20%~30% 의 성능향상이 있다는 것을 확인했다.

제대로 동작하지 않던 캐싱이 정상적으로 동작하니 당연히 성능향상이 있었다.
이어서 nGrinder 를 이용한 성능테스트 과정을 소개한다.


nGrinder 란?


nGrinder 는 스트레스 테스트 도구로 네이버에서 개발한 오픈소스다.
유명한 성능 테스트 도구로 Apache JMeter 가 있었지만, 팀장님의 권유로 nGrinder 를 사용해봤다. (실제로 현재 카카오페이에서도 nGrinder 를 통해 성능테스트를 하고있다.)

nGrinder 는 JAVA 베이스로 동작하기 때문에 Oracle JDK 1.6 이상이 설치되어 있어야 하고, https://github.com/naver/ngrinder/releases 에서 Controller 파일을 다운로드 받을 수 있다.

여기서 Controller 는 웹 기반의 GUI 시스템으로 테스트 전반적인 작업을 할 수 있는 환경을 말한다.

다운로드 받은 Controller 는 Docker 를 이용해서 실행할 수도 있고, WAR 파일을 받아 로컬 환경에서 직접 실행할 수도 있다.


먼저 Docker 를 이용한 방법을 살펴보자.
아래 docker-compose 파일을 만들고, 해당 파일을 통해 컨테이너를 띄우면 된다.

version: '3'
services:
  ngrinder-controller:
    image: ngrinder/controller:3.5.5-p1
    container_name: ngrinder-controller
    ports:
      - "80:80"
      - "16001:16001"
      - "12000-12009:12000-12009"
    volumes:
      - ./ngrinder/controller:/opt/ngrinder-controller
 
  ngrinder-agent:
    container_name: ngrinder-agent-1
    image: ngrinder/agent:3.4
    command: ["ngrinder-controller:80"]

두 번째로 로컬 환경에서 직접 구동하는 방법인데, WAR 파일을 받아 아래 명령어로 실행하면 된다.

java -XX:MaxPermSize=200m -jar ngrinder-controller-3.5.5-p1.war -p 7777

초기화면은 위와같고 기본적으로 설정되어있는 ID/Password 는 admin/admin 이다.

로그인을하고 메뉴를 보면 Download Agent 가 있는데 해당 메뉴를 통해 Agent 파일을 다운로드받아 Agent 역할을 할 PC 에 실행시키면 된다. 이후 Agent Management 메뉴에서 인식된 Agent 의 목록을 확인할 수 있다.

여기서 Agent 는 실제로 요청을 날리는 Client 라고 생각하면된다.

이제 성능테스트를 진행할 수 있는데 기본적으로 Script 를 작성해 요청할 API 와 파라미터 등을 설정할 수 있다. 물론 이렇게 추가된 Script 는 groovy 로 작성되어 있어 직접 수정할수도 있다.

이렇게 Script 를 통해 테스트를 진행할 API 를 설정했으면 vUser 수, 테스트 시간, Ramp-Up 등의 설정을 하고 테스트를 진행하면 된다. 나같은 경우엔 vUser 를 1, 10, 20, 30, 40, 50, 70, 99 로 늘려가며 테스트를 진행했고, 서버에 갑작스런 부하가 가지 않도록 Ramp-Up 을 설정한 후에 진행했다.

왼쪽이 EhCache Self-Invocation 문제를 해결하기 전이고, 오른쪽이 해결한 후의 성능테스트 결과이다.
약, 20~30% 의 성능향상이 있었던 것을 확인할 수 있다.

테스트 하면서 신경썼던 포인트 중 하나는 Saturation Point(포화지점) 을 찾는 것 이었다.

서비스를 이용하는 사용자가 증가하면 TPS 는 지속적으로 증가하게 된다. 하지만, 어느 시점이 되면 아무리 사용자가 늘어나더라도 TPS 는 증가하지 않고, 반대로 지연시간(Latency) 가 증가하게된다.

이 지점이 바로 위에서 말한 포화지점이다.
포화지점을 찾으려고 한 이유는 포화지점을 기준으로 해당 서버가 감당할 수 있는 부하의 한계를 정의할 수 있기 때문이다.

0개의 댓글