[트랜잭션] 동시성 문제 - 비관적 잠금

박상범·2022년 4월 29일
1

트랜잭션

목록 보기
1/1
post-thumbnail

트랜잭션 동시성 문제 중 손실되는 업데이트의 해결 방식 중 하나인 비관적 잠금에 대한 포스팅입니다.

동시성 테스트를 위해 간단한 계좌 입금 시스템을 만들어 테스트 하였습니다.

프로젝트 세팅

plugins {
    id 'org.springframework.boot' version '2.6.7'
    id 'io.spring.dependency-management' version '1.0.11.RELEASE'
    id 'java'
}

group = 'com.account'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '11'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    compileOnly 'org.projectlombok:lombok'
    runtimeOnly 'com.h2database:h2'
    annotationProcessor 'org.projectlombok:lombok'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

tasks.named('test') {
    useJUnitPlatform()
}

클래스 설계

Account

@Getter
@NoArgsConstructor(access = PROTECTED)
@Entity
public class Account {

    @Id
    @GeneratedValue
    private Long id;

    private String name;

    private long balance = 0;

    public static Account of(String name) {
        Account account = new Account();
        account.name = name;
        return account;
    }

    public void addBalance(long amount) {
        if (amount <= 0) throw new IllegalArgumentException("0원 이하는 입금 불가능합니다.");
        this.balance += amount;
    }

}
  • 은행 계좌에 해당하는 클래스

AccountUpdateService

@Service
@RequiredArgsConstructor
public class AccountUpdateService {

    private final AccountRepository accountRepository;

    @Transactional
    public long deposit(long accountId, long amount) {
        Account account = accountRepository.findById(accountId)
                .orElseThrow(() -> new IllegalArgumentException(accountId + "는 존재하지 않는 계좌입니다."));
        
        account.addBalance(amount); // 계좌 입금 처리

        return account.getBalance();
    }
}
  • 계좌 수정 관련한 서비스 클래스
  • deposit 메소드를 통해서 accountId에 amount 만큼 입금을 해주는 메소드

AccountRepository

@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {
}

AccountUpdateService

@SpringBootTest
@AutoConfigureMockMvc
class AccountUpdateServiceTest {

    private static final ExecutorService service = Executors.newFixedThreadPool(100);

    @Autowired AccountUpdateService accountUpdateService;

    @Autowired AccountRepository accountRepository;

    private long accountId;

    @BeforeEach
    void beforeAll() {
        Account account = Account.of("우리");
        accountRepository.save(account);
        accountId = account.getId();
    }

    @Test
    @DisplayName("입금 테스트")
    void depositSimpleTest() {
        // given
        int amount = 10;

        // when
        accountUpdateService.deposit(accountId, amount);
        Account findAccount = accountRepository.findById(accountId)
                .orElseThrow(IllegalArgumentException::new);

        // then
        assertThat(findAccount.getBalance()).isEqualTo(amount);
    }

    @Test
    @DisplayName("입금 멀티 스레드 race condition 테스트")
    void depositMultiThreadRaceConditionTest() throws InterruptedException {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        // given
        int count = 100;

        // then
        CountDownLatch latch = new CountDownLatch(count);
        for (int i = 0; i < count; i++) {
            service.execute(() -> {
                accountUpdateService.deposit(accountId, 10);
                latch.countDown();
            });
        }
        latch.await();

        // when
        Account findAccount = accountRepository.findById(accountId)
                .orElseThrow(IllegalArgumentException::new);

        stopWatch.stop();
        System.out.println(stopWatch.prettyPrint());

        assertThat(findAccount.getBalance()).isEqualTo(10 * 100);

    }

}

손실되는 업데이트

비관적 잠금

이미지 출처: http://jaynewho.com/post/43

  • 데이터베이스가 제공하는 lock 기능을 이용해 엔티티를 영속 상태로 올릴 때부터 다른 세션에서 조회하지 못하도록 잠금 처리합니다.
  • 활동성은 저하되지만 정확성과 세션의 성공은 보장됩니다.

테스트

@SpringBootTest
@AutoConfigureMockMvc
class AccountUpdateServiceTest {

    private static final ExecutorService service = Executors.newFixedThreadPool(100);

    @Autowired AccountUpdateService accountUpdateService;

    @Autowired AccountRepository accountRepository;

    private long accountId;

    @BeforeEach
    void beforeAll() {
        Account account = Account.of("우리");
        accountRepository.save(account);
        accountId = account.getId();
    }

    @Test
    @DisplayName("입금 멀티 스레드 race condition 테스트")
    void depositMultiThreadRaceConditionTest() throws InterruptedException {
        StopWatch stopWatch = new StopWatch();
        stopWatch.start();

        // given
        int count = 100;

        // then
        CountDownLatch latch = new CountDownLatch(count);
        for (int i = 0; i < count; i++) {
            service.execute(() -> {
                accountUpdateService.deposit(accountId, 10);
                latch.countDown();
            });
        }
        latch.await();

        // when
        Account findAccount = accountRepository.findById(accountId)
                .orElseThrow(IllegalArgumentException::new);

        stopWatch.stop();
        System.out.println(stopWatch.prettyPrint());

        assertThat(findAccount.getBalance()).isEqualTo(10 * 100);

    }

}

ExecutorService를 통해 총 100개의 스레드를 생성

총 100번의 스레드가 10원을 입금 하기 때문에 총 1000이 나올 것이라 예상하였다.

결과

하지만 예상과 달리 150원만이 입금 된 것이 확인된다.

입금 내역이 업데이트 되기 전 데이터를 읽어와 일어난 현상이다.

이제 이를 비관적 잠금으로 해결해보자.

AccountRepository 변경

@Repository
public interface AccountRepository extends JpaRepository<Account, Long> {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    Optional<Account> findById(Long id);
}

JPA에서는 @Lock 어노테이션을 제공하여 비관적 잠금을 사용할 수 있도록 지원해준다.

  • 옵션 (LockModeType)

    • LockModeType.PESSIMITIC_WRITE
      • default 옵션
      • 다른 트랜잭션에서 read, write 불가 (배타적 잠금)
    • LockModeType.PESSIMISTIC_READ
      • 반복 읽기만하고 수정하지 않은 용도로 사용
      • 다른 트랜잭션에서 읽기는 가능 (공유 잠금)
    • LockModeType.PESSIMISTIC_FORCE_INCREMENT
      • Version 정보를 사용하는 비관적 락
  • 결과

    테스트는 이상없이 통과하는 것이 확인된다.

    그러나 Lock을 사용하기 전보다 실행시간이 늘어난 것을 확인할 수 있다.

  • 콘솔창

    그러면 무엇이 변경되었길래 멀티 스레드 환경에서 정합성을 맞출 수 있었던 걸까

    그것은 쿼리 콘솔을 보면 쉽게 해결 가능하다.

    Hibernate: 
        select
            account0_.id as id1_0_0_,
            account0_.balance as balance2_0_0_,
            account0_.name as name3_0_0_ 
        from
            account account0_ 
        where
            account0_.id=? for update
                
    Hibernate: 
        update
            account 
        set
            balance=?,
            name=? 
        where
            id=?

    select ~ for update 이전과 달리 for update 구문이 추가된 것이 보인다.

    이는 데이터 베이스에서 제공하는 구문으로써 특정 row에 배타적 LOCK을 거는 행위이다.

깃허브

https://github.com/bum12ark/transaction-account

레퍼런스

profile
배는 항구에 있을 때 가장 안전하다. 그러나 그것이 배의 존재의 이유는 아니다.

0개의 댓글