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 커밋 |
- Thread A, Thread B 두 스레드가 동시에 주문을 요청한다.
- Thread A가 먼저 락을 획득하고, 계좌 잔고를 차감한 뒤 주문을 처리한다.
- 그러나 트랜잭션이 커밋되기 전에 락을 해제한다.
- Thread B는 락이 풀린 걸 감지하고 락을 획득한 후, 이전 트랜잭션(A)의 결과가 반영되지 않은 상태의 잔고를 조회한다.
- 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();
}
}
}
}
- Spring @Transactional은 프록시 기반으로 작동한다.
- AOP는 트랜잭션 시작 전 실행되므로, AOP 내 락 획득 후 트랜잭션이 실행된다.
- 그러나 호출부에서 트랜잭션이 이미 시작되어 있으면, AOP 내부의 트랜잭션 제어는 무의미해지고 락 해제가 트랜잭션 커밋보다 먼저 일어난다.
- 결국, 락 해제 → 트랜잭션 커밋 순서가 되면 다른 스레드가 커밋되지 않은 데이터를 조회해 정합성 문제가 발생한다.
📌 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 |