ABOUT ME

-

Today
-
Yesterday
-
Total
-
  • 계층형 아키텍처에서 정책 변경이 왜 점점 버거워 질까?
    시스템설계 2026. 3. 23. 16:54
    728x90
    반응형

    계층형 아키텍처에서 정책 변경이 왜 점점 버거워질까?

    주문 취소 정책 변경 예제로 보는 20단계 분석

    좋아요. 이 글에서는 아래와 같은 주문 취소 정책 변경이 왜 단순한 조건 수정이 아니라 시스템 전체에 연쇄 영향을 주는 일이 되는지, 실제 코드 예제를 통해 단계별로 살펴봅니다.

    변경된 취소 정책

    • 배송 시작 전까지 취소 가능
    • 디지털 상품은 결제 후 즉시 취소 불가
    • 쿠폰 사용 주문은 취소 시 쿠폰 복구 필요

    처음 보면 단순한 정책 추가처럼 보입니다. 하지만 프로젝트가 커지고 계층형 구조에서 정책이 여러 계층에 흩어지기 시작하면, 이 작은 변경 하나가 Controller, Service, Repository, 외부 시스템 연동, 이벤트 처리, 테스트까지 모두 흔들게 됩니다.


    1단계. 처음에는 취소 규칙이 아주 단순하다

    처음 요구사항은 다음과 같다고 해봅시다.

    • 주문 상태가 PAYMENT_PENDING이면 취소 가능
    • 결제가 끝난 PAID 상태부터는 취소 불가

    이 정도면 Service 하나에서 처리해도 크게 불편하지 않습니다.

    Controller

    @RestController
    @RequiredArgsConstructor
    @RequestMapping("/orders")
    public class OrderController {
    
        private final OrderService orderService;
    
        @PostMapping("/{orderId}/cancel")
        public ResponseEntity<String> cancelOrder(@PathVariable Long orderId) {
            orderService.cancelOrder(orderId);
            return ResponseEntity.ok("주문 취소 완료");
        }
    }

    Service

    @Service
    @RequiredArgsConstructor
    public class OrderService {
    
        private final OrderRepository orderRepository;
    
        public void cancelOrder(Long orderId) {
            Order order = orderRepository.findById(orderId)
                    .orElseThrow(() -> new IllegalArgumentException("주문이 존재하지 않습니다."));
    
            if (!order.getStatus().equals(OrderStatus.PAYMENT_PENDING)) {
                throw new IllegalStateException("결제 완료 전까지만 취소할 수 있습니다.");
            }
    
            order.setStatus(OrderStatus.CANCELED);
            orderRepository.save(order);
        }
    }

    Domain

    @Getter
    @Setter
    @AllArgsConstructor
    @NoArgsConstructor
    public class Order {
        private Long id;
        private OrderStatus status;
    }
    public enum OrderStatus {
        PAYMENT_PENDING,
        PAID,
        SHIPPED,
        DELIVERED,
        CANCELED
    }

    이 시점에는 흐름이 단순합니다.

    • 주문 조회
    • 상태 확인
    • 상태 변경
    • 저장

    그래서 많은 팀이 이 시점의 깔끔함만 보고 계층형 구조를 충분하다고 느낍니다.


    2단계. 첫 번째 정책 변경: 배송 시작 전까지 취소 가능

    이제 요구사항이 바뀝니다.

    기존 규칙

    • PAYMENT_PENDING 상태만 취소 가능

    변경 규칙

    • PAYMENT_PENDING, PAID 상태는 취소 가능
    • SHIPPED, DELIVERED 상태는 취소 불가

    즉, 결제 완료 이후라도 배송 시작 전이면 취소 가능해집니다.

    Service 수정

    기존에는 이랬습니다.

    if (!order.getStatus().equals(OrderStatus.PAYMENT_PENDING)) {
        throw new IllegalStateException("결제 완료 전까지만 취소할 수 있습니다.");
    }

    변경 후에는 이렇게 됩니다.

    if (order.getStatus() == OrderStatus.SHIPPED ||
        order.getStatus() == OrderStatus.DELIVERED) {
        throw new IllegalStateException("배송 시작 후에는 취소할 수 없습니다.");
    }

    전체 코드는 다음과 같습니다.

    @Service
    @RequiredArgsConstructor
    public class OrderService {
    
        private final OrderRepository orderRepository;
    
        public void cancelOrder(Long orderId) {
            Order order = orderRepository.findById(orderId)
                    .orElseThrow(() -> new IllegalArgumentException("주문이 존재하지 않습니다."));
    
            if (order.getStatus() == OrderStatus.SHIPPED ||
                order.getStatus() == OrderStatus.DELIVERED) {
                throw new IllegalStateException("배송 시작 후에는 취소할 수 없습니다.");
            }
    
            order.setStatus(OrderStatus.CANCELED);
            orderRepository.save(order);
        }
    }

    아직은 크게 복잡해 보이지 않습니다. 개발자는 보통 이렇게 생각합니다.

    조건문 한 줄 바꾸면 되네.

    하지만 이 시점부터 정책이 다른 계층으로 스며들기 시작하면 문제가 커집니다.


    3단계. Repository에도 정책이 들어가기 시작한다

    서비스가 커지면 이런 유혹이 생깁니다.

    어차피 취소 불가능한 주문이면 DB에서부터 걸러내면 되지 않을까?

    그래서 비즈니스 규칙 일부가 조회 조건으로 내려갑니다.

    Repository

    public interface OrderRepository extends JpaRepository<OrderEntity, Long> {
    
        @Query("""
            select o
            from OrderEntity o
            where o.id = :orderId
              and o.status not in ('SHIPPED', 'DELIVERED', 'CANCELED')
        """)
        Optional<OrderEntity> findCancelableOrder(@Param("orderId") Long orderId);
    }

    Service

    @Service
    @RequiredArgsConstructor
    public class OrderService {
    
        private final OrderRepository orderRepository;
    
        public void cancelOrder(Long orderId) {
            OrderEntity order = orderRepository.findCancelableOrder(orderId)
                    .orElseThrow(() -> new IllegalStateException("취소 가능한 주문이 아닙니다."));
    
            order.setStatus("CANCELED");
            orderRepository.save(order);
        }
    }

    이제부터 문제가 시작됩니다.

    취소 정책이 더 이상 Service 한 곳에만 있는 것이 아닙니다.

    • Service에도 정책이 있고
    • Repository 쿼리에도 정책이 있습니다

    즉, 나중에 정책이 바뀌면 둘 다 함께 수정해야 합니다.


    4단계. 두 번째 정책 변경: 디지털 상품은 결제 후 즉시 취소 불가

    이제 더 복잡한 요구사항이 추가됩니다.

    새 규칙

    • 일반 상품은 배송 시작 전까지 취소 가능
    • 디지털 상품은 결제 후 즉시 취소 불가

    이제 취소 판단은 더 이상 주문 상태만 보면 안 됩니다.

    • 주문 상태
    • 주문 상품 종류

    둘 다 함께 봐야 합니다.

    즉, 정책이 상태 중심에서 상태 + 상품 속성 중심으로 바뀝니다.


    5단계. Order가 상품 정보를 알아야 한다

    기존 Order는 상태만 있었습니다. 이제는 디지털 상품 포함 여부까지 판단해야 하므로 주문 항목 정보가 필요합니다.

    ProductType

    public enum ProductType {
        PHYSICAL,
        DIGITAL
    }

    OrderItem

    @Getter
    @AllArgsConstructor
    @NoArgsConstructor
    public class OrderItem {
        private Long productId;
        private ProductType productType;
        private int quantity;
    }

    Order

    @Getter
    @Setter
    @AllArgsConstructor
    @NoArgsConstructor
    public class Order {
        private Long id;
        private OrderStatus status;
        private List<OrderItem> items = new ArrayList<>();
    
        public boolean containsDigitalProduct() {
            return items.stream()
                    .anyMatch(item -> item.getProductType() == ProductType.DIGITAL);
        }
    }

    이제 취소 규칙을 위해 도메인 데이터 구조도 바뀌어야 합니다.


    6단계. Service 조건문이 점점 비대해진다

    새 규칙을 반영하면 Service는 이렇게 됩니다.

    @Service
    @RequiredArgsConstructor
    public class OrderService {
    
        private final OrderRepository orderRepository;
    
        public void cancelOrder(Long orderId) {
            Order order = orderRepository.findById(orderId)
                    .orElseThrow(() -> new IllegalArgumentException("주문이 존재하지 않습니다."));
    
            if (order.getStatus() == OrderStatus.SHIPPED ||
                order.getStatus() == OrderStatus.DELIVERED) {
                throw new IllegalStateException("배송 시작 후에는 취소할 수 없습니다.");
            }
    
            if (order.getStatus() == OrderStatus.PAID && order.containsDigitalProduct()) {
                throw new IllegalStateException("디지털 상품은 결제 후 즉시 취소할 수 없습니다.");
            }
    
            order.setStatus(OrderStatus.CANCELED);
            orderRepository.save(order);
        }
    }

    이제 취소 로직은 단순 상태 검사에서 벗어나기 시작했습니다.

    앞으로 이런 요구사항이 추가되면 어떤 일이 생길까요?

    • 예약 상품은 특정 시점 이후 취소 불가
    • 해외배송 상품은 출고 준비 단계부터 취소 불가
    • 새벽배송 상품은 포장 시작 후 취소 불가

    모든 정책이 if 문으로 누적되기 시작합니다.


    7단계. 그런데 Repository는 여전히 예전 규칙을 들고 있다

    앞서 Repository에는 이런 쿼리가 있었습니다.

    @Query("""
        select o
        from OrderEntity o
        where o.id = :orderId
          and o.status not in ('SHIPPED', 'DELIVERED', 'CANCELED')
    """)
    Optional<OrderEntity> findCancelableOrder(@Param("orderId") Long orderId);

    문제는 이 쿼리가 디지털 상품 취소 불가 규칙을 모른다는 것입니다.

    즉, Repository는 “취소 가능한 주문”이라고 가져왔지만, Service에서는 다시 “아니다”라고 말하는 상황이 생깁니다.

    이 순간 findCancelableOrder()라는 이름 자체가 틀려집니다. 진짜 cancelable order가 아니기 때문입니다.


    8단계. 결국 Repository까지 더 복잡해진다

    개발자는 Repository도 새 정책에 맞추려고 합니다.

    @Query("""
        select distinct o
        from OrderEntity o
        join o.items i
        where o.id = :orderId
          and o.status not in ('SHIPPED', 'DELIVERED', 'CANCELED')
          and not (o.status = 'PAID' and i.productType = 'DIGITAL')
    """)
    Optional<OrderEntity> findCancelableOrder(@Param("orderId") Long orderId);

    이제 무슨 일이 벌어졌을까요?

    비즈니스 규칙이 코드에서만 복잡해진 게 아닙니다.
    이제 JPQL 쿼리 안으로도 들어갔습니다.

    즉, 정책 하나 바꾸기 위해 다음을 함께 고려해야 합니다.

    • Service 로직
    • Repository 쿼리
    • Entity 관계 매핑
    • join 중복 여부
    • 성능 영향

    정책 변경이 비즈니스 수정이 아니라 비즈니스 + 쿼리 최적화 + ORM 이해 문제로 바뀝니다.


    9단계. 세 번째 정책 변경: 쿠폰 사용 주문은 취소 시 쿠폰 복구 필요

    이제 또 다른 요구사항이 추가됩니다.

    새 규칙

    • 취소 가능 여부와 별개로
    • 이 주문이 쿠폰을 사용했다면
    • 주문 취소 시 쿠폰을 다시 복구해야 한다

    이건 단순 검증이 아닙니다.
    검증을 넘어서 부수 효과(side effect) 가 발생하는 정책입니다.

    즉, 취소 로직이 단순 판정에서 처리 오케스트레이션으로 발전합니다.


    10단계. Order에 쿠폰 정보가 추가된다

    Coupon

    @Getter
    @Setter
    @AllArgsConstructor
    @NoArgsConstructor
    public class Coupon {
        private Long id;
        private boolean used;
    
        public void restore() {
            this.used = false;
        }
    }

    Order

    @Getter
    @Setter
    @AllArgsConstructor
    @NoArgsConstructor
    public class Order {
        private Long id;
        private OrderStatus status;
        private List<OrderItem> items = new ArrayList<>();
        private Long couponId;
    
        public boolean containsDigitalProduct() {
            return items.stream()
                    .anyMatch(item -> item.getProductType() == ProductType.DIGITAL);
        }
    
        public boolean usesCoupon() {
            return couponId != null;
        }
    }

    이제 주문 취소는 주문 상태만 변경하는 작업이 아니라, 쿠폰과도 연결됩니다.


    11단계. Service는 비대해지고 외부 의존성이 늘어난다

    이제 취소 처리 하나를 위해 해야 할 일이 많아졌습니다.

    1. 주문 조회
    2. 취소 가능 여부 확인
    3. 결제 환불
    4. 쿠폰 복구
    5. 주문 상태 변경
    6. 저장
    7. 취소 이벤트 발행

    Service

    @Service
    @RequiredArgsConstructor
    public class OrderService {
    
        private final OrderRepository orderRepository;
        private final CouponRepository couponRepository;
        private final PaymentClient paymentClient;
        private final EventPublisher eventPublisher;
    
        public void cancelOrder(Long orderId) {
            Order order = orderRepository.findById(orderId)
                    .orElseThrow(() -> new IllegalArgumentException("주문이 존재하지 않습니다."));
    
            // 1. 취소 가능 여부 확인
            if (order.getStatus() == OrderStatus.SHIPPED ||
                order.getStatus() == OrderStatus.DELIVERED) {
                throw new IllegalStateException("배송 시작 후에는 취소할 수 없습니다.");
            }
    
            if (order.getStatus() == OrderStatus.PAID && order.containsDigitalProduct()) {
                throw new IllegalStateException("디지털 상품은 결제 후 즉시 취소할 수 없습니다.");
            }
    
            // 2. 결제 환불
            paymentClient.refund(order.getId());
    
            // 3. 쿠폰 복구
            if (order.usesCoupon()) {
                Coupon coupon = couponRepository.findById(order.getCouponId())
                        .orElseThrow(() -> new IllegalStateException("쿠폰 정보가 없습니다."));
                coupon.restore();
                couponRepository.save(coupon);
            }
    
            // 4. 주문 상태 변경
            order.setStatus(OrderStatus.CANCELED);
    
            // 5. 저장
            orderRepository.save(order);
    
            // 6. 이벤트 발행
            eventPublisher.publish(new OrderCanceledEvent(order.getId()));
        }
    }

    이제 OrderService는 단순 서비스가 아닙니다.

    • 정책 검증
    • 외부 결제 시스템 연동
    • 쿠폰 저장소 접근
    • 이벤트 발행

    즉, 도메인 규칙과 인프라 관심사가 한 클래스에 뒤섞이기 시작했습니다.


    12단계. 정책이 여러 계층에 흩어졌다는 말의 의미

    이제 취소 정책은 어디에 흩어져 있을까요?

    Controller에도 일부 규칙이 들어갈 수 있다

    @RestController
    @RequiredArgsConstructor
    @RequestMapping("/orders")
    public class OrderController {
    
        private final OrderRepository orderRepository;
        private final OrderService orderService;
    
        @PostMapping("/{orderId}/cancel")
        public ResponseEntity<String> cancelOrder(@PathVariable Long orderId) {
            Order order = orderRepository.findById(orderId)
                    .orElseThrow(() -> new IllegalArgumentException("주문이 없습니다."));
    
            if (order.getStatus() == OrderStatus.SHIPPED) {
                return ResponseEntity.badRequest().body("배송 시작 후 취소 불가");
            }
    
            orderService.cancelOrder(orderId);
            return ResponseEntity.ok("주문 취소 완료");
        }
    }

    Service에는 핵심 규칙이 있다

    if (order.getStatus() == OrderStatus.SHIPPED ||
        order.getStatus() == OrderStatus.DELIVERED) {
        throw ...
    }
    
    if (order.getStatus() == OrderStatus.PAID && order.containsDigitalProduct()) {
        throw ...
    }

    Repository에도 규칙이 스며들어 있다

    @Query("""
        select distinct o
        from OrderEntity o
        join o.items i
        where o.id = :orderId
          and o.status not in ('SHIPPED', 'DELIVERED', 'CANCELED')
          and not (o.status = 'PAID' and i.productType = 'DIGITAL')
    """)
    Optional<OrderEntity> findCancelableOrder(Long orderId);

    외부 연동에도 취소 정책의 후속 처리들이 연결된다

    paymentClient.refund(order.getId());
    coupon.restore();
    couponRepository.save(coupon);
    eventPublisher.publish(new OrderCanceledEvent(order.getId()));

    이게 바로 “정책이 흩어졌다”는 뜻입니다.


    13단계. 정책 v2: 더 현실적인 복잡도가 들어온다

    이제 비즈니스 팀이 다시 요구사항을 바꿉니다.

    변경된 취소 정책 v2

    • 배송 시작 전까지 취소 가능
    • 디지털 상품도 구매 후 10분 이내면 취소 가능
    • 쿠폰은 주문 취소 완료 후 1시간 뒤 복구
    • 단, 프로모션 쿠폰은 복구 불가

    겉보기에는 “조금 더 정교해졌다” 수준이지만, 실제로는 시스템 전반에 영향을 줍니다.


    14단계. Domain 수정이 필요해진다

    디지털 상품 10분 제한을 계산하려면 주문 결제 시각이 필요합니다.

    Order

    @Getter
    @Setter
    @AllArgsConstructor
    @NoArgsConstructor
    public class Order {
        private Long id;
        private OrderStatus status;
        private List<OrderItem> items = new ArrayList<>();
        private Long couponId;
        private LocalDateTime paidAt;
    
        public boolean containsDigitalProduct() {
            return items.stream()
                    .anyMatch(item -> item.getProductType() == ProductType.DIGITAL);
        }
    }

    이제 단순한 상태 모델이 시간 개념까지 품기 시작합니다.


    15단계. Service 규칙도 다시 변경된다

    if (order.containsDigitalProduct() && order.getStatus() == OrderStatus.PAID) {
        if (order.getPaidAt().isBefore(LocalDateTime.now().minusMinutes(10))) {
            throw new IllegalStateException("디지털 상품은 결제 후 10분 이내에만 취소할 수 있습니다.");
        }
    }

    여기서 중요한 건 단순히 코드가 길어졌다는 게 아닙니다.

    이제 취소 가능 여부가 다음 요소를 동시에 봐야 하는 판단이 되었습니다.

    • 상태
    • 상품 타입
    • 시간

    즉, 규칙이 한 차원 더 복잡해졌습니다.


    16단계. Repository도 다시 바뀌어야 할 수 있다

    기존 Repository는 디지털 상품이면 PAID 상태에서 취소 불가라고 가정하고 있었습니다.

    하지만 이제는 10분 이내면 가능이므로 쿼리도 바뀌어야 합니다.

    @Query("""
        select distinct o
        from OrderEntity o
        join o.items i
        where o.id = :orderId
          and o.status not in ('SHIPPED', 'DELIVERED', 'CANCELED')
          and not (
                o.status = 'PAID'
            and i.productType = 'DIGITAL'
            and o.paidAt < :cancelDeadline
          )
    """)
    Optional<OrderEntity> findCancelableOrder(
            @Param("orderId") Long orderId,
            @Param("cancelDeadline") LocalDateTime cancelDeadline
    );

    Service 호출부도 바뀝니다.

    OrderEntity order = orderRepository.findCancelableOrder(
            orderId,
            LocalDateTime.now().minusMinutes(10)
    ).orElseThrow(...);

    이제 단순 정책 변경이 아니라 다음이 함께 얽힙니다.

    • 시간 계산
    • 쿼리 파라미터 설계
    • 인덱스 고려
    • DB 표현식 정확성

    17단계. 쿠폰 복구 정책도 지연 처리로 바뀐다

    이전에는 취소 시 쿠폰을 즉시 복구했습니다.

    coupon.restore();
    couponRepository.save(coupon);

    하지만 정책이 바뀌어 1시간 뒤 복구가 됩니다.

    이제는 즉시 저장이 아니라 스케줄링 또는 이벤트 기반 처리로 바뀝니다.

    couponRecoveryScheduler.scheduleRestore(order.getCouponId(), LocalDateTime.now().plusHours(1));

    또는 이벤트 발행 방식일 수도 있습니다.

    eventPublisher.publish(
        new CouponRestoreRequestedEvent(order.getCouponId(), LocalDateTime.now().plusHours(1))
    );

    즉, 이번에는 단순 Service 수정이 아니라 다음이 필요해질 수 있습니다.

    • 스케줄러 추가
    • 비동기 이벤트 처리기 추가
    • 메시지 구조 정의
    • 실패 재시도 정책 수립

    18단계. 프로모션 쿠폰은 복구 불가라는 예외가 생긴다

    정책이 더 정교해지면 쿠폰도 타입을 구분해야 합니다.

    CouponType

    public enum CouponType {
        NORMAL,
        PROMOTION
    }

    Coupon

    @Getter
    @Setter
    @AllArgsConstructor
    @NoArgsConstructor
    public class Coupon {
        private Long id;
        private CouponType type;
        private boolean used;
    
        public boolean restorable() {
            return type == CouponType.NORMAL;
        }
    
        public void restore() {
            if (!restorable()) {
                throw new IllegalStateException("복구할 수 없는 쿠폰입니다.");
            }
            this.used = false;
        }
    }

    Service

    if (order.usesCoupon()) {
        Coupon coupon = couponRepository.findById(order.getCouponId())
                .orElseThrow(() -> new IllegalStateException("쿠폰 정보가 없습니다."));
    
        if (coupon.restorable()) {
            couponRecoveryScheduler.scheduleRestore(coupon.getId(), LocalDateTime.now().plusHours(1));
        }
    }

    이제 주문 취소 정책이 주문만의 문제가 아닙니다.
    쿠폰 도메인의 정책까지 함께 이해해야 합니다.


    19단계. 테스트는 왜 점점 무거워지는가

    처음 취소 정책은 단순해서 테스트도 단순했습니다.

    @Test
    void paymentPendingOrder_canBeCanceled() {
        Order order = new Order(1L, OrderStatus.PAYMENT_PENDING, new ArrayList<>(), null);
    
        order.setStatus(OrderStatus.CANCELED);
    
        assertThat(order.getStatus()).isEqualTo(OrderStatus.CANCELED);
    }

    하지만 지금은 다릅니다.

    • 주문 상태
    • 상품 타입
    • 결제 시각
    • 쿠폰 타입
    • 환불 호출 여부
    • 이벤트 발행 여부
    • 쿠폰 지연 복구 요청 여부

    모두 함께 검증해야 합니다.

    @ExtendWith(MockitoExtension.class)
    class OrderServiceTest {
    
        @Mock OrderRepository orderRepository;
        @Mock CouponRepository couponRepository;
        @Mock PaymentClient paymentClient;
        @Mock EventPublisher eventPublisher;
        @Mock CouponRecoveryScheduler couponRecoveryScheduler;
    
        @InjectMocks
        OrderService orderService;
    
        @Test
        void digitalProduct_paidAfter10Minutes_cannotBeCanceled() {
            OrderItem item = new OrderItem(100L, ProductType.DIGITAL, 1);
    
            Order order = new Order();
            order.setId(1L);
            order.setStatus(OrderStatus.PAID);
            order.setItems(List.of(item));
            order.setPaidAt(LocalDateTime.now().minusMinutes(11));
    
            when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
    
            assertThatThrownBy(() -> orderService.cancelOrder(1L))
                    .isInstanceOf(IllegalStateException.class)
                    .hasMessage("디지털 상품은 결제 후 10분 이내에만 취소할 수 있습니다.");
        }
    
        @Test
        void couponOrder_cancel_requestsCouponRestore() {
            OrderItem item = new OrderItem(100L, ProductType.PHYSICAL, 1);
    
            Order order = new Order();
            order.setId(1L);
            order.setStatus(OrderStatus.PAID);
            order.setItems(List.of(item));
            order.setCouponId(10L);
            order.setPaidAt(LocalDateTime.now().minusMinutes(1));
    
            Coupon coupon = new Coupon(10L, CouponType.NORMAL, true);
    
            when(orderRepository.findById(1L)).thenReturn(Optional.of(order));
            when(couponRepository.findById(10L)).thenReturn(Optional.of(coupon));
    
            orderService.cancelOrder(1L);
    
            verify(paymentClient).refund(1L);
            verify(couponRecoveryScheduler).scheduleRestore(eq(10L), any(LocalDateTime.class));
            verify(eventPublisher).publish(any(OrderCanceledEvent.class));
        }
    }

    이제 테스트는 단순 도메인 규칙 검증이 아니라 여러 의존성을 조립한 통합된 시나리오 검증이 되어갑니다.


    20단계. 왜 이런 구조가 결국 유지보수를 어렵게 만드는가

    핵심은 이것입니다.

    비즈니스 규칙이 한 곳에 응집되어 있지 않습니다.

    정책이 다음 곳들에 분산됩니다.

    • Controller의 사전 검증
    • Service의 핵심 조건문
    • Repository의 조회 조건
    • 외부 결제 시스템 연동
    • 쿠폰 복구 로직
    • 이벤트 발행
    • 비동기 후처리
    • 테스트 시나리오

    그래서 정책 하나 바뀔 때마다 다음 문제가 발생합니다.

    1. 수정 지점을 찾기 어렵다

    취소 정책이 어디에 숨어 있는지 한눈에 파악하기 어렵습니다.

    2. 한 곳만 바꾸면 버그가 난다

    Service는 바꿨는데 Repository는 옛 정책을 유지할 수 있습니다.

    3. 이름과 실제 의미가 어긋난다

    findCancelableOrder()라고 했지만, 실제로는 Service에서 다시 취소 불가 판정을 내릴 수 있습니다.

    4. 작은 변경이 큰 변경으로 번진다

    “디지털 상품 10분 제한” 같은 정책 추가가 Domain, Query, Event, Scheduler, Test까지 확산됩니다.

    5. 테스트 비용이 계속 증가한다

    핵심 정책을 검증하려고 해도 환불, 쿠폰, 이벤트, 시간 조건까지 함께 세팅해야 합니다.


    마무리

    처음에는 단순했던 주문 취소 정책이 다음과 같이 커졌습니다.

    • 배송 시작 전까지 취소 가능
    • 디지털 상품 예외
    • 쿠폰 복구 필요
    • 디지털 상품 10분 제한
    • 쿠폰 지연 복구
    • 프로모션 쿠폰 복구 불가

    이 변화 자체가 문제는 아닙니다.
    비즈니스는 원래 점점 복잡해집니다.

    문제는 이 복잡한 정책이 한 곳에 모이지 않고 여러 계층에 흩어질 때입니다.

    그 순간부터 cancelOrder()는 단순한 취소 함수가 아니라,
    정책 판정, 외부 시스템 연동, 후속 처리, 이벤트 발행, 스케줄링까지 모두 떠안는 거대한 오케스트레이션 코드가 됩니다.

    그리고 바로 그 지점에서 많은 팀이 이런 질문을 하게 됩니다.

    이 취소 정책을 Service의 if문 덩어리로 계속 관리하는 게 맞을까?

    이 질문이 바로 다음 단계로 이어집니다.

    • 도메인으로 규칙을 모을 것인가
    • 애플리케이션 서비스는 무엇만 담당해야 하는가
    • Repository는 데이터 접근만 하게 할 것인가
    • 외부 시스템 연동은 어떻게 분리할 것인가

    즉, 여기서부터 헥사고날 아키텍처, 클린 아키텍처, DDD 같은 접근이 필요한 이유가 자연스럽게 드러납니다.


    다음 글 예고

    다음 글에서는 같은 예제를 그대로 이어서 아래 흐름으로 리팩터링해볼 수 있습니다.

    1. 안 좋은 계층형 구조
    2. 조금 개선된 계층형 구조
    3. 헥사고날 / 클린 아키텍처 스타일로 분리한 구조

    같은 주문 취소 정책을 기준으로, 무엇을 어디로 옮겨야 하는지 코드로 비교해보면 차이가 훨씬 선명하게 보입니다.

    728x90
    반응형
Designed by Tistory.