원격 웨이팅 서비스에서 가장 많이 동작하는 API는 무엇일까? 놀랍게도 조회 API이다. 하루에도 수천 명의 사용자는 수십 개의 매장을 살펴보며 메뉴를 구경하고, 마음에 드는 곳을 발견하면 웨이팅을 걸고는 한다. 라인업지도 그랬다.

만약, 서비스를 접속했는데 주점 목록을 가져오는 데 수 초 이상이 걸린다면 어떻게 될까? 물론 서비스 사용자 수가 적을 때에는 문제가 없겠지만, 축제 기간에 수 백~수 천 명의 사용자가 초 단위로 접속하면 당연히 DB에는 부하가 갈 것이고, 부하가 가면 랜딩 페이지를 가져오는 데에는 오랜 시간이 걸릴 것이다.
사람들이 웹사이트를 처음 보고 해당 웹사이트가 어떤지 평가하는 데 걸리는 시간은 50m/s 이내(20분의 1초)라고 한다.
따라서 랜딩 페이지에서 데이터 로딩이 지연되는 불상사를 막고자, ElastiCache를 통해 캐싱을 도입하는 것이 필수적이라고 판단했다.
캐싱 전략에 대한 고찰
캐싱을 하기 위한 API는 다음과 같다. 랜딩 페이지의 조회 API에 대한 응답 DTO를 모두 캐싱해 주면 성능적으로 최상의 결과를 얻을 수 있었겠지만, 안타깝게도 서비스 특성상 특정 주점에 어느 정도의 대기가 있는지를 실시간으로 알려주어야 했기 때문에 어떤 방식으로 캐싱을 진행할지에 대한 고찰을 진행했다.
- 주점 전체 조회
- 불변한 값: 주점 썸네일, 주점 이름, 메뉴 등등..
- 강한 정합성을 필요로 하는 값: 현재 대기 팀 수
- 주점 상세 조회
- 불변한 값: 주점 썸네일, 주점 이름, 메뉴, 주점 한줄 소개 등등..
- 강한 정합성을 필요로 하는 값: 현재 대기 팀 수, 주점 영업 상태, 주점 대기 상태
위에서도 언급했듯이 여기서 문제가 되는 부분은 강한 정합성을 요구하는 값들에 대한 캐싱인데, 불변한 값인 무형 객체와 정합성을 요구하는 값들에 대한 캐싱 전략을 다르게 세워야 한다는 것이 핵심이었다.
불변한 값들의 경우, 다양한 캐싱 전략 중 Write Around + Look Aside의 캐싱 방식을 도입하면 된다.
보다 자세한 캐싱 전략에 대해서는 애용하는 인파님의 블로그를 참고하면 해당 글을 이해하는 데 있어서 더욱 도움이 될 것이다!
[REDIS] 📚 캐시(Cache) 설계 전략 지침 💯 총정리
Redis - 캐시(Cache) 전략 캐싱 전략은 웹 서비스 환경에서 시스템 성능 향상을 기대할 수 있는 중요한 기술이다. 일반적으로 캐시(cache)는 메모리(RAM)를 사용하기 때문에 데이터베이스 보다 훨씬 빠
inpa.tistory.com
Write Around + Look Aside 전략을 선택한 이유는 다음과 같다. 주점 이름, 메뉴, url 같은 값들은 축제가 끝날 때까지 거의 바뀌는 경우가 드물 것이라고 판단했기 때문이다. 즉, 해당 값들에 대한 쓰기 작업은 서비스 운영 기간에 거의 발생하지 않고, 반복되는 읽기 작업만 발생하는 것이다.
하지만 강한 정합성을 요구하는 값들의 경우, 서비스 운영 기간 동안 엄청난 읽기/쓰기 작업이 동반되며 캐시 스토어(Redis)와 DB 간 완벽한 정합성이 유지되어야 한다.
따라서 캐시에서만 데이터를 읽어오는 전략인 Read Through 패턴을 적용하는 것이 적절하지만, AuroraDB와 Elasticache 간 구현이 기술적으로 어렵다는 단점이 존재했다. 이를 DynamoDB Accelerator(DAX)를 통해 해결할 수 있지만, NoSQL을 통해 구현해주어야 했기 때문에 시간 관계상 적용하지는 못하였다. Read Through 패턴의 도입을 주저한 또다른 이유는 해당 방식의 치명적인 단점인 캐시 스토어가 다운되면 서비스도 같이 다운된다는 점을 우려했기 때문이다..
고심 끝에, 서비스 운영 기간 내에 불변한 값들에 한해 캐싱을 적용하는 것으로 가닥을 잡고, 그렇지 않은 값들은 쿼리 조회를 통해 진행하도록 결정하였다.
구현
CacheConfig

분산 락을 구현해주기 위해 사용한 Redisson 클라이언트를 그대로 사용하여 Redis 기반의 CacheManager를 구현해 주었다. 서비스 정책 상 주점 썸네일과 메뉴에 한해 캐싱을 적용해 주었기 때문에, 이를 각각 담당하는 두 개의 CacheManager 빈을 생성해 주었다.
각 CacheManager는 지정된 키를 가지고 있는 전달받은 객체들을 캐시 스토어에 저장하는 책임을 지닌다. TTL 같은 경우, 서비스의 피크 타임이 오후 4시부터 9시까지 약 5시간 정도라는 것을 감안하여 5시간으로 설정해 주었다.
그렇다면 이제 캐싱을 서비스 로직에서 진행해주어야 할까?
@Cacheable
왜 썸네일과 메뉴만 캐싱해 주었을까?
라인업지의 서비스 로직은 리포지토리에서 필요한 값들을 객체에서 가져온 후, DTO를 생성하여 반환해 준다. 이에 주점 엔티티 안에 '현재 대기 팀 수' 필드가 존재하였기에, 해당 값만 제외한 다른 필드들에 대한 캐싱 작업을 진행해 줄 수는 없었다. 따라서 별도의 테이블로 존재하는 썸네일, 메뉴 엔티티에 대해서만 캐싱을 진행해 줄 수밖에 없었다.

@Cacheable 어노테이션은 다음과 같은 옵션을 설정해 줄 수 있다.
- key:캐시의 Key를 정의한다.
- value: CacheManager에서 지정해 준 Key와 매핑되는 캐시의 이름을 지정한다.
- cacheManager:어떤 CacheManager를 사용할지 지정한다.
각 값들이 주점의 이름을 캐시 키로 사용함에 따라, 동일한 주점의 이름에 대해 해당 메서드가 호출되면 첫 캐싱 이후부터는 DB의 조회 없이 즉시 캐시에서 바로 반환된다. 이때 최초의 캐싱 과정은 해당 메서드에 설정된 value 속성(캐시 이름)에 대응하는 CacheManager가 처리한다.
이제 메뉴와 썸네일은 한 번 캐싱되면 이후 요청 시 DB 조회 없이 즉시 캐시된 값을 반환하게 됩니다. 그러나 실제 운영 과정에서 메뉴 재고 소진 등으로 인해 메뉴 삭제 또는 변경이 필요할 수 있고, 실제로도 그랬다. 이를 어떻게 대처해 주었을까? @CacheEvict를 활용해, 캐시 된 데이터가 더 이상 유효하지 않으므로 캐시 스토어에 저장된 특정 데이터를 삭제해주어야 한다.
@CacheEvict.. 근데 @Caching을 곁들인

이를 구현해 주기 위해 @CacheEvict 어노테이션을 활용할 수 있다. 해당 메서드의 경우, 주점 정보 수정 로직이 썸네일과 메뉴 두 값을 모두 수정할 수 있었기에 @Caching 어노테이션을 통해 여러 캐시를 한 번에 비우는 전략을 택했다. 여기서 'value' 옵션으로 지정한 캐시(menuCache, thumbnailsCache)에 대해 'allEntries = true' 옵션을 통해 해당 캐시 내 모든 항목을 제거해 주었다. 이를 통해 해당 주점 정보가 수정된 이후 메뉴나 썸네일 정보를 요청하면, 캐시 된 값이 없으므로 DB에서 최신 데이터를 조회하여 캐시를 갱신하고, 값을 반환하게 된다.
아쉬웠던 부분
위 코드에서 @CacheEvict를 적용할 때 특정 주점의 값이 아닌 모든 값을 삭제한 이유는, 해당 메서드에서 캐시를 제거할 때 필요한 주점의 이름(pubName)을 얻을 수 없었기 때문이었다.
일반적으로 @CacheEvict에서는 key 속성을 통해 특정 키에 해당하는 캐시만 제거할 수 있다. 예를 들어, 캐시 키로 'pub.pubName'을 사용하고 있기에, 이를 매개변수나 실행 컨택스트에서 쉽게 가져올 수 있다면 해당 값에 해당하는 캐시 데이터만 삭제할 수 있다.
하지만 해당 코드는 메서드 실행 시점에 '주점 이름'을 가져올 수 없어서 'allEntries = true' 옵션을 사용하여 해당 캐시에 속한 모든 엔트리를 삭제하는 전략을 택할 수밖에 없었다. 따라서 하나의 주점이 값을 수정하더라도 모든 주점 정보에 대한 캐싱 데이터가 삭제되었기 때문에, 특정 주점이 정보를 수정하면 캐시 힛 비율이 순간적으로 낮아지는 현상을 목격할 수밖에 없었다.
느낀 점
GTB-86 [feat] 캐싱 적용 by jwnnoh · Pull Request #108 · Gachon-Table/GachonTable-BE
1. 무슨 이유로 코드를 변경했나요? 캐싱을 적용하기 위함입니다. 캐싱 전략에 대한 고찰..🥺 결국 캐싱을 하기 위한 API는 다음과 같아요. 주점 전체 조회 불변한 값: pubId, thumbnails, pubName, oneLiner
github.com
캐싱을 이번 기회에 처음으로 적용해 보았는데, 덕분에 중요한 조회 로직에 대한 전반적인 성능 향상을 이끌어낼 수 있었다! 물론 시간 관계상 높은 완성도를 지닌 캐싱 전략을 구현하지는 못하였지만, 주어진 시간 내에서 할 수 있는 최선의 방안을 택해 유의미한 결과를 낼 수 있어서 나름 만족할 수 있었다.
하지만 분명 보다 좋은 캐싱 전략이 있을 것이라고 생각한다. 주점 정보를 수정할 때 모든 캐싱 데이터가 삭제되는 것도 어떻게 보면 무척 치명적인 이슈이고, 메뉴와 썸네일에 대해서만 캐싱을 적용했기에 결국 랜딩 페이지에서는 다양한 값들을 가져오기 위해 DB 쿼리가 필수적으로 동반된다는 점 또한 개인적으로는 아쉽게 느껴진다. 항상 그렇듯이, 꾸준한 공부와 노력을 통해 추후에는 서비스 정책에 최적화된 개선된 캐싱 전략을 도입할 수 있도록 성장하고자 한다.
참고 자료
[REDIS] 📚 캐시(Cache) 설계 전략 지침 💯 총정리
Redis - 캐시(Cache) 전략 캐싱 전략은 웹 서비스 환경에서 시스템 성능 향상을 기대할 수 있는 중요한 기술이다. 일반적으로 캐시(cache)는 메모리(RAM)를 사용하기 때문에 데이터베이스 보다 훨씬 빠
inpa.tistory.com
How to Use Redis Cache in Java | Redisson
Multiple implementations of Redis Cache usage in Java
redisson.org
'스프링 부트 > Java' 카테고리의 다른 글
AuroraDB와 @Transactional 어노테이션을 통한 간단한 CQRS 구현하기 (0) | 2024.12.10 |
---|---|
@Valid 검증 예외 처리를 통한 공통 응답 보내기 (2) | 2024.12.03 |
AOP 로깅을 통한 효율적인 로깅 로직 구현하기 (1) | 2024.11.27 |
[JPA] 양방향 매핑을 지양해야 하는 이유 (1) | 2024.11.19 |
스프링 이벤트와 비동기 처리를 통한 알림톡 발송 성능 개선하기 (0) | 2024.11.13 |