블라디미르 코리코프 님의 "단위 테스트" 책을 정리한 포스팅입니다.
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);
}
목을 사용한 동작 검증
- 목 객체는 주로 애플리케이션의 경계를 넘는 상호작용이나 외부 의존성의 동작을 검증할 때 사용됩니다.
- 클라이언트 목표에 부합하는 동작이 제대로 이루어졌는지 + 상호작용의 부작용이 제대로 처리되었는지를 검증하는 것이 핵심입니다.
'Code > Test' 카테고리의 다른 글
[단위 테스트] 6-2. 단위 테스트 스타일: 함수형 아키텍처 (0) | 2025.01.29 |
---|---|
[단위 테스트] 6-1. 단위 테스트 스타일: 단위 테스트 스타일 (0) | 2025.01.25 |
[단위 테스트] 5-1. 목과 테스트 취약성: 목 (0) | 2025.01.24 |
[단위 테스트] 4-2. 좋은 단위 테스트의 4대 요소: 이상적인 테스트 (0) | 2025.01.24 |
[단위 테스트] 4-1. 좋은 단위 테스트의 4대 요소: 4대 요소 (0) | 2025.01.19 |