
서버구축 - 데이터베이스 심화
이번 주차는 전 주차에 설계 및 Index가 적용된 DB를 기반으로 동시성 문제가 발생하는 비즈니스 로직을 식별하고, 해당 로직에 대한 DB 관점의 Lock을 통한 동시성 제어를 구현하는 것이 목표였습니다.
동시성의 정의
동시성은 여러 작업이 독립적으로 실행되며, 그 실행이 논리적으로 동시에 일어나는 것처럼보이게 하는 프로그래밍 기법
동시성으로 인한 문제
1. 경쟁 상태, 경쟁 조건(Race condition)
- 두 개 이상의 스레드가 동시에 공유자원에 접근할 때 발생
- 스레드 실행 순서에 따라 결과가 달라짐
- 예시
balance 에 대한 충전과 사용 요청이 동시에 접근하여, 아직 갱신되지 않은 잔액을 기준으로 서로 나중에 완료되는 요청으로 갱신이 될 수 있음
- 이로 인해 분실 갱신이 발생하여 정합성이 깨지고 데이터 불일치가 발생함
2. 데드락(Deadlock)
- 두 개의 스레드가 서로 상대방이 점유하고 있는 자원을 무한정 대기하는 상태
- 발생 조건
- 상호 배제: 자원은 하나의 프로세스만 점유 가능
- 점유와 대기: 자원을 점유한 채로 다른 자원을 기다림
- 비선점: 자원을 강제로 가져오지 못함
- 순환 대기: 프로세스들이 서로 원형으로 대기함
- 예시 1
- 상품 > 쿠폰 > 잔액을 접근하는 주문 메서드를 호출한 스레드와
- 쿠폰 > 상품을 접근하는 다른 메서드가 동시에 요청이 발생 했을 떄,
- 각각의 요청이 상품과 쿠폰을 선점하고 서로 다른 자원을 기다리는 교착상태에 빠짐
- 예시 2
- 두 주문 요청이 발생하는 중, 상품 한 테이블에 대하여 PK에 대한 순서 정렬 없이 재고 차감을 하는 경우
- 3 > 1 > 5 번 상품 재고를 차감하려는 주문 요청
- 1 > 3 번 상품 재고를 차감하려는 주문의 경우
- PK 기준으로 row에 락이 동작해 서로 다음 상품의 재고 차감을 하지 못하도 교착상태에 빠짐
3. 라이브락(Livelock)
- 여러 스레드나 프로세스가 서로의 작업을 방해햐여 진행이 멈춘 상태
- 데드락과 유사하지만, 데드락과 달리 스레드들이 작업을 지속적으로 시도함
4. 기아 상태(Starvation)
- 특정 스레드나 프로세스가 자원에 대한 접근 권한을 지속적으로 얻지 못하고 무한정 대기하는 상태
- 우선순위가 낮거나, 잘못된 자원할당 정책에서 발생할 수 있는 상황
5. 우선순위 역전(Priority Inversion)
- 실시간 시스템에서 낮은 우선순위의 태스크가 높은 우선순위의 태스크보다 먼저 자원을 점유하여 높은 우선순위 태스크의 실행을 지연시키는 문제
E-commerce 서비스에서 예측되는 동시성 문제
1. 상품 재고 차감
- 기능 설명: 주문 생성 중 상품의 재고를 차감하는 경우
- 예측되는 동시성 문제:
Race condition → 재고 음수 발생, 차감 누락
- 발생 시나리오
- 상품 A의
재고 5 일 때, 두 사용자의 요청으로 각각 수량 3을 주문
- 두 트랜젝션이 모두
Product.stock >= quantity를 충족
- 둘 다 차감하고 커밋하면 최종
재고 -1
- 혹은 업데이트를 위해 조회한 5를 기준으로 나중의 것이 덮어쓰기 되어
재고 2
- 비즈니스적 문제
- 실제보다 많은 수량을 판매한 것처럼 기록되어 오배송, 품절, 환불 요청 등으로 재고 관리의 어려움 발생
- 물류를 직접 다루는 도메인에서는 실패를 감내하기 어려움
2. 쿠폰 선착순 발급
- 기능 설명: 쿠폰 정책별 잔여 수량에 대하여 사용자에게 쿠폰을 발급 하는 경우
- 예측되는 동시성 문제:
Race condition → 쿠폰 초과 발급
- 발생 시나리오
- remainingCount = 1 일 때, 두 사용자가 동시에 발급 요청
- 두 요청 모두
remainingCount > 0 조건을 충족
- 둘 다 반영되어 최종 잔여가 -1이 되거나, 사용자 각각의 잘못된 쿠폰이 초과 발급
- 비즈니스적 문제
- 예산을 초과하는 쿠폰 발급으로 인한 마케팅 비용에 손실이 있음
- 공정성 문제가 발생하고, 사용자의 신뢰도가 하락함
- 발생하지 않는 것이 제일 좋지만, 마케팅과 사후 안내를 통한 소량의 초과 발급분 까지 인정하고,
- 회사의 손실보다 사용자의 이익이 강조되는 상황에선 역으로 마케팅이 가능하여 어느 정도 실패를 감내할 수 있음
3. 쿠폰 사용
- 기능 설명: 주문 생성 중 쿠폰을 사용하는 경우
- 예측되는 동시성 문제:
Race condition → 쿠폰의 중복 사용
- 발생 시나리오
- 정상적으로 발급된 쿠폰 A에 대하여, 한 사용자가 두 번의 주문을 동시에 요청
- 요청 시 둘 다
used == false 조건으로 사용 상태로 업데이트
- 하나의 쿠폰이 두번 사용됨
- 비즈니스적 문제
- 쿠폰 관리 시스템에 대한 신뢰도 저하
- 악용 사례로 인한 부수적인 피해 발생
- 발급된 쿠폰에 대한 접근은 발급을 받은 사용자 한명에게만 유효하여 한 주문에 실패 정도는 감내 가능
4. 잔액 차감
- 기능 설명: 주문 생성 중 잔액을 차감하는 경우
- 예상되는 동시성 문제:
Race condition → 잔액의 중복 차감, 덜 차감
- 발생 시나리오
- 사용자 A의 잔액이
5,000인 상태에서, 총액 3,000, 4,000의 주문 동시 발생
- 조건
5,000 >= 주문금액을 모두 통과하고, 음수 잔액 -2,000 혹은, 두 개의 주문 총액보다 덜 차감됨
- 비즈니스적 문제
- 음수 잔액으로 인한 후속 결제 문제, 공급사가 별도로 있다면 정산에도 오류가 발생
- 일부 월말 통합 결제, 미수금 결제 등 B2B 사업에서는 허용될 수 있으나,
- 충전식으로 사용되는 e-commerce 서비스에서는 신뢰성이 크게 하락할수 있음.
- 다만, 잔액은 사용자 단위의 자원이므로, 한 주문 요청을 실패시키는 것에 대한 감내 가능.
5. 잔액 충전 및 사용
- 기능 설명: 잔액 충전 요청과 동시에 주문 생성을 통한 잔액차감 발생
- 예상되는 동시성 문제:
Race condition → 충전과 차감이 충돌하여 잔액 오류 발생
- 발생 시나리오
- 사용자 A가 잔액이
5,000이 있는 상태에서, 3,000의 주문과 3,000의 충전을 동시에 요청
- 충전 및 사용에 조건이 동시에 통과해 동시에 잔액을 참조
- 주문보다 충전이 늦게 완료되어 주문 금액이 무시되고, 충전 금액으로 덮어쓰기 됨
- 비즈니스적 문제
- 충전 후에도 잔액이 감소되어있거나, 반대로 사용했음에도 잔액이 남아있어 신뢰성이 무너짐
- 다만, 잔액은 사용자 단위의 자원이므로, 한가지 요청을 실패시키는 것으로 감내 가능.
6. 주문 생성 전체 흐름
- 기능 설명: 하나의 트랜잭션 내에서 3개의 자원(Product, Coupon, Balance)을 함께 처리
- 예상 동시성 문제:
Deadlock → 교착 상태 발생
- 발생 시나리오 1
- 주문 생성 트랜젝션: 상품 → 쿠폰 → 잔액 순으로 접근
- 주문 생성 외 트랜젝션: 쿠폰 → 상품, 또는 잔액 → 상품 순서 처럼 역방향으로 접근
- 서로 자원 점유 순서가 다르면 서로 다른 자원 점유 후 상대 자원 대기
- 주문 생성 OrderFacade에서 Product > Coupon > Balance 순으로 프로세스 동작
- 현재 시나리오 상으로는 발생 가능성이 낮음
- 발생 시나리오 2
- 주문 상품의 정렬 없이, 상품 목록
[1, 3, 5]와 [3, 1]의 주문이 동시에 요청됨
- PK 1과 3의 자원을 점유한 상태로 교착 상태에 빠짐ㅠㅠ
- 비즈니스적 문제
- 교착 상태로 인한 지속적인 주문 실패는 매우 크리티컬 하여, 서비스 전체적인 신뢰도가 하락함
- 반복적인 재시도 및 CS 요청 처리가 지속적으로 발생
- 교착 상태는 감내할 수 없는 치명적인 장애로, 반드시 배제할 수 있도록 예방 설계가 필요
DB락을 통한 동시성 문제 해결 방법
1. 상품 재고 차감
-
문제: Race condition → 재고 음수 발생, 차감 누락
- 경쟁의 강도:
높음 – 다수의 사용자가 같은 인기 상품에 동시에 접근
- 예측 트래픽:
사용자 수에 비례 – 수천~수만 트랜잭션까지 병행 가능
- 실패 감내:
불가 – 재고 차감 실패 시 물류, 환불, 품절 처리 등 직접적인 운영 비용 발생
-
해결 방법: 비관적 락(PESSIMISTIC_WRITE)
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT p FROM Product p WHERE p.id = :id")
Product findWithLockById(@Param("id") Long id);
- 상품 목록은 PK 기준으로 정렬하여 조회해야 교착 상태 예방 가능
- 정렬 예시:
WHERE p.id IN :ids ORDER BY p.id
2. 쿠폰 선착순 발급
-
문제: Race condition → 쿠폰 초과 발급
- 경쟁의 강도:
매우 높음 – 마케팅 오픈 순간 다수 유저가 동시 요청
- 예측 트래픽:
폭발적 – 몇 초간 수천 건 이상의 발급 요청 가능
- 실패 감내:
일정 수준 허용 가능 – 마케팅 이슈로 감내/보상 가능한 수준의 실패 수용 가능 ( or 감내 불가? )
-
해결 방법: 비관적 락, 재시도를 포함한 낙관적 락, 또는 조건부 업데이트(Optimistic 방식) ✅ 기반 처리
@Modifying
@Query("""
UPDATE CouponPolicy cp
SET cp.remainingCount = cp.remainingCount - 1
WHERE cp.id = :policyId
AND cp.remainingCount > 0
""")
int decreaseRemainingCount(@Param("policyId") Long policyId);
- 조건부 업데이트 쿼리를 직접 수행하여 쿠폰을 발급
- DB 락 만으로는 "선착순"이라는 비즈니스 로직을 해결하기 어려울 것으로 예측함
- 추후 Redis를 학습하면 보완 가능할 것으로 보임
3. 쿠폰 사용
-
문제: Race condition → 쿠폰의 중복 사용
- 경쟁의 강도: 낮음 – 동일한 쿠폰은 한 사용자에게만 할당되므로 한 사용자의 요정 내 경쟁 발생
- 예측 트래픽: 낮음 – 동일 쿠폰을 한 사용자가 두 번 이상 요청할 경우에 한정
- 실패 감내: 허용 가능 – 중복 사용 감지 시 하나의 주문만 실패해도 문제 없음
-
해결 방법: 조건부 업데이트 (Optimistic 방식)
@Modifying(clearAutomatically = true)
@Query("""
UPDATE Coupon c
SET c.used = true
WHERE c.id = :couponId
AND c.user.id = :userId
AND c.used = false
""")
int markCouponAsUsed(@Param("couponId") Long couponId, @Param("userId") Long userId);
used = false 여야 동작 가능. 1 또는 0으로 반환
4. 잔액 차감 - 주문 중복 요청
-
문제: Race condition → 잔액의 중복 차감, 덜 차감
- 경쟁의 강도: 중간 - 사용자 단위의 balance 자원은 충전 및 사용 동시 요청, 중복 요청 등으로 접근이 가능함
- 예측 트래픽: 중간 - 악의적으로 혹은 전송의 오류로 인하여 여러 요청을 보낼 수 있음
- 실패 감내: 허용 가능 - 한 주문의 통과하고, 그 외 일부 주문의 실패는 사용자 한명에게 치명적이지 않고, 오류 메세지로 충분히 반환 가능함
-
해결 방법: 낙관적 락(Optimistic Lock), JPA @Version 활용
@Entity
@Table(name = "balance")
public class Balance {
@Version
private int version;
}
- 일부 주문의 실패는 시스템 재시도 없이 사용자에게 실패 안내를 통한 재시도 요청으로 진행
5. 잔액 충전 및 사용 - 주문과 충전의 충돌, 충전 동시 요청
- 문제:
Race condition → 충전과 차감이 동시에 발생하며, 충전 또는 차감이 유실되거나 덮어써지는 문제
- 경쟁의 강도: 중간 – 충전은 사용자에 의한 명시적 액션이지만, 사용 요청과 겹칠 수 있음
- 예측 트래픽: 중간 – 보통 명시적 충전은 많지 않지만, 악의적, 간헐적으로 동시에 발생 가능
- 실패 감내: 허용 가능 – 둘 중 하나만 반영되어도 다시 재시도 가능
- 해결 방법:
낙관적 락(Optimistic Lock), JPA @Version 활용
6. 주문 생성 전체 흐름 - Product → Coupon → Balance
피드백
- 조건부 업데이트
@Modifying 쿼리 사용의 적절성 양호함
- 락이 적용되지 않았는데도, CannotAcquireLockException로 인해 동시성 문제가 발생하지 않는 경우 더 확인해보기
마치며
- 동시성에 대해서 고민을 많이 해보지 않는 환경에서 작업을 하고 있다보니, 내가 부족했던 부분이 무엇 인 지 알 수 있는 과정이어서 좋았습니다.
- DB락 대신
@Modifying 쿼리를 통한 더 성능이 좋은 동시성 제어를 사용하긴 했으나, 이게 사용성이 과연 좋을 까? 하는 생각에 최종적으로 테스트 결과에서 낙관적 락을 쓰는 것이 좋겠다고 작성하였습니다.
- 이부분에 대해서 DB의 부하가 적은 방법으로 처리해서 BP를 주셨다고 하여, 살짝 부끄러운 결과를 도출했구나 또 하는 생각이 들었습니다.
- 그래도 다양한 동시성 이슈와 제어 방법을 알게 되는 좋은 과정이었습니다.