과정을 즐기자

문제가 많은 Layerd Architecture를 살려보자 본문

아키텍쳐

문제가 많은 Layerd Architecture를 살려보자

320Hwany 2023. 10. 18. 17:21

Spring 프레임워크를 이용해 개발하다보면 여러 장점이 있습니다.

DI/IoC 컨테이너, 테스트 용이, 그리고 스프링부트를 사용한다면 내장 톰캣, 버전 설정, 자동 구성.. 등

여러 장점이 있습니다. Spring은 객체지향 프로그래밍을 더 잘할 수 있도록 도와줍니다.

각각의 역할과 책임을 분리하도록 개발을 유도하기 때문입니다.

 

이때 가장 먼저 접하는 아키텍쳐는 Layerd Architecture로 크게 3가지로 나눈다면 클라이언트 요청을 받고 응답을 해주는

Presentation Layer, 비즈니스 로직을 처리하는 Application Layer, 데이터베이스에 접근하는 Data Access Layer로

나눌 수 있습니다.

 

저도 이러한 구조로 계속 개발을 해왔지만 기능이 많아지고 복잡해질 수록 문제점이 하나 둘 씩 보이기 시작했습니다.

제가 느낀 문제점은 크게 2가지로 첫 번째는 비즈니스 로직에 대한 정의, 두 번째는 Application Layer가 복잡해질 때의 문제입니다.

이번 글에서는 Layerd Architecture를 사용하되 가지고 있는 문제점을 개선할 수 있는 방안을 작성해보려고 합니다.

비즈니스 로직에 대한 정의 

Layed Architectured에서 보통 Presentaion Layer는 Controller, Application Layer는 Service, Data Access Layer는

Repository와 대응한다고 볼 수 있습니다. 

이때 Service에서 비즈니스 로직을 구현한다고 해서 많은 로직들을 Service 안에 넣어놓는 코드를 자주 볼 수 있습니다.

 

예를들어 주문을 생성하는 로직이 있다고 생각해봅시다.

@Service
public class OrderService {

    private final MemberRepository memberRepository;
    private final OrderRepository orderRepository;
    private final ItemRepository itemRepository;

    public OrderService(final MemberRepository memberRepository,
                        final OrderRepository orderRepository,
                        final ItemRepository itemRepository) {
        this.memberRepository = memberRepository;
        this.orderRepository = orderRepository;
        this.itemRepository = itemRepository;
    }

    @Transactional
    public void createOrder(final long memberId, final OrderRequest orderRequest) {
        Item item = itemRepository.getById(orderRequest.itemId());
        Member member = memberRepository.getById(memberId);

        int itemQuantity = item.getQuantity();
        int orderQuantity = orderRequest.orderQuantity();
        if (itemQuantity < orderQuantity) {
            throw new IllegalStateException("주문 수량이 상품 재고보다 많아 주문할 수 없습니다");
        }

        int itemPrice = item.getPrice();
        if (member.getMoney() < itemPrice * orderQuantity) {
            throw new IllegalStateException("금액이 부족하여 상품을 구매할 수 없습니다");
        }

        ....
    }
}

먼저 회원 id와 주문에 대한 정보를 파라미터로 받고 상품, 회원 정보를 찾습니다.

수량이 부족하지는 않은지 회원이 가진 금액이 주문 금액보다 많은지 확인합니다.

다음으로 상품 수량은 주문 수량 만큼 빼고 회원이 가진 금액도 주문 금액만큼 빼고

이 과정에서 동시성 문제가 발생하지 않도록 구현 등등 이렇게 많은 로직이 있습니다.

 

이것이 OrderService 하나의 클래스에 구현되어 있으면 이것은 비즈니스 로직보다는 상세한 구현 로직에 가깝습니다.

createOrder 메소드만 보고 이것이 어떤 일을 하는 지 한눈에 알기 어렵기 때문입니다.

 

비즈니스 계층은 상세한 구현 로직이 아닌 전체적인 비즈니스의 흐름을 알 수 있는 계층입니다.

즉, 비즈니스 계층은 전체적인 기능을 하나로 묶는 계층입니다.

상세한 구현 로직은 도메인이나 다른 하위 계층에 위임을 합니다. (하위 계층은 글 아래에서 다시 설명하겠습니다)

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final OrderValidator orderValidator;

    public OrderService(final OrderRepository orderRepository,
                        final OrderValidator orderValidator) {
        this.orderRepository = orderRepository;
        this.orderValidator = orderValidator;
    }

    @Transactional
    public void createOrder(final long memberId, final OrderRequest orderRequest) {
        orderValidator.validate(orderRequest);

        ...

        orderRepository.save(order);
        ....
    }
}

이렇게 함으로써 createOrder 메소드를 보고 전체적인 흐름을 알 수 있고 상세한 구현은 도메인 같은 하위 계층에 위임했습니다.

Service에 의존하는 클래스가 많아진다

비즈니스 로직에 대해 이해를 했으면 Application 계층은 얇아져서 코드를 한결 이해하기 쉬워졌지만

기능이 많아지면 많아질 수록 Service에 의존하는 클래스가 많아지는 문제점이 있습니다.

@Service
public class OrderService {

    private final OrderRepository orderRepository;
    private final OrderValidator orderValidator;
    private final MemberRepository memberRepository;
    private final ItemRepository itemRepository;
    
    ...


    public OrderService(final OrderRepository orderRepository, 
                        final OrderValidator orderValidator, 
                        final MemberRepository memberRepository,
                        final ItemRepository itemRepository
                        ...
    ) {
        this.orderRepository = orderRepository;
        this.orderValidator = orderValidator;
        this.memberRepository = memberRepository;
        this.itemRepository = itemRepository;
        ...
    }

    ...
}

아무리 도메인에 상세 구현을 위임하더라도 기능이 복잡해질 수록 Service에 의존하는 클래스가 많아지는 것을 

피할 수는 없습니다. 의존하는 클래스가 많아져 7, 8개가 된다면 비즈니스 로직 자체를 이해하기 어려워지며

테스트하기도 어려워집니다.

비즈니스 로직마다 클래스를 분리하자

이때 가장 먼저 생각할 수 있는 해결방법은 비즈니스 로직마다 클래스를 분리하는 것입니다.

지금은 OrderService에 Order와 관련된 비즈니스 로직을 모두 담으려고 해서 발생하는 문제입니다.

주문 생성은 OrderCreator, 주문 취소는 OrderDeleter, 주문 조회는 OrderReader와 같은 방식으로 분리하는 것입니다.

이렇게하면 클래스가 담당하는 역할과 책임을 분리할 수 있고 클래스의 이름을 보고 클래스가 담당하는 역할을 알기 쉽습니다.

@Service
public class OrderCreator {

    private final OrderRepository orderRepository;

    public OrderCreator(final OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    // 주문 생성
    @Transactional
    public void createOrder() {

    }
}
@Service
public class OrderReader {

    private final OrderRepository orderRepository;

    public OrderReader(final OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    // 주문 조회
    @Transactional(readOnly = true)
    public void getOrder() {

    }
}
@Service
public class OrderDeleter {

    private final OrderRepository orderRepository;

    public OrderDeleter(final OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    // 주문 취소
    @Transactional
    public void cancelOrder() {

    }
}

이때 이렇게 하면 Controller에 의존하는 클래스가 많아지는 것 아니냐는 의문이 있을 수 있지만

Presentation 계층의 테스트는 아무리 의존하는 클래스가 많아도 각 API에 해당하는 클래스는 1, 2개 정도이므로 

테스트 하기가 크게 어렵지 않습니다.

계층을 하나 더 두자

하지만 이렇게해도 의존성이 높은 경우가 있습니다.

이럴 경우 다음에 생각해볼 수 있는 것은 계층을 하나 더 두는 것입니다.

지금까지 Application 계층은 비즈니스 로직을 구현하고 도메인에 상세 구현을 위임했지만 이러한 경우에도 복잡할 경우

Composition 계층을 하나 더 만들어 Application 계층에서 비즈니스 로직을 구현하고 Composition 계층에서는

이런 Application 클래스를 조합해서 사용할 수 있습니다.

@Service
public class OrderCreatorComposition {

    private final OrderManager orderManager;
    private final PaymentProcessor paymentProcessor;
    private final ProductValidator productValidator;

    public OrderCreatorComposition(OrderManager orderManager,
                                PaymentProcessor paymentProcessor,
                                ProductValidator productValidator) {
        this.orderManager = orderManager;
        this.paymentProcessor = paymentProcessor;
        this.productValidator = productValidator;
    }

    // 주문 생성
    public void createOrder(final OrderRequest orderRequest) {
        // 주문 유효성 검사
        if (productValidator.isValidOrder(orderRequest)) {
            // 결제 처리
            paymentProcessor.processPayment(orderRequest);

            // 주문 정보 저장
            orderManager.createOrder(orderRequest);
        } else {
            // 유효성 검사 실패 처리
            // 예외 처리 또는 오류 메시지 반환
        }
    }
}

즉, 상세 구현은 도메인에게 위임하는 Application 계층을 두고 Composition 계층에서는 이를 조합하는 방식입니다.

이렇게 함으로써 트랜잭션을 꼭 필요한 Application 계층에만 사용할 수 있도록 하는 장점도 있습니다.  

스프링 이벤트를 적절히 사용

클래스를 기능마다 분리하고 계층을 하나 더 둔다면 훨씬 간결해진 Application 계층을 확인할 수 있습니다.

위에서 설명한 방식 이외에도 스프링 이벤트를 사용하는 방법이 있습니다.

@Service
public class OrderCreator {

    ...

    private final ApplicationEventPublisher publisher;

    public OrderCreator(final ApplicationEventPublisher publisher) {
        this.publisher = publisher;
    }

    public void creatOrder(final long memberId, final OrderRequest orderRequest) {
        ...

        // 스프링 이벤트 호출 - OrderEventListener
        publisher.publishEvent(new CreateOrderEvent(memberId, orderRequest));
    }
}
@Service
public class OrderSpringEvent {

    // creatOrderEvent - 주문 생성시 발생하는 이벤트
    @EventListener(classes = {CreateOrderEvent.class})
    public void subscriptionSaveEvent(final CreateOrderEvent createOrderEvent) {
        ...
    }
}

스프링 이벤트를 사용하면 클래스간의 의존도를 줄일 수 있지만 추적이 어렵기 때문에 꼭 필요한 경우에만 사용해야 합니다.

정리

비즈니스 로직은 기능을 하나로 묶는 전체적인 비즈니스의 흐름을 나타내며 상세한 구현 로직과는 다릅니다.

Application 계층인 Service에 의존하는 클래스가 많아지면 코드도 이해하기 어려워지고 테스트하기도 어려워집니다.

이를 해결하기 위해 먼저 기능마다 클래스를 나누는 것을 생각해볼 수 있고 그 다음으로 계층을 하나 더 만들어

Application 계층에서 조합한 후 Composition 계층에 전달하는 방법이 있습니다.

또한 상황에 따라 스프링 이벤트를 적절히 사용할 수 있지만 추적이 어렵기 때문에 꼭 필요한 경우에만 사용해야 합니다.

 

지속 성장 가능한 소프트웨어를 만들어가는 방법

스프링은 국내에서 정말 많이 쓰이고 있습니다, 개인적으로 많은 회사를 다녀보며 주니어/시니어를 막론하고 많은 분들이 스프링에 함몰되어 개발을 하고 있다는 느낌을 받을 때가 많았고 이

geminikims.medium.com