주문 생성 흐름에서 책임을 어디까지 나눌 것인가

2026. 5. 29. 17:33·루퍼스 4기

서비스 규모가 커질수록 주문 생성 로직은 빠르게 복잡해진다.

처음에는 단순히 주문만 생성하면 되었지만, 점점 처음에 설계하지 않은 요구사항이 추가될 것이다. 

- 재고 차감
- 쿠폰 적용
- 결제 처리
- 주문 상태 관리
- 금액 계산
- 정책 검증

 

문제는 이런 로직들이 하나의 Service에 계속 쌓이기 시작하면 각 계층과 도메인의 책임 경계가 점점 흐려진다는 점이다.

 

특히 주문 생성은 여러 도메인이 함께 협력하는 흐름이기 때문에, 단순히 “Service에 로직을 넣는다”만으로는 유지보수가 어려워질 수 있다.

 

이번 구현에서는 주문 생성 흐름을 다음과 같이 나누어 고민했는데 

 

  • Controller → 요청 진입과 입력 변환
  • Facade(Application Layer) → 유스케이스 흐름 orchestration
  • Service → 주문 도메인 흐름 조합
  • Domain → 상태와 invariant 보호
  • Policy → 변경 가능 정책 분리

 

핵심은 단순히 계층을 나누는 것이 아니라, 각 계층이 어디까지 책임져야 하는가를 명확하게 분리하는 것이었다.

Controller 

 

Controller는 외부 요청을 애플리케이션 내부 흐름으로 연결하는 책임이 존재한다. 

HTTP 요청을 받고 입력을 변환하고 Application Layer를 호출하는 입구 역할에 집중한다. 

@PostMapping
    @ResponseStatus(HttpStatus.CREATED)
    @Override
    public ApiResponse<OrderV1Dto.OrderResponse> createOrder(
            @Valid @RequestBody OrderV1Dto.OrderRequest request,
            @RequestAttribute("authenticatedUserId") Long userId
    ) {
        List<OrderItemInput> items = request.items().stream()
                .map(req -> new OrderItemInput(req.stockId(), req.quantity()))
                .toList();
        return ApiResponse.success(OrderV1Dto.OrderResponse.from(orderFacade.createOrder(userId, items)));
    }

 

Controller는 요청 데이터를 받고 입력 형식을 검증한 뒤 Application Layer로 전달하는 데 집중한다.

여기서는 다음과 같은 도메인 규칙을 판단하지 않는다.

 

  • 재고가 충분한가?
  • 주문 가능한 상태인가?
  • 총금액 계산은 맞는가?

이러한 판단은 Controller 계층의 책임이 아니라 도메인 규칙의 영역이다. 

 

FACADE

 

Facade(Application Layer)는 Controller로부터 전달받은 요청을 기반으로
유스케이스의 전체 흐름을 조율하는 계층이다. 

@Transactional
public OrderInfo createOrder(Long userId, List<OrderItemInput> items) {
    List<OrderItemInput> mergedItems = orderService.mergeItems(items);
    List<ProductStockModel> stocks = productStockService.decrease(mergedItems);
    return OrderInfo.from(orderService.placeOrder(new OrderModel(userId), stocks, mergedItems));
}

 

주문 FACADE는 주문 요청 데이터를 정리하고 재고를 조회 및 차감한 뒤 최종적으로 주문을 생성하는 흐름으로 구성했다.

이 계층에서는 다음과 같은 흐름을 조율한다.

 

  • 어떤 도메인을 어떤 순서로 호출할 것인가
  • 트랜잭션을 어디까지 묶을 것인가
  • 어떤 데이터를 다음 흐름으로 전달할 것인가

특히 주문 생성은 재고 차감과 주문 생성이 함께 성공하거나 실패해야 하는 흐름이라고 판단하여 Facade 계층에서 하나의 트랜잭션으로 묶었다.

 

여기서 고민했던 부분은 mergeItems()의 책임이었다.

이 함수가 수행하는 역할은 클라이언트 요청으로 넘어온 동일 상품 다른 수량을 합산하는 역할을 한다. 

 

예를 들어 클라이언트 요청에서 동일한 상품이 다른 수량으로 들어올 수 있다. 

[ { "stockId": 1, "quantity": 2 }, { "stockId": 1, "quantity": 3 } ]

 

이 경우 Controller는 검증하지 않고 Facade에게 전달하는데 수량을 합쳐 하나의 상품으로 처리할 것인지 그대로 각각 처리할 것인지에 대한 기준이 필요했다. 

 

처음에는 동일 상품을 하나로 합치는 정책 자체가 주문의 비즈니스 규칙이라고 생각이 들어 주문 Service에 PlaceOrder에 들어가야 한다고 생각했지만 그렇게 된다면 재고를 차감하는 부분에서 불필요한 중복쿼리가 실행될 수도 있어 이번 구현에서는 주문 생성 전에 요청 데이터를 정규화하는 책임으로 분리하여 FACADE 흐름으로 담당하도록 역할을 도메인과 분리했다. 

 

이처럼 FACADE에서는 입력 정규화의 흐름을 보여주기도 한다. 

 

Service

 

Service의 책임은 도메인 규칙을 수행하고, 여러 도메인 동작을 하나의 흐름으로 조합하는 것이다. 

@Transactional
public OrderModel placeOrder(OrderModel order, List<ProductStockModel> stocks, List<OrderItemInput> inputs) {
    buildItems(order, stocks, inputs);
    order.updateTotal(orderTotalPolicy.calculate(order.getItems()));
    return orderRepository.save(order);
}

 

주문 생성 과정에서는 주문 상품 구성, 총 주문 금액 계산, 주문 상태 변경과 같은 도메인 규칙이 함께 수행된다.

OrderService는 이러한 도메인 동작을 하나의 흐름으로 조합하고, 최종적으로 주문 Aggregate를 완성하는 역할을 담당한다.

 

특히 총 주문 금액 계산은 할인 정책, 쿠폰, 프로모션 등의 요구사항에 따라 계산 방식이 변경될 가능성이 높다고 판단했다.

따라서 계산 책임 자체는 주문 흐름에 포함하되, 계산 방식은 OrderTotalPolicy라는 별도의 정책 객체로 분리했다.

@Component
public class DefaultOrderTotalPolicy implements OrderTotalPolicy {

    @Override
    public Money calculate(List<OrderItemModel> items) {
        long total = items.stream()
                .mapToLong(item -> item.getProductPrice().getValue() * item.getQuantity().getValue())
                .sum();
        return new Money(total);
    }
}

 

이를 통해 OrderService는 주문 흐름 orchestration에 집중하고, 계산 정책은 독립적으로 확장 및 교체할 수 있도록 구성했다.

Domain & VO

 

도메인은 단순 데이터 보관 객체가 아니라, 스스로 상태와 invariant를 보호하는 역할을 담당한다.

private static final int PAYMENT_EXPIRY_MINUTES = 15;

@Column(name = "order_number", nullable = false, length = 20)
private String orderNumber;

@Column(nullable = false)
private Long userId;

@Embedded
@AttributeOverride(name = "value", column = @Column(name = "total_amount", nullable = false))
private Money totalMoney;

@Enumerated(EnumType.STRING)
@Column(nullable = false)
private OrderStatus status;

@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItemModel> items = new ArrayList<>();

public OrderModel(Long userId) {
    Guard.notNull(userId, "사용자 ID는 필수입니다.");
    this.orderNumber = UUID.randomUUID().toString().replace("-", "").substring(0, 20);
    this.userId = userId;
    this.totalMoney = new Money(0L);
    this.status = OrderStatus.REQUESTED;
}

public void updateTotal(Money total) {
    Guard.notNull(total, "주문 금액은 필수입니다.");
    this.totalMoney = total;
}

 

주문 도메인은 다음과 같은 invariant를 스스로 보호하도록 구성했다.

 

  • 주문은 REQUESTED 상태로 생성되어야 한다
  • 주문 금액은 음수가 될 수 없다
  • 잘못된 상태 변경을 허용하지 않는다

또한 금액과 수량 같은 값들은 단순 primitive 타입이 아니라, 값 자체로 의미와 검증 규칙을 가지는 VO(Value Object)로 분리했다.

public class Money {

    @Column(name = "total_amount", nullable = false)
    private Long value;

    public Money(Long value) {
        Guard.notNegative(value, "금액은 0 이상이어야 합니다.");
        this.value = value;
    }
}

 

이를 통해 단순 숫자가 아니라 “의미 있는 값” 자체를 객체로 표현하고, 관련 규칙 역시 객체 내부에서 함께 관리하도록 구성했다.

 

주문 생성 흐름에서 가장 어려웠던 부분은 “어떤 계층이 어디까지 책임져야 하는가”를 결정하는 일이었고 결국 중요한 것은 단순히 계층을 나누는 것이 아니라, 각 계층이 자신의 책임에만 집중할 수 있도록 경계를 분리하는 것이라고 생각한다.

저작자표시 (새창열림)

'루퍼스 4기' 카테고리의 다른 글

루퍼스 4주차 WIL (동시성 문제 해결)  (0) 2026.06.14
쇼핑몰에서 안전하게 재고 차감하기  (0) 2026.06.12
루퍼스 2주차 WIL (소프트웨어 설계)  (0) 2026.05.24
이커머스 도메인을 설계하면서 나는 어떤 고민을 했을까  (0) 2026.05.22
루퍼스 1주차 WIL (TDD & 테스트 가능한 구조)  (0) 2026.05.15
'루퍼스 4기' 카테고리의 다른 글
  • 루퍼스 4주차 WIL (동시성 문제 해결)
  • 쇼핑몰에서 안전하게 재고 차감하기
  • 루퍼스 2주차 WIL (소프트웨어 설계)
  • 이커머스 도메인을 설계하면서 나는 어떤 고민을 했을까
LIMTAEYANG
LIMTAEYANG
오늘 할 코딩을 내일로 미루지 말자
  • LIMTAEYANG
    lty's Blog
    LIMTAEYANG
  • 전체
    오늘
    어제
    • 분류 전체보기 (80)
      • 웹개발 (23)
        • JAVA (1)
        • Spring (6)
        • JavaScript (7)
        • css (2)
        • Vue (1)
      • 클라우드 (4)
        • AWS (2)
        • MSA (0)
        • Docker (2)
      • 운영 및 배포 (4)
      • Backend (6)
      • Database (2)
      • 명령어 (2)
        • Linux (2)
      • 형상관리 (8)
        • github & git (8)
      • 알고리즘 (1)
      • 개인 프로젝트 (2)
      • CS (11)
        • 소프트웨어 공학 (10)
        • 운영체제 (1)
      • 정보처리기사 (2)
      • 취미활동 (2)
        • 등산 (1)
        • 여행 (0)
        • 글쓰기 (1)
      • 회고 (0)
      • 루퍼스 4기 (13)
  • 블로그 메뉴

    • 홈
    • 개인프로젝트
    • 프로그래밍 언어
    • 운영체제
    • 형상관리
    • 자기개발
  • 링크

  • 공지사항

  • 인기 글

  • 태그

    배포 자동화
    LOOPPAK
    설계ERD
    대기열 TPS 테스트
    낙관적 락
    환경변수
    Callback 장애 대응
    DDD 주문설계
    대기열 배치사이즈
    설계
    prefilight
    루퍼스 4기 수료 후기
    원자적 연산
    PG 정합성
    루프팩백엔드4기
    4주차 WIL
    CDC 방식
    reset
    CI/CD
    되돌리기
    Jenkins
    TDD
    소프트웨어
    랭킹 레디스 장애 대응
    루퍼스 백엔드 코스
    2주차 회고
    비관적 락
    테스팅
    재고 동시성 차감
    아웃박스 테이블 polling 방식
  • 최근 댓글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.6
LIMTAEYANG
주문 생성 흐름에서 책임을 어디까지 나눌 것인가
상단으로

티스토리툴바