스터디/JPA 스터디

[JPA] 트랜잭션과 락, 2차 캐시

제이온 (Jayon) 2022. 3. 29.

jpa-study에서 스터디를 진행하고 있습니다.

 

트랜잭션과 락

트랜잭션과 격리 수준

  • ACID
    • 원자성
      • 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공하든가 모두 실패해야 한다.
    • 일관성
      • 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다. 예를 들어 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 한다.
    • 격리성
      • 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 못하도록 격리한다. 예를 들어 동시에 같은 데이터를 수정하지 못하도록 해야 한다. 격리성은 동시성과 관련된 성능 이슈로 인해 격리 수준을 선택할 수 있다.
    • 지속성
      • 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다. 중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해서 성공한 트랜잭션 내용을 복구해야 한다.
  • 트랜잭션 격리 수준
    • READ UNCOMMITED
      • 커밋하지 않은 데이터를 읽을 수 있다.
      • 트랜잭션 1이 데이터를 수정하고 있는데, 커밋하지 않아도 트랜잭션 2가 수정 중인 데이터를 조회할 수 있다. (DIRTY READ 문제)
    • READ COMMITED
      • 커밋한 데이터만 읽을 수 있다.
      • 트랜잭션 1이 회원 A를 조회 중인데 갑자기 트랜잭션 2가 회원 A를 수정하고 커밋하면, 트랜잭션 1이 회원 A를 조회했을 때 수정된 데이터가 조회된다. (NON-REPEATABLE READ 문제)
    • REPEATABLE READ
      • 한 번 조회한 데이터를 반복해서 조회해도 같은 데이터가 조회된다.
      • 트랜잭션 1이 10살 이하의 회원을 조회했는데, 트랜잭션 2가 5살 회원을 추가하고 커밋하면 트랜잭션 1이 다시 10살 이하의 회원을 조회할 때 회원 하나가 추가된 상태로 조회된다. (PHANTOM READ 문제, 주로 select ... for update와 같이 비낙관적 락을 사용할 때 발생함.)
    • SERIALIZABLE
      • 가장 엄격한 트랜잭션 격리 수준으로, 어떠한 부정합 문제도 발생하지 않는다.
      • 동시성 처리 성능이 매우 떨어진다.
    • 데이터베이스들은 보통 READ COMMITED 격리 수준을 사용한다.

 

낙관적 락과 비낙관적 락 기초

  • JPA는 데이터베이스 트랜잭션 격리 수준을 READ COMMITED 정도로 가정한다.
  • 낙관적 락
    • 이름 그대로 트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 기법이다.
    • 데이터베이스가 제공하는 락이 아니라, JPA가 제공하는 버전 관리 기능을 사용한다.
    • 낙관적 락은 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌 여부를 확인할 수 없다.
  • 비낙관적 락
    • 이름 그대로 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 기법이다.
    • 데이터베이스가 제공하는 락 기능을 사용한다.
    • select ... for update
  • 두 번의 갱신 분실 문제
    • 사용자 A와 B가 동시에 제목이 같은 공지 사항을 수정하는 상황을 떠올려 보자.
    • 둘이 동시에 수정 화면을 열어서 내용을 수정하는 중에 사용자 A가 먼저 수정 완료 버튼을 누르고, 그 다음 사용자 B가 수정 완료 버튼을 누르면 커밋이 2번 실행되었음에도 나중에 완료한 사용자 B의 수정 사항만 남게 된다.
    • 3가지 방법으로 이 문제를 해결할 수 있다.
      • 마지막 커밋만 인정하기: 사용자 A의 내용은 무시한다. (주로 사용됨.)
      • 최초 커밋만 인정하기: 사용자 B가 수정을 완료할 때 오류가 발생한다. (JPA의 버전 관리 기능 사용하여 구현 가능.)
      • 충돌하는 갱신 내용 병합하기: 사용자 A와 사용자 B의 수정 사항을 병합한다.

 

@Version

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

 

@Entity
public class Board {

    @Id
    @GeneratedValue
    private Integer id;

    private String title;

    @Version // Long, Integer, Short, Timestamp에만 적용 가능.
    private Integer version;
}

 

이렇게 버전 관리 기능을 추가하면, 엔티티를 수정할 때마다 버전이 하나씩 자동으로 증가한다. 그리고 엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외가 발생한다. 가령, 트랜잭션 1이 조회한 엔티티를 수정하고 있는데, 트랜잭션 2에서 같은 엔티티를 수정하고 커밋해서 버전을 증가해버리면 트랜잭션 1이 커밋할 때 버전 정보가 다르므로 오류가 발생한다.

 

Untitled

 

버전 정보를 사용하면 최초 커밋만 인정하기가 적용된다. 트랜잭션 2가 커밋을 반영하고 나면 버전이 증가되므로 트랜잭션 1은 커밋을 수행할 수 없기 때문이다.

 

버전 정보 비교 방법

UPDATE BOARD
SET
    TITLE = ?,
    VERSION = ? (버전 1 증가)
WHERE
    ID = ?
    AND VERSION = ? (버전 비교)

 

데이터베이스 버전과 엔티티 버전이 같으면 데이터를 수정하면서 동시에 버전도 하나 증가하는 방식으로 쿼리가 작성되어 있다. 버전은 엔티티의 값을 변경할 때 증가하며, 연관 관계 필드의 경우 외래 키를 관리하는 연관 관계의 주인 필드를 수정할 때만 버전이 증가한다.

 

JPA 락 사용

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

 

  • EntityManager.lock(), EntityManager.find(), EntityManager.refresh()
  • Query.setLockMode()
  • @NamedQuery

 

JPA가 제공하는 락 옵션은 javax.persistence.LockModeType 에 정의되어 있고, LockModeType의 속성은 아래와 같다.

 

Untitled

 

JPA 낙관적 락

@Version을 사용해야 하며, 해당 어노테이션만 있어도 자동으로 낙관적 락이 적용된다. 다만, 락 옵션을 사용하면 락을 더 세밀하게 제어할 수 있다.

 

NONE

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

 

  • 용도
    • 조회한 엔티티를 수정할 때 다른 트랜잭션에 의해 변경되지 않게 한다.
    • 엔티티를 수정할 때 버전을 체크하면서 버전을 증가한다. 이때 데이터베이스의 버전 값이 현재 버전과 다르면 예외가 발생한다.
  • 이점
    • 두 번의 갱신 분실 문제를 예방한다.

 

OPTIMISTIC

@Version만 적용했을 때는 엔티티를 수정해야 버전을 체크하지만, 이 옵션을 추가하면 엔티티를 조회만 해도 버전을 체크한다.

 

  • 용도
    • 조회한 엔티티를 트랜잭션이 끝날 때까지 다른 트랜잭션에 의해 변경되지 않게 한다.
    • 트랜잭션을 커밋할 때 버전 정보를 조회해서 현재 엔티티의 버전과 같은지 검증한다.
  • 이점
    • DIRTY READ와 NON-REPEATABLE READ 문제를 방지한다.

 

Untitled

 

OPTIMISTIC_FORCE_INCREMENT

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

 

  • 용도
    • 논리적인 단위의 엔티티 묶음을 관리할 수 있다
      • 예를 들어 게시물과 첨부 파일이 일대다, 다대일의 양방향 연관 관계이고 첨부 파일이 연관 관계의 주인이다.
      • 게시물을 수정하는 데 단순히 첨부 파일만 추가하면 게시물의 버전은 증가하지 않을 것이다. 하지만, 해당 게시물은 물리적으로 변경되지 않았어도 논리적으로는 변경되었다.
      • 이때 게시물의 버전도 강제로 증가하고 싶을 때 사용한다.
    • 엔티티를 수정하지 않아도 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해서 버전 정보를 강제로 증가시킨다.
  • 이점
    • 강제로 버전을 증가해서 논리적인 단위의 엔티티 묶음을 버전 관리할 수 있다.

 

Untitled

 

JPA 비낙관적 락

JPA가 제공하는 비낙관적 락은 데이터베이스 트랜잭션 락 메커니즘에 의존하는 방법이다. 비낙관적 락은 스칼라 타입을 조회할 때도 사용할 수 있고, 데이터를 수정하는 즉시 트랜잭션 충돌을 감지할 수 있다는 장점이 있다.

 

PESSIMISTIC_WRITE

데이터베이스에 쓰기 락을 걸 때 사용한다.

 

  • 용도
    • 데이터베이스에 쓰기 락을 건다.
  • 동작
    • 데이터베이스 select ... for update 쿼리를 사용해서 락을 건다.
  • 이점
    • NON-REPEATABLE READ 문제를 방지한다.

 

PESSIMISTIC_READ

데이터를 반복 읽기만 하고 수정하지 않는 용도로 락을 걸 때 사용한다.

 

  • MySQL: lock in share mode
  • PostgreSQL: for share

 

PESSIMISTIC_FORCE_INCREMENT

비낙관적 락중 유일하게 버전 정보를 사용하며, 강제로 버전 정보를 증가시킨다.

 

  • 오라클: for update nowait
  • PostgreSQL: for update nowait
  • 그 외(nowait 지원 x): for update

 

비낙관적 락과 타임 아웃

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

 

2차 캐시

1차 캐시와 2차 캐시

1차 캐시

Untitled

 

  1. 최초 조회할 때는 1차 캐시에 엔티티가 없으므로
  2. 데이터베이스에서 엔티티를 조회해서
  3. 1차 캐시에 보관하고
  4. 1차 캐시에 보관한 결과를 반환한다.
  5. 이후 같은 엔티티를 조회하면 1차 캐시에 같은 엔티티가 있으므로 데이터베이스를 조회하지 않고 1차 캐시의 엔티티를 그대로 반환한다.

 

1차 캐시는 같은 엔티티가 있으면 해당 엔티티를 그대로 반환하여 동일성을 보장하며, 기본적으로 영속성 컨텍스트 범위의 캐시이다.(컨테이너 환경에서는 트랜잭션 범위의 캐시, OSIV를 적용하면 요청 범위의 캐시.)

 

2차 캐시

2차 캐시는 애플리케이션 범위의 캐시로, 애플리케이션을 종료할 때까지 캐시가 유지된다. 2차 캐시를 적용하면 엔티티 매니저를 통해 데이터를 조회할 때 우선 2차 캐시에서 찾고, 없으면 데이터베이스에서 찾는다.

 

Untitled

 

  1. 영속성 컨텍스트는 엔티티가 필요하면 2차 캐시를 조회한다.
  2. 2차 캐시에 엔티티가 없으면 데이터베이스를 조회해서
  3. 결과를 2차 캐시에 보관한다.
  4. 2차 캐시는 자신이 보관하고 있는 엔티티를 복사해서 반환한다.
  5. 2차 캐시에 저장되어 있는 엔티티를 조회하면 복사본을 만들어 반환한다.

 

2차 캐시의 특징은 다음과 같다.

 

  • 2차 캐시는 영속성 유닛 범위의 캐시이다.
  • 2차 캐시는 조회한 객체를 그대로 반환하는 것이 아니라 복사본을 반환한다.
  • 2차 캐시는 데이터베이스 키를 기준으로 캐시하지만, 영속성 컨텍스트가 다르면 객체의 동일성을 보장하지 않는다.

 

JPA 2차 캐시 기능

캐시 모드 설정

@Cacheable // 기본 값 true. false로 지정하면 2차 캐시 x
@Entity
public class Member {

    @Id
    @GeneratedValue
    private Long id;

    ...
}

 

위와 같이 캐시를 사용할 엔티티에 @Cacheable을 붙여 주고, 설정 파일에서 ShardCacheMode 값을 정해주면 된다.

 

Untitled

 

예를 들면 persistence.xml에서 아래와 같이 속성을 주면 된다.

 

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

 

캐시 조회, 저장 방식 설정

캐시를 무시하고 데이터베이스를 직접 조회하거나 캐시를 갱신하려면, 캐시 조회 모드와 캐시 보관 모드를 사용하면 된다.

 

// 캐시 조회 모드
public enum CacheRetrieveMode {

    USE, // 캐시를 조회한다.
    BYPASS // 캐시를 무시하고 데이터베이스에 직접 접근한다.
}

// 캐시 보관 모드
public enum CacheStoreMode {

    USE, // 조회한 데이터를 캐시에 저장한다. 이미 캐시에 데이터가 있으면 갱신하지는 않는다.
    BYPASS, // 캐시에 저장하지 않는다.
    REFRESH // USE 전략에 추가로 데이터베이스에서 조회한 엔티티를 최산 상태로 다시 캐시한다.
}

 

JPA 캐시 관리 API

Cache cache = entityManagerFactory.getCache();
boolean contains = cache.contains(TestEntity.class, testEntity.getId());
assertThat(contains).isTrue();

 

캐시 객체는 엔티티 매니저 팩토리에서 얻어올 수 있다. Cache 인터페이스는 다음 기능을 제공한다.

 

  • 해당 엔티티가 캐시에 있는지 확인
  • 특정 식별자를 가진 엔티티를 캐시에서 제거
  • 해당 엔티티 전체를 캐시에서 제거
  • 모든 캐시 데이터 제거
  • ...

 

하이버네이트와 EHCACHE 적용

하이버네이트가 지원하는 캐시는 크게 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 = new ArrayList<ChildMember>();

    ...
}

 

@Cache의 옵션은 다음과 같다.

 

  • usage
    • CacheConcurrencyStrategy를 사용해서 캐시 동시성 전략을 설정한다.
  • region
    • 캐시 지역 설정
  • include
    • 연관 객체를 캐시에 포함할지 선택한다.

 

중요한 것은 캐시 동시성 전략을 설정하는 usage 옵션이다. 동시성 옵션은 다음과 같다.

 

Untitled

 

캐시 영역

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

 

  • 엔티티 캐시 영역: jpabook.jpashop.domain.test.cache.ParentMember
  • 컬렉션 캐시 영역: jpabook.jpashop.domain.test.cache.ParentMember.childMembers

 

쿼리 캐시

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

 

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

쿼리 캐시와 컬렉션 캐시는 결과 집합의 식별자 값만 캐시한다. 따라서 쿼리 캐시나 컬렉션 캐시만 사용하고 대상 엔티티에 엔티티 캐시를 적용하지 않으면 성능이 매우 떨어진다.

 

  1. select m from Member m 쿼리를 실행했는데 쿼리 캐시가 적용되어 있고, 결과가 100건이다.
  2. 결과 집합에는 식별자만 있으므로 한 건씩 엔티티 캐시 영역에서 조회한다.
  3. Member 엔티티에는 엔티티 캐시를 사용하지 않으므로 한 건씩 데이터베이스에서 조회한다.
  4. 결국 100건의 SQL이 실행된다.

 

출처

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

댓글

추천 글