Development Artist

헤드 퍼스트 디자인 패턴 정리 -상- ( 결제 시스템 ) 본문

Research/Spring

헤드 퍼스트 디자인 패턴 정리 -상- ( 결제 시스템 )

JMcunst 2025. 3. 13. 20:15
728x90
반응형

서론

이번에 헤드 퍼스트 디자인 패턴이라는 책을 절반 정도 읽은 시점에서 한 번 정리를 해보고자 한다.

 

이 책은 꾀 유명한 책으로, 객체지향 설계를 고민하는 개발자에게 매우 도움이 되는 책이다. 현재는 DevOps를 주 업무로 하고 있지만, 개발에도 관심이 많은 사람으로서 꼭 읽어보고 싶었던 책이었다. 실무에서 백엔드 코드들을 보면서 여러 패턴들이 적용된 것을 확인할 수 있었으며, 이를 통해 더 깊이 있는 이해를 하게 되었다.

 

이 글에서는 패턴의 개념보다는 업무에서 어떻게 쓰이고, 어떤 기준으로 판단해야 하는지를 예시를 통해 다루고자 한다.


패턴

책의 전반부에서 다루는 패턴은 총 7가지이다:

  1. 전략 패턴 (Strategy Pattern)
  2. 옵저버 패턴 (Observer Pattern)
  3. 데코레이터 패턴 (Decorator Pattern)
  4. 팩토리 패턴 (Factory Pattern)
  5. 싱글턴 패턴 (Singleton Pattern)
  6. 커맨드 패턴 (Command Pattern)
  7. 어댑터 패턴과 퍼사드 패턴 (Adapter & Facade Pattern)

이제 어떻게 활용되는지 살펴보자.


예시

결제 시스템 구축하기

결제 시스템에도 여러개의 기능이 들어간다. 결제 수단, 결제 방식, 결제, 알림, 부가서비스 등 다양한 기능이 들어가며 이를 구현할때 마다 어떤 패턴을 사용하면 좋을지 개발자의 고민이 들어가게 된다. 전반부까지 배운 패턴을 사용해서 결제 시스템을 구축해보고자 한다.

사용할 패턴

  1. 팩토리 패턴 (Factory Pattern) → 결제 수단(Credit Card, PayPal 등)을 생성
  2. 전략 패턴 (Strategy Pattern) → 결제 방식 (일반 결제, 정기 결제, 할인 결제) 선택
  3. 데코레이터 패턴 (Decorator Pattern) → 부가 서비스 (포인트 적립, 쿠폰 적용)
  4. 옵저버 패턴 (Observer Pattern) → 결제 완료 후 알림 (이메일, SMS)
  5. 싱글턴 패턴 (Singleton Pattern) → 결제 로그 관리
  6. 커맨드 패턴 (Command Pattern) → 결제 요청 처리

구현

팩토리 패턴을 사용해서 결제 수단을 생성한다. 팩토리 패턴을 사용한 이유는

  • 결제 수단이 추가될 때 기존 코드를 수정할 필요가 없이 새로운 클래스를 추가하면 된다. 직접 객체를 생성하게 되면 if-else 또는 switch-case를 수정해야하고 OCP에 위배된다.
  • 객체 생성 로직을 한 곳에서 관리하여 코드 중복을 방지할 수 있다.
  • SRP를 만족한다. PaymentFactory는 결제 수단 생성만 담당하고 결제 로직과는 분리된다.
interface PaymentMethod {
    void pay(double amount);
}

class CreditCardPayment implements PaymentMethod {
    public void pay(double amount) {
        System.out.println("Credit Card로 " + amount + "원 결제 완료");
    }
}

class PayPalPayment implements PaymentMethod {
    public void pay(double amount) {
        System.out.println("PayPal로 " + amount + "원 결제 완료");
    }
}

class PaymentFactory {
    public static PaymentMethod getPaymentMethod(String type) {
        return switch (type) {
            case "credit" -> new CreditCardPayment();
            case "paypal" -> new PayPalPayment();
            default -> throw new IllegalArgumentException("지원되지 않는 결제 방식");
        };
    }
}

결제 방식을 선택하는 것은 전략 패턴으로 구현했다. 전략 패턴을 사용한 이유는

  • 결제 방식을 동적으로 변경이 가능하다.
  • OCP를 만족한다. 기존 코드를 변경하지 않고 새로운 전략을 추가할 수 있다.
  • 결제 로직과 전략을 분리하여 단일 책임 원칙을 준수한다.
  • 성능적으로도 브랜칭을 줄일 수 있다.
interface PaymentStrategy {
    void processPayment(double amount);
}

class NormalPayment implements PaymentStrategy {
    public void processPayment(double amount) {
        System.out.println("일반 결제 진행: " + amount + "원");
    }
}

class DiscountPayment implements PaymentStrategy {
    public void processPayment(double amount) {
        System.out.println("할인 결제 진행: " + (amount * 0.9) + "원 (10% 할인)");
    }
}

class PaymentProcessor {
    private PaymentStrategy strategy;
    
    public void setPaymentStrategy(PaymentStrategy strategy) {
        this.strategy = strategy;
    }
    
    public void pay(double amount) {
        strategy.processPayment(amount);
    }
}

데코레이터 패턴을 사용해서 포인트 적립 및 쿠폰 적용 기능을 구현했다. 상속을 사용할 수도 있겠지만, 결합도가 높아지고 무수히 많은 클래스를 만들 수 있게 된다.

  • 결제 로직을 변경하지 않고 부가 기능을 동적으로 추가 가능하다.
  • 새로운 기능을 추가해도 기존 코드 변경 없이 적용이 가능하다.
  • 컴포지션 기반 설계로 기능 조합이 자유롭다.
abstract class PaymentDecorator implements PaymentMethod {
    protected PaymentMethod paymentMethod;
    
    public PaymentDecorator(PaymentMethod paymentMethod) {
        this.paymentMethod = paymentMethod;
    }
    
    public void pay(double amount) {
        paymentMethod.pay(amount);
    }
}

class PointReward extends PaymentDecorator {
    public PointReward(PaymentMethod paymentMethod) {
        super(paymentMethod);
    }

    public void pay(double amount) {
        super.pay(amount);
        System.out.println("포인트 적립: " + (amount * 0.05) + "원");
    }
}

class CouponDiscount extends PaymentDecorator {
    public CouponDiscount(PaymentMethod paymentMethod) {
        super(paymentMethod);
    }

    public void pay(double amount) {
        double discountedAmount = amount - 1000;
        super.pay(discountedAmount);
        System.out.println("쿠폰 적용: 1000원 할인");
    }
}

옵저버 패턴을 통해 결제 완료 후 알림을 보내게 끔 구현했다.

  • 결제 완료 후 여러 알림(이메일, SMS)을 동적으로 추가할 수 있다.
  • 결제 프로세스와 알림 로직을 분리하여 결합도를 낮출 수 있다.
  • 새로운 알림 방식 추가 시 기존 코드 수정이 불필요하다. (OCP 만족)
interface Observer {
    void update(String message);
}

class EmailNotifier implements Observer {
    public void update(String message) {
        System.out.println("이메일 알림: " + message);
    }
}

class SMSNotifier implements Observer {
    public void update(String message) {
        System.out.println("SMS 알림: " + message);
    }
}

class PaymentNotifier {
    private List<Observer> observers = new ArrayList<>();
    
    public void addObserver(Observer observer) {
        observers.add(observer);
    }
    
    public void notifyObservers(String message) {
        for (Observer observer : observers) {
            observer.update(message);
        }
    }
}

하지만, notifyObservers() 메서드가 순차적으로 실행되므로, 1000개 이상의 알림이 발생할 경우는 성능 저하가 발생할 수 있다.

멀티 쓰레드나 비동기 큐를 통해 개선을 할 수 있다. 바로 아래는 멀티 쓰레드에 대한 예시이다.

import java.util.concurrent.*;

class PaymentNotifier {
    private List<Observer> observers = new ArrayList<>();
    private ExecutorService executor = Executors.newFixedThreadPool(10); // 10개 스레드로 병렬 처리

    public void addObserver(Observer observer) {
        observers.add(observer);
    }

    public void notifyObservers(String message) {
        for (Observer observer : observers) {
            executor.submit(() -> observer.update(message)); // 비동기 실행
        }
    }

    public void shutdown() {
        executor.shutdown();
    }
}

그리고 옵저버 패턴을 다루면 EDA라는 용어도 많이 볼 수 있게 된다. 옵저버 패턴은 객체가 직접 Observer를 호출하기 때문에 EDA가 이것을 해소할 수 있다. 예로는 Kafka, RabbitMQ비동기 메시징 큐가 있다. 아래는 Kafka를 사용해본 예시이다.

// Kafka Producer를 활용하여 알림 메시지를 이벤트 큐에 저장
class KafkaNotifier {
    private KafkaProducer<String, String> producer;

    public KafkaNotifier() {
        Properties props = new Properties();
        props.put("bootstrap.servers", "localhost:9092");
        props.put("key.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        props.put("value.serializer", "org.apache.kafka.common.serialization.StringSerializer");
        producer = new KafkaProducer<>(props);
    }

    public void notifyObservers(String message) {
        producer.send(new ProducerRecord<>("payment-notifications", message));
    }
}

 

싱글턴 패턴을 통해 결제 로그를 관리한다.

  • 로그 관리는 전역적으로 하나의 인스턴스만 필요하기 때문이다.
  • 동시성 문제를 해결할 수 있다.
  • 불필요한 객체 생성을 방지하여 리소스를 절약할 수 있다. (메모리 낭비)
class PaymentLogger {
    private static PaymentLogger instance;
    
    private PaymentLogger() {}

    public static PaymentLogger getInstance() {
        if (instance == null) {
            instance = new PaymentLogger();
        }
        return instance;
    }

    public void log(String message) {
        System.out.println("[결제 로그] " + message);
    }
}

하지만, 싱글턴이 동시성 이슈에 대해서 완벽하게 해결해주는 것은 아니다. 멀티스레드 환경에서 발생할 수 있다. 이를 해결하기 위해 synchronized를 적용하거나 Double-Checked Locking을 활용하여 동시성을 보장할 수 있다. 

class PaymentLogger {
    private static volatile PaymentLogger instance; // volatile 추가

    private PaymentLogger() {}

    public static PaymentLogger getInstance() {
        if (instance == null) { // 첫 번째 체크
            synchronized (PaymentLogger.class) {
                if (instance == null) { // 두 번째 체크
                    instance = new PaymentLogger();
                }
            }
        }
        return instance;
    }

    public void log(String message) {
        System.out.println("[결제 로그] " + message);
    }
}

커맨드 패턴을 통해 결제 요청을 처리한다.

  • 결제 요청을 객체로 분리하여 실행 취소(rollback) 가능하다.
  • 결제 요청을 큐에 저장하여 나중에 실행 가능 하다 ( 비동기 처리 가능 )
  • 새로운 결제 요청이 추가될 때 기존 코드 수정이 불필요하다. (OCP 만족)
interface Command {
    void execute();
}

class PaymentCommand implements Command {
    private PaymentMethod paymentMethod;
    private double amount;

    public PaymentCommand(PaymentMethod paymentMethod, double amount) {
        this.paymentMethod = paymentMethod;
        this.amount = amount;
    }

    public void execute() {
        paymentMethod.pay(amount);
    }
}

class PaymentInvoker {
    private List<Command> commands = new ArrayList<>();

    public void addCommand(Command command) {
        commands.add(command);
    }

    public void executeCommands() {
        for (Command command : commands) {
            command.execute();
        }
        commands.clear();
    }
}

결제 요청의 경우에도 큐에 저장한 후 비동기 실행을 통해 성능을 개선할 수 있다. 아래는 커맨드를 병렬로 실행하여 대량 결제 요청 시 성능을 개선하는 예시이다.

class PaymentInvoker {
    private List<Command> commands = new ArrayList<>();
    private ExecutorService executor = Executors.newFixedThreadPool(5); // 5개 스레드 풀

    public void addCommand(Command command) {
        commands.add(command);
    }

    public void executeCommands() {
        for (Command command : commands) {
            executor.submit(command::execute); // 비동기 실행
        }
        commands.clear();
    }

    public void shutdown() {
        executor.shutdown();
    }
}

추가 사항

추가적으로 이번에는 기술하지 않았지만, 결제 검증 절차체인 오브 리스폰서빌리티 패턴으로 아래와 같이 쓸 수 있을 것 같다.

abstract class PaymentHandler {
    protected PaymentHandler nextHandler;

    public void setNextHandler(PaymentHandler nextHandler) {
        this.nextHandler = nextHandler;
    }

    public void handle(PaymentRequest request) {
        if (nextHandler != null) {
            nextHandler.handle(request);
        }
    }
}

class BalanceCheckHandler extends PaymentHandler {
    public void handle(PaymentRequest request) {
        if (request.getAmount() > request.getBalance()) {
            throw new RuntimeException("잔액 부족");
        }
        System.out.println("잔고 확인 완료");
        super.handle(request);
    }
}

class FraudCheckHandler extends PaymentHandler {
    public void handle(PaymentRequest request) {
        if (request.isFraudulent()) {
            throw new RuntimeException("사기 거래 감지됨");
        }
        System.out.println("사기 거래 검증 완료");
        super.handle(request);
    }
}

마무리

이번 글에서는 헤드 퍼스트 디자인 패턴을 절반 정도 읽고, 실무에서 어떻게 적용할 수 있을지를 고민하며 결제 시스템을 구축하는 방식으로 정리해보았다.

 

처음에는 단순히 개념을 익히는 수준에서 시작했지만, 실제 코드로 구현해 보면서 패턴을 선택하는 기준이 중요하다는 것을 느꼈다. 팩토리 패턴, 전략 패턴, 데코레이터 패턴, 옵저버 패턴, 싱글턴 패턴, 커맨드 패턴을 활용하여 확장성과 유지보수성이 높은 결제 시스템을 설계할 수 있었다.

 

읽어주셔서 감사합니다! 

728x90
반응형
Comments