종우의 삶 (전체 공개)

Market 프로젝트 리팩토링 - 2 // Order 도메인 리팩토링 본문

개발/Spring

Market 프로젝트 리팩토링 - 2 // Order 도메인 리팩토링

jonggae 2024. 3. 14. 01:15

지난 포스트에서 진행하지 못한 Order 도메인의 Service 코드를 분석해본다.

 

다른 도메인과는 다르게 Order에는 다양한 도메인과 연관되어 있는 것이 많았다.

 

단순히 Service 코드 내에서 리팩토링을 진행하면 되는 줄 알았는데, 찾아보니 DDD, 도메인 주도 설계 (Domain-Driven Design)방식 이라는 것이 있었다. 

 

트랜잭션 스크립트라는 말도 이번에 처음 알게 되었는데, 비즈니스 로직이 서비스 레이어에 집중되어있는 형태라 알게모르게 현재까지 이러한 방식으로 설계를 해왔던 것 같다. 사실 다른 방법은 알지도 못했다. 막연히 다양한 메서드들은 Service에서 관리하고, 다양한 의존성주입으로 다른 레이어에서 사용되는 것만 알고 있었을뿐. 

 

애초에 이러한 방식도 제대로 능란하게 사용을 하지 못했기에 다른 방식은 아직 찾아볼 여력이 없었던 것 같다.

트랜잭션 스크립트 방식에도 원칙들이 존재하고, SOLID 라고 불리는 객체지향의 설계원칙을 적용할 수도 있을것이다. 

 

객체 지향 설계에서의 원칙들,

다양한 디자인 패턴이나 아키텍쳐 설계법들 (DDD, Transaction Script)과 같은 다양한 지식들을 얻게 되어 기분이 좋다. 좀 더 알아보고 서적도 읽어보는 시간이 필요하겠다. 더 많은 지식이 생긴다면 코드를 작성하는데 더 도움이 되지 않을까.


리팩토링

 

어쨌든 DDD는 말만 들어봤지 실제로 어떤 코드형태인지는 잘 몰랐는데, Order 도메인을 변경해보면서 조금은 알게 된 것 같다.

(사실 찾아보니 무척 복잡했다.. 아직 정확히 파악하진 못한듯)

 

Order 도메인의 Service 레이어는 무척이나 복잡한 형태였다. 다양한 코드들을 참고하고, 기능 구현에 초점을 맞추어 개발을 우선으로 하다보니 묘한 로직들이 보였다. 내가 작성했는데도 이해가 안가는 그러한 코드들이 생긴 것이다. 가령

 

public List<OrderDto> getOrderList(Long customerId) {
        List<Order> orders = orderRepository.findAllByCustomerId(customerId);

        return orders.stream().map(order -> {
            List<OrderItemDto> orderItems = order.getOrderItems().stream()
                    .map(OrderItemDto::from)
                    .collect(Collectors.toList());

            return OrderDto.builder()
                    .orderId(order.getId())
                    .customerId(customerId)
                    .orderItems(orderItems)
                    .status(order.getOrderStatus()) // 실제 주문의 상태를 반영
                    .orderDateTime(order.getOrderDate())
                    .build();
        }).collect(Collectors.toList());
    }

 

회원의 주문목록을 조회하는 이러한 로직을 살펴보면, 

클라이언트의 GET 요청을 받고, 그 클라이언트의 customerId를 조회하여 customerId에 연결되어있는

Order 객체, Order 객체 안의 OrderItem을 List로 만들어 DTO로 담아 반환하는 것이다. 

 

반환 값 내부에 다시 OrderItem과 OrderDto 객체를 만들어버리는 요상한 로직이 되어있었는데, 이럴 필요가 없었던 것이다. 우선 이 로직은 단순히 정적메서드 from을 사용하는 것으로 해결되었다. 코드가 간략해지고, 가독성이 좋아졌다.

 

    public List<OrderDto> getOrderList(Long customerId) {
        return orderRepository.findAllByCustomerId(customerId).stream()
                .map(OrderDto::from)
                .collect(Collectors.toList());
    }

--------

 public static OrderDto from(Order order) {
        List<OrderItemDto> orderItemsDto = order.getOrderItems().stream()
                .map(OrderItemDto::from)
                .collect(Collectors.toList());

        return OrderDto.builder()
                .orderId(order.getId())
                .customerId(order.getCustomer().getId())
                .orderItems(orderItemsDto)
                .status(order.getOrderStatus())
                .orderDateTime(order.getOrderDate())
                .build();
    }

 

OrderDto 클래스에 from으로 Dto를 생성하는 로직을 만들어준다. (이미 예전에 많이 했었던)

로직의 마지막에서 어쨌든 Order 객체를 Dto로 변화해야하므로, 이러한 로직을 미리 from 메서드로 만들어 놓는다.

 

객체에서 DTO로의 변환이 캡슐화되어 중복 코드없이 나중에 재사용이 가능하다.

 

Repository와 DTO변환이 함께 쓰였던 메서드에서, from을 통해 분리함으로써 관심사가 분리되었다.

가독성은 덤이었다.

 

이러한 형태의 리팩토링은 앞에서 이미 많이 진행했었다. 왜 저렇게 처음에 만들었는지? ㅋㅋ


DDD를 실제로 적용한 부분들을 살펴보자.

상품의 재고수량 에 대한 메서드들이 Product entity로 이전되었다.

 

변경 이전의 코드

 public OrderItemDto addOrderItem(Long customerId, OrderItemDto orderItemDto) {
        customerRepository.findById(customerId)
                .orElseThrow(NotFoundMemberException::new);

        Order order = orderRepository.findByCustomerIdAndOrderStatus(customerId, OrderStatus.PENDING_ORDER)
                .orElseGet(() -> createPendingOrder(customerId));

        Product product = productRepository.findById(orderItemDto.getProductId())
                .orElseThrow(NotFoundProductException::new);

        OrderItem newOrderItem = OrderItem.builder()
                .order(order)
                .product(product)
                .quantity(orderItemDto.getQuantity())
                .build();

        newOrderItem = orderItemRepository.save(newOrderItem);

        order.getOrderItems().add(newOrderItem);
        orderRepository.save(order);

        return OrderItemDto.from(newOrderItem);
    }

 

나의 주문에 주문할 상품을 추가하는 로직이다.

클라이언트는 이렇게 자신의 주문함에 상품들을 추가하고, 이후 주문 확정 로직을 거칠 것이다.

 

단순히 주문할 상품을 추가하는 과정이다. 

우선 매개변수 customerId를 이용하여 사용자를 식별한다. 

 

customerId에 해당하는 사용자의 Order, 주문목록을 불러온다. 비어있거나, 물건이 있을것이다.

만약 주문 목록 자체가 없다면 createPendingOrder메서드를 실행해 빈 목록을 만든다.

 

실제 상품 추가 로직은 직후 등장한다.

builder 패턴으로 OrderItem 객체를 새롭게 생성하여 원하는 상품을 OrderItem 객체로 만들어 저장한다. OrderItemRepository.save() 사용.

 

이후 Order 엔티티의 OrderItems().add(newOrderItem)으로 리스트에 그 객체를 추가하여 준다. 

그리고 주문 객체를 저장하여주면 로직이 마무리 되는 것이다.

 

반환값은 OrderItemDto로 받는다. 

 

어떻게 바꿀 수 있었을까?

 

변경 이후 로직

 public OrderItemDto addOrderItem(Long customerId, OrderItemDto orderItemDto) {
        customerRepository.findById(customerId)
                .orElseThrow(NotFoundMemberException::new);

        Order order = orderRepository.findByCustomerIdAndOrderStatus(customerId, OrderStatus.PENDING_ORDER)
                .orElseGet(() -> createPendingOrder(customerId));

        Product product = productRepository.findById(orderItemDto.getProductId())
                .orElseThrow(NotFoundProductException::new);

        // 재고 줄이고 확인
        boolean stockReduced = product.reduceStock(orderItemDto.getQuantity());
        if (!stockReduced) {
            throw new InsufficientStockException(product.getProductName());
        }

        OrderItem newOrderItem = order.addOrderItem(product, orderItemDto.getQuantity());
        orderRepository.save(order);

        return OrderItemDto.from(newOrderItem);
    }

 

여기서 재고와 관련된 로직이 추가되었다. (boolean stockReduced)

API 테스트를 진행한 결과, 재고가 모자람에도 주문 추가는 가능한 응답이 나왔다. (재고가 20인데 주문에 1500개를 추가해도 들어감) <<< 이러한 방식은 옳지 않다고 생각하여, 그것을 체크해주기로 한 것이다.

 

추가된 로직들을 자세히 살펴보자

 

   // ... 
   boolean stockReduced = product.reduceStock(orderItemDto.getQuantity());
        if (!stockReduced) {
            throw new InsufficientStockException(product.getProductName());
        }

        OrderItem newOrderItem = order.addOrderItem(product, orderItemDto.getQuantity());
        orderRepository.save(order);

        return OrderItemDto.from(newOrderItem);

 

product.reduceStock

order.addOrderItem 메서드가 새롭게 추가되었다. 이것은 각각의 도메인의 엔티티 클래스에 추가되었다. (DDD)

 

 public boolean reduceStock(int quantity) {
        long newStock = this.stock - quantity; // 현재 재고에서 요청된 수량을 뺀다.
        if (newStock<0) { // 만약 그 요청이 재고를 초과하면
            return false; // false 반환
        }
        this.stock = newStock; // 재고를 줄이고 성공적으로 재고 감소가 되었음을 나타냄
        return true;
    }
    
    /////////////////
    
    public OrderItem addOrderItem(Product product, int quantity) {
        product.reduceStock(quantity);
        OrderItem newOrderItem = OrderItem.builder()
                .order(this)
                .product(product)
                .quantity(quantity)
                .build();
        this.orderItems.add(newOrderItem);
        return newOrderItem;
    }

 

Order 엔티티의 addOrderItem() 메서드에도 reduceStock이 들어가있으므로,

사용자는 주문 상품을 추가할 때 재고를 넘는 수치를 요청하면 에러 메시지를 받게 된다.

 

boolean 값의 stockReduced는 이러한 로직에서 False를 받으면 InsufficientStockException 을 반환할 수 있게 만든 것이다. T F의 값이 헷갈릴 수 있으니 주의하자.

 

그리고 Order entity에 추가된 addOrderItem() 메서드에 실제로 재고를 감소시키고 orderItem 객체를 생성하는 로직이 들어갔다.

이후 save()를 통해 DB에 업데이트 되고, 반환하여 사용자에게 추가된 주문 상품을 보여주는 것이다.

 

이 과정에서 서비스 레이어가 아닌 Product, Order 엔티티에 직접 메서드가 추가되었다. 사실 서비스 레이어에 직접 추가하여 사용하여도 되지만, 다른 형태의 디자인이므로 한번 경험해보는 식으로 접근해보았다.

 

Entity에 직접 메서드를 추가하여 DDD방식으로 구현하게되면, 앞서 말했듯 재사용에도 이점이 있고 상대적으로 덜 복잡한 코드를 보여줄 수 있다. 물론 entity 코드가 복잡해 질수가 있으니 주의하자.


이런식으로 몇가지의 메서드들을 직접 도메인 entity에 추가하며 리팩토링을 진행하였다.

 

정리를 하면서 어렴풋이 DDD방식이 어떻게 진행되는지 이해가 되는 듯 하다. 

관련있는 로직들을 비슷한 도메인에 함께 작성해주는 것이다. 

 

서비스 로직에 직접 구현하는 것과 장단점이 확연히 느껴지고 있다. 코드의 형태도 늘 봐오던 모습과 다르고

각 도메인의 Entity 클래스에 직접 들어가서 로직을 확인해봐야 하는 수고가 생겼다. 또한 직접 겪지는 못했지만 트랜잭션관리에 문제가 생길수도 있다고 한다.

 

다시 훑어보니 또 코드에 빈틈이 보이고 있는데, 리팩토링은 계속 진행될 것이다. 

 

좀더 탄탄하고 깔끔한 코드를 만들 수 있도록 노력해보자!

Comments