동시성을 잡아보자 (Lock, DeadLock)

Panda·2024년 1월 9일
1

Database

목록 보기
6/6

실무를 접하기 전에는
동시성, 데드락? 그냥 하나의 이론으로 넘어갔었는데.....
막상 실무에서는 동시성 문제가 매우 흔하게 발생하더라고요..........

따라서 동시성 이슈를 해결하기 위해 생존을 위한 공부를 해보았습니다.

잠금

스프링에서 멀티 스레드를 위한 synchronized 라는 키워드가 있지만
실제 서버는 단일 서버에서만 도는것이 아닐뿐더러 @Transactional 로 인한 프록시 문제도 존재합니다.

따라서 데이터베이스 잠금으로 동시성 이슈를 해결하는 법을 알아보도록 하겠습니다.

잠금이란?

여러 사용자가 동시에 데이터베이스에 접근할 때 데이터 무결성을 보장하기 위해 트랜잭션의 순차적 진행을 보장할 수 있는 직렬화 장치

동시성 이슈를 해결하기 전에 잠금 종류가 어떤게 있는지 알아보겠습니다.

잠금 종류

공유잠금 (Shared lock)

읽기 잠금이라고도 불립니다. 그 이유는
어떤 트랜잭션에서 데이터를 읽고자 할 때 다른 공유 잠금은 허용이 되지만 배타적 잠금은 불가능합니다.
즉 여러 사용자로 부터 조회는 가능하지만 변경에 대한 접근은 막는 잠금입니다.

READ ONLY 잠금

배타적 잠금 (Exclusive lock)

어떤 트랜잭션에서 데이터를 변경 또는 작성할 때 해당 트랜잭션이 완료(commit)될 때까지 해당 테이블 or 데이터(row)를 다른 트랜잭션에서 읽거나 쓰지 못하게 하기 위해 배타적 잠금을 겁니다.
배타적 잠금에 걸리게 되면 다른 트랜잭션에서는 해당 데이터에 대해 조회, 변경 접근을 못하게 됩니다.

----------- Caution -----------
이때 매우 매우 주의해야 될 점은 잠금의 범위가 테이블로도 잡히기 때문에
배타적 잠금을 의도적으로 걸게 되면 잠금 범위를 잘 설정해줘야 합니다.

테이블 잠금 걸리는 순간 진짜 되는 수가 있어요.

낙관적 잠금 (Optimistic Lock)

낙관적 잠금은 자원 경쟁을 긍정적인 관점으로 바라보기 때문에, 다중 트랜잭션이 데이터를 동시에 변경하지 않는다고 가정합니다.
따라서 데이터를 읽을 때는 잠금을 설정하지 않습니다. 하지만 다중 트랜잭션에 의해 잘못된 갱신을 방지하지 않기 때문에 데이터를 변경하는 접근에서만 앞에 읽은 데이터가 다른 트랜잭션에 의해 변경되었는지 검사해야합니다.

데이터 버전 정보를 통해서 변경 감지를 할 수 있는 기법입니다.

  • 스프링 JPA에서는 @Version 어노테이션으로 낙관적 잠금을 지원

비관적 잠금 (Pessimistic Lock)

비관적 잠금은 자원 경쟁을 비관적으로 관점으로 보기 때문에, 다중 트랜잭션이 데이터를 동시에 변경할 것이라고 가정합니다.
따라서 하나의 트랜잭션이 데이터를 읽는 시점에서 잠금을 걸고, 조회 또는 갱신 처리가 완료(commit)될 때까지 유지합니다. 조회 때 잠금을 획득하는 대표적인 쿼리는 SELECT ~~ FOR UPDATE 입니다.
SELECT ~~ FOR UPDATE 로 조회된 해당 데이터는(row) 잠금을 획득하여 해당 트랜잭션이 끝나기 전까지 다른 트랜잭션이 접근하지 못하고 대기해야 합니다.

트랜잭션의 동시성 접근을 방지할 수 있습니다.
다만 잘못 사용하게 될 경우 데드락이 발생된다던지, 해당 트랜잭션이 처리시간이 길어져 성능이 떨어질 수 있기 때문에 비관적 잠금을 사용하게 된다면 신경을 써서 처리를 해야합니다.

데드락

데드락은 여러개의 트랜잭션들이 실행을 하지 못하고 서로 무한정 기다리는 상태를 의미

위에서 설명한 잠금들로 인하여 데드락이 발생할 수 있습니다.

예제)
Step 1
트랜
잭션 1 : A 테이블 Insert 쿼리
트랜잭션 2 : B 테이블 Insert 쿼리

Step 2
트랜잭션 1 : B 테이블 Insert 쿼리
트랜잭션 2 : A 테이블 Insert 쿼리

위와 같은 상황일 때 두 트랜잭션 모두 Commit 하기 전에 잠금이 걸려있는 테이블에 요청하기 때문에
무한정으로 잠금이 풀릴 때 까지 기다리는 데드락이 발생합니다.
(테이블에 데드락걸리면 진짜 끔찍..............)

데드락 상태를 회피할 수 있는 2가지 방법을 알아보았습니다.

Wait-Die

다른 트랜잭션이 데이터를 점유하고 있을 때 기다리거나(Wait) 포기(Die)하는 방식
TimeStamp 기준

  • T1 < T2 < T3

이때 T2 보다 낮은 T1이 접근을 하기때문에 Wait 하고
T2보다 높은 T3가 접근을하게 되면 Die(포기) 합니다.

Wound-Die


다른 트랜잭션이 데이터를 점유하고 있을 때 빼앗거나(Wound) 기다리는(Wait) 방식
TimeStamp 기준

  • T1 < T2 < T3

이때 T2보다 낮은 T1이 접근을 하면 T2를 롤백시키고 T1이 잠금을 얻습니다.
T2보다 높은 T3가 접근을하게 되면 Wait 합니다.

동시성 해결

잠금, 데드락을 공부해보았는데요
결국 동시성 문제를 해결하기 위해 공부를 하였습니다.
과연 동시성은 어떻게 해결할 수 있을까요?

크게 낙관적 잠금, 비관적 잠금 처리로 동시성 문제를 해결할 수 있습니다.
다만 낙관적 잠금에 경우 버전 정보 비교 로직 필요, 동시성 충돌 시 추가작업 등등이 필요하기 때문에
비관적 잠금으로 동시성 이슈를 해결해보려고 합니다.

스프링 JPA 비관적 잠금으로 설명하겠습니다.

public interface UserRepository extends JpaRepository<UserEntity, Long> {
    // 비관적 잠금 설정
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("select u from UserEntity u where u.id = :id")
    Optional<UserEntity> findByIdForUpdate(@Param("id") Long id);
}

// 하루에 5번만 행운을 받을 수 있음
@Transactional
public void userTodayLuck(long userId) {
    UserEntity user = userRepository.findByIdForUpdate(userId)
        .orElseThrow(() -> new IllegalArgumentException("User Not Found."));
    user.increaseTodayLuckCount();
    luckyItemRepository.save(new LuckyItem(user));
}

발생 쿼리

select
 u1.id
 u1.todayLuckCount
from
 user u1
where
 u1.id = ?
for update // 해당 쿼리 부분때문에 비관적 잠금 발생

굳이 JPA가 아니더라도 Mybatis 또는 QueryDSL 로도 select ~ for update 쿼리를 작성하여 비관적 잠금을 할 수 있습니다.

다만 비관적 잠금은 단일 DB에만 적용이 가능하기 때문에 분산 DB인 환경에서는 다른 방법을 찾아봐야 할 것 같습니다.

코드상으로는 되게 간단하게 동시성 문제를 해결했는데
잠금, 데드락 개념에 대해서 자세히 공부했던 이유는 잠금을 잘 몰라 잠금의 범위가 테이블 단위로 잡힌다던지 잠금의 성능적 이슈라던지 등 진짜 심각한 상황으로 이어질 수 있기때문에......... 공부를 제대로 하였습니다.

또한 비관적 잠금을 설정할 때는 성능적인 이슈로 이어질 수 있기때문에
동시에 발생하는 요청이 많은지?, Commit 시간이 짧은지 등등 에 대해 잘 생각해야 될 것 같습니다.

느낀 점

이런 기초적인 내용들을 이번에서야 제대로 공부했다는게 아쉽긴하네요 ㅠㅠㅠ
하지만 제대로 이해했으니 앞으로 동시성 이슈가 발생할 때마다 어떤식으로 해결해야 할 지 습득해서 다행입니다.

생각외로 데이터베이스 공부도 재밌을지도???

참고

profile
실력있는 개발자가 되보자!

0개의 댓글