본문 바로가기
🌱 Spring

Cache를 적용하여 Read API 기능을 개선해보자 (1) 이론학습

by iirin 2023. 11. 24.
이 글은 당근마켓을 모티브로 한 프로젝트 Secondhand 구현시 이슈 사항을 정리한 글입니다.

 

사실 처음 시작은 조회수 구현을 어떻게 효율적으로 할 수 있을까에서 출발하였습니다. [조회수 관련해서 참고한 블로그 글] 세션별로 중복되지 않게 일정 시간동안 1회씩 카운팅하고 싶은 욕심이었습니다.

그런데 학습을 하다보니 레디스 Cache를 읽기 API에서도 사용해보고 싶더라고요🔥 API 성능을 개선해본 뒤 차근차근 조회수도 개선해보도록 하겠습니다.

 

 

Cache에 대하여

캐시 Cache는 자주 사용하는 연산에 대하여 속도가 빠른 임시 공간에 저장해두어 애플리케이션 연산 속도를 높일 수 있습니다.

애플리케이션 연산 속도는 DB I/O작업을 얼마나 줄이느냐에 비례하는데요. 캐시를 사용해서 DB는 물론, 어플리케이션 연산도 skip할 수가 있습니다.

Cache는 나중에 요청을 위해 결과를 미리 저장해두었다가 빠르게 서비스 해주는 것을 의미합니다.
- 우아한레디스 (강대명)

 

애플리케이션이 Database(Disk)에 접속하여 데이터를 읽어오는 것보다는 자주 읽는 데이터를 애플리케이션이 빠르게 접근할 수 있는 메모리에 올려두고 빠르게 가져와 사용하는 것이지요. 창고에 있는 데이터를 책상에 올려두는 것과 같습니다.

비단 읽는 것뿐만 아니라 쓰기작업 혹은 연산 작업에도 적용됩니다. 다이나믹 프로그래밍을 생각해보세요. 중간 연산 결과를 계속 캐싱하여 다음 연산을 실행한다면 매번 처음부터 연산을 하는 것보다 훨씬 빠를 겁니다.

이 글에서는 Redis를 이용해서 cache를 구현합니다. 하지만 캐시 구현에 가장 손쉬운 방법은 ConcurrentHashMap 과 같은 자료구조를 이용해 로컬 인메모리로 구현하는 것일 수 있습니다.

 

 

헷갈리는 Cache와 Buffer의 차이

대부분 두 용어의 차이점을 정확하게 알지 못하기 때문에 종종 이 용어를 횬용해서 쓰기도 해서 간단하게 짚고 넘어가겠습니다. (제 얘기)

사실 엄밀히 말하자면 다른 의미를 갖고 있습니다.

 

공통점

캐시와 버퍼는 둘 다 데이터를 임시로 저장하는 데 사용됩니다.

차이점

Buffer는 전통적으로 빠른 속도의 장치와 느린 속도의 장치 사이에서 데이터를 일시적으로 보관하는 데 사용합니다. 빠른 속도의 장치에서 느린 속도의 장치로 데이터를 보낼 때 데이터의 유실, 손실 상황을 막기 위함입니다. 느린 속도의 장치가 이 버퍼 데이터를 받을 때는 한번에 받도록 하여 이 속도 차이를 완화시킵니다. 그러다보니 데이터는 버퍼에서 한 번만 쓰거나 읽을 수 있습니다.

예를 들어 컴퓨터에서 입력장치인 키보드의 데이터를 CPU가 받는다고 할 때, 키보드 버퍼에 데이터를 잠시 저장하거나 네트워킹에서 다른 장치로 데이터가 이동할 때 잠시 저장해두는 용도로 사용할 수 있습니다.

 

`Cache` 의 경우, 성능 향상에 목적이 있습니다. 캐시는 자주 사용하는 정보, 데이터(값비싼 연산결과나 자주 참조되는 데이터)를 메모리에 올려두고 사용할 수 있는 저장소입니다.

디스크 I/O보다 캐시 메모리에 올라간 데이터를 읽는 것이 훨씬 빠르기 때문입니다.

버퍼와 달리 데이터가 한번 읽고 쓰는 것으로 끝나지 않고, 일정 시간동안 계속 있으면서 반복적인 작업에 사용됩니다.

마치 책상 위에 자주 쓰는 필기구와 노트를 가져다 놓는 것처럼요. 서랍에 있으면 매번 꺼내쓰는 데 번거롭겠죠?

 

언제 사용해야 좋을까요?

캐시는 애플리케이션 전반에서 사용할 수 있습니다.

만약 캐시가 없는 애플리케이션 서버가 동일한 API 요청을 N번 받으면 매번 DB 조회 후에 모든 로직을 거쳐 반환하는 과정을 겪어야 겠지요.

대표적인 사용 예시

캐시의 장점은 데이터베이스에 직접 조회하는 것보다 빠른 것뿐만 아니라 데이터베이스의 부하를 줄일 수 있다는 점도 있습니다. 같은 API로직을 10번 요청받았을 때, 최초 1회만 DB에 접근하​면 되니까요.

 

캐시에 유리한 데이터

캐시에 들어갈 데이터는 자주 사용되지만 변경은 자주 일어나지 않는 것이 유리합니다.

만약 자주 사용되지 않은 데이터라면 기껏 캐시 메모리에 올려두었지만 안쓰이고 그냥 사라진다면 안되겠지요?

반대로  같은 데이터는 자주 바뀌기 때문에 사실 캐싱하기에는 별로 좋지 않은 데이터이지요. 물론 어떻게 구현하느냐, 어떤 로직이 필요하느냐에 따라 선택의 결과는 다를 수 있습니다.

다만 조회는 잦으면서 변함이 별로 없는 캐시라면 TTL(Time To Live)을 길게해서 메모리에 올려두면 더 좋습니다.

아무것도 모를 때 채팅을 캐싱해보겠다며 팀 메이트와 그려본 플로우 차트 👀

 

캐시를 사용하기 전에 선택할 것들

이처럼 캐시를 이용하여 DB 커넥션을 줄이고 서비스 로직 실행도 생략할 수가 있어서 DB와 애플리케이션 성능에 크게 도움이 되는데요. 캐시를 도입하기 전에 선택해야 하는 문제들이 몇가지 있습니다.

 

Local cache와 Global Cache

캐시를 WAS(Web Application Server) 에 저장하는 방식인 Local cache와 별도 캐시 서버를 구축하는 Global Cache 방식 두가지가 있는데요. 👉 Local cache 참고링크

이번 프로젝트의 경우에는 Scale out 가능성이 있다는 가정을 하고 있으며, 이미 Redis pub/sub으로 채팅 서비스를 구현한 상태이기 때문에 자연스럽게 Redis cache를 이용하여 Global Cache 를 사용하기로 하였습니다.

Local cache의 경우, WAS 인스턴스 메모리에 데이터를 저장하므로 속도가 매우 빠르고 별도 인프라를 구축할 필요가 없어서 선택하는 경우도 있습니다.

 

캐시 읽기/쓰기 전략과 TTL

캐시 읽기 전략과 쓰기 전략에는 여러가지 종류가 있었는데요. 데이터베이스와 캐시에서 어떻게 데이터를 가져올지에 대한 전략입니다.

캐싱할 API의 성격과 환경에 따라 선택해야 했습니다. 해당 전략에 대해서는 이 링크를 참고하였습니다.

저희 프로젝트는 Read API에 대해서 Read through 정책을, Write API에 대해서는 Write back정책을 선택했습니다. API 성능 개선도 목표였지만 결국 가장 큰 목표는 캐시에 대한 학습과 구현에 있었기 때문에 코드를 직접 쓰기도 했습니다.

 

Read API에 적용해보자

우선적으로 캐싱 할 API로는 다음을 염두에 두고 있었는데요. 이유는 아래와 같습니다.

 

1. 상품판매 목록 조회

  • 첫 페이지이므로 조회 빈도가 높은 편
  • 여러 데이터를 조회해야하므로 쿼리가 복잡한 편
  • 다만 데이터의 수정이 잦은 편이기 때문에 TTL을 짧게 해두는 것이 좋을 것 같음

2. 지역 목록 조회

  • 키워드 검색 결과 (key를 키워드를 붙여서 적용)는 프론트에서 수시로 보내는 요청 (사용자가 타이핑을 하는 동안 여러번 요청이 온다.)
  • 데이터가 변함이 없는 편
  • TTL을 조금 길게 해두어도 좋을 것 같음

3. 채팅 로그와 채팅방 메타 데이터

  • 속도가 중요한 도메인
  • 채팅 로그의 경우, 데이터가 계속 생성되어 쌓이는 특징을 가지고 있으므로 수정, 삭제를 신경쓰지 않아도됨
  • 쓰기 버퍼의 역할도 할 수 있을 것 같음.

 

Cache Abstraction

막상 적용하려고 자료를 찾아보니 Spring Framework는 캐시 추상화가 되어있기 때문에 어노테이션을 기반으로 손쉽게 구현할 수 있었습니다. 스프링 3.1 버전부터 기존 Spring 애플리케이션에 캐싱을 투명하게 추가할 수 있는 지원을 제공해왔는데,  캐싱 추상화를 통해 코드에 미치는 영향을 최소화하면서 다양한 캐싱 솔루션을 일관되게 사용할 수 있습니다.

// 인터페이스를 제공하고 다른 구현체들이 구체적인 로직을 수행하는 것입니다.
org.springframework.cache.*

 

트랜잭션이나 Async처럼 선언적인 어노테이션으로 코드를 바꾸지 않고도 사용할 수 있었습니다.

  • @Cacheable: Triggers cache population.
  • @CacheEvict: Triggers cache eviction.
  • @CachePut: Updates the cache without interfering with the method execution.
  • @Caching: Regroups multiple cache operations to be applied on a method.
  • @CacheConfig: Shares some common cache-related settings at class-level.

 

설정하기

@Configuration
@EnableCaching
@RequiredArgsConstructor
public class CacheConfig {

    private final RedisConnectionFactory redisConnectionFactory;
    private final ObjectMapper objectMapper;

    @Bean
    public CacheManager redisCacheManager() {
        return RedisCacheManager.RedisCacheManagerBuilder
                .fromConnectionFactory(redisConnectionFactory)
                .cacheDefaults(defaultCacheConfiguration())
                .withInitialCacheConfigurations(CacheConfigurations())
                .build();
    }

    @Bean(name = "defaultCacheConfiguration")
    public RedisCacheConfiguration defaultCacheConfiguration() {
        return RedisCacheConfiguration.defaultCacheConfig()
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(
                        new GenericJackson2JsonRedisSerializer()))
                .entryTtl(Duration.ofSeconds(3))
                .disableCachingNullValues();
    }

    @Bean
    public Map<String, RedisCacheConfiguration> CacheConfigurations() {
        return Map.of(
                CacheKey.MEMBER, defaultCacheConfiguration().entryTtl(Duration.ofSeconds(5)),
                CacheKey.ITEM, defaultCacheConfiguration().entryTtl(Duration.ofSeconds(3)),
                CacheKey.REGION, defaultCacheConfiguration().entryTtl(Duration.ofSeconds(100))
        );
    }
}

도메인별로 용도가 조금씩 달라서 TTL을 다르게 설정해주기 위해 CacheConfiguration Map을 Cache manager에게 전달하였습니다.

그리고 이름붙인 것은 예를 들어 이런식으로 적용할 수 있습니다.

@Service
@CacheConfig(cacheNames = CacheKey.REGION)
@RequiredArgsConstructor
public class RegionService implements GetValidRegionsUsecase {

    private final RegionRepository regionRepository;

	// 생략

    @Override
    @Transactional(readOnly = true)
    @Cacheable(key = "#id")
    public Region getRegion(Long id) throws NotValidRegionException {
        return regionRepository.findById(id).orElseThrow(() -> new NotValidRegionException("해당하는 지역이 없습니다."));
    }

    @Transactional(readOnly = true)
    @Cacheable(key = "#address")
    public List<Region> findRegionByAddress (String address) {
        return regionRepository.findAllByAddress(address);
    }
}

 

'강남'으로 주소 검색을 했을 때, 다음과 같이 Redis에 저장되는 것을 확인했습니다. DB access log도 1회만 찍히고 응답 속도도 100ms에서 10ms로 개선된 것을 확인할 수 있었습니다.

 

 

그런데... 문제는 채팅과 상품 판매 조회에 대한 캐싱이었는데요.

어노테이션을 사용하는 것으로는 채워지지 않은(..) 요구사항들이 있었습니다.

예를 들어 Write back을 구현한다거나, redis 저장 타입을 바꾸고 싶다거나 하는 것들이요.

 

이부분에 대해서는 두번째 글로 정리해보도록하겠습니다 👋


참고링크