Code/Test

[단위 테스트] 5-2. 목과 테스트 취약성: 통신

noahkim_ 2025. 1. 24. 21:53

블라디미르 코리코프 님의 "단위 테스트" 책을 정리한 포스팅입니다.


3. 목과 테스트 취약성 간의 관계

육각형 아키텍처

  • 도메인을 중심으로, 포트(Port)와 어댑터(Adapter)를 통해 외부와의 상호작용을 처리하는 구조
구성 요소 설명
Domain
핵심 비즈니스 로직. 의존성 없음 (순수 계층)
Port (Interface)
외부와 상호작용하기 위한 입출력 인터페이스
Adapter 외부 시스템 구현체 (DB, Email, API 등)
Application Service 도메인 호출 + 외부 시스템과의 조율 담당

 

예제) Order

더보기

도메인

public class Order {
    private String orderId;
    private String customerId;
    private List<Item> items;
    private OrderStatus status;

    public Order(String orderId, String customerId, List<Item> items) {
        this.orderId = orderId;
        this.customerId = customerId;
        this.items = items;
        this.status = OrderStatus.PENDING;
    }

    public void addItem(Item item) {
        this.items.add(item);
    }

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

public enum OrderStatus {
    PENDING, COMPLETED, CANCELED;
}

 

애플리케이션 서비스

public class OrderService {
    private OrderRepository orderRepository;

    public OrderService(OrderRepository orderRepository) {
        this.orderRepository = orderRepository;
    }

    public void createOrder(String customerId, List<Item> items) {
        // 도메인 로직을 사용하여 주문 생성
        Order order = new Order(UUID.randomUUID().toString(), customerId, items);
        orderRepository.save(order);
    }

    public void completeOrder(String orderId) {
        // 도메인 로직을 사용하여 주문 완료 처리
        Order order = orderRepository.findById(orderId);
        order.completeOrder();
        orderRepository.save(order);
    }
}

 

포트

public interface OrderRepository {
    void save(Order order);
    Order findById(String orderId);
}

 

어댑터

public class JdbcOrderRepository implements OrderRepository {
    private DataSource dataSource;

    public JdbcOrderRepository(DataSource dataSource) {
        this.dataSource = dataSource;
    }

    @Override
    public void save(Order order) {
        // JDBC를 사용하여 데이터베이스에 주문을 저장
        String sql = "INSERT INTO orders (order_id, customer_id, status) VALUES (?, ?, ?)";
        try (Connection conn = dataSource.getConnection()) {
            try (PreparedStatement ps = conn.prepareStatement(sql)) {
                ps.setString(1, order.getOrderId());
                ps.setString(2, order.getCustomerId());
                ps.setString(3, order.getStatus().name());
                ps.executeUpdate();
            }
        } catch (SQLException e) {
            throw new RuntimeException("Error saving order", e);
        }
    }

    @Override
    public Order findById(String orderId) {
        // JDBC를 사용하여 데이터베이스에서 주문 조회
        String sql = "SELECT * FROM orders WHERE order_id = ?";
        try (Connection conn = dataSource.getConnection()) {
            try (PreparedStatement ps = conn.prepareStatement(sql)) {
                ps.setString(1, orderId);
                ResultSet rs = ps.executeQuery();
                if (rs.next()) {
                    return new Order(rs.getString("order_id"), rs.getString("customer_id"), null);
                }
            }
        } catch (SQLException e) {
            throw new RuntimeException("Error fetching order", e);
        }
        return null;
    }
}

 

특징
구성 요소 설명
계층간 관심사 분리 - 도메인: 순수 비즈니스 로직 담당
- 애플리케이션 서비스: 흐름/조율 담당
→ 각 계층이 역할에 집중
단방향 통신 - 서비스 계층 → 도메인 계층으로만 의존성 흐름 존재
- 도메인은 외부 시스템을 알지 못함 (완전한 캡슐화)
프렉탈 - 전체와 부분이 같은 구조로 반복
- 테스트나 설계 관점에서도 동일한 패턴을 반복적으로 적용
→ 일관된 테스트 전략, 유지보수성 향상

 

장점
장점 설명
유연성 - 기술 스택 변경 시에도 도메인 계층은 영향 없음 (예: DB, 메시지 큐, 프레임워크 교체)
- 변경은 오직 어댑터나 서비스 계층에서 해결
테스트 용이성 - 도메인은 외부 의존이 없어 순수 테스트 가능
- 외부 시스템은 Mock으로 대체하면 테스트 범위/속도/안정성 확보
비즈니스 로직의 독립성 - 외부 환경의 변화에도 핵심 로직은 불변
- 유지보수성과 기능 안정성이 높음
유지보수 용이성 - 각 계층이 모듈화되어 있어 일부만 변경 가능
- 책임이 명확하므로 기능 추가, 리팩터링, 디버깅이 쉬움

 

시스템 내부 통신 vs 시스템 간 통신

구분 시스템 내부 통신 시스템 간 통신
설명 애플리케이션 내 클래스/모듈 간 통신
→ 내부 구현 디테일 중심
서로 다른 시스템 간 통신 (API, 메시지, 이벤트 등)
→ 외부와의 명확한 계약
식별 가능성 ❌ 식별할 수 있는 동작 아님
→ 클라이언트 관점에서 의미 없음
✅ 식별할 수 있는 동작
→ 외부 클라이언트가 인식하고 사용하는 명세
성격 내부 협력 로직, 구현 세부 사항
공개된 API, 명시적 계약 (예: REST API 명세, 프로토콜)
목표와 연결 사용자 목표와 직접 연결 ❌
사용자/외부 시스템의 목표 달성에 직접 연관됨
호환성 유지 내부 구조 변경에 자유로움
(단, 내부 모듈 간 영향 고려 필요)
버전 관리, 하위 호환성 필수
→ 계약 파괴 시 외부 오류 발생
예시 서비스 클래스 간 메서드 호출
(도메인→ 유틸 호출 등)
A 시스템의 결제 API를 B 시스템이 호출
이벤트 발행 후 타 시스템 소비

 

4. 단위 테스트의 고전파와 런던파 재고

모든 프로세스 외부 의존성을 목으로 해야 하는 것은 아니다

상황 설명 적절한 테스트 전략
외부 통신이 구현 세부사항일 때 - 통신 방식 변경되어도 도메인 요구사항은 불변
- 비즈니스 로직과 직접 관련 없음
- Mock 사용 OK
- 프록시/인터페이스 추상화 후 테스트 대상에서 분리
통신 방식 자체가 요구사항일 때 - 통신 순서, 방식이 비즈니스 로직에 포함됨
- 실제 통신이 이뤄지는 통합 테스트 필요
- Mock은 사용하되, 실제 환경 테스트도 병행
외부 의존성이 독립 시스템일 때 - 상태/응답이 바뀌는 시스템
- 예: 외부 인증 서버, 타사 결제 API 등
- Mock은 테스트 신뢰성에 취약
- 실제 시스템과 E2E 또는 통합 테스트 수행 권장

 

예제) 외부 의존성과의 통신이 구현 세부사항인 경우

더보기
// 비즈니스 로직: OrderService
public class OrderService {
    private PaymentApiProxy paymentApiProxy;

    public OrderService(PaymentApiProxy paymentApiProxy) {
        this.paymentApiProxy = paymentApiProxy;
    }

    public String processOrder(Order order) {
        boolean isPaymentSuccessful = paymentApiProxy.makePayment(order);
        if (isPaymentSuccessful) {
            return "주문이 성공적으로 처리되었습니다.";
        } else {
            return "결제 실패. 주문을 다시 시도해주세요.";
        }
    }
}

// 프록시 클래스: PaymentApiProxy
public class PaymentApiProxy {
    private ExternalPaymentApi externalPaymentApi;

    public PaymentApiProxy(ExternalPaymentApi externalPaymentApi) {
        this.externalPaymentApi = externalPaymentApi;
    }

    // 실제 외부 API와의 통신을 숨김
    public boolean makePayment(Order order) {
        return externalPaymentApi.processPayment(order.getAmount());
    }
}

// 외부 통신 객체: ExternalPaymentApi
public class ExternalPaymentApi {
    public boolean processPayment(double amount) {
        System.out.println("외부 결제 시스템과 통신 중... 결제 금액: " + amount);
        return true;  // 성공적으로 결제했다고 가정
    }
}

// 주문 클래스: Order
public class Order {
    private double amount;

    public Order(double amount) {
        this.amount = amount;
    }

    public double getAmount() {
        return amount;
    }
}

 

public class OrderServiceTest {

    @Test
    public void testProcessOrderSuccess() {
        // 1. 외부 결제 API 객체 모킹
        ExternalPaymentApi mockPaymentApi = mock(ExternalPaymentApi.class);
        when(mockPaymentApi.processPayment(100.0)).thenReturn(true); // 결제 성공 시나리오

        // 2. 프록시 객체 생성 (모킹된 결제 API를 사용)
        PaymentApiProxy paymentApiProxy = new PaymentApiProxy(mockPaymentApi);

        // 3. 주문 서비스 객체 생성
        OrderService orderService = new OrderService(paymentApiProxy);

        // 4. 주문 처리
        Order order = new Order(100.0);
        String result = orderService.processOrder(order);

        // 5. 결과 검증
        assertEquals("주문이 성공적으로 처리되었습니다.", result);
    }

    @Test
    public void testProcessOrderFailure() {
        // 1. 외부 결제 API 객체 모킹
        ExternalPaymentApi mockPaymentApi = mock(ExternalPaymentApi.class);
        when(mockPaymentApi.processPayment(200.0)).thenReturn(false); // 결제 실패 시나리오

        // 2. 프록시 객체 생성 (모킹된 결제 API를 사용)
        PaymentApiProxy paymentApiProxy = new PaymentApiProxy(mockPaymentApi);

        // 3. 주문 서비스 객체 생성
        OrderService orderService = new OrderService(paymentApiProxy);

        // 4. 주문 처리
        Order order = new Order(200.0);
        String result = orderService.processOrder(order);

        // 5. 결과 검증
        assertEquals("결제 실패. 주문을 다시 시도해주세요.", result);
    }
}

 

 

예제) 외부 시스템과 통신하는 패턴이 비즈니스 요구사항인 경우

더보기
public class PaymentService {
    private final PaymentGateway paymentGateway;

    public PaymentService(PaymentGateway paymentGateway) {
        this.paymentGateway = paymentGateway;
    }

    public String processPayment(double amount) {
        // 외부 결제 시스템과의 통신은 비즈니스 요구사항의 핵심
        return paymentGateway.makePayment(amount);
    }
}

public interface PaymentGateway {
    String makePayment(double amount);
}
@Test
public void testPaymentProcessing() {
    PaymentGateway paymentGateway = new RealPaymentGateway(); // 실제 결제 API 사용
    PaymentService paymentService = new PaymentService(paymentGateway);
    String response = paymentService.processPayment(100.0);

    // 실제 결제 시스템의 응답을 확인
    assertEquals("Payment Successful", response);
}

 

예제) 외부 의존성이 독립적인 시스템일 경우

더보기
public class SmsService {
    private final SmsGateway smsGateway;

    public SmsService(SmsGateway smsGateway) {
        this.smsGateway = smsGateway;
    }

    public boolean sendSms(String phoneNumber, String message) {
        return smsGateway.send(phoneNumber, message);
    }
}

public interface SmsGateway {
    boolean send(String phoneNumber, String message);
}
@Test
public void testSendSmsIntegration() {
    SmsGateway smsGateway = new RealSmsGateway(); // 실제 SMS 발송 API 사용
    SmsService smsService = new SmsService(smsGateway);

    boolean result = smsService.sendSms("123-456-7890", "Test Message");

    // 실제 SMS 발송 시스템에 대한 응답 확인
    assertTrue(result);
}

 

목을 사용한 동작 검증

  • 목 객체는 주로 애플리케이션의 경계를 넘는 상호작용이나 외부 의존성의 동작을 검증할 때 사용됩니다.
  • 클라이언트 목표에 부합하는 동작이 제대로 이루어졌는지 + 상호작용의 부작용이 제대로 처리되었는지를 검증하는 것이 핵심입니다.