[CS] GoF 디자인 패턴 — 생성·구조·행위 패턴 완전 정복
목차
디자인 패턴 개요
일종의 설계 기법이며, 설계 방법이다.
목적: SW 재사용성, 호환성, 유지 보수성을 보장.
디자인 패턴은 아이디어이며, 특정한 구현이 아니다. 프로젝트에 항상 적용해야 하는 것은 아니지만, 추후 재사용·호환·유지 보수 시 발생하는 문제 해결을 예방하기 위해 패턴을 만들어 둔 것이다.
원칙 — SOLID (객체지향 설계 원칙)
| 원칙 | 설명 |
|---|---|
| SRP (Single Responsibility Principle) | 하나의 클래스는 하나의 역할만 해야 함 |
| OCP (Open-Closed Principle) | 확장(상속)에는 열려있고, 수정에는 닫혀 있어야 함 |
| LSP (Liskov Substitution Principle) | 자식이 부모의 자리에 항상 교체될 수 있어야 함 |
| ISP (Interface Segregation Principle) | 인터페이스가 잘 분리되어서, 클래스가 꼭 필요한 인터페이스만 구현하도록 해야 함 |
| DIP (Dependency Inversion Property) | 상위 모듈이 하위 모듈에 의존하면 안 됨. 둘 다 추상화에 의존하며, 추상화는 세부 사항에 의존하면 안 됨 |
분류 (중요)
3가지 패턴의 목적을 이해하는 것이 핵심이다.
| 분류 | 목적 | 예시 |
|---|---|---|
| 생성 패턴 (Creational) | 객체의 생성 방식 결정 | DBConnection을 관리하는 Instance를 하나만 만들 수 있도록 제한하여 불필요한 연결을 막음 |
| 구조 패턴 (Structural) | 객체간의 관계를 조직 | 2개의 인터페이스가 서로 호환이 되지 않을 때, 새로운 클래스를 만들어 연결시킬 수 있도록 함 |
| 행위 패턴 (Behavioral) | 객체의 행위를 조직·관리·연합 | 하위 클래스에서 구현해야 하는 함수 및 알고리즘들을 미리 선언하여, 상속 시 이를 필수로 구현하도록 함 |
어댑터 패턴 (Adapter Pattern)
클래스를 바로 사용할 수 없는 경우가 있다. (다른 곳에서 개발했거나, 수정할 수 없을 때)
중간에서 변환 역할을 해주는 클래스가 필요한데, 이것이 어댑터 패턴이다.
- 사용 방법: 상속
- 호환되지 않은 인터페이스를 사용하는 클라이언트 그대로 활용 가능
- 향후 인터페이스가 바뀌더라도, 변경 내역은 어댑터에 캡슐화되므로 클라이언트는 바뀔 필요 없음
클래스 다이어그램

아이폰의 이어폰을 생각해보자. 일반 이어폰 잭을 아이폰에 사용하려면 잭 자체가 맞지 않는다. 따라서 어댑터를 따로 구매해서 연결해야 한다.
이처럼 어댑터는 필요로 하는 인터페이스로 바꿔주는 역할을 한다.

업체에서 제공한 클래스가 기존 시스템에 맞지 않으면 기존 시스템을 수정할 것이 아니라, 어댑터를 활용해 유연하게 해결하자.
코드로 어댑터 패턴 이해하기
오리와 칠면조 인터페이스 생성
만약 오리 객체가 부족해서 칠면조 객체를 대신 사용해야 한다면?
두 객체는 인터페이스가 다르므로, 바로 칠면조 객체를 사용하는 것은 불가능함
따라서 칠면조 어댑터를 생성해서 활용해야 한다
- Duck.java
package AdapterPattern;
public interface Duck {
public void quack();
public void fly();
}
- Turkey.java
package AdapterPattern;
public interface Turkey {
public void gobble();
public void fly();
}
- WildTurkey.java
package AdapterPattern;
public class WildTurkey implements Turkey {
@Override
public void gobble() {
System.out.println("Gobble gobble");
}
@Override
public void fly() {
System.out.println("I'm flying a short distance");
}
}
- TurkeyAdapter.java
package AdapterPattern;
public class TurkeyAdapter implements Duck {
Turkey turkey;
public TurkeyAdapter(Turkey turkey) {
this.turkey = turkey;
}
@Override
public void quack() {
turkey.gobble();
}
@Override
public void fly() {
turkey.fly();
}
}
- DuckTest.java
package AdapterPattern;
public class DuckTest {
public static void main(String[] args) {
MallardDuck duck = new MallardDuck();
WildTurkey turkey = new WildTurkey();
Duck turkeyAdapter = new TurkeyAdapter(turkey);
System.out.println("The turkey says...");
turkey.gobble();
turkey.fly();
System.out.println("The Duck says...");
testDuck(duck);
System.out.println("The TurkeyAdapter says...");
testDuck(turkeyAdapter);
}
public static void testDuck(Duck duck) {
duck.quack();
duck.fly();
}
}
클래스 다이어그램에서 Target은 오리에 해당하며, Adapter는 칠면조라고 생각하면 된다.
싱글톤 패턴 (Singleton Pattern)
애플리케이션이 시작될 때, 어떤 클래스가 최초 한 번만 메모리를 할당(static)하고 해당 메모리에 인스턴스를 만들어 사용하는 패턴.
싱글톤 패턴은 ‘하나’의 인스턴스만 생성하여 사용하는 디자인 패턴이다.
인스턴스가 필요할 때 똑같은 인스턴스를 만들지 않고 기존의 인스턴스를 활용한다.
생성자가 여러 번 호출되도, 실제로 생성되는 객체는 하나이며 최초로 생성된 이후에 호출된 생성자는 이미 생성한 객체를 반환시키도록 만드는 것이다.
(Java에서는 생성자를 private으로 선언해 다른 곳에서 생성하지 못하도록 만들고, getInstance() 메소드를 통해 받아서 사용하도록 구현한다)
왜 쓰나요?
- 한 번의
new를 통해 객체를 생성하므로 메모리 낭비를 방지할 수 있다. - 싱글톤으로 구현한 인스턴스는 ‘전역’이므로, 다른 클래스의 인스턴스들이 데이터를 공유하는 것이 가능하다.
많이 사용하는 경우
- 데이터베이스에서 커넥션풀, 스레드풀, 캐시, 로그 기록 객체 등
- 안드로이드 앱: 각 액티비티들이나 클래스마다 주요 클래스들을 하나하나 전달하는 게 번거롭기 때문에 싱글톤 클래스를 만들어 어디서든 접근하도록 설계
단점
- 싱글톤 인스턴스가 너무 많은 일을 하거나 많은 데이터를 공유시키면 다른 클래스들 간의 결합도가 높아져 개방-폐쇄 원칙(OCP)에 위배된다.
- 결합도가 높아지면 유지보수가 힘들고 테스트도 원활하게 진행할 수 없다.
- 멀티 스레드 환경에서 동기화 처리를 하지 않았을 때, 인스턴스가 2개 생성되는 문제가 발생할 수 있다.
멀티스레드 환경에서 안전한 싱글톤 만드는 법
1. Lazy Initialization (게으른 초기화)
public class ThreadSafe_Lazy_Initialization{
private static ThreadSafe_Lazy_Initialization instance;
private ThreadSafe_Lazy_Initialization(){}
public static synchronized ThreadSafe_Lazy_Initialization getInstance(){
if(instance == null){
instance = new ThreadSafe_Lazy_Initialization();
}
return instance;
}
}
private static으로 인스턴스 변수 선언private으로 생성자를 만들어 외부에서의 생성을 막음synchronized동기화를 활용해 스레드를 안전하게 만듦- 단점:
synchronized는 큰 성능저하를 발생시키므로 권장하지 않는 방법
2. Lazy Initialization + Double-checked Locking
1번의 성능저하를 완화시키는 방법.
public class ThreadSafe_Lazy_Initialization{
private volatile static ThreadSafe_Lazy_Initialization instance;
private ThreadSafe_Lazy_Initialization(){}
public static ThreadSafe_Lazy_Initialization getInstance(){
if(instance == null) {
synchronized (ThreadSafe_Lazy_Initialization.class){
if(instance == null){
instance = new ThreadSafe_Lazy_Initialization();
}
}
}
return instance;
}
}
먼저 조건문으로 인스턴스 존재 여부를 확인한 다음 두 번째 조건문에서 synchronized를 통해 동기화를 시켜 인스턴스를 생성하는 방법.
스레드를 안전하게 만들면서, 처음 생성 이후에는 synchronized를 실행하지 않기 때문에 성능저하 완화가 가능하다. 단 완전히 완벽한 방법은 아니다.
3. Initialization on demand holder idiom (holder에 의한 초기화)
클래스 안에 클래스(holder)를 두어 JVM의 클래스 로더 메커니즘과 클래스가 로드되는 시점을 이용한 방법. 실제로 가장 많이 사용되는 방법이다.
public class Something {
private Something() {
}
private static class LazyHolder {
public static final Something INSTANCE = new Something();
}
public static Something getInstance() {
return LazyHolder.INSTANCE;
}
}
JVM의 클래스 초기화 과정에서 보장되는 원자적 특성을 이용해 싱글톤의 초기화 문제에 대한 책임을 JVM에게 위임한다.
클래스 안에 선언한 holder에서 선언된 인스턴스는 static이기 때문에 클래스 로딩 시점에서 한 번만 호출된다. 또한 final을 사용해서 다시 값이 할당되지 않도록 만든다.
템플릿 메소드 패턴 (Template Method Pattern)
- 로직을 단계별로 나눠야 하는 상황에서 적용한다.
- 단계별로 나눈 로직들이 앞으로 수정될 가능성이 있을 경우 더 효율적이다.
조건
- 클래스는 추상(abstract)으로 만든다.
- 단계를 진행하는 메소드는 수정이 불가능하도록
final키워드를 추가한다. - 각 단계들은 외부는 막고, 자식들만 활용할 수 있도록
protected로 선언한다.
피자를 만들 때는 크게 반죽 → 토핑 → 굽기 3단계로 이루어져 있다. 이 단계는 항상 유지되며, 피자 종류에 따라 토핑만 바꾸면 된다.
abstract class Pizza {
protected void 반죽() { System.out.println("반죽!"); }
abstract void 토핑() {}
protected void 굽기() { System.out.println("굽기!"); }
final void makePizza() { // 상속 받은 클래스에서 수정 불가
this.반죽();
this.토핑();
this.굽기();
}
}
class PotatoPizza extends Pizza {
@Override
void 토핑() {
System.out.println("고구마 넣기!");
}
}
class TomatoPizza extends Pizza {
@Override
void 토핑() {
System.out.println("토마토 넣기!");
}
}
abstract 키워드를 통해 자식 클래스에서는 선택적으로 메소드를 오버라이드할 수 있게 된다.
abstract와 Interface의 차이
| 구분 | 사용 시점 |
|---|---|
| abstract | 부모의 기능을 자식에서 확장시켜나가고 싶을 때 |
| interface | 해당 클래스가 가진 함수의 기능을 활용하고 싶을 때 |
abstract는 다중 상속이 안된다. 상황에 맞게 활용하자.
팩토리 메소드 패턴 (Factory Method Pattern)
객체를 만드는 부분을 Sub class에 맡기는 패턴
Robot (추상 클래스)
- SuperRobot
- PowerRobot
RobotFactory (추상 클래스)
- SuperRobotFactory
- ModifiedSuperRobotFactory
Robot이라는 클래스를 RobotFactory에서 생성한다.
- RobotFactory 클래스
public abstract class RobotFactory {
abstract Robot createRobot(String name);
}
- SuperRobotFactory 클래스
public class SuperRobotFactory extends RobotFactory {
@Override
Robot createRobot(String name) {
switch(name) {
case "super" :
return new SuperRobot();
case "power" :
return new PowerRobot();
}
return null;
}
}
생성하는 클래스를 따로 만들어, factory 클래스를 상속하기 때문에 반드시 createRobot을 선언해야 한다. name으로 건너오는 값에 따라서 생성되는 Robot이 다르게 설계된다.
정리하면, 생성하는 객체를 별도로 둔다. 그리고 그 객체에 넘어오는 값에 따라서 다른 로봇(피자)를 만들어 낸다.
옵저버 패턴 (Observer Pattern)
- 상태를 가지고 있는 주체 객체 & 상태의 변경을 알아야 하는 관찰 객체 (1:1 or 1:N 관계)
서로의 정보를 주고받는 과정에서 정보의 단위가 클수록, 객체들의 규모가 클수록 복잡성이 증가하게 된다. 이때 가이드라인을 제시해줄 수 있는 것이 옵저버 패턴이다.
주체 객체와 관찰 객체의 예
- 잡지사 : 구독자
- 우유배달업체 : 고객
관계가 지속되다가 정보를 원하지 않으면 해제할 수도 있다. (잡지 구독을 취소하거나 우유 배달을 중지하는 것처럼)
이때 객체와의 관계를 맺고 끊는 상태 변경 정보를 Observer에 알려줘서 관리하는 것을 말한다.

- Publisher 인터페이스 — Observer들을 관리하는 메소드를 가지고 있음 (등록, 제외, 알림)
public interface Publisher {
public void add(Observer observer);
public void delete(Observer observer);
public void notifyObserver();
}
- Observer 인터페이스 — 정보를 업데이트(update)
public interface Observer {
public void update(String title, String news); }
- NewsMachine 클래스 — Publisher를 구현한 클래스로, 정보를 제공해주는 퍼블리셔가 됨
public class NewsMachine implements Publisher {
private ArrayList<Observer> observers;
private String title;
private String news;
public NewsMachine() {
observers = new ArrayList<>();
}
@Override public void add(Observer observer) {
observers.add(observer);
}
@Override public void delete(Observer observer) {
int index = observers.indexOf(observer);
observers.remove(index);
}
@Override public void notifyObserver() {
for(Observer observer : observers) {
observer.update(title, news);
}
}
public void setNewsInfo(String title, String news) {
this.title = title;
this.news = news;
notifyObserver();
}
public String getTitle() { return title; } public String getNews() { return news; }
}
- AnnualSubscriber, EventSubscriber 클래스 — Observer를 구현한 클래스들로,
notifyObserver()를 호출하면서 알려줄 때마다update가 호출됨
public class EventSubscriber implements Observer {
private String newsString;
private Publisher publisher;
public EventSubscriber(Publisher publisher) {
this.publisher = publisher;
publisher.add(this);
}
@Override
public void update(String title, String news) {
newsString = title + " " + news;
display();
}
public void withdraw() {
publisher.delete(this);
}
public void display() {
System.out.println("이벤트 유저");
System.out.println(newsString);
}
}
Java에는 옵저버 패턴을 적용한 Observer 인터페이스와 Observable 클래스를 기본적으로 제공한다. 단, Observable은 클래스로 구현되어 있기 때문에 사용하려면 상속을 해야 하며, 다른 상속을 함께 이용할 수 없는 단점이 존재한다.
정리: 옵저버 패턴은 한 객체의 상태가 바뀌면 그 객체에 의존하는 다른 객체들에게 연락이 가고, 자동으로 정보가 갱신되는 1:N 관계(혹은 1:1) 를 정의한다.
인터페이스를 통해 연결하여 느슨한 결합성을 유지하며, Publisher와 Observer 인터페이스를 적용한다.
안드로이드 개발 시, OnClickListener와 같은 것들이 옵저버 패턴이 적용된 것이다. (버튼(Publisher)을 클릭했을 때 상태 변화를 옵저버인 OnClickListener로 알려주도록 함)
스트레티지 패턴 (Strategy Pattern)
- 어떤 동작을 하는 로직을 정의하고, 이것들을 하나로 묶어(캡슐화) 관리하는 패턴
- 새로운 로직을 추가하거나 변경할 때, 한번에 효율적으로 변경이 가능하다.
[ 슈팅 게임을 설계하시오 ]
유닛 종류 : 전투기, 헬리콥터
유닛들은 미사일을 발사할 수 있다.
전투기는 직선 미사일을, 헬리콥터는 유도 미사일을 발사한다.
필살기로는 폭탄이 있는데, 전투기에는 있고 헬리콥터에는 없다.
Strategy Pattern을 적용한 설계는 아래와 같다.

-
상속은 무분별한 소스 중복이 일어날 수 있으므로, 컴포지션을 활용한다. (인터페이스와 로직의 클래스와의 관계를 컴포지션하고, 유닛에서 상황에 맞는 로직을 쓰게끔 유도하는 것)
-
미사일을 쏘는 것과 폭탄을 사용하는 것을 캡슐화하자.
ShootAction과BombAction으로 인터페이스를 선언하고, 각자 필요한 로직을 클래스로 만들어implement한다. -
전투기와 헬리콥터를 묶을
Unit추상 클래스를 만들자. Unit에는 공통적으로 사용되는 메서드들이 들어있고, 미사일과 폭탄을 선언하기 위해 variable로 인터페이스들을 선언한다.
전투기 예시:
class Fighter extends Unit {
private ShootAction shootAction;
private BombAction bombAction;
public Fighter() {
shootAction = new OneWayMissle();
bombAction = new SpreadBomb();
}
}
Fighter.doAttack()을 호출하면, OneWayMissle의 attack()이 호출될 것이다.
정리: Strategy Pattern을 활용하면 로직을 독립적으로 관리하는 것이 편해진다.
로직에 들어가는 ‘행동’을 클래스로 선언하고, 인터페이스와 연결하는 방식으로 구성하는 것!
컴포지트 패턴 (Composite Pattern)
목적: object의 hierarchies(계층 구조)를 표현하고, 각각의 object를 독립적으로 동일한 인터페이스를 통해 처리할 수 있게 한다.

Leaf 클래스와 Composite 클래스를 같은 인터페이스로 제어하기 위해 Component abstract 클래스를 생성한다.
- Component 클래스
public class Component {
public void operation() {
throw new UnsupportedOperationException();
}
public void add(Component component) {
throw new UnsupportedOperationException();
}
public void remove(Component component) {
throw new UnsupportedOperationException();
}
public Component getChild(int i) {
throw new UnsupportedOperationException();
}
}
Leaf 클래스에서 사용하지 않는 메소드 호출 시 exception을 발생시키게 구현하였다.
- Leaf 클래스
public class Leaf extends Component {
String name;
public Leaf(String name) {
...
}
public void operation() {
.. something ...
}
}
- Composite 클래스
public class Composite extends Component {
ArrayList components = new ArrayList();
String name;
public Composite(String name) {
....
}
public void operation() {
Iterator iter = components.iterator();
while (iter.hasNext()) {
Component component = (Component)iter.next();
component.operation();
}
}
public void add(Component component) {
components.add(component);
}
public void remove(Component component) {
components.remove(component);
}
public Component getChild(int i) {
return (Component)components.get(i);
}
}
구현 시 고려해야 할 사항
- 위의 코드는 parent만이 child를 참조할 수 있다. 구현 이전에 child가 parent를 참조해야 하는지 고려해야 한다.
- 어떤 클래스가 children을 관리할 것인가?
Children 관리를 위한 2가지 방식
| 방식 | 다이어그램 | 장점 | 단점 |
|---|---|---|---|
| Transparency | ![]() | Leaf 클래스와 Composite 클래스를 구분할 필요 없이 Component Class로 생각할 수 있다 | Leaf 클래스가 children 관리 함수 호출 시 런타임에 exception이 발생한다 |
| Safety | ![]() | Leaf 클래스가 children 관리 함수 호출 시 컴파일 타임에 문제를 확인할 수 있다 | Leaf 클래스와 Composite 클래스를 구분하여야 한다 |
관련 패턴
| 패턴 | 공통점 | 차이점 |
|---|---|---|
| Decorator | composition이 재귀적으로 발생한다 | decorator 패턴은 responsibilities를 추가하는 것이 목표이지만 composite 패턴은 hierarchy를 표현하기 위해 사용된다 |
| Iterator | aggregate object를 순차적으로 접근한다 | — |
SOLID 설계 원칙 심화
Design Smells — 나쁜 디자인의 증상
| 증상 | 설명 |
|---|---|
| Rigidity (경직성) | 하나의 변경을 위해서 다른 것들을 변경해야 할 때. 경직성이 높다면 관리자는 non-critical한 문제가 발생했을 때 수정을 요청하기가 두려워진다 |
| Fragility (취약성) | 어떤 부분을 수정하였는데 관련이 없는 다른 부분에 영향을 준다. 관리하는 비용이 커지며 시스템의 credibility(신뢰성)를 잃는다 |
| Immobility (부동성) | 재사용하기 위해서 시스템을 분리해서 컴포넌트를 만드는 것이 어렵다 |
| Viscosity (점착성) | 디자인을 유지하는 방법보다 핵(hack)을 추가하는 것이 더 쉬운 경우 (디자인 점착성), 또는 컴파일 시간이 길어 작은 규모로 수정하려 하는 경우 (환경 점착성) |
SRP (Single Responsibility Principle)
A class should have one, and only one, reason to change.
클래스는 오직 하나의 이유로 수정이 되어야 한다.
SRP를 위반하는 예제:

Register 클래스가 Student 클래스에 dependency를 가지고 있는 모습이다.

Student 클래스가 바뀌어서 Register 클래스가 영향을 받는다. 정렬을 위한 변경이 관련없는 Register 클래스에 영향을 끼쳤기 때문에 SRP를 위반한다.

각각의 정렬 방식을 가진 클래스를 새로 생성하고 Client는 새로 생긴 클래스를 호출한다.
SRP는 같은 목적으로 responsibility를 가지는 cohesion(응집도) 과 관련이 깊다.
OCP (Open Closed Principle)
Software entities should be open for extension but closed for modification.
자신의 확장에는 열려있고 주변의 변화에는 닫혀 있어야 한다.

void incAll(Employee[] emps) {
for (int i=0; i<emps.size(); i++) {
if(emps[i].empType == FACULTY)
incFacultySalary((FACULTY)emps[i])
else if(emps[i].empType == STAFF)
incStaffSalary((STAFF)emps[i])
else if(emps[i].empType == SECRETARY)
incSecretarySalary((SECRETARY)emps[i])
}
}
- Rigid — 새로운 employee type이 계속 요구된다.
- Fragile — 많은 if/else 구문과 코드를 찾기 어렵다.

incAll() 함수를 통해 문제를 해결한다.
LSP (Liskov Substitution Principle)
Subtypes must be substitutable for their base types.
base 클래스에서 파생된 클래스는 base 클래스를 대체해서 사용할 수 있어야 한다.
아래는 Java 라이브러리의 Date 클래스 예시:

java.util.Date date = new java.util.Date();
int dateValue = date.getDate(); // Okay
date = new java.sql.Time(10,10,10);
dataValue = date.getDate(); // throws IllegalArgumentException
Inheritance vs. Composition

List의 Implementation을 재사용하게 된다면 inheritance보다 object composition을 사용하는 것을 추천한다. Queue 클래스가 List 클래스를 inheritance 한다면 LSP를 위반하게 된다.
ISP (Interface Segregation Principle)
Clients should not be forced to depend on methods they do not use.
사용하지 않는 메소드에 의존하면 안 된다.

Roast Application은 getName(), getSSN() 메소드만을 사용하고 Account Application은 getInvoice(), postPayment() 메소드만을 사용한다.

클래스에 맞는 interface를 만들어서 제공하면 ISP 문제를 해결할 수 있다.
DIP (Dependency Inversion Principle)
High-level modules should not depend on low-level modules. Both should depend on abstractions.
자신(high level module)보다 변하기 쉬운 모듈(low level module)에 의존해서는 안 된다.

Program 클래스는 Module 클래스에 dependency를 가지고 있으며 Module 클래스는 Function 클래스에 의존하고 있다.

Module 클래스를 인터페이스 클래스로 변경하면 dependency가 inversion된 모습을 볼 수 있다.
DIP는 dependency를 inversion하는 것뿐 아니라 ownership 또한 inversion한다.

