16장 트랜잭션과 락, 2차 캐시

자바 ORM 표준 JPA 프로그래밍 16장을 요약한 내용 입니다.

트랜잭션과 락

트랜잭션 기초와 JPA가 제공하는 낙관적 락과 비관적 락에 대해 알아보자

트랜잭션과 격리 수준

트랜잭션은 ACID라 하는 원자성(Atomicty), 일관성(Consistency), 격리성(Isolation), 지속성(Durability)을 보장해야 한다.

  • 원자성 : 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공 하든가 모두 실패해야 한다.

  • 일관성 : 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다.

  • 격리성 : 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다.

  • 지속성 : 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다.

ISOLATION LEVEL

DRITY READ

NON-REPEATABLE READ

PHANTOM READ

READ UNCOMMITTED

O

O

O

READ COMMITTED

O

O

REPEATABLED READ

O

SERIALIZABLE

문제는 격리성인인데 트래잭션 간에 격리성을 완벽히 보장하려면 트랜잭션을 거의 차례대로 실행해야 한다. 이런 문제로 인해 ANSI 표준은 트랜잭션의 격리 수준을 4단계로 나누어 정의했다.

  • READ UNCOMMITED(커밋되지 않은 읽기)

  • READ COMMITTED(커밋된 읽기)

  • REPEATABLE READ(반복 가능한 읽기)

  • SERIALIZABLE(직렬화 가능)

격리 수준에 따른 문제점은 다음과 같다.

  • READ UNCOMMITTED : 커밋하지 않은 데이터를 읽을 수 있다.

  • READ COMMITED : 커밋한 데이터만 읽을 수 있다.

  • REPEATABLE READ : 한 번 조회한 데이터를 반복해서 조회해도 같은 데이터가 조회된다.

  • SERIALIZABLE : 가장 엄격한 트랜잭션 격리 수준이다. 여기서는 PHANTOM READ가 발생하지 않는다.

격리 수준에 따른 문제점

DESC

DRITY READ

커밋하지 않은 데이터를 읽을 수 있다.

NON-REPEATABLE READ

반복해서 같은 데이터를 읽을 수 없다.

PHANTOM READ

반복 조회 시 결과 집합이 달라진다.

낙관적 락과 비관적 락 기초

JPA는 데이터베이스 트랜잭션 격리 수준을 READ COMMITED 정도로 가정한다. 만약 일부 로직에 더 높은 격리 수준이 필요하면 낙관적 락비관적 락 중 하나를 사용하면 된다.

낙관적 락

데이터베이스가 제공하는 락 기능을 사용하는 것이 아니라 JPA가 제공하는 버전 관리 기능을 사용한다. 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌을 알 수 없다는 특징이 있다.

비관적 락

트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법이다. 데이터베이스가 제공하는 락 기능을 사용한다. 대표적으로 select for update 구문이 있다.

두 번의 갱신 분실 문제

사용자 A가 수정하고 사용자 B가 1초 뒤에 수정 요청을 하게 되면 사용자 B의 수정사항만 남게 된다. 이것을 두 번의 갱신 분실 문제라 한다. 이를 해결하기 위한 3가지 선택 방법이 있다.

  • 마지막 커밋만 인정하기 : 사용자 A의 내용은 무시하고 마지막에 커밋한 사용자 B의 내용만 인정한다.

  • 최초 커밋만 인정하기 : 사용자 A가 이미 수정을 완료했으므로 사용자 B가 수정을 완료할 때 오류가 발생한다.

  • 충돌하는 갱신 내용 병합하기 : 사용자 A와 사용자 B의 수정사항을 병합한다.

@Version

JPA가 제공하는 낙관적 락을 사용하려면 @Version 어노테이션을 사용해서 버전 관리 기능을 추가해야 한다.

@Version 적용 가능 타입

  • Long (long)

  • Integer (int)

  • Shoort (short)

  • Timestamp

@Entity
public class Board {

	@Id
	private String id;
	private String title;

	@Version
	private Integer 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

트랜잭션 1이 데이터를 제목 B로 변경하고 트랜잭션을 커밋하는 순간 엔티티를 조회할 때 버전과 데이터베이스의 현재 버전 정보가 다르므로 예외가 발생한다. 따라서 버전 정보를 사용하면 최초 커밋만 인정하기가 적용된다.

@Version으로 추가한 버전 관리 필드는 JPA가 직접 관리하므로 개발자가 임의로 수정하면 안 된다(벌크 연산 제외) 만약 버전 값을 강제로 증가 하려면 특별한 락 옵션을 선택하면 된다.

벌크 연산은 버전을 무시한다. 벌크 연산에서 버전을 증가하려면 버전 필드를 강제로 증가시켜야 한다. update Member m set m.name = '변경', m.version = m.version + 1

JPA 락 사용

JPA를 사용할 때 추천하는 전략은 READ COMMITTED 트랜잭션 격리 수준 + 낙관적 버전 관리다. (두 번의 갱신 내역 분실 문제 예방)

락은 다음 위치에 적용할 수 있다.

  • EntityManager.lock(), EntityManager.find(), EntityManager.refresh()

  • Query.setLockMode() (TypeQuery 포함)

  • @NamedQuery

JPA가 제공하는 락 옵션은 javax.persistence.LockModeType에 정의되어 있다.

락 모드

타입

설명

낙관적 락

OPTIMISTIC

낙관적 락을 사용한다

낙관적 락

OPTIMISTIC_FORCE_INCREMENT

낙관적 락 + 버전정보를 강제로 증가한다

비관적 락

PESSIMISTIC_READ

비관적 락, 읽기 락을 사용한다

비관적 락

PESSIMISTIC_WRITE

비관적 락, 쓰기 락을 사용한다.

비관적 락

PESSIMISTIC_FORCE_INCREMENT

비관적 락 + 버전정보를 강제로 증가한다

기타

NONE

락을 걸지 않는다

기타

READ

JPA1.0 호환 기능이다. OPTIMISTIC과 같으므로 OPTIMISTIC을 사용하면 된다.

기타

WRITE

JPA1.0 호환 기능이다. OPTIMISTIC_FORCE_INCREMENT와 같다

LcokModeType 속

락모드

타입

설명

낙관적 락

OPTIMISTIC

낙관적 락을 사용한다

낙관적 락

OPTIMISTIC_FORCE_INCREMENT

낙관적 락 + 버전정보를 강제로 증가한다

비관적 락

PESSIMISTIC_READ

비관적 락, 읽기 락을 사용한다

비관적 락

PESSIMISTIC_WRITE

비관적 락, 쓰기 락을 사용한다.

비관적 락

PESSIMISTIC_FORCE_INCREMENT

비관적 락 + 버전정보를 강제로 증가한다

기타

NONE

락을 걸지 않는다

기타

READ

JPA1.0 호환 기능이다. OPTIMISTIC과 같으므로 OPTIMISTIC을 사용하면 된다.

기타

WRITE

JPA1.0 호환 기능이다. OPTIMISTIC_FORCE_INCREMENT와 같다

JPA 낙관적 락

낙관적 락을 사용하려면 버전이 있어야 한다. 낙관적 락은 트랜잭션을 커밋하는 시점에 충돌을 알 수 있다는 특징이 있다. 낙관적 락의 옵션에 따른 효과를 하나씩 알아보자

NONE

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

  • 용도 : 조회한 엔티티를 수정할 때 다른 트랜잭션에 의해 변경(삭제)되지 않아야 한다. 조회 시점부터 수정 시점까지를 보장한다

  • 동작 : 엔티티를 수정할 때 버전체크하면서 버전을 증가한다.(UPDATE 쿼리 사용)

  • 이점 : 두 번의 갱신 분실 문제를 예방한다.

OPTIMISTIC

@Version만 적용했을 때는 엔티티를 수정해야 버전을 체크하지만 이 옵션을 추가하면 엔티티를 조회만 해도 버전을 체크한다. 한 번 조회한 엔티티는 트랜잭션을 종료할 때까지 다른 트랜잭션에서 변경되지 않음을 보장한다.

  • 용도 : 조회 시점부터 트랜잭션이 끝날 때까지 조회한 엔티티가 변경되지 않음을 보장한다

  • 동작 : 트랜잭션을 커밋할 때 버전 정보를 조회해서 현재 엔티티의 버전과 같은지 검증한다. 만약 같지 않으면 예외가 발생한다.

  • 이점 : OPTIMISTIC 옵션은 DIRTY READNON-REPEATABLE READ를 방지한다.

OPTIMISTIC_FORCE_INCREMENT

  • 용도 : 논리적인 단위의 엔티티 묶음을 관리할 수 있다. 게시물과 첨부파일이 일대다, 다대일양방향 연관관계이고 첨부파일이 연관관계의 주인이다. 게시물을 수정하는 데 단순히 첨부파일만 추가하면 게시물의 버전은 증가하지 않는다. 이때 게시물의 버전도 강제로 증가하려면 OPTIMISTIC_FORCE_INCREMENT를 사용하면 된다.

  • 동작 : 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해서 버전 정보를 강제로 증가시킨다. 추가로 엔티티를 수정하면 수정 시 버전 UPDATE가 발생한다. 따라서 총 2번의 버전 증가가 나타날 수 있다.

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

JPA 비관적 락

JPA가 제공하는 비관적 락은 데이터베이스 트랜잭션 락 메커니즘에 의존하는 방법이다. 주로 SQL 쿼리에 select for update 구문을 사용하면서 시작하고 버전 정보는 사용하지 않는다.

비관적 락은 다음과 같은 특징이 있다.

  • 엔티티가 아닌 스칼라 타입을 조회할 때도 사용할 수 있다.

  • 데이터를 수정하는 즉시 트랜잭션 충돌을 감지할 수 있다.

PESSIMISTIC_WRITE

  • 용도 : 데이터베이스에 쓰기 락을 건다

  • 동작 : 데이터베이스 select for update를 사용해서 락을 건다.

  • 이점 : NON-REPEATABLE READ를 방지한다. 락이 걸린 로우는 다른 트랜잭션이 수정할 수 없다.

PESSIMISTIC_READ

  • 데이터를 반복 읽기만 하고 수정하지 않는 용도로 락을 걸 때 사용한다. 데이터베이스 대부분은 PESSIMISTIC_WRITE로 동작한다.

  • MySQL : lock in share mode

  • PostgreSQL : for share

PESSIMISTIC_FORCE_INCREMENT

비관적 락중 유일하게 버전 정보를 사용한다. 하이버네이트는 nowait를 지원하는 데이터베이스에 대해서 for update nowait 옵션을 적용한다.

  • 오라클 : for update nowait

  • PostreSQL : for update nowait

  • nowait를 지원하지 않으면 for update가 사용된다.

비관적 락과 타임아웃

비관적 락을 사용하면 락을 획득할 때까지 트랜잭션대기한다. 무한정 기다릴 수는 없으므로 타임아웃 시간을 줄 수 있다.

2차 캐시

1차 캐시와 2차 캐시

영속성 컨텍스트로 얻을 수 있는 이점이 많지만, 일반적인 웹 애플리케이션 환경은 트랜잭션을 시작하고 종료할 때까지만 1차 캐시가 유효하다. OSIV를 사용해도 클라이언트의 요청이 들어올 때부터 끝날 때까지만 1차 캐시가 유효하다. 따라서 애플리케이션 전체로 보면 데이터베이스 접근 횟수를 획기적으로 줄이지는 못한다.

하이버네이트를 포함한 대부분의 JPA 구현체들은 애플리케이션 범위의 캐시를 지원하는데 이것을 공유 캐시 또는 2차 캐시라 한다.

2차 캐시

2차 캐시는 애플리케이션 범위의 캐시가. 따라서 애플리케이션종료할 때까지 캐시가 유지된다. 분산 캐시나 클러스터링 환경의 캐시는 애플리케이션보다 더 오래 유지될 수도 있다. 2차 캐시를 적절히 활용하면 데이터베이스 조회 횟수를 획기적으로 줄일 수 있다.

2차 캐시동시성극대화하려고 캐시 한 객체를 직접 반환하지 않고 복사본을 만들어서 반환한다. 만약 캐시한 객체를 그대로 반환하면 여러 곳에서 같은 객체를 동시에 수정하는 문제가 발생할 수 있다. 이 문제를 해결하려면 객체에 을 걸어야 하는데 이렇게 하면 동시성이 떨어질 수 있다.

JPA 2차 캐시 기능

캐시 모드 설정

2차 캐시를 사용하려면 javax.persistence.Cacheable 어노테이션을 사용하면 된다.

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

같이 persistence.xmlshared-cache-mode를 설정해서 애플리케이션 전체에 캐시를 어떻게 적용할지 옵션을 설정해야 한다.

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

하이버네이트와 EHCACHE 적용

하이버네이트가 지원하는 캐시는 크게 3가지가 있다.

  1. 엔티티 캐시 : 엔티티 단위로 캐시한다. 식별자로 엔티티를 조회하거나 컬렉션이 아닌 연관된 엔티티를 로딩할 때 사용한다.

  2. 컬렉션 캐시 : 엔티티와 연관된 컬렉션캐시한다. 컬렉션이 엔티티를 담고 있으면 식별자 값만 캐시한다(하이버네이트 기능)

  3. 쿼리 캐시 : 쿼리파라미터 정보를 키로 사용해서 캐시한다. 결과가 엔티티면 식별자 값만 캐시한다.(하이버네이트 기능)

@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
@Entity
public class ParentMember {

	@Id @GeneratedValue
	private Long id;
	private String name;

	@Cache(usage = CacheConcurrencyStrategy.READ_WRITE)
	@OneToMany(mappedBy = "parentMember", cascade = CascadeType.ALL)
	private List<ChildMember> childMembers;
}

@Cache

하이버네이트 @Cache 속

속성

설명

usage

CacheConcurrencyStrategy를 사용해서 캐시 동시성 전략을 설정한다.

region

캐시 지역 설정

include

연관 객체를 캐시에 포함할지 선택한다. all, non-lazy 옵션을 선택할 수 있다.

CacheConcurrencyStategy 속

속성

설명

NONE

캐시를 설정하지 않는다.

READ_ONLY

읽기 전용으로 설정한다. 등록, 삭제는 가능하지만 수정은 불가능하다. 참고로 읽기 전용인 불변 객체는 수정되지 않으므로 하이버네이트는 2차 캐시를 조회할 때 객체를 복사하지 않고 원본 객체를 반환한다.

NONSTRICT_READ_WRITE

엄격하지 않은 읽고 쓰기 전략이다. 동시에 같은 엔티티를 수정하면 데이터 일관성이 깨질 수 있다. EHCACHE는 데이터를 수정하면 캐시 데이터를 무효화한다.

READ_WRITE

읽기 쓰기가 가능하고 READ COMMITTED 정도의 격리 수준을 보장한다. EHCACHE는 데이터를 수정하면 캐시 데이터도 같이 수정한다.

TRANSACTIONAL

컨테이너 관리 환경에서 사용할 수 있다. 설정에 따라 REPEATABLE READ 정도의 격리 수준을 보장받을 수 있다.

쿼리 캐시

쿼리 캐시쿼리와 파라미터 정보를 키로 사용해서 쿼리 결과를 캐시하는 방법이다. 쿼리 캐시를 적용하려면 유닛을 설정에 hibernate.cache.use_query_cache 옵션을 꼭 true로 설정해야 한다.

// 쿼리 캐시 적용
em.createQuery("select i from Item i", Item.class)
		.setHint("org.hibernate.cacheable", true)
		.getResultList();
// 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 {
	...
}

hibernate.cache.use_query_cache 옵션을 true로 설정해서 쿼리 캐시를 활성화하면 다음 두 캐시 영역이 추가된다.

  • org.hibernate.cache.internal.StandardCache: 쿼리 캐시를 저장하는 영역이다. 이곳에는 쿼리, 쿼리 결과 집합, 쿼리를 실행한 시점의 타임스탬프를 보관한다.

  • org.hibernate.cache.spi.UpdateTimestampsCache : 쿼리 캐시가 유효한지 확인하기 위해 쿼리 대상 테이블의 가장 퇴근 변경 시간을 저장하는 영역이다.

쿼리 캐시는 캐시한 데이터 집합을 최신 데이터로 유지하려고 쿼리 캐시를 실행하는 시간과 쿼리 캐시가 사용하는 테이블들이 가장 퇴근에 변경된 시간을 비교한다. 쿼리 캐시가 사용하는 테이블에 조금이라도 변경이 있으면 데이터베이스에서 데이터를 읽어 와서 쿼리 결과를 다시 캐시한다.

쿼리 캐시를 잘 활용하면 극적인 성능 향상이 있지만 빈번하게 변경이 있는 테이블에 사용하면 오히려 성능이 더 저하된다. 따라서 수정이 거의 일어나지 않는 테이블에 사용해야 효과를 볼 수 있다.

쿼리 캐시와 컬렉션 캐시의 주의점

엔티티 캐시를 사용해서 엔티티를 캐시하면 엔티티 정보를 모두 캐시 하지만 쿼리 캐시와 컬렉션 캐시는 결과 집합의 식별자 값만 캐시한다. 문제는 쿼리 캐시나 컬렉션 캐시만 사용하고 대상 엔티티에 엔티티 캐시를 적용하지 않으면 성능상 심각한 문제가 발생할 수 있다.

  1. select m from Member m 쿼리를 실행 했는데 쿼리 캐시가 적용되어 있다. 결과 집합은 100건이다.

  2. 결과 집합에는 식별자만 있으므로 한 건씩 엔티티 캐시 영역에서 조회한다.

  3. Member 엔티티는 엔티티 캐시를 사용하지 않으므로 한 건씩 데이터베이스에서 조회한다.

  4. 결국 100건의 SQL이 실행된다.

따라서 쿼리 캐시컬렉션 캐시를 사용하면 결과 대산 엔티티에는 꼭 엔티티 캐시를 적용해야 한다.

정리

  • 트랜잭션의 격리 수중은 4단계가 있다. 격리 수준이 낮을수록 동시성은 증가하지만 격리 수준에 따른 다양한 문제가 발생한다.

  • 영속성 컨텍스트는 데이터베이스 트랜잭션이 READ COMMITTED 격리 수준이어도 애플리케이션 레벨에서 반복 가능한 읽기(REPEATABLE READ)를 제공한다.

  • JPA낙관적 락비관적 락을 지원한다. 낙관적 락은 애플리케이션이 지원하는 락이고, 비관적 락은 데이터베이스 트랜잭션 락 메커니즘에 의존한다.

  • 2차 캐시를 사용하면 애플리케이션의 조회 성능 극적으로 끌어올릴 수 있다.

Last updated