JAVA

화살표 if문을 DDD로 우아하게 리팩토링하기

Stark97 2024. 10. 29. 23:31
반응형

안녕하세요. 항상 졸린 개발자 stark입니다!

오늘은 DDD에서 도메인을 잘못 설계해서 비즈니스 로직이 꼬인 상황을 살펴보고 이 비즈니스 로직을 리팩토링 해봅시다.

특히 개발자의 적인 화살표 if문을 어떻게 도메인 객체로 풀어나갈 수 있는지 알아봅시다.

 

 

잘못 사용 중인 도메인을 살펴보자.


잘못된 도메인 사용 사례는 코드의 비효율성과 유지보수의 어려움을 야기할 수 있습니다.

예를 들어, 도메인 객체가 단순히 데이터를 담는 역할만 하고 비즈니스 로직이 모두 서비스 계층에 존재하는 경우, 객체지향의 장점을 충분히 활용하지 못하게 됩니다. 도메인은 비즈니스 로직을 포함하여야 함에도 불구하고, 단순히 상태를 저장하는 데이터 전달 객체(Data Transfer Object, DTO)로만 사용되면 코드의 응집도가 낮아지고, 각 계층 간의 의존성이 복잡해져 전체적인 코드 품질이 떨어집니다.

 

예를 들어, 아래와 같은 과장된 잘못된 도메인 사용 사례를 생각해 볼 수 있습니다.

이 코드는 Order 도메인 객체의 예시입니다. 이 객체는 단순히 데이터의 저장과 상태의 추적을 담당하고 있으며, 비즈니스 로직은 포함하고 있지 않은 상태입니다.

public class Order {

    private Payment payment;
    private boolean valid;
    private OrderStatus status;

    public Payment getPayment() {
        return payment;
    }

    public boolean isValid() {
        return valid;
    }

    public void changeStatus(OrderStatus status) {
        this.status = status;
    }
    
}

 

 

화살표 If문이란 무엇인가?


화살표 If문은 조건이 중첩되어 가독성이 떨어지는 코드를 일컫는 용어입니다. 여러 개의 if문이 마치 화살표 모양처럼 들여 쓰기 되며 중첩되면서 코드가 길어지고 복잡해집니다. 아래는 대표적인 화살표 if문의 예입니다.

public void processOrder(Order order) {
    // 분기처리를 통해 주문 프로세스가 진행됩니다. (굉장히 복잡하고 보기 싫습니다.)
    if (order != null) {
        if (order.isValid()) {
            if (order.getPayment() != null) {
                if (order.getPayment().isSuccessful()) {
                    // 주문 처리 로직
                    System.out.println("주문이 성공적으로 처리되었습니다.");
                    order.setStatus(OrderStatus.PROCESSED);
                } else {
                    System.out.println("결제가 실패하였습니다.");
                    order.setStatus(OrderStatus.PAYMENT_FAILED);
                }
            } else {
                System.out.println("결제가 없습니다.");
                order.setStatus(OrderStatus.PAYMENT_MISSING);
            }
        } else {
            System.out.println("주문이 유효하지 않습니다.");
            order.setStatus(OrderStatus.INVALID);
        }
    } else {
        System.out.println("주문이 존재하지 않습니다.");
    }
    
}

위 코드를 보면 잘못 사용 중인 Order 도메인 객체에는 주문과 관련된 상태와 데이터가 담겨 있지만, 비즈니스 로직은 서비스 레이어에 집중되어 있습니다. 이 때문에 조건이 많아질수록 서비스 로직에 중첩된 if문이 생기며, 비즈니스 로직을 분산시켜 코드가 점점 더 복잡해지는 문제가 발생합니다.

 

 

화살표 If문의 문제점


화살표 If문은 코드의 가독성을 크게 저하시킬 뿐만 아니라 유지보수에도 큰 어려움을 초래합니다.

앞의 예시 코드를 보면서 이러한 문제점들을 하나씩 살펴봅시다.

 

먼저, 가독성 문제를 살펴보겠습니다.

조건이 여러 번 중첩되어 코드의 들여 쓰기가 깊어지면서, order.getPayment().isSuccessful()와 같은 조건을 이해하기 위해 각 조건을 따라가야 합니다. 조건이 중첩되면 어떤 순서로 조건이 평가되는지 파악하기 어렵고, 각각의 조건이 어떤 의미를 갖고 있는지도 쉽게 파악하기 어렵습니다. 이렇게 가독성이 떨어지는 코드는 팀원들과 협업할 때 다른 개발자들이 이해하기 힘들고, 코드 리뷰나 디버깅 과정에서 시간 낭비로 이어질 수 있습니다.

 

또한, 확장성의 한계가 있습니다.

비즈니스 요구사항이 늘어나면 if문 역시 계속해서 중첩될 수밖에 없습니다. 예를 들어, 새로운 비즈니스 로직이나 검증 조건이 추가되면 기존의 코드에 새로운 if문을 더 추가해야 하고, 이로 인해 코드의 복잡도는 더욱 증가하게 됩니다. 이러한 방식은 변경이 있을 때마다 코드의 일관성을 유지하기 어렵게 만듭니다.

 

마지막으로, 유지보수의 어려움을 들 수 있습니다.

위와 같은 중첩된 구조에서는 문제가 발생했을 때 그 원인을 찾기가 어렵습니다. 예를 들어, 주문이 유효하지 않습니다.라는 메시지가 출력되었을 때, 이 메시지가 출력된 이유를 파악하려면 코드의 모든 조건을 분석하고 어떤 조건에서 실패했는지 추적해야 합니다. 특히 조건이 많고 각 조건이 서로 복잡하게 얽혀 있을 경우, 버그를 찾아 수정하는 데 많은 시간이 소요됩니다. 지금 코드만 봐도 벌써 어지러움이 느껴집니다.

 

결국, 이러한 화살표 if문 구조는 코드의 유지보수성을 저하시킬 뿐만 아니라, 코드 작성자와 나중에 유지보수를 맡은 개발자 모두에게 큰 부담을 주게 됩니다. 이러한 문제점들을 해결하기 위해 도메인 주도 설계(DDD)에 맞도록 잘못 작성된 도메인 코드를 리팩토링 하는 것이 중요합니다.

 

 

가독성 향상을 위한 리팩토링


메서드 추출을 통해 코드를 단순화시켜 봅시다.

가장 쉬운 방법입니다. 이미 작성된 코드를 잘 읽어보며 중첩된 조건들을 각각 독립적인 메서드로 추출합니다. 이렇게 하면 각 메서드가 특정한 조건만을 책임지며 코드가 읽기 쉬워집니다. IntelliJ에서는 Method Extract 기능도 지원하기에 더 쉽게 할 수 있습니다.

public void processOrder(Order order) {

    if (isOrderInvalid(order)) return;

    if (isPaymentInvalid(order.getPayment())) return;

    if (isPaymentFailed(order.getPayment())) return;

    // 주문 처리 로직
    System.out.println("Order processed successfully");
    order.setStatus(OrderStatus.PROCESSED);
    
}

private boolean isOrderInvalid(Order order) {

    if (order == null) {
        System.out.println("Order is null");
        return true;
    }
    if (!order.isValid()) {
        System.out.println("Order is invalid");
        return true;
    }
    return false;
    
}

private boolean isPaymentInvalid(Payment payment) {

    if (payment == null) {
        System.out.println("Payment is missing");
        return true;
    }
    return false;
    
}

private boolean isPaymentFailed(Payment payment) {

    if (!payment.isSuccessful()) {
        System.out.println("Payment failed");
        return true;
    }
    return false;
    
}

각 조건을 메서드로 분리하여 가독성을 높였습니다. 이렇게 함으로써 코드가 더 단순해지고 각 조건을 독립적으로 이해할 수 있게 됩니다. 또한 각 메서드는 특정한 조건에 대해서만 책임을 가지므로 코드의 재사용성과 유지보수성도 함께 향상됩니다.

 

하지만 여전히 검증과 관련된 비즈니스 로직이 서비스 계층에 존재하고, 전체적으로 비즈니스 로직의 응집도가 떨어집니다. 이를 개선하기 위해 비즈니스 로직을 도메인 객체로 이동시켜 DDD를 적용해 봅시다.

 

 

DDD 적용: 도메인과 서비스의 분리


도메인 주도 설계(DDD)를 사용하면 비즈니스 로직을 도메인 객체로 이동시켜 코드의 구조를 개선할 수 있습니다. 이 과정에서 커스텀 예외 클래스를 정의해 특정 에러 상황을 명확히 처리할 수 있습니다.

 

아래와 같이 도메인 로직에서 발생할 수 있는 예외 상황을 각기 다른 커스텀 예외로 처리하여, 코드의 표현력을 높였습니다.

public class InvalidOrderException extends RuntimeException {
    public InvalidOrderException(String message) {
        super(message);
    }
}
// PaymentMissingException, PaymentFailedException도 유사하게 정의

다음으로 Order 도메인 객체가 자신의 유효성 검사를 직접 수행하고, 각 조건에 대해 예외를 던지도록 했습니다.

public class Order {

    private Payment payment;
    private boolean valid;
    private OrderStatus status;

    public void process() {
        validate();
        checkPaymentExists();
        validatePayment();
        executeOrder();
    }

    private void validate() {
        if (!isValid()) {
            throw new InvalidOrderException("주문이 유효하지 않습니다");
        }
    }

    private void checkPaymentExists() {
        if (payment == null) {
            throw new PaymentMissingException("결제가 존재하지 않습니다");
        }
    }

    private void validatePayment() {
        if (!payment.isSuccessful()) {
            throw new PaymentFailedException("결제가 실패하였습니다");
        }
    }

    private void executeOrder() {
        // 실제 주문 처리 로직 구현
        System.out.println("주문 처리 로직을 실행 중...");
        changeStatus(OrderStatus.PROCESSED);
    }

    public void changeStatus(OrderStatus status) {
        this.status = status;
    }
    
}

이제 Order 도메인 객체가 모든 비즈니스 로직을 직접 수행하게 되었고, 서비스 계층에서의 검증 로직이 도메인 객체로 옮겨졌습니다. 이렇게 하면 각 객체가 자신의 상태와 행위를 책임지게 되어 응집도가 크게 향상됩니다.

 

그럼 이제 서비스 클래스를 리팩토링 해봅시다. 이제 서비스 클래스에서는 단순히 도메인 객체의 메서드만 호출하면 됩니다. 이렇게 하면 서비스 클래스는 조정자(coordinator) 역할만 수행하게 되어 책임이 명확해집니다.

@Service
public class OrderService {

    public String processOrder(Order order) {
        // 도메인의 비즈니스 처리 메서드 호출
        order.process();
        return "주문이 성공적으로 처리되었습니다.";
    }
    
}

 

 

도메인 주도 설계(DDD)의 장점


지금까지 잘못 사용 중이던 도메인 객체를 리팩토링 하면서 if문의 구조까지 완벽하게 바꿔보았습니다. 이제 DDD가 적용된 전체 코드의 구조를 한번 정리해 봅시다.

 

도메인 주도 설계를 적용한 후의 코드 구조를 살펴보면, 각 계층의 역할이 분명히 분리됩니다. 도메인 객체인 Order는 비즈니스 로직을 직접 담당하며, 서비스 계층에서는 도메인 객체의 메서드를 호출하여 로직을 실행합니다. 이렇게 하면 코드는 더 명확하고 읽기 쉽게 됩니다. 이것은 DDD를 적용해서 얻을 수 있는 최대 장점입니다.

 

도메인 객체(Order)는 비즈니스 로직을 직접 담당하고, 외부에서는 이 객체를 통해 로직을 실행합니다. 예를 들어 주문의 유효성을 확인하고, 결제가 유효한지 확인하며, 결제가 성공했는지 확인하는 등의 로직은 모두 Order 객체 내부에 위치합니다. 이는 객체가 스스로의 상태와 행위를 관리하도록 하여 응집도를 높입니다.

 

서비스(OrderService)는 단순히 도메인 객체의 메서드를 호출하고 예외 처리를 위임합니다. 서비스는 비즈니스 로직을 구현하기보다는 도메인 객체를 조정하고 비즈니스 로직을 실행할 수 있는 환경을 제공하는 역할을 합니다. 따라서 서비스는 복잡한 로직 없이 간결하게 유지할 수 있습니다.

 

DDD를 적용해 도메인 객체에 비즈니스 로직을 위임하면 중첩된 if문을 제거하여 코드의 응집도를 높이고, 각 객체가 자신의 상태와 유효성을 스스로 검사하도록 만듭니다. 이렇게 하면 복잡한 조건을 단순하고 명확하게 표현할 수 있어 코드의 흐름이 자연스럽고 가독성이 높아지며, 조건 검사가 객체의 책임으로 일관되게 처리됩니다.

 

 

리팩토링 후 테스트 작성하기


리팩토링 후 테스트 작성은 코드가 예상대로 동작하는지 보장하기 위해 필수적입니다. 특히 DDD를 통해 비즈니스 로직을 도메인 객체로 위임한 경우, 각 도메인 메서드가 제대로 작동하는지 단위 테스트로 검증해야 합니다. POJO 객체인 도메인 클래스는 자체적으로 비즈니스 로직을 처리하므로, 주로 단위 테스트가 적합합니다. 테스트는 정상 시나리오부터 예외 상황까지 차례로 검증하여, 리팩터링 된 코드가 의도대로 작동하는지 확인하고 향후 변경에도 안정성을 유지할 수 있게 합니다.

import static org.junit.jupiter.api.Assertions.*;
import org.junit.jupiter.api.Test;

class OrderTest {

    @Test
    void process_validOrder_shouldSucceed() {
        // Given
        Order order = new Order();
        order.setPayment(new Payment(true)); // 결제 성공 상태
        order.setValid(true); // 주문 유효 상태
        
        // When
        order.process();
        
        // Then
        assertEquals(OrderStatus.PROCESSED, order.getStatus()); // 주문 상태가 'PROCESSED'로 변경되었는지 검증
    }

    @Test
    void process_withInvalidOrder_shouldThrowException() {
        // Given
        Order order = new Order();
        order.setValid(false); // 유효하지 않은 주문
        
        // When & Then
        assertThrows(InvalidOrderException.class, 
            order::process); // 유효하지 않은 주문 시 예외 발생 검증
    }

    @Test
    void process_withoutPayment_shouldThrowException() {
        // Given
        Order order = new Order();
        order.setValid(true); // 유효한 주문이지만 결제 정보가 없음
        
        // When & Then
        assertThrows(PaymentMissingException.class, 
            order::process); // 결제 정보가 없을 때 예외 발생 검증
    }

    @Test
    void process_withFailedPayment_shouldThrowException() {
        // Given
        Order order = new Order();
        order.setPayment(new Payment(false)); // 결제 실패 상태
        order.setValid(true); // 유효한 주문
        
        // When & Then
        assertThrows(PaymentFailedException.class, 
            order::process); // 결제 실패 시 예외 발생 검증
    }
}

 

 

마무리하며


결론적으로, DDD를 적용해 비즈니스 로직의 책임을 도메인 객체에 집중시키고, 서비스는 단순히 조정 역할만 하게 되어 복잡한 if문의 중첩을 피할 수 있게 됩니다. 이러한 구조는 코드의 가독성과 유지보수성을 크게 향상시킵니다.

 

또한, 중첩된 if문을 줄이기 위해 도메인 객체로 비즈니스 로직을 위임하는 것은 코드의 응집도를 높이는 효과가 있습니다. 이러한 응집도 향상은 코드 내에서 조건 검사를 보다 명확하게 하고, 각 객체가 자신의 책임을 갖도록 함으로써 복잡한 조건들을 간결하게 표현할 수 있게 합니다.

 

결과적으로, 비즈니스 로직을 객체 내에서 수행함으로써 if문이 도메인 로직의 흐름에 자연스럽게 녹아들어 가독성 높은 코드가 됩니다.

 

제가 이 글을 적게 된 이유는 DDD를 잘 적용해 도메인 객체에 비즈니스 로직을 녹여내면 개발이 한층 우아해질 수 있다는 점을 공유하고 싶었기 때문입니다. 최근 if문을 줄이는 방법에 대해 큰 관심을 가지고 있었는데 이번 기회에 도메인 객체를 깔끔하게 설계하는 방식을 설명하면서, 도메인을 잘 활용하면 if문도 자연스럽게 줄일 수 있다는 것을 예시로 보여드리면 좋겠다고 생각해 이렇게 정리하게 되었습니다.

 

많이 부족한 내용이지만 좋게 봐주셨으면 하고 언제든지 건전한 피드백 부탁드립니다.

긴 글 읽어주셔서 감사합니다 :)

반응형