6 minute read

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

16.1 트랜잭션과 락

16.1.1 트랜잭션과 격리 수준

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

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

트랜잭션 격리 수준

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

  • 문제점
    • DIRTY READ
    • NON_REPEATABLE READ(반복 불가능한 읽기)
    • PHANTOM READ
  • READ UNCOMMITED
    • 커밋하지 않은 데이터를 읽을 수 있다. 예를 들어 트랜잭션1이 데이터를 수정하고 있는데 커밋하지 않아도 트랜잭션2가 수정 중인 데이터를 조회할 수 있다. 이것을 DIRTY READ 라고 한다.
  • READ COMMITED
    • 커밋한 데이터만 읽을 수 있다. DIRTY READ는 발생하지 않지만 NON_REPEATABLE READ는 발생할 수 있다. 반복해서 같은 데이터를 읽을 수 없는 상태를 NON_REPEATABLE READ라 한다.
  • REPEATABLE READ
    • 한 번 조회한 데이터를 반복해서 조회해도 같은 데이터가 조회된다. 반복 조회 시 결과 집합이 달라질 수 있다.(PHANTOM READ)
  • SERIALIZABLE
    • 가장 엄격한 트랜잭션 수준이다. 여기서는 PHANTOM READ가 발생하지 않는다. 하지만 동시성 처리 성능이 급격히 떨어질 수 있다.

MVCC(Multi-Version Concurrency Control)

데이터베이스에서 동시성을 제어하는 기법 중 하나로, 하나의 데이터를 여러 버전으로 관리하여 읽기 작업과 쓰기 작업이 충돌하지 않도록 하는 방법이다.

MVCC가 필요한 이유

  • 일반적인 락 기반의 동시성 제어는 트랜잭션 간 경쟁이 발생하여 성능이 저하될 수 있다.
  • MVCC는 락을 최소화하면서 동시에 읽기와 쓰기 작업을 수행할 수 있도록 설계된 방식이다.
  • 최근에는 데이터베이스들이 더 많은 동시성 처리를 위해 락보다는 MVCC를 사용한다.

동작 방식

  1. 각 트랜잭션이 데이터를 수정할 때 새로운 버전 생성
  2. 읽기 작업(select)은 기존 버전을 참조
    1. READ COMMITED, REAPEATABLE READ 같은 격리 수준에 사용됨
  3. 쓰기 작업(update, delete, insert)은 새로운 버전 생성
    1. update는 기존 데이터를 즉시 변경하는 것이 아니라, 새로운 버전을 생성함
    2. 이전 데이터를 참조하는 트랜잭션이 종료될 때까지 기존 데이터는 그대로 유지됨.
  4. 불필요한 버전 삭제 (Garbage Collection)
    1. 데이터베이스는 GC 작업을 수행하여 불필요한 버전을 삭제함.

장점

  • 읽기 작업이 블로킹되지 않음 → SELECT 시 락을 사용하지 않음
  • 동시성 향상 → 여러 트랜잭션이 충돌 없이 동작 가능
  • 트랜잭션 격리 수준 향상REPEATABLE READ에서 일관된 데이터 제공

단점

  • 불필요한 데이터 버전이 증가스토리지 공간이 많이 사용됨
  • Garbage Collection 필요 → 불필요한 데이터 삭제 작업이 필요하여 성능 저하 가능
  • 쓰기 작업이 많으면 성능 저하 가능 → 새로운 버전을 계속 생성해야 하므로 Overhead 발생

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

JPA의 영속성 컨텍스트(1차 캐시)를 적절히 활용하면 데이터베이스 트랜잭션이 READ COMMITED 격리 수준이어도 애플리케이션 레벨에서 반복 가능한 읽기가 가능하다.

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

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

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

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

  • 이것은 데이터베이스가 제공하는 락 기능을 사용한다.
    • 대표적으로 select for update 구문이 있다.
      • select for update: 하나의 트랜잭션이 완료되기 전까지 락을 건다.

두 번의 갱신 분실 문제

여러 사용자가 하나의 데이터를 동시에 수정하는 경우, 하나의 수정사항 외에 모든 수정사항이 사라지는 문제이다.

  • 두 번의 갱신 분실 문제는 데이터베이스 트랜잭션의 범위를 넘어선다. 따라서 트랜잭션 만으로는 문제를 해결할 수 없다. 이때는 3가지 선택 방법이 있다.
    • 마지막 커밋만 인정하기(default)
    • 최초 커밋만 인정하기
    • 충돌하는 갱신 내용 병합하기

16.1.3 @Version

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

@Version
private Integer version;

엔티티에 버전 관리용 필드를 하나 추가하고 @Version 을 붙이면 된다. 이제부터 엔티티를 수정할 때 마다 버전이 하나씩 자동으로 증가한다.

그리고 엔티티를 수정할 때 조회 시점의 버전과 수정 시점의 버전이 다르면 예외가 발생한다.

  • 예를 들어 트랜잭션 1이 조회한 엔티티를 수정하고 있는데 트랜잭션 2에서 같은 엔티티를 수정하고 커밋해서 버전이 증가해버리면 트랜잭션 1이 커밋할 때 버전 정보가 다르므로 예외가 발생한다.

→ 버전 정보를 사용하면 최초 커밋만 인정하기가 적용된다.

버전 정보 비교 방법

update 쿼리를 실행할 때 버전을 사용하는 엔티티면 검색 조건에 엔티티의 버전 정보를 추가한다.

update BOARD
set 
		title = ?
		version = ? (버전 + 1 증가)
where
		id = ?
		and version = ? (버전 비교)

버전은 엔티티의 값을 변경하면 증가한다.

그리고 값 타입인 임베디드 타입과 값 타입 컬렉션은 논리적인 개념상 해당 엔티티의 값이므로 수정하면 엔티티의 버전이 증가한다. 단 연관관계 필드는 외래 키를 관리하는 연관관계의 주인 필드를 수정할 때만 버전이 증가한다.(연관관계의 주인인 엔티티가 연관관계 필드를 변경했을 때에만, 엔티티의 버전이 증가한다. 연관관계인 엔티티의 버전은 아무 영향이 없다)

@Version 으로 추가한 버전 관리 필드는 JPA가 직접 관리하므로 개발자가 임의로 수정하면 안된다.(벌크 연산 제외)

벌크 연산은 버전을 무시한다. 벌크 연산에서 버전을 증가하려면 버전 필드를 강제로 증가시켜야 한다.(영속성 컨텍스트를 무시하고 db에 sql을 바로 보내는 경우가 이에 해당한다) update Member m set n.name = ‘변경’, m.version = m.version + 1

16.1.4 JPA 락 사용

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

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

  • EntityManager.lock(), EntityManager.find(), EntityManager.refresh()
  • Query.setLockMode() (TypeQuery 포함)
  • @NamedQuery
Board board = em.find(Board.class, id, LockModeType.OPTIMISTIC);
//조회하면서 즉시 락을 건다.
Board board = em.find(Board.class, id);
...
em.lock(board, LockModeType.OPTIMISTIC);
//필요할 때 락을 건다.

16.1.5 JPA 낙관적 락

JPA가 제공하는 낙관적 락은 버전(@Version)을 사용한다. 따라서 낙관적 락을 사용하려면 버전이 있어야 한다.

낙관적 락은 트랜잭션을 커밋하는 시점에 충돌을 알 수 있다는 특징이 있다.

락 옵션 없이 @Version 만 있어도 낙관적 락이 적용된다. 락 옵션을 사용하면 락을 더 세밀하게 제어할 수 있다.

NONE

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

  • 용도: 조회한 엔티티를 수정할 때 다른 트랜잭션에 의해 변경(삭제)되지 않아야 한다.
  • 동작: 엔티티를 수정할 때 버전을 체크하면서 버전을 증가시킨다(update query). 이때 데이터베이스의 버전 값이 현재 버전이 아니면 예외가 발생한다.
  • 이점: 두 번의 갱신 분실 문제를 예방한다.

OPTIMISTIC

이 옵션을 추가하면 엔티티를 조회만 해도 버전을 체크한다. 쉽게 이야기해서 한 번 조회한 엔티티는 트랜잭션을 종료할 때까지 다른 트랜잭션이 변경하지 않음을 보장한다.

  • 용도: 조회 시점부터 트랜잭션이 끝날 때까지 조회한 엔티티가 변경되지 않음을 보장한다.
  • 동작: 트랜잭션을 커밋할 때 버전 정보를 조회해서(select query) 현재 엔티티의 버전과 같은지 검증한다. 만약 같지 않으면 예외가 발생한다.
  • 이점: OPTIMISTIC 옵션은 DIRTY READ와 NON-REPEARABLE READ를 방지한다.(이 옵션을 사용하면 격리 수준을 repeatable read 수준과 비슷하게 끌어올릴 수 있다.)

OPTIMISTIC_FORCE_INCREMENT

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

  • 용도
    • 논리적인 단위의 엔티티 묶음을 관리할 수 있다. 부모 엔티티는 직접 변경되지 않더라도, 자식 엔티티가 변경되면 부모 엔티티의 버전을 증가시켜야 할 때 사용한다.
    • 트랜잭션에서 특정 엔티티가 변경될 가능성이 높은 경우, 명시적으로 @Version 을 증가시켜서 충돌을 유도할 수 있음.
      • 예를 들어 읽기 작업만 하는 트랜잭션이라도 이후 갱신 작업이 불가능하도록 미리 충돌을 발생시키고 싶을 때 사용할 수 있음.
  • 동작: 엔티티를 수정하지 않아도 트랜잭션을 커밋할 때 update query를 사용해서 버전 정보를 강제로 증가시킨다. 이때 데이터베이스의 버전이 엔티티의 버전과 다르면 예외가 발생한다. 추가로 엔티티를 수정하면 수정 시 버전 update가 발생한다. 따라서 총 2번의 버전 증가가 나타날 수 있다.
  • 이점: 논리적 단위의 엔티티 묶음의 버전을 관리할 수 있다.

16.1.6 JPA 비관적 락

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

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

PESSIMISTIC_WRITE

비관적 락이라 하면 일반적으로 이 옵션을 뜻한다. 데이터베이스에 쓰기 락을 걸 때 사용한다.

  • 용도: 데이터베이스에 쓰기 락을 건다.
  • 동작: 데이터베이스 select for update 를 사용해서 락을 건다.
  • 이점: NON-REPEATABLE READ를 방지한다. 락이 걸린 로우는 다른 트랜잭션이 수정할 수 없다.

PESSIMISTIC_READ

데이터를 반복 읽기만 하고 수정하지 않는 용도로 락을 걸 때 사용한다. 일반적으로 잘 사용하지 않는다. 데이터베이스 대부분은 방언에 의해 PESSIMISTIC_WRITE 로 동작한다.

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

PESSIMISTIC_WRITE(쓰기 락) vs PESSIMISTIC_READ(읽기 락) 쓰기 락은 하나의 트랜잭션이 특정 데이터를 읽고 있을 때 해당 데이터에 다른 트랜잭션이 읽기 작업, 쓰기 작업 모두 수행 불가하다. 읽기 락은 다른 트랜잭션이 읽기 작업은 가능하다.(DIRTY READ 발생 가능)

PESSIMISTIC_FORCE_INCREMENT

비관적 락 중 유일하게 버전 정보를 사용한다. 비관적 락이지만 버전 정보를 강제로 증가시킨다.

하이버네이트는 nowait를 지원하는 데이터베이스에 대해서 for update nowait 옵션을 적용한다.

  • 오라클: for update nowait
  • PostgreSQL: for update nowait
  • nowait를 지원하지 않으면 for update가 사용된다.

Updated: