동시성 문제를 해결해 보자 - 2

PGD·2024년 11월 22일

이전 글에서 언급했듯, 동시성 문제를 해결할 수 있는 방안으로 다음 세 가지를 생각해 보았다.

  1. Java synchronized 키워드를 통해 메소드 동기화
  2. Transaction의 Isolation 레벨을 조정한다.
  3. Redis를 활용해 분산 락을 구현한다.

각 SynchronizedExecutor 구현체에서 위에 제시된 방안을 하나씩 구현하면서 동시성 문제를 해결해 보겠다.

Java syncrhonized 키워드를 통한 동기화

가장 먼저 떠올릴 수 있는 방법으로, Java의 synchronized 키워드를 통해 해당 메소드에 대한 접근을 동기화함으로써 동시성 문제를 해결할 수 있다.

SynchronizedExecutor 구현체도 간단하다.

@Component
public class MethodSynchronizedExecutor implements SynchronizedExecutor {

    @Override
    public synchronized Object executeWithLock(Supplier<Object> targetLogic) throws Throwable {
        return targetLogic.get();
    }
}

일단 돌려 보자.

  • 결과:
org.opentest4j.AssertionFailedError: 
expected: 1L
 but was: 2L
Expected :1L
Actual   :2L

예상과는 다르게 테스트는 실패한다. 두 개의 thread가 그대로 if (this.matchingRepository.existsDuplicateMatchingRequest(requestDto.getRequesterId(), LocalDate.now())) 이 부분을 통과하여 Matching 엔티티의 레코드를 생성하는 코드까지 도달한 것이다.

문제 원인 찾기

우선 requestMatching 메소드가 시작하는 시점과 종료되는 시점에 각각 로그를 찍어 보았다.

@SynchronizedOperation
public Long requestMatching(MatchingRequestDto requestDto) {
    System.out.println(Thread.currentThread().getId() + ": BEFORE");
    if (this.matchingRepository.existsDuplicateMatchingRequest(requestDto.getRequesterId(), LocalDate.now())) {
        throw new OutOfLimitMatchingRequestException("매칭 중복");
    }
    try {
        Matching savedMatching = this.matchingRepository.save(
                new Matching(
                        new Member(requestDto.getRequesterId()),
                        new Member(requestDto.getTargetId()),
                        requestDto.getMeetingPlace(),
                        requestDto.getMeetingPlaceAddress(),
                        requestDto.getMeetingTime()
                )
        );
        System.out.println(Thread.currentThread().getId() + ": AFTER");
        return savedMatching.getMatchingId();
    } catch (DataIntegrityViolationException e) {
        throw new MemberNotFoundException(e);
    }
}

비즈니스 로직 시작 시점에 thread의 id와 BEFORE라는 메시지를 출력한 후, 비즈니스 로직이 끝나는 시점에 thread의 id와 AFTER라는 메시지를 출력했다. 이 다음 실패하는 테스트를 다시 돌려 보자.

"AFTER"를 출력한 thread는 총 2개이다.

...

258: AFTER
262: BEFORE

...

262: AFTER
260: BEFORE

그 외의 thread는 AFTER를 출력하지 못 했다. 258번 thread가 첫 번째로 Matching 엔티티를 영속화하는 데 성공했고, 258번 thread 이외의 모든 thread는 엔티티 영속화 로직까지 도달하면 안 된다. 그러나 262번 thread는 그러지 않았고, 엔티티를 영속화하는 로직까지 도달하면서 중복 데이터를 생산해냈다.

출력을 보면, 258번 thread가 끝난 이후에 262번 thread가 requestMatching 메소드의 로직 수행을 시작했다. 메소드 호출 자체는 동기화되었다. synchronized를 통한 Thread 동기화에는 문제가 없다.

1차 해결 방안

문제 원인은 Commit이 되기 전에 메소드에 대한 락이 풀려 버려 그 이후의 Thread가 접근하기 때문이다. Spring의 @Transactional 어노테이션을 달아 주면 Spring AOP에서 해당 메소드 (혹은 해당 타입 전체)를 대상으로 Weaving해 Transaction을 처리해 준다. Spring AOP가 생성해 주는 Dynamic Proxy의 메소드를 간단한 Pseudo code로 나타내면 다음과 같다.

method dynamicProxy()
    txStatus = startTransaction();
    try
        result = targetLogic();
        commit(txStatus);
        return result;
    failed:
        rollback(txStatus);

여기서 targetLogic은 위에 나타난MethodSynchronizedExecutor.executeWithLock() 메소드가 된다. MethodSynchronizedExecutor.executeWithLock() 메소드는 synchronized 키워드에 의해 락이 걸려 있지만, pseudo code의 dynamicProxy()는 Java 락이 걸려 있지 않다. 이 때문에 commit되기 전에 다른 thread가 진입할 수 있게 되는 것이다.

그래서 위에 제시된 MethodSynchronizedExecutor 클래스를 다음과 같이 변경하였다.

@Component
@RequiredArgsConstructor
@Slf4j
public class MethodSynchronizedExecutor implements SynchronizedExecutor {
    private final PlatformTransactionManager txManager;

    @Override
    public synchronized Object executeWithLock(Supplier<Object> targetLogic) throws Throwable {
        TransactionStatus txStatus = this.txManager.getTransaction(new DefaultTransactionAttribute());
       
        try {
            Object returnValue = targetLogic.get();
            this.txManager.commit(txStatus);
            return returnValue;
        } catch (Exception e) {
            this.txManager.rollback(txStatus);
            throw e;
        }
    }
}

그리고 다시 돌려 보자.

expected: 1L
 but was: 2L
Expected :1L
Actual   :2L

여전히 테스트는 실패한다. 이쯤 되면 다소의 버그는 그냥 허용해도 되지 않을까 싶은 생각이 든다. 그래도 포기하지 않고 디버깅해 보자.

한 번 executeWithLock 메소드에서 트랜잭션을 시작하기 직전과 직후에 트랜잭션이 활성화된 상태인지 확인해 보았다.

@Override
public synchronized Object executeWithLock(Supplier<Object> targetLogic) throws Throwable {
    log.info("TransactionSynchronizationManager.isSynchronizationActive()={}", TransactionSynchronizationManager.isSynchronizationActive());
    log.info("TransactionSynchronizationManager.isActualTransactionActive()={}", TransactionSynchronizationManager.isActualTransactionActive());
    TransactionStatus txStatus = this.txManager.getTransaction(new DefaultTransactionAttribute());
    log.info("TransactionSynchronizationManager.isSynchronizationActive()={}", TransactionSynchronizationManager.isSynchronizationActive());
    log.info("TransactionSynchronizationManager.isActualTransactionActive()={}", TransactionSynchronizationManager.isActualTransactionActive());

    try {
        Object returnValue = targetLogic.get();
        this.txManager.commit(txStatus);
        return returnValue;
    } catch (Exception e) {
        this.txManager.rollback(txStatus);
        throw e;
    }
}

출력 결과는 아래와 같다.

TransactionSynchronizationManager.isSynchronizationActive()=true
TransactionSynchronizationManager.isActualTransactionActive()=true
TransactionSynchronizationManager.isSynchronizationActive()=true
TransactionSynchronizationManager.isActualTransactionActive()=true

예상과는 다르게, 네 개의 log 모두 true를 출력했다. 여기서 예상이 가는 게 있다. 내가 정의한 AOP의 우선순위가 Transaction AOP 우선순위보다 낮은 게 아닐까?

2차 해결 방안

SynchronizedExecutor를 호출하는 Aspect 객체인 SynchronizedOperationAspect의 AOP 우선순위를 최상으로 설정해 보았다. SynchronizedOperationAspect에 대한 설명은 이전 포스트에 있다.

@Aspect
@Order(Ordered.HIGHEST_PRECEDENCE)
@Component
@RequiredArgsConstructor
public class SynchronizedOperationAspect {
    ...
}

이제 기대를 가지고 테스트를 돌려 보자.

드디어 성공했다.

그러면 아까 Transaction 활성화 상태인지 여부를 출력했던 건 어떻게 됐을까?

log.info("TransactionSynchronizationManager.isSynchronizationActive()={}", TransactionSynchronizationManager.isSynchronizationActive());
log.info("TransactionSynchronizationManager.isActualTransactionActive()={}", TransactionSynchronizationManager.isActualTransactionActive());
TransactionStatus txStatus = this.txManager.getTransaction(new DefaultTransactionAttribute());
log.info("TransactionSynchronizationManager.isSynchronizationActive()={}", TransactionSynchronizationManager.isSynchronizationActive());
log.info("TransactionSynchronizationManager.isActualTransactionActive()={}", TransactionSynchronizationManager.isActualTransactionActive());

이 부분을 말하는 것이다.

결과는 아래와 같다.

TransactionSynchronizationManager.isSynchronizationActive()=false
TransactionSynchronizationManager.isActualTransactionActive()=false
TransactionSynchronizationManager.isSynchronizationActive()=true
TransactionSynchronizationManager.isActualTransactionActive()=true

감격스럽게도 기대했던 결과가 나왔다. MethodSynchronizedExecutor.executeWithLock() 메소드 내부에서 Transaction을 시작하기 전에는 false가 나오고 Transaction 시작 이후에 true가 나왔다.

이로써 첫 번째 방법을 구현하는 데 성공했다.

Transaction Isolation level 조정을 통한 동기화

Transaction Isolation level을 조정하여 동시성 문제를 해결할 수 있다. 먼저 Transaction Isolation level에 대하여 탐구해 보자.

Transaction isolation level이란?

여러 Transaction이 동시에 처리될 때, 특정 Transaction이 다른 Transaction에서 변경하거나 조회하는 데이터에 접근하는 수준을 말한다. Isolation level은 격리 수준이 높은 순서로 SERIALIZABLE, REPEATABLE READ, READ COMMITED, READ UNCOMMITED가 존재한다.

Transaction은 다음 네 가지 특성을 보장해야 한다.

  • 원자성 (Atomicity): 트랜잭션 내에서 실행한 작업들은 마치 하나의 작업인 것처럼 모두 성공하거나 모두 실패해야 한다.
  • 일관성 (Consistency): 모든 트랜잭션은 일관성 있는 데이터베이스 상태를 유지해야 한다. 예를 들어 데이터베이스에서 정한 무결성 제약 조건을 항상 만족해야 한다.
  • 격리성 (Isolation): 동시에 실행되는 트랜잭션들이 서로에게 영향을 미치지 않도록 격리한다.
  • 지속성 (Durability): 트랜잭션을 성공적으로 끝내면 그 결과가 항상 기록되어야 한다. 중간에 시스템에 문제가 발생해도 데이터베이스 로그 등을 사용해 성공한 트랜잭션 내용을 복구해야 한다.

완벽한 Isolation을 보장하려면 모든 Transaction을 순차적으로 실행해야 한다. 그러나 이렇게 하면 동시성 처리 성능이 심각하게 나빠진다. ANSI 표준에서는 Transaction의 Isolation level을 4단계로 나누어 정의했다.

  1. READ UNCOMMITTED
  2. READ COMMITTED
  3. REPEATABLE READ
  4. SERIALIZABLE

READ UNCOMMITTED의 격리 수준이 가장 낮으며 SERIALIZABLE의 격리 수준이 가장 높다. 격리 수준이 낮을수록 동시성이 증가한다.

Isolation level이 낮으면 발생하는 문제

격리 수준에 따라 다음 문제가 발생할 수 있다.

  • Dirty Read
  • Non-Repeatable Read
  • Phantom Read

Dirty Read

특정 Transaction에 의해 데이터는 변경되었지만 커밋되지 않았을 때 다른 Transaction에서 커밋되지 않은 변경사항을 읽을 수 있는 문제. 만약 해당 변경사항이 Rollback될 경우 데이터 정합성에 치명적인 문제가 발생할 수 있다.

Non-Repeatable Read

Transaction 1이 회원 A를 조회 중인데 Transaction 2가 회원 A를 수정하고 커밋하면 Transaction 1이 다시 회원 A를 조회했을 때 수정된 데이터가 조회되는 문제. Idempotency가 보장되지 않는다.

Phantom Read

조회한 결과 행이 새로 생기거나 없어지는 현상. Transaction 1이 10살 이하의 회원을 조회했는데 Transaction 2가 5살 회원을 추가하고 커밋하면 Transaction 1이 다시 10살 이하의 회원을 조회했을 때 회원 하나가 추가된 상태로 조회됨.

데이터베이스는 보통 READ COMMITTED 격리 수준을 기본으로 사용한다.

Isolation level에 따라 발생하는 문제

Isolation levelDirty ReadNon-Repeatable ReadPhatom Read
Read UncommittedOOO
Read CommittedOO
Repeatable ReadO
Serialable

READ UNCOMMITTED

커밋하지 않은 데이터를 읽을 수 있다. Transaction 1이 데이터를 수정하고 있는데 커밋하지 않아도 Transaction 2가 수정 중인 데이터를 조회할 수 있다.

READ COMMITTED

커밋한 데이터만 읽을 수 있다. 따라서 Dirty Read가 발생하지 않는다. 하지만 Non-Repeatable Read는 발생할 수 있다.

REPEATABLE READ

한 번 조회한 데이터를 반복해서 조회해도 같은 데이터가 조회된다. 하지만 Phantom Read는 발생할 수 있다. 예를 들어 Transaction 1이 10살 이하의 회원을 조회했는데 Transactio 2가 5살 회원을 추가하고 커밋하면 Transaction 1이 다시 10살 이하의 회원을 조회했을 때 회원 하나가 추가된 상태로 조회된다.

SERIALIZABLE

가장 엄격한 Transaction 격리 수준으로, Phantom Read를 포함한 모든 문제가 발생하지 않는다. 그러나 동시성 처리 성능이 급격히 떨어질 수 있다.

낙관적 락과 비관적 락

낙관적 락

Transaction 대부분은 충돌이 발생하지 않는다고 낙관적으로 가정하는 방법. JPA에서는 데이터베이스가 제공하는 락 기능을 사용하지 않고 JPA가 제공하는 버전 관리 기능을 사용한다. 즉, 데이터베이스가 아닌 애플리케이션이 제공하는 락이다. 낙관적 락은 Transaction을 커밋하기 전까지는 Transaction의 충돌을 알 수 없다는 특징이 있다.

비관적 락

Transaction이 충돌한다고 가정하고 우선 락을 걸고 보는 방법. 데이터베이스가 제공하는 락 기능 사용. 대표적으로 select for update 구문이 있다.

Transaction Isolation level 조정을 통한 동시성 처리

그렇다면 이전 글에서 언급한 프로젝트 상황에서 Isolation level을 어떻게 조정해야지 동시성을 처리할 수 있을까?

"중복된 매칭을 요청할 수 없어야 한다"는 요구사항을 지켜야 한다는 점이 문제 상황이다. 새로운 매칭 요청에 대해 동시성 처리가 이루어져야 한다. 다시 말해 동시성 처리가 필요한 로직은 새로운 레코드를 생성하는 로직을 포함하고 있고, 새로운 레코드 중복 추가를 방지하기 위해 데이터베이스에 중복된 레코드가 있는지 확인하는 로직을 포함하고 있다. 즉, Phantom Read 문제를 해결해야 한다. 그래서 이 상황에서 필요한 Isolation level은 SERIALIZABLE이다.

  • 데이터베이스 격리 수준을 활용하여 동시성 문제를 해결하는 SynchronizedExecutor 구현체
@Component
@Primary
@RequiredArgsConstructor
public class DbIsolationSynchronizedExecutor implements SynchronizedExecutor {

    @Transactional(isolation = Isolation.SERIALIZABLE)
    @Override
    public Object executeWithLock(Supplier<Object> targetLogic) throws Throwable {
        return targetLogic.get();
    }
}

그러면 테스트를 돌려 보자.

테스트가 성공한 것을 확인할 수 있다.

Isolation level을 한 단계 낮춰 보면 어떨까?

@Component
@Primary
@RequiredArgsConstructor
public class DbIsolationSynchronizedExecutor implements SynchronizedExecutor {

    @Transactional(isolation = Isolation.REPEATABLE_READ)
    @Override
    public Object executeWithLock(Supplier<Object> targetLogic) throws Throwable {
        return targetLogic.get();
    }
}

위와 같이 Isolation level을 Repeatable Read로 설정하고 테스트를 돌려 보자.

  • Output
org.opentest4j.AssertionFailedError: 
expected: 1L
 but was: 10L
Expected :1L
Actual   :10L

동시성 문제가 해결되지 않는 것을 확인할 수 있다.


이상으로 동시성 문제를 해결하는 두 가지 방법을 알아 보았다. 다음 글에서는 Redis를 활용한 분산 락을 통해 동시성 문제를 해결하는 방법을 알아보겠다.

profile
student

0개의 댓글