트랜잭션과 락, 2차 캐시

트랜잭션

트랜잭션 ACID 원칙을 지켜야한다.

  1. Acid(원자성): 트랜잭션 내의 작업은 하나의 단위로 관리된다. 일부 작업만 수행되고, 일부 작업은 수행되지 않는 그런 것이 아닌, 모든 작업이 수행되거나, 수행되지 않거나 2가지 경우만 존재한다.

  2. Consistencty(일관성): 트랜잭션은 DB 일관성을 항상 지켜야한다. DB에 지정한 참조 무결셩, 엔티티 무결성, 등 다양한 무결성을 유지해야한다.

  3. Isolation(격리성): 트랜잭션 처리 과정에서, 동시에 같은 데이터를 수정하지 못하도록 한다.(한 영역에는 하나의 트랜잭션만 가능)

  4. Durability(지속성): 트랜잭션을 성공적으로 끝내게 되면 항상 DB에 저장되어야 한다. 만일, 시스템 문제가 발생해도 로그를 통한 트랜잭션 복구를 할 수 있어야 한다.

격리성 원칙을 완벽하게 지키게 되면 모든 트랜잭션을 순차적으로 수행할 수 밖에 없다. 그래서 성능을 위해서 격리성 원칙을 4가지 레벨로 관리한다.

Isolation Level

Isolation level Dirty Read Non-Repeatable Read Phantom Read
READ UNCOMMITED O O O
READ COMMITED   O O
REPEATBLE READ     O
SERIALIZABLE      

READ UNCOMMITED ~ SERIALIZABLE 까지 격리수준이 1~4 까지 존재한다.

격리수준이 낮을 수록 위의 문제점이 존재하게 되는것이다.

READ UNCOMMITED

커밋하지 않은 데이터는 읽을 수 있다. 트랜잭션 1에서 데이터를 수정하고 있는데, 커밋하지 않은 경우 트랜잭션 2에서 해당 데이터를 읽어드릴 수 있는 데, 이를 DIRTY READ 라고 한다.

만약, 트랜잭션 1이 롤백을 수행하게 되면 트랜잭션 2에서 읽어드린 값은 더 이상 사용되지 않는 값으로 일관성이 유지되지 않는다.

READ COMMITED

커밋한 데이터만 읽어드릴 수 있다. 트랜잭션 1이 회원 A를 조회 중에, 트랜잭션 2가 회원 A를 수정하고 커밋하면, 트랜잭션 1은 수정된 회원 A의 데이터를 읽게 된다. 이 처럼, 반복해서 같은 데이터를 읽어드릴 수 없는 문제는 Non-Repeatable Read라고 한다.

REPEATABLE READ

한번 조회한 데이터에 대해서는 같은 데이터를 조회하게 된다. 트랜잭션 1 이 10살 이하의 회원을 조회하는 데, 트랜잭션이 5살인 회원을 추가하고 커밋하면, 10살 이하의 회원을 조회했을 때 결과의 개수가 1개 늘어나는데, 이 처럼 반복조회 시 결과 집합이 달라지는 문제를 Phantom Read라고 한다.

SERIALIZABLE

가장 동시성이 떨어지는 방식으로, 동시성은 유지되지만 성능 상 좋지 않다.

대부분의 DB는 READ-COMMITED 수준으로 격리성을 유지하고, 중요한 비즈니스 로직에 대해서는 DB 락을 이용하는 방식을 사용한다.

JPA 에서는 REPEATBLE-READ 수준으로 격리성을 유지하게 된다. 한번 조회를 수행한 엔티티는 1차 캐시에 저장되며, 다른 트랜잭션에서 수정후 커밋을 한다 하더라도 선행 트랜잭션에서 다시금 조회를 진행하면 DB에서 조회하는 것이 아닌 1차 캐시에서 조회하기 때문에 repeatable-read 수준을 유지하게 된다.

낙관적 락 , 비관적 락

낙관적 락

낙관적 락은 트랜잭션에서 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법이다. 이는 DB가 제공해주는 락이 아닌, JPA 즉, 애플리케이션이 제공해주는 락을 활용하게 된다. 이렇게 하면, 트랜잭션을 커밋하기 전까지는 충돌 문제를 알 수 없는 특징이 있다.

비관적 락

비관적 락은 트랜잭션에서 충돌이 발생한다고 가정하고 락을 걸게된다. 이때는, DB가 제공해주는 락을 이용한다.

두 번 갱신 분실의 문제

트랜잭션 범위를 넘어서는 문제도 존재하는데, 가령 사용자 A,B 가 동시에 같은 공지사항을 수정한다고 가정하자. 이때, 사용자 A가 먼저 수정을 수행하고 곧이어 사용자B가 수정을 하게 되면, 사용자 A의 수정사항은 사라지고, B의 수정사항이 남게 된다

이를 해결하는 방법에는 아래와 같이 3가지가 존재한다.

  • 마지막 커밋만 인정
  • 최초 커밋만 인정
  • 충돌 내용 병합

보통은 마지막 커밋만 인정하는 것이 기본 설정인데, 경우에 따라, JPA를 이용해서 최초 커밋 인정 혹은 충돌 내용 병합을 하도록 할 수 있다.

낙관적 락

@Version

JPA 낙관적 락을 이용하려면 @Version annotation을 이용해야한다. @Version은 Long, Integer, Short, Timestamp 자료형에 적용가능하다.

@Entity
public class Board {

	@Id
	private String id;
	private String title;

	@Version
	private Integer version;
}

@Version을 추가하게 되면 엔티티를 수정하게 될때마다 버전이 하나씩 증가하게 된다.

아래와 같이 엔티티 조회 시점과 수정 시점의 버전 정보가 다르면 에러가 발생한다.

// 트랜잭션 1 조회 title="제목A", version=1
Board board = em.find(Board.class, id);

// 트랜잭션 2에서 해당 게시물을 수정해서 title="제목C", version=2로 증가

board.setTitle("제목B"); // 트랜잭션 1 데이터 수정

save(board);
tx.commit(); //예외 발생, 데이터베이스 version=2, 엔티티 version=1

이렇게 구성하게 되면 항상 최초 커밋만 인정되게 된다.

버전 정보 비교 방법

엔티티를 수정하고, 트랜잭션을 커밋하게 되면 아래의 SQL update를 실행해서 버전정보를 수정하게 된다.

update board
set
title=?,
version=? (버전 증가)
where
id=?
and version=? (버전 비교)

위를 보면 where 절에서 version을 비교해서 조회시점과 수정시점에서의 version이 다르게 되면 where 절로 인해 수정할 대상이 없는 것이다. 이렇게 되면 JPA 에서 이미 버전이 수정된 것으로 인식해서 예외를 발생시킨다.

연관관계 필드의 경우, 연관관계의 주인의 필드가 수정될 때만 엔티티의 버전이 증가하게 된다.

@Version으로 등록된 버전 관리 필드는 JPA에서 직접 관리하는 필드로, 임의로 수정해서는 안된다(벌큰 연산은 예외이다). 만일 수정하고자 하면 특별한 락 옵션을 선택하면 안된다.

벌크 연산에서는 버전을 무시하게 되는데, 이때 버전을 증가하고자 하면 버전 필드를 아래와 같이 버전을 강제로 증가 시켜야한다.

Member m set m.name = '변경', m.version = m.version + 1

JPA 락

JPA 락은 아래의 메소드을 이용해서 걸 수 있다.

EntityManager.lock(), EntityManager.find(), EntityManager.refresh()
Query.setLockMode() (TypeQuery 포함)
@NamedQuery
//아래 와 같이 조회를 수정할 때 락을 걸 수 있다.
Board board=em.find(Board.class,id,LockModeType.OPTIMISTIC);

//아니면 em.lock을 이용해서 락을 걸 수도 있다.
Board board=em.find(Board.class,id);
em.lock(board,LockModeType.OPTIMISTIC);

Lock Option

Lock Mode Type Description
낙관적 락 OPTIMISTIC 낙관적 락을 사용한다
낙관적 락 OPTIMISTIC_FORCE_INCREMENT 낙관적 락 + 버전정보를 강제로 증가한다
비관적 락 PESSIMISTIC_READ 비관적 락, 읽기 락을 사용한다
비관적 락 PESSIMISTIC_WRITE 비관적 락, 쓰기 락을 사용한다.
비관적 락 PESSIMISTIC_FORCE_INCREMENT 비관적 락 + 버전정보를 강제로 증가한다
기타 NONE 락을 걸지 않는다
기타 READ JPA1.0 호환 기능이다. OPTIMISTIC과 같으므로 OPTIMISTIC을 사용하면 된다.
기타 WRITE JPA1.0 호환 기능이다. OPTIMISTIC_FORCE_INCREMENT와 같다

JPA 낙관적 락

낙관적 락을 걸기 위해서는 위의 @Version 정보가 필요하다. 낙관적 락의 옵션에 대해서 아래에 알아보자

None

version_lock

락 옵션을 적용하지 않아도, @Version이 적용된 필드가 있으면 낙관적 락이 적용된다.

  • 조회한 엔티티를 수정할 때 다른 트랜잭션에서 의해 수정이 이루어지면 안된다.(이전의 버전 설명시 다룬 예시)
  • 엔티티를 수정하는 시점에서 버전을 체크해서 조회 시점과 비교해서 만일 버전이 다른 경우 예외 표시
  • 두번 갱신 문제의 오류를 최초 커밋 인정 방식으로 처리한다.

Optimistic

optimistic_lock

@Version만 적용했을 때는 엔티티를 수정하는 경우에만 버전을 체크하지만, Optimistic의 경우 엔티티를 조회할 때에도 버전을 체크한다, 이렇게 하면 엔티티를 조회한 트랜잭션 에서는 해당 트랜잭션이 종료 될때까지 다른 트랜잭션에 의해서 변경 되지 않음을 보장한다.

  • 조회 시점 ~ 트랜잭션 끝까지 엔티티 변경되지 않음 보장
  • 트랜잭션을 커밋할 때 버전 정보를 조회해서 엔티티의 버전이 같은지 비교한다.
  • DIRTY READ, Non-Repeatable Read를 방지한다.
//version =1
Board board=em.find(Board.class,id,LockModeType.OPTIMISTIC);

//트랜잭션 2에서 해당 게시글의 제목 수정함  version=2

tx.commit(); //에러 발생

위와 같이 수정작업이 아닌 조회만 했을 때에도 버전 검증을 수행해서 non-repeatable read을 막는다.

Optimistic Force Increment

optimistic_force_increment_lock

낙관적 락을 사용하면서 버전 정보를 강제로 증가한다.

  • 논리적인 단위의 엔티티를 묶음으로 관리할 수 있다. 가령, 게시물과 첨부파일이 있을때, 게시글과 첨부파일은 1대다 연관관계를 지닌다. 이때 연관관계의 주인이 아닌 첨부파일에 변화가 발생하더라도 게시글에는 수정이 발생하지 않는다. 따라서 이럴때는 optimistic_force_increment을 이용해서 강제로 버전을 올린다.

  • 엔티티를 수정하지 않더라도 트랜잭션 커밋시 자동으로 Update 쿼리를 이용해서 버전 정보를 증가킨다. 이때, 엔티티를 수정하는 작업도 같이 있으면, 버전 정보가 2번 증가할 수 있다.

  • 강제로 버전을 증가해서 논리적인 단위의 엔티티를 관리할 수 있다.

//version =1
Board board=em.find(Board.class,id,LockModeType.OPTIMISTIC_FORCE_INCREMENT);

tx.commit(); //커밋 시점에서 강제로 버전 증가, 단 이때에도 DB의 버전 정보와 엔티티의 버전 정보가 다르면 예외를 벌생시킨다.

JPA 비관적 락

비관적 락은 DB에서 제공하는 락 매커니즘을 활용하는 방식이다. SQL 쿼리에 select for update를 사용해서 수행한다.

  • 엔티티가 아닌 스칼라 타입 조회에서도 락 적용 가능
  • 데이터를 수정하는 즉시 트랜잭션 충돌을 감지한다.

Pessimistic Write

  • DB에 쓰기 락을 건다
  • DB에 select for update를 사용해서 락을 건다
  • Non-Repeatable read를 방지한다.(락이 걸린 로우는 다른 트랜잭션에 의해서 수정될 수 없다.)

Pessimistic Read

  • DB에 대해 repeatable-read만 수행하는 용도로만 사용할 때 접속한다.
  • 대부분의 DB가 방언에 의해서 PESSIMISTIC_WRITE 모드로 동작하게 된다.

Pessimistic_Force_Increment

비관적 락 중에서 유일하게 버전 정보를 활용한다. 버전 정보를 강제로 증가시킨다.

  • 하이버네이트에서는 nowait을 지원하는 DB에 대해서 update nowait을 적용한다.

  • 오라클,PostgreSQL : for update nowait
  • nowait 지원 안 할 경우: for update 적용

비관적 락을 이용하는 경우 락을 획득할 때까지 트랜잭션이 대기하게 된다. 하지만 무작정 기다릴 수는 없으므로 타임아웃을 둬 일정 시간 뒤에도 락을 획득하지 못하면 LockTimeoutException에러를 일으킨다.

Map<String,Object> properties=new HashMap<String,Object>();

//타임 아웃 10초 지정
properties.put("javax.persistence.lock.timeout",10000);

Board board=em.find(Board.class,"boardId",LockModeType.PESSIMISTIC_WRITE,properties);

2차 캐시

DB를 이용한 애플리케이션에서 가장 비용이 많이 발생하는 부분은 DB에 쿼리를 요청하는 부분이다. 그래서, 조회한 데이터를 메모리에 보관해서 최대한 쿼리를 최소화하게 되면 성능을 높일 수 있는 것이다.

영속성 컨텍스트 내에는 DB에 조회한 엔티티를 보관하는 1차 캐시가 있지만, 이는 트랜잭션이 시작되고 종료되는 부분에는 유효하다. OSIV를 활용한다 하더라도 클라이언트 하나의 요청의 생존 시간 만큼만 유효하게 된다. 하지만 애플리케이션 전체로 놓고 보면, DB의 접근 횟수를 크게 줄이지 못한다.

이를 위해, 2차 캐시가 있는데, 2차 캐시는 애플리케이션 범위에서 공유되는 캐시이다.

second_level_cache

위를 보면 1차 캐시와 DB 사이에 존재하게 되며, 엔티티에 대한 조회를 수행하게 되면 우선 1차 캐시에서 찾아보고 없으면 2차 캐시를 확인하고 없으면 그때 DB에서 바로 조회하게 된다.

1차 캐시

1차 캐시는 영속성 컨텍스트 내에 존재하며, Entity Manager을 이용해서 조회한 모든 엔티티가 저장되게 된다. 이후, 플러시, 커밋이 수행될 때 DB에 수정사항이 반영된다. 이러한 1차 캐시는 영속성 컨텍스트가 종료 될때 제거된다.

동작 과정

first_level_cache

1차 캐시의 동작과정은 이전에 배웠던 과 같이 DB에 대한 1차 캐시를 유지해서 1차 캐시에 있는 엔티티에 대해서는 DB 조회를 하지 않아도 된다.

1차 캐시의 경우 아래의 특징을 보장한다

  1. 동일성
  2. 영속성 컨텍스트와 동일 범위를 가진다.

2차 캐시

2차 캐시는 애플리케이션 전 범위에서 공유되어, 공유캐시라고도 불린다. 즉, 애플리케이션 시작 ~ 끝 까지 유지된다

동작 과정

second_level_cache2

  1. 영속성 컨텍스트는 1차 캐시에 엔티티가 없으면 우선 2차 캐시에서 조회를 한다.
  2. 만약 없는 경우, DB에 조회한다.
  3. 조회된 엔티티를 2차 캐시에 저장한다.
  4. 그리고 조회된 엔티티의 복사본을 1차 캐시에 반환하게 된다.

위를 보면 조회된 엔티티의 원본을 복사해서 전달하게 되는데, 이는 여러 곳에서 동시에 해당 엔티티에 대해 수정을 할 수 있기 때문에 복사본을 전달하게 되는 것이다. 이 문제를 락을 통해서 해결할 수 도 있지만, 그렇게 되면 동시성이 떨어지기 때문에 2차 캐시에서는 복사본을 전달하는 형태로 해결한다.

2차 캐시는 아래의 특징을 가진다.

  1. 동일성이 유지 되지 않는다.
  2. 영속성 유닛 범위(애플리케이션 범위)이다.

2차 캐시 설정

@Cachecable

2차 캐시를 사용하기 위해, 엔티티에 2차 캐시 모드를 설정한다.

@Cacheable
@Entity
public class Member{
    @Id @GeneratedValue
    private Long id;

}

위와 같이 @Cacheable annotation을 포함하면 2차 캐시를 사용할 수 있다. @Cachecable(true), @Cacheable(false)로 지정할 수 있는데, 기본값이 true이다.

shared-cache-mode

<persistence-unit name="test">
	<shared-cache-mode>ENABLE_SELECTIVE</shared-cache-mode>
</persistence-unit>

영속성 유닛 단위에 캐시를 어떻게 적용할지에 대한 옵션 설정

스프링 프레임워크에서 2차 캐시 적용

<bean id="entityManagerFactory" class="org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean">
    <property name="sharedCacheMode" value="ENABLE_SELECTIVE">

entity manager factory bean 에 대해 shared cache mode 관련 property를 추가한다.

Shared-Cached-Mode Options

Cache Mode Descriptions
ALL 모든 엔티티에 캐시 적용
NONE 캐시 이용 안함
ENABLE_SELECTIVE @Cacheable 엔티티에 대해서만 캐시 적용
DISABLE_SELECTIVE 모든 엔티티에 대해서 캐시를 수행하는데, @Cacheable(false) 엔티티는 제외시킨다.
UNSPECIFIED JPA 구현체가 정의한 설정을 따른다.

캐시 조회, 저장 방식 설정

캐시를 무시하고 DB에 직접 조회하거나 캐시를 갱신하려면 캐시 조회 모드와 캐시 보관 보드를 활용한다.

em.setProperty("javax.persistence.cache.retriveMode",CacheRetrieveMode.BYPASS);
  • 조회 모드: javax.persistence.cache.retriveMode , CacheRetrieveMode 설정

  • 보관 모드: javax.persistence.cache.storeMode , CacheStoreMode 설정

조회 모드

public enum CacheRetriveMode{
    USE,
    BYPASS
}
  • USE: 캐시에서 조회를 수행한다.(기본값)
  • BYPASS: 캐시를 거치지 않고, DB로 바로 조회한다.

캐시 보관 모드

public enum CacheStoreMode{
    USE,
    BYPASS,
    REFRESH
}
  • USE: 조회한 데이터를 캐시에 저장한다. 이미 엔티티가 있는 경우, 최신 상태로 갱신하지는 않고, 트랜잭션을 커밋하게 되면 수정된 엔티티를 캐시에 저장하게 된다.

  • BYPASS: 캐시에 저장하지 않는다.

  • REFRESH: USE 방식에서 최신 상태의 엔티티를 다시 캐시에 저장하는 부분이 추가된다.

캐시 모드 설정은 아래의 3가지 방식 모두 지원한다.

//1. Entity Manager 단위로 설정
em.setProperty("javax.persistence.cache.retriveMode",CacheRetrieveMode.BYPASS);
em.setProperty("javax.persistence.cache.storeMode",CacheStoreMode.BYPASS);

//2. em.find에 적용
Map<String,Object> properties=new HashMap<String,Object>();

properties.put("javax.persistence.cache.retriveMode",CacheRetrieveMode.BYPASS);
properties.put("javax.persistence.cache.storeMode",CacheStoreMode.BYPASS);

em.find(TestEntity.Class,id,properties);

//3. jpql hint 적용
em.createQuery("select e from TestEntity e where e.id=:id",TestEntity.Class)
.setParameter("id",id)
.setHint("javax.persistence.cache.retriveMode",CacheRetrieveMode.BYPASS)
.setHint("javax.persistence.cache.storeMode",CacheStoreMode.BYPASS);
.getSingleResult();

JPA Cache Interface

JPA 에서는 2차 캐시를 관리할 수 있는 Cache 인터페이스를 제공한다.

//emf(Entity Manager Factory 객체)
Cache cache=emf.getCache();
public interface Cache{
    //해당 엔티티가 캐시에 있는 지 확인
    public boolean contains(Class cls,Object primaryKey);

    //해당 엔티티중에서 특별 식별자를 가지는 엔티리를 캐시에서 제거
    public void evict(Class cls,Object primaryKey);

    //해당 엔티티 전체를 캐시에서 제거
    public void evict(Class cls);

    //모든 캐시 데이터 제거
    public void evictAll();

    //JPA Cache 구현체 조회
    public <T> T unwrap(Class<T> cls);
}

JPA에서 제공하는 표준환된 기능은 위와 같다. 세밀한 캐시 기능을 활용하기 위해서는 구현체의 캐시 기능을 활용해야한다.

하이버네이트 와 EHCACHE

하이버네이트가 제공하는 2차 캐시는 아래와 같다.

  1. entity cache: 엔티티 단위로 캐시한다. 식별자로 엔티티를 조회하거나, 연관된 엔티티를 로딩할때 사용

  2. collection cache: 엔티티와 연관된 컬렉션 캐시, 엔티티의 식별자 값만을 보관한다.

  3. Query cache: 쿼리와 파라미터 정보를 키로해서 캐시, 결과가 엔티티이면 식별자 값만 표시

JPA에서는 공식적으로 entity cache만 제공하며, 나머지는 하이버네이트 고유의 기능이다.

환경 설정

EHCACHE를 이용하기 위해 해당 라이브러리를 추가한다.

pom.xml

<dependency>
    <groupId>org.hibernate</groupId>
    <artifactId>hibernate-ehcache</artifactId>
    <version>4.3.10.Final</version>
</dependency>

hibernate-ehcache 라이브러리를 추가한다.

src/main/resources/ehcache.xml

<!-- 파일경로 src/main/resources -->
<ehcahce>
    <defaultCache 
        maxElementsInMemory="10000"
        eternal="false"
        timeToldleSeconds="1200"
        timeToLiveSeconds="1200"
        diskExpiryThreadIntervalSeconds="1200"
        memoryStoreEvictionPolicy="LRU"
    />
</ehcahce>

캐시를 얼만큼 보관할지, 얼마나 보관할시 와 같은 캐시 정책을 정의하는 설정 파일 추가

persistence.xml

<persistence-unit name="test">
    <shared-cache-mode>ENABLE_SELECTIVE</shared-cache-mode>
    <properties>
        <property name="hibernate.cache.use_second_level_cache" value="true"/>
        <property name="hibernate.cache.use_query_cache" value="true"/>
        <property name="hibernate.cache.region.factory_class" value="org.hibernate.cache.ehcache.EhCacheRegionFactory" />
        <property name="hibernate.generate_statistics" value="true" />
    </properties>
    ...
</persistence-unit>

하이버네이트에서 캐시 사용정보를 추가한다. 기존의 2차 캐시 모드 아래에 properties로 추가시킨다.

Options Descriptions
use_second_level_cache 2차 캐시를 활성화, 엔티티 캐시와 컬랙션 캐시를 사용
use_query_cache 쿼리 캐시를 활성화
factory_class 2차 캐시를 처리할 클래스를 지정 여기서는 EHCACHE를 사용하므로 EhCacheRegionFactory로 지정
generate_statistics 하이버네이트가 여러 통계정보를 출력해주며 캐시 적용 여부 확인 가능 (성능에 영향을 주므로 개발 환경에서만 적용하는 것을 추천)
엔티티 캐시, 컬렉션 캐시
@Cacheable // --1
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // --2
@Entity
public class ParentMember {
    @Id @GeneratedValue
    private Long id;
    
    private String name;

    @Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // --3
    @OneToMany(mappedBy = "parentMember", cascade = CascadeType.ALL)
    private List<ChildMember> childMembers = new ArrayList<ChildMember>();
    ...
}
  1. @Cacheable annotation을 이용해서 해당 엔티티를 캐시

  2. @Cache annotation을 이용해서, 엔티티 단위, 컬렉션 단위로 캐시 지정

    • 연관관계 컬렉션에서 연관관계 주인은 엔티티 캐시, 컬렉션은 컬렉션 캐시가 적용된다.

@Cache

Properties Description
usage CacheConcurrentStrategy를 이용해서 캐시 동시성 전략 지정
region 캐시 지역 설정
include 연관 객체의 캐시 포함 여부 결정, all, non-lazy 옵션

CacheConcurrentStrategy

Properties Description
NONE 캐시를 설정하지 않음
READ_ONLY 읽기 전용으로 설정 -등록, 삭제는 가능하지만 수정은 불가능 -읽기 전용은 수정되지 않으므로 2차 캐시를 조회할 때 객체를 복사하지 않고 원본 객체를 반환
NONSTRICT_READ_WRITE 엄격하지 않은 읽고 쓰기 전략 -동시에 같은 엔티티를 수정하면 데이터 일관성이 깨짐, EHCACHE는 데이터를 수정하면 캐시 데이터를 무효화 처리
READ_WRITE 읽기 쓰기가 가능하고 READ COMMITTED 정도의 격리 수준을 보장, EHCACHE는 데이터를 수정하면 캐시 데이터도 같이 수정
TRANSACTIONAL 컨테이너 관리 환경에서 사용 설정에 따라 REPEATABLE READ 정도의 격리 수준을 보장

CacheConcurrentStrategy 지원 여부

Cache read-only nonstrict-read-write read-write transactional
ConcurrentHashMap yes   yes yes
EHCache yes yes yes yes
Infinispan yes     yes

ConcurrentHashMap의 경우 개발 환경에서만 사용해야한다.

region

위에서 캐시를 적용한 코드는 다음 캐시 영역에 저장되게 된다

  • 엔티티 캐시 영역은 기본값으로 [패키지명 + 클래스명]을 사용
    • Ex) jpabook.jpashop.domain.cache.ParentMember
  • 컬렉션 캐시 영역은 캐시 영역 이름에 캐시한 컬렉션의 필드 명이 추가
    • Ex) jpabook.jpashop.domain.cache.ParentMember.childMembers
  • 아니면 @Cache(region=”customRegion”)과 같이 임의의 영역으로 지정가능하다.
  • cache region에 대한 prefix을 지정하는 것도 가능하다.

캐시 영역이 정해져 있어, 특정 캐시에 대한 세부설정을 할 수 있다.

ehcache.xml

<ehcahce>
    <defaultCache 
            maxElementsInMemory="10000"
            eternal="false"
            timeToldleSeconds="1200"
            timeToLiveSeconds="1200"
            diskExpiryThreadIntervalSeconds="1200"
            memoryStoreEvictionPolicy="LRU"/>

    <cache
            name="jpabook.jpashop.domain.cache.ParentMember"
            maxElementsInMemory="10000"
            eternal="false"
            timeToldleSeconds="600"
            timeToLiveSeconds="600"
            overflowToDisk="false"/>
</ehcahce>

위와 같이 하면 컬렉션 캐시에 대해서 600초마다 캐시에서 제거 되도록 할 수 있다.

쿼리 캐시

쿼리 캐시는 쿼리와 파라미터 정보를 키로 사용해서 쿼리 결과를 캐시하는 방식이다. 쿼리 캐시를 사용하기 위해 위의 환경 설정에서 hibernate.cache.use_query_cache 설정을 true로 해준다.

사용 예제

//1. 쿼리 캐시 jpa 힌트 적용
em.createQuery("select i from Item i", Item.class);
	.setHint("org.hibernate.cacheable", true)
	.getResultList();

//2. namedQuery에 쿼리 힌트 적용
@Entity
@NamedQuery(
	hints = @QueryHint(name = "org.hibernate.cacheable", value = "true"),
	name = "Member.findByUsername",
	query = "select m.address from Member m where m.name = :username"
)
public class Member {
	...
}

쿼리 캐시 영역

쿼리 캐시를 사용하게 되면 2개의 영역이 추가된다.

  1. StandardQueryCache: 쿼리 캐시를 저장하는 영역, 쿼리, 쿼리 결과 집합, 쿼리 실행 시점의 타임스탬프 보관
  2. UpdateTimestampsCache: 쿼리 캐시가 유효한지 확인하기 위해 쿼리 대상 테이블의 가장 최근 변경 시간을 저장하는 영역

쿼리 캐시는 쿼리 데이터 집합을 최신 상태로 유지하기 위해 쿼리 캐시 실행 시간과 테이블의 최근 변경 시간을 비교한다. 이때, 테이블에 조금이라도 변경이 있으면 다시 쿼리를 해서 캐시에 저장하게 된다.

사용 예제

public List<ParentMember> findParentMembers() {
    return em.createQuery("select p from ParentMember p join p.childMembers c",
        ParentMember.class)
                .setHint("org.hibernate.cacheable", true)
                .getResultList();	
}

쿼리 캐시가 동작하는 과정은 아래와 같다.

  1. StandardQueryCache 캐시에서 해당 쿼리의 timestamp를 가져온다.
  2. UpdateTimestampsCache 영역에서 조회에 사용된 ParentMember, ChildMember에 대한 timestamp을 가져온다.
  3. StandarQueryCache의 timestamp가 더 늦은 경우, DB에서 다시 데이터를 조회해서 캐시를 수행한다.

쿼리 캐시의 경우 빈번하게 수정이 되는 엔티티에 사용하게 되면 잦은 최신화로 인해 오히려 성능이 떨어질 수 있다.

주의 사항

UpdateTimestampsCache 영역은 만료되지 않도록 설정해야한다. 해당 영역이 만료되게 되면 모든 쿼리 캐시 영역은 무효화되게 된다. 아래와 같이 eternal 속성을 true로 해야한다.

<cache
    name="org.hibernate.cache.spi.UpdateTimestampsCache"
    maxElementsInMemory="10000"
    eternal="true" />

엔티티 캐시와 달리 컬렉션 캐시와 쿼리 캐시는 엔티티의 식별자 값만 저장하고 있다. 따라서 이 식별자를 이용해서 엔티티 캐시에서 조회를 하는 것이다. 하지만 만약, 엔티티 캐시가 없다고 하면 성능상 문제가 발생하게 된다.

select m from Member m

위의 쿼리가 쿼리 캐시로 되어 있을 때, 쿼리 결과가 100개라 했을때, 해당 결과 집합에는 식별자값만 있게 된다. 이를 이용해서 엔티티 캐시에 검색을 하는데, 엔티티가 없다면 매번 식별자를 이용해서 DB에 조회를 하게 되어, 100번의 SQL문을 DB에 수행하게 된다.

이 처럼, 컬렉션 캐시나 쿼리 캐시의 경우 엔티티 캐시와 같이 사용해야 성능 개선을 꾀할 수 있다.

Spring Boot + Ehcache 3

Gradle

build.gradle

//cache 설정
implementation 'org.springframework.boot:spring-boot-starter-cache'
implementation 'org.ehcache:ehcache:3.8.0'
implementation 'javax.cache:cache-api'

Ehcache settings

Ehcache.xml

<config xmlns:xsi='http://www.w3.org/2001/XMLSchema-instance'
        xmlns='http://www.ehcache.org/v3'
        xmlns:jsr107="http://www.ehcache.org/v3/jsr107"
        xsi:schemaLocation="http://www.ehcache.org/v3
        http://www.ehcache.org/schema/ehcache-core.xsd
        http://www.ehcache.org/v3/jsr107
        http://www.ehcache.org/schema/ehcache-107-ext-3.0.xsd">
    <service>
        <jsr107:defaults enable-statistics="true"/>
    </service>
<!--여기부터 -->
    <cache alias="members">
        <expiry>
            <ttl unit="seconds">600</ttl>
        </expiry>

        <listeners>
            <listener>
                <class>hello.springdatajpa.CustomCacheEventListener</class>
                <event-firing-mode>SYNCHRONOUS</event-firing-mode>
                <event-ordering-mode>UNORDERED</event-ordering-mode>
                <events-to-fire-on>CREATED</events-to-fire-on>
                <events-to-fire-on>UPDATED</events-to-fire-on>
                <events-to-fire-on>EXPIRED</events-to-fire-on>
                <events-to-fire-on>REMOVED</events-to-fire-on>
                <events-to-fire-on>EVICTED</events-to-fire-on>
            </listener>
        </listeners>
        <resources>
            <heap unit="entries">2000</heap>
            <offheap unit="MB">100</offheap>
        </resources>
    </cache>
</config>
  1. 구성하고자 하는 캐시하나당 하나의 cache 태그 필요
<cache></cache>
  1. expiry 태그를 통해 캐시 유지 시간을 설정하도록 한다.

  2. listeners을 통해 Cache 관련 event에 대한 listener가 동작하도록 할 수 있다.

CustomCacheEventListener

@Slf4j
public class CustomCacheEventListener implements CacheEventListener<Object, Object> {
    @Override
    public void onEvent(CacheEvent event) {
        log.info("key={},oldValue={},newValue={}", event.getKey(), event.getOldValue(), event.getNewValue());
        System.out.println("CacheEventListener.onEvent");

    }
}
  1. resources 태글 통해, heap-memory와 off-heap 에 대한 설정을 한다.

Spring Boot Cache Setting

Config file

@Configuration
@EnableCaching
public class JpaConfig {
}

application.properties

#Cache Options
spring.cache.jcache.config=classpath:ehcache.xml
spring.cache.enabled=true

Cacheable Entity

  1. Serializable를 구현하도록 해야한다.

    Member.class

public class Member implements Serializable
  1. 캐시를 구성하고자 하는 항목에 대해 Cacheable annotation을 추가한다.

MemberService

@Service
@RequiredArgsConstructor
public class MemberService {
    private final MemberRepository memberRepository;

    @Transactional
    public void signin(Member member) {
        memberRepository.save(member);
    }

    @Transactional
    @Cacheable(cacheNames = "members")
    public List<Member> findAllMembers() {
        return memberRepository.findAll();
    }


    @Cacheable(cacheNames = "members",key="#id")
    public Member findOneMember(Long id) {
        return memberRepository.findById(id).get();
    }

    @CachePut(cacheNames="members",key="#member.getId()")
    public Member addMember(Member member) {
        memberRepository.save(member);
        return member;
    }
}

여기서, cacheNames는 ehcache.xml에서 지정한 cache태그의 이름과 동일해야한다.

Results

2022-08-21 19:59:51.256  INFO 1980 --- [nio-8080-exec-1] h.s.controller.TestController            : From cache
2022-08-21 20:00:00.558  INFO 1980 --- [nio-8080-exec-2] h.s.controller.TestController            : From cache
Hibernate: select member0_.id as id1_1_0_, member0_.city as city2_1_0_, member0_.street as street3_1_0_, member0_.zipcode as zipcode4_1_0_, member0_.name as name5_1_0_, member0_.team_id as team_id6_1_0_ from member member0_ where member0_.id=?
2022-08-21 20:00:05.197  INFO 1980 --- [e [_default_]-0] h.s.CustomCacheEventListener             : key=1,oldValue=null,newValue=[Member][id=1 name=test1 address=null]
CacheEventListener.onEvent
Hibernate: select member0_.id as id1_1_0_, member0_.city as city2_1_0_, member0_.street as street3_1_0_, member0_.zipcode as zipcode4_1_0_, member0_.name as name5_1_0_, member0_.team_id as team_id6_1_0_ from member member0_ where member0_.id=?
2022-08-21 20:00:05.198  INFO 1980 --- [e [_default_]-0] h.s.CustomCacheEventListener             : key=2,oldValue=null,newValue=[Member][id=2 name=test2 address=null]
CacheEventListener.onEvent
Hibernate: select member0_.id as id1_1_0_, member0_.city as city2_1_0_, member0_.street as street3_1_0_, member0_.zipcode as zipcode4_1_0_, member0_.name as name5_1_0_, member0_.team_id as team_id6_1_0_ from member member0_ where member0_.id=?
2022-08-21 20:00:05.200  INFO 1980 --- [e [_default_]-0] h.s.CustomCacheEventListener             : key=3,oldValue=null,newValue=[Member][id=3 name=test3 address=null]
CacheEventListener.onEvent
Hibernate: select member0_.id as id1_1_0_, member0_.city as city2_1_0_, member0_.street as street3_1_0_, member0_.zipcode as zipcode4_1_0_, member0_.name as name5_1_0_, member0_.team_id as team_id6_1_0_ from member member0_ where member0_.id=?
2022-08-21 20:00:05.201  INFO 1980 --- [e [_default_]-0] h.s.CustomCacheEventListener             : key=4,oldValue=null,newValue=[Member][id=4 name=test4 address=null]
CacheEventListener.onEvent
Hibernate: insert into member (city, street, zipcode, name, team_id, id) values (?, ?, ?, ?, ?, ?)
2022-08-21 20:00:05.214  INFO 1980 --- [e [_default_]-0] h.s.CustomCacheEventListener             : key=5,oldValue=null,newValue=[Member][id=5 name=test5 address=null]
CacheEventListener.onEvent

처음 캐시를 구성할 때만 query를 수행하게 되고, 이후에는 반복되는 요청에도 query를 요청하지 않고, 캐시를 활용하게 된다.

Cache 직접 접근


private final CacheManager cacheManager;
Cache cache=cacheManager.getCache("members")
cache.get(1L).get() //members 캐시 안에 key값이 1L인 cache entity에 접근

References

book: 자바 ORM 표준 JPA 프로그래밍 -김영한 저

book_link

hibernate-2nd-level-cache

태그: ,

카테고리:

업데이트:

댓글남기기