티스토리 뷰

Spring Cache로 읽기 작업의 성능 향상시키기

Overview

현재 진행하고 있는 SNS 프로젝트에서 게시물 관련 기능을 개발하고 테스트를 하면서 서버에 동일한 요청을 여러 차례 보내게 되었다. 이 과정에서 동일한 결과를 얻기 위해 매번 데이터베이스와 연결하여 같은 연산을 수행하는 것이 비효율적이라고 생각했다.

 

 

저번 글에서 잠깐 언급한 것처럼 RDB에서는 연산을 처리할 때마다 디스크 I/O가 발생하기 때문이다. 버퍼로 디스크 I/O를 최소화하고 처리 능력을 강화해도 DB 연산 처리 시간 외에 디스크에서 데이터를 찾을 때 발생하는 대기 시간, 디스크에서 버퍼로의 데이터 전송 시간 등이 발생하기 때문에 방대한 I/O 작업을 처리하는 경우 결국 병목 현상이 생기게 된다. 또한, RDB는 데이터 정합성의 보장을 위해 주로 Scale Up 방식으로 성능 향상을 하게 된다. Scale Up은 성능 확장에 한계가 있기 때문에 장애가 발생하지 않도록 부하를 최대한 분산시켜주는 것이 중요하다.

 

위와 같은 비효율성을 개선하고 불필요한 서버 사용량을 줄이기 위해 캐싱을 적용하기로 결정했다. 특히 SNS 특성상 팔로우가 많은 유저의 페이지나 소수의 게시물에 대해서 대부분의 요청이 들어오기 때문에 캐싱을 활용하면 성능을 향상시킬 수 있다고 생각했다.

 

어떤 캐싱 전략이 적합할까? - 로컬 캐싱 vs 글로벌 캐싱

본격적으로 캐싱을 적용하기 전에 어떤 캐싱 전략을 프로젝트에 적합할지 고민해보았다. 캐싱 전략에는 크게 로컬 캐싱과 글로벌 캐싱이 있었다.

 

로컬 캐싱은 서버 내부 저장소에 캐시 데이터를 저장하는 것이다. 따라서, 속도는 빠르지만 서버 간의 데이터 공유가 안된다는 단점이 있다. 예를 들어, 사용자가 같은 리소스에 대한 요청을 반복해서 보내더라도 A 서버에서는 이전 데이터를, B 서버에서는 최신 데이터를 반환하여 각 캐시가 서로 다른 상태를 가질 수도 있다. 즉, 일관성 문제가 발생할 수 있다는 것이다. 이외에도 서버 별 중복된 캐시 데이터로 인한 서버 자원 낭비, 힙 영역에 저장된 데이터로 발생하는 GC에 대한 문제 등을 고려해야 한다.

 

반면에 글로벌 캐싱은 서버 내부 저장소가 아닌 별도의 캐시 서버를 두어 각 서버에서 캐시 서버를 참조하는 것이다. 캐시 데이터를 얻으려 할 때마다 캐시 서버로의 네트워크 트래픽이 발생하기 때문에 로컬 캐싱보다 속도는 느리다. 하지만 서버 간에 캐시 데이터를 쉽게 공유할 수 있기 때문에 위에서 언급한 로컬 캐싱의 문제를 해결할 수 있다. 따라서, 글로벌 캐싱 전략을 선택하고 현재 세션 스토리지로 사용하고 있는 Redis를 캐시 저장소로도 활용하기로 했다.

 

Refactoring: 캐싱 적용

스프링 프레임워크에서는 캐시 추상화를 지원하기 때문에 별도의 캐시 로직을 작성할 필요없이 어플리케이션에 캐싱 기능을 쉽게 적용할 수 있다. 그러나 캐시 저장소를 제공하지는 않으므로 직접 설정을 해주어야 한다. 그리고 CacheManager라는 인터페이스를 활용하여 직접 지정한 저장소의 캐시를 관리하는데 사용되는 캐시 공급자를 구현해야 한다.

 

@EnableCaching 어노테이션 적용

 

 

먼저 @EnableCaching 어노테이션으로 캐싱 기능을 활성화한다. 위의 그림처럼 캐싱 기능이 활성화되면 CacheManager 타입의 빈을 호출하여 캐시 어노테이션이 붙은 컴포넌트들을 스프링이 관리하기 시작한다. @Transaction 처럼 Spring AOP를 통해 @Cacheable 이 설정된 메서드가 호출될 때마다 지정된 인수에 대해 호출 여부를 확인한다. 메서드가 호출된 적이 없었다면 메서드를 호출하는 과정에서 결과를 캐시한 후 사용자에게 반환한다. 반대로 이미 호출된 적이 있었다면 이전에 캐시된 데이터를 반환하게 된다. (위의 그림은 스프링이 제공하는 기본 캐시를 사용할 때의 그림이다. 만약 외부 저장소를 사용한다면 ConcurrentHashMap 대신 외부 저장소 서버로 대체해서 생각하면 된다.)

@EnableCaching
@SpringBootApplication
public class SnsServerApplication {

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

 

RedisCacheManager 등록

우리는 Redis를 캐시 저장소로 사용하기로 했으므로 RedisCacheManager 를 빈으로 등록하여 기본 CacheManager 를 대체한다.

@Configuration
public class CacheConfig {

    @Bean
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {

        RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
                .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer(new GenericJackson2JsonRedisSerializer()));

        // 리소스 유형에 따라 만료 시간을 다르게 지정
        Map<String, RedisCacheConfiguration> redisCacheConfigMap = new HashMap<>();
        redisCacheConfigMap.put(CacheNames.POST, defaultConfig.entryTtl(Duration.ofHours(1)));
        redisCacheConfigMap.put(CacheNames.FEED, defaultConfig.entryTtl(Duration.ofSeconds(5L)));

        RedisCacheManager redisCacheManager = RedisCacheManager.builder(connectionFactory)
                .withInitialCacheConfigurations(redisCacheConfigMap)
                .build();

        return redisCacheManager;
    }
}

RedisCacheConfiguration 오브젝트로 RedisCacheManager 에 관련 옵션을 설정할 수 있으며 각 옵션의 의미는 다음과 같다.

 

  • serializeValuesWith : 캐시 Value를 직렬화-역직렬화 하는데 사용하는 Pair 지정

    • Value는 다양한 자료구조가 올 수 있기 때문에 GenericJackson2JsonRedisSerializer 를 사용
    • Key Serializer 기본 값은 StringRedisSerializer 로 지정되어 있어 serializeKeysWith 생략
  • withInitialCacheConfigurations : 여러 개의 CacheConfiguration 을 설정할 때 사용

  • entryTtl : 캐시의 만료 시간을 설정

 

현재 진행하는 SNS 프로젝트에서는 캐싱을 적용할 대상으로 게시물과 피드로 선정했다. 게시물은 한 번 작성하면 변경이 거의 일어나지 않지만 게시물들이 모여 있는 피드는 각각의 게시물의 생성, 변경, 삭제로 인해 비교적 변경이 자주 발생한다. 그래서 리소스의 유형에 따라 CacheName 을 정의하고 CacheName 마다 캐시의 만료시간을 다르게 설정했다.

 

캐시 어노테이션 적용

스프링의 캐시 추상화에서 제공하는 다양한 캐시 어노테이션으로 메소드에 대한 캐시 제어를 쉽게 할 수 있다. 나의 프로젝트에서 주로 사용한 캐시 어노테이션은 다음과 같다.

 

@Cacheable

@Override
@Cacheable(cacheNames = CacheNames.FEED, key = "#userId")
public List<Post> getPostsByUser(String userId) {

    List<Post> posts = postMapper.getPostsByUserId(userId);

    return posts;
}

@Cacheable 은 해당 메소드의 리턴값을 캐시에 저장한다. @Cacheable 은 주로 읽기 작업이 일어나는 메소드에 적용되며 메소드가 호출될 때마다 캐시 저장소에 저장된 캐시가 있는지 확인하고, 있다면 메소드를 실행하지 않고 캐시 데이터를 반환한다.

  • cacheNames: 캐시 이름
  • key: 캐시 이름이 같을 때, 사용되는 구분 값
    • Redis에 실제로 저장될 때는 "cacheNames::key" 형식으로 저장된다.

 

@CacheEvict

@Override
@CacheEvict(cacheNames = CacheNames.FEED, key = "#user.userId")
public void uploadPost(User user, String content, List<MultipartFile> images) {

    PostUploadInfo postUploadInfo = new PostUploadInfo(user.getUserId(), content);

    postMapper.insertPost(postUploadInfo);
    int postId = postMapper.getLatestPostId(user.getUserId());

    if (!images.isEmpty()) {
        if (images.size() == 1) {
            FileInfo fileInfo = fileService.uploadFile(images.get(0), user.getUserId());
            fileService.uploadImage(postId, fileInfo);
        } else {
            List<FileInfo> fileInfos = fileService.uploadFiles(images, user.getUserId());
            fileService.uploadImages(postId, fileInfos);
        }
    }
}

@CacheEvict 는 지정된 키에 해당하는 캐시를 삭제한다. 캐싱 대상 리소스에 변경 작업을 하는 메소드라면 @CacheEvict 어노테이션을 적용해주는 것이 좋다. 만약 @CacheEvict 을 적용하지 않고 데이터를 수정한다면 데이터베이스의 데이터는 수정되지만 캐시 저장소의 데이터는 수정 전의 상태로 남아있게 된다. 이때 캐시가 만료되기 전에 수정된 데이터를 조회하면 데이터 불일치가 발생하므로 데이터 변경이 일어난다면 기존 캐시 데이터를 제거해야 한다.

 

@Caching

@Override
@Caching(evict = {@CacheEvict(cacheNames = CacheNames.POST, key = "#postId"),
                  @CacheEvict(cacheNames = CacheNames.FEED, key = "#user.userId")})
public void updatePost(User user, int postId, String content) throws AccessException{

    boolean isAuthorizedOnPost = postMapper.isAuthorizedOnPost(user.getUserId(), postId);
    if (isAuthorizedOnPost) {
        postMapper.updatePost(postId, content);
    } else {
        throw new AccessException("해당 게시물의 수정 권한이 없습니다.");
    }
}

@Caching 은 여러 캐싱 작업을 한 번에 적용시키기 위한 어노테이션이다. 주로 조건식이나 표현식이 다른 경우 혹은 여러 종류의 캐시에 함께 작업을 지정해야 하는 경우 사용하게 된다.

 

After Refactoring

Postman으로 사용자의 피드를 가져오는 기능을 테스트를 해보았다.(초기화 설정을 하는 시간을 배제하기 위해 해당 기능을 테스트 하기 전에 충분히 warm up을 하였다.) 캐싱 적용 전에는 응답 시간이 110ms 걸리던 것이

 

캐싱 적용 후에는 42ms로 줄어든 것을 볼 수 있다.

 

Redis에서도 조회해보면 소스코드에서 지정한 캐시 이름과 키로 캐시 데이터가 저장되어 있는 것을 볼 수 있다.

 

 

 


진행 프로젝트

github.com/f-lab-edu/sns-itda

 

f-lab-edu/sns-itda

Contribute to f-lab-edu/sns-itda development by creating an account on GitHub.

github.com

댓글