ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 트랜잭션과 JPA 낙관적 락
    Backend/학습내용 정리 2024. 6. 17. 21:06

     

    트랜잭션과 락 개념

     

    트랜잭션은 작업의 완전성을 보장해 주는 것입니다. 즉, 논리적인 작업 셋을 모두 완벽하게 처리하거나, 처리하지 못할 경우에는 원 상태로 복구해서 작업의 일부만 적용되는 현상이 발생하지 않게 만들어주는 기능입니다.

     

    잠금은 여러 커넥션에서 동시에 동일한 자원을 요청할 경우 순서대로 한 시점에는 하나의 커넥션만 변경할 수 있게 해주는 역할을 합니다.

    트랜잭션의 격리 수준이라는 것은 하나의 트랜잭션 내에서 또 다른 트랜잭션 간의 작업 내용을 어떻게 공유하고 차단할 것인지를 결정하는 레벨을 의미합니다.

     

    락과 트랜잭션은 서로 비슷한 개념 같지만,
    락은 동시성을 제어하기 위한 기능이고,
    트랜잭션은 데이터의 정합성을 보장하기 위한 기능입니다


     

    JPA 낙관적 락

     

    낙관적 락은 DB의 락 기능을 이용한 동시성 제어가 아닌 어플리케이션에서 Version 정보를 이용한 동시성 제어 로직이다. Update 쿼리 발행 시 Where 절에 현재 트랜잭션에서 조회 된 버전의 정보를 조건절로 주도록 하며 업데이트 된 row가 0이라면 OptimisticLockException을 던집니다.

     

    아래 메서드가 존재할 때 처음에 정보를 가져오는 select 쿼리와 업데이트를 하는 쿼리가 2번 생성되게 됩니다.

    @Transactional  
    public Stock updateStock(Long id, int quantity) {  
        Stock stock = stockRepository.findById(id)  
                .orElseThrow(() -> new EntityNotFoundException("상품이 없습니다."));  
    
        stock.update(quantity);  
        return stock;  
    }

     

     

    생성된 쿼리는 아래와 같습니다.

    select s1_0.id,s1_0.name,s1_0.quantity,s1_0.version from stock s1_0 where s1_0.id=1
    
    update stock set name='product1',quantity=500,version=5 where id=1 and version=4;

     

     

    즉 전체 흐름은 id 값으로 해당 Entity 를 조회한 후 업데이트 로직에서 현재 version 에서 +1 한 값으로 업데이트 쿼리가 생성되는 것을 확인할 수 있었습니다.

     

     

    MVCC(Multi Version Concurrency Control) 에서는 어떻게 작동하는 것인가

    MVCC는 여러 트랜잭션이 동시에 같은 데이터에 접근할 때, 데이터의 일관성과 동시성을 보장하는 방식입니다. 이 기술의 핵심은 잠금(Locking) 없이 데이터를 일관되게 읽는 것이며, InnoDB 스토리지 엔진은 이를 구현하기 위해 언두 로그(Undo log)를 활용합니다. InnoDB 스토리지 엔진은 트랜잭션이 ROLLBACK될 가능성에 대비해 변경되기 전 레코드를 언두 공간에 백업해두고 실제 레코드 값을 변경합니다.

     

    MVCC 에서의 Update

    MVCC라는 버전 관리 방법을 통해 데이터 접근 시 일관성을 보장하는 것을 알았습니다. 이제 실제 낙관적 락의 작동 방법을 확인해보겠습니다.

     

    현재 stock 이라는 테이블이 존재하며 id, name, quantity, version 컬럼이 있습니다.

     

     

     

    지금 부터 2개의 커넥션을 통해 낙관적 락의 쿼리를 실행해보도록 하겠습니다.

     

    쿼리 순서는 수동으로 트랜잭션을 실행하고, id = 1 인 레코드의 quantity 값을 버전과 같이 수정합니다. 이후 정상 동작을 확인하고 commit 하는 과정의 쿼리로 테스트하겠습니다.

     

    먼저 1번 커넥션에서 트랜잭션을 실행하고 id = 1 인 레코드를 조회한 모습입니다.

     

     

     

    다음으로 2번 커넥션에서 트랜잭션을 실행하고 id = 1 인 레코드를 조회한 모습입니다.

     

     

     

    현재까지는 2 커넥션 모두 동일한 quantity 를 보여주고 있습니다.

     

    이제 1번 커넥션에서 quantity 를 100으로 version을 6으로 업데이트 한 후 select 한 모습입니다.

     

     

     

    결과를 확인해보면 정상적으로 업데이트가 된 것을 확인할 수 있습니다.
    이때 다시 2번 커넥션에서 id = 1 인 레코드를 조회하면 현재 MySQL의 기본 격리 수준인 REPEATABLE READ 이기 때문에 동일한 결과를 보여줍니다.

     

     

     

    이후 1번 커넥션에서 커밋을 완료한 후 다시 2번 커넥션에서 조회해도 동일한 결과가 나올 것입니다.

     

    이제 2번 커넥션에서 version 5 값을 이용해 quantity 값을 5000 으로 업데이트 하는 쿼리를 실행해보겠습니다.

     

     

     

    위 결과를 보면 이상하다고 느낄 부분이 있습니다. 바로 update 쿼리를 실행하기 전에는 version 값이 분명이 5인데(물론 커넥션 1이 커밋을 진행한 후이기 때문에 실제는 6이고 quantity 값도 100 입니다. 이는 MVCC 와 격리 수준때문입니다) 해당하는 버전으로 업데이트를 진행했지만 업데이트 쿼리의 결과가 0인 것을 확인할 수 있습니다.

     

    따라서 JPA의 낙관적 락 기능은 DB에서 에러가 발생하는 것이 아니라 업데이트 쿼리의 결과가 0이면 ObjectOptimisticLockingException 을 어플리케이션에서 발생시키는 원리입니다.

     

    그러면 MVCC의 언두 로그에서 select를 하고 관련된 데이터를 update 하는데 왜 업데이트할 때는 언두 로그를 보지 않는가 의문이 생길 수 있습니다.

     

    이유는 MySQL 공식문서의 17.7.2.3 Consistent Nonlocking Reads 부분을 보면 알 수 있습니다. 이 문서의 Note 부분을 확인해 보면 아래와 같습니다.

     

    The snapshot of the database state applies to SELECT statements within a transaction, not necessarily to [DML](https://dev.mysql.com/doc/refman/8.0/en/glossary.html#glos_dml "DML") statements. If you insert or modify some rows and then commit that transaction, a DELETE or UPDATE statement issued from another concurrent `REPEATABLE READ` transaction could affect those just-committed rows, even though the session could not query them. If a transaction does update or delete rows committed by a different transaction, those changes do become visible to the current transaction. For example, you might encounter a situation like the following:...

     

     

    즉, 트랜잭션 내에서 SELECT 문과 DML(INSERT, UPDATE, DELETE) 문이 스냅샷 격리 수준에서 어떻게 다르게 작동하는지 설명합니다. REPEATABLE READ 격리 수준에서 트랜잭션이 동일한 스냅샷을 사용하여 SELECT 문을 실행하지만, DML 문은 다른 트랜잭션이 커밋한 변경 사항에 영향을 받을 수 있습니다.

     

    따라서 커넥션 1번에서 version 을 업데이트한 결과가 영향을 받아서 커넥션 2의 업데애트 쿼리의 version 에 맞지 않아 아무런 업데이트가 되지 않는 것입니다.

     

    위 상태에서 version = 6 으로 수정해서 쿼리를 실행한다면 정상적으로 업데이트가 실행될 것입니다. 아래 사진을 통해 확인할 수 있습니다. 1번 커넥션에서 version 을 6으로 업데이트 했기 때문에 업데이트 쿼리의 where 조건이 만족해서 1개의 row가 업데이트 되고 있습니다.

     

     

     

    이는 원하던 동작이 아니기 때문에 2번 커넥션은 롤백 시키고 select를 진행하면 1번 커넥션의 결과가 정상적으로 출력되는 것을 확인할 수 있습니다.

     

     

     

    지금까지 MVCC 에서 JPA의 낙관적 락의 원리에 대해 직접 쿼리를 통해 테스트하며 확인할 수 있었습니다. 처음 SELECT 의 일관성을 위해 다른 커넥션에서 커밋하더라도 트랜잭션 실행 시 스냅샷에서 데이터를 읽어와 일관성을 유지하지만, 왜 update는 스냅샷 조건에서 작동하지 않는지 의문이었는데. 공식 문서를 통해 확인이 가능했습니다.

     

    JPA의 낙관적 락은 이처럼 락을 사용하지 않고 버전 관리를 어플리케이션에서 업데이트가 진행되지 않은 경우 Exception을 발생시켜 동시성을 보장하고 있습니다.

     

    지금까지 개념적으로만 이해하던 내용을 직접 쿼리를 통해 실습을 해보니 좀 더 명확하게 이해할 수 있는 시간이었습니다.

     

    테스트에 사용한 쿼리들은 아래와 같습니다. 잘못된 내용이나 피드백은 언제나 환영입니다

     

    create table stock (  
        id int primary key,  
        name varchar(255) not null,  
        quantity int not null,  
        version int not null  
    );  
    
    insert into stock values (1, 'product1', 100, 1);  
    insert into stock values (2, 'product2', 200, 1);  
    insert into stock values (3, 'product3', 300, 1);  
    
    
    # 낙관적 락 테스트  
    start transaction;  
    select * from stock where id = 1;  
    update stock set quantity = 50, version = 2 where id = 1 and version = 1;  
    select * from stock where id = 1;  
    commit;  

     

     

     

    아래는 참고한 블로그와 글 목록입니다.

    https://cookie-dev.tistory.com/30
    https://jyeonth.tistory.com/32
    https://dev.mysql.com/doc/refman/8.0/en/innodb-consistent-read.html

     

     

     

     

     

     

     

     

    'Backend > 학습내용 정리' 카테고리의 다른 글

    JDBC 살펴보기  (1) 2024.09.12
    Spring @EventListener 사용하기 (2)  (0) 2024.07.19
    Spring @EventListener 사용하기  (0) 2024.05.29
    Server Sent Event 정리  (0) 2024.05.16

    댓글

Designed by Tistory.