디자인 패턴

1. 개요
2. 역사
3. 생성 패턴(추상 객체 인스턴스화)
3.1. 추상 팩토리(Abstract Factory)
3.2. 팩토리(Factory Method)
3.3. 빌더(Builder)
3.4. 프로토타입
3.5. 싱글톤(Singleton)
4. 구조 패턴(객체 결합)
4.1. 어댑터
4.2. 브리지
4.3. 컴포지트
4.4. 데코레이터
4.5. 파사드(Facade)
4.6. 플라이웨이트
4.7. 프록시
5. 행위 패턴(객체 간 커뮤니케이션)
5.1. 책임 체인
5.2. 커맨드
5.3. 인터프리터
5.4. 반복자(iterator)
5.5. 중재자
5.6. 메멘토
5.7. 옵저버(Observer)
5.8. 상태
5.9. 전략(stretegy)
5.10. 템플릿 메소드
5.11. 방문자

1. 개요

객체 지향 프로그래밍 설계를 할 때 자주 발생하는 문제들을 피하기 위해 사용되는 패턴.

여러 사람이 협업해서 개발할 때 다른 사람이 작성한 코드, 기존에 존재하는 코드를 이해하는 것은 어렵다. 이런 코드를 수정하거나 새로운 기능을 추가해야 하는데 의도치 않은 결과나 버그를 발생시키기 쉽고 성능을 최적화시키기도 어렵다. 이로 인해 시간과 예산이 소모된다.

디자인 패턴은 의사소통 수단의 일종으로서 이런 문제를 해결해준다. 예를 들어 문제 해결의 제안에 있어서도 “기능마다 별도의 클래스를 만들고, 그 기능들로 해야할 일을 한번에 처리해주는 클래스를 만들자.”라고 제안하는 것보다 "Facade 패턴을 써보자."라고 제안하는 쪽이 이해하기 쉽다.

일반 프로그래머가 만나는 문제가 지구상에서 유일한 문제[1]일 확률은 거의 없다. 이미 수많은 사람들이 부딪힌 문제다. 따라서 전문가들이 기존에 해결책을 다 마련해 놓았다.

다만 과유불급. 디자인 패턴을 맹신한 나머지 모든 문제를 패턴을 써서 해결하려 드는 패턴병에 걸리지 않도록 조심하자. 디자인 패턴보다 중요한 것은 코드베이스의 간결성이다. 즉 디자인 패턴 적용이 굳이 필요가 없을 것 같은 부분은 적용하지 않는게 상책이다. 디자인 패턴은 알고리즘이 아니라 상황에 따라 자주 쓰이는 설계 방법을 정리한 코딩 방법론일 뿐이며 모든 상황의 해결책이 아니다. 디자인 패턴에 얽매이는 것보단 그 패턴이 왜 효율적인 방식인지를 이해해야 한다. 같은 이름의 패턴이 다른 언어로 구현된 모습을 보면 이에 대해 좀 더 쉽게 이해할 수 있을 것이다.

2. 역사

논문 "Using Pattern Languages for Object-Oriented Programs" (1987)을 통해 제안되었다. 이후 큰 유명세를 타게 된 것은 23개의 패턴을 수록한 'GoF' (1995)이다.[2] 현재에는 수천여개의 패턴이 발표되어 있다.

아래는 유명하고 자주 쓰이는 패턴 목록.

3. 생성 패턴(추상 객체 인스턴스화)

3.1. 추상 팩토리(Abstract Factory)

많은 수의 연관된 서브 클래스를 특정 그룹으로 묶어 한번에 교체할 수 있도록 만들었다.

3.2. 팩토리(Factory Method)

객체를 만들어 반환하는 함수를 (생성자 대신) 제공하여 초기화 과정을 외부에서 보지 못하게 숨기고 반환 타입을 제어하는 방법.

크게 두 가지 방법이 있는데, 하나는 아예 다른 객체를 직접 만들어 넘겨주는 객체를 따로 만드는 것이 있고, 다른 방식으로 팩토리 기능을 하는 함수가 자기 자신에 포함되어 있고 생성자 대신 사용하는 게 있다.

첫번째 방법의 예시로, 스타크래프트를 만든다고 가정해 보자. 일단 먼저 유닛 클래스를 만들어야 한다.

#!syntax java
class Unit {
    Unit() {
        //생성자
    }
   //이하 유닛의 메소드들
}

그리고 각 유닛별(마린, 파이어벳 등등...) 클래스를 만들어야 한다.

#!syntax java
class Marine extends Unit {
    Marine() {
        //생성자
    }
    //이하 마린의 메소드들
}
#!syntax java
class Firebat extends Unit {
    Firebat() {
        //생성자
    }
    //이하 파이어뱃의 메소드들
}

... 열심히 모든 유닛의 클래스를 만들었다고 가정하자. 이제 다른 부분을 구현할 텐데, 저장된 파일로부터 유닛을 배치하는 '맵 로드' 기능을 구현해 보자.(유닛 데이터는 각 줄이 String[] 형태인 테이블로 되어있다고 가정하자.)

#!syntax java
class Map {
    Map(File mapFile) {
        while(mapFile.hasNext() == true) {
            String[] unit = mapFile.getNext();
            if(unit[0].equals("Marine")) {
                Marine marine = new Marine(unit);
            } else if(unit[0].equals("Firebat")) {
                Firebat firebat = new Firebat(unit);
            }
           //기타 유닛의 생성자들
       }
       //유닛 초기화 이후 코드
    }
}

작동 자체에는 문제가 없는 코드이지만, 객체 지향적으로 보면 단일 책임 원칙을 위반하였다. Map은 말 그대로 맵의 구현 방법에 대해서만 서술되어야 하는데, 파일을 읽는 부분에서 '유닛을 분류하는' 추가적인 책임이 포함되어있다. 만일 새 확장팩 Brood War가 출시되고, 새 유닛 Medic을 넣어야 한다면 전혀 상관없는 Map 클래스를 수정해야 할 것이다.

그래서 다양한 하위 클래스들을 생성하는(Factory : 공장) 클래스를 만들어 그 클래스에 책임을 위임하는 것이다.

그러면 새 클래스 UnitFactory를 만들어보자.

#!syntax java
class UnitFactory {
    static Unit create(String[] data) {
        if(data[0].equals("Marine")) {
            return new Marine(data);
        } else if(data[0].equals("Firebat")) {
            return new Firebat(data);
        }
       //기타 유닛의 생성자들
    }
}

이후 Map은 이렇게 수정하면 된다.

#!syntax java
class Map {
    Map(File mapFile) {
        while(mapFile.hasNext() == true) {
            Unit unit = UnitFactory.create(mapFile.getNext());
       }
       //유닛 초기화 이후 코드
    }
}

이렇게 한다면, 새 유닛을 추가하는지의 여부에 상관없이 다른 클래스를 수정할 필요가 없어져 단일 책임 원칙을 잘 지키는 코드가 된다.

두번째 방법인 생성자 대신 사용하는 함수는 왜 사용하느냐 하면, 언어 문법상 생성자를 바로 접근하지 못하도록 막아야 구현할 수 있는 문제가 몇몇 있기 때문.

예시로는

  • 상속을 막고 싶은데 final 키워드가 직접 지원되지 않는 언어 버전이라던가
  • 생성 객체의 총 수를 제한하고 싶다던가
  • 생성 도중에 C++ 예외가 터지기 때문에 생성자에 초기화 코드를 넣기 곤란한 경우라던가 [3]
  • 생성 과정에 다른 객체를 참조해서 생성 순서를 조절해야 하는 경우라던가
  • 생성 직후 반환값을 사용해서 계산하는게 주 업무인 객체라던가
  • 생성되는 객체의 구체적인 타입을 숨기거나 도중에 바꾸고 싶은 경우 (라이브러리 등)
  • 또는 객체 생성이 확실하지 않은 연산 과정의 캡슐화

등 생각보다 많다. C++에서는 'std::chrono::어쩌구저쩌구_clock::now()' class static 함수가 대표적인 팩토리 패턴이다.

3.3. 빌더(Builder)

#!syntax java
class Something {

    private Something(int number, String name, double size) {
        //Something 클래스 초기화
    }

    public static class Builder {
        int number=0;
        String name=null;
        double size=0d;

        public Builder() {
            //Builder 초기화
        }

        public Builder setNumber(int number) {
            this.number = number;
            return this;
        }

        public Builder setName(String name) {
            this.name = name;
            return this;
        }

        public Builder setSize(double size) {
            this.size = size;
            return this;
        }

        public Something build() {
            return new Something(number, name, size);
        }
    }
}

(Java 기준, 대상 클래스와 빌더 클래스)

#!syntax java
public void createSomething() {
    Something something = new Something.Builder().setNumber(number).setName(name).setSize(size).build();
}

(Java 기준, 빌더 클래스의 사용)

빌더 클래스는 인스턴스를 생성자를 통해 직접 생성하지 않고, 빌더라는 내부 클래스를 통해 간접적으로 생성하게 하는 패턴이다.

사용 목적에는 크게 두 가지로 나뉜다.

  • 클래스와 사용 대상의 결합도를 낮추기 위해

어떤 클래스의 사양 변경으로 인해, 생성자에 인수로 전달해야 하는 부분의 규격이 변경되었다면 어떻게 수정해야 될까?

일반적인 패턴으로는 일단 해당 클래스를 수정한 후, 해당 클래스를 생성하는 모든 부분의 코드를 일일히 다 수정해야 할 것이다.(그렇지 않으면 컴파일 오류가 난다.)

혼자 만드는 건 어찌어찌 Ctrl+F로 코드 찾아가면서 해당 클래스의 생성자를 전부 찾아가면서 변경을 하겠지만, 해당 부분이 다른 사람에게 배포하여 사용하는 Library같은 물건이라면?

Builder는 해당 문제점을 해결하기 위해 고안된 패턴이다.

대상 클래스의 생성자는 private 등의 접근 제한자로 제한하여 외부에서 임의로 접근하는 것을 막아 클래스와 사용대상의 결합도를 떨어뜨리고, 대신 Builder라는 내부 클래스를 통해 해당 클래스를 간접적으로 생성한다.

Builder는 설정되지 않은 인수에 대해서는 적절한 값으로 초기화를 하여 해당 인수가 할당되지 않더라도 일단 컴파일 자체는 가능하며, 사용자의 요청에 따라 상세한 값을 설정하는 것도 가능하다.

예를 들어, 위 Something 클래스에서 double weight라는 인수를 추가로 할당하려고 하면, 전통적인 패턴에서는 위에 언급한대로 모든 생성자마다 double weight라는 단서를 추가로 달아야겠지만, Builder 패턴에서는 대상 클래스의 private 생성자에 weight를 추가하고, Builder에 setWeight(double weight) 하나만 추가하면 끝. 기본값은 -1(설정되지 않음)으로 하면 수많은 코드들을 일일히 찾아다니지 않아도 기능 추가가 가능하다.

  • 생성자에 전달하는 인수에 의미를 부여하기 위해

예를 들어서, 위에 제시된 예시에서 빌더 패턴이 없다고 가정하고 인스턴스를 생성하려면 Something something = new Something(number, name, size); 이렇게 코드를 작성하여야 한다.

위의 예시에서는 인수가 세 개니까 그냥 저렇게 써도 큰 문제는 없지만, 생성자에 전달하는 인수의 가짓수가 열 종류 가까이 되는 클래스의 경우에는 고전적인 생성자 패턴으로는 인수를 전달하는 것이 상당히 비직관적이 된다.(인수의 종류를 외워서 써넣어야되는 것뿐만 아니라, 인수의 순서까지 고려해야 한다!)

그래서 빌더 패턴을 통해 setXXX 형식으로 인수를 전달하면 한 눈에 보기에도 이것이 무슨 인수인지를 파악하기가 쉽다.

빌더와 팩토리 패턴은 유사점이 많아 그냥 팩토리로 퉁쳐서 칭하기도 한다. 특히 자바 이외의 언어에서.

3.4. 프로토타입

원본(Prototype)을 만들어 놓고 원본 객체를 복사하여 사용하는 방식.

3.5. 싱글톤(Singleton)

#!syntax java
class Singleton {

    static final Singleton instance = new Singleton();

    private Singleton() {
        //초기화
    }

    public Singleton getInstance() {
        return instance;
    }

}

(Java에서의 예시)

키보드 리더, 프린터 스풀러, 점수기록표 등 클래스의 객체를 하나만 만들어야 하는 경우 사용한다. 클래스 내에서 인스턴스가 단 하나뿐임을 보장하므로, 프로그램 전역에서 해당 클래스의 인스턴스를 바로 얻을 수 있고, 불필요한 메모리 낭비를 최소화한다.

이 패턴에서는 생성자를 클래스 자체만 사용할 수 있도록 private 등의 접근제한자를 통하여 제한하여야 한다. 생성자를 다른 곳에서도 사용할 수 있으면 그 곳에서도 인스턴스를 만들 수 있기 때문.

싱글톤 패턴을 사용하기 위해서는 반드시 접근제한자를 이용하여 외부의 접근을 막거나, final로 reference를 변경 불가능하게 설정하여야 한다. 물론 생성자에 접근제한자를 사용하면 최소한 다른 인스턴스로 레퍼런스시키지는 못하겠지만, ClassName.singleton = null; 처럼 레퍼런스 자체를 지워버릴 수 있기 때문.

구현 방법에는 사전 초기화, 늦 초기화 등이 있다.

  • Eager initialization(사전 초기화)

클래스 로딩시에 인스턴스를 생성하는 방법이다. 위의 예시가 사전 초기화. 멀티스레드 환경에서의 이중 객체 생성 문제[4]가 없지만, 인스턴스를 호출하지 않아도 무조건 클래스를 초기화하기에 메모리 효율이나 연산 효율은 낮다.

Java에서는 static block initialization이라는 변종도 있다. 클래스가 로딩될 때 최초 1회만 실행되는 static block을 통해 싱글톤 인스턴스를 초기화하는 방법인데, 구조적으로는 크게 다르지 않다.

  • Lazy initialization(늦 초기화)

인스턴스를 실제로 사용할 시점에서 인스턴스를 생성하는 방법이다. 세심한 방법을 쓰지 않으면 위에 언급한 이중 객체 생성 문제가 발생할 가능성이 높으나, 인스턴스를 실제로 사용하지 않는다면 메모리와 연산량을 아낄 수 있다는 장점이 있다.

4. 구조 패턴(객체 결합)

4.1. 어댑터

4.2. 브리지

4.3. 컴포지트

4.4. 데코레이터

4.5. 파사드(Facade)

쉽게 설명하자면 복잡한 호출과정을 대신 처리해주는 wrapper 객체를 따로 만드는 것. 함수 호출 비용이 조금 들어가나 훨씬 쉽게 사용할 수 있다.

굳이 객체를 따로 만드는 이유로는 하위 모듈을 건드릴 수 없는 경우(외부 라이브러리)나 저수준과 고수준 추상층(abstract layer) 개념 구분을 하고 싶은 경우, 크로스플랫폼 기술 구현 등의 이유가 있다.

파사드는 프랑스어 Façade에서 차용된 단어로 보통 건물의 출입구로 이용되는 정면 외벽 부분을 가리키는 말이다.

파사드 패턴은 시스템의 복잡성을 감추고, 사용자(Client)가 시스템에 접근할 수 있는 인터페이스(Interface)를 사용자(Client)에게 제공한다. 따라서 파사드 패턴은 기존의 시스템에 인터페이스를 추가함으로써, 복잡성을 감추기 위해 사용된다. 파사드 패턴은 구조적 패턴(Structural Pattern)에 포함된다.

구현 방법

1단계:

인터페이스를 생성한다.

Shape.java

#!syntax java
public interface Shape {
   void draw();
}

2단계:

그 인터페이스를 구현하기 위한 구체적인 클래스를 생성한다.

Rectangle.java

#!syntax java
public class Rectangle implements Shape {

   @Override
   public void draw() {
      System.out.println("Rectangle::draw()");
   }
}

Square.java

#!syntax java
public class Square implements Shape {

   @Override
   public void draw() {
      System.out.println("Square::draw()");
   }
}

Circle.java

#!syntax java
public class Circle implements Shape {

   @Override
   public void draw() {
      System.out.println("Circle::draw()");
   }
}

3단계:

파사드 클래스를 생성한다.

ShapeMaker.java

#!syntax java
public class ShapeMaker {
   private Shape circle;
   private Shape rectangle;
   private Shape square;

   public ShapeMaker() {
      circle = new Circle();
      rectangle = new Rectangle();
      square = new Square();
   }

   public void drawCircle(){
      circle.draw();
   }
   public void drawRectangle(){
      rectangle.draw();
   }
   public void drawSquare(){
      square.draw();
   }
}

4단계:

다양한 종류의 형태를 만들기 위해 파사드를 사용한다.

FacadePatternDemo.java

#!syntax java
public class FacadePatternDemo {
   public static void main(String[] args) {
      ShapeMaker shapeMaker = new ShapeMaker();

      shapeMaker.drawCircle();
      shapeMaker.drawRectangle();
      shapeMaker.drawSquare();		
   }
}

5단계:

결과값을 확인한다.

#!syntax java
Circle::draw()
Rectangle::draw()
Square::draw()

4.6. 플라이웨이트

4.7. 프록시

연산을 할 때 객체 스스로가 직접 처리하지 않고 중간에 다른 '숨겨진' 객체를 통해 처리하는 방법.

C++에서 다중 배열 접근은 operator[] 를 통해 이루어지는데, 만약에 배열을 내부에 캡슐화하고 var[1][2] 처럼 접근하고 싶어서 연산자 오버로딩을 동원하면 컴파일이 되지 않는다. operator[][] 는 없기 때문.

이 경우 프록시 객체를 따로 만들어서 내부 배열 첨자를 참조하는 다른 객체를 반환하게 하고(1차원, 2차원, ...) 중첩 operator[] 를 각각의 객체에 적용하면 .operator[](임시 객체.operator[](...)) 처럼 처리되어 구현할 수 있게 된다.

std::vector<bool> 클래스도 내부적으로는 1비트 단위로 접근하기 위해 비트 연산을 동원하는데, 이 과정에서 프록시 클래스를 경유한다. 물론 deprecated (사용 금지 권고) 받은 구시대 유물이니 std::bitset을 대신 사용하도록 하자.

표현식 템플릿(expression template) 이라는 고급 최적화 기법을 구현하는 방법도 프록시 클래스다.

템플릿을 통해 컴파일 타임에 게으른 평가(lazy evaluation)를 적용시켜 여러 연산자, 특히 행렬 처리를 풀어헤쳐서 임시 객체 생성을 최소화시킨다.

5. 행위 패턴(객체 간 커뮤니케이션)

5.1. 책임 체인

5.2. 커맨드

5.3. 인터프리터

5.4. 반복자(iterator)

객체 지향 언어에서 가장 접하기 쉬운 패턴. 당장 C#의 foreach 문은 반복자(IEnumerable) 인터페이스를 구현해야 사용 가능하다. 또한 C#의 모든 배열은 IEnumerable 인터페이스를 구현한다.

#!syntax csharp
List<int> list = new List<int>();
// List에 요소 추가 //

foreach(int _value in list) {
   Console.WriteLine(_value);
}

자세히 설명하자면, 고전적인 패턴으로 자료구조에서 자료 전체를 순회할 때 List같은 구조에서는 아래와 같이 반복할 것이다.

#!syntax java
for(int index=0; index<list.size(); index++) {
  list.get(index).doSomething();
}

저런 식으로 index를 하나씩 올려가며 순회하는 알고리즘은 List에는 적절하지만, 자료구조에는 List만 있는 것이 아니다. Tree, Trie, Graph, Map 등 오히려 index 접근을 못하는 구조가 더 많다.

이 경우에는 반복자(Iterator)라는 것을 사용하게 된다.

반복자는 인터페이스인데, 자바로 치면 아래와 같은 메서드를 정의해둔다.(참고)

#!syntax java
interface Iterator<E> {
  boolean hasNext();
  E next();
  void remove();
}

[5]

이렇게 정의를 하고, 실제로 자료구조에 접근할 때에는 아래와 같이 접근하면 된다.

#!syntax java
Iterator<Object> iterator = collection.iterator();
while(iterator.hasNext() == true) {
  Object object = iterator.next();
  object.doSomething();
}

이러면 자료구조가 Array이든 List이든 Tree Graph든, Iterable을 정의해놓기만 하면 저런 식으로 자료구조 전체를 순회하여 작업을 할 수 있게 된다.

물론 맨 위의 방법처럼 고전적인 for문을 쓸 수 있다면 그 쪽이 더 빠르긴 하다. for문은 컴퓨터 구조적인 발전으로 인해 캐시 메모리의 효과를 가장 많이 받는 구문이지만, 반복자는 그렇지 않기 때문.

C++의 경우엔 반복자를 정의하는 클래스가 begin과 end란 이름의 함수, 그리고 반복자엔 ++연산자 오버로딩, * 연산자 오버로딩을 구현해야 하도록 되어 있다. begin은 첫 자료의 반복자를 반환하고 end는 마지막 반복자를 반환하는데, 사실 빈 반복자를 반환하면 된다. 어디에 인터페이스같은게 정의되 있는 것은 아니라서 배우기는 상대적으로 어려운 편.

실제 자료구조엔 이런식으로 접근한다.

#!syntax cpp
std::list<int>list;
for(std::list<int>::iterator it = list.begin(); it != list.end(); ++it){
  (*it)=3;
}

5.5. 중재자

5.6. 메멘토

5.7. 옵저버(Observer)

5.8. 상태

5.9. 전략(stretegy)

5.10. 템플릿 메소드

전체적인 레이아웃을 통일시키지만 상속받은 클래스가 유연성을 가질 수 있게 만드는 패턴이다.

5.11. 방문자


  1. [1] 주문형 반도체 제어가 여기에 들어간다.
  2. [2] '4인방' (Gang of Fours). Design Patterns: Elements of Reusable Object- Oriented Software.
  3. [3] 예외의 예외가 터지면 꼼짝없이 생성자를 탈출해서 프로그램 크래시 터진다.
  4. [4] 거의 비슷한 시간에 두 스레드가 동시에 싱글톤 인스턴스에 접근-없는 것을 확인하고 생성하여 객체를 생성하다가 싱글톤임에도 불구하고 객체가 중복되어 생성되는 문제
  5. [5] E는 제네릭(필요에 따라 자료형을 정의하여 사용하면 됨)이고, remove는 구현해도 안 해도 상관은 없다.

최종 확인 버전:

cc by-nc-sa 2.0 kr

Contents from Namu Wiki

Contact - 미러 (Namu)는 나무 위키의 표가 깨지는게 안타까워 만들어진 사이트입니다. (50.31ms)