[트러블슈팅] Redisson 분산락과 트랜잭션 적용 시점 충돌(AOP 를 활용한 해결)

2025. 6. 18. 09:46·프로젝트/트러블슈팅

intro

  • 프로젝트를 진행하며 발생한 문제 상황과 해결 과정들을 상세히 기록하고 추후에 같은 문제가 발생 했을때 빠르게 문제 해결하기 위해 트러블 슈팅을 정리할려고 한다.
  • 기록하는 습관을 기르기 위해 프로젝트 기간동안 꾸준히 작성할 것 이다.

처음에는 @DistributedLock과 @Transactional을 함께 사용하면 락과 트랜잭션이 동시에 작동해 정합성이 보장될 줄 알았다. 하지만 실제 테스트 과정에서 다음과 같은 문제가 발생했다.

⚠️ 문제 상황 발생

@Around("@annotation(distributedLock)")
public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
    String key = distributedLock.key();
    long waitTime = distributedLock.waitTime();
    long leaseTime = distributedLock.leaseTime();

    RLock lock = redissonClient.getLock(key);

    boolean isLocked = false;
    try {
       isLocked = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
       if (!isLocked) {
          throw new IllegalStateException("락 획득 실패: key = " + key);
       }
       return joinPoint.proceed(); // 비즈니스 로직 실행
    } finally {
       if (isLocked && lock.isHeldByCurrentThread()) {
          lock.unlock();
       }
    }
}
  • 커스텀 어노테이션으로 Redisson 락은 성공적으로 구현하였으나, 트랜잭션이 롤백되지 않거나 반대로 트랜잭션이 먼저 커밋된 뒤 락이 해제되면서 정합성 오류 발생했다.

 

✅ 트랜잭션 적용 전

  • tradeService 의 체결 비즈니스 로직에 트랜잭션을 적용하지 않으면 정상적으로 락이 적용돼 씹히는 주문 없이 해결된다.

 

 

 

✅ 트랜잭션 적용 후

  • tradeService 의 체결 비즈니스 로직에 트랜잭션을 적용하면 50% 정도만 체결되는것을 볼 수있다.

 

 

🔍 2. 원인 분석

문제의 원인은 트랜잭션 커밋 시점에 있었다. Spring Transactional 의 기본 전파 속성은 REQUIRED 이다.

즉,부모 트랜잭션이 존재하면 부모 트랜잭션에 합류하고, 그렇지 않으면 새로운 트랜잭션을 만든다.

이게 트랜잭션과 동시성 문제에 어떤 연관이 있는지 살펴보았다.

 

만약 , 트랜잭션 커밋 이전에 락을 해제 해버리면 어떤일이 일어날까?

Redisson 의 쓰레드 관리방법은 순차적으로 락을 관리하는 것인데 트랜잭션과는 정해진 순서가 없기 때문에 아래의 표와 같이 문제가 발생할수 있다.

 

🔴 트랜잭션 커밋 전에 락 해제

순서 Thread A Thread B
1 트랜잭션 A 시작 트랜잭션 B 시작
2 락 획득 락 대기
3 계좌 잔고 조회 → 50000  
4 잔고 차감 (잔고 = 49500)  
5 주문 저장  
6 락 해제 (커밋 전) 락 획득 (커밋 전)
7 트랜잭션 A 커밋 계좌 잔고 조회 → 50000 ← A의 커밋 반영 전
8 잔고 차감 (잔고 = 49500)  
9 주문 저장  
10 트랜잭션 B 커밋  
  1. Thread A, Thread B 두 스레드가 동시에 주문을 요청한다.
  2. Thread A가 먼저 락을 획득하고, 계좌 잔고를 차감한 뒤 주문을 처리한다.
  3. 그러나 트랜잭션이 커밋되기 전에 락을 해제한다.
  4. Thread B는 락이 풀린 걸 감지하고 락을 획득한 후, 이전 트랜잭션(A)의 결과가 반영되지 않은 상태의 잔고를 조회한다.
  5. Thread B는 잘못된 잔고 기반으로 주문을 처리하게 되고, 트랜잭션 커밋이 완료되면 정합성이 어긋난 상태가 된다.

 

🔴 트랜잭션 커밋 후 락 해제

순서 Thread A Thread B
1 락 획득 락 대기
2 트랜잭션 A 시작  
3 잔고 조회 → 50000  
4 잔고 차감 → 49500  
5 트랜잭션 A 커밋 완료  
6 락 해제 락 획득
7 트랜잭션 B 시작 잔고 조회 → 49500 ← 반영된 값
8 잔고 차감 → 49000 트랜잭션 B 커밋 완료

 

  • Thread A, Thread B 두 스레드가 동시에 tradeOrder 메서드에 접근한다.
  • Thread A가 먼저 락을 획득한 뒤 트랜잭션을 시작한다.
  • Thread A는 현재 계좌 잔고와 보유 수량을 조회한 후, 주문 수량만큼 잔고를 차감하고 보유 수량을 증가시킨다.
  • Thread A는 트랜잭션을 먼저 커밋하여 DB에 변경 사항을 반영한다.
  • 이후 락을 해제한다.
  • 락이 해제되자 Thread B가 락을 획득하고 트랜잭션을 시작한다.
  • Thread B는 Thread A의 변경 사항이 반영된 상태의 잔고/보유 수량을 조회한다.
  • Thread B도 동일하게 잔고 차감 및 보유 수량 증가를 수행하고, 트랜잭션을 커밋한 뒤 락을 해제한다.

 

💡 이처럼 트랜잭션 커밋 후에 락을 해제 하면, 다음 스레드가 정확히 반영된 상태의 DB 데이터를 조회할 수 있어 데이터 정합성을 지킬 수 있을거 같다.

 

✅ 그래서 왜 커밋 전에 락이 해제되는가?

1️⃣ Spring AOP는 비즈니스 로직이 종료된 시점에 끝난다

@Around("@annotation(...)")
public Object lock(ProceedingJoinPoint joinPoint) throws Throwable {
    lock.lock();
    try {
        return joinPoint.proceed(); // 비즈니스 로직 실행
    } finally {
        lock.unlock(); // 비즈니스 로직 끝나자마자 락 해제
    }
}

 

  • joinPoint.proceed()가 리턴되면 AOP는 종료되고 락도 즉시 해제된다.
  • 하지만 이 시점은 아직 트랜잭션이 커밋되기 전일 수 있음.

 

2️⃣Spring의 @Transactional은 메서드가 정상 종료된 후에 트랜잭션을 커밋함

 

 

  • @Transactional 메서드가 리턴되는 순간 → 그제야 트랜잭션 커밋 시도
  • 따라서 락 해제 시점 < 트랜잭션 커밋 시점 이 되어 락은 먼저 풀리고 커밋은 나중에 이루어짐
  • 정리하지면, [AOP 락 획득] → [비즈니스 로직 실행] → [AOP 종료 = 락 해제] → [트랜잭션 커밋] 과정으로 진행됨.

 

 

 

 

📝 3. 해결 방안

기존에는 @DistributedLock 어노테이션만 활용해 락은 걸 수 있었지만, 트랜잭션을 별도로 컨트롤하지 못해 락이 먼저 해제되고 트랜잭션이 나중에 커밋되는 문제가 발생했다.

이 문제를 해결하기 위해 트랜잭션을 수동으로 제어하는 방식으로 AOP 코드를 수정하였다.

 

DistributedLockAspect.class

@Aspect
@Component
@RequiredArgsConstructor
public class DistributedLockAspect {

	private final RedissonClient redissonClient;
	private final PlatformTransactionManager transactionManager;

	@Around("@annotation(distributedLock)")
	public Object lock(ProceedingJoinPoint joinPoint, DistributedLock distributedLock) throws Throwable {
		String key = distributedLock.key();
		long waitTime = distributedLock.waitTime();
		long leaseTime = distributedLock.leaseTime();

		RLock lock = redissonClient.getLock(key);

		boolean isLocked = false;
		try {
			isLocked = lock.tryLock(waitTime, leaseTime, TimeUnit.SECONDS);
			if (!isLocked) {
				throw new IllegalStateException("락 획득 실패: key = " + key);
			}

			TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager);
			transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);

			return transactionTemplate.execute(status -> {
				try {
					return joinPoint.proceed(); // 비즈니스 로직 실행
				} catch (Throwable throwable) {
					// 트랜잭션 롤백을 명시적으로 설정
					status.setRollbackOnly();
					throw new RuntimeException(throwable);
				}
			}); // 비즈니스 로직 실행
		} finally {
			if (isLocked && lock.isHeldByCurrentThread()) {
				lock.unlock();
			}
		}
	}

}

 

 

  1. Spring @Transactional은 프록시 기반으로 작동한다.
  2. AOP는 트랜잭션 시작 전 실행되므로, AOP 내 락 획득 후 트랜잭션이 실행된다.
  3. 그러나 호출부에서 트랜잭션이 이미 시작되어 있으면, AOP 내부의 트랜잭션 제어는 무의미해지고 락 해제가 트랜잭션 커밋보다 먼저 일어난다.
  4. 결국, 락 해제 → 트랜잭션 커밋 순서가 되면 다른 스레드가 커밋되지 않은 데이터를 조회해 정합성 문제가 발생한다.

 

📌 4. 결과 확인

 

 

  • 정확히 100건의 주문이 모두 반영되었다.
  • 트랜잭션이 커밋된 후 락을 해제하는 방식으로 구현했기 때문에, 이전 작업의 결과가 반영된 최신 상태를 기준으로 다음 작업이 처리됐다.

 

 

📜 회고

- @Transactional 은 프록시 기반으로 작동한다.

- AOP는 트랜잭션 시작 전 실행되므로, AOP 내 락 획득 후 트랜잭션이 실행된다.

- 그러나 호출부에서 트랜잭션이 이미 시작되어 있으면, AOP 내부의 트랜잭션 제어는 무의미해지고 락 해제가 트랜잭션 커밋보다       먼저 일어난다.

- 결국, 락 해제 → 트랜잭션 커밋 순서가 되면 다른 스레드가 커밋되지 않은 데이터를 조회해 정합성 문제가 발생한다.

 

'프로젝트 > 트러블슈팅' 카테고리의 다른 글

[Spring] Redis 캐시에서 객체를 꺼낼 때 타입 오류 발생  (1) 2025.05.22
[Spring]단위 테스트 Mockito willReturn(Optional<T>) 오류 해결  (1) 2025.05.19
[QueryDSL] Hibernate SemanticException과 fetchJoin() 트러블슈팅  (0) 2025.05.13
[Spring]무한 스크롤 + Enum 정렬 트러블슈팅  (2) 2025.04.23
[Spring/Security] 403 Forbidden? 권한 문제가 아니라 CSRF 이 원인이였다  (0) 2025.04.21
'프로젝트/트러블슈팅' 카테고리의 다른 글
  • [Spring] Redis 캐시에서 객체를 꺼낼 때 타입 오류 발생
  • [Spring]단위 테스트 Mockito willReturn(Optional<T>) 오류 해결
  • [QueryDSL] Hibernate SemanticException과 fetchJoin() 트러블슈팅
  • [Spring]무한 스크롤 + Enum 정렬 트러블슈팅
코딩로봇
코딩로봇
금융 IT 개발자
  • 코딩로봇
    쟈니의 일지
    코딩로봇
  • 전체
    오늘
    어제
    • 분류 전체보기 (151) N
      • JavaScript (8)
      • SQL (11)
      • 코딩테스트 (30)
        • Java (15)
        • SQL (13)
      • Java (10)
      • 프로젝트 (30) N
        • 트러블슈팅 (10)
        • 프로젝트 회고 (18) N
      • git,Github (2)
      • TIL (38)
      • Spring (20)
  • 블로그 메뉴

    • 홈
    • 태그
    • 방명록
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    스파르타 코딩 #부트캠프 #첫ot
    java #arraylist #list #배열
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
코딩로봇
[트러블슈팅] Redisson 분산락과 트랜잭션 적용 시점 충돌(AOP 를 활용한 해결)
상단으로

티스토리툴바