트랜잭션이 중첩되게 실행되어 문제가 발생하는 상황을 막기 위해 Lock 을 사용할 수 있습니다. 하나의 트랜잭션이 데이터 변경하는 동안, 다른 트랜잭션이 변경하려는 데이터에 접근할 수 없도록 합니다. 업데이트 시작시 Lock 을 흭득하고 업데이트가 모두 끝나면 그때서야 비로서 Lock 을 해제하여 다른 트랜잭션이 해당 데이터에 접근할 수 있도록 하는 방식입니다.
이렇게 동시에 작동하는 다중 트랜잭션의 상호 간섭 작용에서 테이터베이스를 보호해야 원하는 데이터를 얻을 수 있는데, 이를 동시성 제어(Concurrency Control) 이라고 합니다.
이런 동시성 제어 기법에는 크게 비관적 동시성 제어와 낙관적 동시성 제어가 있습니다.
말그대로 상황을 비관적으로 보고 동시성을 제어한다는 것입니다. 즉, 사용자들이 같은 데이터를 동시에 수정할 것이라는 비관적 상황을 가정하고 동시성을 제어합니다. 그렇기 때문에 데이터를 읽는 시점에 Lock 을 걸고 트랜잭션이 완료될 때 까지 이를 유지하는 방식입니다.
비관적 락을 사용하도록 코드를 수정한 모습입니다. 강제로 flush 할 필요가 없고 dirty checking 을 통해 업데이트가 되도록 @Transational 어노테이션을 다시 추가하였습니다.
@Transactional
public void request(AuthenticatedUser authUser, MentorRequestDto mentorRequestDto) {
MentorInfo mentorInfo = findMentorInfo(mentorRequestDto); // 해당 로우에 락을 겁니다.
MentorRequest mentorRequest = MentorRequest.create(authUser, mentorRequestDto);
saveMentorRequest(mentorRequest);
mentorInfo.increaseMentee();
}
public interface MentorInfoRepository extends JpaRepository<MentorInfo, Long> {
@Lock(LockModeType.PESSIMISTIC_WRITE) // 데이터베이스에 쓰기 락을 겁니다.(해당되는 로우에 건다.)
@Query("select m from MentorInfo m where m.userId = :userId")
MentorInfo findMentorInfoByUserId(long userId);
}
테스트를 돌려보면?

통과하였습니다. 실제 로그를 보면 select .. for update 쿼리가 나간 것을 알 수 있습니다. for update 절은 쓰기 잠금(배타 잠금, Exclusive lock) 을 설정합니다. 그렇기 때문에 다른 트랜잭션에서는 해당 레코드를 변경하려고 하는 쿼리는 실행할 수 없습니다.
무슨 일이 일어난 걸까? 같은 레코드를 수정하려고 하는 두 개의 트랜잭션이 있다고 가정했을 때, 비관적 락(정확히 말하면 쓰기 잠금)을 사용했을 경우를 표로 나타나면 아래와 같습니다.
핵심이 되는 부분은 10:02분에 발생하는 세션-2의 Transation 내 Operation(select for update) 입니다. 세션-2는 먼저 트랜잭션을 시작했음에도 불구하고 해당 레코드에 대해서 세션-1이 먼저 락을 걸었기 때문에 락을 흭득할 때까지 잠금을 기다립니다.
| 시간 | 세션-1 | 세션-2 |
|---|---|---|
| 10:00 | BEGIN; | |
| 10:01 | SELECT * FROM MENTORE_INFO WHERE user_id = 1 FOR UPDATE; | BEGIN; |
| 10:02 | SELECT * FROM MENTORE_INFO WHERE user_id = 1 FOR UPDATE; => 세션-1의 잠금을 기다립니다 | |
| 10:03 | COMMIT; | |
| 10:04 | => SELECT 쿼리 결과 반환 |
참고로 InnoDB 스토리지 엔진을 사용하는 테이블에서는 잠금 없는 읽기가 지원됩니다. 따라서 특정 레코드가
SELCT FOR ... UPDATE쿼리에 의해서 잠겨진 상태라 하더라도 FOR SHARE 나 FOR UPDATE 절을 가지지 않은 단순 SELECT 쿼리는 아무런 대기 없이 실행됩니다.
여러대의 서버에서 동시에 멘토 요청이 들어온다고 했을 때의 모습니다.
동시에 여러대의 서버에서 요청이 들어온다고 가정 했을 때, 먼저 락을 획득한 서버에 대해서만 쓰기를 허용할 수 있도록 합니다. 그리고 나머지 서버들은 락을 획득할 때 까지 대기하다가 락을 획득 하면 비로소 데이터에 대한 접근을 하게 됩니다. 이를 통해 데이터의 정합성을 보장할 수 있게 되었습니다.
하지만 비관적 락이 장점만 있는 것은 아닙니다. 그림에서 볼 수 있듯이 Lock 을 흭득하지 못한 트랜잭션의 경우에는 잠금을 흭득할 때까지 기다려야 합니다. 그렇기 때문에 많은 사용자가 몰릴 경우 계속해서 대기를 해야하는 상황이 있을 수 있습니다.
참고로
NOWAIT과SKIP LOCKED옵션이 MySQL 8.0 버전부터 추가되었습니다. 이는 다른 트랜잭션이 변경하고자 하는 레코드를 잠그고 있다면, 잠금이 해제될 때 까지 기다리는 것이 아니라 에러를 반환하면서 쿼리를 중지시킬 수 있도록 합니다.