본문 바로가기

Development/Diary

[Diary] Spring 비관적 락(Pessimistic Lock)과 Transaction Isolation level의 조합으로 발생할 수 있는 현상(+Snapshot Isolation)

이 글에서는 비관적 락을 사용하였음에도 멀티 스레드 환경에서 원하는 결괏값이 나오지 않아 발생한 문제 상황에 대해 작성하고자 합니다.

문제 상황: 한 스레드가 새로운 레코드를 Insert 하고 다른 스레드가 findAll() 했을 때 새로운 레코드를 읽지 않는다?

문제 재현

UserTest

@Entity
@Getter
@Setter
public class UserTest {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private Long id;

    @Column(nullable = false)
    private String username;

}

UserTestRepository

@Repository
public interface UserTestRepository extends JpaRepository<UserTest, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    List<UserTest> findAllByOrderByIdDesc();

}
  • findAllByOrderByIdDesc()은 비관적 락을 사용한다.
  • Id 기준 내림차순으로 정렬하여 List를 반환한다.

UserTestService

@Service
public class UserTestService {

    @Autowired
    private UserTestRepository userTestRepository;

    @Transactional
    public void thread1() {
        System.out.println("[Thread1] Transaction Start");
        UserTest userTest = userTestRepository.findAllByOrderByIdDesc().get(0);
        System.out.println("[Thread1] find userTest = " + userTest.getUsername());
        
        UserTest newUserTest = new UserTest();
        newUserTest.setUsername("test2");
        userTestRepository.save(newUserTest);
        System.out.println("[Thread1] Save New UserTest = " + newUserTest.getUsername());
        
        UserTest insertedUserTest = userTestRepository.findAllByOrderByIdDesc().get(0);
        System.out.println("[Thread1] InsertedUserTest Id = " + insertedUserTest.getId() + " username = " + insertedUserTest.getUsername());

        System.out.println("[Thread1] Transaction End");
    }

    @Transactional
    public void thread2() {
        System.out.println("[Thread2] Transaction Start");
        UserTest userTest = userTestRepository.findAllByOrderByIdDesc().get(0);
        System.out.println("[Thread2] UserTest Id = " + userTest.getId() + " username = " + userTest.getUsername());

        System.out.println("[Thread2] Transaction End");
    }

}
  • thread1 메서드는 userTestRepository에 있는 모든 UserTest를 Id 기준 내림차순으로 read 한 후, 0번째 인덱스 엔티티의 username을 출력한다. 그리고 newUserTest라는 새로운 UserTest를 insert 한다.
    그 후 다시 UserTest를 내림차순으로 read 하고, 0번째 인덱스 엔티티의 username을 출력한다.
  • thread2 메서드는 userTestRepository에 있는 모든 UserTest를 read 한 후, 0번째 인덱스 엔티티의 username을 출력한다.

application-test.yml

spring:
  datasource:
      url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1
      driverClassName: org.h2.Driver
      username: sa
      password:
  jpa:
    database-platform: org.hibernate.dialect.H2Dialect
    hibernate:
      ddl-auto: create-drop
    properties:
      hibernate:
        show_sql: true
        format_sql: true

logging:
  level:
    org.hibernate.sql: info
  • 테스트 Database는 H2를 사용한다.

ConcurrenyTest

@SpringBootTest
@ActiveProfiles("test")
public class ConcurrencyTest {

    @Autowired
    private UserTestRepository userTestRepository;

    @Autowired
    private UserTestService userTestService;

    @Test
    public void testConcurrentUpdates() throws InterruptedException {
        UserTest userTest = new UserTest();
        userTest.setUsername("test1");
        userTestRepository.save(userTest);
        ExecutorService executor = Executors.newFixedThreadPool(2);

        executor.submit(() -> userTestService.thread1());
        executor.submit(() -> userTestService.thread2());

        executor.shutdown();
        executor.awaitTermination(1, TimeUnit.MINUTES);
    }

}
  • 위 테스트 코드는 먼저 "test1"이라는 username의 UserTest를 새로 생성하여 UserTestRepository에 save를 한다.
  • 그 후 2개의 스레드를 생성한 후, 각각 userTestService의 thread1, thread2를 수행한다.

이제 테스트 코드의 진행을 살펴보자.

우선 테스트 메서드에 있는 UserTest를 insert 한다. 이 UserTest의 username은 "test1"이다. 


스레드 Thread1, Thread2의 Transaction이 시작한다.

여기서 Thread1과 Thread2가 userTestRepository.findAllByOrderByIdDesc()를 동시에 수행하여 쿼리문이 동시에 실행된다.

Thread1이 먼저 userTestRepository.findAllByOrderByIdDesc()을 수행하여 Lock을 얻는다.

Thread1은 테스트 메서드에서 insert 한 UserTest 엔티티의 username인 "test1"을 출력한다.

 

test2라는 username을 가진 newUserTest를 insert 한다.

그리고 findAllByOrderByIdDesc(). get(0)을 수행하여 가져온 UserTest의 username을 출력하면 새롭게 삽입한 test2가 출력이 된다. 그리고 Transaction을 종료한다.

 

Spring은 Transaction이 종료되면 영속성 컨텍스트에 있는 엔티티들의 상태가 DB에 Commit 된다.

따라서 test2라는 username의 UserTest는 DB에 반영된다.

그러면 UserTest의 테이블은 아래처럼 반영되어 있을 것이다.

Id username
1 test1
2 test2

 

이제 Thread2가 Lock을 획득하고, findAllByOrderByIdDesc()을 수행한다.

그 후 0번째 index의 데이터를 읽고 username을 출력한다.

하지만 Thread1에서 insert 한 test2라는 이름의 UserTest가 아닌, 처음 테스트 메서드에서 insert한 UserTest 엔티티의 username을 출력한다. 정상적인 결과는 id기준 내림차순 정렬이기 때문에 id = 2인 test2가 출력되어야 할 것이다.

 

이러한 현상의 원인을 분석하기 전에 알아야 할 개념들을 살펴보자.

Pessimistic Lock(비관적 락)

Pessimistic Lock(비관적 락)은 데이터의 동시 접근을 제어하기 위해 사용되는 메커니즘이다.

Lock의 방식은 두 가지가 있다.

  • PESSIMISTIC_READ: 다른 Transaction이 READ 작업을 수행할 수 있지만 쓰기는 불가능하다.
  • PESSIMISTIC_WRITE: 다른 트랜잭션이 READ와 WRITE 모두 불가능하다. 

Transaction 내에서 데이터를 읽기 전에 Lock을 설정하여 다른 Transaction이 해당 데이터에 접근하지 못하게 한다.
Transaction이 종료되면 Lock을 해제한다.(release)

Transaction Isolation Level (트랜잭션 격리 수준)

Transaction Isolation Level(트랜잭션 격리 수준)은 여러 Transaction이 동시에 수행될 때 데이터의 일관성을 보장하기 위해 사용된다.

Isolation level에 따라 발생할 수 있는 문제들에는 Dirty Read, Non-repeatable Read, Phantom Read가 있다.

  1. Dirty Read
    Dirty Read는 Transaction이 다른 Transaction에서 아직 commit되지 않은 변경 사항을 읽는 상황이다. 이로 인해 읽은 데이터가 나중에 rollback되면 잘못된 정보를 기반으로 작업을 수행하게 된다. Transaction A가 rollback되면 Transaction B는 존재하지 않는 데이터를 읽게 된다.
  2. Non-repeatable Read
    Non-repeatable Read는 같은 Transaction 내에서 같은 데이터를 여러 번 읽을 때, 그 값이 변경되는 상황이다. 이는 Transaction이 데이터 일관성을 보장하지 못하게 한다.
  3. Phantom Read
    Phantom Read는 Transaction이 동일한 쿼리를 여러 번 실행할 때, 다른 Transaction이 새로운 데이터를 삽입하거나 삭제하여 결과 집합이 달라지는 상황이다.

이러한 문제를 방지하기 위하여 SQL 표준에서는 4가지 Isolation level을 정의한다.

  1. Read Uncommitted
       - 가장 낮은 Isolation Level이다.
       - 다른 Transaction이 Commit하지 않은 변경 사항도 읽을 수 있다.
       - Dirty Read, Non-repeatable Read, Phantom Read가 발생할 수 있다.
  2. Read Committed
       - Commit된 데이터만 읽을 수 있다.
       - Non-repeatable Read, Phantom Read가 발생할 수 있다.
  3. Repeatable Read
       - Transaction 내에서 동일한 쿼리를 반복 실행해도 동일한 결과를 보장한다.
       - Phantom Read가 발생할 수 있다.
  4. Serializable
       - 가장 높은 Isolation level 이다.
       - Transaction이 순차적으로 실행되는 것처럼 보장한다.
       - 성능 저하, DeadLock의 위험이 있다.

MVCC와 Snapshot Isolation

Multi-Version Concurrency Control (MVCC)는 데이터베이스 시스템에서 일관성과 동시성을 관리하기 위해 사용되는 기술이다. MVCC는 각 행의 버전 정보를 관리한다.

예를 들어 PostgreSQL 은 각 행의 버전을 관리하기 위해 시스템 컬럼을 사용한다. 

  • xmin: 이 컬럼은 행이 INSERT되거나 UPDATE될 때, 해당 트랜잭션의 ID (Transaction ID)를 저장한다. 즉, 행이 어느 트랜잭션에 의해 생성되었는지를 나타낸다.
  • xmax: 이 컬럼은 행이 DELETE되거나 UPDATE될 때, 해당 트랜잭션의 ID를 저장한다. 행이 어느 트랜잭션에 의해 삭제되었는지 또는 새로운 버전으로 대체되었는지를 나타낸다.

이 두 컬럼을 사용하여 PostgreSQL은 각 행의 버전과 해당 행이 특정 트랜잭션에 의해 볼 수 있는지를 결정한다.

MVCC는 SnapShot 기법을 사용하는데, SnapShot은 Transaction이 시작될 때의 데이터베이스 상태를 고정하여, 해당 Transaction이 데이터를 읽을 때 항상 동일한 데이터를 보게 한다.


Transaction이 시작될 때 데이터의 SnapShot을 생성하여 사용하며, 각 Transaction은 데이터의 특정 Version을 읽고 수정한다.
Transaction이 Commit되면 새로운 데이터 버전이 생성된다.

원인 분석하기: 동시 SELECT...FOR UPDATE 쿼리 실행 이후 INSERT는 SnapShot에 반영되지 않는다.

H2 데이터베이스의 기본 Transaction Isolation level Read Committed이다.

H2 데이터베이스에서 Transaction Isolation level이 Read Committed일 때, 새로운 Transaction이 시작될 때마다 데이터베이스는 최근 commit된 데이터를 읽는다. 또한 H2는 MVCC를 지원한다. 따라서 쿼리가 실행될 때의 상태를 SnapShot에 반영한다.

 

그러면 다시 처음에 보았던 테스트 코드의 흐름을 살펴보자.

여기서 Thread1은 현재 시점의 DB 상태를 SnapShot에 반영하고 Lock을 획득한다. (findAllByOrderByIdDesc 메서드는 비관적 락이 걸려있는 것을 기억하자.)

Thread2는 현재 시점의 DB 상태를 SnapShot에 반영하고 Lock을 획득하기 위해 대기한다.

Thread1은 test2라는 UserTest를 INSERT하고 Commit 한다.

Thread2는 Thread1이 새로운 transaction을 commit 했기 때문에 자신의 Snapshot에 있는 데이터와 Commit된 데이터의 Version을 비교한다. 

그러나 Thread2 snapshot에 존재하는 데이터(test1)는 Thread1이 commit한 version에서 어떠한 수정이 이루어지지 않았다.  

따라서 Thread2는 Snapshot에 있는 test1을 출력한다.

 

위 테스트 코드의 시나리오를 다음과 같이 표현할 수 있다.

  1. Thread 1과 Thread 2가 동시에 SELECT...FOR UPDATE 구문을 실행한다. 이 때 각각 같은 SnapShot을 반영한다.
  2. Thread 1이 Lock을 획득한 상태에서 데이터를 Insert하고 Commit하면, 새로운 데이터는 데이터베이스에 반영된다.
  3. 이후 Thread 2가 Lock을 획득하고 데이터를 조회할 때, Thread 2의 Transaction은 Lock을 얻기 위해 대기하는 시점의 SnapShot을 사용한다. 이 SnapShot은 Thread 1이 commit한 데이터를 포함하지 않는다.
  4. 따라서 Thread 2의 SnapShot에 반영되어 있는 데이터는 어떠한 변경(UPDATE or DELETE)이 없어서 SnapShot을 그대로 사용한다.

결과적으로 Thread 2가 "test2"를 출력하지 않은 이유는 Thread 1이 새로운 데이터를 insert한 후 commit했지만, Thread 2가 데이터를 조회할 때 Thread 1이 SnapShot을 사용하기 때문이다.

동시 SELECT 쿼리 실행 이후 UPDATE의 경우?

다음과 같이 코드를 수정하고 테스트 코드를 실행해보았다.

@Transactional
    public void thread1() {
        System.out.println("[Thread1] Transaction Start");
        UserTest userTest = userTestRepository.findAllByOrderByIdDesc().get(0); // Acquire Lock
        userTest.setUsername("newTest1");
        userTestRepository.save(userTest);
        System.out.println("[Thread1] Update userTest = " + userTest.getUsername());
        System.out.println("[Thread1] Transaction End");
    }

    @Transactional
    public void thread2() {
        System.out.println("[Thread2] Transaction Start");
        UserTest userTest = userTestRepository.findAllByOrderByIdDesc().get(0);
        System.out.println("[Thread2] UserTest Id = " + userTest.getId() + " username = " + userTest.getUsername());

        System.out.println("[Thread2] Transaction End");
    }
  1. thread1 메서드는 findAllByOrderByIdDesc()를 호출하여 Lock을 획득하고, test1 UserTest를 불러온다.
  2. 이후 test1의 이름을 "newTest1"로 수정하고 save한다.
  3. thread2가 lock을 얻고, findAllByOrderByIdDesc().get(0)을 호출하고 username을 출력한다. 

위 로그를 보면 Thread 1, Thread 2 모두 동시에 Select 쿼리를 실행했다.

Thread 1이 Lock을 획득하고 UserTest를 Update 한다.

Thread 1이 Transaction을 끝내면서 Commit을 한다. 따라서 update쿼리가 발생했다.

Thread 2는 UPDATE된 newTest1을 출력한다.

이는 Thread 2 SnapShot은Thread 1이 Commit 하기 이전 시점임에도, 다시 Version을 체크하여 Update된 데이터를 반영한 것으로 볼 수 있다.

 

아래는 위 시나리오를 도식화한 그림이다.

 

INSERT때와 다른 점은 Thread2가 READ 하려는 데이터가 변했느냐, 안 변했느냐의 차이이다.

PostgreSQL에서 테스트?

H2와 PostgreSQL의 동작은 같았다. 즉 지금까지 설명한 내용과 같이 동작했다.

MySQL의 경우: 동시 SELECT...FOR 이후 INSERT는 Deadlock을 발생시킨다.

MySQL의 동작은 다르다.

Thread1이 INSERT를 실행하려 하자, DeadLock이 발생하여 Rollback 되었고, Thread2가 실행되었다.

 

아래 SQL문을 각각 다른 Transaction을 만들어서 직접 DB console에 실행시켜 보고, STATUS를 출력해보았다.

START TRANSACTION;
SELECT
    usertest0_.id AS id1_9_,
    usertest0_.username AS username2_9_
FROM
    user_test usertest0_
ORDER BY
    usertest0_.id DESC
    FOR UPDATE;
COMMIT;
START TRANSACTION;
SELECT
    usertest0_.id AS id1_9_,
    usertest0_.username AS username2_9_
FROM
    user_test usertest0_
ORDER BY
    usertest0_.id DESC
    FOR UPDATE;
INSERT INTO user_test (username)
VALUES ('test1');
COMMIT;
SHOW ENGINE INNODB STATUS;
LATEST DETECTED DEADLOCK
------------------------
2024-06-10 16:33:16 140467187582528
*** (1) TRANSACTION:
TRANSACTION 6829, ACTIVE 5 sec starting index read
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 2 row lock(s)
MySQL thread id 251, OS thread handle 140466417808960, query id 2788 172.17.0.1 root executing
/* ApplicationName=IntelliJ IDEA 2024.1.1 */ SELECT
    usertest0_.id AS id1_9_,
    usertest0_.username AS username2_9_
FROM
    user_test usertest0_
ORDER BY
    usertest0_.id DESC
    FOR UPDATE

*** (1) HOLDS THE LOCK(S):
RECORD LOCKS space id 173 page no 4 n bits 72 index PRIMARY of table `the_survey_revision`.`user_test` trx id 6829 lock_mode X
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;


*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 173 page no 4 n bits 72 index PRIMARY of table `the_survey_revision`.`user_test` trx id 6829 lock_mode X waiting
Record lock, heap no 4 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 8; hex 8000000000000005; asc         ;;
 1: len 6; hex 000000001aa9; asc       ;;
 2: len 7; hex 81000000f00110; asc        ;;
 3: len 5; hex 7465737431; asc test1;;


*** (2) TRANSACTION:
TRANSACTION 6828, ACTIVE 7 sec inserting
mysql tables in use 1, locked 1
LOCK WAIT 3 lock struct(s), heap size 1128, 4 row lock(s)
MySQL thread id 250, OS thread handle 140466418865728, query id 2798 172.17.0.1 root update
/* ApplicationName=IntelliJ IDEA 2024.1.1 */ INSERT INTO user_test (username)
VALUES ('test1')

*** (2) HOLDS THE LOCK(S):
RECORD LOCKS space id 173 page no 4 n bits 72 index PRIMARY of table `the_survey_revision`.`user_test` trx id 6828 lock_mode X locks rec but not gap
Record lock, heap no 2 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 8; hex 8000000000000001; asc         ;;
 1: len 6; hex 000000001a8f; asc       ;;
 2: len 7; hex 82000000d70110; asc        ;;
 3: len 5; hex 7465737431; asc test1;;

Record lock, heap no 3 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 8; hex 8000000000000003; asc         ;;
 1: len 6; hex 000000001aa2; asc       ;;
 2: len 7; hex 81000000eb0110; asc        ;;
 3: len 5; hex 7465737431; asc test1;;

Record lock, heap no 4 PHYSICAL RECORD: n_fields 4; compact format; info bits 0
 0: len 8; hex 8000000000000005; asc         ;;
 1: len 6; hex 000000001aa9; asc       ;;
 2: len 7; hex 81000000f00110; asc        ;;
 3: len 5; hex 7465737431; asc test1;;


*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
RECORD LOCKS space id 173 page no 4 n bits 72 index PRIMARY of table `the_survey_revision`.`user_test` trx id 6828 lock_mode X insert intention waiting
Record lock, heap no 1 PHYSICAL RECORD: n_fields 1; compact format; info bits 0
 0: len 8; hex 73757072656d756d; asc supremum;;

*** WE ROLL BACK TRANSACTION (2)

로그 분석

  1. 트랜잭션 1 (TRANSACTION 6829):
    • 쿼리: SELECT ... FOR UPDATE를 사용하여 user_test 테이블의 모든 행을 id 기준으로 내림차순 정렬하여 잠금.
    • 잠금 상태:
      • 획득한 잠금: 기본 키(PRIMARY) 인덱스의 페이지 4에 대한 레코드 잠금(레코드 번호 1, supremum).
      • 대기 중인 잠금: 기본 키(PRIMARY) 인덱스의 페이지 4에 대한 레코드 잠금(레코드 번호 4, user_id 5).
  2. 트랜잭션 2 (TRANSACTION 6828):
    • 쿼리: INSERT INTO user_test (username) VALUES ('test1')를 사용하여 새로운 행을 삽입하려고 시도 중.
    • 잠금 상태:
      • 획득한 잠금: 기본 키(PRIMARY) 인덱스의 페이지 4에 대한 레코드 잠금(레코드 번호 2, 3, 4).
      • 대기 중인 잠금: 기본 키(PRIMARY) 인덱스의 페이지 4에 대한 삽입 의도 잠금(레코드 번호 1, supremum).

데드락 발생 원인

Backward Index Scan

EXPLAIN
SELECT
    usertest0_.id AS id1_9_,
    usertest0_.username AS username2_9_
FROM
    user_test usertest0_
ORDER BY
    usertest0_.id DESC
    FOR UPDATE;

맨 윗 줄에 EXPLAIN을 추가하면 아래와 같이 쿼리의 실행 계획이 나오게 된다.

Backward Index Scan은 MySQL이 인덱스를 역순으로 스캔하는 방식이다. 인덱스를 역순으로 스캔하여 정렬된 결과를 얻을 수 있는 경우에 사용된다. ORDER BY ID DESC를 실행했으므로, 이러한 스캔 방식으로 진행했음을 알 수 있다.

 

참고로 InnoDB는 명시적으로 인덱스를 만들지 않아도, ID와 같은 기본 키를 Clustered Index로 자동으로 관리한다.

따라서 위에서 언급한 인덱스는 Clustered Index이다.

supremum

위에서 봤던 Log에서 Transaction 1이 쥐고 있는 supremum이란 레코드는 실제 데이터가 아니며, 인덱스 페이지의 끝을 표시하는 역할을 한다.

supremum 레코드는 인덱스에서 실제 존재하는 값들 보다 더 큰 값을 가진다. 이는 인덱스 페이지의 끝을 나타내며, 페이지가 분할될 때 새로운 페이지의 경계를 설정하는 데 사용된다.

supremum은 MySQL의 Next-Key Lock에서 사용될 수 있는데, InnoDB가 인덱스의 끝부분을 잠글 때, Supremum 레코드를 사용하여 그 다음에 올 수 있는 레코드 삽입을 방지한다. 이를 통해 Phantom Read를 방지한다.

 

 

정리하자면 DeadLock의 원인은 Transaction1이 먼저 시작하여 supremum 레코드의 lock을 얻었고, Transaction2가 Insert intention Lock을 획득하기 위해 supremum 레코드의 Lock을 얻으려하며, 서로의 Lock을 기다리게 되는 동작을 했다.

문제 해결: Critical Section 만들기

Class ReentrantLock

ReentrantLock은 Java의 java.util.concurrent.locks 패키지에 속하는 클래스로, 재진입 가능한 Mutual Exclusive Lock을 제공하여 스레드 간의 동기화를 제어하는 데 사용된다. ReentrantLock은 synchronized 키워드와 유사한 기능을 제공하지만, 더 많은 기능과 유연성을 제공한다.
예를 들어, ReentrantLock은 tryLock() 메서드를 제공하여 스레드가 무한정 기다리지 않고 잠금을 시도할 수 있게 한다. 
lockInterruptibly() 메서드는 인터럽트가 가능한 방식으로 잠금을 획득할 수 있게 한다. 이는 대기 중인 스레드가 인터럽트 신호에 응답할 수 있게 한다.
ReentrantLock은 tryLock(long timeout, TimeUnit unit)을 사용하여 스레드가 지정된 시간 동안 잠금을 시도하도록 허용한다.

 

ReentrantLock을 사용하여 각 메서드를 수정하였다.

@Service
public class UserTestService {

    @Autowired
    private UserTestRepository userTestRepository;

    private final Lock lock = new ReentrantLock();

    @Transactional
    public void thread1() {
        System.out.println("[Thread1] Transaction Start");
        lock.lock();
        try {
            UserTest userTest = userTestRepository.findAllByOrderByIdDesc().get(0);
            System.out.println("[Thread1] find userTest = " + userTest.getUsername());

            UserTest newUserTest = new UserTest();
            newUserTest.setUsername("test2");
            userTestRepository.save(newUserTest);
            System.out.println("[Thread1] Save New UserTest = " + newUserTest.getUsername());

            UserTest insertedUserTest = userTestRepository.findAllByOrderByIdDesc().get(0);
            System.out.println("[Thread1] InsertedUserTest Id = " + insertedUserTest.getId() + " username = " + insertedUserTest.getUsername());

            System.out.println("[Thread1] Transaction End");
        } finally {
            lock.unlock();
        }
    }

    @Transactional
    public void thread2() {
        System.out.println("[Thread2] Transaction Start");
        lock.lock();
        try {
            UserTest userTest = userTestRepository.findAllByOrderByIdDesc().get(0);
            System.out.println("[Thread2] UserTest Id = " + userTest.getId() + " username = " + userTest.getUsername());

            System.out.println("[Thread2] Transaction End");
        } finally {
            lock.unlock();
        }
    }

}

findAllByOrderByIdDesc() 메서드를 호출하기 전에 Lock을 획득하도록 추가했다.

결과는 이렇다.

Transaction이 동시에 시작하지만, select 쿼리가 이번엔 동시에 실행되지 않고 따로 실행되고 있다.

따라서 Thread2는 Thread1이 INSERT한 test2를 조회할 수 있게 되었다.

마치며

  • Lock과 동시 요청에 따른 DBMS의 동작을 공부할 수 있었다.
  • DBMS에 동시 문제를 해결하도록 맡기는 것 보다는, Application level에서 처리하도록 하는게 좋을 것 같다.

참조