꿈꾸는 새벽하늘

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

🌿 Spring & Spring Boot/📗 자바 ORM 표준 JPA 프로그래밍

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

rovemin 2023. 9. 11. 23:31

1. 트랜잭션과 락

1) 트랜잭션과 격리 수준

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

  • 원자성: 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공하든가 모두 실패해야 한다.
  • 일관성: 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다. 예를 들어 데이터베이에서 정한 무결성 제약 조건을 항상 만족해야 한다.
  • 격리성: 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다. 예를 들어 동시에 같은 데이터를 수정하지 못하도록 해야 한다. 격리성은 동시성과 관련된 성능 이슈로 인해 격리 수준을 선택할 수 있다.
  • 지속성: 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다. 중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해서 성공한 트랜잭션 내용을 복구해야 한다.

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

  • READ UNCOMMITED (커밋되지 않은 읽기)
  • READ COMMITTED (커밋된 읽기)
  • REPEATABLE READ (반복 가능한 읽기)
  • SERIALIZABLE (직렬화 가능)

순서대로 READ UNCOMMITED의 격리 수준이 가장 낮고 SERIALIZABLE의 격리 수준이 가장 높다.

격리 수준이 낮을수록 동시성은 증가하지만 격리 수준에 따른 다양한 문제가 발생한다.

 

격리 수준에 따른 문제점

  • DIRTY READ
  • NON-REPEATABLE READ (반복 불가능한 읽기)
  • PHANTOM READ

트랜잭션 격리 수준과 문제점

격리 수준 DIRTY READ NON-REPEATABLE READ PHANTOM READ
READ UNCOMMITTED O O O
READ COMMITTED   O O
REPEATABLE READ     O
SERIALIZABLE      

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

낙관적 락은 이름 그대로 트랜잭션 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법이다.

이것은 데이터베이스가 제공하는 락 기능을 사용하는 것이 아니라 JPA가 제공하는 버전 관리 기능을 사용한다. 즉, 애플리케이션이 제공하는 락이다.

낙관적 락은 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌을 알 수 없다는 특징이 있다.

 

비관적 락은 이름 그대로 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법이다.

이것은 데이터베이스가 제공하는 락 기능을 사용한다. 대표적으로 select for update 구문이 있다.

 

데이터베이스 트랜잭션 범위를 넘어서는 문제도 있다.

예를 들어 사용자 A와 B가 동시에 제목이 같은 공지사항을 수정한다고 하자. 둘이 동시에 수정 화면을 열어서 내용을 수정하는 중에 사용자 A가 먼저 수정완료 버튼을 눌렀다. 잠시 후에 사용자 B가 수정완료 버튼을 눌렀다. 결과적으로 먼저 완료한 사용자 A의 수정사항은 사라지고 나중에 완료한 사용자 B의 수정사항만 남게 된다. 이를 두 번의 갱신 분실 문제라 한다.

두 번의 갱신 분실 문제는 데이터베이스 트랜잭션 범위를 넘어선다. 따라서 트랜잭션만으로는 문제 해결이 불가하고, 3가지 선택 방법이 있다.

  • 마지막 커밋만 인정하기: 사용자 A의 내용은 무시하고 마지막에 커밋한 사용자 B의 내용만 인정한다.
  • 최초 커밋만 인정하기: 사용자 A가 이미 수정을 완료했으므로 사용자 B가 수정을 완료할 때 오류가 발생한다.
  • 충돌하는 갱신 내용 병합하기: 사용자 A와 사용자 B의 수정사항을 병합한다.

3) @Version

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

 

@Version 적용 가능 타입

  • Long (long)
  • Integer (int)
  • Short (short)
  • Timestamp

4) JPA 락 사용

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

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

LockModeType 속성

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

2. 2차 캐시

1) 1차 캐시와 2차 캐시

1차 캐시

1차 캐시는 영속성 컨텍스트 내부에 있다. 엔티티 매니저로 조회하거나 변경하는 모든 엔티티는 1차 캐시에 저장된다. 트랜잭션을 커밋하거나 플러시를 호출하면 1차 캐시에 있는 엔티티의 변경 내역을 데이터베이스에 동기화한다.

1차 캐시는 끄고 켤 수 있는 옵션이 아니다. 영속성 컨텍스트 자체가 사실상 1차 캐시다.

 

1차 캐시의 동작 방식

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

1차 캐시의 특징

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

2차 캐시

2차 캐시는 애플리케이션에서 공유하는 캐시로, JPA는 공유 캐시라 한다. 2차 캐시는 애플리케이션 범위의 캐시다. 따라서 애플리케이션을 종료할 때까지 캐시가 유지된다. 분산 캐시나 클러스터링 환경의 캐시는 애플리케이션보다 더 오래 유지될 수도 있다.

2차 캐시를 적용하면 엔티티 매니저를 통해 데이터를 조회할 때 우선 2차 캐시에서 찾고 없으면 데이터베이스에서 찾는다. 2차 캐시를 적절히 활용하면 데이터베이스 조회 횟수를 획기적으로 줄일 수 있다.

 

2차 캐시의 동작 방식

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

2차 캐시의 특징

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

2) JPA 2차 캐시 기능

2차 캐시 모드 설정

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

persistence.xml에 캐시 모드 설정

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

캐시 모드 스프링 프레임워크 XML 설정

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

 

캐시 조회, 저장 방식 설정

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

 

캐시 조회 모드

public enum CacheRetrieveMode {
    USE,
    BYPASS
}
  • USE: 캐시에서 조회한다. 기본값이다.
  • BYPASS: 캐시를 무시하고 데이터베이스에 직접 접근한다.

 

캐시 보관 모드

public enum CacheStoreMode {
    USE,
    BYPASS,
    REFRESH
}
  • USE: 조회한 데이터를 캐시에 저장한다. 조회한 데이터가 이미 캐시에 있으면 캐시 데이터를 최신 상태로 갱신하지는 않는다. 트랜잭션을 커밋하면 등록 수정한 엔티티도 캐시에 저장한다. 기본값이다.
  • BYPASS: 캐시에 저장하지 않는다.
  • REFRESH: USE 전략에 추가로 데이터베이스에서 조회한 엔티티를 최신 상태로 다시 캐시한다.

JPA 캐시 관리 API

Cache 관리 객체 조회

Cache cache = emf.getCache();
boolean contains = cache.contains(TestEntity.class, testEntity.getId());
System.out.println("contains = " + contains);

Cache 인터페이스

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);
}