Home [soldout] 즉시 구매 기능 구현시 책임 분리 문제
Post
Cancel

[soldout] 즉시 구매 기능 구현시 책임 분리 문제

# 문제점


판매 입찰 최고가에 대한 즉시 구매 기능을 구현하던 중, 클래스 별 책임 분리가 애매한 상황에 마주하게 되었습니다.

수정 전 코드는 다음과 같습니다.

OrderServiceImpl

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

  private final OrderRepository orderRepository;

  private final TradeService tradeService;

  // 즉시 구매 기능
  @Override
  @Transactional
  public void orderNow(OrderCommand command) {
    OrderDto order = OrderDto.builder()
        .userId(command.getUserId())
        .productId(command.getProductId())
        .size(command.getSize())
        .price(command.getPrice())
        .date(command.getPeriod())
        .type(command.getType())
        .status(OrderStatus.BID_PROGRESS)
        .build();
    // 구매 목록 저장
    orderRepository.saveOrder(order);
    // 거래 체결 과정 -> Trade에게 책임 이관
    tradeService.matchTradeByOrder(
        order.getId(), order.getProductId(), order.getSize(), order.getPrice()
    );
    // 거래 체결 후 "구매 완료"로 상태 정보 변경
    orderRepository.updateOrderStatus(order.getId(), OrderStatus.MATCHING_COMPLETE);

  }
  
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
@Service
@RequiredArgsConstructor
public class TradeServiceImpl implements TradeService {

  private final TradeRepository tradeRepository;

  private final SaleRepository saleRepository;
  
  // 즉시구매 요청에 따른 거래 체결 기능
  @Override
  @Transactional
  public void matchTradeByOrder(int orderId, int productId, int size, int price) {

    List<SaleDto> saleDtoList = saleService.findByProductIdAndSizeAndPriceAndSaleStatus(

        productId, size, price, SaleStatus.BID_PROGRESS

    );

    if (saleDtoList.size() == 0) {

      throw new AlreadyMatchedException("찾는 판매 입찰가가 없습니다.");

    }

    int saleId = findFirstSaleId(saleDtoList);

    TradeDto tradeDto = TradeDto.builder()
        .productId(productId)
        .orderId(orderId)
        .saleId(saleId)
        .size(size)
        .price(price)
        .status(TradeStatus.MATCHING_COMPLETE)
        .date(LocalDateTime.now())
        .build();

    tradeRepository.saveTrade(tradeDto);

    saleRepository.updateSaleStatus(saleId, SaleStatus.MATCHING_COMPLETE);

  }
  
  private int findFirstSaleId(List<SaleDto> saleDtoList) {

    Optional<SaleDto> findSaleDto = saleDtoList.stream().findFirst();

    return findSaleDto.map(SaleDto::getId).orElse(0);

  }

}

위 코드에서 즉시 구매 기능은 1)새로운 Order 개체에 대한 생성 및 저장이 완료되면 2)TradeService 객체에서 거래 체결을 위한 메소드를 호출하고, 거래 체결이 완료되면 3)구매 상태 값을 변경하는 순서로 동작합니다.

그러다 보니 TradeSeviceImpl에서 거래 체결에 대한 로직을 구현하기 위해 SaleRepository를 주입받아 메소드를 호출해와야 하고 OrderRepository또한 추후에 주입해줘야 할 것으로 예상됩니다.

이러한 구조 속에서는 Trade 단에서 Sale 테이블에 대한 조회나 거래 체결 이후 Sale 개체의 상태 변경 메소드를 호출해야 합니다.

이렇게 구조가 복잡해지고 각 클래스끼리의 결합도도 높아져 기능 수정이 필요할 경우 연관된 많은 코드를 수정해야 할 수 있는 위험이 높아지고 있었습니다.

이는 SRP에 대한 위배로 인해 발생한 문제라 생각했고 거래 체결 로직에 대해 처리를 담당해줄 새로운 존재가 필요할 것으로 판단했습니다.

# 해결방안


ApplicationEventPublisher

ApplicationEventPublisher 클래스는 특정 이벤트를 발행시켜주는 역할을 합니다.

EventListener

EventListener의 역할로 선언된 클래스는 ApplicationEventPublisher에서 발행한 이벤트 객체를 가지고 해당 메소드를 실행시켜 추가적인 로직이 동작하도록 구성할 수 있습니다.

Listener의 역할을 할 메소드에 EventListener 어노테이션을 선언하면 따로 상속을 받을 필요 없이 원하는 클래스를 Listener로 지정할 수 있습니다.

OrderServiceImpl(수정후)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
@Slf4j
@Service
@RequiredArgsConstructor
public class OrderServiceImpl implements OrderService {

  private final OrderRepository orderRepository;
  
  // ApplicationEventPublisher 객체 주입
  private final ApplicationEventPublisher eventPublisher;

  @Override
  @Transactional
  public void orderNow(OrderCommand command) {

    OrderDto order = OrderDto.builder()
        .userId(command.getUserId())
        .productId(command.getProductId())
        .size(command.getSize())
        .price(command.getPrice())
        .date(command.getPeriod())
        .type(command.getType())
        .status(OrderStatus.BID_PROGRESS)
        .build();

    orderRepository.saveOrder(order);
    // 이벤트 발행
    eventPublisher.publishEvent(

        OrderCreated.from(order.getId(), order.getProductId(), order.getSize(), order.getPrice()

        )

    );

  }
}

OrderServiceImpl 에선 TradeService 빈 객체를 주입받는 것이 아닌 ApplicationEventPublisher 객체를 주입받아 order 개체 저장 후 이벤트를 발행하도록 합니다.

OrderCreated

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Getter
@AllArgsConstructor
public class OrderCreated {

  private int orderId;
  private int productId;
  private int size;
  private int price;
  
  public static OrderCreated from(int orderId, int productId, int size, int price) {

    return new OrderCreated(orderId, productId, size, price);

  }

}

발행된 이벤트 클래스의 이름은 이미 일어난 상황에 대한 설명을 하는 것이 직관적인 표현이기 때문에 과거형으로 지정했고 Listener에서 거래 체결에 대한 로직을 수행하기 위해 필요한 변수들을 가지고 있도록 구성했습니다.

또한 OrderServiceImpl 에서 Event 객체를 생성하지 않도록 팩토리 메서드 패턴을 적용해봤습니다.

TradeEventListener

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
@Component
@RequiredArgsConstructor
public class TradeEventListener {

  private final TradeService tradeService;

  private final OrderService orderService;

  private final SaleService saleService;
  
  // OrderCreated 이벤트에 대한 Listener 메소드
  // 이 위치에서 거래 체결을 위한 로직을 구현한다.
  @EventListener
  public void matchTradeByOrder(OrderCreated event) {

    List<SaleDto> saleDtoList = saleService.findByProductIdAndSizeAndPriceAndSaleStatus(

        event.getProductId(), event.getSize(), event.getPrice(), SaleStatus.BID_PROGRESS

    );

    if (saleDtoList.size() == 0) {

      throw new AlreadyMatchedException("찾는 판매 입찰가가 없습니다.");

    }

    int saleId = findFirstSaleId(saleDtoList);

    tradeService.saveTrade(

        event.getProductId(), event.getOrderId(), saleId, event.getSize(), event.getPrice()

    );

    orderService.updateOrderStatus(event.getOrderId(), OrderStatus.MATCHING_COMPLETE);

    saleService.updateSaleStatus(saleId, SaleStatus.MATCHING_COMPLETE);

  }

  private int findFirstSaleId(List<SaleDto> saleDtoList) {

    Optional<SaleDto> findSaleDto = saleDtoList.stream().findFirst();

    return findSaleDto.map(SaleDto::getId).orElse(0);

  }

}

위 코드처럼 발행된 Event 객체를 가지고 TradeEventListener 클래스 내에서 Trade, order, Sale 에 대한 서비스 객체를 주입받고, 거래 체결 로직을 수행하도록 필요한 메소드를 구성합니다.

# 마치며


거래 체결이라는 기능을 수행하기 위해선 order, sale, trade 단의 객체들이 혼합적으로 필요했고 이를 TradeService에서 해결하도록 구성하다 보니 각각의 객체들이 하나의 역할을 맡지 못하고 강하게 결합되는 구조로 설계되고 있었습니다.

이를 Event 발행을 통한 Listener에서의 처리로 구조를 변경함으로써Trade, order, Sale 단에선 각각의 엔티티 처리에 필요한 로직만 가지고 있으면 거래 체결을 위한 역할은 Listener에서 담당해 줌으로써 조금 더 분명하게 역할을 분리할 수 있었습니다.

# 참고 자료


  • https://newwisdom.tistory.com/75
  • https://sukyology.tistory.com/18
This post is licensed under CC BY 4.0 by the author.

[soldout] 로그인 회원 정보 조회를 위한 Resolver 구현

[soldout] DB Replication 적용