1. 문제 정의

업브렐라 서비스의 특성 상 협업지점의 CUD는 자주 일어나지 않지만, 조회 API는 자주 호출되고 있습니다.

협업지점 조회 API는 여러 테이블이 조인을 하고, 자주 호출되는데 이것이 매번 호출되면 DB의 부하 증가로 이어지게 됩니다. 따라서 업브렐라 개발팀은 Redis를 이용하여 DB 데이터를 캐싱하기로 결정하였습니다.

2. Redis

2 - 1. Redis 설치

  • 아래의 명령어를 통해 redis를 설치해줍니다.
sudo apt install redis-server
  • redis 설정 변경
sudo vim /etc/redis/redis.conf

여기서 bind 옵션을 허용하고 싶은 ip로 변경해줍니다.

2 - 2. Spring Boot Redis 설정

  • build.gradle 의존성 추가해줍니다.
    // Spring Data Redis
    implementation 'org.springframework.boot:spring-boot-starter-data-redis'
    // Spring Session Data Redis
    implementation 'org.springframework.session:spring-session-data-redis'

3. Cache를 왜 써야할까?

3 - 1. 파레토 법칙

파레토 법칙

이미지 출처 : https://www.briantracy.com/blog/personal-success/how-to-use-the-80-20-rule-pareto-principle/

파레토 법칙은 업브렐라 서비스에 그대로 적용할 수 있습니다. 자주 사용되는 20% 데이터에 캐싱을 적용하면 80% 결과에 대해서 성능 향상을 기대할 수 있을 것입니다.

3 - 2. 주의점

캐싱을 적용하고 데이터 변경에 대한 설정을 하지 않는다면, 사용자는 데이터가 변경되었음에도 캐싱된 데이터를 반환받게 됩니다. 즉, 변경된 DB 데이터를 반영하지 못하게 됩니다.

이러한 문제를 해결하기 위한 여러 전략은 다음과 같습니다.

  1. TTL (Time To Live) 설정 : 캐시에 저장된 데이터에는 일정 시간 동안만 유지되게 만들 수 있는 TTL 값을 설정합니다. TTL이 만료되면 해당 캐시 데이터는 자동으로 삭제되며, 다음 요청 시 DB에서 다시 데이터를 가져와 캐시합니다. 하지만, 이 방법은 최신 데이터를 항상 반영한다는 보장은 없습니다.
  2. DB 변경 감지: DB의 데이터 변경을 감지할 수 있는 트리거나 이벤트를 사용하여 데이터가 변경될 때 캐시를 업데이트하거나 삭제하는 방법이 있습니다. 예를 들어, DB 트리거를 사용하여 데이터 변경 시 외부 서비스를 호출하여 캐시를 업데이트할 수 있습니다.
  3. 캐시 무효화 (Cache Invalidation): 데이터가 변경될 때마다 관련된 캐시를 수동으로 무효화하는 방법입니다. 예를 들어, 사용자 정보를 수정하는 서비스가 있다면, 해당 서비스 로직 내에서 관련된 캐시 데이터를 삭제하거나 업데이트 합니다.

여러가지 방법 중, 업브렐라 개발팀은 캐시 무효화 방법을 통해 캐시를 관리하도록 결정하였습니다.

4. 캐시 적용

4 - 1. 조회 Service에 캐시 적용

    @Transactional
    @Cacheable(value = "stores", key = "'allStores'")
    public List<SingleStoreResponse> findAllStores() {

        List<StoreDetail> storeDetails = storeDetailRepository.findAllStores();
        return storeDetails.stream()
                .map(this::createSingleStoreResponse)
                .collect(Collectors.toList());
    }

@Cacheable 어노테이션을 사용해서 필요한 데이터에 캐시를 적용할 수 있습니다.

스크린샷 2023-09-26 오후 6 04 08 (주의: redis는 싱글 스레드이기 때문에 keys 명령어 처럼 O(n) 시간복잡도를 가진 명령어는 실행하지 않는 것이 좋습니다.)

key : stores

value : allStores

session에 적용된 모습을 확인할 수 있습니다.

4 - 2. CUD 캐시 무효화 설정

    @Transactional
    @CacheEvict(value = "stores", key = "'allStores'")
    public void deleteStoreMeta(long storeMetaId) {

        findStoreMetaById(storeMetaId).delete();
    }

@CacheEvict 어노테이션을 통해 적용된 캐시를 무효화 합니다.

스크린샷 2023-09-26 오후 6 03 54

@CacheEvict 를 통해 stores:allStores 가 삭제된 것을 확인할 수 있습니다.

5. 성능 비교

Cache 적용 전
before cache

vUser 50을 기준으로 평균 TPS는 117.1이었습니다.

Cache 적용 후
after cache

동일한 조건에서 캐시를 적용해보니, 평균 TPS 2386.1로 상승한 것을 확인할 수 있습니다.

TPS를 비교해보면, 117 TPS -> 2386 TPS로 1943.59%의 성능 향상이 이루어졌습니다.

6. Sequence Diagram 비교

어떻게 API 흐름이 변경되었는지 Sequence Diagram을 통해 알아보도록 하겠습니다.

Cache 적용 이전
스크린샷 2023-09-26 오후 5 55 54

서버에서 조회가 일어날 때마다 같은 데이터를 반환할 수 있음에도 불구하고, 매번 DB에 접근하며 부하를 일으키게 됩니다.

Cache 적용 이후(Cache Hit)
스크린샷 2023-09-26 오후 5 55 33

이렇게 Cache에 저장된 데이터가 있다면, DB에 접근하지 않고도 훨씬 빠른 속도로 데이터를 응답할 수 있게 됩니다.

만약 캐싱된 데이터가 없다면 어떻게 될까요?

Cache Miss

스크린샷 2023-09-26 오후 5 55 03

이처럼 캐시가 없을 경우 DB를 조회한 후 다시 캐싱해주는 과정을 거치게 됩니다.

Cache Miss가 발생할 경우 이처럼 Sequence가 복잡해질 수 있고, 자주 사용되지 않는 데이터를 캐싱할 경우 리소스 낭비가 될 수 있기 때문에, 데이터 변경이 자주 일어나지 않고, 조회를 많이 하는 데이터만 캐싱하는 것이 성능상 유리합니다.

7. 마무리

이번 포스팅을 통해 DB에 캐시를 적용하는 방법을 알아보았습니다.

DB 데이터를 적절하게 캐싱한다면 API 성능을 더욱 끌어올릴 수 있을 것입니다.