🎯 문제 상황
현재 진행중인 프로젝트에 사용자 포인트를 증가, 감소하는 메서드들이 있습니다. 거래가 동시에 진행되다면 데이터 정합성 문제가 발생할 수 있다 생각했습니다.
먼저 코드에 대해 간단히 설명드리겠습니다.
✅ 사용자 포인트를 감소시키는 메서드
@Transactional
public void acceptProtectedDeal(Long dealId) {
//안전거래 조회
ProtectedDeal protectedDeal = OptionalUtil.getOrElseThrow(protectedDealRepository.findById(dealId), NOT_EXIST_DEAL_ID);
User getter = OptionalUtil.getOrElseThrow(userRepository.findById(protectedDeal.getGetterId()), NOT_EXIST_USER_ID);
UserAccount userAccount = userAccountRepository.findByUserId(getter.getId()).get();
//임차인 포인트가 충분한지 검증
userAccount.validatePointsSufficiency(protectedDeal.calculateTotalPrice());
//임차인 포인트 감소
userAccount.decreasePoint(protectedDeal.calculateTotalPrice());
//안전거래 상태 변경
protectedDeal.setDealState(DealState.ACCEPT_DEAL);
protectedDeal.getProtectedDealDateTime().setStartAt(LocalDateTime.now());
}
위 코드에서는 사용자의 포인트가 충분한지를 1차로 검증 후 예외가 발생하지 않으면 사용자 포인트를 감소시키는 로직이 포함되어 있습니다. 이 메서드에서 다음과 같은 문제가 발생할 수 있습니다.
<문제 발생 시나리오>
예를 들어 ThreadA 와 ThreadB 가 동시에 같은 UserAccount 객체의 포인트를 업데이트 한다고 가정해보면
1. Thread A : findByUserId(getter.getId()) 호출 -> 현재 포인트 : 1000
2. Thread B : findByUserId(getter.getId()) 호출 -> 현재 포인트 : 1000
3. Thread A : 거래 가격 500을 차감 -> points = 500 -> DB에 저장
4. Thread B : 거래 가격 500을 차감 -> points = 500 -> DB에 저장
즉, 예상해야 하는 정상적인 값은 1000 - 500 - 500 = 0 이지만 실제 저장된 값은 500 으로 저장될 수 있습니다. 즉 ThreadB 가 ThreadA 의 변경 사항을 무시하고 기존 데이터를 덮어 쓰면서 포인트가 손실되는 문제가 발생한 것입니다.
즉, Race Condition(경쟁 상태) 로 인한 데이터 정합 성 문제가 발생한 것입니다. ThreadA 와 ThreadB 가 동시에 같은 데이터를 수정하면서 동시에 같은 데이터를 업데이트하는 트랜잭션간 충돌 문제라 볼 수 있습니다.
이러한 동시성 문제를 해결하기 위해 비관적 락(Pessimistic Lock) 또는 낙관적 락(Optimistic Lock) 을 적용할 수 있습니다. 먼저 비관적 락과 낙관적 락에 대해 설명드리겠습니다.
비관적 락 : 충돌이 발생할 가능성이 높다 가정하고, 미리 락(Lock) 을 걸어 다른 트랜잭션이 접근하지 못하도록 하는 방식입니다.
낙관적 락 : 충돌이 거의 발생하지 않을 것이라 가정하고, 데이터를 변경할 때 충돌 여부를 검사해 해결하는 방식입니다.
이러한 두가지 방식을 이용해 데이터 정합성 문제를 해결할 수 있습니다. 그럼 비관적 락으로 문제를 해결하는 방법에 대해 말씀드리겠습니다. 저는 비관적 락을 이용해 사용자 포인트의 정합성을 확보했습니다. 이유는 아래에서 설명드리겠습니다.
🎯 PESSIMISTIC_READ 와 PESSIMISTIC_WRITE
Spring 에서 JPA 를 사용할때 애노테이션을 사용해 간단하게 Lock 을 걸 수 있습니다. @Lock 애노테이션과 함께 사용되는 속성이 있는데 바로 PESSIMISTIC_WRITE 와 PESSIMISTIC_READ 입니다.
PESSIMISTIC_READ 는 읽기 잠금(Read Lock) 을 획득합니다. 이는 다른 트랜잭션이 해당 데이터를 수정하거나 삭제할 수 없게 만듭니다. 하지만 다른 트랜잭션이 같은 데이터를 읽는 것은 허용합니다. 즉, 다중 읽기는 허용하지만, 쓰기는 차단됩니다.
사용방법은 다음과 같습니다.
@Lock(LockModeType.PESSIMISTIC_READ)
@Query("SELECT h FROM Home h WHERE h.id = :id")
Optional<Home> findByIdWithReadLock(@Param("id") Long id);
PESSIMISTIC_WRITE 는 쓰기 잠금(Write Lock) 을 획득합니다. 다른 트랜잭션이 해당 데이터를 읽거나 수정할 수 없게 만듭니다. 즉 읽기 조차 차단됩니다. 이는 하나의 트랜잭션에만 데이터에 접근할 수 있도록 보장합니다.
사용방법은 다음과 같습니다.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT h FROM Home h WHERE h.id = :id")
Optional<Home> findByIdWithWriteLock(@Param("id") Long id);
그러면 이 둘의 속성은 각각 언제 사용해야 할까?
PESSIMISTIC_READ : 동시 읽기는 허용되지만, 데이터가 변경되는 것은 방지하고 싶을때 사용합니다. 재고 수량 조회, 인기 게시글 조회 등의 기능에 사용됩니다.
PESSIMISTIC_WRITE : 데이터의 일관성이 가장 중요하고,동시 접근을 완전히 차단할때 사용합니다. 은행 계좌 잔액 변경 과 같은 기능에 사용됩니다.
🎯 비관적 락 적용 방법
다른 트랜잭션이 이 데이터를 변경하면 안돼! 라고 알려주는 방법입니다. 즉, 데이터를 시점에 락을 사용해 동시에 다른 트랜잭선이 접근하는 것을 차단하는 것입니다.
[트랜잭션이 끝날때 까지 다른 트랜잭선이 데이터를 수정하지 못합니다.]
✅ JPA에서 비관적 락 적용 방법
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM UserAccount u WHERE u.id = :userId")
Optional<UserAccount> findByIdWithLock(@Param("userId") Long userId);
Repository 영역에서 @Lock 애노테이션을 사용해 비관적 락을 적용할 수 있습니다.
LockModeType.PESSIMISTIC_WRITE를 사용하면 다른 트랜잭션이 해당 데이터를 읽을 수도 없음LockModeType.PESSIMISTIC_READ를 사용하면 다른 트랜잭션이 읽을 수는 있지만, 수정은 불가능
사용자 포인트는 실제 현금으로 환전이 가능하기 때문에 데이터의 일관성이 가장 중요합니다. 때문에 저는 PESSIMISTIC_WRITE 을 사용해 락을 걸었습니다.
이처럼 비관적 락을 적용하면 Thread A 가 사용자 계좌에 대한 트랜잭션이 실행중일때 ThreadB 는 해당 데이터로 읽기/쓰기가 금지되어 데이터의 일관성을 확보할 수 있습니다.
🎯 비관적 락 테스트
총 2개의 쓰레드가 존재하고 첫번째 쓰레드에서 Lock 을 걸면, 두번째 쓰레드에서는 Lock 이 해제될때 까지 해당 데이터로 접근하지 못합니다. 이러한 상황을 테스트 하기 위해 멀티 쓰레드를 활용해 두개의 요청을 동시에 보내 테스트 하려 합니다.
✅ 테스트 시나리오
(1) 첫번째 쓰레드가 Lock 을 건다. (3초)
(2) 두번째 쓰레드가 같은 데이터에 접근할때 Lock 에 의해 조회/쓰기가 불가능하다.
(3) 첫번째 쓰레드의 Lock 이 해제되면 두번째 쓰레드에서 데이터에 접근한다.
즉, 첫번째 쓰레드를 호출하고 3초의 시간을 확보한 뒤 두번째 쓰레드가 호출하고 데이터가 조회될때 3초 이상의 시간 이 측정되면 비관적 락이 잘 적용됐음을 확인할 수 있습니다. 자 ! 이제 테스트에 필요한 코드들을 먼저 살펴봅시다.
✅ executeUnTransaction(Callable<T> action)
private <T> T executeInTransaction(Callable<T> action) {
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); // 새로운 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(def);
try {
T result = action.call();
transactionManager.commit(status);
return result;
} catch (Exception e) {
transactionManager.rollback(status);
throw new RuntimeException(e);
}
}
PROPAGATION_REQUEST_NEW 는 기존 트랜잭션과 별도로 항상 새로운 트랜잭션을 생성합니다. 비관적 락 메서드를 테스트 하기 위해서는 트랜잭션을 분리해 실행해야 합니다.
✅ executeUnTransaction(Runnable action)
private void executeInTransaction(Runnable action) {
executeInTransaction(() -> {
action.run();
return null;
});
}
위 메서드를 오버로딩한 메서드 입니다. 단순 실행만 필요하고 결과 값을 반환하지 않는 메서드 입니다.
이 두가지 메서드가 테스트 코드에서 필요한 이유는 멀티 스레드 환경에서 비관적 락의 동작을 검증해야 하기 때문입니다. @Transactional 은 기존 트랜잭션을 공유하기 때문에, 비관적 락 테스트에서는 트랜잭션이 독립적으로 실행되지 않습니다. 즉, PROPAGATION_REQUEST_NEW 를 명시적으로 설정하지 않으면, 첫번째와 두번째 트랜잭션이 같은 트랜잭션 내에서 실행될 수 있기 때문에 수동 트랜잭션 을 관리하기 위해 해당 메서드가 필요합니다.
✅ 전체 테스트 코드
@DataJpaTest
@ActiveProfiles("test")
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
@Import({TestConfig.class, TimerAop.class})
public class UserAccountTest {
@Autowired
private UserAccountRepository userAccountRepository;
@Autowired
private PlatformTransactionManager transactionManager;
@PersistenceContext
private EntityManager entityManager;
private final ExecutorService executorService = Executors.newFixedThreadPool(2);
@BeforeEach
void setBefore(){
userAccountRepository.deleteAll();
}
@Test
void 비관적_락이_잘_적용되는지_테스트() throws ExecutionException, InterruptedException {
userAccountRepository.save(generateUserAccount(1L, 1000));
CountDownLatch latch = new CountDownLatch(1);
// 첫 번째 트랜잭션: 락을 획득하고 3초 동안 대기
Future<Void> firstTransaction = executorService.submit(() -> {
executeInTransaction(() -> {
userAccountRepository.findByIdWithLock(1L);
latch.countDown();
try {
Thread.sleep(3000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
});
return null;
});
latch.await(); // 첫 번째 트랜잭션이 락을 잡을 때까지 대기
// 두 번째 트랜잭션 실행
Future<Long> secondTransaction = executorService.submit(() -> {
long startTime = System.currentTimeMillis();
executeInTransaction(() -> {
userAccountRepository.findByIdWithLock(1L);
});
long elapsedTime = System.currentTimeMillis() - startTime;
System.out.println("두 번째 트랜잭션 종료 - 대기 시간: " + elapsedTime + "ms");
return elapsedTime;
});
firstTransaction.get();
long waitingTime = secondTransaction.get();
//두 번째 트랜잭션이 최소 3초 이상 대기했는지 검증
assertThat(waitingTime).isGreaterThanOrEqualTo(3000);
}
/**
* 트랜잭션을 수동으로 시작하고 실행하는 메서드
*/
private <T> T executeInTransaction(Callable<T> action) {
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW); // 새로운 트랜잭션 시작
TransactionStatus status = transactionManager.getTransaction(def);
try {
T result = action.call();
transactionManager.commit(status);
return result;
} catch (Exception e) {
transactionManager.rollback(status);
throw new RuntimeException(e);
}
}
private void executeInTransaction(Runnable action) {
executeInTransaction(() -> {
action.run();
return null;
});
}
}
위에서 설명한 시나리오처럼 첫번째 쓰레드와 두번째 쓰레드에서의 시간이 3초 이상임을 확인하며 비관적 락이 잘 적용됐음을 확인할 수 있습니다.
멀티 스레드에 대한 테스트 코드 작성에 대한 자세한 설명은 또 다른 포스팅으로 설명드리겠습니다.
정리해보면, 비관적 락이 올바르게 동작하는지 검증하는 통합 테스트 입니다.
(1) 멀티 스레드 환경에서 동시에 같은 데이터를 조회할때의 동작을 검증
(2) 첫 번째 트랜잭션이 락을 획득한 후 3초 동안 유지
(3) 두 번째 트랜잭션이 3초동안 대기 후 실행되는지 확인
(4) 트랜잭션을 수동으로 관리
🎯 낙관적 락 적용 방법
낙관적 락은 락을 걸지 않고 최종 저장때 충돌 여부를 검사합니다. @Version 필드를 만들어 변경시 버전 정보를 비교하며 동시성을 제오합니다.
✅ JPA에서 낙관적 락 적용 방법
@Entity
public class UserAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Version // 버전 필드 추가
private Integer version;
private int points;
}
UserAccount account = userAccountRepository.findById(userId).orElseThrow();
account.setPoints(account.getPoints() - 500);
userAccountRepository.save(account); // @Version 필드 검증 후 저장
이처럼 UserAccount 에 버전 필드를 추가합니다. 이후 충돌이 발생하면 예외를 발생하여 롤백후 다시 시도합니다. 이처럼 낙관적락을 사용하면 다음과 같이 동작합니다.
- Thread A가 findById(1L) 실행 → version = 1, 포인트 1000
- Thread B가 findById(1L) 실행 → version = 1, 포인트 1000
- Thread A가 포인트 500 차감 후 version = 2로 저장
- Thread B가 포인트 500 차감 후 저장 시도 → version = 1이므로 OptimisticLockException 발생
- Thread B는 데이터를 다시 불러와 재시도
즉, 데이터 정합성을 유지하지만 Thread B 는 재시도 해야 합니다.
🎯 데드락 문제 해결
지금까지 비관적 락과 낙관적 락을 이용한 데이터 정합성 문제를 해결하는 방법을 알아봤습니다. 저는 이번 문제 상황에서 비관적 락을 사용했습니다. 금융 관련 기능에서 다음과 같은 원칙을 세워야 합니다.
1. 데이터 정합성 : 중복처리나 손실이 발생하면 안됨
2. 원자성 보장 : 거래 중 일부만 처리되면 안됨
3. 동시성 처리 : 같은 사용자의 계좌를 여러 요청이 동시에 수정할 경우 충돌을 방지해야함
비관적 락은 거의 모든 충돌을 방지하고 낙관적 락은 충돌 발생시 예외를 발생시킵니다. 또한 비관적 락은 강제적으로 데이터 정합성을 유지 하는 반면 낙관적 락은 충돌 발생시 롤백이 필요 합니다.
금융 관련 기능에서는 동시성 문제가 가장 치명적이므로 비관적 락이 더 안전한 선택이라 생각합니다. 하지만 비관적 락을 사용할때 주의해야 할 점이 있습니다. 바로 데드락(DEAD LOCK) 상황에 대처해야 합니다.
비관적 락을 사용할때 여러 트랜잭션이 서로 상대방이 보유한 락을 기다리면서 영훤이 대기에 빠질 수 있습니다. 때문에 락을 요청할때 타임 아웃을 설정해야 합니다. 일정시간이 지나면 락을 얻지 못한 트랜잭션이 자동으로 롤백되어 데드락문제를 해결할 수 있습니다.
트랜잭션이 일정 시간동안 락을 획들하지 못하면 즉시 예외를 던져 데드락을 예방할 수 있습니다. 아래와 같이 타임아웃을 추가하면, 특정 시간이 지나도 락을 잡지 못하면 데드락에 빠지지 않습니다.
✅ 타임 아웃 설정
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT u FROM UserAccount u WHERE u.userId = :userId")
@QueryHints({
@QueryHint(name = "jakarta.persistence.lock.timeout", value = "5000")
})
Optional<UserAccount> findByIdWithLock(@Param("userId") Long userId);
이처럼 @QueryHint 를 사용하면 타임아웃을 설정해 설정한 시간동안 락을 획득하지 못하면, LockTimeoutException 예외를 발생시켜 데드락 문제를 해결할 수 있습니다.
'Trouble Shooting && 성능 개선' 카테고리의 다른 글
[성능 개선] ElasticSearch 를 이용한 조회 성능 개선 (0) | 2025.04.25 |
---|---|
[Trouble Shooting] Redis Master-Replica 구조에서의 권한 문제 (0) | 2025.03.10 |
[Trouble Shooting] 서비스 운영에 필요한 로그 관리 방법 with CloudWatch (0) | 2025.01.28 |
[Trouble Shooting] EC2 다운 문제, CPU 사용률 100% 해결 (0) | 2025.01.19 |
[Trouble Shooting] Spring Boot Redis List 역직렬화 에러 (0) | 2025.01.15 |