Spring - Spring Cache 추상화(스프링 캐시 추상화),@Cacheable,@CacheEvict,@CachePut

2019. 9. 11. 23:54Web/Spring

 

이번에 다루어 볼 포스팅은 Spring Cache이다. 스프링 3.1부터 빈의 메서드에 캐시 서비스를 적용할 수 있는 기능을 제공한다. 캐시 서비스는 트랜잭션(@Transaction)과 마찬가지로 AOP를 이용해 메서드 실행 과정에 우리가 모르도록 투명하게 적용된다. 또한 스프링은 장점으로 캐시 서비스 구현 기술에 종속되지 않도록 추상화된 서비스를 제공해주므로 환경이 바뀌거나 구현 기술이 바뀌어도 우리가 적용한 코드의 변경 없이 구현 기술을 바꿔줄 수 있다.

 

그렇다면 여기서 짚고 넘어갈 것이, "애플리케이션 빈의 메서드에 캐시를 적용하는 이유 혹은 목적"은 무엇일까?

 

캐시는 기본적으로 성능의 향상을 위해 사용한다. 여기서 성능 향상은 애플리케이션이 갑자기 막 빨라지고 하는 성능 향상이 아니다. 어떤 요청을 처리하는데 연산이 아주 복잡하거나 데이터베이스와 같은 백엔드 시스템에 많은 부하를 주거나, 외부 API 호출처럼 시간이 오래 걸린다면 캐시의 사용을 고려해볼 만하다. 하지만 결코 앞과 같은 상황에 직면했다고 캐시 기능을 사용하면 안된다.

 

캐시(Cache)는 임시 저장소라는 뜻이다. 복잡한 계산이나 데이터베이스 작업, 외부 API 요청의 처리 결과 등을 임시 저장소인 캐시에 저장해뒀다가 동일한 요청이 들어오면 복잡한 작업을 수행해서 결과를 만드는 대신 이미 캐시에 보관된 결과를 바로 돌려주는 방식이다. 만약 이런 상황이 있다고 생각하자.

 

사용자가 많은 웹사이트 서비스가 있다. 메인 페이지에 접속하면 항상 공지사항이 출력돼야 한다. 공지사항은 어떠한 추가적인 내용이 추가되거나 혹은 내용이 수정되기 전까지는 모든 사용자에게 동일한 내용을 보여준다. 그렇다면 사용자가 만명이 있고 사용자가 들어올때 마다 공지사항을 데이터베이스에 접근하여 보여준다면 총 만번의 데이터베이스 액세스가 이루어질 것이다. 사실 데이터베이스에 자주 접근하는 연산은 비용이 비싸다. 그만큼 데이터베이스에 부하가 간다는 뜻이다. 만약 이럴때 캐시를 적용한다면 첫 사용자가 접근할때만 데이터베이스에서 공지사항 내용을 가져오고 나머지 9999명은 캐시에 저장된 내용을 그대로 돌려준다면 만명의 사용자가 한번의 데이터베이스 액세스로 공지사항을 볼 수 있게 되는 것이다.

 

이렇게 캐시는 여러가지 장점을 가졌지만, 그렇다면 모든 상황에서 캐시를 사용하면 안된다. 

 

"값비싼 비용이 들어가는 요청, 데이터베이스 접근, 외부 요청 등에 캐시를 사용하지만 여기서 전제가 있다. 캐시는 반복적으로 동일한 결과를 주는 작업에만 이용해야 한다. 매번 다른 결과를 돌려줘야 하는 작업에는 캐시를 적용해봐야 오히려 성능이 떨어진다."

 

그렇다면 캐시를 사용하면 아주 주의깊게 체크해야 할 것이 있다.  바로, 캐시에 저장해둔 컨텐츠의 내용이 바뀌는 상황 혹은 시점을 잘 파악하는 것이다. 공지사항이 추가되거나 수정되었다면 캐시는 아직까지 이전 내용의 공지사항을 가지고 있을 것이므로, 이러한 상황에서는 캐시에 저장된 내용을 삭제하고 첫 사용자가 접근하여 캐시의 내용을 최신 내용으로 업데이트 해야한다.

 

이제 직접 코드를 보며 스프링에서 사용할 수 있는 캐시 서비스를 알아보자. 우선 캐시 기능을 사용하기 위해서는 @EnableCaching 애너테이션을 달아주어야 한다.

 

애너테이션을 이용한 캐시 기능 사용(@Cacheable)

스프링의 캐시 서비스 추상화는 AOP를 이용한다. 캐시 기능을 담은 어드바이스는 스프링이 제공한다. 이를 적용할 대상 빈과 메서드를 선정하고 속성을 부여하는 작업은 <aop:config>,<aop:advisor> 같은 기본 AOP 설정 방법을 이용할 수 있다. 하지만 이보다는 @Transaction 애노테이션을 이용하는 것처럼 캐시 서비스도 애너테이션을 이용할 수 있다.

 

캐시 서비스는 보통 메서드 단위로 지정한다. 클래스나 인터페이스 레벨에 캐시를 지정할 수도 있지만 캐시의 특성상 여러 메서드에 일괄적인 설정을 하는 경우는 드물다. 캐시에 저장할 내용과 캐시 설정 정보로 메서드의 리턴 값과 메서드 파라미터를 사용하기에 메서드 레벨로 적용하는 것이 수월하다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
@Getter
@Setter
@AllArgsConstructor
@NoArgsConstructor
@ToString
public class Product {
 
    private int productId;
    private ProductType type;
    private String productName;
    private boolean isBest;
 
}
 
@Slf4j
@Service
public class ProductService {
 
    @Cacheable("product")
    public List<Product> bestProductsByType(ProductType productType){
 
        log.info("ProductService.bestProductsByType");
 
        List<Product> bestProducts = new ArrayList<>();
        if(productType.equals(ProductType.TOP)){
            bestProducts.add(new Product(1,ProductType.TOP,"GUCCI SHIRTS",true));
        }else if(productType.equals(ProductType.PANTS)){
            bestProducts.add(new Product(1,ProductType.PANTS,"GUCCI PANTS",true));
        }else{
            bestProducts.add(new Product(1,ProductType.OUTER,"GUCCI OUTER",true));
        }
 
        return bestProducts;
    }
 
    @Cacheable(value = "product", key = "#productType")
    public List<Product> bestProduct(User user, LocalDateTime time, ProductType productType){
 
        log.info("ProductService.bestProduct");
 
        List<Product> bestProducts = new ArrayList<>();
        if(productType.equals(ProductType.TOP)){
            bestProducts.add(new Product(1,ProductType.TOP,"GUCCI SHIRTS",true));
        }else if(productType.equals(ProductType.PANTS)){
            bestProducts.add(new Product(1,ProductType.PANTS,"GUCCI PANTS",true));
        }else{
            bestProducts.add(new Product(1,ProductType.OUTER,"GUCCI OUTER",true));
        }
 
        return bestProducts;
    }
 
    @Cacheable(value = "product", condition = "#user.firstName == 'sora'")
    public List<Product> bestProductForGoldUser(User user){
 
        log.info("ProductService.bestProductForGoldUser");
 
        List<Product> bestProducts = new ArrayList<>();
        bestProducts.add(new Product(1,ProductType.OUTER,"CHANNEL OUTER",true));
        return bestProducts;
    }
 
    @Cacheable("product")
    public List<Product> allBestProduct(){
 
        log.info("ProductService.allBestProduct");
 
        List<Product> bestProducts = new ArrayList<>();
        bestProducts.add(new Product(1,ProductType.TOP,"GUCCI SHIRTS",true));
        bestProducts.add(new Product(1,ProductType.PANTS,"GUCCI PANTS",true));
        bestProducts.add(new Product(1,ProductType.OUTER,"GUCCI OUTER",true));
        return bestProducts;
    }
 
}
cs

 

위 코드는 @Cacheable 애너테이션을 이용하여 캐시 기능을 사용하는 예제 코드이다. 우선 첫번째로 ProductService 클래스의 첫번째 메서드를 살펴보자. 해당 메서드가 처음 호출되는 순간에 캐시에 "product"라는 이름의 공간이 생긴다. 그리고 파리미터로 productType(ProductType.TOP이라는 enum이 들어왔다 생각)이 들어오는데, 해당 값을 이용하여 키를 만든다. 이 말은 "product"라는 캐시 공간에 특정 키값으로 데이터를 캐시한다는 뜻이다. 그리고 첫번째 호출 이후에 동일한 메서드에 동일한 파라미터로 요청이 들어오면 "product"라는 캐시 공간에 ProductType.TOP을 이용한 키값을 가진 데이터가 있는 지 확인하고 만약 데이터가 존재하면 해당 메서드를 호출하지 않고 캐시에 있는 데이터를 돌려준다. 만약에 해당 키값으로 데이터가 없다면 메서드 로직을 수행한 후에 반환 값을 캐시에 적재한다.

 

그렇다면 두번째 메서드처럼 파라미터가 여러개인 메서드라면 어떻게 할까? 답은 바로 코드에 있다. 바로 key라는 설정 값에 SpEL식을 이용해 키값으로 사용할 파라미터 값을 명시할 수 있다. 그렇다면 이제 이 메서드는 명시한 파라미터로만 키값을 생성하게 된다. 만약 파라미터가 특정 객체로 들어온다면 key = "Product.productType" 과 같이 설정할 수 있다. 만약 키값으로 사용할 어떠한 설정 내용도 명시하지 않는 다면 어떻게 처리할까? 바로 여러개의 파라미터의 hashCode()를 조합하여 키값을 생성한다. 그렇지만 해시 코드 값의 조합이 키로서 의미가 있다면 문제가 없지만 대부분은 그렇지 않은 경우가 많기에 key 설정값을 이용하여 키로 사용할 파라미터를 명시해주는 것이 좋다.

 

세번째는 특정 조건에서만 캐싱을 하고 나머지 상황에서는 캐싱을 하고 싶지 않은 경우가 있다. 이럴 경우에는 세번째 메서드처럼 condition이라는 설정 값을 이용한다. 이 설정은 파라미터가 특정 값으로 들어올때만 캐시하고 싶을 때, 사용가능하다. 예제는 위와 같이 사용하면 된다.

 

마지막으로 메서드에 매개변수가 없다면 어떻게 될까? 이럴때는 디폴트 키 값으로 세팅이 되기 때문에 메서드가 처음 호출되면 무조건 데이터가 캐싱이 된다.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
@SpringBootTest
@RunWith(SpringRunner.class)
public class ProductServiceTest {
 
    @Autowired
    ProductService service;
    List<Product> bestProducts;
    User basicUser;
    User goldUser;
 
    @Before
    public void init(){
        bestProducts = new ArrayList<>();
        basicUser = new User(1,"yeoseong","yoon",28, Level.BASIC);
        goldUser = new User(1,"sora","hwang",30, Level.GOLD);
    }
 
    @Test
    public void noArgCacheTest(){
        bestProducts.addAll(service.allBestProduct());
 
        MatcherAssert.assertThat(bestProducts.size(), CoreMatchers.equalTo(3));
 
        service.allBestProduct();
    }
 
    @Test
    public void oneArgCacheTest(){
 
        bestProducts.addAll(service.bestProductsByType(ProductType.TOP));
 
        MatcherAssert.assertThat(bestProducts.get(0).getProductName(),CoreMatchers.equalTo("GUCCI SHIRTS"));
 
        service.bestProductsByType(ProductType.TOP);
 
        clearList(bestProducts);
 
        bestProducts.addAll(service.bestProductsByType(ProductType.PANTS));
 
        MatcherAssert.assertThat(bestProducts.get(0).getProductName(),CoreMatchers.equalTo("GUCCI PANTS"));
 
        service.bestProductsByType(ProductType.PANTS);
 
        clearList(bestProducts);
 
        bestProducts.addAll(service.bestProductsByType(ProductType.OUTER));
 
        MatcherAssert.assertThat(bestProducts.get(0).getProductName(),CoreMatchers.equalTo("GUCCI OUTER"));
 
        service.bestProductsByType(ProductType.OUTER);
 
    }
 
    @Test
    public void manyArgsCacheTest(){
        bestProducts.addAll(service.bestProduct(basicUser, LocalDateTime.now(),ProductType.TOP));
 
        MatcherAssert.assertThat(bestProducts.get(0).getProductName(),CoreMatchers.equalTo("GUCCI SHIRTS"));
 
        service.bestProduct(goldUser, LocalDateTime.now(),ProductType.TOP);
    }
 
    @Test
    public void conditionCacheTest(){
        bestProducts.addAll(service.bestProductForGoldUser(basicUser));
 
        MatcherAssert.assertThat(bestProducts.get(0).getProductName(),CoreMatchers.equalTo("CHANNEL OUTER"));
 
        service.bestProductForGoldUser(goldUser);
        service.bestProductForGoldUser(goldUser);
    }
 
    private void clearList(List<Product> products){
        products.clear();
    }
 
}
cs

 

위의 테스트 코드를 실행시키면 캐시가 적용된 이후에는 더 이상 ProductService 메서드의 로그가 출력되지 않을 것이다. 그 말은 캐시가 적용되어있다면 메서드 자체를 호출하지 않는 것을 알 수 있다.

 

캐시 데이터 삭제(@CacheEvict)

캐시는 적절한 시점에 제거돼야 한다. 캐시는 메서드를 실행했을 때와 동일한 겨로가가 보장되는 동안에만 사용돼야 하고 메서드 실행 결과가 캐시 값과 달리지는 순간 제거돼야 한다. 캐시를 적절히 제거해주지 않으면 사용자에게 잘못된 결과를 리턴하게 된다.

 

캐시를 제거하는 방법은 두 가지가 있다. 하나는 일정한 주기로 캐시를 제거하는 것과 하나는 캐시에 저장한 값이 변경되는 상황이 생겼을 때 캐시를 삭제하는 것이다.

 

캐시의 제거에도 AOP를 이용한다. 간단하게 메서드에 @CacheEvict 애너테이션을 붙여주면 된다. 캐시 삭제도 캐시 적재와 동일하게 키값을 기준해 적용한다. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
    @CacheEvict("product")
    public void clearProductCache(){
        log.info("ProductService.clearProductCache");
    }
 
    @CacheEvict(value = "product", key = "#productType")
    public void clearProductCache(ProductType productType){
        log.info("ProductService.clearProductCache");
    }
 
    @CacheEvict(value = "product", condition = "#user.firstName == 'sora'")
    public void clearProductCache(User user){
        log.info("ProductService.clearProductCache");
    }
    //product의 모든 키값에 해당하는 캐시 데이터 삭제
    @CacheEvict(value = "product", allEntries = true)
    public void clearProductCacheAll(){
        
    }
cs

 

위의 코드를 이용하여 테스트 코드를 만들어 로그가 찍히는 횟수를 확인하자.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
@SpringBootTest
@RunWith(SpringRunner.class)
public class ProductServiceTest {
 
    @Autowired
    ProductService service;
    List<Product> bestProducts;
    User basicUser;
    User goldUser;
 
    @Before
    public void init(){
        bestProducts = new ArrayList<>();
        basicUser = new User(1,"yeoseong","yoon",28, Level.BASIC);
        goldUser = new User(1,"sora","hwang",30, Level.GOLD);
    }
 
    @Test
    public void clearCacheTest(){
        service.allBestProduct();
        service.clearProductCache();
        service.allBestProduct();
        System.out.println("===================================");
        service.bestProductsByType(ProductType.TOP);
        service.bestProductsByType(ProductType.PANTS);
        service.clearProductCache(ProductType.TOP);
        service.bestProductsByType(ProductType.TOP);
        service.bestProductsByType(ProductType.PANTS);
        System.out.println("===================================");
        service.bestProductsByType(ProductType.TOP);
        service.clearProductCache();
        service.bestProductsByType(ProductType.TOP);
        System.out.println("===================================");
        service.bestProductForGoldUser(goldUser);
        service.clearProductCache(basicUser);
        service.bestProductForGoldUser(goldUser);
        service.clearProductCache(goldUser);
        service.bestProductForGoldUser(goldUser);
    }
 
}
cs

 

이외에 @CachePut이라는 애너테이션이 존재한다. 이 애너테이션은 캐시에 값을 저장하는 용도로 사용한다. @Cacheable과 비슷하게 메서드 실행 결과를 캐시에 저장하지만 @CachePut은 캐시 데이터를 사용하는 것이 아니고 해당 애너테이션이 붙은 메서드를 호출 할때마다 결과 데이터를 캐시에 적재한다. 보통 한 번에 캐시에 많은 정보를 저장해두는 작업이나, 다른 사용자가 참고할 정보를 생성하는 용도로만 사용되는 메서드에 이용할 수 있다.

 

Cache Manager

스프링의 캐시 서비스는 AOP를 이용해 애플리케이션 코드를 수정하지 않고도 캐시 부가기능을 메서드에 적용할 수 있게 해준다. 동시에 캐시 기술의 종류와 상관없이 후상화된 스프링 캐시 API를 이용할 수 있게 해주는 서비스 추상화를 제공한다. 캐시 기능을 적용하는 AOP 어드바이스는 스프링이 제공해주는 것을 애너테이션을 통해 적용하면 되므로, 우리가 신경 쓸 부분은 적용할 캐시 기술을 선정하고 캐시 관련 설정을 넣어주는 것이다. 

 

캐시 추상화에서는 적용할 캐시 기술을 지원하는 캐시 매너저를 빈으로 등록해줘야 한다. 여기서는 자세한 설정법은 다루지 않고 캐시 매니저에는 무엇이 있는 지만 다루어 볼 것이다.

 

캐시 매니저 설명
ConcurrentMapCacheManager ConcurrentMapCache 클래스를 캐시로 사용하는 캐시 매니저다. ConcurrentHashMap을 이용해 캐시 기능을 구현한 간단한 캐시다. 캐시 정보를 Map 타입으로 메모리에 저장해두기 때문에 빠르고 별다른 설정이 필요없다는 장점이 있지만, 실제 서비스에서 사용하기에는 기능이 빈약하다. 캐시별 용량 제한이나 다양한 저장 방식 지원, 다중 서버 분산과 같이 고급 캐시 프레임워크가 제공하는 기능을 지원하지 않는다. 따라서 저장될 캐시 양이 많지 않고 간단한 기능에 적용할때 혹은 테스트 용도로만 사용해야 한다.
SimpleCacheManager 기본적으로 제공하는 캐시가 없다. 따라서 프로퍼티를 이용해서 사용할 캐시를 직접 등록해줘야 한다. 스프링 Cache 인터페이스를 구현해서 캐시 클래스를 직접 만드는 경우 테스트에서 사용하기 적당하다.
EhCacheCacheManager 자바에서 가장 인기있는 캐시 프레임워크의 하나인 EhCache를 지원하는 캐시 매니저다. 본격적으로 캐시 기능을 적용하려면 사용을 고려할만하다.
CompositeCacheManager 하나 이상의 캐시 매니저를 사용하도록 지원해주는 혼합 캐시 매니저다. 여러 개의 캐시 기술이나 캐시 서비스를 동시에 사용해야 할 때 이용할 수 있다. CompositeCacheManager의 cacheManagers 프로퍼티에 적용할 캐시 매니저 빈을 모두 등록해주면 된다.
NoOpCacheManager 아무런 기능을 갖지 않은 캐시 매니저다. 보통 캐시가 지원되지 않는 환경에서 동작할 때 기존 캐시 관련 설정을 제거하지 않아도 에러가 나지 않게 해주는 기능이다.

 

여기까지 스프링 캐시 추상화에 대해 다루어보았다. 아마 다음 포스팅은 직접 상용에서 사용할 수 있을 만한 캐시 매니저 구현를 이용해보는 포스팅이 될 것같다.