캐싱을 통한 API 성능 개선

2024. 9. 24. 19:37프로젝트/[EATceed] 몸무게 증량 어플

728x90

 

EATceed의 핵심 기능 중 하나는 사용자 개개인의 신체 정보와 식단 기록을 기반으로, 매일 목표에 도달했는지를 월별 캘린더 형식으로 시각화하는 것입니다. 하지만 기존 API 구조는 모든 데이터를 실시간으로 계산하여 비효율적이라는 문제가 있었습니다.

 

이번 포스팅에서는 이러한 API를 개선한 과정을 상세히 설명하려고 합니다.

 

기존 방식

 

기존에는 모든 요청에 대해 월 단위로 데이터를 실시간으로 계산하고 분석 결과를 반환하는 방식이었습니다. 즉, 과거, 현재, 미래의 데이터를 모두 동일하게 처리하면서 불필요한 쿼리 요청이 발생하는 비효율적인 구조였습니다.

그러나 과거 데이터는 변하지 않는다는 점에 착안해, 다음과 같이 로직을 개선했습니다.

 

 

예시 코드

@Service
public class GetMonthlyAnalysisService implements GetMonthlyAnalysisUsecase {

		/**
		* 요청 받은 월에대한 분석 결과를 반환
		*/
    @Override
    @Transactional(readOnly = true)
    public GetMonthlyAnalysisDTO execute(GetMonthlyAnalysisCommand command) {
        ...
    }
}

 

개선 방식

 

API 로직을 각 월의 특성에 맞게 구분하여 처리하도록 변경했습니다.

 

  1. 과거 월 분석 결과
    • 과거 데이터는 변경되지 않으므로 처음 한 번만 계산한 후 캐시에 저장해 반복적인 쿼리를 방지합니다.
  2. 현재 월 분석 결과
    • 현재 날짜 이전의 데이터는 이미 확정된 값이므로 과거 데이터처럼 캐싱이 가능합니다.
    • 오늘의 데이터는 변동 가능성이 있기 때문에 실시간으로 계산해 처리합니다.
  3. 미래 월 분석 결과
    • 미래 월의 데이터는 고정된 값을 미리 계산하여 서버에 정적으로 저장해, 성능을 최적화합니다.

 

예시 코드

@Service
@RequiredArgsConstructor
public class GetAnalysisService implements GetAnalysisUsecase {

    private final GetDailyAnalysisUsecase getDailyAnalysisUsecase;
    private final GetMonthlyAnalysisUsecase getMonthlyAnalysisUsecase;

    @Override
    public GetMonthlyAnalysisResponse execute(GetAnalysisCommand command) {
        if (isAfterYearMonth(command.requestDate(), command.nowDate())) {
            // 서버에 이미 저장되어있는 정적인 값 반환
        }
        if (isBeforeYearMonth(command.requestDate(), command.nowDate())) {
		        getMonthlyAnalysisUsecase.executeToPast();
            // 캐싱된 정보를 반환
        }
        getMonthlyAnalysisUsecase.executeNow();
        getDaysAnalysisUsecase.execute();
        // 캐싱된 정보와 비교하여 캐시에 반영이 안 된 데이터는 직접 계산 후 둘의 결과를 조합해 반환
    }

 

캐싱 전략

 

이러한 분석 방식으로 데이터를 구분하면서, 각 데이터를 효율적으로 처리할 방법을 고민한 결과, 캐싱을 도입하는 것이 적합하다고 판단했습니다. 특히 변동이 거의 없는 데이터에 캐싱을 적용하면 반복적인 쿼리를 줄여 성능을 크게 향상시킬 수 있을 것으로 예상했습니다.

변동이 없는 데이터는 캐시에 저장해 불필요한 계산을 방지하고, 실시간으로 변하는 데이터는 필요한 순간에만 계산함으로써 시스템의 부담을 최소화할 수 있습니다.

 

  • 과거 월: 변하지 않는 과거의 분석 결과는 캐싱하여 반복적인 쿼리를 방지했습니다.
  • 현재 월: 오늘 이전의 데이터는 과거 데이터처럼 고정된 값이므로 캐싱을 적용했습니다. 반면, 오늘의 데이터는 실시간으로 변동할 수 있기 때문에 매번 새로 계산하지만, 과거 데이터를 캐싱함으로써 실시간으로 처리해야 할 양을 최소화했습니다.
  • 미래 월: 미래 월은 아직 식사 기록이 없으므로 목표 달성 여부가 무조건 false입니다. 이를 바탕으로 애플리케이션에 정적으로 분석 결과를 미리 생성해 두었습니다.

 

Look Aside 패턴

 

EATceed 서비스는 회원별 맞춤형 데이터를 제공해야 하기 때문에, 모든 회원의 데이터를 일괄적으로 캐싱하는 것보다는 자주 조회되는 데이터만 선택적으로 캐싱하는 것이 더 효율적이라고 판단했습니다. 이에 따라 Look Aside 패턴을 도입했습니다.

Look Aside 패턴의 동작 방식은 다음과 같습니다.

 

  1. 캐시 조회: 서버는 먼저 캐시에서 데이터를 조회합니다.
  2. DB 조회: 캐시에 데이터가 없을 경우, DB에서 데이터를 조회합니다.
  3. 캐시 저장: DB에서 조회한 데이터를 필요할 때만 캐시에 저장합니다.
  4. 반복 조회: 한 번 캐시에 저장된 데이터는 이후 반복적인 요청에서 캐시에서 직접 반환됩니다.

@Service
@RequiredArgsConstructor
public class GetMonthlyAnalysisService implements GetMonthlyAnalysisUsecase {

    @Override
    @Transactional(readOnly = true)
    @Cacheable(cacheNames = ..., key = ...)
    public String execute(GetMonthlyAnalysisCommand command) {
        ...
    }
}

 

캐싱 갱신

 

캐싱된 분석 결과는 하루가 지나면 어제 내용까지 포함된 분석 결과로 바뀌기 때문에, 캐싱된 분석 결과에 어제 분석 결과를 갱신해줘야 합니다.

2024.09.22일에서 2024.09.23일로 넘어갈 때 스케줄러 작동하여 2024.09.22에 대한 결과가 반영된 모습

2024.09.22
2024.09.23



    public class UpdateCacheScheduler {
    	@Scheduled(...)
        public void run() {
        	// scan keys
			for(String keys : key){
            	dailyAnalysisCacheUpdater.updateCacheForKey(...);
            }
        }
    }



그러나, 캐시 갱신시 해결해야되는 문제들이 몇 가지 있습니다. 

 

00시에 스케줄러가 작동할 때 캐시 정합성

 

00시에 스케줄러가 작동할 때(캐시가 갱신되기 전) 해당 캐시를 이용하면 캐시 정합성 문제가 발생합니다.

이를 해결하기 위해 캐시에 updatedAt 필드를 추가하여 조회 시에 캐시의 updatedAt 필드를 확인하여 갱신이 안 된 캐시라면 해당 기간을 추가로 계산하여 캐시 결과와 조합하여 반환합니다.

 

스케줄러 작동이 실패하였을 경우

 

Spring Batch를 사용하여 캐시 업데이트한 부분부터 재시도가 가능하게끔 한다.

 

 

 

캐시 제거

회원 탈퇴 시, 해당 회원의 분석 결과와 관련된 모든 캐시 데이터를 삭제합니다.

이때, 회원과 관련된 Key들을 조회하여야 하는데, KEYS 명령어는 해당 명령어가 실행되는 도중 다른 명령어가 블로킹 되기 때문에 Redis SCAN 명령어를 사용하였습니다.

 

Redis Transaction 사용에 관한 고민

 

(Multi + Exec 명령어 사용)

@Component
@RequiredArgsConstructor
public class DeleteMemberCacheListener {

    private final RedisUtils redisUtils;

    @TransactionalEventListener(classes = DeleteMemberEvent.class)
    public void handle(DeleteMemberEvent event) {
        // Redis scan으로 Key들을 조회
        // 해당 key들을 제거
    }
}

 

 

과연 해당 명령어들을 Single Isolation Operation 처리가 가능하도록 제공해야하는 가를 고민해봤을 때 회원 탈퇴한 회원의 분석 결과인 특수성과 트랜잭션의 비용(Multi + Exec)을 고려할 때 트랜잭션을 사용할 필요가 없다고 판단했습니다.

 

Redis Pipeline 도입

 

Redis 파이프라이닝은 개별 명령에 대한 응답을 기다리지 않고 한 번에 여러 명령을 실행하여 성능을 향상시키는 기술입니다.

Redis scan으로 key들을 조회하고, 해당 key들을 제거하는 상황에서 Redis pipeline을 사용하여 RTT를 대폭 감소시킬 수 있다고 판단해 이를 사용하였습니다.

또한, Redis Docs에서는 파이프라이닝이 RTT외에도 Redis 서버에서 초당 수행할 수 있는 작업 수를 크게 향상시켜 도입을 하지 않을 이유가 없었습니다.

https://redis.io/docs/latest/develop/use/pipelining/

 

Redis pipelining

How to optimize round-trip times by batching Redis commands

redis.io

 

 

성능 개선 전 후 비교


 

성능 테스트 환경

 

EC2 : T3.micro

RDB : T3.micro

Redis : EC2에 Docker로 띄움.

 

분석 API 요청 일자 월별로 다르게 하여(2월 ~ 10월) 5분동안 요청을 계속 보낸 결과

 

 

 

Response Time : 99%

캐싱 적용 전 TPS는 39.93, 응답 시간은 약 2초

 

 

Response Time : 99%

 

캐싱 적용 후 TPS는 63.03, 응답 시간은 약 0.4초

 

 

결과


 

같은 시간동안 부하 테스트 시 캐싱 적용한 테스트 케이스가 약 2배 되는 요청을 보냈음에도 불구하고 TPS가 37% 상승하였고, 응답 시간은 80% 개선된 것을 확인할 수 있었습니다.

 

감사합니다!

 

728x90