티스토리 뷰

구실에서 사용할 서비스를 구현하는데, API 를 테스트 해보다가 겪어보지 못한 증상을 겪었습니다. 

Spring boot로(사실 어떤 프레임워크던...) 간단한 서비스만 만들었기 때문에 

멀티쓰레드나 동시성에 대한 생각을 전혀 해본적이 없고 고려할 필요(물론 멀티 쓰레드에서 제대로 돌지 않겠지만) 없는  프로젝트만 했었는데

이번에 만들 서비스는 사용자가 많지 않더래도, 동시성에 대해 대비하지 않으면 데이터베이스가 심각하게 꼬이기에

한 번의 실수로 서비스 자체를 중지시키고 데이터베이스를 되돌려 놓아야 할 상황이었습니다. 

JPA를 사용하고 있기 때문에, JPA 내부적으로 Database 의 Lock 과 어떻게 싱크를 맞추고 협력하는지 직관적으로 알 수 없었고.

JPA Lock 이나 JPA isolation level로 검색해서 여러 정보를 얻을 수 있었습니다만

데이터베이스와 JPA 에 대한 숙련도 둘 다 높지 않기 때문에 데이터베이스 강의때 배운 ACID 나 Isolation Level로는

부족할 것 같다고 생각이 들어서 

JPA에서 동시성을 어떻게 대비하고 처리해야할지 전용 프로젝트를 만들어서 탐구해보고자 합니다. 

최대한 best practice 로 만드려 하겠지만, document 나 stackoverflow 및 서적을 참고해서 문제해결을 하기 때문에.. 

비판적으로 읽어주시고, 틀리게 이해한 점이 있다면 가르쳐주시면 감사합니다 (__)

탐구를 도와줄 프로젝트는, 동시성에 목숨을 걸어야하는 가상 은행시스템입니다.

환경은 아래와 같습니다

Java 12

Spring boot v2.3.3

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

group = 'com.bank'
version = '0.0.1-SNAPSHOT'
sourceCompatibility = '12'

configurations {
    compileOnly {
        extendsFrom annotationProcessor
    }
}

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
    implementation 'org.springframework.boot:spring-boot-starter-jdbc'
    implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
    implementation 'org.springframework.boot:spring-boot-starter-web'
    compileOnly 'org.projectlombok:lombok'
    developmentOnly 'org.springframework.boot:spring-boot-devtools'
    runtimeOnly 'com.h2database:h2'
    runtimeOnly 'mysql:mysql-connector-java'
    annotationProcessor 'org.springframework.boot:spring-boot-configuration-processor'
    annotationProcessor 'org.projectlombok:lombok'
    testCompile('org.springframework.boot:spring-boot-starter-test')
    // https://mvnrepository.com/artifact/org.hamcrest/hamcrest-all

}

test {
    useJUnitPlatform()
}

[build.gradle]

package com.bank;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Getter
@Setter
@Entity(name = "account")
public class Account {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private long accountId;

    private String name;

    @ManyToOne
    private Holder holder;

    private long balance;

    public Account() {}

    public Account(String name) {
        this.name = name;
        this.balance = 0;
    }
}

[Account.java]

Account 는 은행계좌를 의미하는 클래스입니다.

package com.bank;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;

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

[AccountRepository.java]

package com.bank;

import org.springframework.stereotype.Service;

import javax.transaction.Transactional;

@Service
public interface AccountService {

    @Transactional
    public long deposit(long accountId, long amount);
}

[AccountService.java]

package com.bank;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;

import javax.transaction.Transactional;

@Service
@RequiredArgsConstructor
public class AccountServiceImpl implements AccountService{

    private final AccountRepository accountRepository;

    @Transactional
    public long deposit(long accountId, long amount) {
        Account account = accountRepository.findById(accountId).orElseThrow();
        long currBalance = account.getBalance();
        account.setBalance(currBalance + amount);
        accountRepository.save(account);
        return currBalance + amount;
    }
}

[AccountServiceImpl.java]

deposit은 이름에서 알 수 있듯이, accountId에 해당하는 Account에 amount 만큼

입금을 해주는 메소드입니다. 

 

package com.bank;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;
import java.util.List;

@Getter
@Setter
@Entity(name = "holder")
public class Holder {

    @Id @GeneratedValue(strategy=  GenerationType.SEQUENCE)
    private long holderId;

    private String name;

    @OneToMany(mappedBy = "holder")
    private List<Account> accounts;

    public Holder() {}

    public Holder(String name) {
        this.name = name;
    }

}

 

[Holder.java]

예금주를 나타내는 Entity class입니다. 

package com.bank;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.repository.CrudRepository;
import org.springframework.stereotype.Repository;

@Repository
public interface HolderRepository extends JpaRepository<Holder, Long> {
}

[HolderRepository.java]

 

자, 이제 기본적인 뼈대가 준비되었습니다.

이제 유닛테스트를 작성해서 제가 궁금한 것들을 하나씩 해보면서 동시성에 대비하는지

아니면, 멀티 쓰레드 환경에서 Race condition 이 발생해서 문제가 생기는지 확인해보고 

검색 후 알아낸 JPA lock과 isolation 기능들을 이용해서, 세이프한 클래스로 바꾸어 보겠습니다.

package com.bank;

import org.junit.Before;
import org.junit.Test;
import org.junit.jupiter.api.BeforeEach;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.context.junit4.SpringRunner;

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

import static org.assertj.core.api.AssertionsForClassTypes.assertThat;

@RunWith(SpringRunner.class)
@SpringBootTest
public class AccountServiceTest {

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

    @Autowired
    private AccountService accountService;

    @Autowired
    private AccountRepository accountRepository;

    private long accountId;

    @Before
    public void setUp() {
        Account account = new Account("신한 S20");
        account = accountRepository.save(account);
        accountId = account.getAccountId();

    }

    @Test
    public void SimultaneousDepositPassWithNoRaceCondition() throws InterruptedException {
        CountDownLatch latch = new CountDownLatch(100);
        for (int i=0; i < 100; i++) {
            service.execute(() -> {
                accountService.deposit(accountId, 10);
                latch.countDown();
            });
        }
        latch.await();
        Account richAccount = accountRepository.findById(accountId).orElseThrow();
        assertThat(richAccount.getBalance()).isEqualTo(10 * 100);

    }

}

 

아주 간단한 유닛 테스트 클래스입니다. SimultaneousDepositPassWithNoRaceCondition은 아래와 같이 작동합니다.

일단, 클래스에 static 으로 Fixed Threadpool을 만들어 두어서, 최대한 쓰레드 생성시간을 줄여서 Race Condition이

생길 확률이 높아질 수 있도록 했습니다.

메소드 내부에서는 CountDownLatch로 100번의 deposit 메소드 호출을 해서 10원을 100번 입금했습니다.

그리고 나서 부자가 된 계좌 richAccount 를 다시 얻어내서 정말 최종적으로 1000원이 들어있는지 확인해봅니다.

저는, 이 테스트가 반드시 실패할거라고 예상해봅니다.

보기 좋게 3번의 시도 다 다른 값이 얻어짐으로써 실패했습니다. 

deposit 메소드의 구현을 보면서 왜 실패했는지 되새겨보겠습니다.

@Transactional
    public long deposit(long accountId, long amount) {
        Account account = accountRepository.findById(accountId).orElseThrow();
        long currBalance = account.getBalance();
        account.setBalance(currBalance + amount);
        accountRepository.save(account);
        return currBalance + amount;
    }

직관적으로 봤을 때도 문제가 생김을 추측할 수 있습니다. 

어느 시점에 들어온 쓰레드 A가 account.setBalance(currBalance + amount); 를 실행시켜서, 실제 잔고가 150원 이었어야 했다고 칩시다.

이 시점에 A에게는 150원이 되어야겠지만, 아직 commit 이 되지 않은 상태이기 때문에, 쓰레드 A 다음에 들어온

쓰레드 B는 Account account = accountRepository.findById(accountId).orElseThrow(); 로 얻은 account 에서 

140원이 가지고 있다고 보게 됩니다. 

그러면 당연히 B도 최종적으로 잔고를 150으로 수정하겠죠? 이런 Race condition 이 반복적으로 일어나서

1000원이 되었어야 할 잔고는 200원을 한 번도 넘지 못하게 됩니다. 은행은 당연히 망하겠죠..

1. JPA lock 을 사용

JPA 는 lock 이 있고, Optimistic lock 과 Pessimistic lock 이 있습니다.

 

Optimistic Locking 

데이터를 읽을 때 버전 넘버를 써두었다가 그 버전이 쓰는 시점에 바뀌지 않았음을 확인하는 방식입니다.

하지만 꼭 버전 넘버를 통해서만 하는 것이 아니라, 데이터베이스 스키마에 버전 컬럼을 추가하기 어려운 경우

타임스탬프나 로우의 전체 상태를 가지고서 확인하는 방식도 사용할 수 있습니다.

즉, race condition 이 발생하지 않을 것 이라고 생각하는 방식의 locking 입니다. 

따라서 잠근다는 의미보다는 충돌이 났을 때 이를 감지한다는 의미가 더 큽니다.

또한, 어플리케이션이 쓰기보다 읽기 작업이 많은 경우에 적합한 locking strategy 입니다.

충돌이 많이 나지 않을 것 같은 상황에 사용하기 적합합니다.

 

Pessimistic Locking

race condition 이 발생한다는 것을 전제로 두고 아예 잠궈버리는 방식입니다.

optimistic locking 보다 데이터 정합성에서 더 확실하지만, 데드락에 빠지지 않도록 신경써주어야 합니다.

 

1-1. PESSIMISTIC_FORCE_INCREMENT 를 사용하기

Account 클래스에 version column 을 추가해줍니다.

package com.bank;

import lombok.Getter;
import lombok.Setter;

import javax.persistence.*;

@Getter
@Setter
@Entity(name = "account")
public class Account {

    @Id
    @GeneratedValue(strategy = GenerationType.AUTO)
    private long accountId;
    
    private String name;

    @ManyToOne
    private Holder holder;

    private long balance;

    public Account() {}

    public Account(String name) {
        this.name = name;
        this.balance = 0;
    }
}

AccountRepository 를 아래와 같이 바꿉니다.

package com.bank;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.stereotype.Repository;

import javax.persistence.LockModeType;

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

    @Lock(LockModeType.PESSIMISTIC_FORCE_INCREMENT)
    public Account findByAccountId(long accountId);

}

AccountServiceImpl 은 아래와 같이 바꿉니다.

@Service
@RequiredArgsConstructor
public class AccountServiceImpl implements AccountService {

    private final AccountRepository accountRepository;

    @Transactional
    public void deposit(long accountId, long amount) {
        Account account = accountRepository.findByAccountId(accountId);
        long currBalance = account.getBalance();
        System.out.println("thread = " + Thread.currentThread().getName() + ", " + "currBalance = " + currBalance);
        account.setBalance(currBalance + amount);
        System.out.println("thread = " + Thread.currentThread().getName() + ", " + "currBalance = " + (currBalance + amount));
        accountRepository.save(account);
    }

    @Transactional
    public void withdraw(long accountId, long amount) {
        Account account = accountRepository.findByAccountId(accountId);
        long currBalance = account.getBalance();
        System.out.println("감소thread = " + Thread.currentThread().getName() + ", " + "currBalance = " + currBalance);
        if (currBalance - amount < 0) {
            throw new IllegalArgumentException("잔액이 부족합니다");
        }
        account.setBalance(currBalance - amount);
        System.out.println("감소thread = " + Thread.currentThread().getName() + ", " + "currBalance = " + (currBalance - amount));
        accountRepository.save(account);
    }
 }

 withdraw 는 기능을 추가하기 위한 메소드이므로 일단 추가하였습니다. 우선 deposit 이 어떻게 바뀐지 보시면

findByAccountId 는 pessimistic lock 을 걸어놓은 repository method 입니다.

test code는 아래와 같습니다.

@Test
    public void SimultaneousDepositPassWithNoRaceCondition() throws InterruptedException {
        Account account = new Account("신한 S20");
        account.setBalance(1000);
        account = accountRepository.save(account);
        long accountId = account.getAccountId();

        CountDownLatch latch = new CountDownLatch(100);
        for (int i=0; i < 100; i++) {
            service.execute(() -> {
                try {
                    accountService.deposit(accountId, 10);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                latch.countDown();
            });
        }
        latch.await();
        Account richAccount = accountRepository.findById(accountId).orElseThrow();
        assertThat(richAccount.getBalance()).isEqualTo(1000 + 10 * 100);
    }

test 의 실행결과는 아래와 같이 성공한 것을 볼 수 있습니다.

PESSIMISTIC_WRITE 가 있으니 당연히 PESSIMISTIC_READ 도 있습니다.

PESSIMISTIC_READ

lock 을 취득하면 dirty read 나 non-repeatable read 이 불가능하게 됩니다. 

만약 data를 바꿀 수 있어야 한다면, PESSIMISTIC_WRITE를 사용해주어야 합니다. 

PESSIMISTIC_WIRTE 

추가적인 lock 의 취득이 필요없이 데이터를 업데이트 할 수 있으며, dirty read와 non-repeatable read가 

불가능하다는 것을 보장해줍니다.

 

제 코드는 입금 요청을 여러 쓰레드가 접근해서 바꿀 수 있게(하지만 동시에는 아닌) 되길 바라므로 

PESSIMISTIC_WRITE 를 사용하였습니다.

위와 같이 테스트가 성공한 것을 볼 수 있습니다.

 

------------------------------------------------------------------------------------------------------------------------

참고자료

[1] Java ORM 표준 JPA 프로그래밍 - 김영한 저

 

vladmihalcea.com/hibernate-locking-patterns-how-do-pessimistic_read-and-pessimistic_write-work/

 

How do LockModeType.PESSIMISTIC_READ and LockModeType.PESSIMISTIC_WRITE work in JPA and Hibernate - Vlad Mihalcea

Learn how the PESSIMISTIC_READ and PESSIMISTIC_WRITE JPA LockModeType strategies acquire read or write locks when using Hibernate.

vladmihalcea.com

reiphiel.tistory.com/entry/understanding-jpa-lock

 

JPA 잠금(Lock) 이해하기

JPA(Hibernate:하이버네이트)에 의한 잠금(Lock:락) 사용중에 생각하고 있던바와 동작이 좀 다른 부분이 있어서 전반적으로 정리해 보았습니다. 잠금(Lock)의 종류 낙관적 잠금(Optimisstic Lock) 낙관적 잠�

reiphiel.tistory.com

medium.com/@recepinancc/til-9-optimistic-vs-pessimistic-locking-79a349b76dc8

 

TIL-9: Optimistic vs. Pessimistic Locking

“Today I learned the two types of locking, Optimistic and Pessimistic and the differences between them.”

medium.com

 

댓글