JAVA

Java 실무에서 자주 쓰는 디자인 패턴 5가지 (Strategy, Factory, Builder, Singleton, Observer)

IT Lab 2026. 2. 26. 10:00

Java 실무에서 자주 쓰는 디자인 패턴 5가지 (Strategy, Factory, Builder, Singleton, Observer)
Java 17 기준으로 실무에서 가장 자주 마주치는 5가지 디자인 패턴을 “왜 쓰는지” 중심으로 정리하고, 바로 실행 가능한 간결한 예제로 감을 잡아봅니다.

도입 (문제 상황)

기능은 돌아가는데, 요구사항이 조금만 바뀌면 여기저기 if/else가 늘어나고 클래스가 비대해지는 경험을 해보셨을 거예요. “이 정도는 그냥 하드코딩해도 되지 않나?” 싶다가도, 다음 스프린트에 바로 후회하게 되죠. 실무에서 자주 쓰는 디자인 패턴 5가지는 이런 변경 비용을 줄이기 위한 최소한의 도구 세트에 가깝습니다.

핵심 개념: Java 실무에서 5가지 패턴이 중요한 이유

변경 유형에 따라 Strategy/Factory/Builder/Singleton/Observer를 선택하는 간단한 의사결정 다이어그램

디자인 패턴은 “정답 코드”가 아니라, 변경을 어디에 가둘지에 대한 합의입니다. 특히 Java에서는 인터페이스/클래스 경계가 명확해서 패턴의 효과가 더 잘 드러나요.

아래 5가지는 등장 빈도가 높고, 서로 보완 관계가 많습니다.

패턴 해결하는 대표 문제 핵심 이점 과용 시 부작용
Strategy 알고리즘/정책이 자주 바뀜 (할인, 정렬, 검증) if/else 제거, 테스트 용이 전략 클래스가 너무 쪼개짐
Factory 생성 로직이 복잡하거나 분기 많음 생성 책임 분리, 의존성 단순화 “그냥 new”인데 과한 추상화
Builder 생성자 파라미터가 많고 선택 값이 많음 가독성, 불변 객체 구성 쉬움 단순 DTO에 남발
Singleton 전역 1개 자원(설정, 레지스트리) 접근 단순, 비용 절감 숨은 전역 상태, 테스트 어려움
Observer 이벤트/상태 변경을 여러 곳에 알림 결합도 감소, 확장 용이 구독 해제 누락, 이벤트 폭주

또 하나 중요한 포인트는 SOLID(#27)와의 연결입니다. 예를 들어 Strategy/Observer는 OCP(확장엔 열려 있고 변경엔 닫힘)를, Factory/Builder는 SRP(책임 분리)를, Singleton은 “편의”를 제공하지만 DIP/테스트 관점에서는 특히 신중해야 합니다.

flowchart TD
  A["변경이 잦은 지점 발견"] --> B["분기(if/else) 증가"]
  B --> C["Strategy: 정책을 객체로 분리"]
  B --> D["Factory: 생성 분기를 한 곳으로"]
  A --> E["파라미터/옵션 증가"]
  E --> F["Builder: 조립 과정을 단계화"]
  A --> G["전역 1개 자원 필요"]
  G --> H["Singleton: 단일 인스턴스 관리"]
  A --> I["이벤트 알림 필요"]
  I --> J["Observer: 구독/발행으로 분리"]

변경 유형에 따라 “어떤 패턴으로 변경을 격리할지”를 고르는 흐름도입니다.

코드 예제: Strategy / Factory / Builder / Singleton / Observer 한 번에 실행하기 (Java 17)

아래 코드는 한 파일로 복붙해서 실행할 수 있게 구성했습니다. 각 패턴을 “최소 뼈대”로 보여주되, 실무에서 흔한 맥락(할인 정책, 결제 수단 생성, 주문 객체 생성, 설정 레지스트리, 이벤트 알림)을 담았습니다.

import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.function.Supplier;

public class DesignPatterns5Demo {

    public static void main(String[] args) {
        // 1) Strategy: 할인 정책을 런타임에 교체
        DiscountStrategy discount = new RateDiscount(0.10); // 10%
        PricingService pricingService = new PricingService(discount);
        Money discounted = pricingService.finalPrice(new Money(100_00)); // 100.00
        System.out.println("Strategy final price = " + discounted);

        // 2) Factory: 결제 수단 생성 분기 캡슐화
        Payment payment = PaymentFactory.create(PaymentType.CARD);
        String receipt = payment.pay(discounted);
        System.out.println("Factory payment = " + receipt);

        // 3) Builder: 옵션 많은 주문 객체를 안전하게 생성(불변)
        Order order = Order.builder()
                .orderId("ORD-2026-0001")
                .amount(discounted)
                .customerEmail("dev@example.com")
                .note("Leave at the door")
                .build();
        System.out.println("Builder order = " + order);

        // 4) Singleton: 애플리케이션 설정 레지스트리(예시)
        AppConfig config = AppConfig.getInstance();
        System.out.println("Singleton config env = " + config.get("env"));

        // 5) Observer: 주문 생성 이벤트를 여러 구독자에게 알림
        EventBus<OrderCreatedEvent> bus = new EventBus<>();
        bus.subscribe(e -> System.out.println("Observer(Email) send to " + e.order().customerEmail()));
        bus.subscribe(e -> System.out.println("Observer(Metrics) count order " + e.order().orderId()));
        bus.publish(new OrderCreatedEvent(order));
    }

    /* =========================
     * 1) Strategy
     * ========================= */
    interface DiscountStrategy {
        Money apply(Money original);
    }

    static final class NoDiscount implements DiscountStrategy {
        @Override public Money apply(Money original) { return original; }
    }

    static final class RateDiscount implements DiscountStrategy {
        private final double rate; // 0.10 = 10%
        RateDiscount(double rate) {
            if (rate < 0 || rate > 1) throw new IllegalArgumentException("rate must be 0..1");
            this.rate = rate;
        }
        @Override
        public Money apply(Money original) {
            long discounted = Math.round(original.cents() * (1.0 - rate));
            return new Money(discounted);
        }
    }

    static final class PricingService {
        private final DiscountStrategy discountStrategy;
        PricingService(DiscountStrategy discountStrategy) {
            this.discountStrategy = Objects.requireNonNull(discountStrategy);
        }
        Money finalPrice(Money original) {
            return discountStrategy.apply(original);
        }
    }

    /* =========================
     * 2) Factory
     * ========================= */
    enum PaymentType { CARD, BANK_TRANSFER }

    interface Payment {
        String pay(Money amount);
    }

    static final class CardPayment implements Payment {
        @Override public String pay(Money amount) { return "CARD charged " + amount; }
    }

    static final class BankTransferPayment implements Payment {
        @Override public String pay(Money amount) { return "BANK_TRANSFER sent " + amount; }
    }

    static final class PaymentFactory {
        // 분기 로직을 한 곳에 모아 호출부를 단순화
        private static final Map<PaymentType, Supplier<Payment>> REGISTRY = Map.of(
                PaymentType.CARD, CardPayment::new,
                PaymentType.BANK_TRANSFER, BankTransferPayment::new
        );

        static Payment create(PaymentType type) {
            Supplier<Payment> supplier = REGISTRY.get(type);
            if (supplier == null) throw new IllegalArgumentException("Unsupported type: " + type);
            return supplier.get();
        }
    }

    /* =========================
     * 3) Builder
     * ========================= */
    static final class Order {
        private final String orderId;
        private final Money amount;
        private final String customerEmail;
        private final String note; // optional

        private Order(Builder b) {
            this.orderId = Objects.requireNonNull(b.orderId);
            this.amount = Objects.requireNonNull(b.amount);
            this.customerEmail = Objects.requireNonNull(b.customerEmail);
            this.note = b.note;
        }

        public static Builder builder() { return new Builder(); }

        public String orderId() { return orderId; }
        public Money amount() { return amount; }
        public String customerEmail() { return customerEmail; }
        public String note() { return note; }

        @Override public String toString() {
            return "Order{id=" + orderId + ", amount=" + amount + ", email=" + customerEmail + ", note=" + note + "}";
        }

        static final class Builder {
            private String orderId;
            private Money amount;
            private String customerEmail;
            private String note;

            Builder orderId(String orderId) { this.orderId = orderId; return this; }
            Builder amount(Money amount) { this.amount = amount; return this; }
            Builder customerEmail(String customerEmail) { this.customerEmail = customerEmail; return this; }
            Builder note(String note) { this.note = note; return this; }

            Order build() { return new Order(this); }
        }
    }

    /* =========================
     * 4) Singleton
     * ========================= */
    static final class AppConfig {
        // 클래스 초기화 시점에 안전하게 1회 생성 (thread-safe)
        private static final AppConfig INSTANCE = new AppConfig();

        private final Map<String, String> values = Map.of(
                "env", "local",
                "featureX", "true"
        );

        private AppConfig() { }

        static AppConfig getInstance() { return INSTANCE; }

        String get(String key) { return values.get(key); }
    }

    /* =========================
     * 5) Observer
     * ========================= */
    static final class EventBus<E> {
        // 구독/발행이 단순하고, 예제에서는 thread-safe 컬렉션 사용
        private final List<Listener<E>> listeners = new CopyOnWriteArrayList<>();

        void subscribe(Listener<E> listener) {
            listeners.add(Objects.requireNonNull(listener));
        }

        void unsubscribe(Listener<E> listener) {
            listeners.remove(listener);
        }

        void publish(E event) {
            for (Listener<E> l : listeners) {
                l.onEvent(event);
            }
        }
    }

    @FunctionalInterface
    interface Listener<E> {
        void onEvent(E event);
    }

    record OrderCreatedEvent(Order order) { }

    /* =========================
     * Support: Money (value object)
     * ========================= */
    record Money(long cents) {
        Money {
            if (cents < 0) throw new IllegalArgumentException("cents must be >= 0");
        }
        @Override public String toString() {
            return String.format("$%.2f", cents / 100.0);
        }
    }
}

실무 팁

💡 실무에서는: Strategy는 “정책 테이블”로 키워두면 더 오래갑니다

  • 할인/검증/수수료처럼 분기가 늘어나는 영역은 Map<Key, Strategy> 형태로 레지스트리를 두면 if/else가 급격히 줄어듭니다.
  • 전략이 DB 설정으로 바뀔 가능성이 있다면, “전략 선택”과 “전략 구현”을 분리해 두세요(선택 로직은 도메인 규칙이라 자주 바뀝니다).

💡 실무에서는: Singleton은 편하지만 테스트와 DI를 먼저 생각해요

  • 전역 접근이 쉬운 대신, 숨은 의존성이 생겨 단위 테스트에서 대역 주입이 어려워집니다.
  • 가능하면 싱글톤 자체를 직접 참조하기보다, 인터페이스 뒤로 숨기고(예: ConfigProvider) DI 컨테이너(Spring 등)에서 싱글톤 스코프로 관리하는 쪽이 운영/테스트 모두 안정적입니다.

핵심 요약

변경이 잦은 지점에 패턴을 적용하면 if/else와 결합도를 눈에 띄게 줄일 수 있습니다.
Strategy/Factory/Builder는 “변경 격리”에 강하고, Singleton/Observer는 “편의”만큼 부작용도 함께 관리해야 합니다.
작게 시작해서, 분기가 늘어날 때 패턴으로 옮기는 타이밍이 가장 좋습니다.

다음 글: [#29 불변 객체와 방어적 복사]