본문 바로가기

Language & Framework/Spring

JPA 2차 캐시 세팅부터 테스트 코드까지. 그리고 멍청한 ㅜ 착각.

 

최근 블로그에 언급할 수 없는 모종의 이유로 일주일간 어떤 프로젝트를 진행했는데. 

빈번하게 조회되는 데이터에 대해 2차 캐시를 설정해서 최적화를 진행..하려고 했다.

다른 캐시 전략이 아닌 2차 캐시를 사용하려고 한 이유는 해당 프로젝트만의 언급할 수 없는 어떤 특성 때문이였다.

 

근데 일주일이라는 짧은 시간 동안 많은 분량을 소화해야 했기 때문에 결국 2차 캐시가 똑바로 동작하는지 테스트하지 못했고, 지금와서 확인해보니 역시나 똑바로 동작하지 않고 있었다 ^^~~~

 

테스트 코드는 커녕 눈으로 확인해볼 시간도 없었으니.. 라는 핑계를 대보며 이제라도 똑바로 2차 캐시가 어떻게 동작하는지 테스트 코드로 확인하려고 한다.

 

참고로 이론적인 부분은 레퍼런스에 친절하게 나와있기 때문에 따로 다루지 않을 것이다.

무조건 무조건 무조건 레퍼런스를 읽자.

막상 읽어보면 별로 길지 않다.

 

https://docs.jboss.org/hibernate/orm/5.6/userguide/html_single/Hibernate_User_Guide.html#caching

 

Hibernate ORM 5.6.15.Final User Guide

Fetching, essentially, is the process of grabbing data from the database and making it available to the application. Tuning how an application does fetching is one of the biggest factors in determining how an application will perform. Fetching too much dat

docs.jboss.org

 

이 포스팅은 2차 캐시를 세팅하는 간단한 과정을 보고 싶은 사람이나, 나처럼 테스트 코드 작성하다가 도저히 안돼서 눈물 줄줄 흘리고 있는 사람한테 적합 한글이다.

 

 

 

1. YML Setting

 

다른 부분은 그냥 평소 사용하던대로 하면 된다.

아래 기재된 것들만 추가해주자.

spring.jpa.properties.hibernate.cache.use_second_level_cache: true
spring.jpa.properties.hibernate.cache.region.factory_class: org.hibernate.cache.ehcache.EhCacheRegionFactory
spring.jpa.properties.hibernate.cache.generate_statistics: true

logging.level.org.hibernate.cache: debug

use_second_level_cache랑 region.factory_class 항목은 따로 설명하지 않아도 될거고, generate_statistics는 Hibernate가 Cache에 대한 통계를 기록하도록 설정하는 옵션이다.

 

해당 옵션을 true로 설정해야 테스트 코드에서 cache hit, cache miss, cache put이 각각 몇 번 발생했는지 확인할 수 있다.

로깅 레벨은 굳이 설정하지 않아도 되긴 하는데.. 취향대로 선택하자.

나는 테스트 때문에 6시간이나 헤매고 있었기 때문에 설정한 것이다.

 

참고로 꼭 ehCache가 아니여도 2차 캐시를 저장할 수 있다.

"second level cache with redis"로 검색하면 수두룩하게 나오니까 그 쪽에 관심이 있다면 찾아보삼.

 

 

2. Gradle Setting

 

다른 건 알아서 하면 되고, ehCache에 대한 의존성만 추가해주면 된다.

hibernate-ehcache는 hibernate가 ehcache를 사용하기 위해 필요한 의존성이고, 아래는 그냥 ehcache 자체 의존성이다.

implementation 'org.hibernate:hibernate-ehcache:5.6.15.Final'
implementation 'net.sf.ehcache:ehcache:2.10.9.2'

 

 

3. Entity Setting

 

 

당연히 알아서 하면 된다.

글로만 볼 사람을 위해 참고하라고 올렸다.

설정을 바꿔가며 테스트할 예정이라 Cache 관련 어노테이션은 적용하지 않았다.

 

기왕 하는 거 엔티티 하나만 달랑 만들어서 하지 말고, 꼭 ManyToOne으로 만들어서 하자. 이유 있음.

나처럼 OneToOne 이상한 짓은 할 필요 없다, 그냥 해당 프로젝트에서 사용했던 구조라서 그대로 썼을 뿐임.

 

 

 

4. Write Test Code

 

우선 기본 테스트 코드를 작성해놓고 눈으로 확인할 예정이다.

물론 눈으로 보는 거 말고, Jupiter나 AssertJ를 활용한 테스트도 가능하다.

근데 지금은 안할 거다. 밑에서 마저 보고 응용해서 직접 하삼.

 

 
 
 
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
@SpringBootTest
public class SecondLevelCacheTest {
    @PersistenceUnit
    EntityManagerFactory entityManagerFactory;
 
    @Autowired
    TransactionTemplate transactionTemplate;
 
    @Autowired
    HeaderEntityRepository headerEntityRepository;
 
    @Autowired
    NodeEntityRepository nodeEntityRepository;
 
    HeaderEntity headerEntity;
 
    @BeforeEach
    void init1() {
        transactionTemplate.executeWithoutResult(status -> {
                    headerEntity = new HeaderEntity();
                    headerEntityRepository.save(headerEntity);
 
                    NodeEntity nodeEntityA = new NodeEntity();
                    NodeEntity nodeEntityB = new NodeEntity();
                    NodeEntity nodeEntityC = new NodeEntity();
 
                    nodeEntityA.setNextNode(nodeEntityB);
                    nodeEntityB.setNextNode(nodeEntityC);
                    nodeEntityC.setNextNode(null);
 
                    nodeEntityA.setPrevNode(null);
                    nodeEntityB.setPrevNode(nodeEntityA);
                    nodeEntityC.setPrevNode(nodeEntityB);
 
                    nodeEntityA.setHeader(headerEntity);
                    nodeEntityB.setHeader(headerEntity);
                    nodeEntityC.setHeader(headerEntity);
 
                    List<NodeEntity> nodes = List.of(nodeEntityA, nodeEntityB, nodeEntityC);
                    nodeEntityRepository.saveAll(nodes);
                    headerEntity.getNodes().addAll(nodes);
                }
        );
    }
 
    @Test
    void init() {
        //given
        entityManagerFactory.getCache().evictAll();
        Statistics statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics();
        statistics.setStatisticsEnabled(true);
 
        //when
        transactionTemplate.executeWithoutResult(status -> {
            HeaderEntity headerEntity = headerEntityRepository.findById(1L).get();
            headerEntity.getNodes()
                    .forEach(e -> System.out.println(e.getId()));
        });
 
        transactionTemplate.executeWithoutResult(status -> {
            HeaderEntity headerEntity = headerEntityRepository.findById(1L).get();
            List<NodeEntity> nodes = headerEntity.getNodes();
            headerEntity.getNodes()
                    .forEach(e -> System.out.println(e.getId()));
        });
 
        //then
        System.out.println("hit : " + statistics.getSecondLevelCacheHitCount());
        System.out.println("miss : " + statistics.getSecondLevelCacheMissCount());
        System.out.println("put : " + statistics.getSecondLevelCachePutCount());
    }
}
 
cs

 

기존 테스트 코드들과 몬가.. 몬가 다르다.

이유는 하나의 트랜잭션에서 테스트 코드를 실행하면 테스트가 정상적으로 실행되지 않는다.

 

원래 나는 하나의 트랜잭션에서 findById로 데이터를 조회하고, entityManager의 detach() 메서드를 사용해 해당 엔티티들을 1차 캐시에서 제거한 뒤 다시 findById로 데이터를 조회해서 CacheHit 여부를 판단하려고 했다.

 

근데 그렇게 하면 안된다.

왜 안되는지 나도 알고 싶다.

정확하게는 cacheHit은 되는데, "Cache hit, but item is unreadable/invalid"라는 로그가 출력되면서 쿼리를 다시 실행한다.

 

일단 나는 6시간을 헤매다가 결국 포기하고 예전에 동시성 테스트 시 사용했던 transactionTemplate을 활용하기로 했다.

transactionTeamplate을 활용하면 간편하게 spring의 transaction을 실행할 수 있다.

 

EntityManagerFactory는 Hibernate의 Session을 가져오기 위해 필요하고, Session에서 Statistics 객체를 가져와 setStatisticsEnabled 설정까지 완료해줘야 CacheHit에 대한 기록이 진행된다.

 

그리고 YML 설정 빼먹지 마삼.

 

 

 

5. First Test

 

 

테스트를 돌려보면 hit, miss, put이 모두 0이고 쿼리는 총 4

혹시 쿼리가 한 번 실행되고 있다면 몬가.. 몬가 잘못된 거니까 테스트 코드 다시 작성하삼.

 

 

 

6. Add Annotation

 

 

이제 어노테이션을 추가해볼 시간이다.

우선 One에 해당하는 엔티티에 @Cacheable을 추가하고, @org.hibernate.annotations.Cache를 추가해 어떤 캐시 전략을 사용할 것인지 명시해야 한다.

캐싱 전략은 설명 안한다.

제일 위에 언급한 문서에 들어가면 모두 써있다.

 

 

테스트 코드를 다시 실행해보면 hit, miss, put의 카운트가 1씩 증가한 것을 확인할 수 있다.

 

1. 첫 번째로 Header를 조회할 때 -> cache가 없으므로 cacheMiss++, cache를 추가하여 cachePut++

2. 두 번째로 Header를 조회할 때 -> cache가 있으므로 cacheHit++

 

nodes에 대한 Cache를 추가하지 않았기 때문에 총 쿼리는 3회 실행된다. (Header 1회, Node 2회)

 

 

 

 

7. 그러면 nodes에 Cache 설정을 추가해볼까요.

 

 

중요한 부분이다.

위에서 엔티티 하나만 달랑 만들지 말라고 한 이유가 있다.

 

 

CacheHit, Miss, Put이 모두 2회로 증가했다.

그러면 쿼리는 몇 번 실행됐을까?

 

5번 실행됬다.

캐싱하지 않았을 때보다 더 많은 쿼리가 실행되고 있는 것이다.

이유는 레퍼런스에서 확인할 수 있다.

 

https://docs.jboss.org/hibernate/orm/5.6/userguide/html_single/Hibernate_User_Guide.html#caching

"The Collection cache entry will store the entity identifiers only"

 

컬렉션에 캐시를 지정할 경우, 해당 엔티티 자체를 캐싱하는 것이 아니라 식별자만 저장한다.

따라서 컬렉션에 저장되는 엔티티에도 별도로 캐시 설정을 해주지 않게 된다면, 캐싱된 식별자를 이용해 데이터를 "1개씩" 조회한다.

 

만약에 컬렉션에 데이터가 100개 저장되어 있으면 쿼리도 100번 실행되는 것이다.

까먹지 말고 반드시 해당 엔티티에도 Cache 설정을 적용해주자.

 

 

 

 

8. Node Entity에도 Cache를 적용합시다.

 

 

이번에는 NodeEntity에도 @Cacheable과 @Cache 어노테이션을 적용했다.

다시 한 번 돌려보자.

 

 

 

Hit이 5회, miss가 2회, put이 5회 발생했다.

이 오묘한 숫자는 뭘까?

 

1. Header Entity를 최초 조회한다

  -> Header Entity가 캐싱되어 있지 않으므로 cache miss가 발생한다. (miss 1회)

  -> Header Entity를 캐싱한다. (put 1회)

  -> Header Entity의 Collection이 캐싱되어 있지 않으므로 cache miss가 발생한다. (miss 2회)

  -> Collection을 캐싱한다. (put 2회)

  -> Header Entity를 통한 캐싱이였으므로 Header Entity에서 Node를 탐색한 cache miss가 별도로 발생하지 않음.

2. Header Entity를 두 번째 조회한다.

  -> Header Entity가 캐싱되어 있으므로 cache hit이 발생한다. (hit 1회)

  -> Header Entity의 컬렉션이 캐싱되어 있으므로 cache hit이 발생한다. (hit 2회)

  -> Header Entity에서 3개의 데이터를 순차 조회한다. 기존에 해당 데이터들을 조회한 이력이 있으므로 모두 캐싱되어 있다. (hit 5회)

 

쿼리는 당연히 총 2회 실행된다. (Header 1회, 연관된 Node 1회)

 

트랜잭션 템플릿을 복사해서 5개까지 늘려봤다.

 

 

기존에 캐싱된 데이터가 있으니 당연히 쿼리는 여전히 두 번만 실행되고 있고, miss와 put도 2와 5에서 더이상 증가하지 않는다.

cache hit 횟수만 계속해서 증가한다.

 

 

 

 

9. 의문점(이였는데 해결됨;; 똥글이라서 읽어도 아무런 영양가가 없습니다.)

 

Node Entity의 OneToOne 관계에 LazyLoading 설정을 추가해보자.

 

 

그리고 테스트 코드를 아래와 같이 변경해서, 첫 번째 조회 시 한 개의 엔티티만 불러오도록 한다.

 

 

그러면 아래와 같은 쿼리 결과를 확인할 수 있다.

 

 

분명히 오직 한 개의 node entity만을 쿼리로 불러왔다.

그러면 결과적으로 쿼리는 총 몇 번 실행되었고, cache hit, miss, put의 결과는 어떨까?

 

 

 

정답은 여전히 쿼리는 총 두 번 실행되고, hit, miss, put 횟수도 똑같다.

그리고 각 NodeEntity에 대한 주소값도 이미 가지고 있다.

혹시나 싶어서 NodeEntity에 name이라는 필드를 추가하고, 해당 필드를 불러와봤지만 여전히 추가 쿼리는 실행되지 않는다.

 

대체 이게 무슨 일일까?

쿼리해서 가져온 데이터는 분명히 한 개인데, 어느 시점에서 나머지 두 개의 node까지 캐싱한 걸까?

정말 의문스러운 일이다.

 

오늘은 알아내지 못했지만 다음 기회에 꼭 알아내고 싶다.. Hibernate의 신비..

 

아무튼 오늘은 여기서 끝이다.

 

 

 

--

 

 

라고 썼었는데, 그냥 내가 대충 보고 바보 같이 생각한 것이였다.

쿼리를 다시 읽어보면 애초에 컬렉션에서 get(0)을 하든, 다 불러오든 상관 없이 header_id를 기준으로 모든 데이터를 가져온다.

연관관계의 주인이 어차피 Many에 있고, get(0)이 무엇인지는 모든 Many를 불러와야 알 수 있는 부분이니 당연하다.

 

내가 node id를 기준으로 데이터를 불러오고 있다고 착각한 이유는 LazyLoading 옵션을 추가하기 전까지는 쿼리가 다른 형태로 실행되고 있었는데, LazyLoading을 붙이고나서 쿼리 형태가 변해버리길래 대충 읽어보고 "엥? 하나만 조회했는데 왜 다 캐싱하지"라고 말도 안되는 생각을 했기 때문이다.

JPA의 동작을 아직도 꿰차고 있지 못하다니.. 나의 치부를 들킨 기분이라 부끄럽다. 오늘부터 JPA 복습 달려야겠다.

 

늘 반복되는 일상.. "앗, 이해할 수 없는 이상한 일이 생겼다" => "ㅋㅋ 이상한 건 저였구요~"