밤빵's 개발일지
결제시스템구현: 동시성 문제&LOCK 개념 본문
결제 기능을 구현하게되면서, 기술 매니저님의 조언에 따라 꼭 알아두어야 한다는 동시성 이슈와 LOCK 개념에 대해서 공부하면서 개념을 정리했다. 예시마다 이해가 쉽지않아서 시간이 오래걸렸다🥺
▶ 동시성 이슈란?
동시성 문제(concurrency issue)란, 여러 사용자가 동시에 같은 자원 (데이터, 메모리 등)에 접근하거나 수정할 때 발생하는 문제를 의미한다. 결제 시스템에서는 이러한 문제가 심각한 버그나 금전적 손실로 이어질 수 있어 특히 주의가 필요하다.
▷ 온라인 쇼핑몰에서 100개 한정판 상품을 판매한다고 가정했을 때 : 재고 관리 문제
public class InventoryService {
// 주문을 처리하는 메서드
public void processOrder(Long productId, int quantity) {
// 1.상품 ID로 해당 상품을 데이터 베이스에서 조회
Product product = productRepository.findById(productId);
// 1.재고가 주문 수량보다 크거나 같은지 확인
if (product.getStock() >= quantity) {
// 3.재고를 주문 수량만큼 차감
product.setStock(product.getStock() - quantity);
// 4.변경된 재고 정보를 데이터베이스에 저장
productRepository.save(product);
// 5.주문 처리 로직 (ex.주문 생성)
createOrder(product, quantity);
} else {
// 6.재고가 부족한 경우 얘외를 던져서 알림
throw new OutOfStockException();
}
}
→ 여러 사용자가 동시에 주문을 시도 할 겨우, 실제 재고보다 많은 주문이 처리 될 수 있다.
▷ 사용자 A가 100,000원 짜리 상품을 구매하려 하는데, 네트워크 지연으로 인해 사용자 A가 실수로 결제버튼을 두 번 클릭했다고 가정했을 때 : 중복 결제 문제
public class PaymentService {
// 사용자의 결제를 처리하는 메서드
public void processPayment(Long userId, Long productId, BigDecimal amount) {
// 1. 사용자 ID로 해당 사용자를 데이터베이스에서 조회
User user = userRepository.findById(userId);
// 2. 상품 ID로 해당 상품을 데이터베이스에서 조회
Product product = productRepository.findById(productId);
// 3. 사용자의 잔액이 결제 금액보다 크거나 같은지 확인
if (user.getBalance().compareTo(amount) >= 0) {
// 4. 사용자의 잔액에서 결제 금액을 차감
user.setBalance(user.getBalance().subtract(amount));
// 5. 변경된 사용자 잔액을 데이터베이스에 저장
userRepository.save(user);
// 6. 결제 처리 로직 (예: 결제 내역 생성)
createPaymentRecord(user, product, amount);
} else {
// 7. 사용자의 잔액이 부족한 경우 예외를 던져서 알림
throw new InsufficientBalanceException();
}
}
}
→ 두 개의 결제 요청이 거의 동시에 처리 될 경우, 둘 다 잔액 체크를 통과하여 중복 결제가 발생 할 수 있다.
이러한 동시성 문제를 해결하지 않으면, 데이터의 일관성이 깨지거나 시스템이 예기치 않게 동작 할 수 있다.
▶ LOCK 개념
위의 동시성 문제를 해결하기 위해 사용되는것이 LOCK개념으로, 결제 시스템을 설계할 때 주로 데이터베이스에서 사용된다. 여러 트랜잭션이 동시에 데이터에 접근하거나 수정할 때 데이터의 일관성을 유지하기 위한 방법과, LOCK을 통해 여러 사용자가 동시에 같은 데이터를 변경하지 못하도록 하는 방법이 있고, 크게 두 가지 주요 개념이 있다.
▷ 비관적 락 (Pessimistic Lock)
→ 이 방식은 자원에 접근할 때, 다른 작업이 접근하지 못하도록 먼저 LOCK을 걸고, LOCK이 해제 되기 전까지는 다른 작업이 해당 자원에 접근 할 수 없게 된다.
- 동시성 출돌이 자주 일어날 것으로 예상될 때 유용하다.
→ 장점: 동시성 문제가 발생할 확률이 적다.
데이터의 안전성을 보장한다.
→ 단점 : 자원이 잠겨 있는동안 다른 작업이 대기해야 하므로 성능이 저하될 수 있다.
많은 사용자가 동시에 접근하면 대기 시간이 길어질 수 있다.
@Transactional // 이 메서드가 하나의 트랜잭션으로 처리되도록 설정. 만약 에러가 발생하면 모든 변경 사항이 롤백됨.
public void updateAccount(Long accountId, BigDecimal amount) {
// 1. 주어진 accountId를 가진 계좌 정보를 데이터베이스에서 조회하며, PESSIMISTIC_WRITE 락을 사용하여 다른 트랜잭션이 계좌를 수정하지 못하게 함
Account account = entityManager.find(Account.class, accountId, LockModeType.PESSIMISTIC_WRITE);
// 2. 조회한 계좌의 현재 잔액에 주어진 amount를 더함
account.setBalance(account.getBalance().add(amount));
// 3. 변경된 계좌 정보를 데이터베이스에 저장 (merge는 엔티티의 변경된 상태를 영속성 컨텍스트에 반영)
entityManager.merge(account);
}
→ LockModeType.PESSIMISTIC_WRITE :
이 LOCK모드는 다른 트랜잭션이 이 계좌를 읽거나 쓰지 못하게 "잠그는"역할을 한다. 현재 트랜잭션이 완료될 때 까지 다른 사용자는 이 계좌를 수정할 수 없다. 이를 통해 동시성 문제를 방지한다.
→ account.setBalance(account.getBalance().add(amount)); :
조회한 account 객체의 현재 잔액에 amount를 더한다.
예를 들어, 계좌의 현재 잔액이 1,000원이었고 amount가 500원이라면, 새로운 잔액은 1,500원이 되는 것.
→ entityManager.merge(account); :
entityManager.merge() 메서드는 변경된 account 객체를 데이터베이스에 저장한다.
이 메서드는 변경된 엔티티를 영속성 컨텍스트에 병합해서, 해당 변경 사항이 데이터베이스에 반영되도록 한다.
→ 비관적 락을 사용해서 계좌의 잔액을 안전하게 업데이트 하는 방법을 보여준다. 이 방식은 여러 사용자가 같은 계좌를 수정하려 할 때 발생할 수 있는 동시성 문제를 방지하는데 유용하다. 결제 시스템과 같은 중요한 금융 애플리케이션에서 데이터의 무결성과 일관성을 유지하는 데 매우 중요한 개념이다.
▷ 낙관적 락 (Optimistic Lock)
→ 이 방식은 자원에 접근할 때 별 다른 제약 없이 자유롭게 접근하도록 허용한다. 다만 자원을 수정하기 전에 변경된 내용이 없는지 확인하고, 만약 변경 내용이 있다면 수정 작업을 중단하고 다시 시도하도록 한다.
→ 장점 : 자주 충돌이 일어나지 않는 경우, 시스템 성능이 높아질 수 있다.
데이터 접근이 빠르다.
→ 단점 : 충돌이 자주 발생하면 성능이 떨어질 수 있다.
이 경우 트랜잭션을 다시 시도해야 한다.
// Account 엔티티 클래스 정의
@Entity // JPA 엔티티임을 나타내는 어노테이션
public class Account {
@Id // 기본 키를 나타내는 어노테이션
private Long id;
@Version // 낙관적 락을 위한 버전 필드. 데이터 변경 시 버전이 자동으로 증가하여 충돌을 방지.
private Long version;
private BigDecimal balance; // 계좌의 잔액을 나타내는 필드
// ======== 기타 필드 ==========
// Getter와 Setter 메서드들
// 계좌 ID, 버전, 잔액에 대한 getter와 setter를 제공하여 외부에서 접근 및 수정 가능
public Long getId() {
return id;
}
}
// 계좌의 잔액을 업데이트하는 서비스 메서드 정의
@Transactional // 이 메서드가 트랜잭션 내에서 실행되도록 보장하는 어노테이션
public void updateAccount(Long accountId, BigDecimal amount) {
// 1. 주어진 accountId로 계좌를 조회하고, 계좌가 없으면 AccountNotFoundException을 던짐
Account account = accountRepository.findById(accountId)
.orElseThrow(() -> new AccountNotFoundException());
// 2. 조회한 계좌의 현재 잔액에 주어진 amount를 더함
account.setBalance(account.getBalance().add(amount));
// 3. 변경된 계좌 정보를 데이터베이스에 저장 (이때 @Version 필드가 변경되어 낙관적 락 충돌을 방지)
accountRepository.save(account);
}
→ @Version 필드 version
낙관적 락을 위한 버전필드. 데이터가 업데이트 될 때마다 JPA는 이 필드의 값을 자동으로 증가시키고, 동시에 여러 트랜잭션이 같은 데이터를 변경하려 할 때 충돌을 감지하고 방지할 수 있다.
→ 낙관적 락을 통해 동시에 여러 트랜잭션이 동일한 계좌 데이터를 수정할 때 발생할 수 있는 동시성 문제를 방지하는 방법을 보여준다. @Version 필드는 데이터 충돌을 감지하는데 중요한 역할을 하고, 이를 통해 데이터의 무결성을 유지할 수 있다.
▶ LOCK의 주요역할
결제 과정 중에 발생할 수 있는 모든 데이터의 일관성을 유지하는 것. 결제는 단순히 돈이 오고가는 과정이 아닌 재고관리, 사용자 정보 업데이트, 주문 내역 기록 등 다양한 데이터가 복잡하게 얽혀있는 작업으로 이러한 데이터가 제대로 관리되지 않으면, 큰 문제가 발생할 수 있다 .
→ 재고관리 : 재고를 올바르게 관리 하려면 LOCk이 필요하다
→ 결제처리 : 결제 과정에서 돈이 중복으로 빠져나가는 것을 방지한다
→ 주문기록 : 주문 내역이 중복되거나 누락되지 않도록 보장한다.
▶결론
LOCK개념은 시스템 전체의 데이터 일관성을 유지하고, 안전성을 보장하기 위한 필수적인 도구로 결제라는 민감한 과정에서 오류를 최소화하고 사용자에게 신뢰할 수 있는 서비스를 제공하기 위해서는 LOCK개념을 잘 이해하고 적용하는 것이 중요하다. 비관적 락과 낙관적 락은 각각의 장단점이 있고, 특성과 요구사항을 고려하면서 결정해야한다.
실제 프로젝트에서는 이 두가지 방식을 혼합해서 사용하는 경우도 많은데, 중요한 건 동시성 이슈가 발생할 수 있는 상황을 인지하고, 적합한 전략을 선택하여 대응하는 것이다.
🤓배운 점
- 동시성 문제는 항상 예기치 못한 형태로 나타날 수 있다. 시스템의 모든 측면을 고려해야한다.
- 트랜잭션 관리와 상태 확인은 데이터 일관성을 유지하는데 매우 중요하다. 특히 우리 프로젝트의 견적 제시나 선택 과정에서 트랜잭션을 적절히 관리하고 상태를 확인하여 데이터 불일치를 방지할 수 있을것 같다.
- 동시성 문제에서 자주 일어나는 여러 구매자의 동시접근을 걱정했지만, 한 의뢰당 한 구매자만 존재하는 시스템의 특성을 이해하고 나니 더 적합한 해결책을 찾을 수 있을 것 같다.
- 지속적인 리뷰와 개선이 필요하다. 초보개발자로서 시스템을 개발하고 운영하면서 계속해서 새로운 상황을 마주하게 될것이므로, 주기적으로 시스템을 검토하고 개선점을 찾아야 할 것 같다.
'단체개발일지' 카테고리의 다른 글
HikariPool...!! (0) | 2024.09.08 |
---|---|
결제시스템구현: 멱등성 문제 (PUT & PATCH) (0) | 2024.09.01 |
stateless 특성과 JWT의 관계 (0) | 2024.08.19 |
서버가 상태를 가지고 있으면 안된다? = Stateless (0) | 2024.08.12 |
팀 개발일지에 합류하며 (1) | 2024.08.04 |