Spring boot, JPA (3) - 1차 캐시

1차 캐시와 2차 캐시 등, JPA Cache 사용법
김주혁's avatar
Dec 03, 2024
Spring boot, JPA (3) - 1차 캐시
 
class PersistenceContext { Map<EntityId, Entity> cache; // 엔티티 저장소 ... }
엔티티 매니저는 엔티티 매니저(Entity Manager)를 통해 엔티티를 저장하거나 조회할 때 영속성 컨텍스트의 1차 캐시에서 먼저 확인합니다.
  • 조회 시: DB에서 데이터를 가져와 1차 캐시에 저장한 후 반환합니다.
  • 저장 시: 1차 캐시에 엔티티를 저장하고, 트랜잭션 커밋 시점에 DB에 반영됩니다.
 

실제 동작

 
누군가는 EntityId가 중복되는 경우에는 어떻게 처리하는지 궁금하실 수 있는데(Id가 1인 user와 Id가 1인 order가 존재하는 경우) 영속성 컨텍스트는 이를 해결하기 위해, 실제로는 엔티티 타입도 함께 고려합니다. EntityId는 보통 다음과 같은 복합 키로 구성됩니다.
 
class EntityId { Class<?> entityClass; // 엔티티 타입 Object id; // 식별자 값 // equals & hashCode 구현 @Override public boolean equals(Object o) { if (this == o) return true; if (!(o instanceof EntityId)) return false; EntityId entityId = (EntityId) o; return entityClass.equals(entityId.entityClass) && id.equals(entityId.id); } }
 
따라서 실제 1차 캐시는 이렇게 동작합니다.
// 내부적으로는 이런 식으로 구분됨 cache.put(new EntityId(User.class, 1L), userEntity); cache.put(new EntityId(Order.class, 1L), orderEntity); // 서로 다른 엔티티로 인식됨 User user = em.find(User.class, 1L); // User 엔티티 반환 Order order = em.find(Order.class, 1L); // Order 엔티티 반환 *** if (1차캐시에 있는가?) { 캐시에서 엔티티 반환 } else { DB 조회 1차 캐시에 저장 엔티티 반환 } // 실제로는 이런식입니다.
 
1차 캐시는 트랜잭션 커밋 시나 강제로 플러시 할 때 데이터베이스에 반영됩니다. 중요한 점은, EntityManager가 종료되는 시점이 아닌, 트랜잭션 커밋 시점에 DB에 반영됩니다. 커밋 전에 롤백하면 1차 캐시의 내용은 DB에 전혀 반영되지 않고, 변경감지(Dirty Checking)로 인한 update도 커밋 시점에 발생합니다.
 
따라서, 1차 캐시는 EntityManager의 세션이 종료될 때 같이 제거되며 DB에 커밋되는 시점은 아닙니다. 1차 캐시에 반영된 내역이 DB에 전파되는 것은 언제나 트랜잭션 커밋과 Flush 순간입니다.
 

생명주기

 
영속성 컨텍스트의 1차 캐시의 생명 주기는 기본적으로 영속성 컨텍스트와 동일한 주기를 가집니다. 트랜잭션이 시작되고 종료될 때 까지만 유지됩니다. EntityManager를 닫으면, 1차 캐시도 함께 소멸합니다.
 

1차 캐시의 한계

 
1차 캐시는 트랜잭션 범위를 벗어나면, 혹은 엔티티 매니저가 닫히면 사라지는 한계를 가지고 있습니다. 분산 환경이나 여러 트랜잭션에서 공유가 필요할 때도 공유할 수 없다는 단점이 있습니다. 따라서 이런 글로벌한 문제를 해결하기 위해 있는 것이 바로 2차 캐시입니다.
 

2차 캐시

 
2차 캐시는 애플리케이션 전체에서 공유되는 캐시로, 영속성 컨텍스트의 1차 캐시보다 더 넓은 범위인 EntityManagerFactory레벨에서 작동합니다. 즉, Application 내에서는 글로벌하게 적용된다는 의미입니다. 작동 방식을 살펴 보면,
 
  1. 엔티티를 조회할 때 우선 1차 캐시에서 찾습니다.
  1. 1차 캐시에 없으면 2차 캐시에서 찾습니다.
  1. 2차 캐시에도 없으면 데이터베이스에서 조회합니다.
 
이렇게 사용함으로써, 서로 다른 영속성 컨텍스트 간 데이터를 공유할 수 있고 데이터베이스 접근 횟수를 줄여 성능을 향상시킬 수 있습니다.
 
JPA는 2.0에서 2차 레벨의 캐시에 대한 표준을 정의 했습니다. JPA 2차 캐시를 사용하기 위해서는 어떤 구현체를 쓸 지 정해야 하는데 이 글에서는 ehcache를 사용합니다.
 
  • build.gradle
    • implementation 'org.hibernate:hibernate-ehcache' implementation 'net.sf.ehcache:ehcache'
  • application.yaml
    • spring: jpa: properties: hibernate: cache: use_second_level_cache: true region.factory_class: org.hibernate.cache.ehcache.EhCacheRegionFactory
  • ehcache.xml
    • ehcache.xml 파일은 src/main/resources/ehcache.xml에 위치시키는 걸로 적용됩니다.
      <?xml version="1.0" encoding="UTF-8"?> <ehcache xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="http://ehcache.org/ehcache.xsd" updateCheck="false"> <defaultCache maxElementsInMemory="10000" eternal="false" timeToIdleSeconds="300" timeToLiveSeconds="600" overflowToDisk="false"/> <!-- Entity Cache --> <cache name="com.example.domain.Member" maxElementsInMemory="10000" eternal="false" timeToIdleSeconds="300" timeToLiveSeconds="600" overflowToDisk="false"/> <!-- Collection Cache --> <cache name="com.example.domain.Team.members" maxElementsInMemory="10000" eternal="false" timeToIdleSeconds="300" timeToLiveSeconds="600" overflowToDisk="false"/> <!-- Query Cache --> <cache name="org.hibernate.cache.internal.StandardQueryCache" maxElementsInMemory="10000" eternal="false" timeToIdleSeconds="300" timeToLiveSeconds="600" overflowToDisk="false"/> </ehcache>
      ehcache.xml의 각 설정값들을 설명하면,
    • maxElementsInMemory="10000"
      • 메모리에 저장할 수 있는 최대 엔트리(캐시 항목) 개수
      • 이 개수를 초과하면 LRU(Least Recently Used) 정책에 따라 오래된 데이터부터 삭제됨
      • 예: 10000개의 Member 엔티티를 메모리에 캐시할 수 있음
    • eternal="false"
      • true: 엔트리가 영구적으로 캐시됨 (만료되지 않음)
      • false: timeToIdleSeconds와 timeToLiveSeconds 설정에 따라 캐시 엔트리가 만료됨
      • 보통 false로 설정하여 캐시 데이터가 적절히 갱신되도록 함
    • timeToIdleSeconds="300"
      • 캐시된 데이터가 마지막으로 접근된 후 얼마나 유지될지 설정
      • 300초(5분) 동안 해당 캐시에 접근하지 않으면 데이터가 삭제됨
      • 캐시 데이터가 사용되지 않을 때의 만료 시간
    • timeToLiveSeconds="600"
      • 캐시된 데이터의 최대 수명 시간
      • 600초(10분)이 지나면 해당 데이터는 무조건 캐시에서 삭제됨
      • 접근 여부와 관계없이 데이터가 캐시에 머무를 수 있는 최대 시간
    • overflowToDisk="false"
      • true: maxElementsInMemory 초과 시 디스크에 캐시 데이터를 저장
      • false: 디스크 저장을 사용하지 않고, 메모리에서만 캐시 관리
      • 메모리 사용량 관리를 위해 보통 false로 설정
       
실제 적용은 다음과 같이 사용합니다.
@Entity @Cacheable @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // Entity Cache public class Member { @Id private Long id; private String name; @ManyToOne @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) private Team team; } @Entity public class Team { @Id private Long id; @OneToMany(mappedBy = "team") @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // Collection Cache private List<Member> members = new ArrayList<>(); } // Query Cache // 쿼리 캐시를 사용할 때는 반드시 해당 엔티티에도 @Cache 어노테이션을 적용해야 합니다. @Repository public class MemberRepository extends JpaRepository<Member, Long> { @QueryHints(@QueryHint(name = "org.hibernate.cacheable", value = "true")) @Query("select m from Member m where m.name = :name") List<Member> findByName(@Param("name") String name); } // 또는 EntityManager 사용 시 public List<Member> findMembers() { return entityManager.createQuery("select m from Member m", Member.class) .setHint("org.hibernate.cacheable", true) .getResultList(); }
 
각 캐시 별 옵션을 설정할 수 있는데 CacheConcurrencyStrategy는
  • READ_ONLY: 읽기 전용. 수정 불가능
  • NONSTRICT_READ_WRITE: 읽기/쓰기. 동시성 보장이 완벽하지 않음
  • READ_WRITE: 읽기/쓰기. 동시성 보장
  • TRANSACTIONAL: 트랜잭션 지원. JTA 필요
이렇게 옵션을 지원하고 있습니다.
 
ehcache에 적용한 대로 만약 동작한다고 하면,
  • Member 엔티티를 조회하면 2차 캐시에 등록
  • timeToIdleSeconds="300" 이기 때문에 5분 동안 아무도 조회하지 않으면 5분 뒤에 삭제(TTL)
  • timeToLiveSeconds="600” 이기 때문에 누가 조회를 간헐적으로 진행해도 마지막 조회로부터 10분 뒤에는 삭제(TTL)
  • maxElementsInMemory="10000” 이기 때문에 Member는 최대 10000개 까지만 저장됨, 만약 새로운 멤버를 캐싱하려고 하면 가장 오래된 멤버가 사라짐
  • overflowToDisk="false” 이기 때문에 10000이 넘어가면 그 넘어가는 숫자는 maxElementsInMemory로 적용
으로 실제 동작을 예측해 볼 수 있습니다.
 
L2 캐싱은 애플리케이션의 메모리 소비를 증가시키므로, 캐싱의 크기를 제한하는 것이 중요합니다. 또한 클러스터 환경에서는 객체가 업데이트될 때 오래된(stale) 데이터가 캐시에 남아있을 수 있습니다. 주로 읽기 작업이 많고 수정이 드물게 일어나는 엔티티에 대해서만 적용하는 것이 좋습니다. 반면에, 자주 업데이트되거나 동시에 여러 곳에서 수정이 일어나는 엔티티는 2차 캐시 사용을 피하는 것이 좋습니다.
 
한편 IBM에서는 JPA 2.0의 2차 캐시레이어에 대해 라고 설명했고, 인프런에서 김영한 선생님은
먼저 1번은 어떤 것을 사용해도 동일합니다. (스프링에서 javax의 캐시이든 스프링의 캐시이든 동일하게 지원합니다.) 다만 여기서 동시성 문제도 말씀을 하셨는데, 주의해야할 점은 엔티티를 스프링이나 외부 캐시에 저장하면 절대! 안됩니다. 엔티티는 영속성 컨텍스트에서 상태를 관리하기 때문에, 항상 DTO로 변환해서 변환한 DTO를 캐시에 저장해서 관리해야 합니다! (물론 하이버네이트가 지원하는 2차 캐시는 예외입니다)
2번은 ehcache, Infinispan 등이 공식 지원됩니다. redis 등은 공식 지원되지 않습니다. 앞서 두 가지도 글로벌 캐시로 사용이 가능합니다. (자세한 내용은 각 캐시 메뉴얼을 보셔야 합니다.)
캐시와 관련해서 제가 실무 조언을 드리고 싶은 부분은 하이버네이트 2차 캐시보다는 스프링이 지원하는 캐시를 서비스 계층에서 사용하는게 더 효과적이라는 점 입니다. 2차 캐시는 설정도 복잡하고, 지원하는 캐시 라이브러리도 작습니다. 무엇보다 실무에서는 서비스 계층에서 복잡하게 외부 API도 호출하고, 여러 엔티티도 조회해서 그 결과로 DTO를 생성합니다. 스프링을 사용하면 이 DTO를 효과적으로 캐시할 수 있고, 지원하는 캐시 라이브러리도 풍부합니다. 그런데 2차 캐시는 단순히 엔티티 조회(쿼리포함)와 관련된 부분만 캐시가 지원됩니다.
이런 점 때문에 하이버네이트 2차 캐시 보다는 스프링이 지원하는 캐시를 적극 사용하는 것을 권장드립니다.
라고 말하셧으니, 사용할 때 충분히 고려해보고 사용하는 것이 좋다고 판단됩니다.
Share article

vlogue