DB/자바 ORM 표준 JPA 프로그래밍

[16장] 트랜잭션과 락

꾸준함. 2025. 3. 22. 16:07

트랜잭션과 락

 

1. 트랜잭션과 격리 수준

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

 

  • 트랜잭션은 기본적으로 원자성, 일관성 그리고 지속성을 보장함
  • 문제는 격리성인데 트랜잭션 간에 격리성을 완벽히 보장하기 위해서는 트랜잭션을 거의 차례대로 실행해야 함
    • 트랜잭션을 차례대로 실행하면 성능이 매우 나빠짐
    • 이런 문제로 인해 ANSI 표준은 트랜잭션의 격리 수준을 네 단계로 나누어 정의함

 

  • 트랜잭션 격리 수준은 다음과 같음
    • READ UNCOMMITTED (커밋되지 않은 읽기)
    • READ COMMITTED (커밋된 읽기)
    • REPEATABLE READ (반복 가능한 읽기)
    • SERIALIZABLE (직렬화 가능)

 

  •  순서대로 READ UNCOMMITED 격리 수준이 가장 낮고 SERIALIZABLLE의 격리 수준이 가장 높음
    • 격리 수준이 낮을수록 동시성은 증가하지만
    • 격리 수준에 따른 다양한 문제가 발생함

 

  • 격리 수준에 따른 문제점은 다음과 같음
    • DIRTY READ
    • NON-REPEATABLE READ
    • PHANTOM READ

 

  • 격리 수준이 낮을수록 더 많은 문제가 발생하며 트랜잭션 격리 수준에 따른 문제점은 다음과 같음
    • READ UNCOMMITTED: 커밋하지 않은 데이터를 읽을 수 있음 i.g. 트랜잭션1이 데이터를 수정하고 있는데 커밋하지 않아도 트랜잭션2가 수정 중인 데이터를 조회할 수 있으며 이를 DIRTY READ라 함, 트랜잭션2가 DIRTY READ 한 데이터를 사용하는데 트랜잭션1을 롤백하면 데이터 정합성에 심각한 문제가 발생할 수 있음
    • READ COMMITTED: 커밋한 데이터만 읽을 수 있으므로 DIRTY READ가 발생하지 않지만 NON-REPEATABLE READ는 발생할 수 있음 i.g. 트랜잭션1이 회원A를 조회 중인데 갑자기 트랜잭션2가 회원A를 수정하고 커밋하면 트랜잭션1이 다시 회원A를 조회했을 때 수정된 데이터가 조회됨, 이처럼 반복해서 같은 데이터를 읽을 수 없는 상태를 NON-REPEATABLE READ라 함
    • REPEATABLE READ: 한 번 조회한 데이터를 반복해서 조회해도 같은 데이터가 조회되지만 PHANTOM READ가 발생할 수 있음 i.g. 트랜잭션1이 10살 이하의 회원을 조회했는데 트랜잭션2가 5살 회원을 추가하고 커밋하면 트랜잭션1이 다시 10살 이하의 회원을 조회했을 때 회원 하나가 추가된 상태로 조회됨, 이처럼 반복 조회 시 결과 집합이 달라지는 것을 PHANTOM READ라 함
    • SERIALIZABLE: 가장 엄격한 트랜잭션 격리 수준으로 PHANTOM_READ 조차 발생하지 않지만 동시성 처리 성능이 급격히 떨어질 수 있음

 

https://akasai.space/db/about_isolation/

 

  • 애플리케이션 대부분은 동시성 처리가 중요하므로 DB들은 보통 READ COMMITTED 격리 수준을 디폴트로 사용
    • 일부 중요한 비즈니스 로직에 더 높은 격리 수준이 필요할 경우 DB 트랜잭션이 제공하는 Lock 기능을 사용하면 됨

 

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

  • JPA의 1차 캐시인 영속성 컨텍스트를 적절히 활용하면 DB 트랜잭션이 READ COMMITTED 겨리 수준이어도 애플리케이션 레벨에서 REPEATABLE READ가 가능함
    • 물론 엔티티가 아닌 스칼라 값을 직접 조회할 경우 영속성 컨텍스트의 관리를 받지 못하므로 반복 가능한 읽기를 할 수 없음

 

  • JPA는 DB 트랜잭션 격리 수준을 READ COMMITTED 정도로 가정함
  • 만약 일부 로직에 더 높은 격리 수준이 필요할 경우 낙관적 락과 비관적 락 중 하나를 사용하면 됨
    • 낙관적 락: DB가 제공하는 락 기능을 사용하는 것이 아니라 JPA가 제공하는 버전 관리 기능을 사용하며 트랜잭션을 커밋하기 전까지는 트랜잭션의 충돌을 알 수 없다는 특징이 있음
    • 비관적 락: 트랜잭션의 충돌이 발생한다고 가정하고 우선 락을 걸고 보는 방법으로 DB가 제공하는 락 기능을 사용 (SELECT FOR UPDATE)

 

  • 추가로 `두 번의 갱신 분실 문제 (second lost updates problem)`과 같이 DB 트랜잭션 범위를 넘어서는 문제도 있음
    • i.g. 사용자 A와 B가 동시에 수정 화면을 열어서 내용을 수정하는 중 사용자 A가 먼저 수정완료 버튼을 눌렀고 사용자 B가 수정완료 버튼을 눌렀다고 가정했을 때 결과적으로 먼저 완료한 사용자 A의 수정사항은 사라지고 나중에 완료한 사용자 B의 수정사항만 남게 되는 문제

 

  • 두 번의 갱신 분실 문제는 DB 트랜잭션의 범위를 넘어서므로 트랜잭션만으로는 문제를 해결할 수 없고 다음 세 가지 방법으로 해결해야 함
    • 마지막 커밋만 인정하기: 사용자 A의 내용은 무시하고 마지막에 커밋한 사용자 B의 내용만 인정 (default)
    • 최초 커밋만 인정하기: 사용자 A가 이미 수정을 완료했으므로 사용자 B가 수정을 완료할 때 오류가 발생함
    • 충돌하는 갱신 내용 병합하기: 사용자 A와 사용자 B의 수정사항을 병합

 

  • 기본은 `마지막 커밋만 인정하기`이지만 상황에 따라 `최초 커밋만 인정하기`가 더 합리적일 수 있고 JPA가 제공하는 버전 관리 기능을 사용하면 손쉽게 최초 커밋만 인정하기를 구현 가능
  • `충돌하는 갱신 내용 병합하기`는 `최초 커밋만 인정하기`를 조금 더 우아하게 처리하는 방법이고 애플리케이션 개발자가 직접 사용자를 위해 병합 방법을 제공해야 함

 

3. @Version

  • JPA가 제공하는 낙관적 락을 사용하려면 @Version 어노테이션을 사용해서 버전 관리 기능을 추가해야 함
  • @Version 적용 가능 타입은 다음과 같음
    • Long (long)
    • Integer (int)
    • Short (short)
    • Timestamp

 

 

부연 설명

  • 버전 관리 기능을 적용하려면 엔티티에 버전 관리용 필드를 하나 추가하고 @Version을 붙이면 됨
    • 이제부터 엔티티를 수정할 때마다 버전이 하나씩 자동으로 증가하고 엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외가 발생함
    • ex) 트랜잭션1이 조회한 엔티티를 수정하고 있는데 트랜잭션2에서 같은 엔티티를 수정하고 커밋해서 버전이 증가해 버리면 트랜잭션1이 커밋할 때 버전 정보가 다르므로 예외가 발생함
    • 버전 정보를 사용하면 `최초 커밋만 인정하기`가 적용됨

 

https://milenote.tistory.com/156

 

3.1 버전 정보 비교 방법

  • 엔티티를 수정하고 트랜잭션을 커밋하면 영속성 컨텍스트를 flush 하면서 UPDATE 쿼리를 실행하고 이때 버전을 사용하는 엔티티면 검색 조건에 엔티티의 버전 정보를 추가함
    • DB 버전과 엔티티 버전이 같은면 데이터를 수정하면서 동시에 버전도 하나 증가시킴
    • 만약 DB 버전이 이미 증가해서 수정 중인 엔티티의 버전과 다르면 UPDATE 쿼리의 WHERE 문에서 VERSION 값이 다르므로 수정할 대상이 없어져 JPA에서 예외를 발생시킴

 

 

  • 다시 한 번 강조하자면 버전은 엔티티의 값을 변경하면 증가함
    • 값 타입인 임베디드 타입과 값 타입 컬렉션은 노리적인 개념상 해당 엔티티의 값이므로 수정하면 엔티티의 버전이 증가함
    • 단, 연관관계 필드는 외래 키를 관리하는 연관관계의 주인 필드를 수정할 때만 버전이 증가

 

  • JPA가 자동으로 관리하는 @Version 애노테이션 필드는 벌크 연산의 경우를 제외하고, 개발자가 임의로 수정해서는 안 됨
    • 만약 버전 값을 강제로 증가하려면 특별한 락 옵션을 선택하면 됨

 

4. JPA 락 사용

  • 락은 다음 위치에 적용할 수 있음
    • EntityManager.lock(), EntityManager.find(), EntityManager.refresh()
    • Query.setLockMode()
    • @NamedQuery

 

  • 조회하면서 즉시 락을 걸 수도 있고 필요할 때 락을 걸 수도 있음

 

 

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

 

https://kimjinyounga.github.io/server/JPA_16/

 

5. JPA 낙관적 락

  • JPA가 제공하는 낙관적 락은 @Version을 사용함
    • 낙관적 락을 사용하려면 버전이 있어야 함
    • 낙관적 락은 트랜잭션을 커밋하는 시점에 충돌을 알 수 있다는 특징이 있음

 

  • 낙관적 락에서 발생하는 예외는 다음과 같음
    • JPA 예외: javax.persistence.OptimisticLockException
    • 하이버네이트 예외: org.hibernate.StaleObjectStateException
    • 스프링 예외 추상화: org.springframework.orm.ObjectOptimisticLockingFailureException

 

5.1 낙관적 락 옵션: NONE

  • 락 옵션을 적용하지 않아도 엔티티에 @Version이 적용된 필드만 있으면 낙관적 락이 적용됨
    • 용도: 조회한 엔티티를 수정할 때 다른 트랜잭션에 의해 변경되지 않아야 하며 조회 시점부터 수정 시점까지를 보장함
    • 동작: 엔티티를 수정할 때 버전을 체크하면서 버전을 증가시키며 이때 DB의 버전 값이 현재 버전이 아니면 예외 발생시킴
    • 이점: 두 번의 갱신 분실 문제를 예방함

 

5.2 낙관적 락 옵션: OPTIMISTIC

  • @Version만 적용했을 때는 엔티티를 수정해야 버전을 체크하지만 해당 옵션을 추가하면 엔티티를 조회만 해도 버전을 체크함
    • 용도: 조회한 엔티티는 트랜잭션이 끝날 때까지 다른 트랜잭션에 의해 변경되지 않아야 하며 조회 시점부터 트랜잭션이 끝날 때까지 조회한 엔티티가 변경되지 않음을 보장
    • 동작: 트랜잭션을 커밋할 때 버전 정보를 조회해서 현재 엔티티의 버전과 같은지 검증하며 만약 같지 않을 경우 예외를 발생시킴
    • 이점: OPTIMISTIC 옵션은 DIRTY READ와 NON_REPEATABLE READ를 방지함

 

https://milenote.tistory.com/156

 

5.3 낙관적 락 옵션: OPTIMISTIC_FORCE_INCREMENT

  • 낙관적 락을 사용하면서 버전 정보를 강제로 증가시킴
    • 용도: 논리적 단위의 엔티티 묶음을 관리할 때, 예를 들어 게시물과 첨부파일이 일대다 및 다대일 양방향 연관관계에서 첨부파일이 관계의 주인인 경우 게시물을 수정하면서 첨부파일만 추가하면 물리적으로는 변경되지 않아 게시물 버전이 증가하지 않지만 논리적으로는 변경된 것으로 간주되므로 게시물의 버전을 강제로 증가시키려면 OPTIMISTIC_FORCE_INCREMENT를 사용하면 됨
    • 동작: 엔티티를 수정하지 않아도 트랜잭션을 커밋할 때 UPDATE 쿼리를 사용해서 버전 정보를 강제로 증가시키며 이때 DB의 버전이 엔티티의 버전과 다르면 예외가 발생함
      • 엔티티를 수정할 때도 UPDATE가 발생하므로 총 두 번의 버전 증가가 발생할 수 있음
    • 이점: 강제로 버전을 증가시켜 논리적인 단위의 엔티티 묶음을 대상으로 버전 관리 가능

 

 

https://velog.io/@rosesua318/16%EC%9E%A5-%ED%8A%B8%EB%9E%9C%EC%9E%AD%EC%85%98%EA%B3%BC-%EB%9D%BD-2%EC%B0%A8-%EC%BA%90%EC%8B%9C

 

6. JPA 비관적 락

  • JPA가 제공하는 비관적 락은 DB 트랜잭션 락 메커니즘에 의존하는 방법
    • 주로 SQL 쿼리에 SELECT FOR UPDATE 구문을 사용하면서 시작하고 버전 정보는 사용하지 않음
    • 비관적 락은 주로 PESSIMISTIC_WRITE 모드를 사용

 

  • 비관적 락은 다음과 같은 특징이 있음
    • 엔티티가 아닌 스칼라 타입을 조회할 때도 사용 가능
    • 데이터를 수정하는 즉시 트랜잭션 충돌 감지 가능

 

  • 비관적 락에서 발생하는 예외는 다음과 같음
    • JPA 예외: javax.persistence.PessimisticLockException
    • 스프링 예외 추상화: org.springframework.dao.PessimisticLockingFailureException

 

6.1 비관적 락 옵션: PESSIMISTIC_WRITE

  • 일반적으로 해당 옵션을 사용하며 DB에 쓰기 락을 걸 때  사용
    • 용도: DB에 쓰기 락을 부여
    • 동작: 데이터베이스 SELECT FOR UPDATE를 사용해서 락을 검
    • 이점: NON-REPEATABLE READ를 방지하며 락이 걸린 로우는 다른 트랜잭션이 수정할 수 없음

 

6.2 비관적 락 옵션: PESSIMISTIC_READ

  • 데이터를 반복 읽기만 하고 수정하지 않는 용도로 락을 걸 때 사용
  • 일반적으로 사용하지 않음

 

6.3 비관적 락 옵션: PESSIMISTIC_FORCE_INCREMENT

  • 비관적 락 중 유이할게 버전 정보를 사용
    • 비관적 락이지만 버전 정보를 강제로 증가시킴
    • 하이버네이트는 nowait를 지원하는 DB에 대해서 FOR UPDATE NOWAIT 옵션을 적용

 

7. 비관적 락과 타임아웃

  • 비관적 락을 사용하면 락을 획득할 때까지 트랜잭션이 대기하지만 무한정 기다릴 수는 없으므로 타임아웃 시간을 줄 수 있음
    • 타임아웃이 발생하면 javax.persistence.LockTimeoutException 예외가 발생함
    • 단, 타임아웃은 DB 특성에 따라 동작하지 않을 수 있음

 

2차 캐시

 

1. 1차 캐시와 2차 캐시

  • 네트워크를 통해 DB에 접근하는 시간 비용은 애플리케이션 서버에서 내부 메모리에 접근하는 시간 비용보다 수만에서 수십만 배 이상 비쌈
    • 따라서 조회한 데이터를 메모리에 캐싱해서DB 접근 횟수를 줄이면 애플리케이션 성능을 획기적으로 개선할 수 있음

 

  • 영속성 컨텍스트 내부에는 엔티티를 보관하는 저장소가 있는데 이것을 1차 캐시라 지칭
    • 일반적인 웹 애플리케이션 환경은 트랜잭션을 시작하고 종료할 때까지만 1차 캐시가 유효함
    • OSIV를 사용해도 클라이언트의 요청이 들어올 때부터 끝날 때까지만 1차 캐시가 유효함
    • 따라서 애플리케이션 전체 라이프 사이클 관점에서 보면 DB 접근 횟수를 획기적으로 줄이지는 못 함

 

  • 하이버네이트를 포함한 대부분의 JPA 구현체들은 애플리케이션 범위의 캐시를 지원하는데 이것을 공유 캐시 또는 2차 캐시라 지칭
    • 2차 캐시를 활용하면 애플리케이션 조회 성능을 향상할 수 있음

 

https://milenote.tistory.com/157

 

1.1 1차 캐시

  • 1차 캐시는 영속성 컨텍스트 내부에 있으며 엔티티 매니저로 조회하거나 변경한느 모든 엔티티는 1차 캐시에 저장됨
  • 트랜잭션을 커밋하거나 flush를 호출하면 1차 캐시에 있는 엔티티의 변경 내역을 DB에 동기화 시킴
  • JPA를 스프링 프레임워크 같은 컨테이너 위에서 실행하면 트랜잭션을 시작할 때 영속성 컨텍스트를 생성하고 트랜잭션을 종료할 때 영속성 컨텍스트도 종료시킴
    • OSIV를 사용하면 HTTP 요청의 시작부터 끝까지 같은 영속성 컨텍스트를 유지함

 

https://milenote.tistory.com/157

 

  • 정리하면 1차 캐시의 특징은 다음과 같음
    • 1차 캐시는 같은 엔티티가 있으면 해당 엔티티를 그대로 반환함 (1차 캐시는 객체 동일성을 보장)
    • 1차 캐시는 기본적으로 영속성 컨텍스트 범위의 캐시, OSIV를 적용하면 HTTP 요청 범위의 캐시

 

1.2 2차 캐시

  • 애플리케이션에서 공유하는 캐시를 JPA는 공유 캐시 (Shared Cache)라 하는데 일반적으로 2차 캐시 (Second Level Cache, L2 Cache)라 부름
    • 2차 캐시는 애플리케이션 범위의 캐시이며 애플리케이션을 종료할 때까지 캐시가 유지됨
    • 분산 캐시나 클러스터 환경의 캐시는 애플리케이션보다 더 오래 유지될 수도 있음
    • 2차 캐시를 적용하면 엔티티 매니저를 통해 데이터를 조회할 때 우선 2차 캐시에서 조회하고 없으면 DB에서 찾기 때문에 적절히 활용하면 DB 조회 횟수를 획기적으로 줄일 수 있음

 

https://milenote.tistory.com/157

 

  • 2차 캐시는 동시성을 극대화하기 위해 캐싱한 객체를 직접 반환하지 않고 복사본을 생성해서 반환시킴
    • 만약 캐싱한 객체를 그대로 반환하면 여러 곳에서 같은 객체를 동시에 수정하는 문제가 발생할 수 있음
    • 위 문제를 해결하기 위해 락을 걸 수도 있지만 동시성이 떨어지므로 객체 복사본을 반환시킴

 

  • 정리하면 2차 캐시의 특징은 다음과 같음
    • 2차 캐시는 영속성 유닛 범위의 캐시
    • 2차 캐시는 조회한 객체를 그대로 반환하는 것이 아니라 복사본을 생성해서 반환
    • 2차 캐시는 DB 기본 키를 기준으로 캐싱하지만 영속성 컨텍스트가 다르면 객체 동일성을 보장하지 않음

 

2. JPA 2차 캐시 기능

  • JPA 구현체 대부분은 캐시 기능을 각자 지원했는데 JPA는 2.0에 와서야 캐시 표준을 정의했음
    • JPA 캐시 표준은 여러 구현체가 공통으로 사용하는 부분만 표준화해서 세밀한 설정을 하려면 구현체에 의존적인 기능을 사용해야 함

 

2.1 캐시 모드 설정

  • 2차 캐시를 사용하려면 엔티티에 javax.persistence.Cacheable 어노테이션을 사용하면 됨
    • @Cacheable은 @Cacheable(true), @Cacheable(false)를 설정할 수 있으며 default 값은 true

 

 

https://kimjinyounga.github.io/server/JPA_16/

 

2.2 캐시 조회, 저장 방식 설정

  • 캐시를 무시하고 DB를 직접 조회하거나 캐시를 갱신하려면 캐시 조회 모드와 캐시 보관 모드를 사용하면 됨
    • em.setProperty("javax.persistence.cache.retrieveMode", CacheRetrieveMode.BYPASS);

 

  • 캐시 조회 모드나 보관 모드에 따라 사용할 프로퍼티와 옵션이 상이함
  • 프로퍼티 이름은 다음과 같음
    • 캐시 조회 모드 프로퍼티명: javax.persistence.cache.retrieveMode
    • 캐시 보관 모드 프로퍼티명: javax.persistence.cache.storeMode

 

  • 옵션은 다음과 같음
    • 캐시 조회 모드 설정 옵션: javax.persistence.CacheRetrieveMode
    • 캐시 보관 모드 설정 옵션: javax.persistence.CacheStoreMode

 

 

2.3 JPA 캐시 관리 API

  • JPA는 캐시를 관리하기 위한 javax.persistence.Cache 인터페이스를 제공하며 이것은 EntityManagerFactory에서 구할 수 있음

 

 

참고

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

반응형