이번 시간에는 JPA가 제공하는 트랜잭션과 락 기능,
JPA가 제공하는 애플리케이션 범위의 캐시 (특히 2차 캐시를 중점으로)에 대해 다뤄보겠다.
JPA의 트랜잭션과 락 기능에 대해 알아보기 이전에 트랜잭션에 대해 가볍게 살펴보고 가겠다.
우선 기본적으로 트랜잭션은 ACID 를 만족해야 한다.
** ACID 란?
트랜잭션은 원자성, 일관성, 지속성을 보장한다.
그런데 격리성을 완벽히 보장하려면 트랜잭션을 거의 차례대로 실행해야 하는데 이렇게 되면 동시성 처리 성능이 매우 나빠진다. 😢
그래서 ANSI 표준은 트랜잭션 격리 수준을 4단계로 나누어 정리하였다.
참고로 표에서 아래로 갈수록 격리 수준이 높다.
격리 수준 | 발생하는 문제점 | 설명 |
---|---|---|
READ UNCOMMITED | DIRTY READ , NON- REPEATABLE READ , PHANTOM READ | 커밋하지 않은 데이터를 읽을 수 있다. 트랜잭션 1이 데이터를 수정하고 있는데 커밋하지 않아도 트랜잭션 2가 수정 중인 데이터를 조회할 수 있다. (= DIRTY READ) |
-- | -- | -- |
READ COMMITED | NON-REPEATABLE READ , PHANTOM READ | 커밋한 데이터만 읽을 수 있다. 하지만 트랜잭션 1이 회원 A를 조회 중일 때 트랜잭션 2가 회원 A를 수정하고 커밋하면 트랜잭션 1이 다시 조회할 때 수정된 회원 A가 조회되는, 반복해서 같은 데이터를 읽을 수 없는 NON-REPEATABLE READ가 발생한다. |
-- | -- | -- |
REPEATABLE READ | PHANTOM READ | 한 번 조회한 데이터는 반복해도 같은 데이터가 조회된다. 하지만 트랜잭션1이 10살 이하 회원을 조회했고, 트랜잭션 2가 5살 회원을 추가하고 커밋을 한 상태에서, 트랜잭션1 이 다시 10살 이해 회원을 조회할 때 회원이 하나로 추가된 상태로 조회되는, 반복 조회 시 결과 집합이 달라지는 PHANTOM READ가 발생할 수 있다. |
-- | -- | -- |
SERIALIZABLE | 가장 엄격한 트랜잭션 격리 수준이다. 하지만 동시성 처리 성능이 급격히 떨어질 수 있다. |
JPA의 영속성 컨텍스트를 적절히 활용하면 데이터베이스 트랜잭션이 READ COMMITEED 격리 수준이어도 애플리케이션 레벨에서 반복 가능한 읽기가 가능하다.
그런데 일부 로직에서 더 높은 격리 수준이 필요하다면 낙관적 락과 비관적 락 중 하나를 사용하면 된다.
트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법으로, JPA가 제공하는 버전 관리 기능을 사용한다.
낙관적 락은 트랜잭션 커밋 전까지 트랜잭션 충돌을 알 수 없다.
트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법이다. 데이터베이스가 제공하는 락 기능을 사용한다.
⭐️ 알고가기 ( 두 번의 갱실 분실 문제)
: 사용자 A, B가 동시에 같은 공지사항을 수정했을 때 사용자 A가 먼저 수정 완료 버튼을 누른 후 B가 눌렀을 때 사용자 A의 수정사항은 사라지고 B의 수정사항만 남게되는 문제. 트랜잭션 범위를 넘어섰기 때문에 트랜잭션만으로 해결할 수 없고 다음의 3가지 해결방법이 있다.
1. 마지막 커밋만 인정 -> 기본적으로 사용되는 방법
2. 최초 커밋만 인정 -> 이 방법이 필요하면, JPA의 버전 관리 기능을 사용하면 구현 가능
3. 사용자 A와 사용자 B의 수정사항을 병합 -> 개발자가 직접 병합 방법을 제공해야 함
마지막으로 @Version만 보고 본격적으로 낙관적 락과 비관적 락을 살펴보겠다.
JPA가 제공하는 낙관적 락을 사용하려면 @Version 어노테이션을 사용해서 버전 관리 기능을 추가해야 함.
적용 가능 타입은 Long (long), Integer (int) , Short (short) , Timestamp 가 있다.
버전 관리 기능을 적용하려면 아래 코드처럼 엔티티에 버전 관리용 필드를 추가하고 어노테이션을 붙이면 된다.
@Entity
public class Board {
...
@Version
private Integer version;
|
이렇게 적용하면 엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외가 발생한다.
예를 들어 트랜잭션 1이 게시물A를 조회하고 -> 트랜잭션 2가 게시물 A를 수정 후 커밋하여 게시물A의 버전 정보가 2로 증가되었다 -> 트랜잭션 1이 게시물 A를 수정하고 커밋하는 순간 조회할 때 버전과 데이터베이스의 현재 버전 정보가 다르므로 예외가 발생하게 된다.
따라서 버전 관리 기능을 적용하면 두 번의 갱실 분실 문제에서 최초 커밋만 인정하기가 적용된다.
참고로 버전 정보는 엔티티를 수정하고 영속성 컨텍스트가 플러시 될 때 아래와 같은 UPDATE 쿼리를 실행하여 버전 정보를 비교한다.
UPDATE BOARD
SET
TITLE = ?
VERSION = ? (버전 + 1 증가)
WHERE
ID = ?
AND VERSION = ?
만약 데이터베이스 버전과 엔티티 버전이 같으면 위의 sql이 정상적으로 실행될 것이고,
다르다면 수정할 대상이 조회되지 않기 때문에 JPA는 버전이 증가한 것으로 판단하고 예외를 발생시킨다.
버전은 엔티티의 값을 변경하면 증가하는데 연관관계 필드는 연관관계의 주인 필드를 수정할 때만 버전이 증가한다.
그런데 벌크 연산은 버전을 무시하니까 주의하자! 😱 벌크 연산에서 버전을 증가하려면 버전 필드를 강제로 증가시켜야 한다.
이제 본격적으로 어떻게 사용하는지 알아보자.
참고 : JPA를 사용할 땐 READ COMMITTED 트랜잭션 격리 수준 + 낙관적 버전 관리를 추천한다.
락은 다음 위치에 적용할 수 있다.
예를 들어 조회하면서 즉시 락을 걸려면
em.find(Board.class , id , LockModeType.OPTIMISTIC);
이와 같이 걸 수 있다.
JPA가 제공하는 락 옵션은 javax.presistence.LockModeType
에 정의되어 있다.
LockModeType | 설명 |
---|---|
NONE | 엔티티에 대한 락을 적용하지 않음. 기본값. |
OPTIMISTIC | 낙관적 락을 적용하며, 버전 필드를 기반으로 변경 충돌을 감지함. |
OPTIMISTIC_FORCE_INCREMENT | OPTIMISTIC 과 동일하지만, 트랜잭션이 끝날 때 버전 값을 강제로 증가시킴. |
PESSIMISTIC_READ | 비관적 락을 적용하며, 다른 트랜잭션이 해당 데이터를 수정할 수 없도록 공유 잠금을 설정함. |
PESSIMISTIC_WRITE | 비관적 락을 적용하며, 다른 트랜잭션이 해당 데이터를 읽거나 수정할 수 없도록 배타적 잠금을 설정함. |
PESSIMISTIC_FORCE_INCREMENT | PESSIMISTIC_WRITE 와 동일하지만, 트랜잭션이 끝날 때 버전 값을 강제로 증가시킴. |
앞서 말했듯이 낙관적 락은 버전을 사용한다고 했다.
락 옵션 없이 @Version만 있어도 낙관적 락이 적용되지만, 락 옵션을 사용하면 조금 더 세밀한 제어가 가능하다.
** 낙관적 락 옵션
NONE
옵션을 적용하지 않을 때의 기본 적용 옵션
엔티티를 수정할 때 버전을 체크하면서 버전을 증가시키고, 데이터베이스의 버전 값이 현재 버전이 아니면 예외를 발생시킴. 두번의 갱신 분실 문제를 예방함
OPTIMISTIC
엔티티를 조회만 해도 버전을 체크함.
트랜잭션을 커밋할 때 버전 정보를 조회해서 현재 엔티티의 버전과 같은지 검증하며 같지 않으면 예외를 발생시킴. 조회 시점부터 트랜잭션이 끝날 때까지 조회한 엔티티가 변경되지 않음을 보장함.
OPTIMISTIC_FORCE_INCREMENT
낙관적 락을 사용하면서 버전 정보를 강제로 증가시킴.
원래 연관관계에서 연관관계의 주인 필드만 수정이 해야 버전이 증가하는데, 주인이 아닌 필드를 수정해도 버전이 증가되어, 논리적인 단위의 엔티티 묶음을 버전 관리 할 수 있게 함.
JPA가 제공하는 비관적 락은 데이터베이스 트랜잭션 락 메커니즘에 의존한다.
주로 SQL 쿼리에 select for update 구문을 사용하여 시작하고 버전 정보는 사용하지 않는다.
비관적 락을 사용하면 데이터를 수정하는 즉시 트랜잭션 충돌을 감지할 수 있다.
비관적 락은 주로 PERSSIMISTIC_WRITE 모드를 사용하는데 하나씩 살펴보겠다.
PERSSIMISTIC_WRITE
가장 일반적으로 사용하는 옵션으로 데이터베이스에 쓰기 락을 걸 때 사용.
락이 걸린 로우는 다른 트랜잭션이 수정도 조회도 할 수 없음.
PESSIMISTIC_READ
데이터를 반복 읽기만 하고 수정하지 않는 용도로 락을 걸 때 사용함. 잘 사용하지는 않음.
PESSIMISTIC_FORCE_INCREMENT
비관적 락 중 유일하게 버전 정보를 사용. 비관적 락이지만 버전 정보를 강제로 증가시킴. 이 락을 설정하면, 비관적 락을 걸었지만 다른 트랜잭션이 낙관적 락을 사용할 때 그곳에도 충돌을 감지할 수가 있다.
비관적 락을 사용하면 락을 획득까지 트랜잭션이 대기해야 한다.
타임아웃 시간을 설정할 수 있는데 설정 타임아웃 시간을 넘어가면 javax.persistence.LockTimeoutException이 발생하게 된다.
Map<String, Object> properties = new HashMap<String,Object>();
properties.put("javax.persistence.lock.timeout", 10000); // 타임아웃 10초로 설정
em.find(Board.class , "boardId" ,
LockModeType.PERSSIMISTIC_WRITE , properties);
네트워크로 데이터베이스로 접근하는 시간은 정말 비싸다.
이 시간 비용을 1차 캐시와 2차 캐시로 줄일 수가 있다.
1차 캐시는 앞선 곳에서 계속 설명하고 언급했던 영속성 컨텍스트 내부에 엔티티를 보관하는 저장소를 말한다.
그런데 트랜잭션을 시작하고 종료할 때까지만 1차 캐시가 유효해서 애플리케이션 전체로 보면 데이터베이스 접근 횟수를 크게 줄이지는 못한다.
대부분의 JPA 구현체들은 애플리케이션 범위의 캐시를 지원하는데 이를 공유 캐시 또는 2차 캐시라고 한다.
1차 캐시의 동작 방식은 앞서 설명했으니 2차 캐시 동작 방식에 대해 자세히 살펴보자.
❓ 1차 캐시 동작 방식 모르겠다면 ? 간단하게 휘리릭 보기💨
최초 조회할 땐 1차 캐시에 엔티티가 없으니 ->
DB에 엔티티를 조회해서 1차 캐시에 보관하고 ->
이거를 반환함 -> 이후 해당 엔티티에 대한 조회 요청이 오면 DB에서 조회하지 않고 1차 캐시의 엔티티를 그대로 반환
2차 캐시는 애플리케이션 범위여서 애플리케이션을 종요할 때까지 캐시가 유지된다.
2차 캐시를 저굥하면 엔티티 메니저를 통해 데이터를 조회할 때 우선 2차 캐시에서 찾고 없으면 DB에서 찾게 된다. 그래서 잘 활용하면 데이터베이스 조회 횟수를 획기적으로 줄일 수 있다.
동작 방식을 보자.
2차 캐시는 보관하고 있는 엔티티 원본을 반환하지 않고 복사를 해서 반환한다.
이렇게 하면 동시성을 극대화할 수 있기 때문인데,
객체를 그대로 반환하면 여러 곳에서 같은 객체를 동시에 수정하는 문제가 발생할 수 있기 때문이다.
객체에 락을 거는 방법도 있지만 락에 비해 객체를 복사하는 비용이 더 저렴하다.
2차 캐시는 데이터베이스 기본 키를 기준으로 캐시하지만 영속성 컨텍스트가 다르면 객체 동일성 (a==b)를 보장하지 않으니 주의하자.
이번에는 JPA가 정의한 2차 캐시 표준을 살펴보자.
(아래에서부터 2차 캐시를 그냥 캐시라 부르겠다. 1차 캐시는 1차 캐시라고 부르겠다.)
엔티티에 javax.persistence.Cacheable
어노테이션을 사용하면 2차 캐시를 사용할 수 있다.
@Cacheable(true/false) 를 설정할 수 있는데 기본 값은 true이다.
@Cacheable
@Entity
public class Member {
...
}
다음으로 persistence.xml
에 share-cache-mode
를 설정해 캐시를 어떻게 적용할지 옵션을 설정해야 한다.
<persistence-unit name = "test">
<shared-cache-mode>ENABLE_SELECTIVE</shared-cache-mode>
</persistence-unit>
여기서 캐시 모드는 javax.persistence.SharedCacheMode
에 정의되어있지만 아래에 표로 정리해두었으니 굳이 저기까지 갈 필요는 없을 것 같다.
모드 | 설명 |
---|---|
ALL | 모든 엔티티를 캐시한다. |
NONE | 캐시를 사용하지 않음 |
ENABLE_SELECTIVE | 특정 엔티티만 2차 캐시에 저장하도록 설정할 수 있음. (기본값) |
DISABLE_SELECTIVE | 모든 엔티티를 캐시하는데 Cacheable(false)로 명시된 엔티티는 캐시하지 않음. |
UNSPECIFIED | JPA 구현체가 정의한 설정을 따름 |
캐시를 무시하고 데이터베이스를 직접 조회하거나 캐시를 갱신하려면
캐시 조회 모드 와 보관 모드를 사용하면 된다.
//사용 예시 코드
em.setProperty("javax.persistence.cache.retrieveMode" ,
CacheRetrieveMode.BYPASS);
프로퍼티 이름은 javax.persistece.cache.retrieveMode
을 사용한다.
캐시 조회 모드의 설정 옵션은 USE
, BYPASS
가 있다.
USE
: 캐시에서 조회하며 기본 값임
BYPASS
: 캐시를 무시하고 데이터베이스에 직접 접근함
프로퍼티 이름은 javax.persistence.cache.storeMode
을 사용한다.
옵션에는 USE
, BYPASS
, REFRESH
가 있다.
USE
: 기본값이며 조회한 데이터를 캐시에 저장. 조회한 데이터가 이미 캐시에 있으면 캐시 데이터를 최신 상태로 갱신하지 않음. 트랜잭션을 커밋하면 등록 수정한 엔티티도 캐시에 저장함.
BYPASS
: 캐시에 저장하지 않음.
REFRESH
: USE
전략에 추가로 데이터베이스에서 조회한 엔티티를 최신 상태로 다시 캐시함.
캐시 모드는 EntityManager.setPropert()로 엔티티 매니저 단위로 설정하거나,
EntityManager.find()/refresh()와 같이 더 세밀하게 설정 할 수 있다.
Query.setHint() 에도 사용할 수 있다.
JPA는 캐시를 관리하기 위한 javax.persistence.Cache
인터페이스를 제공한다.
EntityManagerFactory
에서 구하여 사용할 수 있다.
Cache cache = emf.getCache();
boolean contains =
cache.contains(TestEntity.class, testEntity.getId());
//Cache 관리 객체 조회
Cache interface의 자세한 기능은 아래를 참조하자.
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차 캐시를 적용해보자.
하이버네이트가 지원하는 캐시는 크게 3가지가 있다.
JPA 표준에는 아래에서 엔티티 캐시만 정의되어 있다.
하이버네이트에서 EHCACHE를 사용하려면 hibernate-ehcache
라이브러리를 pom.xml에 추가해야 한다.
그리고 이것을 추가하면 net.sf.ehcache-core
라이브러리도 추가된다.
EHCACHE는 ehcache.xml을 설정 파일로 사용하는데, 이 설정 파일은
캐시를 얼마만큼 보관할지, 얼마 동안 보관할지와 같은 캐시 정책을 정의하는 파일이다.
이 파일을 클래스패스 루트인 src/main/resources
에 두어야 한다.
//ehcache.xml 예시
<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>
설정한 속성 정보는 아래와 같다.
준비가 되었으니 본격적으로 사용해보자.
함 적용해보자.😾
@Cacheable // (1)
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // (2)
@Entity
public class ParentMember {
@Id @GeneratedValue
private Long id;
....
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE) // (3)
@OneToMany(mappedBy = "parentMember", cascade = CascadeType.ALL)
private List<ChildMember> childMembers = new ArrayList<ChildMember>();
...
}
여기서 하이버네이트 전용인 Cache 어노테이션를 자세히 살펴보자.
** 하이버네이트 @Cache 속성
속성 | 설명 |
---|---|
usage | CacheConcurrencyStrategy 를 사용해 캐시 동시성 전략을 설정한다. |
region | 캐시 지역 설정 |
include | 연관 객체를 캐시에 포함할지 선택한다. all, non-lazy 옵션을 선택할 수 있으며 기본값은 all 이다. |
캐시 동시성 전략을 설정할 수 있는 usage 속성은 꽤나 중요하니 CacheConcurrencyStrategy 를 살펴보자.
** CacheConcurrencyStrategy 속성
속성 | 설명 |
---|---|
none | 캐시를 설정하지 않음 |
READ_ONLY | 읽기 전용. 등록 삭제는 가능하나 수정할 수 없음. 자주 변경되지 않는 데이터에 적합. |
NONSTRICT_READ_WRITE | 엄격하지 않은 읽고 쓰기 전략. 동시에 같은 엔티티를 수정하면 데이터 일관성이 깨질 수 있음. EHCACHE는 데이터를 수정하면 캐시 데이터를 무효화함. |
READ_WRITE | 읽기-쓰기 가능하며, READ COMMITTED 정도의 격리 수준을 보장함. EHCACHE는 데이터를 수정하면 캐시 데이터도 같이 수정함. |
TRANSACTIONAL | 컨테이너 관리 환경에서 사용할 수 있으며 설정에 따라 REPEATABLE READ 정도의 격리 수준을 보장받을 수 있음. |
캐시 종류에 따라 동시성 전략 지연 여부는 다른데, EHCache는 다 지원해준다
이렇게 캐시를 적용한 코드는 다음의 캐시 영역에 저장된다.
엔티티 캐시 영역 : jpabook.jpashop.domain.test.cache.ParentMember
컬렉션 캐시 영역 : jpabook.jpashop.domain.test.cache.ParentMember.childMembers
경로는 프로젝트 설정에 따라 달라지겠지만,
엔티티 캐시 영역은 기본적으로 [패키지명 + 클래스 명] 을 사용하고
컬렉션 캐시 영역은 캐시 영역 이름에 캐시한 컬렉션의 필드명이 추가된다.
쿼리 캐시는 쿼리와 파라미터 정보를 키로 사용해서 쿼리 결과를 캐시하는 방법이다.
쿼리 캐시를 적용하려면, 영속성 유닛 설정에 hibernate.cache.use_query_cache
옵션을 꼭 true로 설정해야 한다.
아래는 쿼리 캐시를 적용하는 예시이다.
em.createQuery("select i from Item i", Item.class);
.setHint("org.hibernate.cacheable", true)
.getResultList();
NameQuery에서도 사용가능하다.
@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 {
...
}
쿼리 캐시를 활성화하면 다음 두 캐시 영역이 추가된다.
쿼리 캐시는 캐시한 데이터 집합을 최신 데이터로 유지하려고 이 두 영역을 활용한다.
쿼리 캐시를 적용하고 난 후 쿼리 캐시가 사용하는 테이블에 조금이라도 변경이 있으면 데이터베이스에서 데이터를 읽어와서 쿼리 결과를 다시 캐시한다.
과정을 더 자세히 설명하자면,
쿼리 캐시가 활성화된 쿼리를 실행하면, 우선 StandardQueryCache 영역에서 타임스탬프를 조회한다.
그리고 쿼리가 사용하는 엔티티의 테이블들을 UpdateTimestampsCache 캐시 영역에서 조회하여 테이블들의 타임스탬프를 확인한다.
이때 StandardQueryCache 캐시 영역의 타임스탬프가 더 오래되었다면 캐시가 유효하지 않은 것으로 보고 데이터베이스에서 데이터를 조회해 다시 캐시한다.
따라서 빈번하게 변경이 있는 테이블에 사용하면 오히려 성능이 저하될 수 있으니 주의하여 사용해야 한다.
엔티티 캐시를 사용하면 엔티티 정보를 모두 캐시하지만 쿼리 캐시와 컬렉션 캐시는 결과 집합의 식별자 값만 캐시한다.
그래서 쿼리 캐시와 컬렉션 캐시를 조회하면 그 안에는 식별자 값만 들어 있다.
이 식별자 값을 하나씩 엔티티 캐시에서 조회해서 실제 엔티티를 찾는다.
이 때문에 쿼리 캐시/컬렉션 캐시만 사용하고 대상 엔티티에 엔티티 캐시를 적용하지 않으면 심각한 문제😬 가 발생할 수 있다.
예를 들어
select m from Member m 라는 쿼리 캐시가 적용되어 있는 쿼리를 실행했고 결과 집합은 100건이 나왔다.
결과 집합에는 식별자만 있으니 한 건씩 엔티티 캐시 영역에서 조회한다.
Member 엔티티는 엔티티 캐시를 사용하지 않으므로 한 건씩 데이터베이스에서 조회한다.
-> 결국 100건 의 SQL 이 실행된다.
이 문제를 방지하기 위해 쿼리 캐시/컬렉션 캐시를 사용하면 결과 대상 엔티티에는 꼭 엔티티 캐시를 적용 해야한다.
이렇게 길고 길었던 JPA에 대해 다 알아보았다.
물론 다는 아니겠지만, 이정도 알면 어디가서 JPA 모른다는 소리는 절대 듣지 않을 것 같다.
참조 : 자바 ORM 표준 JPA 프로그래밍 - 김영한