Transactions

Tx하나의 논리적인 작업의 단위 를 의미한다. 데이터베이스에 저장된 모든 데이터는 Integrity 하여야 한다. 그렇기 위해서는 시스템 장애가 발생하더라도 데이터를 신뢰할 수 있어야 하며, 동시(concurrently)에 데이터베이스에 접근하는 프로세스로 부터 격리를 제공해야 한다.

즉, Tx 의 주 목적은 데이터의 무결성(Integrity)을 지키기 위한 것 이며, 무결성을 지키기 위한 Tx 의 4가지 성질이 ACID 이다.

Character Description
Atomicity All or Nothing
트랜잭션의 연산은 데이터베이스에 모두 반영되든지 아니면 전혀 반영되지 않아야 한다.
트랜잭션 내의 모든 명령은 반드시 완벽히 수행되어야 하며, 모두가 완벽히 수 행되지 않고 어느하나라도 오류가 발생하면 데이터베이스 상태를 트랜잭션 작업 이전으로 되돌려서 원자성을 보장 해야 한다.
Consistency 트랜잭션 수행 전과, 수행 완료 후의 상태가 같아야 한다.
명시적인 일관성 : 기본 키, 외래 키 등과 같은 무결성 제약조건
비명시적인 일관성 : Ex. 계좌 이체에서, A 계좌에서 출금이 일어나고 그 돈이 B 계좌로 입금된다 했을 때, 트랜잭션의 전과 후 두 계좌 잔고의 합이 같아야 한다.
Isolation 둘 이상의 트랜잭션이 동시에 병행 실행되는 경우 어느 하나의 트랜잭션 실행중에 다른 트랜잭션의 연산이 끼어들 수 없다.
Durability 성공적으로 완료된 트랜잭션의 결과는 시스템이 고장나더라도 영구적으로 반영되어야 한다.

데이터의 무결성을 지키기 위해서 동시 접근으로 부터 격리를 해야하는데 어느 수준(level)로 하는지가 문제가 된다. 크게 3가지 문제가 존재한다.

  • Dirty Read : 트랜잭션에서 처리 중인, 아직 커밋 되지 않은 데이터를 다른 트랜잭션에서 읽는 것을 허용하게 됨으로써 발생하는 문제이다.
  • Non-Repeatable Read: 트랜잭션이 커밋되어 확정된 데이터를 읽는 것을 허용하게 됨으로써 발생하는 문제이다. Tx1 이 데이터 조회 후 Tx2 가 update or delete 를 한다음 Tx1 이 조회를 한 번더 하면 변경된 값을 읽게 된다.
  • Phantom Read: MySQL 의 기본 트랜잭션 격리 방식인 Repeatable Read 도 Phantom Read 는 여전히 발생한다. Tx1 이 데이터 개수 조회 후 Tx2 가 insert 를 한 다음 Tx1 이 count 조회를 한 번더 하면 업데이트된 개수를 읽게된다.

위 문제들을 적절한 트랜잭션 격리 수준을 설정해서 해결할 수 있다. 즉, 트랜잭션 격리 수준을 이해해야 하는 이유는 일관성과 성능의 Trade-off 를 판단하는 기준이 되기 때문이다.

Transaction Isolation Levels:

Character Description
Read Uncommitted 트랜잭션에서 처리 중인, 아직 커밋 되지 않은 데이터를 다른 트랜잭션에서 읽는 것을 허용
Dirty Read, Non-Repeatable Read, Phantom Read 현상 발생
Read Committed Dirty Read 방지 - 트랜잭션이 커밋이 확정된 데이터만 읽는다.
Non-Repeatable Read, Phantom Read 현상은 여전히 발생
Repeatable Read 선행 트랜잭션이 읽은 데이터는 트랜잭션이 종료될 때가지 후행 트랜잭션이 갱신하거나 삭제하는 것은 불허함으로써 (Insert 는 가능) 같은 데이터를 두 번 쿼리했을 때 일관성 있는 결과를 리턴
MySQL InnoDB 에서 기본으로 채택하고 있는 격리 수준
Phantom Read 현상은 여전히 발생
Serializable Read 선행 트랜잭션이 읽은 데이터를 후행 트랜잭션이 갱신하거나 삭제하지 못할 뿐만 아니라 중간에 새로운 레코드를 삽입하는 것도 막아줌. 완벽하게 읽기 일관성 모드를 제공
동시성 이슈 해결 가능

Database Vendor 들이 가장 높은 격리 수준인 Serializable Read 를 설정하지 않는 이유는 실제로 finances 와 같이 absolute correctness is not needed 한 경우가 많기 때문이다. 즉, 스펙에 따라 상품 목록을 조회할 때, 스펙에 부합하더라도 데이터가 업데이트된 지 얼마 되지 않은 상품이 목록에 나타나지 않아도 별 문제가 되지 않는 경우가 대부분이다. 또한 Serializable Read 는 동시성을 해결할 수는 있지만 성능적인 문제가 발생할 수 있다.

그래서 일반적으로 Concurrency 이슈를 해결하기 위해 Consistency & Performance 의 Trade-off 로 Repeatable Read 격리 수준을 선택하며, Lock Mechanism 을 같이 사용한다.

MySQL InnoDB 의 Repeatable Read 는 어떻게 읽기 일관성을 보장하기 위해서 Snapshot 을 사용한다. 스냅샷(snapshot)은 특정 시간의 데이터 표현으로, 첫 번째 읽기가 스냅샷(시간 지점)을 설정하고 이후의 모든 읽기가 서로에 대해 일관성을 유지한다는 의미이다.

JPA 가 First Level Cache 를 통해 Repeatable Read 수준의 읽기 일관성을 제공한다.

@DisplayName("Repeatable Read Test")
@SpringBootTest
class RepeatableReadTest {

    @Value("${persistence.unitname}")
    private String persistenceUnitName;

    @DisplayName("1차 캐시를 통한 Repeatable Read 를 지원하는지 테스트")
    @Test
    void repeatableReadByCache() throws Exception {
        // given
        EntityManagerFactory emf = Persistence.createEntityManagerFactory(persistenceUnitName);
        insertDummyData(emf);

        // when
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin(); // 선행 트랜잭션 시작
        Member findMember1 = em.find(Member.class, 1L);
        /**
         * findMember1 : Member 를 데이터베이스에서 조회
         * Member 를 다시 조회하기 전에 H2 데이터 베이스에서 UPDATE 문 실행
         * findMember2 : Member 를 데이터베이스가 아닌 1차 캐시에서 조회
         */
        Member findMember2 = em.find(Member.class, 1L);
        tx.commit(); // 선행 트랜잭션 종료
        em.close();
        emf.close();

        // then
        assertThat(findMember1.getUserName()).isEqualTo(findMember2.getUserName());
    }

    @DisplayName("더미 데이터 삽입")
    private void insertDummyData(EntityManagerFactory emf) {
        EntityManager em = emf.createEntityManager();
        EntityTransaction tx = em.getTransaction();

        tx.begin();

        Member member = new Member();
        member.setUserName("JungHo");
        em.persist(member);
        tx.commit();

        em.close();
    }
}

Snapshot 은 양호한 수준의 읽기 일관성을 보장하지만, 데이터를 일시적으로 임의의 공간에 보관해야 하므로 성능적으로 약간의 희생이 발생한다. JPA 에서는 비지니스로직을 처리하면서 데이터를 읽고, 변경까지 하는 경우에는 Snapshot 을 통해 Repeatable Read 수준의 읽기 일관성을 보장해야 할 것이다. 하지만, 비지니스 로직을 처리하는데 오직 읽기만 한다면 Snapshot 저장공간이 필요 없지 않을까?

이러한 아이디어를 통해 성능을 올리고자 Declarative Transaction 을 사용할때, 트랜잭션을 read-only 로 설정하면, 별도의 Snapshot 저장공간이 필요 없어지게 되어 성능 향상에 도움이 된다. 즉, START TRANSACTION READ ONLY 구문을 사용하여 읽기 전용 트랜잭션을 명시적으로 정의하는 것이다.

Advanced