-
Notifications
You must be signed in to change notification settings - Fork 0
chap 16 minsung
- 이 장에서 다루는 내용
- 트랜잭션과 락 : JPA가 제공하는 트랜잭션과 락 기능
- 2차 캐시 : JPA가 제공하는 애플리케이션 범위의 캐시
-
트랜잭션은 ACID을 보장해야 한다
- 원자성(Atomicity) : 트랜잭션 내에서 실행한 작업들은 모두 성공하거나 실패
- 일관성(Consistency) : 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 함(무결성 제약 조건을 항상 만족)
- 격리성(Isolation) : 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리함(동시에 같은 데이터를 수정하지 못하도록 함) 동시성과 관련된 성능 이슈로 인해 격리 수준을 선택 가능
- 지속성(Durability) : 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 함. 중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해서 성공한 트랜잭션 내용을 복구해야 함
-
트랜잭션 간 격리성을 완벽히 보장하려면 트랜잭션을 차례대로 실행해야 한다. 이 경우 동시성 처리 성능이 매우 나빠진다.
-
ANSI 표준에서는 트랜잭션의 격리 수준을 4단계로 나누어 정의함
-
트랜잭션 격리 수준(Isolation level)
- READ UNCOMMITTED(커밋되지 않은 읽기)
- READ COMMITTED(커밋된 읽기)
- REPEATABLE READ(반복 가능한 읽기)
- SERIALIZABLE(직렬화 가능)
-
READ UNCOMMITTED의 격리 수준이 가장 낮고 SERIALIZABLE의 격리 수준이 가장 높다. 격리 수준이 낮을수록 동시성은 증가하지만 격리 수준에 따른 다양한 문제가 발생.
-
트랜잭션 격리 수준과 문제점
격리 수준 | DIRTY READ | NON-REPEATABLE READ | PHANTOM READ |
---|---|---|---|
READ UNCOMMITTED | O | O | O |
READ COMMTITED | O | O | |
REPEATABLE READ | O | ||
SERIALIZABLE |
참고 : https://nesoy.github.io/articles/2019-05/Database-Transaction-isolation
-
READ UNCOMMITTED
- 커밋하지 않은 데이터를 읽을 수 있다. (DIRTY READ)
- 데이터 정합성에 심각한 문제가 발생할 수 있다
-
READ COMMITTED
- 커밋한 데이터만 읽을 수 있다. (DIRTY READ 발생하지 않음)
- 트랜잭션내에서 같은 데이터를 읽을 수 없는 상태가 발생할 수 있다. (NON-REPEATABLE READ)
-
REPEATABLE READ
- MySQL에서는 트랜잭션마다 트랜잭션ID를 부여하여 트랜잭션ID 보다 작은 트랜잭션 번호에서 변경한 것만 읽게 한다. (NON-REPEATABLE READ 발생하지 않음)
- 반복 조회시 결과 집합이 달라지는 문제가 발생할 수 있다(PHANTOM READ)
-
SERIALIZABLE
- 가장 엄격한 트랜잭션 격리 수준. PHANTOM READ가 발생하지 않는다
- 동시성 처리 성능이 급격히 떨어질 수 있다
-
데이터베이스에서는 READ COMMITTED 격리 수준을 기본으로 사용
- JPA의 영속성 컨텍스트를 적절히 활용하면 데이터베이스 트랜잭션이 READ COMMITTED 격리 수준이어도 애플리케이션 레벨에서 반복 가능한 읽기(REPEATABLE READ) 가능(엔티티 조회시)
- JPA는 데이터베이스 트랜잭션 격리 수준을 READ COMMITTED 정도로 가정. 일부 로직에 더 높은 격리 수준이 필요하면 낙관적 락, 비관적 락 중 하나를 사용
- 낙관적 락
- 트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법
- JPA가 제공하는 버전 관리 기능을 사용(애플리케이션이 제공하는 락)
- 트랜잭션을 커밋하기 전까지 트랜잭션의 충돌을 알 수 없다
- 비관적 락
- 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 거는 방법
- 데이터베이스가 제공하는 락 기능을 사용(select for update) - https://dololak.tistory.com/446
- 트랜잭션 범위를 넘어서는 문제(두 번의 갱신 분실 문제)
- 사용자 두 명이 동시에 같은 내용을 수정할 때 한 명의 수정사항이 사라지는 문제
- 마지막 커밋만 인정(기본) or 최초 커밋만 인정(JPA 버전 관리 기능) or 충돌하는 갱신 내용 병합(개발자가 직접 방법 제공)
- JPA가 제공하는 낙관적 락을 사용하려면
@Version
어노테이션을 사용해서 버전 관리 기능을 추가 - 적용 가능 타입 : Long, Integer, Short, Timestamp
- 엔티티에 버전 관리용 필드를 추가하고
@Version
을 붙이면 엔티티를 수정할 때 마다 버전이 하나씩 자동으로 증가한다 - 버전 정보를 비교하여 조회 시점의 버전과 수정 시점의 버전이 다르면 예외가 발생
- 버전 정보를 사용하면 최초 커밋만 인정하기가 적용된다
// Board.java
@Entity
public class Board {
@Id
private String id;
private String title;
@Version
private Integer version;
}
- 데이터베이스 버전과 엔티티 버전이 같으면 데이터를 수정하면서 동시에 버전도 증가시킨다
- 버전이 다른 경우 JPA가 예외를 발생시킨다
- 버전은 엔티티의 값을 변경하면 증가한다. 값 타입, 값 타입 컬렉션은 논리적인 개념상 엔티티의 값이므로 역시 수정하면 엔티티의 버전이 변경된다. 단 연관관계 필드는 외래 키를 관리하는 연관관계의 주인 필드를 수정할 때만 버전이 증가
-
@Version
으로 추가한 버전 관리 필드는 JPA가 직접 관리하므로 개발자가 임의로 수정하면 안 된다(벌크 연산 제외) 버전 값을 강제로 증가하려면 락 옵션을 선택하면 됨
- JPA를 사용할 때 추천하는 전략은 READ COMMITTED 트랜잭션 격리 수준 + 낙관적 버전 관리
- JPA 락 적용 위치
- em.lock(), em.find(), em.refresh()
- query.setLockMode()
- @NamedQuery
- JPA가 제공하는 락 옵션(javax.persistence.LockModeType)
- JPA가 제공하는 낙관적 락은
@Version
을 사용 - 트랜잭션을 커밋하는 시점에 충돌을 알 수 있다
- 낙관적 락에서 발생하는 예외
- javax.persistence.OptimisticLockException
- org.hibernate.StaleObjectStateException
- org.springframework.orm.ObjectOptimisticLockingFailureException
- 락 옵션 없이
@Version
만 있어도 낙관적 락이 적용
-
NONE
-
@Version
+ 락 옵션을 적용하지 않음 - 용도 : 조회한 엔티티를 수정할 때 다른 트랜잭션에 의해 변경(삭제)되지 않아야 함. 조회 시점부터 수정 시점까지를 보장
- 동작 : 엔티티를 수정할 때 버전을 체크하면서 버전을 증가. 데이터베이스의 버전 값이 현재 버전이 아니면 예외 발생
- 이점 : 두 번의 갱신 분실 문제를 예방
-
-
OPTIMISTIC
- 해당 옵션을 추가하면 엔티티를 조회만 해도 버전을 체크함
- 용도 : 조회 시점부터 트랜잭션이 끝날 때까지 조회한 엔티티가 변경되지 않음을 보장
- 동작 : 트랜잭션을 커밋할 때 버전 정보를 조회해서(SELECT 쿼리) 현재 엔티티의 버전과 같은지 검증. 같지 않으면 예외 발생
- 이점 : DIRTY READ, NON-REPEATABLE READ를 방지
- OPTIMISTIC_FORCE_INCREMENT
- 낙관적 락을 사용하면서 버전 정보를 강제로 증가
- 용도 : 논리적 단위의 엔티티 묶음을 관리. 게시물-첨부파일의 양방향 연관관계에서 연관관계의 주인인 첨부파일이 수정되었을 때 게시물 버전은 물리적으로 변하지 않지만 논리적으로는 변경되었으므로 해당 기능을 사용
- 동작 : 엔티티를 수정하지 않아도 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해서 버전 정보를 강제로 증가. 추가로 엔티티를 수정하면 수정 시 버전 UPDATE가 추가로 발생
- 이점 : 강제로 버전을 증가해서 논리적인 단위의 엔티티 묶음을 버전 관리할 수 있다
-
JPA가 제공하는 비관적 락은 데이터베이스 트랜잭션 락 메커니즘에 의존하는 방법
-
SQL 쿼리에 select for update 구문을 사용, 버전 정보는 사용하지 않음
-
주로 PESSIMISTIC_WRITE 모드 사용
-
특징
- 엔티티가 아닌 스칼라 타입을 조회할 때도 사용
- 데이터를 수정하는 즉시 트랜잭션 충돌 감지 가능
-
비관적 락에서 발생하는 예외
- javax.persistence.PessimisticLockException
- org.springframework.dao.PessimisticLockingFailureException
-
PESSIMISTIC_WRITE
- 비관적 락의 일반적 옵션
- 용도 : 데이터베이스에 쓰기 락을 건다
- 동작 : 데이터베이스 select for update를 사용해서 락을 건다
- 이점 : NON-REPEATABLE READ를 방지. 락이 걸린 로우는 다른 트랜잭션이 수정할 수 없다
-
PESSIMISTIC_READ
- 데이터를 반복 읽기만 하고 수정하지 않는 용도로 락을 걸 때 사용. 데이터베이스 대부분은 방언에 의해 PESSIMISTIC_WRITE로 동작
-
PESSIMISTIC_FORCE_INCREMENT
- 비관적 락이지만 버전 정보를 강제로 증가시킨다. 하이버네이트는 nowait를 지원하는 데이터베이스에 대해 for update nowait 옵션을 적용(오라클, PostgreSQL) nowait를 지원하지 않으면 for update가 사용됨
- 비관적 락을 사용하면 락을 획득할 때까지 트랜잭션이 대기하는데, 이 때 타임아웃 시간을 줄 수 있다.
- 응답이 없는 경우 javax.persistence.LockTimeoutException 예외가 발생
- 네트워크를 통해 데이터베이스에 접근하는 시간 비용 >>>> 애플리케이션 서버에서 내부 메모리에 접근하는 시간 비용
- 조회한 데이터를 메모리에 캐시에서 데이터베이스 접근 횟수를 줄이면 성능 개선 가능
- 1차 캐시 : 영속성 컨텍스트 내부의 엔티티를 보관하는 장소. 일반적인 웹 애플리케이션 환경에서는 트랜잭션을 시작하고 종료할 때까지만 유효함(OSIV 사용해도 클라이언트가 요청을 시작하고 끝날 때까지만 유효)
- 2차 캐시(공유 캐시) : 하이버네이트를 포함한 대부분의 JPA 구현체들은 애플리케이션 범위의 캐시를 지원
- 영속성 컨텍스트 내부에 있으며 엔티티 매니저로 조회하거나 변경하는 모든 엔티티가 저장. 트랜잭션 커밋이나 플러시 호출 시 1차 캐시에 있는 엔티티의 변경 내역이 데이터베이스에 동기화 됨
- 끄고 켤수 있는 옵션이 아니다. 영속성 컨텍스트 자체가 사실상 1차 캐시
- 특징
- 같은 엔티티가 있으면 해당 엔티티를 그대로 반환. 1차 캐시는 객체 동일성(a == b)을 보장
- 기본적으로 영속성 컨텍스트 범위의 캐시(스프링 프레임워크 같은 컨테이너 환경에서는 트랜잭션 범위, OSIV를 적용하면 요청 범위)
- 애플리케이션에서 공유하는 캐시로 애플리캐이션을 종료할 때까지 캐시가 유지된다
- 2차 캐시를 적용하면 엔티티 매니저를 통해 데이터를 조회할 때 우선 2차 캐시에서 찾고 없으면 데이터베이스에서 찾는다
-
2차 캐시의 동작 방식
- 영속성 컨텍스트는 엔티티가 필요하면 2차 캐시를 조회
- 2차 캐시에 엔티티가 없으면 데이터베이스를 조회하여 2차 캐시에 보관
- 2차 캐시는 자신이 보관하고 있는 엔티티를 복사해서 반환
- 2차 캐시에 저장되어 있는 엔티티를 조회하면 복사본을 만들어 반환
-
2차 캐시는 동시성을 극대화하려고 캐시한 객체를 직접 반환하지 않고 복사본을 만들어 반환한다. 이것은 여러 곳에서 같은 객체를 동시에 수정하기 위해 락을 걸지 않아도 되어 비용적 측면에서 저렴하다.
-
2차 캐시의 특징
- 영속성 유닛 범위의 캐시이다
- 조회한 객체를 그대로 반환하는 것이 아니라 복사본을 만들어서 반환한다
- 데이터베이스 기본 키를 기준으로 캐시하지만 영속성 컨텍스트가 다르면 객체 동일성(a == b)를 보장하지 않는다
- JPA 캐시 표준은 여러 구현체가 공통으로 사용하는 부분만 표준화함
- 세밀한 설정을 하기 위해서는 구현체에 의존적인 기능을 사용해야 함
- 2차 캐시를 사용하려면 엔티티에
@Cacheable
어노테이션 사용. 기본값은 true - persistence.xml에 shared-cache-mode 설정으로 애플리케이션 전체에 캐시를 어떻게 적용할지 옵션 설정
- 캐시를 무시하고 데이터베이스를 직접 조회하거나 캐시를 갱신하려면 캐시 조회 모드와 캐시 보관 모드를 사용
- 캐시 조회 모드
- retrieveMode(프로퍼티), CacheRetrieveMode(설정 옵션)
public enum CacheRetrieveMode {
USE, // 캐시에서 조회
BYPASS // 캐시를 무시하고 데이터베이스에 직접 접근
}
- 캐시 보관 모드
- storeMode(프로퍼티), CacheStoreMode(설정 옵션)
public enum CacheStoreMode {
USE, // 조회한 데이터를 캐시에 저장. 이미 캐시에 있으면 최신 상태로 갱신하지 않음.
// 트랜잭션 커밋시 등록 수정한 엔티티도 캐시에 저장
BYPASS, // 캐시에 저장하지 않는다
REFRESH // USE 전략에 추가로 데이터베이스에서 조회한 엔티티를 최신 상태로 다시 캐시
}
- 캐시 모드는 1. em.setProperty()로 엔티티 매니저 단위로 설정하거나 2. em.find(), em.refresh()에 설정할 수 있다. 3. Query.setHint()에 사용할 수 있다
// 1. em.setProperty()
em.setProperty("javax.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS);
em.setProperty("javax.persistence.cache.storeMode", CacheStoreMode.BYPASS);
// 2. em.find()
Map<String, Object> param = new HashMap<String, Object>();
param.put("javax.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS);
param.put("javax.persistence.cache.storeMode", CacheStoreMode.BYPASS);
em.find(TestEntity.class, id, param);
// 3. Query.setHint()
em.createQuery("select a from TestEntity e where e.id = :id", TestEntity.class)
.setParameter("id", id);
.setHint("javax.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS);
.setHint("javax.persistence.cache.storeMode", CacheStoreMode.BYPASS);
.getSingleResult();
- JPA는 캐시를 관리하기 위한 javax.persistence.Cache 인터페이스를 제공
- 하이버네이트가 지원하는 캐시
- 엔티티 캐시 : 엔티티 단위로 캐시. 식별자로 엔티티를 조회하거나 컬렉션이 아닌 연관된 엔티티를 로딩할 때 사용(JPA 표준)
- 컬렉션 캐시 : 엔티티와 연관된 컬렉션을 캐시. 컬렉션이 엔티티를 담고 있으면 식별자 값만 캐시(하이버네이트)
- 쿼리 캐시 : 쿼리와 파라미터 정보를 키로 사용해서 캐시. 결과가 엔티티면 식별자 값만 캐시(하이버네이트)
- pom.xml에 hibernate-ehcache 라이브러리 추가
- src/main/resources/ehcache.xml 설정 파일 추가
- 캐시 보관 기간, 캐시 보관 크기 등의 캐시 정책 설정
- persistence.xml에 캐시 사용정보 설정
- ParentMember는 엔티티 캐시 적용, ParentMember.childMembers는 컬렉션 캐시 적용
참고 : https://jojoldu.tistory.com/57
- 엔티티 캐시 영역은 [패키지 명 + 클래스 명], 컬렉션 캐시 영역은 [패키지 명 + 클래스 명 + 필드 명]의 기본값을 가진다. 필요하다면 region 속성으로 캐시 영역 직접 지정 가능
- 캐시 영역을 위한 접두사를 설정하려면 region_prefix 사용
- ehcache.xml에 캐시 영역별 세부 설정 가능
- 쿼리와 파라미터 정보를 키로 사용해서 쿼리 결과를 캐시하는 방법
- hibernate.cache.use_query_cache 옵션을 true로 설정해줘야 함
- 캐시를 적용하려는 쿼리마다 org.hibernate.cacheable을 true로 설정하는 힌트를 주면 됨
- 쿼리 캐시를 활성화하면 두 캐시 영역이 추가됨
- org.hibernate.cache.internal.StandardQueryCache : 쿼리 캐시를 저장하는 영역. 쿼리, 쿼리 결과 집합, 쿼리를 실행한 시점의 타임스탬프 보관
- org.hibernate.cache.spi.UpdateTimestampsCache : 쿼리 캐시가 유효한지 확인하기 위해 쿼리 대상 테이블의 가장 최근 변경 시간을 저장하는 영역. 테이블 명과 변경된 타임스탬프를 보관. 해당 영역이 만료되면 모든 쿼리 캐시가 무효화 되므로 eternal="true" 옵션을 사용.
- 쿼리 캐시는 캐시한 데이터 집합을 최신 데이터로 유지하려고 쿼리 캐시를 실행하는 시간과 테이블들이 가장 최근에 변경된 시간을 비교하여 변경이 있으면 데이터베이스에서 데이터를 읽어와 쿼리 결과를 다시 캐시 함
- 쿼리 캐시를 잘 활용하면 성능 향상이 있지만 빈번하게 변경이 있는 테이블에 사용하면 성능이 저하될 수 있다. 수정이 적은 테이블에 사용해야 효과적
- 엔티티 캐시는 엔티티 정보를 모두 캐시, 쿼리 캐시와 컬렉션 캐시는 결과 집합의 식별자 값만 캐시
- 쿼리 캐시와 컬렉션 캐시는 식별자 값을 하나씩 엔티티 캐시에서 조회해서 실제 엔티티를 찾는다
- 엔티티에 쿼리 캐시나 컬렉션 캐시만 사용하고 엔티티 캐시를 적용하지 않으면 결과 집합만큼 SQL을 실행하는 성능 문제가 발생할 수 있다
- 쿼리 캐시나 컬렉션 캐시 사용시 꼭 엔티티 캐시를 적용해야 함
- 트랜잭션의 격리 수준은 4단계가 있다. 격리 수준이 낮을수록 동시성은 증가하지만 격리 수준에 따른 문제가 발생
- 영속성 컨텍스트는 데이터베이스 트랜잭션이 READ COMMITTED 격리 수준이어도 애플리케이션 레벨에서 REPEATABLE READ를 제공
- JPA는 낙관적 락과 비관적 락을 지원한다. 낙관적 락은 애플리케이션이 지원하는 락, 비관적 락은 데이터베이스 트랜잭션 락 메커니즘에 의존
- 2차 캐시를 사용하면 애플리케이션의 조회 성능을 극적으로 끌어올릴 수 있다