Spring 01: 디자인 패턴 (1)
Spring을 이해하기 위한 필수 디자인 패턴 - Singleton, Factory, Template Method, Strategy를 이커머스 예제와 함께 학습합니다.
1. 왜 디자인 패턴을 알아야 하는가?
2. Singleton 패턴 - 상품 캐시 매니저
3. Factory 패턴 - 할인 정책 생성
4. Template Method 패턴 - 주문 처리 템플릿
5. Strategy 패턴 - 결제 수단 전략
6. 이커머스 실습 - 패턴 조합 적용
7. 정리 및 Spring 연결
- • Spring에서 사용되는 핵심 디자인 패턴 4가지를 이해한다
- • 각 패턴이 Spring 어디에 적용되어 있는지 파악한다
- • 이커머스 도메인에 패턴을 적용하는 방법을 익힌다
- • 패턴을 활용하여 확장 가능한 코드를 작성할 수 있다
1. 왜 디자인 패턴을 알아야 하는가?
"디자인 패턴은 소프트웨어 설계에서 반복적으로 발생하는 문제에 대한 재사용 가능한 해결책이다."
— Gang of Four, Design Patterns (1994)
1.1 Spring을 이해하려면 패턴을 알아야 한다
Spring Framework는 디자인 패턴의 집합체입니다. Spring 코드를 읽다 보면 "왜 이렇게 복잡하게 만들었지?"라는 의문이 들 때가 있습니다. 하지만 패턴을 알면 그 설계 의도가 명확하게 보입니다.
Spring에서 사용되는 주요 디자인 패턴
| 패턴 | Spring 적용 | 예시 |
|---|---|---|
| Singleton | Bean 기본 스코프 | @Component, @Service |
| Factory | Bean 생성 | BeanFactory, @Bean |
| Template Method | 반복 코드 제거 | JdbcTemplate, RestTemplate |
| Strategy | 알고리즘 교체 | 인터페이스 + DI |
| Proxy | 횡단 관심사 | AOP, @Transactional |
| Observer | 이벤트 처리 | ApplicationEvent |
| Adapter | 인터페이스 변환 | HandlerAdapter |
| Facade | 복잡성 숨김 | Service Layer |
1.2 패턴 없는 코드 vs 패턴 적용 코드
이커머스에서 할인 정책을 적용하는 코드를 예로 들어보겠습니다.
❌ 패턴 없는 코드 (안티패턴)
public class OrderService {
public Money calculateDiscount(Order order, String discountType) {
Money discount = Money.ZERO;
if (discountType.equals("PERCENT")) {
// 퍼센트 할인
discount = order.getTotalPrice().multiply(0.1);
} else if (discountType.equals("FIXED")) {
// 정액 할인
discount = Money.of(10000);
} else if (discountType.equals("BULK")) {
// 대량 구매 할인
if (order.getItemCount() >= 10) {
discount = order.getTotalPrice().multiply(0.15);
}
} else if (discountType.equals("VIP")) {
// VIP 할인
if (order.getCustomer().isVip()) {
discount = order.getTotalPrice().multiply(0.2);
}
} else if (discountType.equals("SEASONAL")) {
// 시즌 할인
if (isHolidaySeason()) {
discount = order.getTotalPrice().multiply(0.25);
}
}
// 새로운 할인 정책이 추가될 때마다 else if 추가...
return discount;
}
}문제점: 새로운 할인 정책이 추가될 때마다 이 메서드를 수정해야 합니다. 이는 OCP(Open-Closed Principle)를 위반하며, 코드가 점점 복잡해집니다.
✅ Strategy 패턴 적용 코드
// 1. 전략 인터페이스 정의
public interface DiscountPolicy {
Money calculateDiscount(Order order);
boolean supports(Order order);
}
// 2. 구체적인 전략 구현
@Component
public class PercentDiscountPolicy implements DiscountPolicy {
@Override
public Money calculateDiscount(Order order) {
return order.getTotalPrice().multiply(0.1);
}
@Override
public boolean supports(Order order) {
return order.hasPercentCoupon();
}
}
@Component
public class VipDiscountPolicy implements DiscountPolicy {
@Override
public Money calculateDiscount(Order order) {
return order.getTotalPrice().multiply(0.2);
}
@Override
public boolean supports(Order order) {
return order.getCustomer().isVip();
}
}
// 3. 서비스에서 전략 사용 (Spring DI 활용)
@Service
@RequiredArgsConstructor
public class OrderService {
private final List<DiscountPolicy> discountPolicies; // 모든 정책 자동 주입
public Money calculateDiscount(Order order) {
return discountPolicies.stream()
.filter(policy -> policy.supports(order))
.map(policy -> policy.calculateDiscount(order))
.reduce(Money.ZERO, Money::add);
}
}1.3 패턴을 알면 보이는 것들
패턴 학습 전후 비교
┌─────────────────────────────────────────────────────────────────┐
│ 패턴을 모를 때 │
├─────────────────────────────────────────────────────────────────┤
│ @Transactional → "어노테이션 붙이면 트랜잭션 된다" │
│ JdbcTemplate → "SQL 실행하는 유틸 클래스" │
│ BeanFactory → "Bean 만드는 곳" │
│ @Autowired → "자동으로 주입해주는 것" │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ 패턴을 알고 난 후 │
├─────────────────────────────────────────────────────────────────┤
│ @Transactional → "Proxy 패턴으로 메서드 전후에 트랜잭션 처리" │
│ JdbcTemplate → "Template Method로 반복 코드 제거" │
│ BeanFactory → "Factory 패턴으로 객체 생성 캡슐화" │
│ @Autowired → "Strategy 패턴 + DI로 구현체 교체 가능" │
└─────────────────────────────────────────────────────────────────┘💡 이 강좌에서 배울 패턴
Session 01 (이번 시간)
- • Singleton - 인스턴스 하나만 유지
- • Factory - 객체 생성 캡슐화
- • Template Method - 알고리즘 골격 정의
- • Strategy - 알고리즘 교체 가능
Session 02 (다음 시간)
- • Proxy - 대리자를 통한 접근 제어
- • Decorator - 기능 동적 추가
- • Observer - 이벤트 기반 통신
- • Adapter - 인터페이스 변환
- • Facade - 복잡성 숨김
학습 팁: 각 패턴을 배울 때 "이 패턴이 Spring 어디에 적용되어 있는가?"를 항상 생각하세요. 패턴과 Spring을 연결하면 이해가 훨씬 깊어집니다.
2. Singleton 패턴 - 상품 캐시 매니저
"클래스의 인스턴스가 오직 하나만 생성되도록 보장하고, 이 인스턴스에 대한 전역적인 접근점을 제공한다."
— Gang of Four, Design Patterns
2.1 Singleton 패턴이란?
Singleton 패턴은 애플리케이션 전체에서 단 하나의 인스턴스만 존재해야 하는 객체를 만들 때 사용합니다. 데이터베이스 커넥션 풀, 로거, 설정 관리자 등이 대표적인 예입니다.
Singleton 패턴 구조
┌─────────────────────────────────────────────────────────┐
│ Singleton │
├─────────────────────────────────────────────────────────┤
│ - instance: Singleton ← 유일한 인스턴스 (static) │
├─────────────────────────────────────────────────────────┤
│ - Singleton() ← private 생성자 │
│ + getInstance(): Singleton ← 전역 접근점 │
│ + operation(): void │
└─────────────────────────────────────────────────────────┘
Client A ──┐
│
Client B ──┼──→ getInstance() ──→ [동일한 인스턴스]
│
Client C ──┘2.2 전통적인 Singleton 구현
방법 1: Eager Initialization (즉시 초기화)
public class ProductCacheManager {
// 클래스 로딩 시점에 인스턴스 생성
private static final ProductCacheManager INSTANCE = new ProductCacheManager();
private final Map<Long, Product> cache = new ConcurrentHashMap<>();
// private 생성자 - 외부에서 new 불가
private ProductCacheManager() {
System.out.println("ProductCacheManager 인스턴스 생성");
}
// 전역 접근점
public static ProductCacheManager getInstance() {
return INSTANCE;
}
public void put(Long productId, Product product) {
cache.put(productId, product);
}
public Optional<Product> get(Long productId) {
return Optional.ofNullable(cache.get(productId));
}
public void evict(Long productId) {
cache.remove(productId);
}
}
// 사용
ProductCacheManager cache = ProductCacheManager.getInstance();
cache.put(1L, product);단점: 사용하지 않아도 인스턴스 생성됨
방법 2: Lazy Initialization with Double-Checked Locking
public class ProductCacheManager {
// volatile: 멀티스레드 환경에서 가시성 보장
private static volatile ProductCacheManager instance;
private final Map<Long, Product> cache = new ConcurrentHashMap<>();
private ProductCacheManager() {}
public static ProductCacheManager getInstance() {
if (instance == null) { // 1차 체크 (성능)
synchronized (ProductCacheManager.class) {
if (instance == null) { // 2차 체크 (안전성)
instance = new ProductCacheManager();
}
}
}
return instance;
}
}단점: 코드가 복잡함
방법 3: Bill Pugh Singleton (권장)
public class ProductCacheManager {
private final Map<Long, Product> cache = new ConcurrentHashMap<>();
private ProductCacheManager() {}
// static inner class - 클래스 로딩 시점에 초기화
private static class Holder {
private static final ProductCacheManager INSTANCE = new ProductCacheManager();
}
public static ProductCacheManager getInstance() {
return Holder.INSTANCE; // Holder 클래스가 로딩될 때 인스턴스 생성
}
}참고: JVM의 클래스 로딩 메커니즘을 활용
2.3 Spring의 Singleton
Spring은 위의 복잡한 구현을 개발자가 직접 할 필요가 없습니다.Spring Container가 Singleton을 관리해줍니다.
✅ Spring 방식 (권장)
@Component // Spring이 Singleton으로 관리
public class ProductCacheManager {
private final Map<Long, Product> cache = new ConcurrentHashMap<>();
// public 생성자 OK - Spring이 한 번만 호출
public ProductCacheManager() {
System.out.println("ProductCacheManager 인스턴스 생성");
}
public void put(Long productId, Product product) {
cache.put(productId, product);
}
public Optional<Product> get(Long productId) {
return Optional.ofNullable(cache.get(productId));
}
}
// 사용 - DI로 주입받음
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductCacheManager cacheManager; // 자동 주입
public Product getProduct(Long id) {
return cacheManager.get(id)
.orElseGet(() -> loadFromDatabase(id));
}
}전통적 Singleton vs Spring Singleton
┌─────────────────────────────────────────────────────────────────┐
│ 전통적 Singleton │
├─────────────────────────────────────────────────────────────────┤
│ • private 생성자 필수 │
│ • static getInstance() 메서드 필요 │
│ • 개발자가 Thread-safety 보장해야 함 │
│ • 테스트하기 어려움 (Mock 교체 불가) │
│ • 전역 상태로 인한 결합도 증가 │
└─────────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────────┐
│ Spring Singleton │
├─────────────────────────────────────────────────────────────────┤
│ • public 생성자 OK │
│ • @Component만 붙이면 끝 │
│ • Spring Container가 Thread-safety 보장 │
│ • 테스트 용이 (Mock 주입 가능) │
│ • DI로 느슨한 결합 유지 │
└─────────────────────────────────────────────────────────────────┘2.4 Singleton 사용 시 주의사항
핵심 규칙: Singleton Bean은 상태를 가지면 안 됩니다 (Stateless). 여러 스레드가 동시에 접근하므로 상태가 있으면 동시성 문제가 발생합니다.
❌ 잘못된 예 - 상태를 가진 Singleton
@Service
public class OrderService {
// ❌ 위험! 인스턴스 변수로 상태 저장
private Order currentOrder;
private Customer currentCustomer;
public void createOrder(Customer customer) {
this.currentCustomer = customer; // Thread A가 설정
this.currentOrder = new Order();
// ... 처리 중 Thread B가 currentCustomer를 덮어씀!
this.currentOrder.setCustomer(this.currentCustomer); // 잘못된 고객!
}
}✅ 올바른 예 - Stateless Singleton
@Service
@RequiredArgsConstructor
public class OrderService {
private final OrderRepository orderRepository; // ✅ 의존성만 가짐
private final PaymentService paymentService;
// 모든 상태는 메서드 파라미터와 지역 변수로 처리
public Order createOrder(Customer customer, List<OrderItem> items) {
Order order = new Order(); // ✅ 지역 변수
order.setCustomer(customer);
order.setItems(items);
order.calculateTotal();
return orderRepository.save(order);
}
}2.5 이커머스 실전 예제: 상품 캐시
Spring + Caffeine 캐시를 활용한 상품 캐시
@Component
public class ProductCache {
// Caffeine 캐시 - Thread-safe한 상태 저장 가능
private final Cache<Long, Product> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.recordStats()
.build();
public Optional<Product> get(Long productId) {
return Optional.ofNullable(cache.getIfPresent(productId));
}
public void put(Long productId, Product product) {
cache.put(productId, product);
}
public void evict(Long productId) {
cache.invalidate(productId);
}
public CacheStats getStats() {
return cache.stats();
}
}
@Service
@RequiredArgsConstructor
public class ProductService {
private final ProductCache productCache; // Singleton
private final ProductRepository productRepository;
public Product getProduct(Long productId) {
// 1. 캐시 조회
return productCache.get(productId)
.orElseGet(() -> {
// 2. DB 조회
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
// 3. 캐시 저장
productCache.put(productId, product);
return product;
});
}
@Transactional
public Product updateProduct(Long productId, ProductUpdateRequest request) {
Product product = productRepository.findById(productId)
.orElseThrow(() -> new ProductNotFoundException(productId));
product.update(request);
// 캐시 무효화
productCache.evict(productId);
return productRepository.save(product);
}
}- • @Component, @Service, @Repository는 기본적으로 Singleton
- • 상태를 가지지 않도록 설계 (Stateless)
- • 상태가 필요하면 Thread-safe한 자료구조 사용 (ConcurrentHashMap, Caffeine 등)
- • 테스트 시 Mock으로 쉽게 교체 가능
3. Factory 패턴 - 할인 정책 생성
"객체 생성을 서브클래스에 위임하여, 어떤 클래스의 인스턴스를 만들지를 서브클래스가 결정하게 한다."
— Gang of Four, Design Patterns
3.1 Factory 패턴이란?
Factory 패턴은 객체 생성 로직을 캡슐화하는 패턴입니다. 클라이언트는 구체적인 클래스를 알 필요 없이, Factory에게 객체 생성을 요청합니다.
Factory 패턴의 종류
Simple Factory
하나의 Factory 클래스가 조건에 따라 다른 객체 생성
Factory Method
서브클래스가 어떤 객체를 생성할지 결정
Abstract Factory
관련된 객체들의 집합을 생성하는 인터페이스 제공
Factory Method 패턴 구조
┌─────────────────────────────────────────────────────────────────┐
│ «interface» │
│ Product │
├─────────────────────────────────────────────────────────────────┤
│ + operation(): void │
└─────────────────────────────────────────────────────────────────┘
△ △
│ │
┌─────────┴─────────┐ ┌───────┴────────┐
│ ConcreteProductA │ │ ConcreteProductB │
└───────────────────┘ └─────────────────┘
△ △
│ creates │ creates
│ │
┌─────────────────────────────────────────────────────────────────┐
│ «interface» │
│ Factory │
├─────────────────────────────────────────────────────────────────┤
│ + createProduct(): Product │
└─────────────────────────────────────────────────────────────────┘
△ △
│ │
┌─────────┴─────────┐ ┌───────┴────────┐
│ ConcreteFactoryA │ │ ConcreteFactoryB │
└───────────────────┘ └─────────────────┘3.2 이커머스 예제: 할인 정책 Factory
이커머스에서는 다양한 할인 정책이 있습니다: 퍼센트 할인, 정액 할인, 대량 구매 할인, VIP 할인 등. Factory 패턴으로 이를 깔끔하게 관리할 수 있습니다.
❌ Factory 없이 구현 (안티패턴)
@Service
public class DiscountService {
public DiscountPolicy getDiscountPolicy(String type, Map<String, Object> params) {
// 조건문 지옥
if ("PERCENT".equals(type)) {
double rate = (Double) params.get("rate");
return new PercentDiscountPolicy(rate);
} else if ("FIXED".equals(type)) {
Money amount = (Money) params.get("amount");
return new FixedDiscountPolicy(amount);
} else if ("BULK".equals(type)) {
int minQuantity = (Integer) params.get("minQuantity");
double rate = (Double) params.get("rate");
return new BulkDiscountPolicy(minQuantity, rate);
} else if ("VIP".equals(type)) {
return new VipDiscountPolicy();
} else if ("SEASONAL".equals(type)) {
LocalDate startDate = (LocalDate) params.get("startDate");
LocalDate endDate = (LocalDate) params.get("endDate");
double rate = (Double) params.get("rate");
return new SeasonalDiscountPolicy(startDate, endDate, rate);
}
// 새로운 할인 정책 추가 시 여기를 수정해야 함...
throw new IllegalArgumentException("Unknown discount type: " + type);
}
}✅ Factory 패턴 적용
// 1. 할인 정책 인터페이스
public interface DiscountPolicy {
Money calculateDiscount(Order order);
DiscountType getType();
}
// 2. 구체적인 할인 정책들
public class PercentDiscountPolicy implements DiscountPolicy {
private final double rate;
public PercentDiscountPolicy(double rate) {
this.rate = rate;
}
@Override
public Money calculateDiscount(Order order) {
return order.getTotalPrice().multiply(rate);
}
@Override
public DiscountType getType() {
return DiscountType.PERCENT;
}
}
public class FixedDiscountPolicy implements DiscountPolicy {
private final Money amount;
public FixedDiscountPolicy(Money amount) {
this.amount = amount;
}
@Override
public Money calculateDiscount(Order order) {
return amount;
}
@Override
public DiscountType getType() {
return DiscountType.FIXED;
}
}
// 3. Factory 인터페이스
public interface DiscountPolicyFactory {
DiscountPolicy create(DiscountCondition condition);
DiscountType getSupportedType();
}
// 4. 구체적인 Factory들
@Component
public class PercentDiscountPolicyFactory implements DiscountPolicyFactory {
@Override
public DiscountPolicy create(DiscountCondition condition) {
return new PercentDiscountPolicy(condition.getRate());
}
@Override
public DiscountType getSupportedType() {
return DiscountType.PERCENT;
}
}
@Component
public class FixedDiscountPolicyFactory implements DiscountPolicyFactory {
@Override
public DiscountPolicy create(DiscountCondition condition) {
return new FixedDiscountPolicy(condition.getAmount());
}
@Override
public DiscountType getSupportedType() {
return DiscountType.FIXED;
}
}5. Factory Registry (Spring DI 활용)
@Component
public class DiscountPolicyFactoryRegistry {
private final Map<DiscountType, DiscountPolicyFactory> factories;
// Spring이 모든 DiscountPolicyFactory 구현체를 자동 주입
public DiscountPolicyFactoryRegistry(List<DiscountPolicyFactory> factoryList) {
this.factories = factoryList.stream()
.collect(Collectors.toMap(
DiscountPolicyFactory::getSupportedType,
Function.identity()
));
}
public DiscountPolicy create(DiscountType type, DiscountCondition condition) {
DiscountPolicyFactory factory = factories.get(type);
if (factory == null) {
throw new IllegalArgumentException("No factory for type: " + type);
}
return factory.create(condition);
}
}
// 사용
@Service
@RequiredArgsConstructor
public class OrderService {
private final DiscountPolicyFactoryRegistry factoryRegistry;
public Order applyDiscount(Order order, Coupon coupon) {
DiscountPolicy policy = factoryRegistry.create(
coupon.getDiscountType(),
coupon.getCondition()
);
Money discount = policy.calculateDiscount(order);
order.applyDiscount(discount);
return order;
}
}Factory Registry 동작 흐름
┌──────────────────────────────────────────────────────────────────────┐
│ Spring Container │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ PercentDiscountPolicyFactory │ │
│ │ FixedDiscountPolicyFactory ──→ 자동 수집 │ │
│ │ BulkDiscountPolicyFactory │ │ │
│ │ VipDiscountPolicyFactory ↓ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
│ │ │
│ ↓ │
│ ┌─────────────────────────────────────────────────────────────────┐ │
│ │ DiscountPolicyFactoryRegistry │ │
│ │ ┌─────────────────────────────────────────────────────────┐ │ │
│ │ │ Map<DiscountType, Factory> │ │ │
│ │ │ PERCENT → PercentDiscountPolicyFactory │ │ │
│ │ │ FIXED → FixedDiscountPolicyFactory │ │ │
│ │ │ BULK → BulkDiscountPolicyFactory │ │ │
│ │ │ VIP → VipDiscountPolicyFactory │ │ │
│ │ └─────────────────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────────┘
OrderService.applyDiscount(order, coupon)
│
↓
factoryRegistry.create(PERCENT, condition)
│
↓
PercentDiscountPolicyFactory.create(condition)
│
↓
new PercentDiscountPolicy(rate)3.3 Spring의 Factory 패턴
Spring 자체도 Factory 패턴을 광범위하게 사용합니다.
Spring의 Factory 패턴 적용 사례
| Spring 컴포넌트 | 역할 | 생성하는 객체 |
|---|---|---|
| BeanFactory | Bean 생성 및 관리 | 모든 Spring Bean |
| FactoryBean<T> | 복잡한 Bean 생성 로직 캡슐화 | 커스텀 객체 |
| @Bean 메서드 | 수동 Bean 등록 | 설정된 객체 |
| ObjectFactory<T> | 지연 생성 | Prototype Bean |
@Bean 메서드 = Factory Method
@Configuration
public class PaymentConfig {
// @Bean 메서드는 Factory Method 패턴
@Bean
public PaymentGateway paymentGateway(PaymentProperties properties) {
// 복잡한 생성 로직을 캡슐화
PaymentGateway gateway = new PaymentGateway();
gateway.setApiKey(properties.getApiKey());
gateway.setSecretKey(properties.getSecretKey());
gateway.setEnvironment(properties.getEnvironment());
gateway.setTimeout(properties.getTimeout());
gateway.setRetryCount(properties.getRetryCount());
gateway.initialize(); // 초기화 로직
return gateway;
}
@Bean
@Profile("production")
public PaymentProcessor realPaymentProcessor(PaymentGateway gateway) {
return new RealPaymentProcessor(gateway);
}
@Bean
@Profile("!production")
public PaymentProcessor mockPaymentProcessor() {
return new MockPaymentProcessor(); // 테스트용
}
}FactoryBean 인터페이스
// 복잡한 객체 생성을 위한 FactoryBean
@Component
public class ShippingCalculatorFactoryBean implements FactoryBean<ShippingCalculator> {
@Value("${shipping.provider}")
private String provider;
@Autowired
private ShippingProperties properties;
@Override
public ShippingCalculator getObject() throws Exception {
// 복잡한 생성 로직
ShippingCalculator calculator = switch (provider) {
case "DHL" -> new DhlShippingCalculator(properties.getDhlConfig());
case "FEDEX" -> new FedexShippingCalculator(properties.getFedexConfig());
case "UPS" -> new UpsShippingCalculator(properties.getUpsConfig());
default -> new DefaultShippingCalculator();
};
calculator.initialize();
return calculator;
}
@Override
public Class<?> getObjectType() {
return ShippingCalculator.class;
}
@Override
public boolean isSingleton() {
return true;
}
}
// 사용 - ShippingCalculator로 주입받음 (FactoryBean이 아님!)
@Service
@RequiredArgsConstructor
public class ShippingService {
private final ShippingCalculator shippingCalculator;
}- • 객체 생성 로직을 캡슐화하여 클라이언트와 분리
- • 새로운 타입 추가 시 기존 코드 수정 없이 Factory만 추가 (OCP)
- • Spring에서는 @Bean, FactoryBean, Factory Registry 패턴 활용
- • DI와 결합하면 더욱 유연한 설계 가능
4. Template Method 패턴 - 주문 처리 템플릿
"알고리즘의 골격을 정의하고, 일부 단계를 서브클래스에 위임한다. 서브클래스가 알고리즘의 구조를 변경하지 않고 특정 단계를 재정의할 수 있게 한다."
— Gang of Four, Design Patterns
4.1 Template Method 패턴이란?
Template Method 패턴은 알고리즘의 뼈대(골격)를 정의하고, 세부 구현은 서브클래스에 맡기는 패턴입니다. "변하지 않는 부분"과 "변하는 부분"을 분리합니다.
Template Method 패턴 구조
┌─────────────────────────────────────────────────────────────────┐
│ AbstractClass │
├─────────────────────────────────────────────────────────────────┤
│ + templateMethod() ← 알고리즘 골격 (final) │
│ # step1() ← 공통 구현 │
│ # step2() ← 추상 메서드 (서브클래스 구현) │
│ # step3() ← 추상 메서드 (서브클래스 구현) │
│ # hook() ← 선택적 오버라이드 (Hook) │
└─────────────────────────────────────────────────────────────────┘
△
│
┌─────────────────┴─────────────────┐
│ │
┌───────────────────────┐ ┌───────────────────────┐
│ ConcreteClassA │ │ ConcreteClassB │
├───────────────────────┤ ├───────────────────────┤
│ # step2() { ... } │ │ # step2() { ... } │
│ # step3() { ... } │ │ # step3() { ... } │
└───────────────────────┘ └───────────────────────┘
templateMethod() 실행 흐름:
┌─────────────────────────────────────────────────────────────────┐
│ 1. step1() ← 공통 로직 (부모 클래스) │
│ 2. step2() ← 서브클래스 구현 호출 │
│ 3. step3() ← 서브클래스 구현 호출 │
│ 4. hook() ← 선택적 확장점 │
└─────────────────────────────────────────────────────────────────┘4.2 이커머스 예제: 주문 처리 템플릿
이커머스에서 주문 처리는 공통된 흐름이 있지만, 결제 방식이나 배송 방식에 따라 세부 구현이 달라집니다. Template Method로 이를 구현해봅시다.
❌ Template Method 없이 구현
@Service
public class CreditCardOrderService {
public Order processOrder(OrderRequest request) {
// 1. 주문 검증 (공통)
validateOrder(request);
// 2. 재고 확인 (공통)
checkInventory(request.getItems());
// 3. 결제 처리 (신용카드 전용)
CreditCardPayment payment = processCreditCardPayment(request);
// 4. 주문 생성 (공통)
Order order = createOrder(request, payment);
// 5. 재고 차감 (공통)
decreaseInventory(request.getItems());
// 6. 알림 발송 (공통)
sendNotification(order);
return order;
}
}
@Service
public class KakaoPayOrderService {
public Order processOrder(OrderRequest request) {
// 1. 주문 검증 (공통) - 중복!
validateOrder(request);
// 2. 재고 확인 (공통) - 중복!
checkInventory(request.getItems());
// 3. 결제 처리 (카카오페이 전용)
KakaoPayment payment = processKakaoPayment(request);
// 4. 주문 생성 (공통) - 중복!
Order order = createOrder(request, payment);
// ... 나머지도 중복
}
}
// 결제 방식마다 서비스 클래스가 늘어나고, 공통 로직이 중복됨✅ Template Method 패턴 적용
// 1. 추상 템플릿 클래스
public abstract class OrderProcessTemplate {
// 템플릿 메서드 - 알고리즘 골격 정의
public final Order processOrder(OrderRequest request) {
// 1. 주문 검증 (공통)
validateOrder(request);
// 2. 재고 확인 (공통)
checkInventory(request.getItems());
// 3. 결제 처리 (서브클래스 구현)
Payment payment = processPayment(request);
// 4. 주문 생성 (공통)
Order order = createOrder(request, payment);
// 5. 재고 차감 (공통)
decreaseInventory(request.getItems());
// 6. 후처리 Hook (선택적 확장)
afterOrderCreated(order);
// 7. 알림 발송 (공통)
sendNotification(order);
return order;
}
// 공통 구현
private void validateOrder(OrderRequest request) {
if (request.getItems().isEmpty()) {
throw new InvalidOrderException("주문 항목이 비어있습니다");
}
// 기타 검증 로직...
}
private void checkInventory(List<OrderItem> items) {
for (OrderItem item : items) {
if (!inventoryService.hasStock(item.getProductId(), item.getQuantity())) {
throw new OutOfStockException(item.getProductId());
}
}
}
private Order createOrder(OrderRequest request, Payment payment) {
Order order = Order.create(request, payment);
return orderRepository.save(order);
}
private void decreaseInventory(List<OrderItem> items) {
items.forEach(item ->
inventoryService.decrease(item.getProductId(), item.getQuantity())
);
}
private void sendNotification(Order order) {
notificationService.sendOrderConfirmation(order);
}
// 추상 메서드 - 서브클래스가 반드시 구현
protected abstract Payment processPayment(OrderRequest request);
// Hook 메서드 - 선택적 오버라이드
protected void afterOrderCreated(Order order) {
// 기본 구현은 비어있음
}
}2. 구체적인 구현 클래스들
@Component
public class CreditCardOrderProcessor extends OrderProcessTemplate {
private final CreditCardGateway creditCardGateway;
@Override
protected Payment processPayment(OrderRequest request) {
CreditCardInfo cardInfo = request.getCreditCardInfo();
// 신용카드 결제 처리
PaymentResult result = creditCardGateway.charge(
cardInfo.getCardNumber(),
cardInfo.getExpiry(),
cardInfo.getCvv(),
request.getTotalAmount()
);
return Payment.creditCard(result.getTransactionId(), request.getTotalAmount());
}
}
@Component
public class KakaoPayOrderProcessor extends OrderProcessTemplate {
private final KakaoPayClient kakaoPayClient;
@Override
protected Payment processPayment(OrderRequest request) {
// 카카오페이 결제 처리
KakaoPayResponse response = kakaoPayClient.requestPayment(
request.getOrderId(),
request.getTotalAmount()
);
return Payment.kakaoPay(response.getTid(), request.getTotalAmount());
}
@Override
protected void afterOrderCreated(Order order) {
// 카카오페이 전용 후처리 - 포인트 적립
kakaoPayClient.earnPoints(order.getCustomerId(), order.getTotalAmount());
}
}
@Component
public class NaverPayOrderProcessor extends OrderProcessTemplate {
private final NaverPayClient naverPayClient;
@Override
protected Payment processPayment(OrderRequest request) {
// 네이버페이 결제 처리
NaverPayResponse response = naverPayClient.pay(
request.getOrderId(),
request.getTotalAmount()
);
return Payment.naverPay(response.getPaymentId(), request.getTotalAmount());
}
}Template Method 실행 흐름
CreditCardOrderProcessor.processOrder(request)
│
↓
┌─────────────────────────────────────────────────────────────────┐
│ OrderProcessTemplate.processOrder() │
├─────────────────────────────────────────────────────────────────┤
│ 1. validateOrder(request) ← 부모 클래스 (공통) │
│ 2. checkInventory(items) ← 부모 클래스 (공통) │
│ 3. processPayment(request) ← CreditCardOrderProcessor │
│ └→ creditCardGateway.charge() (서브클래스 구현) │
│ 4. createOrder(request, payment) ← 부모 클래스 (공통) │
│ 5. decreaseInventory(items) ← 부모 클래스 (공통) │
│ 6. afterOrderCreated(order) ← Hook (기본: 빈 구현) │
│ 7. sendNotification(order) ← 부모 클래스 (공통) │
└─────────────────────────────────────────────────────────────────┘
│
↓
Order 반환4.3 Spring의 Template Method 패턴
Spring은 xxxTemplate 클래스들에서 이 패턴을 광범위하게 사용합니다.
JdbcTemplate - JDBC 반복 코드 제거
// JdbcTemplate 없이 (반복 코드 지옥)
public List<Product> findAllProducts() {
Connection conn = null;
PreparedStatement pstmt = null;
ResultSet rs = null;
List<Product> products = new ArrayList<>();
try {
conn = dataSource.getConnection(); // 1. 커넥션 획득
pstmt = conn.prepareStatement( // 2. SQL 준비
"SELECT * FROM products WHERE active = ?"
);
pstmt.setBoolean(1, true); // 3. 파라미터 바인딩
rs = pstmt.executeQuery(); // 4. 쿼리 실행
while (rs.next()) { // 5. 결과 매핑
products.add(new Product(
rs.getLong("id"),
rs.getString("name"),
rs.getBigDecimal("price")
));
}
} catch (SQLException e) {
throw new DataAccessException(e); // 6. 예외 변환
} finally {
if (rs != null) try { rs.close(); } catch (SQLException e) {}
if (pstmt != null) try { pstmt.close(); } catch (SQLException e) {}
if (conn != null) try { conn.close(); } catch (SQLException e) {}
} // 7. 리소스 정리
return products;
}
// JdbcTemplate 사용 (Template Method 패턴)
public List<Product> findAllProducts() {
return jdbcTemplate.query(
"SELECT * FROM products WHERE active = ?",
(rs, rowNum) -> new Product( // 변하는 부분만 구현
rs.getLong("id"),
rs.getString("name"),
rs.getBigDecimal("price")
),
true
);
}JdbcTemplate 내부 동작
JdbcTemplate.query(sql, rowMapper, params)
│
↓
┌─────────────────────────────────────────────────────────────────┐
│ JdbcTemplate (Template) │
├─────────────────────────────────────────────────────────────────┤
│ 1. getConnection() ← 공통 (커넥션 획득) │
│ 2. prepareStatement(sql) ← 공통 (SQL 준비) │
│ 3. setParameters(params) ← 공통 (파라미터 바인딩) │
│ 4. executeQuery() ← 공통 (쿼리 실행) │
│ 5. rowMapper.mapRow(rs, rowNum) ← 콜백 (개발자 구현) │
│ 6. handleException() ← 공통 (예외 변환) │
│ 7. closeResources() ← 공통 (리소스 정리) │
└─────────────────────────────────────────────────────────────────┘
개발자는 5번 (결과 매핑)만 구현하면 됨!Spring의 다양한 Template 클래스들
| Template 클래스 | 용도 | 제거되는 반복 코드 |
|---|---|---|
| JdbcTemplate | JDBC 작업 | 커넥션 관리, 예외 처리, 리소스 정리 |
| RestTemplate | HTTP 클라이언트 | 커넥션 관리, 직렬화/역직렬화 |
| TransactionTemplate | 프로그래밍 방식 트랜잭션 | 트랜잭션 시작/커밋/롤백 |
| RedisTemplate | Redis 작업 | 커넥션 관리, 직렬화 |
| KafkaTemplate | Kafka 메시지 발행 | 프로듀서 관리, 직렬화 |
TransactionTemplate 예제
@Service
@RequiredArgsConstructor
public class OrderService {
private final TransactionTemplate transactionTemplate;
private final OrderRepository orderRepository;
private final InventoryService inventoryService;
public Order createOrder(OrderRequest request) {
// TransactionTemplate이 트랜잭션 시작/커밋/롤백을 처리
return transactionTemplate.execute(status -> {
// 개발자는 비즈니스 로직만 구현
Order order = Order.create(request);
orderRepository.save(order);
for (OrderItem item : order.getItems()) {
inventoryService.decrease(item.getProductId(), item.getQuantity());
}
return order;
});
}
}- • 알고리즘의 골격(변하지 않는 부분)을 부모 클래스에 정의
- • 세부 구현(변하는 부분)은 서브클래스나 콜백으로 위임
- • Spring의 xxxTemplate 클래스들이 대표적인 예
- • 반복 코드를 제거하고 핵심 로직에 집중할 수 있게 함
5. Strategy 패턴 - 결제 수단 전략
"알고리즘 군을 정의하고 각각을 캡슐화하여 교환 가능하게 만든다. Strategy를 사용하면 알고리즘을 사용하는 클라이언트와 독립적으로 알고리즘을 변경할 수 있다."
— Gang of Four, Design Patterns
5.1 Strategy 패턴이란?
Strategy 패턴은 동일한 문제를 해결하는 여러 알고리즘을 정의하고, 런타임에 알고리즘을 선택할 수 있게 합니다. if-else나 switch 문을 객체지향적으로 대체합니다.
Strategy 패턴 구조
┌─────────────────────────────────────────────────────────────────┐
│ Context │
├─────────────────────────────────────────────────────────────────┤
│ - strategy: Strategy │
├─────────────────────────────────────────────────────────────────┤
│ + setStrategy(Strategy) │
│ + executeStrategy() → strategy.execute() │
└─────────────────────────────────────────────────────────────────┘
│
│ uses
↓
┌─────────────────────────────────────────────────────────────────┐
│ «interface» │
│ Strategy │
├─────────────────────────────────────────────────────────────────┤
│ + execute(): Result │
└─────────────────────────────────────────────────────────────────┘
△
│
┌────────────────────┼────────────────────┐
│ │ │
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
│ ConcreteStrategyA│ │ ConcreteStrategyB│ │ ConcreteStrategyC│
├─────────────────┤ ├─────────────────┤ ├─────────────────┤
│ + execute() │ │ + execute() │ │ + execute() │
└─────────────────┘ └─────────────────┘ └─────────────────┘5.2 이커머스 예제: 결제 수단 전략
이커머스에서 결제 수단은 다양합니다: 신용카드, 카카오페이, 네이버페이, 토스페이, 무통장입금 등. Strategy 패턴으로 결제 로직을 깔끔하게 분리할 수 있습니다.
❌ Strategy 없이 구현 (조건문 지옥)
@Service
public class PaymentService {
public PaymentResult processPayment(Order order, String paymentMethod) {
if ("CREDIT_CARD".equals(paymentMethod)) {
// 신용카드 결제 로직
CreditCardInfo cardInfo = order.getCreditCardInfo();
return creditCardGateway.charge(cardInfo, order.getTotalAmount());
} else if ("KAKAO_PAY".equals(paymentMethod)) {
// 카카오페이 결제 로직
return kakaoPayClient.requestPayment(order.getId(), order.getTotalAmount());
} else if ("NAVER_PAY".equals(paymentMethod)) {
// 네이버페이 결제 로직
return naverPayClient.pay(order.getId(), order.getTotalAmount());
} else if ("TOSS_PAY".equals(paymentMethod)) {
// 토스페이 결제 로직
return tossPayClient.execute(order.getId(), order.getTotalAmount());
} else if ("BANK_TRANSFER".equals(paymentMethod)) {
// 무통장입금 로직
return bankTransferService.createVirtualAccount(order);
}
// 새로운 결제 수단 추가 시 여기를 수정해야 함...
throw new UnsupportedPaymentMethodException(paymentMethod);
}
}✅ Strategy 패턴 적용
// 1. Strategy 인터페이스
public interface PaymentStrategy {
PaymentResult pay(Order order);
PaymentMethod getPaymentMethod();
boolean supports(PaymentMethod method);
}
// 2. 구체적인 Strategy 구현들
@Component
@RequiredArgsConstructor
public class CreditCardPaymentStrategy implements PaymentStrategy {
private final CreditCardGateway creditCardGateway;
@Override
public PaymentResult pay(Order order) {
CreditCardInfo cardInfo = order.getCreditCardInfo();
// 카드 유효성 검증
creditCardGateway.validate(cardInfo);
// 결제 실행
ChargeResult result = creditCardGateway.charge(
cardInfo.getCardNumber(),
cardInfo.getExpiry(),
cardInfo.getCvv(),
order.getTotalAmount()
);
return PaymentResult.success(
result.getTransactionId(),
PaymentMethod.CREDIT_CARD,
order.getTotalAmount()
);
}
@Override
public PaymentMethod getPaymentMethod() {
return PaymentMethod.CREDIT_CARD;
}
@Override
public boolean supports(PaymentMethod method) {
return PaymentMethod.CREDIT_CARD == method;
}
}
@Component
@RequiredArgsConstructor
public class KakaoPayPaymentStrategy implements PaymentStrategy {
private final KakaoPayClient kakaoPayClient;
@Override
public PaymentResult pay(Order order) {
// 카카오페이 결제 준비
ReadyResponse ready = kakaoPayClient.ready(
order.getId(),
order.getTotalAmount(),
order.getItemName()
);
// 결제 승인 (실제로는 리다이렉트 후 콜백에서 처리)
ApproveResponse approve = kakaoPayClient.approve(
ready.getTid(),
order.getCustomerId()
);
return PaymentResult.success(
approve.getTid(),
PaymentMethod.KAKAO_PAY,
order.getTotalAmount()
);
}
@Override
public PaymentMethod getPaymentMethod() {
return PaymentMethod.KAKAO_PAY;
}
@Override
public boolean supports(PaymentMethod method) {
return PaymentMethod.KAKAO_PAY == method;
}
}
@Component
@RequiredArgsConstructor
public class NaverPayPaymentStrategy implements PaymentStrategy {
private final NaverPayClient naverPayClient;
@Override
public PaymentResult pay(Order order) {
NaverPayResponse response = naverPayClient.pay(
order.getId(),
order.getTotalAmount()
);
return PaymentResult.success(
response.getPaymentId(),
PaymentMethod.NAVER_PAY,
order.getTotalAmount()
);
}
@Override
public PaymentMethod getPaymentMethod() {
return PaymentMethod.NAVER_PAY;
}
@Override
public boolean supports(PaymentMethod method) {
return PaymentMethod.NAVER_PAY == method;
}
}3. Context 클래스 (Strategy 사용)
@Service
@RequiredArgsConstructor
public class PaymentService {
// Spring이 모든 PaymentStrategy 구현체를 자동 주입
private final List<PaymentStrategy> paymentStrategies;
public PaymentResult processPayment(Order order, PaymentMethod paymentMethod) {
// 적합한 Strategy 찾기
PaymentStrategy strategy = paymentStrategies.stream()
.filter(s -> s.supports(paymentMethod))
.findFirst()
.orElseThrow(() -> new UnsupportedPaymentMethodException(paymentMethod));
// Strategy 실행
return strategy.pay(order);
}
}
// 또는 Map으로 관리
@Service
public class PaymentService {
private final Map<PaymentMethod, PaymentStrategy> strategyMap;
public PaymentService(List<PaymentStrategy> strategies) {
this.strategyMap = strategies.stream()
.collect(Collectors.toMap(
PaymentStrategy::getPaymentMethod,
Function.identity()
));
}
public PaymentResult processPayment(Order order, PaymentMethod paymentMethod) {
PaymentStrategy strategy = strategyMap.get(paymentMethod);
if (strategy == null) {
throw new UnsupportedPaymentMethodException(paymentMethod);
}
return strategy.pay(order);
}
}Strategy 패턴 실행 흐름
PaymentService.processPayment(order, KAKAO_PAY)
│
↓
┌─────────────────────────────────────────────────────────────────┐
│ 1. paymentStrategies에서 KAKAO_PAY 지원하는 Strategy 찾기 │
│ │
│ [CreditCardPaymentStrategy] → supports(KAKAO_PAY)? ❌ │
│ [KakaoPayPaymentStrategy] → supports(KAKAO_PAY)? ✅ │
│ [NaverPayPaymentStrategy] → supports(KAKAO_PAY)? ❌ │
└─────────────────────────────────────────────────────────────────┘
│
↓
┌─────────────────────────────────────────────────────────────────┐
│ 2. KakaoPayPaymentStrategy.pay(order) 실행 │
│ │
│ → kakaoPayClient.ready() │
│ → kakaoPayClient.approve() │
│ → PaymentResult.success() 반환 │
└─────────────────────────────────────────────────────────────────┘
│
↓
PaymentResult5.3 Strategy + DI = 강력한 조합
Spring의 DI와 Strategy 패턴을 결합하면 매우 유연한 설계가 가능합니다.
새로운 결제 수단 추가하기
// 토스페이 추가 - 기존 코드 수정 없이 클래스만 추가!
@Component
@RequiredArgsConstructor
public class TossPayPaymentStrategy implements PaymentStrategy {
private final TossPayClient tossPayClient;
@Override
public PaymentResult pay(Order order) {
TossPayResponse response = tossPayClient.execute(
order.getId(),
order.getTotalAmount()
);
return PaymentResult.success(
response.getPaymentKey(),
PaymentMethod.TOSS_PAY,
order.getTotalAmount()
);
}
@Override
public PaymentMethod getPaymentMethod() {
return PaymentMethod.TOSS_PAY;
}
@Override
public boolean supports(PaymentMethod method) {
return PaymentMethod.TOSS_PAY == method;
}
}
// PaymentService는 수정할 필요 없음!
// Spring이 자동으로 새 Strategy를 주입함OCP (Open-Closed Principle) 준수
┌─────────────────────────────────────────────────────────────────┐ │ 새로운 결제 수단 추가 시 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ ❌ 조건문 방식: │ │ PaymentService.java 수정 필요 │ │ → 기존 코드 변경 (OCP 위반) │ │ → 테스트 다시 필요 │ │ → 버그 발생 위험 │ │ │ │ ✅ Strategy 패턴: │ │ 새 Strategy 클래스만 추가 │ │ → 기존 코드 변경 없음 (OCP 준수) │ │ → 새 클래스만 테스트 │ │ → 기존 기능에 영향 없음 │ │ │ └─────────────────────────────────────────────────────────────────┘
5.4 실전 활용: 배송비 계산 전략
배송비 계산 Strategy
// Strategy 인터페이스
public interface ShippingFeeStrategy {
Money calculateFee(Order order);
ShippingType getShippingType();
}
// 일반 배송
@Component
public class StandardShippingStrategy implements ShippingFeeStrategy {
private static final Money BASE_FEE = Money.of(3000);
private static final Money FREE_THRESHOLD = Money.of(50000);
@Override
public Money calculateFee(Order order) {
if (order.getTotalPrice().isGreaterThanOrEqual(FREE_THRESHOLD)) {
return Money.ZERO; // 5만원 이상 무료배송
}
return BASE_FEE;
}
@Override
public ShippingType getShippingType() {
return ShippingType.STANDARD;
}
}
// 새벽 배송
@Component
public class DawnDeliveryStrategy implements ShippingFeeStrategy {
private static final Money DAWN_FEE = Money.of(5000);
@Override
public Money calculateFee(Order order) {
// 새벽 배송은 항상 5000원
return DAWN_FEE;
}
@Override
public ShippingType getShippingType() {
return ShippingType.DAWN;
}
}
// 당일 배송
@Component
public class SameDayDeliveryStrategy implements ShippingFeeStrategy {
@Override
public Money calculateFee(Order order) {
// 거리에 따른 요금 계산
int distance = calculateDistance(order.getDeliveryAddress());
if (distance <= 10) {
return Money.of(4000);
} else if (distance <= 20) {
return Money.of(6000);
} else {
return Money.of(8000);
}
}
@Override
public ShippingType getShippingType() {
return ShippingType.SAME_DAY;
}
}
// 사용
@Service
@RequiredArgsConstructor
public class ShippingService {
private final Map<ShippingType, ShippingFeeStrategy> strategies;
public ShippingService(List<ShippingFeeStrategy> strategyList) {
this.strategies = strategyList.stream()
.collect(Collectors.toMap(
ShippingFeeStrategy::getShippingType,
Function.identity()
));
}
public Money calculateShippingFee(Order order, ShippingType type) {
ShippingFeeStrategy strategy = strategies.get(type);
return strategy.calculateFee(order);
}
}- • 동일한 문제를 해결하는 여러 알고리즘을 캡슐화
- • 런타임에 알고리즘 교체 가능
- • if-else/switch 문을 객체지향적으로 대체
- • Spring DI와 결합하면 새 전략 추가 시 기존 코드 수정 불필요 (OCP)
- • 결제, 할인, 배송비, 정렬 등 다양한 곳에 적용 가능
6. 이커머스 실습 - 패턴 조합 적용
지금까지 배운 4가지 패턴(Singleton, Factory, Template Method, Strategy)을 이커머스 주문 시스템에 종합적으로 적용해봅시다.
6.1 시나리오: 주문 생성 시스템
요구사항
- • 다양한 결제 수단 지원 (신용카드, 카카오페이, 네이버페이)
- • 다양한 할인 정책 적용 (퍼센트, 정액, VIP)
- • 주문 처리 흐름은 동일하되, 결제/할인만 다름
- • 상품 정보는 캐싱하여 성능 최적화
전체 아키텍처
┌─────────────────────────────────────────────────────────────────────────┐ │ Order System │ ├─────────────────────────────────────────────────────────────────────────┤ │ │ │ ┌─────────────────┐ │ │ │ OrderController │ ← REST API │ │ └────────┬────────┘ │ │ │ │ │ ↓ │ │ ┌─────────────────────────────────────────────────────────────────┐ │ │ │ OrderFacadeService │ │ │ │ (Facade 패턴 - 복잡한 서브시스템을 단순화) │ │ │ └─────────────────────────────────────────────────────────────────┘ │ │ │ │ │ ├──────────────────┬──────────────────┬──────────────────┐ │ │ ↓ ↓ ↓ ↓ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────┐ │ │ │ProductCache │ │DiscountSvc │ │ PaymentSvc │ │OrderSvc │ │ │ │ (Singleton) │ │ (Strategy) │ │ (Strategy) │ │(Template)│ │ │ └─────────────┘ └─────────────┘ └─────────────┘ └─────────┘ │ │ │ │ │ │ │ │ │ ↓ ↓ │ │ │ │ ┌───────────┐ ┌───────────┐ │ │ │ │ │ Discount │ │ Payment │ │ │ │ │ │ Policies │ │ Strategies│ │ │ │ │ │ (Factory) │ │ │ │ │ │ │ └───────────┘ └───────────┘ │ │ │ │ │ │ │ ↓ ↓ │ │ ┌─────────────┐ ┌───────────┐ │ │ │ Database │ │ Database │ │ │ │ (Products) │ │ (Orders) │ │ │ └─────────────┘ └───────────┘ │ │ │ └─────────────────────────────────────────────────────────────────────────┘
6.2 구현 코드
1. 상품 캐시 (Singleton)
@Component
public class ProductCache {
private final Cache<Long, Product> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.build();
private final ProductRepository productRepository;
public ProductCache(ProductRepository productRepository) {
this.productRepository = productRepository;
}
public Product getProduct(Long productId) {
return cache.get(productId, id ->
productRepository.findById(id)
.orElseThrow(() -> new ProductNotFoundException(id))
);
}
public List<Product> getProducts(List<Long> productIds) {
return productIds.stream()
.map(this::getProduct)
.toList();
}
public void evict(Long productId) {
cache.invalidate(productId);
}
}2. 할인 정책 (Strategy + Factory)
// Strategy 인터페이스
public interface DiscountPolicy {
Money calculateDiscount(Order order);
DiscountType getType();
}
// 구체적인 Strategy들
@Component
public class PercentDiscountPolicy implements DiscountPolicy {
@Override
public Money calculateDiscount(Order order) {
Coupon coupon = order.getCoupon();
return order.getTotalPrice().multiply(coupon.getDiscountRate());
}
@Override
public DiscountType getType() {
return DiscountType.PERCENT;
}
}
@Component
public class FixedDiscountPolicy implements DiscountPolicy {
@Override
public Money calculateDiscount(Order order) {
return order.getCoupon().getDiscountAmount();
}
@Override
public DiscountType getType() {
return DiscountType.FIXED;
}
}
@Component
public class VipDiscountPolicy implements DiscountPolicy {
private static final double VIP_RATE = 0.1;
@Override
public Money calculateDiscount(Order order) {
if (order.getCustomer().isVip()) {
return order.getTotalPrice().multiply(VIP_RATE);
}
return Money.ZERO;
}
@Override
public DiscountType getType() {
return DiscountType.VIP;
}
}
// Factory Registry
@Component
public class DiscountPolicyRegistry {
private final Map<DiscountType, DiscountPolicy> policies;
public DiscountPolicyRegistry(List<DiscountPolicy> policyList) {
this.policies = policyList.stream()
.collect(Collectors.toMap(
DiscountPolicy::getType,
Function.identity()
));
}
public DiscountPolicy getPolicy(DiscountType type) {
return policies.get(type);
}
public Money calculateTotalDiscount(Order order) {
Money totalDiscount = Money.ZERO;
// 쿠폰 할인
if (order.hasCoupon()) {
DiscountPolicy couponPolicy = getPolicy(order.getCoupon().getType());
totalDiscount = totalDiscount.add(couponPolicy.calculateDiscount(order));
}
// VIP 할인 (중복 적용)
DiscountPolicy vipPolicy = getPolicy(DiscountType.VIP);
totalDiscount = totalDiscount.add(vipPolicy.calculateDiscount(order));
return totalDiscount;
}
}3. 결제 처리 (Strategy)
// Strategy 인터페이스
public interface PaymentStrategy {
PaymentResult pay(PaymentRequest request);
PaymentMethod getMethod();
}
// 구체적인 Strategy들
@Component
@RequiredArgsConstructor
public class CreditCardPaymentStrategy implements PaymentStrategy {
private final CreditCardGateway gateway;
@Override
public PaymentResult pay(PaymentRequest request) {
ChargeResult result = gateway.charge(
request.getCardInfo(),
request.getAmount()
);
return PaymentResult.of(result.getTransactionId(), getMethod());
}
@Override
public PaymentMethod getMethod() {
return PaymentMethod.CREDIT_CARD;
}
}
@Component
@RequiredArgsConstructor
public class KakaoPayPaymentStrategy implements PaymentStrategy {
private final KakaoPayClient client;
@Override
public PaymentResult pay(PaymentRequest request) {
KakaoPayResponse response = client.pay(
request.getOrderId(),
request.getAmount()
);
return PaymentResult.of(response.getTid(), getMethod());
}
@Override
public PaymentMethod getMethod() {
return PaymentMethod.KAKAO_PAY;
}
}
// Payment Service
@Service
public class PaymentService {
private final Map<PaymentMethod, PaymentStrategy> strategies;
public PaymentService(List<PaymentStrategy> strategyList) {
this.strategies = strategyList.stream()
.collect(Collectors.toMap(
PaymentStrategy::getMethod,
Function.identity()
));
}
public PaymentResult processPayment(PaymentRequest request) {
PaymentStrategy strategy = strategies.get(request.getPaymentMethod());
if (strategy == null) {
throw new UnsupportedPaymentMethodException(request.getPaymentMethod());
}
return strategy.pay(request);
}
}4. 주문 처리 (Template Method)
@Service
@RequiredArgsConstructor
public class OrderService {
private final ProductCache productCache;
private final DiscountPolicyRegistry discountRegistry;
private final PaymentService paymentService;
private final InventoryService inventoryService;
private final OrderRepository orderRepository;
private final ApplicationEventPublisher eventPublisher;
@Transactional
public Order createOrder(OrderCreateRequest request) {
// 1. 상품 조회 (캐시 활용)
List<Product> products = productCache.getProducts(
request.getItems().stream()
.map(OrderItemRequest::getProductId)
.toList()
);
// 2. 주문 생성
Order order = Order.create(request.getCustomer(), products, request.getItems());
// 3. 할인 적용 (Strategy)
Money discount = discountRegistry.calculateTotalDiscount(order);
order.applyDiscount(discount);
// 4. 재고 확인 및 차감
for (OrderItem item : order.getItems()) {
inventoryService.decrease(item.getProductId(), item.getQuantity());
}
// 5. 결제 처리 (Strategy)
PaymentRequest paymentRequest = PaymentRequest.builder()
.orderId(order.getId())
.amount(order.getFinalPrice())
.paymentMethod(request.getPaymentMethod())
.cardInfo(request.getCardInfo())
.build();
PaymentResult paymentResult = paymentService.processPayment(paymentRequest);
order.completePayment(paymentResult);
// 6. 주문 저장
Order savedOrder = orderRepository.save(order);
// 7. 이벤트 발행
eventPublisher.publishEvent(new OrderCreatedEvent(savedOrder));
return savedOrder;
}
}5. Facade (복잡성 숨김)
@Service
@RequiredArgsConstructor
public class OrderFacadeService {
private final OrderService orderService;
private final CustomerService customerService;
private final CouponService couponService;
private final NotificationService notificationService;
/**
* 클라이언트에게 단순한 인터페이스 제공
* 내부의 복잡한 서브시스템 호출을 숨김
*/
public OrderResponse createOrder(OrderRequest request) {
// 1. 고객 조회
Customer customer = customerService.getCustomer(request.getCustomerId());
// 2. 쿠폰 검증 및 적용
Coupon coupon = null;
if (request.getCouponCode() != null) {
coupon = couponService.validateAndUse(request.getCouponCode(), customer);
}
// 3. 주문 생성 요청 구성
OrderCreateRequest createRequest = OrderCreateRequest.builder()
.customer(customer)
.items(request.getItems())
.coupon(coupon)
.paymentMethod(request.getPaymentMethod())
.cardInfo(request.getCardInfo())
.shippingAddress(request.getShippingAddress())
.build();
// 4. 주문 생성
Order order = orderService.createOrder(createRequest);
// 5. 알림 발송
notificationService.sendOrderConfirmation(order);
// 6. 응답 변환
return OrderResponse.from(order);
}
}
// Controller는 Facade만 호출
@RestController
@RequestMapping("/api/orders")
@RequiredArgsConstructor
public class OrderController {
private final OrderFacadeService orderFacade;
@PostMapping
public ResponseEntity<OrderResponse> createOrder(@RequestBody @Valid OrderRequest request) {
OrderResponse response = orderFacade.createOrder(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}패턴 적용 요약
┌─────────────────────────────────────────────────────────────────┐ │ 적용된 디자인 패턴 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ Singleton │ │ └─ ProductCache: 상품 정보 캐싱, 단일 인스턴스 │ │ │ │ Factory │ │ └─ DiscountPolicyRegistry: 할인 정책 생성 및 관리 │ │ │ │ Strategy │ │ ├─ DiscountPolicy: 다양한 할인 정책 (퍼센트, 정액, VIP) │ │ └─ PaymentStrategy: 다양한 결제 수단 (카드, 카카오, 네이버) │ │ │ │ Template Method │ │ └─ OrderService: 주문 처리 흐름 (검증→할인→결제→저장) │ │ │ │ Facade │ │ └─ OrderFacadeService: 복잡한 서브시스템을 단순화 │ │ │ └─────────────────────────────────────────────────────────────────┘
- • 새로운 결제 수단 추가: PaymentStrategy 구현체만 추가
- • 새로운 할인 정책 추가: DiscountPolicy 구현체만 추가
- • 기존 코드 수정 없이 기능 확장 가능 (OCP 준수)
📚 관련 콘텐츠
- → Hexagonal Architecture + DDD Workshop: 이 패턴들을 헥사고날 아키텍처에 적용
- → DDD 이론 6: Entity와 Value Object: 도메인 모델 설계
7. 정리 및 Spring 연결
7.1 이번 세션에서 배운 패턴
Singleton 패턴
- 인스턴스를 하나만 생성하여 공유
- Spring Bean은 기본적으로 Singleton
- 상태를 가지면 안 됨 (Stateless)
Factory 패턴
- 객체 생성 로직을 캡슐화
- 클라이언트와 구체 클래스 분리
- 새 타입 추가 시 Factory만 수정
Template Method 패턴
- 알고리즘 골격을 정의
- 세부 구현은 서브클래스에 위임
- 반복 코드 제거
Strategy 패턴
- 알고리즘을 캡슐화하여 교체 가능
- if-else/switch 문 대체
- DI와 결합하면 OCP 준수
7.2 패턴 → Spring 매핑 표
| 디자인 패턴 | Spring 적용 | 예시 |
|---|---|---|
| Singleton | Bean 기본 스코프 | @Component, @Service |
| Factory | Bean 생성 | BeanFactory, @Bean |
| Template Method | xxxTemplate 클래스 | JdbcTemplate, RestTemplate |
| Strategy | 인터페이스 + DI | List<Interface> 주입 |
| Proxy | AOP, 트랜잭션 | @Transactional, @Async |
| Decorator | Filter Chain | Security Filter |
| Observer | 이벤트 시스템 | @EventListener |
| Adapter | Handler Adapter | HandlerAdapter |
| Facade | Service Layer | Application Service |
* 회색 배경: 다음 세션에서 학습할 패턴
7.3 핵심 포인트
┌─────────────────────────────────────────────────────────────────┐ │ 핵심 포인트 정리 │ ├─────────────────────────────────────────────────────────────────┤ │ │ │ 1. 패턴을 알면 Spring 코드가 보인다 │ │ → "왜 이렇게 설계했는지" 이해 가능 │ │ │ │ 2. Spring은 패턴의 집합체 │ │ → 직접 구현할 필요 없이 Spring이 제공 │ │ │ │ 3. 패턴 + DI = 강력한 조합 │ │ → 느슨한 결합, 테스트 용이성, OCP 준수 │ │ │ │ 4. 실무에서 자주 쓰는 패턴 │ │ → Strategy (결제, 할인, 배송) │ │ → Factory (정책 생성) │ │ → Template Method (반복 코드 제거) │ │ │ └─────────────────────────────────────────────────────────────────┘
7.4 다음 세션 예고
Spring을 위한 디자인 패턴 (2)
다음 세션에서는 Spring의 핵심 메커니즘을 이해하는 데 필수적인 패턴들을 학습합니다.
🎯 Proxy 패턴
@Transactional, @Async, AOP의 기반 기술
🎨 Decorator 패턴
Security Filter Chain, 기능 동적 추가
👀 Observer 패턴
ApplicationEvent, @EventListener
🔌 Adapter & Facade
HandlerAdapter, Service Layer
학습 팁: 이번 세션의 패턴들을 실제 프로젝트에 적용해보세요. 특히 Strategy 패턴은 결제, 할인, 배송비 계산 등 다양한 곳에 활용할 수 있습니다.
📝 Session 01 요약
배운 내용
- ✅ Singleton - 인스턴스 하나만 유지
- ✅ Factory - 객체 생성 캡슐화
- ✅ Template Method - 알고리즘 골격 정의
- ✅ Strategy - 알고리즘 교체 가능
핵심 키워드
다음 단계
다음 세션에서는 Proxy, Decorator, Observer, Adapter, Facade 패턴을 학습합니다. 특히 Proxy 패턴은 Spring AOP와 @Transactional의 핵심이므로 꼭 이해해야 합니다.