Spring Boot 기반 실무에서의 디자인 패턴과 도메인 디자인 패턴 적용 사례
Spring Boot 기반의 백엔드 개발을 진행하면서 다양한 디자인 패턴을 적용해야 하는 상황을 자주 마주하게 됩니다. 특히 도메인 중심의 설계를 고려할 때, 단순한 CRUD를 넘어 비즈니스 로직을 체계적으로 관리하는 것이 중요합니다. 이번 포스팅에서는 실무에서 경험한 디자인 패턴과 도메인 디자인 패턴을 적용한 사례를 공유하며, 어떤 고민을 했고, 어떤 방식으로 해결했는지를 정리해 보겠습니다.
본 게시글은 실무 경험을 기반으로 작성되었으나, 회사의 실제 데이터 모델 및 프로젝트 내용과는 무관하며 일부 내용을 각색하였습니다. 보안 및 기밀 유지 정책을 준수하기 위해 특정 기술적 세부 사항이 변경되었음을 알려드립니다.
1. 레이어드 아키텍처와 DIP (Dependency Inversion Principle) 적용
💡 고민: 서비스 간 결합도를 낮추면서 유지보수성을 높이는 방법
Spring Boot 기반의 애플리케이션에서는 흔히 Controller-Service-Repository로 구성된 레이어드 아키텍처를 사용합니다. 하지만 서비스 레이어가 점점 커지면서 특정 서비스 간의 의존성이 강해지는 문제가 발생할 수 있습니다.
✅ 해결: 인터페이스 기반의 DIP 적용
// 도메인 계층 인터페이스 정의
public interface OrderService {
Order createOrder(OrderRequest request);
}
// 구현체 - 실제 비즈니스 로직 처리
@Service
public class OrderServiceImpl implements OrderService {
private final OrderRepository orderRepository;
@Autowired
public OrderServiceImpl(OrderRepository orderRepository) {
this.orderRepository = orderRepository;
}
@Override
public Order createOrder(OrderRequest request) {
// 도메인 로직 처리
return orderRepository.save(new Order(request));
}
}
}
💡 핵심 아이디어:
- OrderService 인터페이스를 도입하여 OrderServiceImpl을 구현체로 둠으로써, **의존성 주입을 통해 DIP(의존 역전 원칙)**을 적용.
- 특정 구현체에 의존하지 않도록 설계하여 테스트 용이성 및 확장성 확보.
2. Factory 패턴을 활용한 객체 생성 관리
💡 고민: 도메인 객체의 복잡한 생성을 한 곳에서 관리하는 방법
서비스 레이어에서 여러 개의 도메인 객체를 생성해야 하는 경우, 생성 로직이 중복되거나 여러 곳에 흩어질 가능성이 있습니다. 이를 해결하기 위해 팩토리 패턴을 적용했습니다.
✅ 해결: Factory 클래스를 도입하여 생성 책임을 위임
@Component
public class OrderFactory {
public Order createOrder(OrderRequest request) {
return new Order(
request.getUserId(),
request.getProductId(),
request.getAmount(),
OrderStatus.PENDING
);
}
}
// 서비스 레이어에서 사용
@Service
public class OrderServiceImpl implements OrderService {
private final OrderRepository orderRepository;
private final OrderFactory orderFactory;
@Autowired
public OrderServiceImpl(OrderRepository orderRepository, OrderFactory orderFactory) {
this.orderRepository = orderRepository;
this.orderFactory = orderFactory;
}
@Override
public Order createOrder(OrderRequest request) {
Order order = orderFactory.createOrder(request);
return orderRepository.save(order);
}
}
💡 핵심 아이디어:
- 객체 생성의 책임을 OrderFactory로 위임하여 서비스 레이어가 도메인 생성 로직을 직접 다루지 않도록 함.
- 유지보수성과 가독성을 높이고, 향후 객체 생성 로직 변경이 필요할 때 한 곳에서만 수정할 수 있도록 구조화.
3. 전략 패턴 (Strategy Pattern) 기반의 비즈니스 로직 분리
💡 고민: 다양한 주문 유형별 처리 로직을 유연하게 적용하는 방법
B2B 플랫폼에서 결제 방식(카드, 계좌이체, 포인트 등)에 따라 다른 정책을 적용해야 하는 요구사항이 있었습니다.
이전에는 if-else가 반복되는 방식으로 구현했으나, 새로운 결제 수단이 추가될 때마다 코드 변경이 필요해지는 문제점이 있었습니다.
✅ 해결: 전략 패턴을 활용한 유연한 정책 적용
public interface PaymentStrategy {
void pay(Order order);
}
// 카드 결제
@Component
public class CardPaymentStrategy implements PaymentStrategy {
@Override
public void pay(Order order) {
System.out.println("Processing card payment for order: " + order.getId());
}
}
// 계좌이체 결제
@Component
public class BankTransferPaymentStrategy implements PaymentStrategy {
@Override
public void pay(Order order) {
System.out.println("Processing bank transfer for order: " + order.getId());
}
}
// 결제 서비스에서 전략 주입
@Service
public class PaymentService {
private final Map<PaymentType, PaymentStrategy> strategyMap;
@Autowired
public PaymentService(List<PaymentStrategy> strategies) {
this.strategyMap = strategies.stream()
.collect(Collectors.toMap(strategy -> strategy.getClass().getAnnotation(Component.class).value(), strategy -> strategy));
}
public void processPayment(PaymentType type, Order order) {
PaymentStrategy strategy = strategyMap.get(type);
if (strategy != null) {
strategy.pay(order);
} else {
throw new IllegalArgumentException("Unsupported payment type: " + type);
}
}
}
💡 핵심 아이디어:
- PaymentStrategy 인터페이스를 정의하고, 결제 방식마다 구현체를 분리하여 유연성을 확보.
- PaymentService에서 특정 결제 방식을 선택하여 실행, 새로운 결제 방식이 추가될 경우 기존 코드 변경 없이 확장 가능.
4. 애그리게이트 루트(Aggregate Root)와 도메인 이벤트 적용
💡 고민: 한 번의 트랜잭션에서 여러 도메인 변경을 안전하게 처리하는 방법
주문을 생성하면서 사용자의 포인트 차감, 배송 정보 업데이트 등 여러 도메인 객체를 동시에 변경해야 하는 상황이 있었습니다.
이를 기존 서비스 계층에서 직접 처리하면 트랜잭션 관리 및 도메인 변경이 강하게 결합되는 문제가 발생했습니다.
✅ 해결: 도메인 이벤트(Domain Event)를 활용한 비즈니스 로직 분리
// 도메인 이벤트 정의
public class OrderCreatedEvent {
private final Order order;
public OrderCreatedEvent(Order order) {
this.order = order;
}
public Order getOrder() {
return order;
}
}
// 이벤트 리스너 활용
@Component
public class OrderEventListener {
@EventListener
public void handleOrderCreated(OrderCreatedEvent event) {
System.out.println("Handling event for order: " + event.getOrder().getId());
}
}
// 서비스에서 이벤트 발행
@Service
public class OrderServiceImpl {
private final ApplicationEventPublisher eventPublisher;
@Autowired
public OrderServiceImpl(ApplicationEventPublisher eventPublisher) {
this.eventPublisher = eventPublisher;
}
public Order createOrder(OrderRequest request) {
Order order = new Order(request);
eventPublisher.publishEvent(new OrderCreatedEvent(order)); // 이벤트 발행
return order;
}
}
💡 핵심 아이디어:
- 도메인 이벤트를 활용하여 주문 생성 이후 후속 처리를 비동기적으로 실행.
- 서비스 간 강한 결합을 줄이고, 이벤트 리스너를 활용해 유지보수성을 향상.
결론
이번 포스팅에서는 Spring Boot 기반 실무에서 적용한 주요 디자인 패턴(팩토리, 전략, 도메인 이벤트 등)을 활용한 고민과 해결 방안을 다뤘습니다.
이를 통해 유지보수성과 확장성이 뛰어난 구조를 만들 수 있었고, 도메인 중심의 설계(Domain-Driven Design, DDD) 관점에서도 효과적인 패턴들을 활용할 수 있었습니다.
앞으로도 실무에서의 다양한 경험을 바탕으로 더욱 발전된 패턴들을 연구하고 적용해 나가겠습니다.
'백엔드 프레임워크 > SpringBoot' 카테고리의 다른 글
Spring @Transactional - REQUIRES_NEW vs REQUIRED, 그리고 실전 적용 사례 (0) | 2025.03.11 |
---|