디자인 패턴은 특정 상황에서 반복적으로 등장하는 문제에 대한 잘 알려진 솔루션이다.
디자인 패턴은 말그대로 '패턴' 이기 때문에 이렇게 만들어야겠다 하고 만들어낸 것이 아니라 (not invented)
사람들이 자주 사용하는 솔루션들을 패턴으로 정리해낸 것이다. (discover)
그렇다고 아무 문제에서 누구나 쉽게 떠올릴 수 있는 솔루션들을 정리한 것은 아니다.
디자인 패턴은 어려운 문제에 대해 기발하고 유용했던 솔루션들을 모아서 정리함으로써,
비슷한 문제가 발생했을 때 동일한 솔루션을 재사용해서 효율적으로 문제를 풀어나가기 위한 방법이다.
디자인 패턴은 이름, 문제, 해결방법, 예시코드, 연관된 패턴 정보들로 구성되어있다.
이름은 그 패턴의 이름만으로 이 패턴이 어떤 문제를 어떻게 해결하려고 하는지를 연상할 수 있는 의미있는 이름이어야 한다.
그리고 이 패턴이 어떤 문제를 풀기 위한 패턴인지, 그리고 그 문제를 어떻게 푸는지를 설명할 수 있어야 한다.
그런데 솔루션은 굉장히 일반화된 형태로만 설명이 되어있기 때문에 이해를 돕기 위해 실제 예시 코드도 함께 있어야 한다.
마지막으로 연관된 패턴의 경우, 이 패턴을 적용할 수 있을 줄 알았는데 적용을 못할 때, 그럼 비슷한 상황에서 쓸 수 있는 다른 패턴은 무엇이 있는지 쉽게 찾기 위해 함께 기술한다.
디자인 패턴 중에서 제일 유명한 것은 GoF 디자인 패턴이다.
GoF 디자인 패턴은 패턴을 크게 생성(creational), 구조(structural), 행위(behavioural) 로 구분한다.
이제 각 구분 별로 대표적인 패턴을 하나씩 정리해보자.
Creational - Singleton
생성 패턴 (creational pattern) 은 말 그대로 오브젝트 인스턴스를 생성하는 것과 관련된 패턴이다.
creational 패턴의 대표적 예시 중 하나는 싱글톤 패턴이다.
싱글톤 패턴을 적용하는 문제 상황은 하나의 클래스로부터 만들어지는 인스턴스가 1개임을 보장하는 것이다.
그리고 싱글톤 패턴의 solution 은 constructor 에 대한 접근을 제한하는 것으로 이를 해결한다.
그림과 같이 식당 클래스로부터 유일하게 1개의 인스턴스만 만들어지기를 원한다고 해보자.
그러면 먼저 instance 정보를 저장할 변수를 static (class-scope) 으로 선언한다. (밑줄을 그으면 static 이라는 뜻)
그리고 instance 를 조회할 getInstance() 메서드도 static 으로 선언한다.
마지막으로 식당 생성자에 아무도 접근할 수 없도록 private 으로 만든다.
그러면 Restaurant 클래스의 인스턴스를 얻기 위해서는 반드시 getInstance() 메서드를 통해서만 인스턴스를 얻을 수 있게 된다.
getInstance() 메서드가 어떻게 하나의 인스턴스만 반환하도록 동작하는지, 그 로직을 시퀀스 다이어그램으로 그려보면 다음과 같다.
사실 로직은 간단하다.
getInstance() 를 호출했을 때, instance 클래스 변수가 null 이면 private 생성자를 호출하여 인스턴스를 만든 뒤,
instance 변수에 담아서 instance 값을 리턴해주고
instance 클래스 변수가 null 이 아니면 그 참조를 그대로 리턴해주면 된다.
(참고로 위 그림에서 instance 변수에 값을 할당하는 코드가 2개 있는데,
왼쪽의 instance 변수는 Object 클래스의 멤버 변수고 오른쪽의 instance 변수는 Restaurant 클래스의 static 멤버 변수다)
위 그림에서 Restaurant 클래스 앞에 : 이 없다는 것에 주의하자.
또한 그렇게 받아온 instance 에서 getName 을 호출하면 언제나 singleton 이라는 이름의 인스턴스의 getName 을 호출하게 된다.
또한 싱글톤 패턴을 응용하면, 인스턴스를 정확히 10개만 만들어야 하고, 그보다 많은 인스턴스는 만들 수 없게 제한을 두는 것도 해결할 수 있다.
이 경우에도 인스턴스 생성에 제한을 두는 것이므로 생성자는 private 으로 만들어서 외부에서 접근하지 못하도록 만들고,
static 으로 만들어진 instance 를 보관할 array 와 지금까지 만들어둔 개수를 저장하는 변수를 선언해두면 만들고자 하는 인스턴스의 개수를 제한할 수 있다.
Structural - Composite
구조 (structural) 패턴은 말 그대로 클래스와 오브젝트들의 구성 방법(구조)을 결정하는 패턴이다.
객체지향에서 제공하는 상속, aggregation / composition 과 같은 여러 관계를 활용하여 요구사항을 해결한다.
구조 패턴의 대표적인 예시 중 하나는 Composite 패턴이다.
이 패턴이 해결하려는 문제 상황은 어떤 객체가 있을 때, 그 객체가 단일 객체든 복합 객체든 상관없이 동일하게 다루고 싶은 상황이다.
예를 들어 파일시스템을 생각해볼 수 있다.
다음과 같은 IS-A 관계가 있다고 해보자.
VideoFile 과 TextFile 은 FileSystemComponent 를 상속받고 있다.
FileSystemComponent 의 delete() 메서드는 virtual 함수이며, 그 구현은 VideoFile, TextFile 에서 하고 있다.
우리는 이 FileSystemComponent가 단일객체든 복합(composite)객체든 동일하게 'delete()' 인터페이스를 가지도록 만들고 싶다.
FileSystemComponent 가 단일 객체라는 것은 쉽게 이해가 간다. (서브 클래스의 단일 인스턴스)
그런데 FileSystemComponent 가 복합객체라는 것은 무슨 말일까?
바로 VideoFile 과 TextFile 인스턴스가 뒤죽박죽 섞여있다는 뜻이다.
그게 어떻게 가능할까?
위 그림과 같이 FileSystemComponent의 서브 클래스 인스턴스를 여러 개 가질 수 있는(has-a) Directory 클래스를 생각해보자.
이 클래스 안에는 VideoFile 과 TextFile 이 뒤죽박죽 섞여있으며,
이 클래스에도 동일하게 delete() 라는 인터페이스가 존재하는 상황이다.
그러면 나이브하게 구현한다면, Directory 클래스에는 FileSystemComponent 타입의 배열을 선언해서 VideoFile 과 TextFile 인스턴스들을 섞어서 가지고 있을 것이다.
이제 우리가 원하는 것은 VideoFile 하나든, TextFile 하나든, 이 두가지 타입을 뒤죽박죽 섞어서 가지고 있는 Directory 든 모두 같은 delete() 인터페이스를 정의해서 하나의 인터페이스로 다루고자 한다.
그러면 다음과 같이 클래스 구조를 설정함으로서 이 문제를 해결할 수 있다.
OS 에서 FileSystem 을 다룰 때 FileSystemComponent 들로 다룬다.
FileSystemComponent 에는 VideoFile, TextFile, Directory 가 있으며,
이때 Directory 는 FileSystemComponent 의 인스턴스 여러개를 갖고 있다.
Directory 의 상위 클래스가 FileSystemComponent 가 되었기 때문에,
addFile(), removeFile() 메서드의 정의 역시 부모 클래스에 함께 두어야 한다.
Directory 안에 Directory 를 넣는 것도 가능하다는 것을 생각할 때
자신이 가지고 있는 Directory의 addFile() 메서드를 호출하려면 FileSystemComponent 에도 addFile 이 정의되어있어야 한다.
그리고 Directory 클래스의 delete() 메서드 동작은 위와 같이 로직을 구현할 수 있다.
그냥 collection 인스턴스를 순회하면서 각 인스턴스의 delete() 메서드를 호출하는 것이다.
따라서 FileSystemComponent 는 TextFile, VideoFile, Directory 3가지 중 하나인데
Directory 안에는 다시 TextFile, VideoFile, Directory 인스턴스가 섞여서 들어갈 수 있고,
다시 그 안에 있는 Directory 에는 TextFile, VideoFile, Directory 인스턴스가 섞여서 들어갈 수 있는
마치 트리구조같은 재귀적인 형태를 구성할 수 있게된다.
만약 디렉토리 안에 TextFile, VideoFile 만 존재한다면 더 이상 재귀적인 구조가 반복되지 않는다.
그래서 Composite 패턴을 일반화할 때 TextFile, VideoFile 을 가리켜 Leaf 라고 부르기도 한다.
Directory 같이 Leaf 를 가질 수 있는 복합 클래스는 Composite 라고 부른다.
Composite 에 정의된 Leaf 와 동일한 메서드의 동작은, 컬렉션을 순회하면서 그 동일한 메서드를 호출하는 형태의 로직을 갖는다.
그리고 FileSystemComponent 같이 이들을 모두 아우르는 상위클래스는 Component 라고 부른다.
결과적으로는 Component의 실제 타입이 composite이든 composite가 아니든(leaf) 동일한 인터페이스(composite) 를 제공하는데 성공했다.
즉, 외부에서는 FileSystemComponent 를 다룰 때, 이 타입의 실제 타입이 단일 객체든 composite 객체든 신경쓰지 않고 같은 방식으로 다룰 수 있게 된 것이다.
따라서 이 패턴을 통해 우리는 polymorphism 을 설계할 때, 구현 클래스가 단일 인스턴스가 아닌, 복합 인스턴스를 가진 인스턴스를 가질 수 있도록 하는 방법도 배울 수 있다.
Behavioural - State
마지막으로 행위(behavioural) 패턴은 클래스에 책임을 부여하거나, 알고리즘을 설계할 때 발생하는 문제를 해결하는 패턴이다.
문제를 해결할 때는 오브젝트와 클래스 사이에 정적인 관계를 두거나, 오브젝트와 오브젝트 사이의 커뮤니케이션 방법을 정의하여 해결한다.
행위 패턴의 예시로는 State 패턴이 있다.
이 패턴은 State machine 을 디자인 패턴으로 풀어낸 것과 같다.
(state machine 을 객체지향적으로 풀어낸 것)
예를 들어 배달앱의 주문 Order 클래스가 있다고 생각해보자.
주문의 상태는 결제 완료, 조리중, 배달중, 배달완료 4가지 상태를 가질 수 있다.
이때 Order 인스턴스의 일부 operation은 각 상태에 따라서 다르게 동작한다고 해보자.
그러면 생각할 수 있는 제일 단순한 구현 방법은 swtich-case 구문을 사용하여 구현하는 방법일 것이다.
(operation 호출을 event 로, operation 의 구현을 action & transition 으로 보는 것과 같다)
하지만 이렇게 조건문을 사용해서 구현하면 유지보수하기가 매우 힘들다.
따라서 이를 객체지향적으로 더 깔끔하게 다음과 같이 구현할 수 있다.
먼저 Order 의 상태를 나타낼 OrderState 클래스(인터페이스)를 정의한다.
그리고 이 클래스의 서브 클래스(구현 클래스)로 4가지 상태 클래스를 정의한다.
OrderState 클래스와 4가지 서브클래스는 4가지 상태에 따라서 다르게 동작하던 메서드들을 모두 갖는다.
또한 Order 클래스 입장에서는 OrderState 만 보이고, 그 내부의 각 상태들은 어떤 것들이 있는지 전혀 알지 못한다.
클래스 다이어그램으로는 이렇게 그릴 수 있다.
finishCooking(), getStateName(), calcExpectedTime() 메서드는 4가지 상태에 따라 다르게 구현되는 메서드이다.
이 구현을 case 문과 같은 조건문으로 처리하지 않고, OrderState 의 각 상태별 구현 클래스의 동일이름 메서드로 구현한다.
예를 들어 Order 클래스에서 calcExpectedTime() 메서드를 호출했을 때의 처리 로직을 시퀀스 다이어그램으로 그려보면 다음과 같다.
기존에는 switch 문을 사용하여 케이스를 나눠 로직을 처리했다면
State 패턴을 적용한 결과 항상 OrderState 클래스의 calcExpectedTime() 메서드를 호출하는 방식으로 더 간단하게 구현할 수 있다.
이 메서드가 호출되면 실제로는 현재 상태에 맞는 구현 클래스의 calcExpectedTime() 메서드가 호출될 것이다.
(위 그림에서는 예시로 Paid 상태일 때를 보여준다)
그리고 메서드의 로직이 끝나면, 액티베이션이 끝나기 전에 Order 클래스의 changeState(next) 를 호출하여 상태를 변경(transition)한다.
(즉, OrderState 의 구현 클래스들은 Order 클래스의 인스턴스 참조를 갖고 있다)
transition은 Order 클래스의 currentState 인스턴스를 변경된 상태 클래스의 인스턴스 참조로 변경하는 것과 같다.
이제 Order 클래스는 Order 의 상태에 따른 실제 구현 로직과 완벽하게 분리되어있다.
따라서 각 상태별 실제 action 에 변화가 생긴 경우, 그 상태 클래스의 구현부로 가서 메서드 하나의 로직을 수정하면 끝이다.
만약 새로운 상태가 추가된다면, Order 클래스는 건들 필요 없이, OrderState 를 상속받는 새로운 상태 구현 클래스를 추가하면 된다.
지금까지 디자인 패턴의 3가지 분류와 각 분류별 실제 패턴 예시를 한 가지씩 살펴보았다.
디자인 패턴은 확실히 문제를 해결하는 기발한 해결책들이다.
하지만 그렇다고 디자인 패턴을 무조건 적용해서는 안된다.
디자인 패턴을 적용하기 전에 다음 사항을 먼저 고려해보자.
1. 더 간단한 해결책이 없는가?
디자인 패턴은 기발하지만 복잡한 경우가 많다.
디자인 패턴을 적용하지 않고 더 간단하게 해결할 수 있다면 그것이 더 좋은 방법이다.
2. 현재 문제 컨텍스트와 디자인 패턴이 해결하는 문제의 컨텍스트가 동일한가?
디자인 패턴은 특정 문제 상황에서 적용하기 좋은 해결책을 제시한 것이다.
현재 문제 상황에 맞지도 않는데, 디자인 패턴을 억지로 끼워맞춰서 적용하는 것은 좋지 않다.
3. 해결책을 적용한 결과 문제가 정말 해결되는가?
다음으로 디자인 패턴을 선택한 후에는 다음 순서대로 디자인 패턴을 적용하면 된다.
1. 디자인 패턴의 general form (overview) 를 읽어본다.
위에서는 구체적인 예시로 다이어그램을 그렸지만, 보통 일반적인 형태로 다이어그램이 그려져있다.
이를 먼저 읽어보자.
2. 패턴에 등장하는 구조, 클래스, collaboration 을 자세히 학습한다.
3. 샘플 코드를 확인하고, general form 에 적혀있는 클래스 이름, operation 이름 등을 현재 어플리케이션에 맞게 수정한다.
4. 구조에 맞게 구현한다.
'CS > 소프트웨어공학' 카테고리의 다른 글
[소프트웨어공학] 19. System Design and Architecture (0) | 2025.06.08 |
---|---|
[소프트웨어공학] 18. Refining the Requirements Model (1) | 2025.06.07 |
[소프트웨어공학] 17. State Machine 예제 (수정예정) (0) | 2025.06.07 |
[소프트웨어공학] 16. Designing Boundary Class (0) | 2025.06.07 |
[소프트웨어공학] 15. Detailed Design (2) | 2025.06.05 |