CS

TDD, 좋은 설계를 위한 내비게이션

sudo-terry 2025. 10. 13. 05:37

들어가며

코로나 팬데믹을 기점으로 IT산업이 크게 각광받으며 우리의 삶이 빠르게 디지털로 전환되었습니다. 이런 시대적 흐름에 따라, 프로그래머라는 직업에 대한 관심 또한 크게 증가하고 있습니다. 일례로, 제가 재학 중인 대학교에서는 '컴퓨팅적 사고' 와 같은 필수 교양을 신설하며 개발자를 꿈꾸는 학생들의 수요를 맞춰나가고 있습니다.

 

그렇다면 컴퓨팅적 사고란 무엇일까요? 무엇이 그렇게 다르기에 별도의 과목으로 컴퓨팅적 사고 방법을 따로 가르치려는 것일까요? Wikipedia에서는 다음과 같이 이를 정의하고 있습니다.

 

컴퓨팅 사고란 복잡한 문제를 컴퓨터가 효과적으로 처리할 수 있는 방식으로 정의하고 해결책을 찾는 사고 과정입니다. 컴퓨터 과학자의 문제 해결 접근법을 기반으로 하며, 분해, 패턴 인식, 추상화, 알고리즘 설계의 네 가지 핵심 요소를 포함합니다.

 

요컨대, 복잡한 현실의 문제를 컴퓨터가 이해할 수 있는 단위로 분해하고, 그 요소들을 논리적으로 조립하여 문제를 해결하는 것이죠. 문제를 작게 나눌수록 요구사항은 단순해지고, 변화에 유연해집니다. 이러한 배경 속에서 우리가 배우는 객체 지향 프로그래밍 또한 출발했습니다.

 

 


 

객체지향의 캡슐화

객체 지향의 4대 특성 중 하나인 캡슐화에 대해 생각해 봅시다. 캡슐화란 무엇인가요? Wikipedia에서는 다음과 같이 이야기하고 있습니다.

객체의 속성(data fields)과 행위(메서드, methods)를 하나로 묶고, 실제 구현 내용 일부를 내부에 감추어 은닉한다.

 

그렇다면 왜 구현 내용을 우리는 외부에 감출까요?

 

핸드폰을 예시로 들어봅시다. 핸드폰의 전원을 키고 싶다면 우측의 전원 버튼을 누르면 됩니다. 일반적인 사용자들은 회로의 각 부품에 전력이 얼마나 공급되는지, OS 커널이 메모리에 어떻게 적재되는지는 전혀 알 필요가 없습니다. OS가 업데이트가 되거나, 회로의 부품이 바뀌어 전력이 다르게 공급되어도 전원 버튼이라는 인터페이스만 동일하게 유지된다면 휴대폰의 전원을 키는 방법은 그대로인 것이죠.

 

캡슐화의 힘은 여기에서 잘 드러납니다. 외부에 공개되어 있는 public 인터페이스를 통해 자신의 책임을 다 하고, 복잡한 과정들은 private으로 전부 숨겨버리는거죠. 사용자 입장에서는 객체를 사용하기가 매우 쉬워지는 것이죠.

 


 

단위 테스트와 캡슐화

그렇다면 어떻게 캡슐화, 책임의 경계를 잘 분리할 수 있을까요?

 

저는 단위 테스트가 그 답이 되어준다고 생각합니다. 단위 테스트의 '단위(Unit)'는 무엇일까요? 바로 테스트하고자 하는 '하나의 책임'입니다. 우리가 하나의 기능만을 고립시켜 테스트하기 위해 의존성을 분리하고 Mock 객체를 사용하는 과정은, 자연스럽게 해당 객체가 가진 단 하나의 책임을 명확하게 정의하고 그 경계를 긋는 행위와 같습니다.

 

이런 측면에서 본다면 단위 테스트와 캡슐화는 책임을 명확하게 정의하여 분리하자라는 동일한 배경을 가진다는 것을 알 수 있습니다. 좋은 단위 테스트를 작성하려고 노력한다면 자연스럽게 SRP가 잘 지켜진, 캡슐화가 잘 된 코드를 작성할 수 있는 것이죠. 아래와 같은 고민을 단위 테스트를 작성하며 하신 적은 없으신가요?

  • 테스트를 어떻게 작성할 지 감이 오지 않는다.
  • 테스트를 위해 Mock을 엄청나게 해야 한다, 혹은 assert할게 너무 많다.

위의 상황은 대부분 SRP를 위반한 코드를 작성했기 때문에, 그리고 비즈니스 로직을 테스트에 앞서 작성했기 때문에 겪는 문제들입니다.

 

다음과 같이 SRP를 위반한, OrderService에 대해 이야기해봅시다.

public class OrderService {
    public void processOrder(Order order) {
        // 1. 주문 유효성 검증 로직
        if (order.getQuantity() <= 0) {
            throw new IllegalArgumentException("수량은 0보다 커야 합니다.");
        }
        // ...

        // 2. 재고 확인 및 차감 로직
        Stock stock = stockRepository.findStockByOrderId(order.getProductId());
        if (stock.getQuantity() < order.getQuantity()) {
            throw new RuntimeException("재고가 부족합니다.");
        }
        stock.decreaseQuantity(order.getQuantity());
        stockRepository.save(stock);

        // 3. 결제 처리 로직
        PaymentGateway.requestPayment(order.getCardInfo(), order.getTotalPrice());
        
        // 4. 이메일 발송 로직 
        // /* ... */
    }
}

 

이 서비스의 processOrder은 어떻게 테스트를 작성해야 할까요? stockRepository와 PaymentGateway를 Mock 객체로 만들고 주문이 유효한지, 재고가 줄었는지, 결제가 요청되었는지, 이메일이 발송되었는지를 모두 검증해야 합니다.

그렇다면, 책임이 잘 분리된, 테스트하기 쉬운 코드를 한 번 확인해 봅시다.

 

// 1. 주문 검증 책임
public class OrderValidator {
    public void validate(Order order) { /* ... */ }
}

// 2. 재고 관리 책임
public class StockManager {
    public void decreaseStock(Product product, int quantity) { /* ... */ }
}

// 3. 결제 처리 책임
public class PaymentService {
    public void processPayment(CardInfo card, int price) { /* ... */ }
}

// 책임을 조율하는 OrderService
public class OrderService {
    private final OrderValidator validator;
    private final StockManager stockManager;
    private final PaymentService paymentService;

    public void processOrder(Order order) {
        validator.validate(order);
        stockManager.decreaseStock(order.getProduct(), order.getQuantity());
        paymentService.processPayment(order.getCardInfo(), order.getTotalPrice());
    }
}

 

위의 구조에서는 OrderValidator를 테스트하기 위해 재고나 결제에 대해 전혀 신경 쓸 필요가 없습니다. 오직 '주문 데이터가 유효한가'라는 단 하나의 책임, 명확하게 그어진 경계 안에서 간결한 테스트를 작성할 수 있게 됩니다.

 


 

TDD, 좋은 설계를 위한 내비게이션

앞서 우리는 좋은 단위 테스트를 작성하려는 노력이 자연스럽게 캡슐화가 잘 된 SRP 코드로 이어진다는 것을 확인했습니다. 하지만 구체적으로 어떻게 해야 좋은 단위 테스트를 작성할 수 있는지는 잘 와닿지 않습니다. 이에 대한 가장 효과적인 대답은 테스트 주도 개발(Test-Driven Development, TDD)입니다.

 

앞서 본 예제의 요구사항을 구현하는 과정을 TDD로 다시 함께 풀어나가 봅시다.

1단계. 흐름의 정의

먼저, OrderService의 processOrder 메서드가 어떤 순서로 무슨 일을 해야 하는지부터 정의해봅시다. 주문을 하면, 주문에 대해 유효한지 검증하고, 재고를 차감하고, 결제가 진행하고, 결제 완료 메일을 보내야 합니다.

따라서 processOrder에서는 각 단계를 담당할 Mock 객체를 만들고 구상한 흐름에 따라 각 책임을 맡을 객체들에 대한 검증을 진행해야겠지요?

class OrderServiceTest {

    @Test
    void it_should_call() {
        // 1. Mock들을 준비
        OrderValidator mockValidator = mock(OrderValidator.class);
        StockManager mockStockManager = mock(StockManager.class);
        PaymentService mockPaymentService = mock(PaymentService.class);
        
        OrderService orderService = new OrderService(mockValidator, mockStockManager, mockPaymentService);
        Order order = new Order(/* ... */);

        // 2. 실행
        orderService.processOrder(order);

        // 3. Mock들이 올바른 순서로 호출되었는지 검증!
        InOrder inOrder = inOrder(mockValidator, mockStockManager, mockPaymentService);
        inOrder.verify(mockValidator).validate(order);
        inOrder.verify(mockStockManager).decreaseStock(any(), anyInt());
        inOrder.verify(mockPaymentService).processPayment(any(), anyInt());
    }
}

 

2단계. 책임의 구체화

전체 흐름을 잡았으니, 이제 각 책임을 담당하는 Mock들에 대한 테스트를 만들어 봅시다. processOrder에서 OrderValidator는 분명 주문이 유효한지를 검증하는 녀석이었죠. 그럼 유효하지 않은 질문은 무엇인가? 라고 생각해보며 테스트를 써내려가 봅시다.

class OrderValidatorTest {
    
    // 주문의 수량은 양수여야 한다.
    @Test
    void it_should_throw_exception_when_quantity_is_not_positive() {
        OrderValidator validator = new OrderValidator();
        Order invalidOrder = new Order(product, 0); // 수량이 0인 주문

        assertThrows(IllegalArgumentException.class, () -> {
            validator.validate(invalidOrder);
        });
    }
    
    // 주문의 상태가 '결제 전' 이어야 한다.
    /* ... */
    
    // 배송지 정보가 null이지 않아야 한다.
    /* ... */

}

 

3단계. 책임의 구현

이제 앞서 우리가 구체화하였던 책임을 담당할 수 있는, 테스트를 성공시키는 OrderValidator를 구현해봅시다.

public class OrderValidator {
    public void validate(Order order) {
    	//주문의 수량이 양수인지?
        if (order.getQuantity() <= 0) {
            throw new IllegalArgumentException("수량은 0보다 커야 합니다.");
        }
        // 주문의 상태가 '결제 전'인지?
        
        // 송지 정보가 null이 아닌지?
    }
}

 

이제 StockManager, PaymentService에 대해서도 동일한 TDD 사이클을 반복하며 각자의 책임을 구체화해 나가면 요구사항을 잘 구현함과 동시에 책임이 잘 분리된 코드가 될 수 있겠죠?

 

이처럼 TDD, 특히 바깥쪽에서 안쪽으로 파고드는 방식은 거대한 요구사항을 논리적인 책임 단위로 자연스럽게 분해하도록 유도합니다. 지금 당장 이 흐름을 완성하기 위해 필요한 역할은 무엇인가? 라는 질문에 답하는 과정 자체가 바로 SRP에 기반한 설계를 하는 과정인 것이죠.

 


 

마무리

 

거대한 레거시 코드베이스를 마주했을 때, 어디서부터 손대야 할지 막막한 코드 덩어리 속에서, 저희는 책임을 떼어내고, 테스트를 작성함으로써 안전한 발판을 마련할 수 있습니다. 잘 작성된 테스트는 우리가 코드를 수정할 때마다 "이 변경이 다른 곳에 예상치 못한 문제를 일으키진 않았는가?"라는 불안감으로부터 해방시켜 주는 강력한 서킷 브레이커가 되어줍니다.

 

결국 좋은 개발자란, SRP와 같은 원칙을 아는 것에 그치지 않고 체화하여 꾸준히 자신의 코드에 녹여내고, 이를 명료한 단위 테스트로 검증해 나가는 사람이 아닐까요? 테스트를 단순히 버그를 잡기 위한 귀찮은 작업으로 생각하기보다는, 더 좋은 설계를 위한 주춧돌이자 방향으로 여기는 것은 어떨까요?

'CS' 카테고리의 다른 글

MSA (MicroService Architecture) 알아보기  (1) 2025.10.22