Project/Sell-everything

[#2] Redis 캐시를 통해 읽기 성능 향상하기

개발하자 2021. 9. 7. 20:33

이번 포스팅에서는 스프링 캐시를 적용하면서 읽기 성능을 향상했던 경험을 공유하고자 합니다.

 

나름대로, 캐시란 무엇인가에 대해 정의를 내려보았습니다.

 

데이터의 실시간성을 약간 포기하고,

큰 성능적 이점을 얻는 것

 

물론 캐시는 이미 공식적인 정의가 존재하지만,

 

기존의 정의에서는 '실시간성을 포기한다'라는 trade off를

 

추론을 통해 알아내야 했기 때문에,

 

그 부분까지 확실하게 함께 담아 정리해보았답니다. 😄

 

그럼, 캐시를 적용하기까지 과정은 어땠는지,

 

어떻게 캐시를 통해 성능을 개선했는지

 

이야기해보도록 하겠습니다.

 


목차

  1. 캐시?
  2. Spring Cache Abstraction
  3. Local Cache vs Global Cache
  4. 캐싱 적용하기
  5. 캐싱 & 성능 확인

캐시?

위키백과에서 찾아본 캐시의 정의는 다음과 같습니다.

캐시(cache, 문화어: 캐쉬, 고속완충기, 고속완충기억기)는 컴퓨터 과학에서 데이터나 값을 미리 복사해 놓는 임시 장소를 가리킨다. 캐시는 캐시의 접근 시간에 비해 원래 데이터를 접근하는 시간이 오래 걸리는 경우나 값을 다시 계산하는 시간을 절약하고 싶은 경우에 사용한다. 캐시에 데이터를 미리 복사해 놓으면 계산이나 접근 시간 없이 더 빠른 속도로 데이터에 접근할 수 있다.

 

즉 캐시는, 접근 비용이 비싼 데이터의 사본을 만들어 저장하고, 동일한 요청이 있을 때 원본 데이터에 접근하지 않고 사본 데이터를 제공할 수 있게 하는 중간 장치의 개념입니다.

 

네트워크 통신, 하드 디스크 접근 등 컴퓨터 시스템의 곳곳에서 병목이 일어날 수 있을만한 지점의 성능 향상을 위해 캐시를 사용하곤 합니다.

 

제가 진행 중인 프로젝트에서도 캐싱을 적용할만한 지점을 발견했는데, 바로 데이터베이스에 저장된 데이터를 가져와 클라이언트에게 제공하는 과정이었습니다.

 

서비스의 데이터를 저장하기 위해 MySQL 데이터베이스를 사용하고 있는데, MySQL은 하드디스크 기반의 데이터 스토리지이기 때문에 서버의 메모리보다 훨씬 느린 속도로 데이터를 탐색합니다.

 

그렇기 때문에, 클라이언트의 동일한 읽기 요청에 대해 데이터베이스를 매번 접근하지 않고, 처음에 접근해 얻은 데이터의 사본을 메모리에 저장하고 정해진 유효기간동안 사용한다면 기존의 데이터베이스에서 매번 원본 데이터를 가져오는 것보다 성능상의 이점이 클 것이라 생각했습니다.

 

DB Caching flow

Spring Cache Abstraction

Spring Cache Manager의 문서에서는, 스프링이 AOP를 사용하여 비침투적으로 캐싱을 적용한다고 설명합니다.

Both the cache:annotation-driven element and @EnableCaching annotation allow various options to be specified that influence the way the caching behavior is added to the application through AOP. The configuration is intentionally similar with that of @Transactional

 

위 설명을 읽어보면, @Transactional 애너테이션을 통해 트랜잭션 서비스를 제공하는 것과 비슷한 패턴으로 캐싱을 적용한다는 것을 알 수 있습니다.

 

스프링은 특정 기술을 추상화 계층으로 가져가고, 개발자에게는 기술을 사용하는 인터페이스를 동일한 형태로 제공함으로써 기술의 벤더가 변경된다고 할 지라도 코드에는 전혀 영향을 미치지 않게 합니다.

 

이를테면 스프링 캐시를 관리하는 CacheManager에는 ConcurrentMapCacheManager, SimpleCacheManager, EhCacheManager, RedisCacheManager 등 다양한 기술 벤더가 존재하는데, 이들 모두 CacheManager 인터페이스를 구현하고 있기 때문에 우리는 동일한 시그니쳐로 캐시 기능을 사용할 수 있습니다.

 

이와 같은 방법으로, 프로그램이 특정 기술에 종속되지 않게 하는 방식을 PSA(Portable Service Abstraction)라고 하며, 스프링은 이를 스프링의 3대 요소 중 하나로 꼽습니다.

 

스프링 PSA 덕분에, 우리는 서버의 환경에 맞추어서 필요한 캐시 기술을 선택할 수 있고, 캐시 설정만 바꾸어주면 어떠한 캐시 기술로든 전환할 수 있습니다.

Local Cache vs Global Cache

스프링의 PSA 덕분에, 우리는 서비스 환경에 따라 어떤 Cache 전략이던 CacheManager를 구현하고 있기만 하다면 유연하게 우리 코드에 적용할 수 있습니다.

 

그럼 어떠한 Cache 기술을 적용할지 고려해보아야 되겠죠? Cache 관리 전략을 선택할 때 가장 먼저 고려해야 할 요소는, 캐시 데이터를 저장할 스토리지를 서버가 자체적으로 소유하고 있을지, 외부 서버에 캐시 저장소를 따로 둘 지에 대한 부분입니다. 캐시 저장소를 서버에 두는 방식을 Local Cache, 외부 캐시 저장소를 두는 방식을 Global Cache라고 합니다.

 

Local Cache vs Global Cache

 

저장 위치의 차이로 인해, Local Cache와 Global Cache는 다양한 상황에서 성능적 차이를 보입니다.

Local Cache와 Global Cache의 특징을 각각 알아보겠습니다.

 

Local Cache의 특징

 

 

데이터 조회 오버헤드가 없다.

 

캐시 데이터를 서버 메모리상에 두는 것의 가장 큰 장점은, 무엇보다도 속도가 빠르다는 점입니다. 캐시를 외부 저장소에 저장하면 네트워크 통신을 통해 캐시 저장소에 접근하고, 데이터를 가져오는 과정 등의 오버헤드가 없기 때문에 Local Cache의 데이터 읽기 속도는 현저히 빠릅니다.

 

서버 간 데이터 일관성이 깨질 수 있다.

 

Local Cache는 단일 서버 인스턴스에 캐시 데이터를 저장하기 때문에, 서버의 인스턴스가 여러 개일 경우 서버 간 캐시 데이터가 일치하지 않아 신뢰성을 떨어뜨릴 수 있습니다.

 

서버간 동기화가 어렵고, 동기화 비용이 발생한다.

 

캐시 일관성을 유지하기 위해 동기화를 한다고 하더라도, 추가적인 비용이 발생합니다. 더군다나 서버의 개수가 늘어날수록, 자신을 제외한 모든 인스턴스와 동기화 작업을 해야 하기 때문에 비용의 크기는 서버의 개수의 제곱에 비례하여 증가합니다.

 

Global Cache의 특징

 

 

네트워크 I/O 비용 발생

 

Global Cache는 외부 캐시 저장소에 접근하여 데이터를 가져오기 때문에, 이 과정에서 네트워크 I/O가 비용이 발생합니다. 하지만 서버 인스턴스가 추가될 때에도 동일한 비용만을 요구하기 때문에, 서버가 고도화될수록 더 높은 효율을 발휘합니다.

 

데이터 일관성을 유지할 수 있다.

 

Global Cache는 모든 서버의 인스턴스가 동일한 캐시 저장소에 접근하기 때문에, 데이터의 일관성을 보장할 수 있습니다. 데이터의 일관성이 깨지기 쉬운 분산 서버 환경에 적합한 구조입니다.

 

Local Cache vs Global Cache, 어떤 기준으로 선택해야 할까?

 

 

Local Cache와 Global Cache의 특성을 고려했을 때, 어떤 기술을 선택해야 할지에 대한 기준은 "데이터의 일관성이 깨져도 비즈니스에 영향을 주지 않는가?"라고 생각합니다.

 

이를테면, 사용자 정보가 변경되어 프로필에 반영되어야 하는 상황을 가정할 때, 서버 간 동기화가 맞지 않아서 프로필에 반영되는데 시간이 조금 걸린다 하더라도, 전체적인 서비스 운영에 큰 타격을 주지는 않습니다. 금전이 오고가는 문제도 아니고, 프로필의 정보가 조금 늦게 반영된다고 해서 큰 문제가 발생하지 않으니까요. 이러한 경우에는 서버간 동기화 없이 서버 자체적으로 로컬 캐싱을 하는 것도 괜찮은 선택지라고 생각합니다.

 

하지만, 상품 데이터를 캐싱한다고 했을 때는 상황이 달라집니다. 사용자가 가격을 변경했는데, 그것이 반영되지 않으면 서비스 신뢰를 심각하게 손상하고, 운이 나쁘면 법적 문제로 이어지기도 합니다. 따라서, 이러한 경우에는 동기화가 속도보다 더 중요하며, 그렇기에 동기화가 확실하게 보장되는 Global Cache를 사용하는 것이 좋습니다.

 

진행 중인 프로젝트에서는 게시글과 댓글에 캐싱을 적용하였고, 둘 다 데이터의 일관성이 중요하다고 판단되어 Global Cache인 Redis를 적용하였습니다. 추후 성능 테스트를 진행하면서, Local Cache를 적용해 성능을 향상할 수 있는 지점을 발견하면, CompositeCacheManager를 사용해 2차 캐시를 구성해보고, 관련한 내용을 새로 포스팅하도록 하겠습니다.

 

 

캐싱 적용하기

본 예시는 Redis가 설치된 환경을 가정하여 진행합니다.
/* RedisConfig.java */
@Configuration
@EnableRedisHttpSession
public class RedisConfig {

    /* DateTimeFormat을 String 형태로 지원하기 위한 ObjectMapper */
    @Autowired
    private ObjectMapper objectMapper;

    @Value("${spring.redis.host}")
    private String redisHost;

    @Value("${spring.redis.port}")
    private int redisPort;

    @Bean
    public RedisConnectionFactory redisConnectionFactory() {
        return new LettuceConnectionFactory(redisHost, redisPort);
    }

    @Bean
    public RedisCacheManager redisCacheManager() {

        RedisCacheConfiguration redisCacheConfiguration = RedisCacheConfiguration
            .defaultCacheConfig()
            .disableCachingNullValues()
            .serializeValuesWith(
                RedisSerializationContext.SerializationPair
                    .fromSerializer(new GenericJackson2JsonRedisSerializer(objectMapper))
            );

        Map<String, RedisCacheConfiguration> redisCacheConfigurationMap = new HashMap<>();
        redisCacheConfigurationMap
            .put(CacheNames.POST, redisCacheConfiguration.entryTtl(Duration.ofMinutes(5)));

        return RedisCacheManager.RedisCacheManagerBuilder
            .fromConnectionFactory(redisConnectionFactory())
            .withInitialCacheConfigurations(redisCacheConfigurationMap)
            .cacheDefaults(redisCacheConfiguration)
            .build();

    }

}
/* MapperConfig.java */
@Configuration
public class MapperConfig {

    public static final DateTimeFormatter FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");

    @Bean
    public ObjectMapper serializingObjectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        JavaTimeModule javaTimeModule = new JavaTimeModule();
        javaTimeModule.addSerializer(LocalDateTime.class, new LocalDateTimeSerializer());
        javaTimeModule.addDeserializer(LocalDateTime.class, new LocalDateTimeDeserializer());
        /*
        */
        objectMapper.registerModules(javaTimeModule, new Jdk8Module());
        objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
        return objectMapper;
    }

    public class LocalDateTimeSerializer extends JsonSerializer<LocalDateTime> {

        @Override
        public void serialize(LocalDateTime value, JsonGenerator gen, SerializerProvider serializers) throws IOException {
            gen.writeString(value.format(FORMATTER));
        }
    }

    public class LocalDateTimeDeserializer extends JsonDeserializer<LocalDateTime> {

        @Override
        public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException {
            return LocalDateTime.parse(p.getValueAsString(), FORMATTER);
        }
    }

}

 

Redis Cache를 적용하기 위해, RedisCacheManagerRedisConnectionFactory를 등록해주면 Redis 캐시 설정이 완료됩니다. 제 경우에는 데이터에 LocalDateTime format이 있는데, 해당 타입이 String 형태로 나오지 않아 이것을 String 형태로 변환해주기 위해 ObjectMapper를 따로 구성하여 주었습니다.

 

이후, 메인 파일에 @EnableCaching 애너테이션을 작성하여 스프링에게 캐싱 적용 대상임을 알려줍니다. 해당 애너테이션을 작성하면, 스프링은 AOP를 통해 비 침투적으로 캐싱 전략을 적용합니다.

@EnableCaching
@SpringBootApplication
public class SellEverythingApplication {

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

}

 

그다음, 캐싱을 적용할 지점에 @Cacheable을 지정해주고, 캐싱이 삭제되어야 하는 지점에 @CacheEvict를 적용합니다.

@Service
@AllArgsConstructor
public class PostServiceImpl implements PostService {
    ...
    @Cacheable(value = CacheNames.POST)
    @Override
    public List<PostVO> getPostsByQueryString(Map<String, String> queryMap) {
        return postMapper.getPosts(queryMap);
    }

    @CacheEvict(value = CacheNames.POST, allEntries = true)
    @Override
    public void createPost(PostDTO newPost) {
        int memberIdBySession = authService.getRequestMemberId();
        postMapper.createPost(PostDTO.builder()
            .postTitle(newPost.getPostTitle())
            .postContents(newPost.getPostContents())
            .postItemName(newPost.getPostItemName())
            .postItemPrice(newPost.getPostItemPrice())
            .postCategory(newPost.getPostCategory())
            .memberIdFk(memberIdBySession)
            .build()
        );
    }

    @CacheEvict(value = CacheNames.POST, allEntries = true)
    @Override
    public void updatePostById(int id, PostDTO newPost) {
        int sessionMemberId = sessionAuthService.getRequestMemberId();
        postMapper.updatePostById(id, sessionMemberId, newPost);
    }

    @CacheEvict(value = CacheNames.POST, allEntries = true)
    @Override
    public void deletePostById(int id) {
        int sessionMemberId = sessionAuthService.getRequestMemberId();
        postMapper.deletePostById(id, sessionMemberId);
    }
    ...
}

@Cacheable과 @CacheEvict의 value 파라미터로 캐시 이름을 지정할 수 있습니다. Redis는 여기에 지정된 이름을 기반으로 key-value쌍의 캐시 데이터를 저장합니다.

캐싱 & 성능 확인

캐싱을 적용했으니, 정말로 캐싱이 되고 있는지 확인해봐야겠죠? Redis는 redis-cli라는 인터페이스를 제공하여 캐시를 눈으로 확인할 수 있도록 지원합니다. redis-cli를 설치하고, keys * 명령어를 커맨드 라인에 입력하면 현재 저장된 모든 캐시를 확인할 수 있습니다.

 

빈 캐시 데이터 확인

 

현재는 아무런 조회도 하지 않았기 때문에 당연히 캐시는 비어있습니다.

 

서버에 게시글 데이터 조회 요청을 보내고, 다시 캐시를 확인해보겠습니다.

 

서버에 게시글 데이터 요청

 

게시글 캐시가 적재된 모습

 

게시글을 가져오는데 180ms가 소요되었고, Redis에 캐시가 저장된 것을 확인할 수 있습니다!

그럼 캐시를 통해서 조회 성능이 정말 좋아지는 지도 확인해야겠죠?

 

읽기 속도가 약 5배 향상됨

 

첫 게시글 조회 요청에 비해, 소요 시간이 180ms -> 39ms로 약 5배가량의 성능 향상을 확인할 수 있습니다.

 

 

마치며

 

스프링 캐시를 적용하여, API의 읽기 성능을 향상하였습니다. 이러한 경험을 통해 몇 가지 깨달음을 얻게 되어, 공유하며 글을 마치고자 합니다.

 

먼저, 개발자는 비즈니스 요구사항을 명확하게 이해하고 있어야 합니다. Local Cache와 Global Cache 중, 어떤 것을 선택할지 결정하는 과정에서 비즈니스적인 요소가 의사 결정에 큰 영향을 미쳤습니다. 개발자는 필드에서 수많은 서비스 요구사항을 만나게 될 텐데, 그때마다 비즈니스 요구사항을 명확하게 이해하여서 이를 적합한 기술에 담아낼 수 있어야 기술과 비즈니스, 두 마리 토끼를 모두 잡을 수 있겠다고 생각했습니다.

 

두 번째, 개발자는 기술을 아주 깊게 이해해야 합니다. 기술을 적용할 때 어떤 선택지가 있는지, 지금 이 기술을 선택했을 때 어떤 trade-off가 있는지를 기술 작동 원리에 기반하여 샅샅이 알고 있어야 앞서 말씀드린 비즈니스 요구사항에 적합한 기술을 선택할 수 있고, 그래야 더 괜찮은 성능을 발휘하는 서버 구성이 가능합니다.

 

저는 매주, 기술에 대해 공부하고 토론을 나누는 기술 서적 스터디에 참여하고 있습니다. 매 스터디마다 여기까지 공부한다고?라는 생각이 들 정도로 모든 지식을 파헤치면서 스터디를 진행하고 있는데, 시간이 지나면서 눈에 띄게 성장하는 스스로의 모습을 발견할 수 있었습니다.

 

이 글을 읽게 되시는 개발자분들도, 기술을 깊게 파헤치고 그것을 적용하면서 즐거움을 느껴보시면 좋을 것 같다는 짧은 의견 드리며, 이만 글을 마치겠습니다.

 

 

프로젝트 URL : https://github.com/f-lab-edu/sell-everything

 

GitHub - f-lab-edu/sell-everything: Spring Framework 기반 중고 거래 서비스 플랫폼

Spring Framework 기반 중고 거래 서비스 플랫폼. Contribute to f-lab-edu/sell-everything development by creating an account on GitHub.

github.com

 

반응형