개발/Spring Boot

[Spring Boot] Spring cache 를 사용해 보자

YJ_Lee 2024. 2. 7. 17:34

Introduction

최근 데이터 게시판 조회수 에 관련하여 최적화 방법을 찾아보던 중 Spring 에도 내장된 캐싱 방법이 있다는 것을 발견하고 알아보기로 하였다.

해당 문서에서는 spring--boot-starter-cache 라이브러리에 대한 간단한 설명 및 실습을 진행한다.

Dependencies

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'    
implementation 'org.springframework.boot:spring-boot-starter-web'  
implementation 'org.springframework.boot:spring-boot-starter-cache'

Conception

Spring Cache 는 메서드 단위로 적용이 가능하며, 캐싱이 적용된 특정 메서드에 어떤 값이 인수로 전달되었을 때, 해당 인수와 리턴 값을 key-value 형태로 저장해 놓았다가 후에 동일한 key 값이 인수로 전달되면 메서드를 실행하지 않고 캐싱된 데이터를 바로 리턴해 준다.

 

예를들어 이러한 메서드가 존재한다고 해 보자.

public int plus(int n) {
    return n + 10;
}

해당 메서드에 캐싱을 적용시키면, 처음 실행 시 인수와 리턴 값을 캐싱한다.

plus(10);
// 임의의 캐시 저장공간 Map (10:20)

후에 plus(10) 이 다시 호출된다면 Map 에 10이 존재하므로 plus 메서드를 실행하지 않고 Map 에서 value를 꺼내 바로 리턴한다.

 

해당 로직은 메서드 전후로 실행되므로 AOP가 적용된 기술임을 예상할 수 있다.

위의 결과로 알 수 있듯이 캐싱이 적용될 메서드는 특정 인수를 전달하면 항상 동일한 리턴 값을 전달하는 메서드에만 적용이 가능하며 임의의 값을 리턴하는 메서드에는 적용이 불가능하다. (정확히는 해서는 안 된다.)

CacheManager

캐시 저장공간은 Spring 에서 CacheManager 를 Bean 으로 등록함으로서 구체화 시킬 수 있다. 만약 Spring Application 실행 시, CacheManager Bean 이 등록되어있지 않다면, 해당 순서대로 저장 공간이 구현되어 있는지 탐색을 시도한다.

1. Generic
2. JCache
3. EnCache 2.x
4. Hazelcast
5. Infinispan
6. Couchbase
7. Redis
8. Caffeine
9. Simple (In-memory)

만약 8번까지 탐색에 실패하면 9번을 구현하는데 In-memory 에 ConcurrentHashMap 을 구현하는 방식으로 캐싱 공간을 마련하게 된다.

Implementaion

@EnableCaching

Spring Boot 에서는 Caching 적용에 어노테이션 방법을 사용하므로 @Configuration 할 코드에 @EnableCaching 어노테이션을 추가함으로써 캐싱 사용이 준비가 완료된다.

 

나는 In-memory 캐싱을 사용하기 때문에 별도의 CacheManager 파일을 만들어 주지 않았다. 따라서 @SpringBootApplication 에 추가해 주었다. (해당 어노테이션 또한 @Conriguration 이 포함된다.)

@SpringBootApplication  
@EnableCaching  
public class PostRedisViewsApplication {  

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

@Cacheable

캐싱을 적용할 메서드를 지정한다.
Spring Boot 같은 WebApp 에서는 보통 DB와 커넥션 횟수를 줄이기 위해 캐싱을 사용한다. 따라서 Service 단에서 DB 에 Select 하는 메서드에 캐싱을 적용한다.

// PostService
@Cacheable(value = "post", key = "#postId")  
public PostDto getPost(long postId) {  
    log.info("Get id: {}", postId);  
    Post post = postRepository.findById(postId)  
        .orElseThrow(EntityNotFoundException::new);  
    return modelMapper.map(post, PostDto.class);  
}

@Cacheable 어노테이션을 통해 해당 메서드를 캐싱하겠다고 선언하였다.

 

value 는 캐싱공간의 이름으로, String 을 통해 참조할 캐싱 공간을 구분하게 된다. 다른 메서드에서 동일하게 @Cacheable("post") 어노테이션을 사용한다면 위의 getPost() 메서드와 동일한 캐싱 공간을 사용하게 된다.

 

key 는 캐싱 데이터를 구분할 key 를 의미한다. 위의 코드에서는 postId를 가지고 캐싱 데이터를 구분한다.


만약 현재 캐싱 데이터가 (1:10) 이라고 가정하면 후에 postId 가 1로 다시 호출되면 캐싱된 데이터를 꺼내 반환한다.

만약 객체를 키로 사용할 경우 hashcode 가 제대로 정의되어 있어야 한다. HashMap은 hashcode로 키를 구분하기 때문이다.

 

key 는 SpEL 로 작성되므로 특정 객체의 필드 하나를 키로 지정할 수 있다. 즉,

@Cacheable(value = "person", key = "#p.id") //Person 의 id 필드를 키로 사용
public void getPerson(Person p) {
 ...
}

public class Person {
    int id;
    int age;
    String name;
}

위와 같이 작성할 수 있다.

@CachePut

캐싱된 데이터를 수정할 때 사용된다.


만약, DB 내의 데이터가 업데이트 되었으나 캐싱된 데이터를 업데이트 하지 않을 경우, 업데이트 되지 않은 캐싱 데이터를 리턴함으로써 데이터의 무결성이 깨질 수 있다. 따라서 DB 업데이트를 하는 Service 메서드에 적용함으로써 캐싱공간과 DB의 데이터를 일관되게 관리할 수 있다.

// PostService
public PostDto updatePost(UpdatePost updatePost) {  
    Post post = postRepository.findById(updatePost.getId())  
        .orElseThrow(EntityNotFoundException::new);  

    post.setTitle(updatePost.getTitle());  
    Post resultPost = postRepository.save(post);  
    return modelMapper.map(resultPost, PostDto.class);  
}

@CacheEvict

캐싱된 데이터를 삭제할 때 사용된다.


만약 DB 내의 데이터를 삭제하였으나 캐싱된 데이터를 삭제하지 않을 경우 실제로는 존재하지 않는 캐싱된 데이터를 리턴하는 상황이 벌어질 수 있다. 따라서 DB 데이터를 삭제하는 Service 메서드에 적용한다.

// PostService
@CacheEvict(value = "post", key = "#postId")  
public void deletePost(long postId) {  
    Post post = postRepository.findById(postId)  
        .orElseThrow(EntityNotFoundException::new);  
    postRepository.delete(post);  
}

References