디자인패턴과 안티 패턴

종종 이런 생각을 합니다.
디자인 패턴은 왜 학습해야 할까? 그리고 왜 학습했음에도 프로젝트에 바로 적용하지 못할까?
디자인 패턴은 문제를 단순화하여 해결을 돕는 도구처럼 보이지만, 본질적으로는 새로운 해결책을 만들어내기 위한 수단이 아닙니다. 디자인 패턴은 소프트웨어 개발 과정에서 반복적으로 발생했던 설계 문제를, 검증된 구조로 다시 풀기 위한 가이드라인에 가깝습니다. 그렇기 때문에 패턴을 학습했음에도 실제 프로젝트에서 곧바로 적용하지 못하는 상황은 자연스럽습니다. 이는 패턴이 아직 필요할 만큼 구조적 문제가 명확하게 드러나지 않았기 때문이며, 비정상적인 현상이 아닙니다. 디자인 패턴의 목적은 코드를 단순하게 만드는 데 있지 않습니다. 오히려 결합도를 낮추고 책임을 분리함으로써, 변경과 확장이 발생하더라도 시스템이 감당 가능한 구조를 유지하도록 만드는 데 그 의의가 있습니다. 또한, 디자인 패턴을 올바르게 이해하기 위해서는 어떤 구조가 문제를 만들어내는지, OOP에서 자주 등장하는 안티 패턴을 함께 살펴볼 필요가 있다고 생각합니다.

먼저 자주 활용되어지는 디자인 패턴들부터 알아봅시다.

디자인패턴들중 일부만 알아보겠습니다. 원문에도 많네요.

Factory 패턴

팩토리 패턴은 객체를 생성하기 위한 패턴중 하나입니다.
코드를 작성할때, 클라이언트 코드가 구현과 생성을 직접 하게 만들 수 있습니다.
그러면 다음과 같이 코드를 작성할 수 있습니다.

class Card {
    public void pay() {
        KakaoCard kakaoCard = new KakaoCard();
        kakaoCard.pay();
    }
}

이렇게 new를 구현 코드 안에서 직접 호출하는 순간, 그 코드는 더 이상 '확장 가능한 개념'이 아니라 특정 구현에 고정된 코드가 됩니다.
만약에 카카오 카드가 아닌 네이버 카드를 추가하고 싶다면 어떻게 해야 할까요?

class Card {
    public void pay() {
        // 기존 코드 수정 불가피
        NaverCard naverCard = new NaverCard();
        naverCard.pay();
    }
}

기존 코드를 지우고 다시 작성해야 할지도 모르겠습니다.
즉, 결제 수단이라는 구현이 변경될 때마다 상위 개념인 Card 코드까지 함께 변경됩니다.
이 구조에서는 구현 변경이 곧 클라이언트 코드 수정으로 이어지며, 변경에 취약한 설계가 됩니다.

이는 Open–Closed Principl(개방/폐쇄 원칙)에 위반이 됩니다. 새로운 결제 수단을 추가하는 것은 기능의 확장에 해당하지만,
이를 위해 기존의 Card 코드를 수정해야 하기 때문입니다. 객체지향 설계에서는 하위 구현을 추가하는 것만으로 확장이 가능해야 하며,
상위 개념의 코드는 변경되지 않아야 합니다.

이러한 문제를 해결하기 위해, 객체 생성 책임을 클라이언트 코드에서 분리하는 방식이 필요해집니다.
이때 사용할 수 있는 대표적인 패턴이 Factory 패턴입니다.

Factory 패턴의 핵심은 객체 생성을 한 곳에 모으는 것이 아니라, 구현 변경이 발생하더라도 클라이언트 코드가 영향을 받지 않도록
변경 지점을 격리하는 데 있습니다. 이를 통해 확장은 가능하지만 수정에는 닫힌 구조를 만들 수 있습니다.

/* =========================
   Product
   ========================= */
interface Payment {
    void pay();
}

/* =========================
   Concrete Product
   ========================= */
class KakaoCard implements Payment {
    @Override
    public void pay() {
        System.out.println("카카오 카드 결제");
    }
}

class NaverCard implements Payment {
    @Override
    public void pay() {
        System.out.println("네이버 카드 결제");
    }
}

/* =========================
   Creator (Factory)
   ========================= */
interface PaymentFactory {
    Payment createPayment(); // Factory Method
}

/* =========================
   Concrete Creator
   ========================= */
class KakaoPaymentFactory implements PaymentFactory {
    @Override
    public Payment createPayment() {
        return new KakaoCard();
    }
}

class NaverPaymentFactory implements PaymentFactory {
    @Override
    public Payment createPayment() {
        return new NaverCard();
    }
}

/* =========================
   Client
   ========================= */
class Card {

    private final PaymentFactory paymentFactory;

    public Card(PaymentFactory paymentFactory) {
        this.paymentFactory = paymentFactory;
    }

    public void pay() {
        Payment payment = paymentFactory.createPayment();
        payment.pay();
    }
}

/* =========================
   실행
   ========================= */
public class Main {
    public static void main(String[] args) {

        Card kakaoCard = new Card(new KakaoPaymentFactory());
        kakaoCard.pay();

        Card naverCard = new Card(new NaverPaymentFactory());
        naverCard.pay();
    }
}

 

이것은 개념을 설명하기 위한 기본적인 코드 예제일 뿐입니다.

앞서 언급했듯이 Factory 패턴은 결합을 줄이고 객체 생성의 복잡성을 캡슐화하는 데 도움이 됩니다. 이제 구현 선택과 생성 책임을 동시에 가지게 되어집니다.

Card kakaoCard = new Card(new KakaoPaymentFactory());
kakaoCard.pay();

Factory 패턴에는 다음과 같은 단점들이 존재합니다.

1.  단순한 객체 생성에는 과한 구조가 될 수 있다.

단순히 하나의 구현만 존재하거나, 객체 생성에 변경 가능성이 거의 없는 경우라면 Factory를 도입하는 것은 불필요한 복잡도를 초래할 수 있습니다. 이러한 상황에서는 new 키워드를 통해 객체를 직접 생성하는 편이 코드를 더 간결하고 이해하기 쉽게 만들 수 있습니다.

2. 객체 유형이 많아질수록 구조가 복잡해질 수 있다.

객체의 종류가 많아질수록 Factory 계층이 함께 증가하며, 전체 구조가 복잡해질 수 있습니다. 특히 Simple Factory 형태에서는 객체 유형이 늘어날수록 조건 분기(if / switch)가 증가하여 Factory 클래스가 비대해지고 유지보수가 어려워질 수 있습니다. 이 경우, 결합을 줄이기 위해 도입한 Factory가 오히려 복잡도를 증가시키는 결과를 낳을 수 있습니다.

3. Factory 역시 OCP로부터 완전히 자유롭지는 않다.

Factory 패턴을 적용하더라도, 새로운 생성 규칙이나 정책이 Factory 내부에 추가되는 경우 Factory 자체의 수정은 불가피할 수 있습니다. 즉, Factory 패턴은 클라이언트 코드의 변경을 줄이는 데에는 효과적이지만, 객체 생성 규칙이 변경되는 상황까지 OCP를 완전히 보장해 주는 것은 아닙니다.

Factory 패턴은 클라이언트 코드의 변경을 줄이는 데에는 효과적이지만, 객체 생성 규칙이 변경되는 경우 OCP를 완전히 보장하지는 않습니다. 다만, 레지스트리 기반 공장을 사용하는 등의 방식으로 이러한 위반을 완화할 수는 있습니다.

레지스트리 기반 Factory 패턴

레지스트리 기반 Factory 패턴은 객체 생성 규칙을 조건문이 아닌 '등록된 매핑 정보(Registry)'로 관리하는 Factory 구조입니다.

class PaymentFactory {

    private static final Map<String, Supplier<Payment>> registry = new HashMap<>();

    public static void register(String type, Supplier<Payment> creator) {
        registry.put(type, creator);
    }

    public static Payment create(String type) {
        Supplier<Payment> supplier = registry.get(type);
        if (supplier == null) {
            throw new IllegalArgumentException("지원하지 않는 결제 수단");
        }
        return supplier.get();
    }
}

객체 생성 규칙을 저장소(Registry)에 등록하여 관리하는 방식으로 Factory 패턴을 구현한 구조로 볼 수 있습니다. 이러한 레지스트리는 일반적으로 인 메모리(Map 등)를 활용해 구현된다고 합니다.

싱글톤 패턴

일반적으로 객체는 new 키워드를 통해 필요할 때마다 여러 번 생성할 수 있습니다. 반면 싱글톤 패턴은 객체의 생성을 의도적으로 제한하여, 애플리케이션 전반에서 하나의 인스턴스만 존재하도록 만드는 패턴입니다.

다만 특정 상황에서는 객체가 여러 번 생성되는 것 자체가 문제가 되는 경우가 존재합니다.
대표적으로 동일한 설정 정보를 관리하는 객체나, 공통 자원을 제어하는 객체가 여러 인스턴스로 분리될 경우 다음과 같은 문제가 발생할 수 있습니다.

  • 동일한 객체의 중복 생성으로 인한 불필요한 메모리 사용
  • 여러 인스턴스가 동시에 상태를 변경하며 발생하는 경쟁 조건
  • 인스턴스마다 상태가 달라지며 발생하는 시스템 전반의 일관성 붕괴

싱글톤 패턴은 이러한 문제를 해결하기 위해,
애플리케이션 전반에서 하나의 인스턴스만 존재하도록 객체 생성을 의도적으로 제한하는 방식으로 등장하였습니다. 이로 인해 데이터베이스 연결 관리자, 로깅 인스턴스, 설정(Configuration) 관리자와 같이 시스템 전반에서 단일한 의미를 가져야 하는 객체에 주로 사용됩니다.

다음과 같은 코드를 만들 수 있습니다.

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

싱글톤 패턴 사용 시 고려해야 할 점

싱글톤 패턴 역시 모든 상황에서 자동으로 안전한 선택이 되는 것은 아닙니다. 하나의 인스턴스를 전역적으로 공유하는 구조인 만큼,
다음과 같은 설계 조건이 함께 고려되지 않으면 오히려 문제를 만들 수 있습니다.

첫째, 동일한 인스턴스에 접근하는 방식이 명확해야 합니다.
접근 경로가 불분명하거나 여러 방식으로 노출될 경우, 객체의 사용 흐름을 추적하기 어려워집니다.

둘째, 멀티 스레드 환경에서도 인스턴스가 하나만 생성됨을 보장해야 합니다.
초기화 과정에서의 동시 접근을 고려하지 않으면, 의도와 달리 여러 인스턴스가 생성될 수 있습니다.

셋째, 객체 생성 책임이 외부로 노출되지 않아야 합니다.
외부에서 자유롭게 객체를 생성할 수 있다면, 싱글톤이라는 제약 자체가 무의미해집니다.


싱글톤은 애플리케이션 전역에 공유 리소스를 관리하는 것은 좋은 방법입니다. 또한 싱글톤은 필요한 경우에만 초기화되므로 메모리도 절약할 수 있다는 장점을 가지고 있습니다.

하지만 이 패턴에도 단점이 존재합니다.

전역 상태(Global State)를 도입한다는 점

싱글톤은 애플리케이션 전반에서 동일한 인스턴스를 공유하기 때문에, 상태 변경이 여러 지점에 영향을 미칠 수 있습니다. 특히 규모가 커질수록 예상하지 못한 부작용이 발생할 가능성이 높아집니다.

단위 테스트가 어려워질 수 있다

싱글톤은 인스턴스를 쉽게 교체하거나 테스트용 객체(mock)로 대체하기 어렵습니다. 이로 인해 테스트 대상이 외부 상태에 의존하게 되고,
단위 테스트의 독립성과 신뢰성이 저하될 수 있습니다.

애플리케이션 수명 주기와 함께 유지되는 위험

싱글톤 인스턴스는 애플리케이션이 종료될 때까지 메모리에 유지됩니다. 따라서 내부에 불필요한 참조를 계속 보유하거나, 리소스 해제가 제대로 이루어지지 않을 경우 메모리 누수로 이어질 수 있습니다.


동기화된 블록(synchronized)은 한 시점에 하나의 스레드만 인스턴스 초기화 코드에 진입하도록 보장합니다. 이를 통해 멀티 스레드 환경에서도 싱글톤 인스턴스가 중복 생성되는 문제를 방지할 수 있습니다. 다만 인스턴스를 요청할 때마다 동기화를 수행할 경우, 불필요한 성능 비용이 발생할 수 있습니다. 이를 완화하기 위해, 초기화 시점에만 동기화를 적용하는 더블 체크 잠금(Double-Checked Locking) 방식이 사용되기도 합니다.

안티 패턴

안티 패턴은 코드가 잘못된 방향으로 구조화되었을 때 어떤 문제가 발생하는지를 보여주는 사례입니다. 처음에는 동작하던 코드라도, 잘못된 구조 선택은 시간이 지날수록 유지보수와 테스트를 어렵게 만들 수 있습니다. 이제 이러한 문제를 유발하는 몇 가지 안티 패턴을 살펴보겠습니다.

God Object(신의 객체)

신의 객체(God Object)는 너무 많은 책임을 하나의 객체에 집중시킨 안티 패턴입니다. 이 객체는 자신의 역할을 넘어, 여러 도메인과 기능을 직접 알고 있으며 이를 통제합니다.이러한 구조에서는 객체 간의 책임 경계가 무너지고, 변경이 발생할 때마다 신의 객체가 함께 수정되어야 하는 문제가 발생합니다.

class GodObject {
void processPayroll() {/* Payroll logic */ } 
void handleCustomerService() { /* Customer support */ } 
void manageHR() { /* Employee records */ }

신의 객체는 다양한 문제를 발생시켜 시스템을 취약하게 만들 수 있습니다.
여기서 말하는 다양한 문제란 무엇일까요?

변경에 극도로 취약해진다

신의 객체는 여러 기능과 도메인을 동시에 알고 있기 때문에,하나의 요구사항 변경이 객체 전체 수정으로 이어지는 경우가 많습니다.
결과적으로 작은 변경에도 영향 범위를 예측하기 어려워집니다.

객체 간 결합도가 급격히 증가한다

신의 객체는 시스템 전반의 정보를 알고 있으며 여러 객체를 직접 제어하는 중심이 됩니다. 이로 인해 다른 객체들이 신의 객체에 의해 강하게 의존하게 됩니다.

테스트가 어려워진다

책임이 과도하게 집중된 객체는 외부 의존성과 내부 상태를 동시에 가지는 경우가 많습니다. 그 결과 단위 테스트를 작성하기 어렵고, 테스트 범위가 자연스럽게 커지게 됩니다.

Circular Dependencies(순환 참조)

순환 참조는 두 개 이상의 객체가 서로를 직접 또는 간접적으로 참조하는 구조를 의미합니다. 즉, A 객체가 B 객체를 참조하고,
B 객체가 다시 A 객체를 참조하는 상황에서 발생합니다.이러한 구조는 객체 간 의존 관계를 복잡하게 만들며, 초기화 순서에 따라 런타임 오류로 이어질 가능성이 있습니다. 특히 객체 생성과 의존성 주입이 자동으로 이루어지는 환경에서는 문제가 더 명확하게 드러납니다.

대표적으로 Spring의 빈 설정 과정에서 순환 참조가 발생할 경우, 컨테이너가 빈을 정상적으로 생성하지 못해 애플리케이션 시작 단계에서 오류가 발생할 수 있습니다. 이 때문에 순환 참조는 설계 단계에서 반드시 주의해야 할 안티 패턴 중 하나입니다.

Tight Coupling (강한 결합)

강한 결합은 객체들이 서로를 강하게 의존하는 관계를 맺고 있는 상태를 의미합니다. 앞서 디자인 패턴을 학습하면서, 특히 Factory 패턴을 소개할 때 잠시 등장했던 개념이기도 합니다. 강한 결합 상태에서는 한 객체의 변경이 다른 객체의 변경으로 직접적으로 전파되기 쉽습니다.

이를 조금 더 구체적으로 말하면, 객체가 특정 구현에 직접 의존하고 있으며, 그 생성과 사용을 스스로 책임지는 구조를 강한 결합이라고 볼 수 있습니다. 다음 코드를 통해 이를 확인할 수 있습니다.

class Car {
  private Engine engine = new Engine();
  void start() { engine.start);
  }
}

위 코드에서 Car 객체는 Engine이라는 구체적인 구현 클래스에 직접 의존하고 있습니다. 또한 Engine 객체의 생성 책임까지 Car가 함께 가지고 있습니다. 이러한 구조에서는 다음과 같은 문제가 발생할 수 있습니다.

  • Engine 구현이 변경되면 Car 코드도 함께 수정되어야 합니다.
  • 다른 종류의 Engine으로 교체하기 어렵습니다.
  • 테스트 시 Engine을 대체하거나 분리하기가 쉽지 않습니다.

마무리

다자인 패턴일부와 안티 패턴을 학습해봤습니다. 원문에는 팩토리랑 싱글톤말고 10가지나 되는 패턴들을 소개하고 있습니다. 하지만 이 글에 모두 녹여내기에는 벅찬것도 없지않아 있는거 같습니다. 게다가 디자인 패턴은 따로 정리 중이기 때문에 일부로 전부를 기록하지는 않았습니다. 또한 원문에서는 심플 팩토리 위주로 설명을 진행하였는데 저는 팩토리 메소드 패턴으로 설명을 하였습니다. 그래서 이 부분은 원문과 맞지 않을 수도 있겠네요. 마지막으로 안티 패턴은 개발자라면 당연히? 알고 있는 그런 느낌이긴했습니다. 하지만 정리하면서 조금더 주의를 하면서 개발을 진행해야겠다고 생각이 드네요. 이제 2025년이 얼마 안남네요. 화이팅입니다.

출처
https://blog.bytebytego.com/p/oop-design-patterns-and-anti-patterns


 

'개발 > 디자인패턴' 카테고리의 다른 글

팩토리 메소드 패턴  (0) 2025.12.22
템플릿 메소드 패턴  (0) 2022.03.19
퍼사드 패턴  (0) 2021.12.05
브릿지 패턴  (0) 2021.11.25
전략 패턴  (0) 2021.11.19

댓글

Designed by JB FACTORY