시작하며
안녕하세요. 개발자 stark입니다.
최근 제가 현업에서 테스트 코드 작성과 코드 리팩토링을 진행하며 느낀 점들이 굉장히 많습니다. 이전에 프로젝트를 구성했을 때는 비즈니스에서 요구하는 기능을 구현하는 것과 기술적으로 어떤 것을 사용하는 것이 좋을지에 대해 집중했었던 것과 달리 이번 기회에 개발이란 무엇인가에 대해서 정말 많은 고민을 하게 되었고 저 스스로는 어떤 생각을 하며 개발을 해왔는지도 정리할 수 있게 되었습니다. 그래서 이번 포스팅에서는 제가 한 생각들을 조금 회고해보려고 합니다. 완전 개인적인 생각이니 이런 생각도 있구나 하며 재미있게 봐주세요 ㅎㅎ
추상화는 정말 중요하다
저에게 추상화는 너무 어렵습니다. 클래스나 메서드 이름을 작명할 때 추상화를 적용하게 되는 경우가 많은데 제가 만든 메서드명만 보고 어떤 비즈니스를 하는지 표시가 되면서도 내부의 수많은 작업들은 캡슐화시켜야 하는 이런 상황이 정말 엄청난 고민을 하게 만들어줍니다. 왜냐하면 추상화를 잘못 적용하면 이게 무슨 기능인지 알아보기 힘들어지면서 오히려 가독성을 해치게 되고 과도하거나 부족하면 코드의 흐름이 이해가 가지 않게 되기 때문입니다. 즉, 적절한 중간 지점을 찾아야 하는 굉장히 힘든 과정이라고 생각합니다.
추상화가 과도하게 진행된 메서드는 다음과 같은 형태를 보입니다.
사용자주문처리() {
사용자주문데이터검증();
사용자주문전략선택기생성();
선택된전략 = 사용자주문전략선택기.적절한전략선택(주문);
주문처리팩토리 = 주문처리팩토리생성기.팩토리생성();
주문처리자 = 주문처리팩토리.처리자생성(선택된전략);
결과 = 주문처리자.실행(주문);
결과검증자 = 새로운결과검증자(결과);
검증된결과 = 결과검증자.검증();
return 검증된결과;
}
이 메서드의 문제점은 간단한 주문 처리를 위해 너무 많은 단계와 객체가 필요하고 코드 흐름을 이해하기 위해서는 여러 클래스와 인터페이스를 다 살펴봐야 합니다. 또한 실제로는 필요하지 않은 유연성을 위해 전략을 선택하는 등의 과도한 복잡성이 적용되어 있습니다.
그럼 추상화가 부족하게 적용된다면 어떨까요?
사용자주문처리() {
// 300줄의 코드가 한 메서드에...
if (주문.상품목록 == null || 주문.상품목록.길이 == 0) {
throw new 예외("상품이 없습니다");
}
if (주문.배송주소 == null || 주문.배송주소.길이() < 10) {
throw new 예외("배송주소가 유효하지 않습니다");
}
// 50줄의 추가 유효성 검사 코드...
데이터베이스연결 연결 = 새로운데이터베이스연결();
연결.쿼리실행("INSERT INTO 주문 VALUES (...)");
// 30줄의 재고 업데이트 코드...
연결.쿼리실행("UPDATE 재고 SET 수량 = 수량 - " + 주문상품.수량);
// 40줄의 결제 처리 코드...
결제API.결제요청(주문.카드번호, 주문.금액);
// 30줄의 이메일 발송 코드...
SMTP클라이언트 이메일 = 새로운SMTP클라이언트();
이메일.메시지작성("주문완료", "고객님의 주문이 처리되었습니다...");
이메일.발송(주문.이메일);
// 20줄의 로깅 코드...
로그.정보("주문 " + 주문.아이디 + " 처리됨");
return "성공";
}
이 메서드는 하나의 메서드가 너무 많은 책임을 가지고 있으며 내부 흐름에서 코드 중복이 발생할 가능성이 높습니다. 또한 유지보수와 테스트하기도 어렵습니다. 그리고 이 코드를 이해하기 위해서는 이전에 읽은 코드가 어떤 작업을 했었는지 다 기억해 가며 전체 흐름을 이해해야만 합니다. (코드가 400줄이 넘어간다면 과연 400줄까지 모든 과정이 외워질까요..?)
그럼 대체 어떤 코드가 추상화가 잘 된 코드라고 할 수 있을까요? 아래 메서드를 봅시다.
사용자주문처리() {
주문검증(주문);
주문아이디 = 주문저장(주문);
결제결과 = 결제처리(주문);
if (!결제결과.성공) {
주문상태업데이트(주문아이디, 주문상태.결제실패);
return 새로운주문결과(실패, "결제 실패: " + 결제결과.메시지);
}
주문상태업데이트(주문아이디, 주문상태.결제완료);
재고업데이트(주문.상품목록);
알림전송(주문, 주문아이디, 알림유형.주문확인);
주문완료로깅(주문아이디, 주문);
return 새로운주문결과(성공, 주문아이디);
}
주문검증(주문) {
if (주문.상품목록이_비어있음()) {
throw new 주문예외("상품이 없습니다");
}
if (!주소가_유효함(주문.배송주소)) {
throw new 주문예외("배송주소가 유효하지 않습니다");
}
결제정보검증(주문);
재고확인(주문.상품목록);
}
주소가_유효함(주소) {
return 주소 != null && 주소.길이() >= 10;
}
결제정보검증(주문) {
// 카드 정보 검증 로직
}
재고확인(상품목록) {
// 재고 확인 로직
}
주문저장(주문) {
// 주문 저장 로직
return 주문아이디;
}
결제처리(주문) {
try {
return 결제서비스.처리(주문.결제정보, 주문.총액);
} catch (결제예외 e) {
로그.오류("결제 실패", e);
return 새로운결제결과(실패, e.메시지);
}
}
주문상태업데이트(주문아이디, 상태) {
주문저장소.상태갱신(주문아이디, 상태);
}
재고업데이트(상품목록) {
상품목록.각각에_대해(상품 -> {
재고저장소.수량감소(상품.아이디, 상품.수량);
});
}
알림전송(주문, 주문아이디, 알림유형) {
이메일템플릿 = 이메일템플릿팩토리.템플릿생성(알림유형);
제목 = 이메일템플릿.제목생성(주문아이디);
내용 = 이메일템플릿.내용생성(주문, 주문아이디);
알림서비스.이메일전송(주문.고객이메일, 제목, 내용);
}
주문완료로깅(주문아이디, 주문) {
로그.정보("주문 " + 주문아이디 + " 처리 완료. 고객: " + 주문.고객아이디
+ ", 금액: " + 주문.총액);
}
이 메서드는 추상화가 잘 적용되어 메인 메서드가 사용하는 각 메서드가 명확한 하나의 책임을 갖고 메서드 이름을 보면 명확한 의도를 알 수 있습니다. 또한 주 메서드(사용자주문처리)는 높은 수준의 비즈니스 로직에 집중할 수 있습니다. 제일 중요한 것은 각 기능이 적절히 분리되어 있어 테스트와 유지보수가 매우 용이합니다.
즉, 적절한 추상화를 적용한다면 코드는 '더 읽기 쉽고, 테스트하기 쉬우며, 변경에 유연하게 대응'할 수 있게 됩니다. 저는 이런 개념들을 착실히 적용해나가고 있지만 아직 많은 고민이 됩니다. 추상화를 위해 메서드를 잘 분리한다고 하더라도 대부분 private 메서드로 설계되어 테스트하기 쉽지 않고 어느 정도의 책임을 가지도록 분리하는 게 좋을지 그리고 무조건 잘게 분리해서 메서드를 나누는 게 좋은 방법일지.. 이것들은 직접 경험해 가며 성장해야 한다고 생각하기에 선배님들이 알려준 기준점은 있지만 스스로는 명확한 답을 내놓지 못했습니다.
메서드명은 어떻게 작명해야 하나?
추상화에 대한 개념을 익혀갈 때쯤 도메인 내부에 선언된 비즈니스 메서드의 이름을 어떻게 지어야 할지에 대해서도 많은 고민을 하게 되었습니다. 왜냐하면 도메인 주도 설계(DDD)를 하면서 제가 가장 고민한 것이 풍부한 도메인을 구성하는 것이었는데 이것을 위해서는 도메인이 가진 메서드가 비즈니스를 잘 표현하도록 만들어져야 한다는 것 때문이었습니다. 결국 도메인 내부의 메서드만 봐도 이 도메인을 통해 무엇을 하는지 바로 이해 가능하도록 잘 추상화된 메서드를 만들어야 한다고 생각이 되었기 때문입니다.
제가 처음 DDD를 접하고 마구잡이로 코드를 만들어볼 때는 단순하게 메서드명이 통일성을 가지도록만 구성하였습니다. 그래서 객체에서 'setter를 사용하지 않기만 하면 되겠지~'라고 생각하며 아주 단순하게 메서드명을 직관적으로만 지었습니다. 예를 들어 값 변경이 관련되었다면 changeXxx(), 특정 데이터 추가라면 putXxx() 또는 applyXxx(), 검증은 valideXxx(), 참/거짓 판별은 isXx() 또는 hasXx() 이런 식으로 데이터를 중심으로 메서드명을 구성했습니다.
아래와 같이 도메인 메서드를 구성하고 혼자 뿌듯해하며 이게 비즈니스인가? 이러면서 기뻐하던 제 모습이 떠오릅니다..
public class Order {
private List<OrderItem> items;
private OrderStatus status;
private Customer customer;
private BigDecimal totalAmount;
private PaymentMethod paymentMethod;
private ShippingAddress shippingAddress;
// 표준 패턴만 따르는 메서드명
public void changeStatus(OrderStatus status) {
this.status = status;
}
public void putItem(OrderItem item) {
this.items.add(item);
calculateTotal();
}
public void applyDiscount(Discount discount) {
this.totalAmount = this.totalAmount.subtract(discount.getAmount());
}
public boolean validateShippingAddress() {
return this.shippingAddress != null && this.shippingAddress.isComplete();
}
public boolean isReady() {
return this.status == OrderStatus.CONFIRMED && !this.items.isEmpty();
}
public void changePaymentMethod(PaymentMethod paymentMethod) {
this.paymentMethod = paymentMethod;
}
private void calculateTotal() {
this.totalAmount = items.stream()
.map(OrderItem::getPrice)
.reduce(BigDecimal.ZERO, BigDecimal::add);
}
}
특히 처음 DDD를 접했던 시점에는 경험이 부족하기도 했고 기존 mvc 패턴이 사용된 프로젝트에서 서비스 클래스에서 setter가 선언된 객체가 있다면 그냥 아무 생각 없이 많이들 사용하기 때문에 change, put, apply, validate 같이 setter와는 다른 이름을 지어서 사용한다는 것만으로도 뭔가 풍부한 도메인이 완성된 것만 같은 기분을 느끼게 해 주었습니다.
그러나 계속해서 DDD의 개념을 공부하고 비즈니스에 대한 이해도가 높아지면서 제가 만들고 있는 코드들에서 비즈니스가 보이지 않는다는 것을 알게 되었습니다. 어느 순간 changeXxx();라는 도메인 메서드는 비즈니스를 표현하는 것이 아니라는 것을 깨달은 것입니다. 이것을 알게 되기까지 많은 우여곡절이 있었지만 DDD에서 도메인 비즈니스 메서드 자체가 유비쿼터스 언어(Ubiquitous Language)의 일부라는 것을 이해하기 시작하면서 많은 변화가 있었던 것 같습니다. 이 개념을 제가 만든 도메인에 적용해 봤을 때 대체 이 도메인이 뭘 하는 건지 전혀 알 수 없었기 때문입니다.
이런 고민 과정을 거쳐 성장한 저는 이런 식으로 도메인 메서드를 구성하게 되었습니다.
public class Order {
private List<OrderItem> items;
private OrderStatus status;
private Customer customer;
private Money totalAmount;
private PaymentMethod paymentMethod;
private ShippingAddress shippingAddress;
// 비즈니스 언어와 의도를 명확히 표현하는 메서드명
public void confirmOrder() {
if (!canBeConfirmed()) {
throw new OrderOperationException("Order cannot be confirmed in its current state");
}
this.status = OrderStatus.CONFIRMED;
}
public void cancelOrder(CancellationReason reason) {
if (!canBeCancelled()) {
throw new OrderOperationException("Order cannot be cancelled in its current state");
}
this.status = OrderStatus.CANCELLED;
// 추가적인 취소 처리 로직
}
public void addProductToOrder(Product product, int quantity) {
OrderItem newItem = new OrderItem(product, quantity);
this.items.add(newItem);
recalculateOrderTotal();
}
public void removeProductFromOrder(Product product) {
this.items.removeIf(item -> item.getProduct().equals(product));
recalculateOrderTotal();
}
public void applyPromotionCode(PromotionCode code) {
if (code.isExpired() || !code.isApplicableTo(this)) {
throw new InvalidPromotionCodeException("This promotion code cannot be applied to this order");
}
this.totalAmount = code.applyTo(this.totalAmount);
}
public boolean isReadyForShipment() {
return status == OrderStatus.PAID &&
shippingAddress != null &&
shippingAddress.isValidForDelivery();
}
public void selectPaymentMethod(PaymentMethod paymentMethod) {
if (status != OrderStatus.CREATED) {
throw new OrderOperationException("Payment method can only be selected for new orders");
}
this.paymentMethod = paymentMethod;
}
private boolean canBeConfirmed() {
return status == OrderStatus.CREATED &&
!items.isEmpty() &&
shippingAddress != null;
}
private boolean canBeCancelled() {
return status != OrderStatus.SHIPPED &&
status != OrderStatus.DELIVERED;
}
private void recalculateOrderTotal() {
this.totalAmount = items.stream()
.map(OrderItem::getSubtotal)
.reduce(Money.zero(), Money::add);
}
}
이렇게 부실한 도메인 비즈니스를 개선하였습니다. 이제 Order라는 도메인이 어떤 비즈니스를 가지는지 쉽게 이해할 수 있지 않나요? 처음 제가 작성했던 코드에서는 changeStatus(OrderStatus status) 이런 식으로 데이터 변경이라는 의미를 담은 메서드를 구성했습니다. 그러나 새로운 메서드는 confirmOrder(), cancelOrder(CancellationReason reason) 이런 식으로 실제 비즈니스의 행위를 표현하고 있습니다. 결국 둘 다 status가 변경된다는 것은 동일하지만 메서드를 통해 외부에 표현하는 것이 비즈니스 행위 그 자체가 되었다는 것이 핵심입니다.
또한 applyDiscount, calculateTotal 같이 기술적인 용어를 사용하는 것이 아니라 applyPromotionCode, recalculateOrderTotal처럼 비즈니스 용어를 사용해서 개발자와 도메인 전문가 간의 공통 언어(유비쿼터스 언어)를 형성할 수 있다는 것입니다. 이것이 주는 의미가 굉장한데 기획자, 도메인 전문자, 개발자가 서로 같은 용어를 가지고 대화를 할 수 있다면 정말 빠르고 명확하게 작업을 진행할 수 있게 됩니다.
이렇게 도메인 메서드 작성 방법에 대한 깨달음을 얻은 후 도메인 비즈니스를 리팩토링 하며 알게 된 것은 도메인 객체를 잘 구성하면 단순 데이터 변경을 위한 객체의 의미를 넘어, 비즈니스 규칙과 프로세스를 충실히 반영하고 있는 문서 그 자체가 된다는 생각이 들었습니다. 이제 누가 이 비즈니스를 작업하게 되더라도 코드 자체가 문서이기 때문에 어떤 비즈니스 로직인지 매우 빠르게 파악하고 작업할 수 있다는 장점을 얻게 되었습니다. 제일 좋았던 것은 드디어 제가 단순히 값을 변경하는 것을 넘어선 비즈니스에 대한 가치를 가지는 메서드를 만들게 되었다는 점입니다.
비즈니스 이해가 중요한 이유
이렇게 도메인 메서드를 구성하는 기술을 익힌 저에게 아직 부족한 점이 있었습니다. 바로 비즈니스에 대한 명확한 이해입니다. 이런 생각을 하게 된 이유는 풍부한 도메인 비즈니스를 구성하고 싶지만 실제로 메서드를 만들다 보면 굉장히 부실하게 구성되는 경우들이 있기 때문입니다. 특히 위의 목차에서 본 것처럼 예전의 저는 change, put, valid 이런 단순 값 변경의 의미를 가진 메서드를 많이 만들었고 이런 메서드는 정말 단순해서 setter와 별 다를게 없이 값 1개 만을 변경하고 return 하거나 아주 간단한 예외처리만 하는 경우가 많았습니다.
저는 이런 부실한 도메인 메서드들을 보면서 정말 많은 고민을 했습니다. '정말 이런 별 볼일 없어 보이는 메서드가 DDD의 핵심인 도메인 비즈니스 로직일까?' 이것에 대해 스스로 이해하고 싶다는 오기가 생겼습니다. 그래서 계속 검색도 해보고 직접 그려보기도 하고 생각도 해보면서 엄청난 고민을 했었습니다. 이런 고민들이 지속되던 어느 날 제가 만든 코드를 한줄한줄 읽어가고 있었는데 문득 이런 생각이 들었습니다. '이런 부실해 보이는 메서드가 만들어지는 이유는 내가 비즈니스를 잘 몰라서 그냥 값만 바꾸고 있어서 그런 게 아닐까?' 그러고 저는 바로 작업 중이었던 비즈니스를 처음부터 다시 분석하고 순서를 적어가며 useCase를 정리해 봤습니다. 그러고 나서 정리된 흐름을 따라가며 다시 메서드를 구현해 봤더니 이전과는 달리 의미를 가진 풍부한 도메인 메서드가 만들어진 것을 보게 되었습니다.
즉, 기획서를 보면서 그냥 작성된 대로만 기능을 구현하려고 해서는 풍부한 비즈니스 메서드가 만들어지지 않는다는 것을 알게 된 것입니다. 비즈니스라는 것은 만들어진 여러 기능들이 상호작용하며 한 흐름이 되기 때문에 내가 만들고자 하는 기능에 대한 깊은 이해가 있어야만 풍부한 비즈니스 로직을 만들 수 있었습니다. 그리고 깊은 이해를 위해서는 비즈니스에 대한 끝없는 지식 탐구가 필요하다는 것을 알게 되었습니다.
제가 생각했던 부실한 도메인 메서드를 살펴봅시다.
public class Product {
private String name;
private Money price;
private boolean available;
// 이게 정말 필요한 도메인 메서드일까?
public void markAsUnavailable() {
this.available = false;
}
// 이 메서드는 너무 단순한 것 아닐까?
public void increasePrice(Money amount) {
this.price = this.price.add(amount);
}
}
위 코드를 보면 markAsUnavailable()와 increasePrice(Money amount) 메서드는 한 줄로 작성된 단순한 로직입니다. 처음 제가 구성한 도메인 내부에는 이런 메서드가 굉장히 많았는데 스스로 이것들을 보며 리뷰하면서 "이게 정말 의미 있는 도메인 메서드인가?"라는 의문을 하게 된 것입니다. 그리고 비즈니스를 더 깊게 이해하려는 노력을 하면서 결국 제가 비즈니스를 잘 알지 못했기 때문에 이런 메서드가 탄생한 것이라는 것을 알게 되었습니다.
자 그럼 비즈니스 맥락을 제대로 이해하면 어떻게 메서드가 구성될까요?
public class Product {
private String name;
private Money price;
private Inventory inventory;
private boolean available;
private List<PriceHistory> priceHistory;
// 비즈니스 맥락이 반영된 개선된 메서드
public void discontinue() {
if (this.inventory.getQuantity() > 0) {
throw new ProductOperationException("Cannot discontinue product with remaining inventory");
}
this.available = false;
notifySubscribers(ProductEvent.DISCONTINUED);
}
// 더 풍부한 도메인 로직
public void adjustPrice(Money newPrice, PriceChangeReason reason) {
if (newPrice.isNegative()) {
throw new InvalidPriceException("Product price cannot be negative");
}
Money oldPrice = this.price;
this.price = newPrice;
// 가격 이력 기록
this.priceHistory.add(new PriceHistory(oldPrice, newPrice, reason, LocalDateTime.now()));
// 가격 변동 폭에 따른 알림 처리
if (newPrice.isGreaterThan(oldPrice.multiply(1.2))) { // 20% 이상 인상
notifySubscribers(ProductEvent.SIGNIFICANT_PRICE_INCREASE);
} else if (oldPrice.isGreaterThan(newPrice.multiply(1.2))) { // 20% 이상 인하
notifySubscribers(ProductEvent.SIGNIFICANT_PRICE_DECREASE);
}
}
private void notifySubscribers(ProductEvent event) {
// 구독자에게 제품 이벤트 알림
}
}
기존의 markAsUnavailable() 메서드는 discontinue() 메서드로 발전하여 비즈니스 규칙이 적용되었습니다. 또한 increasePrice() 메서드는 adjustPrice() 메서드로 발전하여 가격 변경 사유라는 비즈니스 개념이 추가되었고 가격 이력 관리를 통한 감사 추적이 가능하게 되었습니다. 이제는 단순한 줄의 데이터 변경이 아니라 비즈니스를 표현하는 메서드로 진화한 것입니다.
결론적으로 단순하게 구성된 메서드일수록 기존 비즈니스 맥락을 더 자세히 살펴봐야 한다고 생각합니다. 정말 비즈니스가 제대로 표현된 게 맞을지 분석하고 이해도를 높여본다면 기존 로직보다 더 멋지게 표현되는 진정한 도메인 메서드가 될 수 있을 가능성이 높습니다. 또한 단순히 로직이 짧다고 해서 비즈니스를 표현하지 않는 것도 아닙니다. 제일 중요한 것은 코드의 길이나 복잡성이 아니라, 그 메서드가 비즈니스 언어와 규칙을 얼마나 잘 반영하는지 파악하는 것입니다.
도메인 단위 테스트의 중요성
최근 저는 도메인 비즈니스 로직에 대한 단위 테스트를 많이 작성하였습니다. 만약 테스트 주도 설계(TDD)를 했다면 이미 모든 테스트가 작성되었을 텐데 비겁한 변명을 하자면 저에게는 TDD를 하면서까지 개발할 시간이 부족했습니다. 왜냐하면 DDD, 헥사고날 아키텍처에 대한 이해를 하기 위한 스터디가 중요하다고 판단했기 때문입니다. 다만 저는 테스트 코드 작성이 정말 중요하다는 것은 알고 있었기에 도메인 비즈니스 로직을 만든 후 항상 단위 테스트를 작성했습니다. 그리고 이런 테스트를 수백 개 작성하며 비즈니스에 대한 이해도가 상당히 높아졌고 로직의 오류가 많이 줄어드는 현상을 경험하게 되었습니다.
단위 테스트를 작성하며 제일 신기한 경험은 구성한 모든 비즈니스가 1개의 happy 케이스를 성공시키기 위해 수많은 bad 케이스를 방어해야 한다는 것이었습니다. 저는 SRP를 중시하는 편이라 비즈니스 메서드를 만들 때도 항상 그 메서드가 1개의 역할만 하도록 구성을 하고 있습니다. 그래서 happy 케이스도 대부분 원했던 응답이 하나 존재합니다. 그러나 이런 구성방식과 관계없이 이 happy를 위해서는 수도 없이 많은 bad 케이스를 돌파해야만 했습니다.
이해를 위해 게시글 도메인을 예로 들어봅시다. 정말 단순하게 게시글 초기 데이터를 세팅하는 'initializePostDataByType()'이라는 별것 없어 보이는 비즈니스 메서드를 만들었습니다. 근데 이렇게 단순히 값 세팅만 하는데 대체 어떤 bad케이스가 발생할 수 있다는 건지 고민이 될 수 있습니다. 지금 구성한 메서드는 Type에 따라 다른 데이터가 세팅되어야 하는 상황입니다. 즉, 뭔가 행위가 없는 단순 데이터의 변경만 하는 메서드라고 하더라도 실제로는 비즈니스가 가진 요구 사항에 맞게 변경되어야만 한다는 것입니다.
이 메서드는 내부에서 Type에 따라 다른 게시글 기본 데이터가 세팅되도록 분기처리가 되고 있을 것입니다. 그렇기에 테스트를 할 때는 bad 케이스가 벌써 Type의 개수만큼 생긴 것입니다. 만약 이런 것들이 검증되지 않는다면 어쩌면 모든 게시글이 잘못된 상태로 저장될 것입니다. 메서드는 입력한 대로 출력해 주는 굉장히 정직한 친구이기 때문에 '휴먼 에러'들을 효과적으로 방지하기 위해서는 우리가 생선 까시를 바르듯이 가능한 bad 케이스를 예쁘게 잘 테스트하며 검증해야만 합니다.
지금처럼 '타입에 따라 게시글의 초기데이터 세팅'이라는 1개의 happy 한 목적을 성공시키기 위해 여러 가지 타입을 확인해야 하는 상황이라면 @ParameterizedTest를 통해 깔끔하게 검증할 수도 있을 것입니다. 다만 Type에 따라 값이 달라야 한다는 것이 핵심이기에 그 타입이 많아질수록 엄청난 bad 케이스를 다 검증해줘야 한다는 것이 중요합니다.
그래도 이런 경험을 해보며 느낀 점이 하나 있습니다. 어쩌면 이렇게 타입 같은 걸로 구분하는 비즈니스 자체가 문제일 수도 있다는 것입니다. 특정 도메인을 꼭 이런 식으로 타입으로 분리해야만 하는가? 그냥 데이터 자체를 분리해서 비즈니스를 구성하면 안 되는 걸까? 이런 식으로 테스트 코드를 작성하며 상황을 보다 보면 아주 단순한 비즈니스 로직이더라도 조금씩 사고의 확장이 되어 도메인과 데이터에 대한 상관관계까지 고민할 수 있게 됩니다.
테스트를 잘 작성하면 내 도메인이 어느 정도의 규모가 되어가는지도 파악할 수 있습니다. 왜냐하면 도메인의 비즈니스의 요구사항이 복잡해질수록 점점 Aggregate가 커지고 내부의 비즈니스 메서드가 가지는 역할이 이전보다 많아지면서 더 많은 bad 케이스를 가지게 될 것이기 때문입니다. 그리고 이것은 내 비즈니스 로직이 너무 방대해지고 있다는 것을 깨달을 수 있도록 도와줍니다. 1개의 happy 케이스를 위해 이전과는 비교도 안 되는 수없이 많은 bad 케이스들을 검증해야만 할 것입니다. 그리고 이런 비즈니스를 과연 메서드만 본다고 이해할 수 있을까요? 메서드를 사용하는 개발자는 어디서 오류가 날지 도저히 알 수 없을 것입니다.
그러니 테스트를 작성하며 내가 만든 비즈니스 로직을 새로운 관점으로 다시 바라보고 너무 방대하고 bad 케이스가 많다면 이것을 재구성해서 다시 테스트가 가능하고 간단하게 구성된 이해할 수 있는 새로운 코드로 개선해 나갈 수 있다는 점도 테스트 작성을 통해서만 얻을 수 있는 엄청난 이점이라고 생각합니다.
근데 만약 제가 테스트 코드를 작성하지 않았다면 이런 개념을 알 수 있었을까요? 절대 그렇지 않았을 것입니다. 결국 테스트를 작성했기 때문에 내가 작성한 비즈니스 로직이 어떤 문제를 가지고 있고 얼마나 거대해지고 있는지 그리고 어떻게 코드를 작성해야 테스트가 가능한 코드가 되는지 제대로 알 수 있었던 것입니다. 결국 테스트 코드를 작성한다는 것은 귀찮은 것이 아니라 내가 코드를 어떻게 만들어가고 있는지 와 어떤 방향으로 비즈니스를 구성해나가고 있는지를 보이게 해주는 창인 것입니다.
마무리하며
최근 저는 외부에 잘 알려진 멋진 기술들을 적용해서 개발하는 것이 중요한 것이 아니라는 것을 깨달았습니다. DDD의 개념을 잘 생각해 보면 정말 중요한 것은 도메인의 비즈니스를 어떻게 표현할지에 대한 것입니다. 즉, 개발자가 가장 집중해야 하는 것은 기술 사용 방법이 아니라 비즈니스를 어떻게 코드 내부에 녹일지에 대한 것입니다.
내가 개발 중인 도메인의 비즈니스를 명확히 이해하자. 그렇다면 앞으로 어떻게 코드를 작성해야 할지 보일 것이다.
- 개발은 문제 영역을 비즈니스로 풀어나갈 때 가장 큰 가치를 가진다. -
'DDD' 카테고리의 다른 글
[DDD] 값 객체(VO)를 활용한 유비쿼터스 언어 기반의 명확한 도메인 표현법 (0) | 2025.05.30 |
---|---|
[DDD] 단일 테이블 기반 다중 애그리거트(Aggregate) 모델링 전략 (1) | 2025.05.06 |
[DDD] Aggregate 당 하나의 Repository, 과연 최선인가? (0) | 2025.04.19 |
[DDD] 애그리게잇(Aggregate) 구성하기 (0) | 2025.03.12 |
[DDD] 유비쿼터스 언어(Ubiquitous Language)의 중요성 (0) | 2024.12.28 |