개발자라면 한 번쯤 TDD(Test-Driven Development)라는 단어를 들어봤을 겁니다. 마치 개발의 성배처럼 여겨지기도 하죠.
저도 처음 TDD를 접했을 때, 그 개념 자체는 너무나 매력적이었습니다.
TDD는 말 그대로 테스트 주도 개발입니다. 코드를 작성하기 전에 테스트 코드를 먼저 작성하는 방식이죠
TDD는 아래와 같은 Red-Green-Refactoring 사이클을 반복하며 진행됩니다.

이미지 출처 https://mbauza.medium.com/red-green-refactor-1a3fb160e649
이 사이클을 반복하면서 개발자는 자연스럽게 견고하고 유지보수하기 쉬운 코드를 작성하게 된다고 합니다.
하지만 막상 실제 프로젝트에 적용하려고 하면 생각만큼 쉽지 않은 것이 TDD였습니다.
그래서 저는 TDD의 큰 그림보다는, 우선 테스트 코드를 작성하는 것부터 시작하기로 했습니다.
"일단 테스트를 해보자!"는 마음으로요.
테스트 코드 작성이 분명 코드의 안정성을 높여주고 미래의 버그를 방지해 줄 것이라 믿었습니다.
그래서 막 새로 추가한 주문 생성 로직에 대한 단위 테스트를 작성하기 시작했습니다.
@Service
@RequiredArgsConstructor
public class OrderService {
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
private final UserRepository userRepository;
private final PointService pointService;
// 주문 전에 재고 차감
public void createOrder(OrderPostRequest request) {
User user = userRepository.findById(request.getUserId()).get();
List<ProductRequestForOrder> productRequest = request.getProducts();
Map<Long, Long> productIdQuntitiyMap = productRequest.stream()
.collect(Collectors.toMap(ProductRequestForOrder::getProductId, ProductRequestForOrder::getQuantity));
// product id 추출
List<Product> products = productRepository.findAllById(productRequest.stream().map(ProductRequestForOrder::getProductId).collect(Collectors.toList()));
// 재고 차감
deductQuantity(products, stockMap);
// 주문
Order order = Order.create(user, products);
orderRepository.save(order);
// 결제
pointService.processPayment(savedOrder);
}
private static void deductQuantity(List<Product> products, Map<Long, Long> productIdQuntitiyMap) {
for (Product product : products) {
Long quantity = productIdQuntitiyMap.get(product.getId());
if (product.isLessThanQuantity(quantity)){
throw new InsufficientStockException(INSUFFICIENT_STOCK);
}
product.deductQuantity(quantity);
}
}
}createOrder 메서드는 주문 생성의 전체적인 흐름을 담고 있습니다. 그런데 문제가 발생했습니다.
이 메서드 안에는 deductQuantity라는 재고 차감 로직을 담당하는 private 메서드가 포함되어 있었습니다.
이 deductQuantity 메서드만을 독립적으로 테스트하고 싶었는데, private 접근 제어자 때문에 외부에서 직접 호출할 수 없는 겁니다!
테스트 코드를 작성하기 어렵다는 것은 단순히 테스트 작성 방법을 몰라서가 아닐 수도 있습니다.
오히려 테스트하기 어렵게 코드가 작성되어 있을 가능성이 높다는 신호일 수 있습니다. 마치 코드가 "나를 좀 더 테스트하기 좋게 바꿔줘!"라고 외치는 것처럼 말이죠.
deductQuantity 메서드는 주문 생성 로직 중 재고 차감이라는 독립적인 관심사를 가지고 있습니다.
이 부분을 OrderService에서 private 메서드로 가지고 있기보다는, 별도의 클래스로 분리하여 독립적인 책임을 갖게 하는 것이 더 좋을 것 같다는 생각이 들었습니다.
deductQuantity 메서드는 주문 생성 로직 중 재고 차감이라는 독립적인 관심사를 가지고 있습니다.
이 부분을 OrderService에서 private 메서드로 가지고 있기보다는, 별도의 클래스로 분리하여 독립적인 책임을 갖게 하는 것이 더 좋을 것 같다는 생각이 들었습니다.
// StockManager 클래스 분리
public class StockManager {
public void deductQuantity(List<Product> products, Map<Long, Long> productIdQuantityMap) {
for (Product product : products) {
Long quantity = productIdQuantityMap.get(product.getId());
if (product.isLessThanQuantity(quantity)){
throw new InsufficientStockException(INSUFFICIENT_STOCK);
}
product.deductQuantity(quantity);
}
}
}
// OrderService 수정
public class OrderService {
private final UserRepository userRepository;
private final ProductRepository productRepository;
private final OrderRepository orderRepository;
private final PointService pointService;
private final StockManager stockManager; // StockManager 주입
public OrderService(UserRepository userRepository, ProductRepository productRepository,
OrderRepository orderRepository, PointService pointService,
StockManager stockManager) { // 생성자 주입
this.userRepository = userRepository;
this.productRepository = productRepository;
this.orderRepository = orderRepository;
this.pointService = pointService;
this.stockManager = stockManager;
}
public void createOrder(OrderPostRequest request) {
// ... (이전 코드와 동일)
// 재고 차감 (이제 private이 아닌 외부 객체의 메서드 호출)
stockManager.deductQuantity(products, productIdQuntitiyMap);
// ... (이전 코드와 동일)
}
}
이제 StockManager의 deductQuantity 메서드는 public이므로 손쉽게 단위 테스트를 작성할 수 있게 되었습니다!
단순히 테스트 코드를 작성하려 했을 뿐인데, 자연스럽게 관심사 분리와 단일 책임 원칙(SRP)을 지키는 방향으로 코드가 개선되었습니다.
다음으로 저를 고민하게 만든 부분은 바로 Repository였습니다.
OrderService는 userRepository와 productRepository, orderRepository에 강하게 의존하고 있었죠.
주문 생성 로직을 테스트하려면 실제 DB에 상품을 등록하고 조회하는 과정이 필요했는데, 이는 스프링 부트 테스트(통합 테스트)로 진행해야만 했습니다.
하지만 실제 스프링 컨텍스트와 DB를 사용하는 테스트는 속도가 느리고, 테스트 환경 설정이 복잡하며, 외부 환경에 의존적이라는 단점이 있습니다.
저는 이를 단위 테스트로 전환하여 더 빠르고 독립적인 테스트를 만들고 싶었습니다.
이를 위해 ProductRepository에 대한 Fake 객체를 만들어 사용하려고 했습니다.
테스트 시에는 실제 DB에 접근하지 않고, 메모리 상에서 동작하는 Fake 객체를 주입하여 테스트의 속도와 독립성을 확보하는 것이 목표였죠.
public class FakeProductRepository implements ProductRepository {
private final AtomicLong autoGeneratedId= new AtomicLong(0);
private final List<Product> data = new ArrayList<>();
@Override
public List<Product> findAllById(List<Long> ids) {
return data.stream()
.filter(product -> ids.contains(product.getId()))
.collect(Collectors.toList());
}
@Override
public Optional<Product> findById(Long id) {
return data.stream().filter(item -> item.getId().equals(id)).findAny();
}
@Override
public List<Product> saveAll(List<Product> products) {
data.addAll(products);
return data;
}
@Override
public Product save(Product product) {
if (product == null || product.getId() == null) {
Product newProduct = Product
.builder()
.id(autoGeneratedId.incrementAndGet())
.name(product.getName())
.price(product.getPrice())
.quantity(product.getQuantity())
.build();
data.add(newProduct);
return newProduct;
}else {
data.removeIf(item -> Objects.equals(item.getId(), product.getId()));
data.add(product);
return product;
}
}
// JPA Repository가 가진 모든 메서드를 Fake 객체에서 구현해야 하는 문제
@Override
public List<Product> findAllByPessimisticLock(List<Long> productIds) { return null; }
@Override
public List<Product> findAll() { return data; }
@Override
public List<Product> findAllByPessimisticLock2(List<Long> productIds) { return null; }
@Override
public Optional<Product> findByIdPessimisticLock(Long productId) { return Optional.empty(); }
}문제는 기존의 ProductRepository 인터페이스가 JpaRepository를 상속받고 있었다는 점이었습니다.
JpaRepository는 매우 많은 메서드를 가지고 있고, 이 모든 메서드를 FakeProductRepository에서 구현해야만 했습니다.
당장 사용하지 않는 메서드들(findAllByPessimisticLock 등)까지도요. 이는 불필요한 작업이었고, ProductRepository가 JPA라는 구체적인 기술에 강하게 결합되어 있다는 명확한 신호였습니다.
이 또한 코드가 보내는 신호였습니다.
OrderService가 JpaRepository라는 구체적인 구현체에 너무 강하게 의존하고 있다는 것을요.
이는 의존성 역전 원칙(DIP)을 위반하는 상황이었습니다. DIP는 "고수준 모듈은 저수준 모듈에 의존해서는 안 되며, 둘 다 추상화에 의존해야 한다"는 원칙입니다.
즉, OrderService가 구체적인 JpaRepository가 아닌, 추상화된 Repository 인터페이스에 의존하도록 만들어야 했습니다.
// ProductRepository 인터페이스: OrderService에서 필요한 메서드만 정의
public interface ProductRepository {
Optional<Product> findById(Long id);
List<Product> findAllById(List<Long> ids);
Product save(Product product);
List<Product> saveAll(List<Product> products);
// PessimisticLock 관련 메서드나 기타 JPA 메서드는 이 인터페이스에 정의하지 않음
}
// 실제 JPA Repository 구현체: ProductRepository 인터페이스를 구현
@Repository
@RequiredArgsConstructor
public class ProductRepositoryImpl implements ProductRepository {
private final ProductJpaRepository productJpaRepository; // 실제 JpaRepository 주입
@Override
public List<Product> findAllById(List<Long> ids) {
return productJpaRepository.findAllById(ids);
}
@Override
public List<Product> saveAll(List<Product> products) {
return productJpaRepository.saveAll(products);
}
@Override
public Product save(Product product) {
return productJpaRepository.save(product);
}
@Override
public Optional<Product> findById(Long id) {
return productJpaRepository.findById(id);
}
// JpaRepository의 다른 메서드들은 ProductRepositoryImpl 내부에서만 사용하고
// ProductRepository 인터페이스에는 노출하지 않음
public List<Product> findAllByPessimisticLock(List<Long> productIds) {
return productJpaRepository.findAllByPessimisticLock(productIds);
}
public List<Product> findAll() {
return productJpaRepository.findAll();
}
// ... 등등
}이제 ProductRepository 인터페이스는 OrderService가 필요로 하는 최소한의 메서드만 정의하게 되었습니다.
실제 JPA의 복잡한 메서드들은 ProductRepositoryImpl 내부에서 처리하고, OrderService는 오직 ProductRepository 인터페이스를 통해 필요한 기능에만 접근합니다.
이로 인해 FakeProductRepository를 만들 때도, ProductRepository 인터페이스에 정의된 몇 개의 메서드만 구현하면 됩니다.
굳이 필요 없는 findAllByPessimisticLock 같은 메서드까지 구현할 필요가 없어지는 것이죠.
덕분에 OrderService의 단위 테스트는 외부 환경에 의존하지 않고 독립적으로 실행할 수 있게 되었고, 테스트 코드 작성도 훨씬 용이해졌습니다.
처음에는 단순히 테스트 코드를 작성하려 했을 뿐인데, 그 과정에서 의미 있는 발견들을 할 수 있었습니다.
테스트하기 어려운 코드는 리팩토링이 필요한 코드일 가능성이 높다. private 메서드나 강결합된 의존성은 테스트를 방해하는 동시에 코드의 설계 문제를 드러냈습니다.
관심사 분리를 통한 단일 책임 원칙(SRP) 적용. deductQuantity 로직을 별도의 StockManager 클래스로 분리함으로써 각 클래스가 하나의 책임만 가지도록 개선되었고, 이는 코드의 가독성과 유지보수성을 크게 향상시켰습니다.
의존성 역전 원칙(DIP)의 중요성 체감. 구체적인 구현체가 아닌 추상화된 인터페이스에 의존하게 함으로써 코드의 유연성을 확보하고 테스트 용이성을 극대화할 수 있었습니다. 특히 JPA Repository와 같은 특정 기술에 대한 의존성을 끊어내어 단위 테스트의 독립성을 확보하는 데 큰 도움이 되었습니다.
이 외에도 테스트 코드를 작성하면서 얻을 수 있는 장점은 많습니다.
테스트 코드는 단순히 버그를 잡는 도구가 아닙니다. 좋은 테스트 코드를 작성하려는 노력은 곧 좋은 코드를 작성하려는 노력과 직결됩니다.
만약 테스트 코드 작성이 어렵게 느껴진다면, 그 어려움이 코드가 보내는 리팩토링 신호일 수 있다는 점을 기억하고 코드를 개선하는 기회로 삼아보세요.
분명 더 나은 개발 경험과 높은 품질의 코드를 만날 수 있을 겁니다!