이 연재글은 SpringBoot2로 Rest api 만들기의 15번째 글입니다.

이번 장에서는 지금까지 개발한 api에 캐시를 적용해 보도록 하겠습니다. 캐시란 자주 사용되는 데이터를 메모리에 저장하고 반환하여 하드디스크의 원본데이터를 거치지 않게 함으로서 리소스 READ시 효율을 높이는 기술을 말합니다. 이번장에서는 api 결과에 캐시를 적용하여 DB에 요청을 최소화 함으로써 DB부하를 감소시키고, api의 결과 데이터 반환 속도를 향상시켜 보겠습니다.

Redis 캐시

캐시종류에는 여러가지가 있으나, 근래에 가장 많이 사용되는 Redis를 캐시 Storage로 사용해보겠습니다. Spring에서는 이미 Redis를 이용하여 캐시를 손쉽게 구현할수 있도록 spring-data-redis 라이브러리를 제공하고 있습니다.

Embedded Redis

테스트를 위해 Redis 서버를 직접 설치하는것보다는 좀더 간편하게 Redis를 사용하기 위하여 Embedded Redis를 이용해 보겠습니다. Embedded Redis는 SpringBoot 실행시 내부적으로 같이 구동되며 종료시엔 Redis도 같이 종료되므로 테스트 환경에서 사용하기 적합합니다.

build.gradle에 라이브러리 추가

Spring에서 redis를 사용하기 위해 spring-boot-starter-data-redis를 추가하고, Embedded Redis를 구동하기 위해 it.ozimov:embedded-redis를 추가합니다.

implementation 'org.springframework.boot:spring-boot-starter-data-redis'
implementation 'it.ozimov:embedded-redis:0.7.2'

Embedded Redis 설정 추가

Embedded Redis는 로컬 환경에서만 사용할 것이므로 @Profile(“local”)을 클래스에 선언합니다.

@Profile("local")
@Configuration
public class EmbeddedRedisConfig {

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

    private RedisServer redisServer;

    @PostConstruct
    public void redisServer() {
        redisServer = new RedisServer(redisPort);
        redisServer.start();
    }

    @PreDestroy
    public void stopRedis() {
        if (redisServer != null) {
            redisServer.stop();
        }
    }
}

application-local.yml에 redis 설정 추가

spring:
  profiles: local
  .... 생략
  redis:
    host: localhost
    port: 6379

application-alpha.yml에는 서버에 구축한 redis 정보를 설정

알파 설정은 서버에 알파 환경을 구축하였다는 가정하에 설정 파일을 만드는 것이고 로컬에서만 개발하고 테스트 할것이면 만들지 않아도 됩니다.

spring:
  profiles: alpha
  .... 생략
  redis:
    host: 서버에 설치한 Redis의 호스트 정보
    port: 서버에 설치한 Redis의 port 정보

RedisConfig.java 추가

@EnableCaching을 선언하여 Redis 캐시사용을 활성화합니다. 여기서 중요한점은 캐시 key별로 유효시간을 설정한다는 점입니다. 또한 캐시키에 유효시간을 설정하지 않은경우에도 default 유효시간으로 캐시가 생성되도록 세팅합니다. 이 설정을 하지 않으면 캐시가 설정될때 유효시간 없이 설정되어 캐시가 지워지지 않으므로 메모리 부족 현상을 겪을수 있습니다.

@RequiredArgsConstructor
@EnableCaching
@Configuration
public class RedisConfig {

    @Bean(name = "cacheManager")
    public RedisCacheManager cacheManager(RedisConnectionFactory connectionFactory) {

        RedisCacheConfiguration configuration = RedisCacheConfiguration.defaultCacheConfig()
                .disableCachingNullValues() // null value 캐시안함
                .entryTtl(Duration.ofSeconds(CacheKey.DEFAULT_EXPIRE_SEC)) // 캐시의 기본 유효시간 설정
                .computePrefixWith(CacheKeyPrefix.simple())
                .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer(new StringRedisSerializer())); // redis 캐시 데이터 저장방식을 StringSeriallizer로 지정

// 캐시키별 default 유효시간 설정
        Map<String, RedisCacheConfiguration> cacheConfigurations = new HashMap<>();
        cacheConfigurations.put(CacheKey.USER, RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(CacheKey.USER_EXPIRE_SEC)));
        cacheConfigurations.put(CacheKey.BOARD, RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(CacheKey.BOARD_EXPIRE_SEC)));
        cacheConfigurations.put(CacheKey.POST, RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(CacheKey.POST_EXPIRE_SEC)));
        cacheConfigurations.put(CacheKey.POSTS, RedisCacheConfiguration.defaultCacheConfig()
                .entryTtl(Duration.ofSeconds(CacheKey.POST_EXPIRE_SEC)));

        return RedisCacheManager.RedisCacheManagerBuilder.fromConnectionFactory(connectionFactory).cacheDefaults(configuration)
                .withInitialCacheConfigurations(cacheConfigurations).build();
    }
}

Cachekey.java 생성

관리 편의성을 위해 캐시 세팅시 사용할 key와 expire등 정적 정보를 하나의 class에서 정의합니다.

public class CacheKey {
    public static final int DEFAULT_EXPIRE_SEC = 60; // 1 minutes
    public static final String USER = "user";
    public static final int USER_EXPIRE_SEC = 60 * 5; // 5 minutes
    public static final String BOARD = "board";
    public static final int BOARD_EXPIRE_SEC = 60 * 10; // 10 minutes
    public static final String POST = "post";
    public static final String POSTS = "posts";
    public static final int POST_EXPIRE_SEC = 60 * 5; // 5 minutes
}

캐싱 객체에 대한 Serializable / @Proxy(lazy = false)

Redis에 객체를 저장하면 내부적으로 직렬화되어 저장되는데, 이때 모델 class에 Serializable을 선언해주지 않으면 오류가 발생할수 있습니다. 또한 JPA를 사용한다면 entity 객체내에서 연관관계 매핑이 있을수 있는데 이때 Lazy(지연)로딩으로 처리되는 경우 Redis 캐싱시 오류가 발생할수 있으며, 이때는 해당 entity에 @Proxy(lazy = false)를 선언해 주도록 합니다.

@Entity
@Getter
@NoArgsConstructor
public class Board extends CommonDateEntity implements Serializable {
    ................ 생략
}

@Entity
@Getter
@NoArgsConstructor
public class Post extends CommonDateEntity implements Serializable {
    ................ 생략
}

// User클래스는 다른 class에서 연관관계 매핑을 통해 Lazy로딩되므로 캐싱시 문제가 발생하지 않도록 proxy false 설정을 한다.
@Proxy(lazy = false)
public class User extends CommonDateEntity implements UserDetails {
    ................ 생략
}

빈번히 호출되는 Method Cache 처리

위의 설정을 기반으로 빈번히 호출되는 Method에 대한 Caching처리를 해보겠습니다.

CustomUserDetailService의 경우 회원정보를 빈번히 호출하므로 캐싱을 적용합니다. @Cacheable을 선언하면 해당 메서드가 호출될때 캐시가 없으면 DB에서 가져와 캐시를 생성하고 데이터를 반환합니다. 캐시가 이미 있는 경우에는 DB를 거치지 않고 바로 캐시 데이터를 반환합니다.

옵션의 value에는 저장시 키값을, key에는 키 생성시 추가로 덧붙일 파라미터 정보를 선언합니다. 여기서는 메서드의 인자인 userPk를 선언합니다. 아래의 경우 캐시키는 user::1004와 같은 형태로 생성됩니다. unless = “#result == null”는 메서드 결과가 null이 아닌 경우에만 캐싱하도록 하는 옵션입니다.

@RequiredArgsConstructor
@Service
public class CustomUserDetailService implements UserDetailsService {

    private final UserJpaRepo userJpaRepo;

    @Cacheable(value = CacheKey.USER, key = "#userPk", unless = "#result == null")
    public UserDetails loadUserByUsername(String userPk) {
        return userJpaRepo.findById(Long.valueOf(userPk)).orElseThrow(CUserNotFoundException::new);
    }
}

CRUD 메서드에 캐싱 처리

게시판 게시물 Create, Read, Update, Delete에 따라 캐싱을 어떻게 하는지 살펴보겠습니다. Spring에서는 캐싱을 위해 다음과 같은 몇가지 annotation을 제공합니다.

@Cacheable
캐시가 존재하면 메서드를 실행하지 않고 캐시된 값이 반환됩니다. 캐시가 존재하지 않으면 메서드가 실행되고 리턴되는 데이터가 캐시에 저장됩니다.
@CachePut
캐시에 데이터를 넣거나 수정시 사용합니다. 메서드의 리턴값이 캐시에 없으면 저장하고 있을경우 갱신합니다.
@CacheEvict
캐시를 삭제합니다.
@Caching
여러개의 캐시 annotation을 실행되어야 할때 사용합니다.

단일 데이터 캐시 처리

// 게시판 정보 조회
@Cacheable(value = CacheKey.BOARD, key = "#boardName", unless = "#result == null")
public Board findBoard(String boardName) {
        return Optional.ofNullable(boardJpaRepo.findByName(boardName)).orElseThrow(CResourceNotExistException::new);
}

다중 데이터 캐시 처리

// 게시글 리스트 조회.
@Cacheable(value = CacheKey.POSTS, key = "#boardName", unless = "#result == null")
public List<Post> findPosts(String boardName) {
    return postJpaRepo.findByBoard(findBoard(boardName));
}

데이터 등록시 캐시 처리

데이터 등록시에는 대부분 캐시 처리가 필요없습니다. 캐시는 읽기 부하를 낮추기 위해 사용하기 때문입니다. 하지만 아래 메서드의 경우 게시글을 1건 등록할경우 게시글 리스트 캐시를 초기화해야 하므로 CacheEvict를 사용하여 캐시를 삭제합니다. 예제에서는 글작성 완료후 다음과 같은 형식의 캐시키가 삭제됩니다. “posts::freeboard”

// 게시글 등록후 캐시 삭제
@CacheEvict(value = CacheKey.POSTS, key = "#boardName")
public Post writePost(String uid, String boardName, ParamsPost paramsPost) {
        Board board = findBoard(boardName);
        Post post = new Post(userJpaRepo.findByUid(uid).orElseThrow(CUserNotFoundException::new), board, paramsPost.getAuthor(), paramsPost.getTitle(), paramsPost.getContent());
        return postJpaRepo.save(post);
}

데이터 수정시 캐시 처리

데이터 수정시에는 상황에 따라 캐시 처리에 두가지 선택이 존재합니다.

첫번째는 기존 캐시를 갱신하는 방법입니다. 단 조건은 수정된 데이터의 캐시만 갱신하면 될경우에 사용합니다. 이 경우에는 CachePut을 사용하면 됩니다.

@CachePut(value = CacheKey.POST, key = "#postId")
public Post updatePost(long postId, String uid, ParamsPost paramsPost) {
        Post post = getPost(postId);
        User user = post.getUser();
        if (!uid.equals(user.getUid()))
            throw new CNotOwnerException();
        post.setUpdate(paramsPost.getAuthor(), paramsPost.getTitle(), paramsPost.getContent());
        return post;
}

두번째는 캐시를 삭제하는 방법입니다. 연관된 캐시가 많아서 갱신이 힘든경우 입니다. 이런 경우는 연관된 여러개의 캐시를 업데이트하는것보다는 삭제하는것이 더 편리합니다. 아래의 경우 게시글과 게시글리스트 2개의 캐시가 연관되어있으므로 캐시를 삭제하는 것이 더 좋습니다. 여러개의 캐시 annotation를 한번에 사용하려면 @Caching을 사용하면 됩니다. 예제에서는 Service를 사용하였는데 그렇게 한 이유는 아래에서 설명합니다.

// 게시글 수정
public Post updatePost(long postId, String uid, ParamsPost paramsPost) {
        Post post = getPost(postId);
        User user = post.getUser();
        if (!uid.equals(user.getUid()))
            throw new CNotOwnerException();
        post.setUpdate(paramsPost.getAuthor(), paramsPost.getTitle(), paramsPost.getContent());
cacheSevice.deleteBoardCache(post.getPostId(), post.getBoard().getName());
        return post;
}

데이터 삭제시 캐시 처리

데이터 삭제시엔 별다른 것 없이 캐시도 삭제하면 됩니다. 단일건인경우 CacheEvict를 사용하면 되고 여러개의 캐시를 지우는 것은 @Caching annotation를 사용하면 됩니다. 예제에서는 Service를 이용하였는데 그렇게 한 이유는 아래에서 설명합니다.

// 게시글 삭제
public boolean deletePost(long postId, String uid) {
    Post post = getPost(postId);
    User user = post.getUser();
    if (!uid.equals(user.getUid()))
        throw new CNotOwnerException();
    postJpaRepo.delete(post);
    cacheSevice.deleteBoardCache(post.getPostId(), post.getBoard().getName());
    return true;
   }
}

CacheService 생성

Spring에서 기본설정으로 Cache를 사용하게되면 Proxy모드로 동작하게 되는데 다음과 같은 제약사항이 있습니다.

  • public method에만 사용가능 함.
  • 같은 객체내의 메서드 호출시 annotation 메서드가 동작하지 않음.
  • RTW(realtime weaving)로 처리 되기 때문에 약간의 성능저하가 있음.

굳이 CacheService를 나누는 이유는 두가지인데 메서드의 인자값으로 삭제할 캐싱키를 조합할수 없어서가 첫번째이고, 두번째는 Proxy의 특성상 같은 객체내에서는 캐싱 처리된 메서드 호출시 동작하지 않기 때문입니다. 두번째 이유를 해결하기 위해 Proxy대신 AspectJ를 사용하는 방법이 있으나 이번장의 내용에서 범위를 벗어나게 되므로 따로 설명하진 않도록 하겠습니다.

다음과 같이 @Caching annotation를 사용하여 여러개의 캐시를 한번에 삭제할 수 있습니다.

@Slf4j
@Service
public class CacheSevice {

    @Caching(evict = {
            @CacheEvict(value = CacheKey.POST, key = "#postId"),
            @CacheEvict(value = CacheKey.POSTS, key = "#boardName")
    })
    public boolean deleteBoardCache(long postId, String boardName) {
        log.debug("deleteBoardCache - postId {}, boardName {}", postId, boardName);
        return true;
    }
}

객체의 변수값으로 캐시 Key 생성

메서드의 인자로 객체가 전달된경우 해당 객체의 특정 필드값으로 캐시키를 조합해야 하는 경우 다음과 같이 사용하면 됩니다. “#객체명.객체의필드값”
아래 예제의 경우 key는 post::1000과 같은 형식으로 생성됩니다.

@CachePut(value = CACHE_KEY, key = "#post.postId")
public Post updatePost(Post post) {
     ..... 생략
}

여러개의 값으로 캐시 Key생성

메서드 인자가 여러개인경우 해당 인자의 값으로 캐시키를 조합하는 경우 다음과 같이 사용하면 됩니다. Cacheable의 key 선언시 괄호로 메서드의 인자들을 묶어서 전달합니다. 예제에서는 key가 post::1000::happy과 같은 형식으로 생성됩니다. 별도의 연산된 결과를 key로 가지고 싶은 경우는 객체의 메서드 사용도 가능합니다.

@Cacheable(value = CACHE_KEY, key = "{#postId, #title}")
public Post getPostMultiKey(long postId, String title) {
        ...... 생략
 }

@CachePut(value = CACHE_KEY, key = "{#post.postId, #post.title}")
public Post updatePostMultiKey(Post post) {
        ....... 생략
}

// 별도의 연산된 결과를 key값으로 사용하고 싶은경우 메서드를 사용가능
@CachePut(value = CACHE_KEY, key = "{#post.postId, #post.getTitle()}")
public Post updatePostMultiKey(Post post) {
        ....... 생략
}

동일 Key 형태의 캐시 모두 삭제

다음과 같은 Key형태를 가진 캐시를 모두 삭제해야 할 경우 allEntries = true 옵션을 주어 일괄 삭제 할 수 있습니다.
User::1, User::2, User::3 …..

@CacheEvict(cacheNames = {"User"}, allEntries = true)
public void clearCache(){}

캐시 생성시 조건 적용

캐시 생성시 간단한 조건을 condition안에 선언하여 적용할 수 있습니다. 여러개의 조건인경우 condition내에서 and 나 or로 조건을 연결시켜 선언하면 됩니다.

@Cacheable(value = CACHE_KEY, key = "{#postId}", condition="#postId > 10")
public Post getPostCondition(long postId) {
..... 생략
}

캐시 KeyGenerator를 사용하여 캐시 Key생성

기본 KeyGenerator를 사용하지 않고 다음과 같이 Custom KeyGenerator를 생성하여 사용이 가능합니다.

KeyGenerator 생성

public class CustomKeyGenerator {
    public static Object create(Object o1, Object o2) {
        return "FRONT:" + o1 + ":" + o2;
    }
}

KeyGenerator를 이용하여 캐싱

CACHE_KEY를 POST로 선언한경우 KeyGenerator에 의해 다음과 같은 형식의 캐시Key가 생성되어 캐시됩니다. “POST::FRONT:1:title_1”

@Cacheable(value = CACHE_KEY, key = "T(com.rest.api.cache.CustomKeyGenerator).create(#postId, #title)")
public Post getPostKeyGenerator(long postId, String title) {
    Post post = new Post();
    post.setPostId(postId);
    post.setTitle("title_" + postId);
    post.setAuthor("author_" + postId);
    post.setContent("content_" + postId);
    return post;
}

Redis 클라이언트

Redis 서버는 Embedded Redis를 통해 따로 설치하지 않고 사용이 가능했지만, Redis 서버에 생성된 캐시를 조회하기 위한 클라이언트가 없어 실제로 캐시가 생성되었는지 확인이 힘들었습니다. 아래의 링크를 통해 FastoRedis를 다운받아 설치하면 다음과 같이 간단한 UI를 통해 Redis 명령어를 실행하고 결과값을 얻을수 있습니다.

https://fastoredis.com/anonim_users_downloads

최신 실습 소스는 아래 GitHub 링크를 참고해 주세요.
https://github.com/codej99/SpringRestApi/tree/cache-data-redis

GitHub Repository를 import하여 Intellij 프로젝트를 구성하는 방법은 다음 포스팅을 참고해주세요.

Docker로 개발 환경을 빠르게 구축하는 것도 가능합니다. 다음 블로그 내용을 읽어보세요!

스프링 api 서버를 이용하여 웹사이트를 만들어보고 싶으시면 아래 포스팅을 참고해 주세요.

연재글 이동[이전글] SpringBoot2로 Rest api 만들기(14) – 간단한 JPA 게시판(board) 만들기
[다음글] SpringBoot2로 Rest api 만들기(16) – AOP와 Custom Annotation을 이용한 금칙어(Forbidden Word) 처리