본문 바로가기
독서&그 외

'개발자가 반드시 정복해야 할 객체 지향과 디자인 패턴' 을 읽고 (7장, 주요 디자인 패턴)

by winteringg 2022. 10. 12.
 

객체 지향과 디자인 패턴 : 네이버 도서

네이버 도서 상세정보를 제공합니다.

search.shopping.naver.com


[7. 주요 디자인 패턴]

이번 장에서 정리할 디자인 패턴

  • 전략 패턴 / 템플릿 메서드 패턴 / 상태 패턴
  • 데코레이터 패턴 / 프록시 패턴 / 어댑터 패턴
  • 옵저버 패턴 / 미디에이터 패턴 / 파사드 패턴
  • 추상 팩토리 패턴 / 컴포지트 패턴

전략 (Strategy) 패턴

 서로 다른 정책들이 한 코드에 섞여있으면 유지보수가 어려워진다. 정책이 추가될때마다 코드가 수정되어야 하고 if-else 블록도 늘어난다. 이럴 때 정책을 추상화하여 인터페이스로 만들어서 사용한다. 알고리즘 정책을 추상화하고 있는 인터페이스를 '전략' 이라고 부르고, 실제 구현 기능 자체의 책임을 가지고 있는 클래스를 '콘텍스트' 라고 부르는데, 이렇게 특정 콘텍스트에서 알고리즘(전략)을 별도로 분리하는 설계 방법이 바로 전략 패턴이다.

 전략 패턴에서 콘텍스트는 사용할 전략을 직접 선택하지 않는다. 대신 콘텍스트의 클라이언트가 콘텍스트에 사용할 전략을 전달해준다. 즉 DI (의존 주입) 를 통해 외부에서 정책을 콘텍스트에게 전달해준다. 정책을 사용하는 코드는 외부에서 어떤 정책이 넘어오던지 상관없이 정책 인터페이스 타입을 사용하므로 코드의 변경이 없어진다. 전략이 어떤 메서드를 제공할 지의 여부는 콘텍스트가 전략을 어떤 식으로 사용하느냐에 따라 달라진다.

 일반적으로 if-else로 구성된 코드 블록이 비슷한 기능을 수행하는 경우나, 완전히 동일한 기능을 제공하지만 성능의 장단점에 따라 알고리즘을 선택해야 하는 경우에 사용한다.

템플릿 메서드 (Template Method) 패턴

 프로그램을 구현하다보면 완전히 동일한 절차를 가진 코드를 작성하게 될 때가 있다. 이렇게 실행 과정 / 단계가 동일한데 각 단계 중 일부의 구현이 다른 경우에 사용할 수 있다. 템플릿 메서드는 아래와 같이 2가지로 구성된다.

  • 실행 과정을 구현한 상위 클래스 : 기능을 구현하는데 필요한 각 단계를 정의하며 이 중 일부 단계는 추상 메서드를 호출하는 방식으로 구현된다. 모든 하위 타입에 동일하게 적용되는 실행 과정을 제공하며 하위 클래스는 상위 클래스에서 호출하는 다른 메서드만 알맞게 재정의해주면 된다.
  • 실행 과정의 일부 단계를 구현한 하위 클래스 : 상위 클래스를 상속받아서 상위 클래스의 추상메서드를 알맞게 구현한다.

 템플릿 메서드 패턴을 사용하게 되면, 동일한 실행 과정의 구현을 제공하면서 동시에 하위 타입에서 일부 단계를 구현하도록 할 수 있기 때문에 코드 중복을 방지해준다.

 템플릿 메서드 패턴의 특징은 하위 클래스가 아닌 상위 클래스에서 흐름 제어를 한다는 것이다. 일반적인 경우 하위 타입이 상위 타입의 기능을 재사용할지 여부를 결정하기 때문에 흐름 제어를 하위 타입이 하게 된다. 반면에 템플릿 메서드 패턴에서는 상위 타입의 템플릿 메서드가 모든 실행 흐름을 제어하고, 하위 타입의 메서드는 템플릿 메서드에서 호출되는 구조를 갖게 된다.

 훅 (hook) 메서드 : 상위 클래스에서 추상 메서드 이외에 기본적으로 구현이 되어 있는 메서드를 제공하기 때문에 하위 클래스의 경우 추상 메서드는 반드시 구현해야 하지만, 기본적으로 구현되어 있는 메서드는 필요한 경우에만 재정의 해주면 된다. 즉, 상위 클래스 입장에서는 제어 대상이 되는 확장 지점이 되며, 하위 클래스 입장에서는 하위 클래스에 맞는 확장 기능을 구현할 위치가 된다. 이렇게 상위 클래스에서 실행 시점이 제어되고, 기본 구현을 제공하면서 하위 클래스에서 알맞게 확장할 수 있는 메서드를 훅 (hook) 메서드라고 한다. 

상태 (State) 패턴

 기능이 상태에 따라 다르게 동작해야 할 때 사용할 수 있는 패턴이 상태 패턴이다. 상태 패턴에서 중요한 점은 상태 객체가 기능을 제공한다는 점이다. 콘텍스트는 필드로 상태 객체를 갖고 있다. 콘텍스트는 클라이언트로부터 기능 실행 요청을 받으면, 상태 객체에 처리를 위임한다. 상태별로 동일한 메서드가 다른 기능을 수행하며, 기능을 수행할 때상태 자체를 변경시킬 수 있다.

 상태 패턴의 첫 번째 장점은 새로운 상태가 추가되더라도 콘텍스트 코드가 받는 영향은 최소화된다는 점이다. 상태가 많아질 경우 조건문을 이용한 방식은 코드가 복잡해져 유지보수가 어려워진다. 하지만 상태 패턴의 경우 상태가 많아지더라도 클래스의 개수는 증가하지만 코드의 복잡도는 증가하지 않는다.

 상태 패턴의 두 번째 장점은 상태에 따른 동작을 구현한 코드가 각 상태 별로 구분되기 때문에 상태 별 동작을 수정하기가 쉽다는 것이다. 예를 들어 자판기 클래스에서 조건문을 이용한 방식을 사용할 경우 동전 없음 상태의 동작을 수정하려면 각 메서드를 찾아다니며 수정해주어야 한다. 반면에 상태 패턴을 적용한 경우는 동전 없음 상태를 표현하는 NoCoin 클래스를 수정해주기만 하면 된다. 관련된 코드가 한 곳에 모여있기 때문에 안전하고 더 빠르게 코드 변경을 할 수 있게 된다.

상태 패턴을 적용할 때 고려할 문제 : 콘텍스트의 상태 변경을 누가 하느냐에 대한 것인데 상태 변경은 콘텍스트나 상태 객체 둘 중 하나가 된다.

 콘텍스트에서 상태를 변경할 경우 콘텍스트의 코드가 다소 복잡해질 수 있다. 콘텍스트에서 상태를 변경하는 방식은 상태 개수가 적고, 상태 변경 규칙이 거의 바뀌지 않는 경우 유리하다. 왜냐하면 상태 종류가 지속적으로 변경되거나 상태 변경 규칙이 자주 바뀔 경우 콘텍스트의 상태 변경 처리 코드가 복잡해질 가능성이 높기 때문이다. 상태 변경 처리 코드가 복잡해질수록 상태 변경의 유연함이 떨어지게 된다.

 반면에 상태 객체에서 콘텍스트의 상태를 변경할 경우, 콘텍스트에 영향을 주지 않으면서 상태를 추가하거나 상태 변경 규칙을 바꿀 수 있게 된다. 하지만 상태 변경 규칙이 여러 클래스에 분산되어 있기 때문에 상태 변경 규칙을 파악하기 쉽지 않고, 상태 클래스 끼리 의존도도 발생한다.

데코레이터 (Decorator) 패턴

 상속은 기능을 확장한다. 이 방법이 쉽긴 하지만 상속을 이용한 확장 기능은 클래스의 증가를 불러온다. 이런 경우 사용할 수 있는 패턴이 데코레이터 패턴이다.

 데코레이터 패턴은 상속이 아닌 위임을 하는 방식으로 기능을 확장해 나간다. 서브클래스를 만드는 것을 통해서 기능을 유연하게 확장할 수 있는 방법을 제공하는 것인데, 쉽게 말해 샌드위치와 같이 본래의 재료에 무언가를 추가할 때 사용된다. 필요한 기능을 가진 인터페이스를 정의하고, 실제 기능 구현은 이 인터페이스를 상속한 클래스에서 한다. 여기서 중요한 점은 기능 확장을 위해 구현한 클래스를 상속받는 것이 아니라 Decorator 라 불리는 별도의 추상 클래스를 만든다는 점이다.

 위의 클래스 다이어그램을 살펴보면 Component는 기본적인 재료를 의미한다. 샌드위치로 예를 들자면 Component는 "빵"이라고 할 수 있다. 기본 재료인 "빵"에 Decorator 를 상속받은 ConcreteDecoratorA, ConcreteDecoratorB 를 사용하여 각종 햄, 야채, 소스를 추가하면 된다. Decorator 객체는 Component의 reference를 가지고 있고, 이 곳에 꾸며야 할 객체(ConcreteComponent)를 전달받아 여러 가지 동작/정보를 추가하게 된다.

 데코레이터 패턴의 장점은 데코레이터를 조합하는 방식으로 기능을 확장할 수 있다는 것이다. 데코레이터 패턴을 사용하면 각 확장 기능들의 구현이 별도의 클래스로 분리되기 때문에, 각 기능 및 원래 기능을 서로 영향 없이 변경할 수 있도록 만들어준다. 즉, 데코레이터 패턴은 단일 책임 원칙을 지킬 수 있도록 만들어 준다.

 데코레이터 패턴을 구현할 때 고려할 점은 데코레이터 대상이 되는 타입(인터페이스)의 기능 개수에 대한 것이다. 정의되어 있는 메서드가 많아지면 데코레이터 구현도 복잡해진다. 한가지 더 고려할 것은 데코레이터 객체가 비정상적으로 동작할 때 어떻게 처리할 것이냐에 대한 것이다.

 데코레이터의 단점은 사용자 입장에서 데코레이터 객체와 실제 구현 객체의 구분이 되지 않기 때문에 코드만으로는 기능이 어떻게 동작하는지 이해하기 어렵다는 점이다.

프록시 (Proxy) 패턴

 실제 객체를 대신하는 프록시 객체를 사용해서 실제 객체의 생성이나 접근 등을 제어할 수 있도록 해주는 패턴이다. 두 가지의 경우 사용하게 되는데, 첫 번째는 바빠서 그 일을 할 수 없는 본인 객체 대신 대리 객체가 어느 정도 그 일을 처리해 줄 필요가 있는 경우이다. 두 번째는 다른 객체에 접근하기 위해 본인 객체는 은닉하고 대리 객체를 두고자 하는 경우이다.

 프록시 패턴은 한정된 자원을 가지고 서비스 요청이 폭주하는 시간에도 안정되고 빠르게 유지하기 위한 (웹툰 페이지 같은) 서비스 등에 이용하며 아래의 4가지 유형으로 구분할 수 있다.

  • 가상 프록시 : 인스턴스가 필요한 순간에만 실제 객체를 생성해 주는 프록시이다.
  • 보호 프록시 : 실제 객체에 대한 접근을 제어하는 프록시로서, 접근 권한이 있는 경우에만 실제 객체의 메서드를 실행하는 방식으로 구현한다. 위 클래스 다이어그램의 경우, RealSubject 기능에 대해 액세스 제한을 설정하는 것이다.
  • 원격 프록시 : 자바의 RMI (Remote Method Invocationi) 처럼 다른 프로세스에 존재하는 객체에 접근할 때 사용하는 프록시이다. 내부적으로 IPC나 TCP 통신을 이용해서 다른 프로세스의 객체를 실행하게 된다. 원격에 있음에도 불구하고 바로 옆에 있는 것처럼 호출한다.
  • 스마트 프록시 : 객체에 대한 단순한 접근 외에 부가적인 작업을 수행해주는 프록시이다.

 프록시를 구현할 때 고려할 점은 실제 객체를 누가 생성할 것이냐에 대한 것이다. 가상 프록시는 필요한 순간에 실제 객체를 생성하기 대문에, 본인 클래스에서 직접 해당 타입을 사용한 것처럼 가상 프록시에서 실제 생성할 객체의 타입을 사용하게 된다.

 반면에 접근 제어를 위한 목적으로 사용되는 보호 프록시는 보호 프록시 객체를 생성할 때 실제 객체를 전달하면 되므로 실제 객체의 타입을 알 필요 없이 추상 타입을 사용하면 된다.

 위임 방식이 아닌 상속을 사용해서 프록시를 구현할 수도 있다. 예를 들어, 특정 기능은 관리자만 실행할 수 있어야 한다고 할 경우 보호 프록시를 사용할 수 있을 것이다. 이 때 보호 프록시는 상위 클래스의 메서드를 재정의하는 방법으로 구현한다. 상속 방식을 사용하면 위임에 비해 구조가 단순해서 구현이 비교적 쉽지만, 상속 방식의 프록시는 객체를 생성하는 순간 실제 객체가 생성되기 때문에 가상 프록시를 구현하기에는 적합하지 않다.

어댑터 (Adapter) 패턴

 클라이언트가 요구하는 인터페이스와 재사용하려는 모듈의 인터페이스가 일치하지 않을 때 사용할 수 있는 패턴이다. 한 클래스의 인터페이스를 클라이언트에서 사용하고자 하는 다른 인터페이스로 변환한다.

 두 인터페이스 사이에 어댑터 클래스를 만들어 클라이언트에서 사용할 수 있게 리턴값을 변환해주기 때문에, 어댑터 패턴을 이용하면 인터페이스 호환성 문제 때문에 같이 쓸 수 없는 클래스들을 연결해서 쓸 수 있다. 쉽게 말해 어댑터 패턴은 B 를 A 로 포장하여 A 로 사용할 수 있게 해주는 것이다.

옵저버 (Observer) 패턴

한 객체의 상태 변화를 정해지지 않은 여러 다른 객체에 통지하고 싶을 때 사용되는 패턴이다. 옵저버 패턴에는 크게 주제 객체와 옵저버 객체가 등장한다.

1) 주체 객체

  • 옵저버 목록을 관리하고, 옵저버를 등록하고 제거할 수 있는 메서드 제공
  • 상태의 변경이 발생하면 등록된 옵저버에 변경 내역 알림

2) 옵저버 객체

  • 각 주제 객체가 호출하는 메서드에서 필요한 기능을 구현

 주제 객체의 상태에 변화가 생길 때 그 내용을 통지받도록 하려면, 옵저버 객체를 주제 객체에 등록해 주어야 한다. 옵저버 패턴을 적용하면 주제 클래스 변경 없이 상태 변경을 통지 받을 옵저버를 추가할 수 있다. 옵저버 패턴을 구현할 때에는 다음 내용을 고려해야 한다.

  • 주제 객체의 통지 기능 실행 주체
  • 옵저버 인터페이스의 분리
  • 통지 시점에서의 주제 객체 상태
  • 옵저버 객체의 실행 제약 조건

미디에이터 (Mediater) 패턴

 책임에 따라 알맞게 객체를 분리했음에도 불구하고 전체 클래스가 단일 구조가 되어 변경이나 재사용이 어려워지는 경우가 있다. 예를 들어 객체 간의 의존이 직접 연결되어 있는 경우 미디에이터 패턴을 사용할 수 있다.

 미디에이터 패턴은 각 객체들이 직접 메시지를 주고받는 대신, 중간에 중계 역할을 수행하는 미디에이터 객체를 두고 미디에이터를 통해서 각 객체들이 간접적으로 메시지를 주고받도록 한다.

 협업 객체들은 모든 요청을 미디에이터에 보내고, 미디에이터는 그 요청을 처리할 알맞은 객체를 실행한다. 이렇게 해주면 각 협업 객체가 서로 알 필요 없이 미디에이터가 각 객체 간의 메시지 흐름을 제어하기 때문에, 새로운 협업 객체가 추가되더라도 기존 클래스를 수정할 필요 없이 미디에이터 클래스만 수정해주면 된다.

 다만 협업 클래스의 수가 증가할수록 미디에이터의 코드가 복잡해져 유지 보수하기 어려워진다.

파사드 (Facade) 패턴

 코드 중복과 직접적인 의존을 해결하는데 도움을 주는 패턴이다. 파사드 패턴은 서브 시스템을 감춰 주는 상위 수준의 인터페이스를 제공한다. 파사드 패턴을 적용하면 클라이언트 코드가 간결해지고, 클라이언트와 서브 시스템 간의 직접적인 의존을 제거해준다. 파사드를 인터페이스로 정의함으로써 클라이언트의 변경 없이 서브 시스템 자체를 변경할 수 있다.

 파사드 패턴을 적용한다고 해서 서브 시스템에 대한 직접적인 접근을 막는 것은 아니다. 여러 클라이언트의 중복된 서브 시스템 사용을 추상화할 뿐이다.

추상 팩토리 (Abstract Factory) 패턴

 추상 팩토리 패턴은 관련된 객체 군을 생성하는 책임을 갖는 타입을 별도로 분리한다. 클라이언트에 영향을 주지 않으면서 사용할 제품군을 교체할 수 있다.

public abstract class EnemyFactory {
    public static EnemyFactory getFactory(int level) {
        if (level == 1) {
            return EasyStageEnemyFactory();
        }
        return HardEnemyFactory();
    }
    
    public abstract Boss createBoss();
    public abstract SmallFlight createSmallFlight();
    public abstract Obstacle createObstacle();
    
}


 DI 를 적용하면 추상 클래스를 인터페이스로 전환할 수도 있다.

컴포지트 (Composite) 패턴

 거의 동일한 코드가 중복되면 복잡도를 높여서 코드의 수정이나 확장을 어렵게 만드는데, 이런 단점을 해소하기 위해 사용하는 패턴이다. 컴포지트 패턴은 전체-부분을 구성하는 클래스가 동일 인터페이스를 구현하도록 만듦으로써 해결한다. 컴포지트 패턴의 장점은 클라이언트가 컴포지트와 컴포넌트를 구분하지 않고 컴포넌트 인터페이스만으로 프로그래밍 할 수 있게 해준다는 것이다.

 컴포지트란?

  • 컴포지트 그룹을 관리한다.
  • 컴포지트에 기능 실행을 요청하면, 컴포지트는 포함하고 있는 컴포넌트들에게 기능 실행 요청을 위임한다.

 컴포지트 패턴을 구현할 때 고려할 점은 컴포넌트를 관리하는 인터페이스를 어디서 구현할지에 대한 여부이다.

Null 객체 패턴

 null 검사 코드의 단점은 개발자가 null 체크를 빼먹을 수 있다는 것이다. 여러 코드에서 한 객체에 대한 null 검사를 하게 되면 null 검사 코드를 누락하기 쉽고 NullPointerException으로 이어진다. Null 객체 패턴은 null 검사 코드 누락에 따른 문제를 없애준다.

 null을 리턴하지 않고 null을 대신할 객체를 리턴함으로써 null 검사 코드를 없앨 수 있도록 한다.

  • null 대신 사용될 클래스를 구현한다. 이 클래스는 상위 타입을 상속받으며, 아무 기능도 수행하지 않는다.
  • null을 리턴하는 대신, null을 대체할 클래스의 객체를 리턴한다.

 null 객체 패턴을 사용하면 null 체크 과정이 없어지므로 코드가 간결해지고 가독성이 높아진다.

댓글