[데브툰] 리팩토링: 프로모션 조회 설계 및 성능 개선 도전하기 - 성능 편

2024. 5. 20. 17:30Project/데브툰

이번 포스팅의 주제는 '프로모션 조회 성능 최적화 도전기'입니다. 캐싱을 도입하게 된 배경과 종류를 살펴본 후 기존 코드, 로컬 캐시 적용, 글로벌 캐시 적용별 캐시 성능 차이를 살펴볼 예정입니다. 앞으로 글의 가독성을 위해 말을 편하게 할 예정입니다. 양해 부탁드립니다. 🍎🍎🍎

 

목차

소개

• 캐싱 도입 배경 및 목표

• 캐시 종류

    • 로컬 캐시

    • 글로벌 캐시

캐시 선택 기준

• 캐시 선택 기준

결론

스프링 부트 구현

• 스프링 부트에서 캐시 기본값 확인

• 스프링부트에서 지원하는 주요 캐시 라이브러리

프로젝트 적용

• 캐시 대상

• 기존 API 수정   

• 사전 데이터 밀어 넣기

성능 테스트

 성능 측정 방법

• 프로모션 조회 성능 테스트

• 전략별 성능

• 전략별 성능 비교

결론

• 결론

• 추가적으로 도입해 볼 방안

회고

 만족한 점

 아쉬운 점

 다음에는 이렇게

 

캐싱 도입 배경 및 목표

데브툰 프로젝트의 프로모션은 특정 기간 동안 파격 할인이 적용되는 이벤트로, 많은 회원이 이용할 것으로 예상된다. 해당 기간 동안 프로모션 수와 내용은 변하지 않아 동일한 데이터를 매우 빈번하게 조회하게 된다. 이에 따라 캐시를 도입하기 적합하다고 판단했다. 프로모션 정보를 캐싱함으로써, 쿠키 결제나 웹툰 미리 보기 결제 시 캐싱된 데이터를 가져와 빠르게 처리하는 것이 목표이다.

 

캐시 종류

 • 로컬 캐시

 • 글로벌 캐시

로컬 캐시

서버 내부 저장소에 캐시 데이터를 저장하는 방식으로 로컬 서버의 리소스(Memory, Disk)를 사용하여 캐싱을 처리

(예: EhCache, Guava Cache, Caffeine Cache)

1️⃣ 장점

속도가 빠르다.

 

2️⃣ 단점

• JVM 힙 메모리에 데이터를 저장한다.

힙 메모리 사용량이 증가하면 GC가 더 자주 실행되어 애플리케이션 성능에 영향을 미칠 수 있다.

 

• 서버 간의 데이터 공유가 안된다. (일관성 문제 발생)

캐시에 저장된 데이터가 변경된다면 모든 웹서버에 동기화를 시켜주어야 한다. 이때, 네트워크 비용이 많이 소모될 것이고 성능에 영향을 미칠 수 있다.

 

 

로컬 캐시 중에서 자주 언급되는 EhCache와 Caffeine의 특징을 살펴보자.

EhCache

가장 널리 사용되는 Java 기반 오픈 소스 캐시 라이브러리

1️⃣ 장점

JVM 힙 메모리를 사용하여 데이터를 캐싱 + OffHeap 메모리 사용 가능 → 힙 메모리와 오프 힙 메모리의 선택적 사용으로 GC 영향 조절 가능하다

• 다양한 기능 제공

 JCache 표준 (JSR-107) 지원으로 표준 API를 사용하여 캐시를 관리할 수 있다.

 

2️⃣ 단점

• ehcache.xml 초기 설정이 다소 복잡하다. (공식문서)

• 대규모 데이터 세트를 캐싱할 때 메모리 사용량이 많이 증가할 수 있다. (오버헤드)

 

3️⃣ 특징

• 3 버전부터 javax.cache API (JSR-107) (JCache) 와의 호환성을 제공

JSR-107(JCache)은 Java에서 제공하는 표준 Cashing API로 내부적으로 JCache의 CachingProvider와 CacheManager 두 개의 인터페이스를 구현하여 실행한다.

 

 저장 공간

힙 메모리 저장 공간 (In-Memory 저장): JVM 힙 메모리에 저장

OffHeap 공간: Memory Heap 외부에 추가적인 유형의 메모리 저장소를 사용할 수 있다. Java GC가 적용되지 않는 저장소이며 Byte 단위의 매우 큰 캐시를 생성할 수 있다.

 

• LRU / LFU / FIFO 제거 알고리즘 제공

 

4️⃣ 사용법

Cache를 사용하고자 하는 Class에 @EnableCaching 선언, Method에 @Cacheable 선언하여 사용한다.

 

Caffeine

Guava 캐시와 ConCurrentHashMap을 개선한 ConcurrentLinkedHashMap을 바탕으로 구현된 캐시

1️⃣ 장점

• 다른 로컬 캐시와 비교했을 때 월등한 성능 차이

• 간단한 설정

• 다양한 캐시 만료 및 제거 정책을 제공하여, 메모리 사용량을 관리하고 불필요한 데이터를 자동으로 제거 가능

 

2️⃣ 단점

• 상대적으로 최근에 나온 라이브러리이기 때문에 커뮤니티와 문서가 부족할 수 있음

• 힙 메모리의 크기 제한 내에서만 사용할 수 있다. JVM 힙 메모리를 사용하여 데이터를 캐싱 → GC에 의해 관리, 영향받음

• JCache 표준 (JSR-107)을 직접 지원하지 않지만, JCache 어댑터를 통해 사용할 수 있다.

 

3️⃣ 특징

• 데이터 처리량 대비 초당 작업에서 높은 처리 속도

• EhCache의 제거 방식보다 적중률이 높은 제거 알고리즘 사용(Window TinyLFU)

• 캐시 내부 알고리즘은 LFU와 LRU의 장점을 통합

 

4️⃣ 사용법

• 애플리케이션 상단에 @EnableCaching어노테이션을 사용해서 캐싱을 사용한다고 명시

• Enum 타입을 사용해서 캐시 이름, 만료 시간, 저장 가능 최대 개수 등 캐시의 설정을 관리

• 캐시의 최대 크기, 만료 시간 등 Config 파일 작성

 

 

이어서 글로벌 캐시에 대해 알아보자

글로벌 캐시

서버 내부 저장소가 아닌 별도의 캐시 서버를 두어 각 서버에서 캐시 서버를 참조하는 방식

(예: Memcached, Redis)

1️⃣ 장점

서버 간에 캐시 데이터를 쉽게 공유할 수 있기 때문에 로컬 캐시의 단점을 극복한다.

 

2️⃣  단점

캐시 데이터를 얻으려 할 때마다 캐시 서버로의 네트워크 트래픽이 발생하기 때문에 로컬 캐싱보다 속도가 느리다.

 

Redis vs Memcached → Redis 승

Redis는 Collection 지원, memcached는 Collection 지원을 안 한다.

Collection이 중요한가?

개발의 편의성과 개발의 난이도가 달라진다. 개발할 때 이미 만들어진 라이브러리(여기서는 Collection)가 있으면 사용하는 게 좋기 때문이다.

Redis

스프링 프레임워크에서는 캐시 추상화를 지원하기 때문에 별도의 캐시 로직을 작성할 필요 없이 애플리케이션에 캐싱 기능을 쉽게 적용할 수 있다. 

1️⃣ 사용법

• @EnableCaching 어노테이션으로 캐싱 기능을 활성화

캐싱 기능이 활성화되면 CacheManager 타입의 빈을 호출하여 캐시 어노테이션이 붙은 컴포넌트들을 스프링이 관리하기 시작한다.

• 적용하고자 하는 메서드 상단 @Cacheable 선언

• Redis를 캐시 저장소로 사용하기로 했으므로 RedisCacheManager를 빈으로 등록하여 기본 CacheManager를 대체

 

여기까지 간단하게 캐시 도입 배경과 캐시 종류에 대해 알아보았다. 그렇다면 나는 어떤 기준으로 캐시를 선택했을까? 이 기준이 최종적으로 캐시를 선택하는 데 핵심 역할을 할 것이다. (스포)


캐시 선택 기준

캐시를 선택할 때 중요한 기준은 데이터가 일관되지 않아도 비즈니스에 큰 영향을 미치지 않는지 여부이다. 두 가지 경우를 살펴보고 나름대로의 결론도 지어보도록 하겠다.

 

case 1. 데이터 업데이트가 지연될 경우

상황: 프로모션 정보가 10일 동안 변하지 않고 유지되는 상황에서 캐시 데이터가 1시간 동안 업데이트 되지 않는다면?

일관성 문제: 업데이트가 지연되더라도 사용자들은 여전히 동일한 프로모션 정보를 받게 된다.

비즈니스 영향: 프로모션 정보가 변하지 않기 때문에, 일관성 문제가 발생해도 실질적으로 사용자 경험에 영향을 주지 않는다.

 

case 2. 캐시 데이터의 일시적인 불일치가 발생할 경우 

상황: 캐시가 만료되기 전에 프로모션 정보가 갱신되면, 새로운 정보가 캐시에 반영되기 전까지 일부 사용자들은 이전 정보를 볼 수 있다.

일관성 문제: 프로모션 정보가 캐시에 전파되는 데 약간의 시간이 걸리지만, 그 후 캐시가 갱신되면 모든 사용자가 동일한 정보를 받게 된다.

비즈니스 영향: 프로모션 정보가 크게 변하지 않기 때문에, 캐시 갱신 지연으로 인해 일시적인 불일치가 발생해도 사용자들에게 큰 영향은 없을 것이라 생각된다. 

결론

프로모션 데이터가 일정 기간 동안 변하지 않는 경우, 데이터 일관성이 약간 깨져도 비즈니스에 큰 영향을 미치지 않는다. 발생 경우를 고려해 보았을 때, 글로벌 캐시보다 로컬 캐시를 사용하는 것이 적합하다고 판단했다. 로컬 캐시를 사용하면 네트워크 지연을 최소화하고 빠른 응답을 제공할 수 있어 사용자 경험을 향상할 수 있는 이점이 있다. 

 

스프링 부트 구현

캐시를 적용하기 전에, 스프링 부트에서 기본적으로 사용하는 캐시 관리자가 어떤 특정 라이브러리의 클래스를 사용하는지 확인해 보자.

스프링 부트에서 캐시 기본값 확인

import org.springframework.cache.annotation.EnableCaching;

@EnableCaching
@SpringBootApplication
public class DevtoonApplication {

    @Autowired
    CacheManager cacheManager;

    public static void main(String[] args) {
        SpringApplication.run(DevtoonApplication.class, args);
    }

    @PostConstruct
    public void init() {
        System.out.println(cacheManager.getClass());
        System.out.println(cacheManager.getClass().getName());
        System.out.println(cacheManager.getClass().getSimpleName());
    }
}

==========================================================================
// 출력
class org.springframework.cache.concurrent.ConcurrentMapCacheManager
org.springframework.cache.concurrent.ConcurrentMapCacheManager
ConcurrentMapCacheManager

스프링 부트에서는 기본적으로 ConcurrentMapCacheManager를 사용하여 캐시를 관리한다. 이 클래스는 스프링의 기본 캐시 관리자로, 단순히 JVM의 메모리를 사용하여 캐시를 저장한다.

 

스프링부트에서 지원하는 주요 캐시 라이브러리

ConcurrentHashMap

스프링 부트에서 기본적으로 제공되는 ConcurrentMapCacheManager는 내부적으로 ConcurrentHashMap을 사용하여 캐시를 관리한다.

1️⃣ 장점

추가적인 라이브러리 없이 Java 표준 라이브러리만으로 사용할 수 있고 메모리와 CPU 오버헤드가 낮다

2️⃣ 단점

캐시 만료, 캐시 크기 제한 등 캐시 관리와 관련된 기능이 거의 없기 때문에, 개발자가 직접 이러한 부분을 처리해야 한다는 단점이 존재한다. 

 

 JCache (JSR-107)

Java Caching 표준 API. 여러 캐시 구현체와 통합하여 사용할 수 있으며, Spring Boot에서 JCache 지원을 통해 다양한 캐시 라이브러리와 연동할 수 있다.

 

 EhCache

의존성 필요

implementation 'org.ehcache:ehcache:3.10.8'

 

• Caffeine

고성능 메모리 캐시 라이브러리로 높은 성능과 낮은 지연 시간을 제공하며, Google Guava의 캐시 라이브러리를 대체하는 용도로 사용한다. 의존성 필요하다.

implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'

 

• Redis

• Memcached

 

그렇다면 EhCache, Caffeine 로컬 캐시에서는 캐시 관리자가 어떤 클래스를 사용할까?

EhCache는 JCache 표준 API(JCache (JSR-107))를 구현한 라이브러리이다.

class org.springframework.cache.jcache.JCacheCacheManager

 

Caffeine

class org.springframework.cache.caffeine.CaffeineCacheManager

 

이 클래스들은 각각 EhCache와 Caffeine의 캐시 관리 역할을 한다. 스프링 부트는 이러한 캐시 매니저 클래스를 통해 캐시를 관리하고, 캐시 라이브러리를 사용할 수 있도록 지원한다.

 

그럼, 둘 다 적용 시 우선순위가 있을까?

EhCache가 우선순위가 높다. EhCache가 JCache 표준 API를 구현한 라이브러리이기 때문이다. Spring Boot에서는 캐시 라이브러리를 자동으로 구성할 때 JCache 구현체가 존재하면 이를 우선적으로 사용한다.

 

프로젝트 적용

간단하게 캐시를 프로젝트에 적용해 보고, 사례를 통해 캐시가 어떻게 저장되는지 알아보자.

@EnableCaching

캐시 기능을 활성화하려면 @EnableCaching 애노테이션을 추가한다.

@EnableCaching
@SpringBootApplication
public class DevtoonApplication {
    public static void main(String[] args) {
        SpringApplication.run(DevtoonApplication.class, args);
    }
}

 

@Cacheable

캐시를 사용할 메서드에 @Cacheable 애노테이션을 추가한다. 여기서는 메모리 상에 "promotion"이라는 이름의 해시 테이블을 만들고, 데이터를 저장한다.

@Cacheable(value = “promotion”)
public Promotion get(Long id) {
	return promotionRepository.findById(id).
			... ... ...
}

 

캐시 사용 사례

case 1. 기본 사용

@Cacheable(value = "promotion")
public Promotion getPromotionById(Long id) {
    // ...
}

 

캐시 구조

{
    "promotion": {
        "임의키1": "data1",
        "임의키2": "data2",
        "임의키3": "data3"
    }
}

 

case 2. 키 사용

@Cacheable(value = "promotion", key = "#id")
public Promotion getPromotionById(Long id) {
    // ...
}

 

캐시 구조

{
    "promotion": {
        "1": "data1",
        "2": "data2",
        "3": "data3"
    }
}

 

캐시 대상

현재 적용가능한 모든 프로모션

 

기존 API 수정 

1️⃣ 프로모션 등록 api 수정

프로모션 등록 시 캐시 업데이트

 

2️⃣ 활성화된 모든 프로모션 조회 api 수정

1. 현재~미래 프로모션 DB조회 후 캐시 저장

2. 현재 프로모션만 남게 필터링해서 반환

 

사전 데이터 밀어 넣기

테스트의 편의성을 위해 과거, 현재, 미래 프로모션 데이터를 밀어 넣었다. (data.sql)

INSERT ~ SELECT : 만약 해당 데이터가 이미 저장되어 있다면, 중복 저장 x

더보기
-- 프로모션 데이터 삽입
-- 프로모션 1 (과거 프로모션)
INSERT INTO promotion (description, discount_type, discount_quantity, is_discount_duplicatable, start_date, end_date, created_at, updated_at)
SELECT '프로모션 1', 'COOKIE_QUANTITY_DISCOUNT', 3, TRUE, '2024-04-01 00:00:00', '2024-05-01 00:00:00', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM promotion WHERE description = '프로모션 1');

-- 프로모션 2 (과거 프로모션)
INSERT INTO promotion (description, discount_type, discount_quantity, is_discount_duplicatable, start_date, end_date, created_at, updated_at)
SELECT '프로모션 2', 'COOKIE_QUANTITY_DISCOUNT', 2, FALSE, '2024-03-01 00:00:00', '2024-03-31 00:00:00', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM promotion WHERE description = '프로모션 2');

-- 프로모션 3 (과거 프로모션)
INSERT INTO promotion (description, discount_type, discount_quantity, is_discount_duplicatable, start_date, end_date, created_at, updated_at)
SELECT '프로모션 3', 'COOKIE_QUANTITY_DISCOUNT', 4, TRUE, '2024-02-01 00:00:00', '2024-02-28 00:00:00', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM promotion WHERE description = '프로모션 3');

-- 프로모션 4
INSERT INTO promotion (description, discount_type, discount_quantity, is_discount_duplicatable, start_date, end_date, created_at, updated_at)
SELECT '프로모션 4', 'COOKIE_QUANTITY_DISCOUNT', 10, TRUE, '2024-05-01 00:00:00', '2024-06-01 00:00:00', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM promotion WHERE description = '프로모션 4');

-- 프로모션 5
INSERT INTO promotion (description, discount_type, discount_quantity, is_discount_duplicatable, start_date, end_date, created_at, updated_at)
SELECT '프로모션 5', 'COOKIE_QUANTITY_DISCOUNT', 5, FALSE, '2024-05-15 00:00:00', '2024-06-15 00:00:00', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM promotion WHERE description = '프로모션 5');

-- 프로모션 6
INSERT INTO promotion (description, discount_type, discount_quantity, is_discount_duplicatable, start_date, end_date, created_at, updated_at)
SELECT '프로모션 6', 'COOKIE_QUANTITY_DISCOUNT', 7, TRUE, '2024-05-01 00:00:00', '2024-06-01 00:00:00', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM promotion WHERE description = '프로모션 6');

-- 프로모션 7 (미래 프로모션)
INSERT INTO promotion (description, discount_type, discount_quantity, is_discount_duplicatable, start_date, end_date, created_at, updated_at)
SELECT '프로모션 7', 'COOKIE_QUANTITY_DISCOUNT', 8, FALSE, '2024-07-01 00:00:00', '2024-08-01 00:00:00', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM promotion WHERE description = '프로모션 7');

-- 프로모션 8 (미래 프로모션)
INSERT INTO promotion (description, discount_type, discount_quantity, is_discount_duplicatable, start_date, end_date, created_at, updated_at)
SELECT '프로모션 8', 'COOKIE_QUANTITY_DISCOUNT', 6, FALSE, '2024-08-01 00:00:00', '2024-09-01 00:00:00', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM promotion WHERE description = '프로모션 8');

-- 프로모션 9 (미래 프로모션)
INSERT INTO promotion (description, discount_type, discount_quantity, is_discount_duplicatable, start_date, end_date, created_at, updated_at)
SELECT '프로모션 9', 'COOKIE_QUANTITY_DISCOUNT', 9, TRUE, '2024-09-01 00:00:00', '2024-10-01 00:00:00', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM promotion WHERE description = '프로모션 9');

-- 프로모션 10 (미래 프로모션)
INSERT INTO promotion (description, discount_type, discount_quantity, is_discount_duplicatable, start_date, end_date, created_at, updated_at)
SELECT '프로모션 10', 'COOKIE_QUANTITY_DISCOUNT', 1, TRUE, '2024-11-01 00:00:00', '2024-12-01 00:00:00', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
WHERE NOT EXISTS (SELECT 1 FROM promotion WHERE description = '프로모션 10');

 

 

성능 테스트

성능 측정 방법

1. 적용 순서: 기존 코드, 로컬 캐시 적용(Caffeine), 글로벌 캐시 적용(Redis)

2. 성능 측정 도구: Locust로 부하 테스트 진행

[공통 적용]
유저 수: 3000,
ramp up: 300,
처리 시간: 1m 

 

[여기서 잠깐] Redis 사용에 대한 설명은 다소 부족했던 것 같아 추가적으로 정리하고 넘어가고자 한다.

더보기

모놀리식 아키텍처에서 성능 최적화를 위해 Spring Boot Redis를 선택했다. 이는 글로벌 Redis 보다 다음과 같은 장점이 있다.

• 설정 파일(application.properties 또는 application.yml)을 통해 간편하게 Redis 설정 가능

Spring Boot의 캐시 추상화를 사용하여 쉽게 사용 가능

단일 애플리케이션 내에서의 데이터 접근 속도 향상

 

Spring Data Redis

의존성 주입 spring-boot-starter-date-redis

 

Redis Clients

1. Lettuce 
2. Jedis

 

RedisTemplate 객체 특징 3가지

abstraction: 추상화

connection: 연결 관리

serializer: 데이터 저장, 가져올 때 변환하는 직렬화

 

진행한 순서 

1. application.yml 설정 및 RedisTemplate @Bean 등록

 

2. 도커에서 redis pull 

docker pull redis
docker run --name redis -d -p 6379:6379 redis
docker ps

 

3. redis에 잘 들어갔는지 확인

docker ps로 redis CONTAINER ID 확인

docker exec -it [CONTAINER ID] redis-cli monitor

Redis에 저장된 key 지우기 (TTL 설정하지 않으면 redis에 영구저장 됨)

docker exec -it 9088bc87c03f redis-cli
unlink [key]
  예) unlink promotion:active:list
exit

 

4. 애플리케이션 실행 후 Postman으로 조회 api 여러 번 요청

 

 5. redis cli 확인

"SETEX" "promotion:active:list"

지금은 전체 리스트를 하나의 키로 저장하고 있다.

나중에 해당 키를 GET 할 때 시간 복잡도는 O(1) 이다. Redis의 GET 명령어는 키에 저장된 값을 바로 반환하기 때문이다. 하지만 이는 데이터의 크기에 따라 메모리 사용량이 증가할 수 있다.

(효율적으로 저장하는 방법 중 하나는 각 프로모션 항목을 별도의 키로 저장하는 것인데 이렇게 하면 특정 프로모션 항목만 갱신하거나 삭제할 수 있어 유연성이 높아지고, 데이터를 보다 구조적으로 관리할 수 있는 이점이 있다.)

 

6. 그 후 조회 api 여러번 요청 시 GET만 나옴 → GOOD

7. 성능 테스트 진행http://localhost:8089/ 접속해서 진행

locust -f promotion_read.py

 

프로모션 조회 성능 테스트

목표: 1.xx 초

1️⃣ 아무것도 적용 안 했을 때

평균 응답 시간: 2.80s

PromotionSerivce 코드

더보기
/**
 * 현재 적용 가능한 모든 프로모션 조회
 * : 프로모션만 조회합니다. 프로모션이 없는 경우 빈 리스트를 반환합니다.
 */
@Transactional(readOnly = true)
public List<PromotionEntity> retrieveActivePromotions() {
    List<PromotionEntity> promotions = fetchAndCachePromotions();
    return promotions;
}

private List<PromotionEntity> fetchAndCachePromotions() {
    List<PromotionEntity> promotions = retrieveCurrentOrFuturePromotion();
    return filterCurrentPromotions(promotions);
}

private List<PromotionEntity> retrieveCurrentOrFuturePromotion() {
    LocalDateTime currentTime = LocalDateTime.now();
    List<PromotionEntity> promotions =
            promotionRepository.findCurrentOrFuturePromotions(currentTime);

    if (promotions.isEmpty()) {
        return Collections.emptyList();
    }
    return promotions;
}

private List<PromotionEntity> filterCurrentPromotions(final List<PromotionEntity> currentAndFuturePromotions) {
    LocalDateTime currentTime = LocalDateTime.now();
    return currentAndFuturePromotions.stream()
            .filter(p -> p.isCurrent(currentTime))
            .toList();
}

 

2️⃣ 로컬 캐시: Caffeine

평균 응답 시간: 0.14s

PromotionSerivce 코드

더보기
private final PromotionCacheService promotionCacheService;

 /**
 * 프로모션 등록
 */
@Transactional
public void register(final PromotionCreateRequest request) {
    PromotionEntity promotion = PromotionEntity.create(
            request.getDescription(),
            request.getDiscountType(),
            request.getDiscountRate(),
            request.getDiscountQuantity(),
            request.getIsDiscountDuplicatable(),
            request.getStartDate(),
            request.getEndDate()
    );
    PromotionEntity savedPromotion = promotionRepository.save(promotion);

    List<PromotionAttributeCreateRequest> promotionAttributeCreateRequests =
            request.getPromotionAttributes();

    promotionAttributeCreateRequests.stream()
            .map(promotionAttributeRequest -> toEntity(savedPromotion,
                    promotionAttributeRequest))
            .forEach(promotionAttributeRepository::save);
            
    // 추가된 부분
    promotionCacheService.updatePromotionInCache(savedPromotion);
}

@Transactional(readOnly = true)
@Cacheable(value = "promotion", key = "'active'")
public List<PromotionEntity> retrieveActivePromotions() {
    List<PromotionEntity> promotions = fetchAndCachePromotions();
    return promotions;
}

private List<PromotionEntity> fetchAndCachePromotions() {
    List<PromotionEntity> promotions = retrieveCurrentOrFuturePromotion();
    return filterCurrentPromotions(promotions);
}

private List<PromotionEntity> retrieveCurrentOrFuturePromotion() {
    LocalDateTime currentTime = LocalDateTime.now();
    List<PromotionEntity> promotions =
            promotionRepository.findCurrentOrFuturePromotions(currentTime);

    if (promotions.isEmpty()) {
        return Collections.emptyList();
    }
    return promotions;
}

private List<PromotionEntity> filterCurrentPromotions(final List<PromotionEntity> currentAndFuturePromotions) {
    LocalDateTime currentTime = LocalDateTime.now();
    return currentAndFuturePromotions.stream()
            .filter(p -> p.isCurrent(currentTime))
            .toList();
}

3️⃣ Spring Boot Redis

평균 응답 시간: 2.2s

PromotionService

더보기
private static final String CACHE_KEY = "promotion:active:list";
private static final Duration CACHE_DURATION = Duration.ofHours(24);

private final RedisTemplate<String, List<PromotionEntity>> promotionRedisTemplate;

/**
 * 현재 적용 가능한 모든 프로모션 조회
 * : 프로모션만 조회합니다. 프로모션이 없는 경우 빈 리스트를 반환합니다.
 */
@Transactional(readOnly = true)
public List<PromotionEntity> retrieveActivePromotions() {
    List<PromotionEntity> promotions = getCachedPromotions();
    if (promotions == null) {
        promotions = fetchAndCachePromotions();
    }
    return promotions;
}

/**
 * 캐시에서 프로모션 조회
 */
private List<PromotionEntity> getCachedPromotions() {
    List<PromotionEntity> cachedPromotions =
            promotionRedisTemplate.opsForValue().get(CACHE_KEY);
    if (cachedPromotions != null) {
        return filterCurrentPromotions(cachedPromotions);
    }
    return null;
}

/**
 * DB에서 프로모션 조회 후 캐시에 저장
 */
private List<PromotionEntity> fetchAndCachePromotions() {
    List<PromotionEntity> promotions = retrieveCurrentOrFuturePromotion();
    promotionRedisTemplate.opsForValue().set(CACHE_KEY, promotions, CACHE_DURATION);
    return filterCurrentPromotions(promotions);
}

private List<PromotionEntity> retrieveCurrentOrFuturePromotion() {
    LocalDateTime currentTime = LocalDateTime.now();
    List<PromotionEntity> promotions =
            promotionRepository.findCurrentOrFuturePromotions(currentTime);

    if (promotions.isEmpty()) {
        return Collections.emptyList();
    }
    return promotions;
}

private List<PromotionEntity> filterCurrentPromotions(final List<PromotionEntity> currentAndFuturePromotions) {
    LocalDateTime currentTime = LocalDateTime.now();
    return currentAndFuturePromotions.stream()
            .filter(p -> p.isCurrent(currentTime))
            .toList();
}

 

 

전략별 성능 

1️⃣ 아무것도 적용하지 않은 경우

평균 응답 시간: 2802ms

초당 요청 처리량: 958.99 RPS

 

2️⃣ 로컬 캐시(Caffeine) 적용

평균 응답 시간: 142ms

초당 요청 처리량: 4953.21 RPS

 

3️⃣ Spring Boot Redis 캐시 적용

평균 응답 시간: 2282ms

초당 요청 처리량: 1176.93 RPS

 

전략별 성능 비교

아무것도 적용하지 않은 경우와 비교:

로컬 캐시 (Caffeine): 평균 응답 시간 94.93% 감소, 초당 요청 처리량 416.23% 증가.

Redis 캐시: 평균 응답 시간 18.57% 감소, 초당 요청 처리량 22.75% 증가.

 

Redis 캐시와 비교:

로컬 캐시 (Caffeine): 평균 응답 시간 93.77% 감소, 초당 요청 처리량 320.81% 증가.

 

결론

DB에 총 10개의 프로모션(과거: 3개, 현재: 3개, 미래: 4개)이 있는 상황에서 테스트를 진행했다.

로컬 캐시인 Caffeine과 Spring Boot Redis를 비교한 결과, Caffeine의 성능이 더 우수했다. 로컬 캐시는 애플리케이션 서버의 메모리 내에서 직접 데이터를 제공하므로 네트워크 오버헤드가 없고 접근 속도가 매우 빠르다. 반면, Redis는 네트워크를 통해 데이터를 접근하므로 네트워크 지연과 I/O 오버헤드가 발생하기 때문이다.

Redis는 메모리 기반의 캐시로 큰 데이터셋을 처리할 때 성능이 크게 향상될 수 있는데 애플리케이션 요구사항상 프로모션은 대량으로 등록될 일이 없고, 현재 모놀리스 아키텍처를 사용하고 있으므로 Caffeine을 선택하는 것이 더 나은 선택이라고 생각했다. 만약 추후 MSA를 적용할 경우, 데이터 일관성 유지와 다양한 자료구조를 지원하는 Redis를 고려할 계획이다.

 

추가적으로 도입해 볼 방안

쿼리 최적화

  • a. 필요한 데이터만 join
  • b. 풀 스캔 여부 확인 및 파티셔닝 (예: 월 별로)
  • c. 읽기, 쓰기 DB 분리
  • d. 인덱스 조정 (실행 계획 확인 - EXPLAIN 명령어 사용)

 

회고

만족한 점

이번 프로젝트에서는 모든 과정에 이유를 두고 진행했다. 단순히 "캐시가 좋으니까" 또는 "다들 도입해 보니까"가 아니라, 현재 프로젝트에 왜 캐시가 필요한지를 먼저 고민했다. 캐시의 종류를 분석하고, 적합한 것을 선택하여 성능 테스트를 진행했다. 그 후 나름대로의 결론을 내어본 점도 만족스럽다. 목차를 잡고 진행한 결과, 기술적으로도 글쓰기 면에서도 체계적으로 접근할 수 있었다.

01
캐시 관련 PR

아쉬운 점

많은 고민을 하고 진행했기 때문에 현 단계에서는 최선의 선택이었다고 생각하지만, 캐시 외의 다른 방법들도 고려하고 다양한 경우의 수를 테스트해 볼 수 있었다면 더 좋았을 것이다. 그리고 성능 테스트 툴 Locust를 사용할 때, 유저 수, ramp up, 지속 시간을 설정하는 기준이 모호했다. 현직자분께 조언을 구했을 때, 현업에서는 회사의 DAU, MAU를 기준으로 설정한다고 하셨다. 나의 경우, 토이 프로젝트이므로 대략적인 수치로 테스트를 진행했다. 만약 면접에서 어떤 기준으로 설정했는지에 대한 질문이 들어온다면 솔직하게 설명하고, 현업에서는 DAU, MAU를 참고한다고 말씀드릴 예정이다.

 

다음에는 이렇게

추가적으로 도입해 볼 방안에서 적었듯 쿼리 최적화에도 도전해보고 싶다. 그리고 프로젝트 내 모든 조회 API에 대해 성능 테스트를 해 볼 계획이다. 캐시 이외의 다른 방법들도 팀원과 논의하여 진행할 생각이다. 여러 프로젝트로 나눠서 진행하는 것도 좋겠지만, 한 프로젝트를 깊이 있게 키워나가는 것이 경험 면에서도 좋을 것 같고, 애정을 가지고 개발할 수 있을 것 같아 데브툰 프로젝트를 계속 이어서 진행할 예정이다.