부트모아

[V3] 캐싱을 통한 쿼리 줄이기

승무_ 2024. 9. 28. 14:37

로그를 살펴 보던 중, 글을 등록, 수정, 삭제 할때 마다 Principal을 통해 인증 정보를 가져오기 위해 query가 발생하는 것을 확인할 수 있었다.

수정 빈도가 매우 적은 user 정보를 매번 db에 요청하는 것은 불필요한 부하라는 생각이 들어 user 정보를 캐싱하고 성능 개선이 어느정도 되었는지를 부하 테스트 해보기로 하였다.

 

캐싱 전 테스트

테스트 종류:

1) Smoke 테스트
VUser: 1 ~ 2
최소의 부하로 시나리오를 검증한다.

 

2) Load 테스트
평소 트래픽과 최대 트래픽일 때 VUser를 계산 후 시나리오를 검증한다.

 

3) Stress 테스트
최대 사용자 혹은 최대 처리량인 경우의 한계점을 확인하는 테스트이다.
점진적으로 부하를 증가시켜본다.
테스트 이후 시스템이 수동 개입 없이 자동 복구되는지 확인해본다.

 

테스트 종합 목표:

1. 최대 latency는 300ms 이하여야 한다.
2. 최대 동시 접속자 수는 200명이여야 한다.

시나리오 수립

게시글 목록 조회 - 게시글 작성 - 해당 게시글 조회

 

스크립트 작성

@RunWith(GrinderRunner)
class Test {
    public static GTest test1
	public static GTest test2
    public static GTest test3

    public static HTTPRequest request
    public Object cookies = []
 
    @BeforeProcess
    public static void beforeProcess() {
        HTTPPluginControl.getConnectionDefaults().timeout = 6000
        test1 = new GTest(1, "127.0.0.1")
		test2 = new GTest(2, "127.0.0.1")
		test3 = new GTest(3, "127.0.0.1")
        request = new HTTPRequest()
    }
 
    @BeforeThread
    public void beforeThread() {
		test1.record(this, "test1")
		test2.record(this, "test2")
		test3.record(this, "test3")
        grinder.statistics.delayReports=true;
         
        // reset to the all cookies
        def threadContext = HTTPPluginControl.getThreadHTTPClientContext()
        cookies = CookieModule.listAllCookies(threadContext)
        cookies.each {
            CookieModule.removeCookie(it, threadContext)
        }
         
        // do login & save to the login info in cookies 
        NVPair[] params = [new NVPair("username", "swkang"), new NVPair("password", "asdf1234")];
        HTTPResponse res = request.POST("http://127.0.0.1:8080/login", params);
        cookies = CookieModule.listAllCookies(threadContext)
    }
 
    @Before
    public void before() {
		grinder.sleep(1000)
        // set cookies for login state
        def threadContext = HTTPPluginControl.getThreadHTTPClientContext()
        cookies.each {
            CookieModule.addCookie(it ,threadContext)
            grinder.logger.info("{}", it)
        }
    }
 
    @Test
    public void test1(){
		long startTime = System.currentTimeMillis()
	
        HTTPResponse result = request.GET("http://127.0.0.1:8080/articles")
		
		long endTime = System.currentTimeMillis()
		
		long responseTime = endTime-startTime
		long maxResponseTime = 300
		
		if (responseTime > maxResponseTime) {
			grinder.logger.error("responseTime exceeded the threshold")
			fail("responseTime exceeded the threshold")
		 }
    }
	@Test
    public void test2(){
		long startTime = System.currentTimeMillis()

	
        NVPair[] params = [new NVPair("title", "title01"), new NVPair("content", "content01"), new NVPair("category", "category01")];
        HTTPResponse result = request.POST("http://127.0.0.1:8080/articles/form", params);
 
		long endTime = System.currentTimeMillis()
		
		long responseTime = endTime-startTime
		long maxResponseTime = 300
		
		if (responseTime > maxResponseTime) {
			grinder.logger.error("responseTime exceeded the threshold")
			fail("responseTime exceeded the threshold")
		 }
    }
	@Test
    public void test3(){
		long startTime = System.currentTimeMillis()

        HTTPResponse result = request.GET("http://127.0.0.1:8080/articles/1")
 
		long endTime = System.currentTimeMillis()
		
		long responseTime = endTime-startTime
		long maxResponseTime = 300
		
		if (responseTime > maxResponseTime) {
			grinder.logger.error("responseTime exceeded the threshold")
			fail("responseTime exceeded the threshold")
		 }
    }
}

reponseTime이 300ms 초과이면 오류 발생

분석

 

peak TPS인 698을 찍고 latency가 급격하게 줄어드는 것을 확인할 수 있었다.

 

Redis를 활용한 캐싱

@RequiredArgsConstructor
@EnableRedisRepositories
@Configuration
public class RedisConfig {
    @Value("${spring.data.redis.host}") private String redisHost;
    @Value("${spring.data.redis.port}") private int redisPort;

    @Bean
    public RedisConnectionFactory redisConnectionFactory(){
        return new LettuceConnectionFactory(redisHost, redisPort);
    }

    // 가장 많이 조회되고, 값 수정이 적은 user를 캐싱
    @Bean
    public RedisTemplate<String, UserAccount> userRedisTemplate(RedisConnectionFactory redisConnectionFactory){
        // RedisTemplate -> Redis 명령어를 쉽게 작성할 수 있도록 도와주는 클래스
        RedisTemplate<String, UserAccount> redisTemplate =new RedisTemplate<>();
        // setConnectionFactory -> 작성한 명령어를 Redis 서버에 날리기 위해 서버 정보 설정
        redisTemplate.setConnectionFactory(redisConnectionFactory);
        // key 직렬화
        redisTemplate.setKeySerializer(new StringRedisSerializer());
        // value 직렬화
        redisTemplate.setValueSerializer(new Jackson2JsonRedisSerializer<UserAccount>(UserAccount.class));

        return  redisTemplate;
    }
}

RedisTemplate는 Operation들을 쉽게 할 수 있는 클래스다. RedisTemplate에 opsForValue() 메소드는 통해 기본적인 GET, SET을 할 수 있는 메소드다. 이외에도 HashSet이나 ZSet 과 같은 여러가지 Command 들이 RedisTemplate 에서 제공하고 있다. 그렇기 때문에 RedisTemplate라는 코드자체는 Redis에 Command를 쉽게 코드를 작성할 수 있게 도와주는 클래스이다.

 

@Bean
public UserDetailsService userDetailsService(UserAccountRepository userAccountRepository, UserCacheRepository userCacheRepository) {
    return username -> userCacheRepository.getUser(username).map(UserAccountDto::from)
            .map(BootPrincipal::from).orElseGet(() ->
                    userAccountRepository
                            .findById(username)
                            .map(UserAccountDto::from)
                            .map(BootPrincipal::from)
                            .orElseThrow(() -> new UsernameNotFoundException("유저를 찾을 수 없습니다.")));
}

loadByUsername를 캐시를 통해 가져오도록 변경하였다.

 

캐싱 후 성능 테스트

TPS가 80퍼 가까이 상승하였고, latency도 빨라져 에러 발생률이 급격하게 줄어든 것을 확인할 수 있었다.