주문 기능에 숨어있는 트랜잭션 설계 이야기

2026. 6. 12. 17:55·개발이야기/개발로그

들어가며

우리는 쿠팡, 네이버 쇼핑 등에서 쉽게 주문을 접한다. 쉽게 쿠폰을 적용하고 주문을 하고, 결제를 하고(또는 마음이 변해서 결제를 취소하고), 상품을 배송받는다.

이러한 서비스 기술들을 우리는 쉽게 접하고, 나와 같은 주니어 개발자들은 프로젝트에서 쉽게 쇼핑몰 프로젝트를 고르곤 한다. 우리는 생각보다 쇼핑몰의 결제에 대해 쉽게 생각할 수 있지만,



 

서비스의 안정성과 데이터 정합성 등을 고려한다면 아주 어려운 주제가 될 수 있다.

 

예를들어.. 
주문이 두 개가 들어갔는데 재고는 하나밖에 안 남았다면?
쿠폰은 선착순 100개였는데 왜 110개가 발급되어 있지?

 

보통 소규모 프로젝트에서는 의도하지 않으면 겪기 힘든 동시성 문제들이 존재한다. 이러한 데이터 정합성을 보장하기 위해 트랜잭션과 DB 락에 대해 고려하여 설계·개발을 진행할 필요가 있다. 현재 진행하고 있는 프로젝트의 구조에서

1. 현재 상황 - 주문이 여러 도메인에 관섭하고있는 상황

 

1. 현재 상황 — 주문이 여러 도메인에 관여하고 있는 상황

주문이 완료되기 위해서는 아래와 같은 과정이 필요하다.

  1. 상품 재고 차감
  2. 쿠폰 사용 처리 (쿠폰 적용 시)
  3. 주문 레코드 생성

위의 작업들은 모두 함께 성공하거나 실패해야 한다.

 

2. 진행

@Transactional
public OrderInfo placeOrder(Long memberId, List<OrderItemRequest> items, Long userCouponId) {
    // 재고 차감 (비관적 락) + OrderItemCommand 생성
    List<OrderItemCommand> orderItems = items.stream()
            .map(item -> {
                ProductModel product = productDomainService.deductStock(item.productId(), item.quantity());
                return new OrderItemCommand(product.getId(), product.getName(), product.getPrice(), item.quantity());
            })
            .toList();

    long originalAmount = orderItems.stream()
            .mapToLong(cmd -> cmd.price() * cmd.quantity())
            .sum();

    // 쿠폰 적용 (비관적 락으로 단 한 번만 사용 보장)
    long discountAmount = 0L;
    if (userCouponId != null) {
        discountAmount = couponService.validateAndUseCoupon(memberId, userCouponId, originalAmount);
    }

    OrderModel order = orderService.createOrder(memberId, orderItems, userCouponId, discountAmount);
    return OrderInfo.from(order);
}

 

그래서 도메인 서비스들은 각자의 책임에 집중하고, 여러 도메인을 조율하는 facade 계층을 트랜젝션 경계를 뒀다.

3. 동시성 문제 - Check-then-Act 패턴의 문제점

 

// 위험한 패턴
if (userCoupon.getStatus() == CouponStatus.AVAILABLE) {  // (1) 상태 확인
    userCoupon.use();  // (2) 사용 처리
}

 

이런 코드의 패턴은 단일 스레드에서는 문제없지만, 동시 요청에서는 .getStatus() 데이터의 정합성이 보장되지 않을 수 있다.

 

4. 비관적 락으로 동시성을 제어

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT uc FROM UserCoupon uc WHERE uc.id = :id AND uc.deletedAt IS NULL")
Optional<UserCoupon> findByIdWithLock(@Param("id") Long id);

위와 같이 비관적 락을 명시함으로써 쿠폰 조회 시 DB 행 수준의 배타 락을 걸어주면, 다음 동시 요청이 들어왔을 때 위의 트랜잭션이 완료되기 전까지 대기가 걸리게 된다.

@Transactional
public long validateAndUseCoupon(Long memberId, Long userCouponId, long orderAmount) {
    UserCoupon userCoupon = userCouponRepository.findWithLock(userCouponId)
            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "존재하지 않는 쿠폰입니다."));

    if (!userCoupon.isOwnedBy(memberId)) {
        throw new CoreException(ErrorType.BAD_REQUEST, "본인 소유의 쿠폰만 사용할 수 있습니다.");
    }

    CouponTemplate template = couponTemplateRepository.find(userCoupon.getCouponTemplateId())
            .orElseThrow(() -> new CoreException(ErrorType.NOT_FOUND, "쿠폰 템플릿을 찾을 수 없습니다."));

    long discountAmount = template.calculateDiscount(orderAmount);
    userCoupon.use();

    return discountAmount;
}

 

첫 번째 트렌젝션이 완료되면 두 번째 요청은 락을 획득하고 다시 쿠폰을 조회한다.

 

5. 스냅샷 저장

또한 쿠폰을 조회하여 사용할 때, 나중에 쿠폰의 할인 금액 등이 변경될 경우 발급 시점을 보장하기 위해 아래처럼 스냅샷 저장을 적용하였다.

public class OrderModel extends BaseEntity {

    private Long memberId;

    @Enumerated(EnumType.STRING)
    private OrderStatus status;

    private Long originalAmount;

    private Long discountAmount;

    private Long totalAmount;

    private Long couponId;

    protected OrderModel() {}

    public OrderModel(Long memberId, Long originalAmount, Long discountAmount, Long couponId) {
        if (memberId == null) {
            throw new CoreException(ErrorType.BAD_REQUEST, "회원 ID는 필수입니다.");
        }
        if (originalAmount == null || originalAmount < 0) {
            throw new CoreException(ErrorType.BAD_REQUEST, "총금액은 0 이상이어야 합니다.");
        }
        if (discountAmount == null || discountAmount < 0) {
            throw new CoreException(ErrorType.BAD_REQUEST, "할인금액은 0 이상이어야 합니다.");
        }
        if (discountAmount > originalAmount) {
            throw new CoreException(ErrorType.BAD_REQUEST, "할인금액이 총금액을 초과할 수 없습니다.");
        }
        this.memberId = memberId;
        this.status = OrderStatus.PENDING;
        this.originalAmount = originalAmount;
        this.discountAmount = discountAmount;
        this.totalAmount = originalAmount - discountAmount;
        this.couponId = couponId;
    }

 

주문 생성 시점에서 쿠폰을 적용하면, 주문 생성 시 발급받은 쿠폰이 삭제·수정되어도 주문 이력이나 금액은 변경되지 않는다.

6. 추가적으로 발생한 고민점

위처럼 스냅샷이 저장된다고 할 때, 주문 조회 내역 기능을 차후에 만든다고 가정하면, 쿠폰의 할인율이 변경되었을 때 쿠폰의 변경 내역이 궁금하다면 어떻게 관리해야 할지 고민해 보았다. 계산 결과가 스냅샷만으로 충분할지는 환불·분쟁 등의 서비스 처리 내역에 따라 다르겠지만, 한번 고민해봐야겠다.

저작자표시 (새창열림)

'개발이야기 > 개발로그' 카테고리의 다른 글

인덱스 설계와 카디널리티  (0) 2026.06.19
귀찮지만 든든해.. TDD(테스트 주도 개발) 에 대해서  (0) 2026.05.10
VLink 서비스 운영 1년간의 회고록 -2-  (6) 2025.09.01
서비스 운영중 이슈 대응 방안  (0) 2025.08.28
VLink 서비스 운영 1년간의 회고록  (8) 2025.08.21
'개발이야기/개발로그' 카테고리의 다른 글
  • 인덱스 설계와 카디널리티
  • 귀찮지만 든든해.. TDD(테스트 주도 개발) 에 대해서
  • VLink 서비스 운영 1년간의 회고록 -2-
  • 서비스 운영중 이슈 대응 방안
김코딩개발자
김코딩개발자
  • 김코딩개발자
    김코딩의 개발로그
    김코딩개발자
  • 전체
    오늘
    어제
    • 분류 전체보기 (69)
      • 개발이야기 (19)
        • 개발로그 (7)
        • 항해일지 (11)
      • Develop (0)
      • Life (0)
      • Stack (29)
        • C++ (6)
        • Ext.js (1)
        • Spring (18)
        • Java (2)
        • JavaScript (2)
      • TechTrend (0)
      • TechKnowledge (20)
        • CS관련지식 (9)
        • 알고리즘 (9)
        • 네트워크 (2)
  • 블로그 메뉴

    • 홈
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    개발 문서 작성방법
    서비스경험
    DB원리
    데이터 용량단위
    괄호문제
    괄호 회전하기
    자바스크립트입문
    동적계획법
    SpringBoot DB
    데이터 마이그레이션
    개발일기
    프로그래머스 LV2
    서비스 경험
    ip통신
    osi 2계층
    software docs
    개발입문
    지연수치표
    개발 설계 문서
    직장인
    시간복잡도
    관점지향프로그래밍
    올바른 괄호
    lan 통신
    동시성제어
    대규모 트래픽
    프로그래머스 멀리뛰기
    응답지연값
    프로그래머스
    네트워크
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
김코딩개발자
주문 기능에 숨어있는 트랜잭션 설계 이야기
상단으로

티스토리툴바