[작성 중] 동시성 문제를 해결해 보자 - 2

PGD·약 16시간 전
0

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

  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에 대하여 탐구해 보자.

profile
student

0개의 댓글