코딩하는 오징어

Persistence Context와 Transaction Isolation 본문

Framework/JPA

Persistence Context와 Transaction Isolation

코딩하는 오징어 2020. 12. 12. 20:10
반응형

JPA는 잘 알고 사용하면 굉장히 좋은 framework이지만 잘 모르고 사용한다면 여러가지 문제가 발생하는 어려운 친구이다. 여기서 다루는 문제외에도 N+1문제등 몇 가지 주의해야할 부분들이 있지만 해당 내용들은 다른 글에서 다루어 보겠다. 이번 글에서는 영속성 컨텍스트의 동작 방식에 의해 트랜잭션의 격리 수준이 정상적으로 동작하지 않는 것 처럼 보이는 것에 대해 공유하려한다. 먼저 트랜잭션 격리 수준에 대해 잘 모르겠다면 다음 글을 한번 보고 나서 해당 글을 읽으면 도움이 될 것 같다.

 

2018/03/09 - [Database/MySQL] - 데이터베이스 Transaction Isolation Level

 

데이터베이스 Isolation Level

오늘은 데이터베이스의 isolation level이 무엇인지 왜 필요한지에대해 알아 보겠습니다. 먼저 isolation level이란 트랜잭션에서 일관성이 없는 데이터를 허용하도록 하는 수준을 의미합니다. 0" style="

effectivesquid.tistory.com

먼저 Entity가 영속성 컨텍스트에서 어떻게 관리되는지는 다른 글이나 책을 통해 학습하였다는 전제하에 글을 써내려 가겠다.

 

영속성 컨텍스트의 1차 캐싱이라는 장점을 얻기 위해서는 Entity를 조회할 때 @Id로 설정한 식별자로 조회해야한다. 그외에 조건절을 JPQL을 이용하여 조회한다면 영속성 컨텍스트를 거치지않고 일단 DB로 직접 조회한다. DB에서 가져온 Entity가 이미 영속성 컨텍스트가 존재한다면 DB에서 조회한 값을 버리고 영속성 컨텍스트에 존재하는 Entity를 반환한다. 이러한 동작이 트랜잭션의 격리 수준을 개발자가 제어할 때 문제가된다. 먼저 다음 코드를 보자.

@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Getter
@EqualsAndHashCode(onlyExplicitlyIncluded = true)
@Entity
@Table(name = "accounts")
public class Account {

    public static int ACCOUNT_NUMBER_SIZE = 14;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @EqualsAndHashCode.Include
    private Long id;
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "user_id", foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT))
    private User user;
    private String number;
    private BigDecimal balance;

    public Account(User user, String number, BigDecimal balance) {
        this.user = user;
        this.number = number;
        this.balance = balance;
    }

    public void withdraw(BigDecimal money) {
        this.balance = this.balance.subtract(money);
    }
}

@RequiredArgsConstructor
@Service
public class AccountService {

    private final AccountRepository repository;
    @PersistenceContext
    private EntityManager entityManager;

    @Transactional(isolation = Isolation.READ_COMMITTED)
    public Account findTwiceByAccountNumber(String accountNumber) {
        System.out.println(">>> findTwiceByAccountNumber");
        Account account = repository.findByNumber(accountNumber)
                .orElseThrow(RuntimeException::new);

        System.out.println(">>> account number: " + account.getNumber() + " balance: " + account.getBalance());

        ThreadUtils.sleep(5000);

        // entityManager.clear(); // 주석 해제후 실행하면 원하는 동작을 하게됨.
        // db를 조회 후 영속성 컨텍스트에 해당 id를 가지는 Account가 존재하므로 db에서 조회한 값은 버리고 영속성 컨텍스트에 있는 entity를 사용
        Account afterAccount = repository.findByNumber(accountNumber)
                .orElseThrow(RuntimeException::new);
        System.out.println(">>> after account number: " + afterAccount.getNumber() + " balance: " + afterAccount.getBalance());

        return afterAccount;
    }

    @Transactional(isolation = Isolation.READ_COMMITTED)
    public Account withdraw(String accountNumber, BigDecimal money) {
        System.out.println(">>> withdraw");
        Account account = repository.findByNumber(accountNumber)
                .orElseThrow(RuntimeException::new);

        account.withdraw(money);
        return repository.save(account);
    }
}

위의 코드를 설명하자면 다음과 같다.

- findTwiceByAccountNumber 함수는 Account를 조회 후 5초 간의 sleep을 호출한 이후에 같은 accountNumber로 다시 Account를 조회한다.

- withdraw 함수는 Account를 조회한 후 money만큼 balance를 인출한 후 저장합니다.

- 두 함수는 모두 READ_COMMITTED로 격리수준을 설정하였다.

 

다음 상황일 때 findTwiceByAccountNumber에서 5초 간의 sleep을 호출한 이후에 출력되는 balance 값은 무엇일까??? 

 

1. 인출하기 전 Account의 balance값은 5000000.00 이다.

2. findTwiceByAccountNumber가 호출되고 sleep을 통해 5초 동안 block이 되어있는 중간에 다른 thread에서 withdraw함수가 호출되어 100000.00을 인출한다. (commit까지 완료되었다.)

3. 5초가 지나 findTwiceByAccountNumber를 호출한 thread가 깨어나 이후 코드인 repository.findByNumer(accountNumber)를 수행한다.

4. System.out.println(">>> after account number: " + afterAccount.getNumber() + " balance: " + afterAccount.getBalance());를 실행한다.

 

위의 4번을 수행 결과 출력되는 값은 어떻게 될까?? 독자들도 한번 생각해본 후 출력된 결과를 확인해보자. 필자는 이전의 설명한 내용을 알고 있었음에도 Transaction의 격리 수준을 READ_COMMITED로 설정한 것에 몰입되어 balance 값으로 4900000.00이 출력될 것 이라고 예상했다. 하지만 결과는 다음과 같았다.

>>> after account number 출력값을 보면 balance 값이 5000000.00으로 나온다. 이게 무슨일인가 우리의 돈이 인출되지 않았다. 분명 트랜잭션 격리 수준을 READ_COMMITTED 설정 하였다. 따라서 withdraw가 commit한 결과가 반영되어야 하는데 반영되지 않았다. 위의 JQPL의 동작방식을 다시한번 되짚어 보자.

 

영속성 컨텍스트의 1차 캐싱이라는 장점을 얻기 위해서는 Entity를 조회할 때 @Id로 설정한 식별자로 조회해야한다. 그외에 조건절을 JPQL을 이용하여 조회한다면 영속성 컨텍스트를 거치지않고 일단 DB로 직접 조회한다. DB에서 가져온 Entity가 이미 영속성 컨텍스트가 존재한다면 DB에서 조회한 값을 버리고 영속성 컨텍스트에 존재하는 Entity를 반환한다.

 

위의 설명을 다시 읽고 아차 싶다면 이 글을 더 이상 읽지 않아도된다. AccountRepository의 코드를 보자.

public interface AccountRepository extends JpaRepository<Account, Long> {

    Optional<Account> findByNumber(String number);
}

spring data jpa의 힘을 빌려 메서드명 convention으로 쿼리를 생성한다. 이 때 해당 쿼리는 JPQL로 generate되며 hibernate dialect 설정에 따라 실제 DB로 조회할때 mysql 쿼리로 변경된다. 해당 쿼리는 id로 조회하는 것이 아니기 때문에 영속성 컨텍스트를 확인하지 않고 일단 DB에 질의한다. 그러고 나서 영속성 컨텍스트에 조회한 Entity의 식별자 값을 가지는 Entity가 있는지 확인한다. 영속성 컨텍스트에 존재한다면 DB에서 조회한 Entity를 버리고 영속성 컨텍스트에 있는 Entity를 반환하게 된다. 트랜잭션의 격리 수준을 READ_COMMITED로 설정해도 REPEATABLE_READ처럼 동작하는 이유이다. 해당 트랜잭션은 READ_COMMITED로 정상 동작하는 트랜잭션이다. 영속성 컨텍스트에 의해 REPEATABLE_READ처럼 보이는 것이다.

위의 문제를 해결하기 위해서는 중간에 주석처리된 entityManager.clear()를 주석해제하여 실행하면 된다. 그럼 다음과 같은 출력을 확인 할 수 있다. 

트랜잭션의 격리 수준을 개발자가 설정할 상황이 그렇게 많지 않을 수도 있다. 그리고 JPA는 트랜잭션의 격리수준을 별도로 설정하지 않으면 DB에 설정된 격리 수준을 따라가는데 Mysql의 default 격리 수준은 REPEATABLE_READ이다. 하지만 영속성 컨텍스트가 어떻게 동작하는지 잘 알면 예상하지 못한 동작을 손쉽게 해결할 수 있다. JPA는 분명 좋은, 그리고 아주 성숙한 orm이다. 하지만 잘 알고 운영 level에서 사용하기에는 위험할 것 같다. 열심히 실험해보고 공부한 뒤에 이 좋은 orm을 잘 사용해보자.

반응형
Comments