개발놀이터

JPA 2차 캐시 (feat. 1차 캐시) 본문

JPA/JPA

JPA 2차 캐시 (feat. 1차 캐시)

마늘냄새폴폴 2023. 6. 20. 18:45

이번 포스팅에서는 JPA의 2차 캐시에 대해서 알아보도록 하겠습니다. 

 

JPA에서 우리가 익숙한 것은 1차 캐시입니다. 보통 영속성 컨텍스트로 많이 알려진 1차 캐시는 JPA가 동작하면서 필요한 데이터들을 모아두는 공간입니다. 

 

보통 1차 캐시는 트랜잭션별로 저장하기 때문에 휘발성이 강한 캐시 저장소입니다. 

 

반면 2차 캐시는 1차 캐시와 다르게 글로벌하게 적용되는 캐시입니다. 애플리케이션마다 적용되는 캐시로서 애플리케이션이 종료될 때까지 유지되는 캐시입니다. 

 

그럼 이제 본격적으로 2차 캐시에 대해서 알아볼까요? 

 

 

2차 캐시

2차 캐시란 무엇인가?

2차 캐시는 앞서 설명했듯이 애플리케이션 단위의 캐시로 애플리케이션이 종료될 때까지 유지됩니다. 이 말은 멀티 스레드 환경에서도 글로벌하게 사용되는 캐시라고 이해할 수 있습니다. 

 

즉, 우리가 만약 아래와 같은 쿼리를 날린다고 가정해봅시다. 

 

public interface MemberRepository extend JpaRepository<Member, Long> {
	
    Optional<Member> findById(Long id);
}

@Service
@RequiredAgrsConstructor
public class MemberService {
	
    private final MemberRepository memberRepository;
    
    @Transactional
    public Member findMemberById(Long id) {
    	return memberRepository.findById(id).orElse(null);
    }
}

 

이 메서드는 호출될 때마다 데이터베이스에 데이터 조회 요청을 보냅니다. 당연합니다. 트랜잭션별로 적용되는 캐시이니 트랜잭션이 끝나는 상황에서 1차캐시는 휘발됩니다. 

 

앞서 설명했듯이 이 상황에서 2차 캐시를 사용하면 휘발되지 않고 사용할 수 있습니다. 

 

2차 캐시는 어떻게 작동하는가?

2차캐시는 다음과 같은 동작 방식을 따릅니다. 

 

  1. 1차 캐시를 뒤진다
  2. 없으면 2차캐시를 뒤진다
  3. 없으면 데이터베이스에서 조회하고 1차 캐시와 2차 캐시에 업데이트한다. 

 

또한, 하이버네이트는 2차캐시의 대상으로 

 

  • 엔티티 캐싱 : 엔티티 단위로 캐싱합니다. 식별자(ID)로 엔티티를 조회하거나 컬렉션이 아닌 연관 관계에 있는 엔티티를 조회할 때 사용합니다. 
  • 컬렉션 캐싱 : 엔티티와 연관된 컬렉션을 캐싱합니다. 컬렉션이 엔티티를 담고 있으면 식별자 값만 캐싱합니다. 
  • 쿼리 캐싱 : 쿼리와 파라미터 정보를 키로 사용해서 캐싱합니다. 결과가 엔티티면 식별자 값만 캐싱합니다. 

 

2차 캐시는 어떻게 사용하는가?

먼저 의존관계를 추가해야합니다. 

 

implementation 'org.hibernate:hibernate-ehcache'

 

JPA의 2차 캐시는 다양한 구현체가 있지만 보통 스프링에 내장되어있는 EHcache를 사용합니다. 

 

그리고 application.yml 파일을 아래와 같이 수정합니다. 

 

spring:
  jpa:
    properties:
      hibernate:
        generate_statistics: true
        format_sql: true
        cache:
          use_second_level_cache: true
          region:
            factory_class: org.hibernate.cache.ehcache.EhCacheRegionFactory
      javax:
        persistence:
          sharedCache:
            mode: ENABLE_SELECTIVE
            
logging:
  level:
    net:
      sf:
        ehcache: debug

 

JPA의 2차 캐시를 사용하기 위해서는 두 가지 어노테이션에 대해서 알아야합니다. 

 

  • @Cacheable : 엔티티 캐시를 적용할 때 사용하는 어노테이션입니다. 
  • @Cache : 하이버네이트 전용 어노테이션입니다. 캐시와 관련된 더 세밀한 설정을 할 때 사용합니다. 또한, 컬렉션 캐시를 적용할 때에도 사용합니다. 
@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Post {
    @Id
    private Long id;

    @OneToMany(mappedBy = "post", cascade = CascadeType.ALL)
    @org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
    private List<Comment> comments = new ArrayList<>();

    // getters and setters
}

@Entity
@Cacheable
@org.hibernate.annotations.Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
public class Comment {
    @Id
    private Long id;

    @ManyToOne
    private Post post;

    // getters and setters
}

 

그리고 이제 xml 파일을 만들어줍니다. 

 

<ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:noNamespaceSchemaLocation="http://www.ehcache.org/ehcache.xsd">

    <defaultCache maxElementsInMemory="100" 
                  eternal="false" 
                  timeToIdleSeconds="120" 
                  timeToLiveSeconds="120" 
                  overflowToDisk="true"/>

    <cache name="items"
           maxElementsInMemory="1000"
           eternal="false"
           timeToIdleSeconds="300"
           timeToLiveSeconds="600"
           overflowToDisk="true"/>
</ehcache>

 

이 xml 파일은 src/main/resources 안에 저장하시면 됩니다. 

 

그리고 Configuration 클래스를 작성해주면 됩니다. 

 

@Configuration
@EnableCaching
public class CacheConfig {

    @Bean
    public CacheManager cacheManager() {
        return new EhCacheCacheManager(ehCacheCacheManager().getObject());
    }

    @Bean
    public EhCacheManagerFactoryBean ehCacheCacheManager() {
        EhCacheManagerFactoryBean cmfb = new EhCacheManagerFactoryBean();
        cmfb.setConfigLocation(new ClassPathResource("ehcache.xml"));
        cmfb.setShared(true);
        return cmfb;
    }
}

 

만약 Spring Data JPA를 사용하고 있다면 다음과 같이 명시하면 됩니다. 

 

public interface ItemRepository extends JpaRepository<Item, Long> {

    @Cacheable("items")
    List<Item> findByItemName(String itemName);
}

 

의문점

JPA의 2차 캐시가 엔티티를 캐싱해 오는 것이라면 만약 JPQL의 join fetch를 사용했을 때의 결과는 어떻게 될까요? 

 

결괏값이 모두 캐싱될까요? 아니면 엔티티만 캐싱될까요? 

 

정답은 엔티티만 캐싱됩니다. JPA의 2차 캐시는 @Cacheable이 붙어있는 엔티티를 캐싱해오고 결괏값은 캐싱해오지 않습니다. 

 

하지만 결괏값을 모두 캐싱하는 것이 가능은 합니다. 바로 위에서 잠깐 언급했던 컬렉션 캐싱을 이용하면 됩니다. @Cache 어노테이션을 이용해 작성하는 것들은 컬렉션 캐싱이 되고 join fetch로 가져오는 결괏값을 캐싱할 수 있습니다. 

 

하지만 컬렉션 캐싱은 일반적으로 추천하는 사용 방식은 아닙니다. 

 

2차 캐시 사용 시 주의점

JPA의 2차 캐시는 캐싱 데이터를 동기화 해야 한다는 문제점이 있습니다. 이를 stale data 문제라고 합니다. 

 

예를 들어

 

  • 다수의 데이터베이스 트랜잭션을 사용하는 경우
  • 다수의 애플리케이션 인스턴스가 있는 경우 (분산 시스템)
  • 컬렉션 캐싱을 하는 경우

 

1. 다수의 데이터베이스 트랜잭션을 사용하는 경우

만약 우리의 애플리케이션이 데이터를 변경할 수 있는 다수의 트랜잭션을 가지고 있는 경우에 stale data문제가 발생합니다. 이 상황에선 멀티 스레드 환경일 수 있습니다. 

 

예를 들어서 트랜잭션 1이 데이터를 읽어와서 캐싱을 했다고 가정합시다. 그리고 얼마 뒤에 트랜잭션 2가 데이터를 변경해서 데이터베이스에 넣었습니다. 

 

하지만 트랜잭션 1은 아직 변경 전 데이터를 캐싱하고 있고 이 경우 데이터의 정합성이 맞지 않아 큰 문제가 발생할 수 있습니다. 

 

2. 다수의 애플리케이션 인스턴스가 있는 경우

만약 우리가 분산 시스템처럼 여러개의 애플리케이션 인스턴스를 띄워서 사용하고 애플리케이션 각각이 2차 캐시를 사용한다면 캐시 데이터의 싱크를 맞추는 것이 하나의 챌린지가 될 수 있습니다. 

 

이 경우는 위의 1번 처럼 캐싱 데이터의 싱크를 맞춰줘야 하는 문제가 발생합니다. 

 

3. 컬렉션을 캐싱하는 경우

우리가 예를 들어 @OneToMany 혹은 @ManyToMany 어노테이션을 이용해 컬렉션을 연관관계로 지정했다면 stale data 위험이 증가합니다. 

 

이는 위에서 엔티티 하나의 데이터를 변경하는 것 뿐만이 아닌 여러개의 데이터를 한방에 캐싱해놓게 되기 때문에 여러개의 데이터가 정합성이 맞지 않을 수 있기 때문입니다. 

 

 

해결책

먼저 첫 번째 해결책으로는 TTL을 설정하는 방법입니다. 짧은 시간으로 TTL을 설정하면 캐싱 데이터가 제때제때 업데이트가 되어 stale data 문제를 피할 수 있습니다. 

 

만약 애플리케이션이 분산 시스템에 멀티 스레드를 적용하고 있다면 캐시 데이터들을 모든 노드에 대해 싱크를 맞춰줘야 한다는 문제가 있다는 것을 기술했습니다. 이때 stale data 문제를 해결하기 위해 Redis나 Memcached와 같은 외장 캐싱을 이용하는 것이 방법이 될 수 있습니다. 

 

또한, 캐싱을 해야하는 데이터를 잘 선정하여 캐싱을 최소한으로 설정하는 방법이 있을 수 있습니다. 

 

곧죽어도 EHcache와 같은 2차 캐시를 이용해야겠다면 사용할 수 있는 설정정보가 있습니다. 바로 2차 캐시에서 제공하는 동시성 컨트롤입니다. 위의 예제에서 @Cache 어노테이션에 붙어있던 설정 정보인데 자세히 설명해보겠습니다. 

 

  • Read-only : Read-only 전략은 데이터가 절대 변경될 일이 없는 엔티티에 사용할 수 있습니다. 만약 Read-only 전략이 붙어있는 캐싱 엔티티에 변경을 시도하면 예외가 발생합니다. 
  • Read-write : 이 설정은 가장 많이 사용되는 설정입니다. Read-write 전략은 데이터의 변경이 많은 경우에 사용될 수 있습니다. 이 말은 아까 말했던 2차 캐시의 문제점인 stale data를 해결할 수 있다는 얘기입니다. 객체에 처음 접근한 트랜잭션에게 락을 주고 다음 트랜잭션이 캐시 데이터에 접근할 수 없게 하는 전략입니다. 이렇게 락을 활용함으로써 데이터의 정합성을 보장합니다. 
  • Nonstrict-read-write : 이 설정은 Read-write와 비슷하지만 Locking 매커니즘만 쏙 빠진 전략입니다. 때문에 데이터 정합성을 보장할 수 없지만 데이터 정합성을 느슨하게 처리함으로써 성능을 끌어올린 설정이라고 할 수 있습니다. 이 설정을 하는 경우는 데이터의 정합성이 그리 중요하지 않아서 결국 데이터의 정합성이 맞는 Eventual Consistency 전략을 취할 때 사용할 수 있습니다. 보통 NoSQL에서 주로 사용하는 전략이죠. 
  • Transactional : 이 상황은 JTA에서 잘 어울립니다. 캐싱 엔티티 안에서 어떤 변화도 같은 트랜잭션 안에서 커밋될 수도 있고 롤백 될 수도 있는 설정입니다. 이는 주로 글로벌 트랜잭션인 JTA의 XA 아키텍처에서 사용하면 용이합니다. 

 

 

마치며

JPA를 깊이있게 공부하면서 알게된 내용중에 가장 흥미로운 내용이었습니다. 이전 두개의 포스팅인 JPA의 트랜잭션 관리법과 동시성 컨트롤은 사실 아는 내용이었어서 그리 큰 임팩트가 있지는 않았습니다. 

 

하지만 제가 이론적으로만 알고 있던 EHcache가 사실은 JPA의 2차 캐시였다는 사실과 이론적으로 알고있던 캐싱 동기화 작업이 필요하다는 사실을 조금 더 깊이있게 공부하는 시간이 되었던 것 같습니다. 

 

EHcache가 실무에서 많이 쓰인다고 하는데 이제 한번 공부 해봤으니 실무에서 조금 더 친숙하게 사용할 수 있을 것 같습니다. 

 

긴 글 읽어주셔서 감사합니다. 오늘도 즐거운 하루 되세요~

 

 

출처

https://www.baeldung.com/hibernate-second-level-cache

 

https://vladmihalcea.com/jpa-hibernate-second-level-cache/

 

The JPA and Hibernate second-level cache - Vlad Mihalcea

Learn how the JPA and Hibernate second-level cache works, what data can be cached, and how to scale the second-level cache.

vladmihalcea.com

https://www.devtalkers.com/2020/04/hibernate-second-level-cache-spring.html

 

Hibernate Second Level Cache - Spring Boot + JPA + EhCache

In this post, we will learn what is Second-Level cache, what are different cache concurrency strategy and how to implement Second-Level cache using Spring Boot, JPA, and EhCache.

www.devtalkers.com

https://cla9.tistory.com/100

 

4. JPA Cache 적용하기

서론 이번 포스팅에서는 JPA에 Cache 적용방법에 대해서 다루어보겠습니다. 먼저 Cache 선정 기준 및 패턴에 대한 소개 및 적용 방법을 설명합니다. Cache로는 Ehcache3을 적용하며, Spring Actuator를 통해

cla9.tistory.com

https://velog.io/@dnjscksdn98/JPA-Hibernate-First-Level-Cache-Second-Level-Cache

 

[JPA & Hibernate] First Level Cache & Second Level Cache

영속성 컨텍스트(Persistence Context)의 내부에는 엔티티를 보관하는 저장소가 있는데 이것을 1차 캐시(First Level Cache)라고 부릅니다. 1차 캐시는 트랜잭션이 시작하고 종료할 때까지만 유효합니다.

velog.io