본문 바로가기
better code

헤드퍼스트 디자인패턴

by marble25 2023. 7. 19.

디자인 패턴

패턴은 특정 컨텍스트 내에서 주어진 문제의 해결책이다. 패턴은 반복적으로 등장하는 문제에 적용할 수 있어야 한다.

  • 디자인 패턴은 개발자 사이에서 서로 모두 이해할 수 있는 용어를 제공한다. 간단한 단어로 많은 얘기를 할 수 있게 된다.
  • 꼭 필요하지 않은 패턴은 빼버린다.

객체지향 원칙

  • 바뀌는 부분은 캡슐화하자.
  • 상속보다는 구성(composition)을 활용하자.
  • 인터페이스에 집중하자.
  • 클래스는 확장에는 열려 있어야 하지만 변경에는 닫혀 있어야 한다. (OCP)
  • 의존성 뒤집기 원칙: 구체적인 클래스에 의존하지 않고 추상적인 것에 의존하자.
    • 변수에 구상 클래스의 레퍼런스를 저장하지 않아야 한다.
    • 구상 클래스에서 유도하지 않고, 인터페이스나 추상 클래스에서 유도하자.
    • 베이스 클래스에 구현되어 있는 메소드를 오버라이드하지 말자.
  • 최소 지식 원칙(= 데메테르의 법칙): 객체 사이의 상호작용은 가까운 ‘친구’ 사이에서만 허용하자.
  • 할리우드 원칙: 저수준 요소는 절대 고수준 구성 요소를 직접 호출할 수 없다.
    • 의존성 부패(의존성 사이클) 해결할 수 있다.
  • 단일 역할 원칙: 어떤 클래스가 바뀌는 이유는 하나뿐이어야 한다.

전략 패턴

전략 패턴은 알고리즘군을 정의하고 캡슐화해서 각각의 알고리즘군을 수정해서 쓸 수 있게 한다.

옵저버 패턴

옵저버 패턴은 한 객체가 바뀌면 그 객체에 의존하는 다른 객체에 연락이 가고 자동으로 내용이 갱신되는 방식으로 의존성을 정의한다.

느슨한 결합은 객체들이 상호작용할 수 있지만, 서로를 잘 모르는 관계를 의미한다. 느슨한 결합을 이용하면 유연성이 매우 좋아진다. 옵저버 패턴에서 느슨한 결합을 적극적으로 사용한다.

  • 주제나 옵저버가 달라지더라도 서로에게 영향을 끼치지 않는다.
  • 새로운 옵저버를 추가 / 삭제할 때에도 주제를 변경할 필요가 없다.

대부분의 경우 푸시 방식보다 풀 방식이 좋다. 현재는 Subject가 옵저버의 update 메소드를 호출할 때 데이터를 실어서 보내는 방식인데, 그보다는 update 메소드를 호출할 때 Subject의 상태를 옵저버에서 참조하도록 구현하는게 좋다.

→ 데이터 입장에서는 풀이지만, 신호를 보내는 방식은 여전히 푸시 방식이다. 풀 방식으로 바꾸려면 특정 시간마다 모든 옵저버가 각각 subject의 상태를 체크해야 한다. 어떤 것이 좋은지는 상황에 따라 다를 것 같다.

데코레이터 패턴

데코레이터 패턴은 객체에 추가 요소를 동적으로 더할 수 있다. 이때, 데코레이터는 자신이 장식하고 이

  • 한 객체를 여러 개의 데코레이터로 감쌀 수 있다.
  • 유연하게 기능을 확장할 수 있다.
public abstract class Beverage {
	String description = "제목 없음";
	
	public String getDescription() {
		return description;
	}

	public abstract double cost();
}

public abstract class CondimentDecorator extends Beverage {
	Beverage beverage;
	public abstract String getDescription();
}
public class Espresso extends Beverage {
	public Espresso() {
		description = "에스프레소";
	}
	
	public double cost() {
		return 1.99;
	}
}

public class Mocha extends CondimentDecorator {
	public Mocha(Beverage beverage) {
		this.beverage = beverage;
	}

	public String getDescription() {
		return beverage.getDescription() + ", 모카";
	}

	public double cost() {
		return beverage.cost() + .20;
	}
}
Beverage beverage = new Mocha(new Mocha(new Espresso()));

위의 코드와 같이 여러 번 데코레이터를 적용해도 상관 없는것이 데코레이터 패턴의 큰 특징이다. 데코레이터는 데코레이터로 감싸는 객체의 형식과 같다.

→ 데코레이터 패턴을 쓰면 관리해야 하는 객체가 늘어나기 때문에 실수할 가능성도 높아진다. 하지만 팩토리나 빌더 같은 다른 패턴으로 데코레이터를 만들면 실수 확률이 줄어든다.

→ 구성 요소의 클라이언트는 데코레이터의 존재를 알 수 없다. 클라이언트가 구성 요소의 구체적인 형식을 의존하고 있다면 문제가 생길 수 있다.

팩토리 패턴

팩토리 메소드 패턴

객체를 생성할 때 필요한 인터페이스를 만든다. 클래스 인스턴스 만드는 일은 서브클래스에서 담당한다.

  • 제품을 생산하는 부분과 사용하는 부분을 분리해서 생성 코드를 체계적으로 관리할 수 있다.
  • 중복을 제거할 수 있다.

추상 팩토리 패턴

구상 클래스에 의존하지 않고도 연관된 객체로 이루어진 제품군을 생산하는 인터페이스를 제공한다.

→ 팩토리 메소드보다 복잡하다. 여러 인터페이스로 구성되어 있다.

→ 제품군을 추가하려면 인터페이스를 수정해야 한다.

팩토리 메소드는 그 자체로 하나의 완성된 객체를 생성한다. 반면 추상 팩토리는 객체의 일부분을 생성해서 팩토리에서 조합할 수 있다.

싱글턴 패턴

싱글턴 패턴은 클래스 인스턴스를 하나만 만들고, 인스턴스로의 전역 접근을 제공한다.

public class Singleton {
	private static Singleton uniqueInstance;
	private Singleton() {}

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

전통적인 방식의 싱글턴 패턴은 멀티스레딩에서 문제가 생길 수 있다. 동시에 instance 생성 요청을 만들면 두 개의 Instance가 생길 수 있다.

추가적으로 리플렉션, 직렬화/역직렬화 역시 문제가 될 수 있다.

  • synchronized 키워드를 붙이거나
  • 싱글톤 인스턴스를 처음부터 만들거나
  • DCL(Double Check Locking)을 사용해서 인스턴스가 생성되어 있지 않을 때에만 synchronized를 붙인다.

해답은, 싱글턴이 필요하면 enum을 쓰면 된다.

커맨드 패턴

커맨드 패턴은 요청 내역을 객체로 캡슐화해서 객체를 서로 다른 요청내역에 따라 매개변수화할 수 있다.

  • 커맨드 패턴은 어떤 작업을 요청하는 쪽과 그 작업을 처리하는 쪽을 분리할 수 있다.
  • undo 메소드도 간단히 구현 가능하다.
  • 여러 커맨드들을 이어 붙인 매크로 커맨드도 쉽게 구현 가능하다.

커맨드 패턴을 응용하면

  • 작업 히스토리 저장
    • 변화가 생길 때마다 스냅샷을 스택에 넣으면 undo, redo 기능 구현이 쉽다.
    • 작업 히스토리 객체를 저장한다면 시스템 다운시 복구가 가능하다.
  • 메세지 큐
    • 모두가 동일한 인터페이스를 가지기 때문에 인보커에서는 메세지 큐에 넣고, 작업 처리 스레드는 커맨드를 큐에서 하나씩 꺼내가고 작업한 후 작업이 끝나면 다시 새로운 커맨드를 가져간다.

어댑터 패턴

어댑터 패턴은 특정 클래스 인터페이스를 클라이언트에서 요구하는 다른 인터페이스로 변환한다.

  • 어댑터는 외부에서 제공하는 클래스를 이용할 때 유용하다. 새 인터페이스에 맞게 고치려면 많은 부분을 고려해야 하고, 코드도 많이 고쳐야 하지만 어댑터 패턴을 사용하면 간단하게 이어붙일 수 있다.
  • 어댑터 패턴은 composition을 사용한다.

어댑터 패턴은 데코레이터 패턴을 사용한다.

퍼사드 패턴

퍼사드 패턴은 서브시스템에 있는 인터페이스를 통합 인터페이스로 묶어 준다.

여러 개의 저수준 명령들을 묶는다면 서브 시스템 구성 요소를 대신 관리할 수 있다.

  • 퍼사드 패턴 덕분에 클라이언트는 더 간단한 고수준 명령만 실행할 수 있다.
  • 클라이언트 구현과 서브 시스템을 분리할 수 있다.

템플릿 메소드 패턴

템플릿 메소드 패턴은 알고리즘의 골격을 정의한다. 일부 단계를 서브 클래스에서 구현하게 만들 수 있다.

abstract class AbstractClass {
	final void templateMethod() { // 서브 클래스에서 알고리즘을 건드리지 못하도록 final로 선언
		primitiveOperation1();
		primitiveOperation2();
		concreteOperation();
		hook();
	}

	abstract void primitiveOperation1();

	abstract void primitiveOperation2();

	final void concreteOperation() {
	}

	void hook() {}
}

훅은 추상 클래스에 선언되어 있지만 기본적인 내용만 구현되어 있거나 빈 메소드이다. 이러면 서브클래스는 다양한 위치에서 알고리즘에 끼어들 수 있다.

반복자 패턴

반복자 패턴은 컬렉션 내의 모든 항목에 접근하는 방법을 제공한다.

Iterator나 Iterable에 존재하는 메소드를 각 컬렉션에 맞게 구현한다면 배열 / 해시맵 / ArrayList인지에 상관없이 어떤 컬렉션이든 1개의 순환문으로 처리할 수 있다.

컴포지트 패턴

컴포지트 패턴으로 객체를 트리 구조로 구성해서 부분-전체 계층구조를 구현한다.

컴포지트 패턴에서는 계층 구조를 관리하는 역할과 실제 작업 모두 처리하기 때문에 단일 역할 원칙을 깨게 된다.

기본적인 구조에서 더 나아가면

  • leaf 노드와 일반 노드의 인터페이스를 달리 할 수 있다. 대신 메소드를 호출하기 전에 객체의 형식을 매번 확인해야 하는 단점이 있다.
  • 자식에게 부모의 레퍼런스를 두면 상향식으로 올라갈 때 편리하다.
  • 자식의 순서도 중요할 수 있다.
  • 노드의 자식에 대한 계산이 있다면, 캐싱도 가능하다.

상태 패턴

상태 패턴을 사용하면 객체의 내부 상태가 바뀜에 따라 객체의 행동을 바꿀 수 있다.

public class GumballMachine {
	State soldOutState;
	State noQuarterState;
	State hasQuarterState;
	State soldState;

	State state;
	
	public GumballMachine() {
		soldOutState = new SoldOutState(this);
		noQuarterState = new NoQuarterState(this);
		hasQuarterState = new HasQuarterState(this);
		soldState = new SoldState(this);
		...
		state = noQuarterState;
	}

	public void insertQuarter() {
		state.insertQuarter();
	}
}
public class HasQuarterState implements State {
	GumballMachine gumballMachine;

	public void insertQuarter() {}
	public void ejectQuarter() {
		gumballMachine.setState(gumballMachine.getNoQuarterState()); // state 전환
	}
}

상태를 담고 있는 머신이라고 보고, 특정 조건에 따라 상태를 변환시킨다. 상태 패턴과 유사하지만, 상태 패턴은 클라이언트가 상태 객체를 모르고, 전략 패턴은 클라이언트가 Context에게 어떤 전략 객체를 사용할지 지정해준다는 차이가 있다.

프록시 패턴

프록시 패턴은 특정 객체로의 접근을 제어하는 대리인을 제공한다.

  • 원격 프록시를 써서 원격 객체로의 접근을 제어할 수 있다.
    • RMI
  • 가상 프록시를 써서 생성이 힘든 자원으로의 접근을 제어할 수 있다.
    • 진짜 객체가 필요한 상황이 오기 전까지 객체의 생성을 미룬다.
    • 객체 생성 전이나 도중에 객체를 대신한다.
    • 생성이 끝나면 RealSubject에게 요청을 전달한다.
  • 보호 프록시를 써서 접근 권한이 필요한 자원으로의 접근을 제어할 수 있다.

→ 데코레이터 패턴과 유사하게 원본 객체와 프록시 객체와 동일한 인터페이스를 가지지만, 프록시는 데코레이터와는 다르게 객체의 대리인 역할을 수행한다는 차이가 있다.

복합 패턴

여러 가지 패턴을 섞어서 복합 패턴을 구성할 수 있다. MVC를 이용해서 알아보자.

  • 뷰: 모델을 표현하는 방법을 제공한다.
  • 컨트롤러: 뷰에서 가져온 입력을 해석 후 모델에게 전달한다.
  • 모델: 모든 데이터, 상태와 애플리케이션 로직이 담긴다. 모델은 뷰와 컨트롤러에 관심이 없다.

뷰와 모델 사이에 컨트롤러를 두어

  • 뷰 코드의 복잡성을 줄인다.
  • 뷰와 모델의 결합을 끊어줄 수 있다.

MVC에서는

  • 옵저버 패턴: 컨트롤러와 뷰는 모델의 옵저버이다. 모델은 뷰나 컨트롤러에 전혀 의존하지 않는다.
  • 전략 패턴: 뷰는 사용자의 행동을 처리하는 작업을 컨트롤러에게 맡겨서 case별로 처리한다.
  • 컴포지트 패턴: 뷰의 다양한 GUI 객체는 컴포지트 패턴을 이용해 구성된다.

빌더 패턴

빌더 패턴을 사용하면 제품 생산을 단계화하여 캡슐화할 수 있다.

→ 팩토리 패턴은 한 단계에서 모든 것을 처리한다.

builder.buildDay(date);
builder.addHotel(date, "Grand Facadian");
builder.addTickets("Patterns on Ice");

Planner yourPlanner = builder.getVacationPlanner();

책임 연쇄 패턴(Chain of Responsible)

~= pipeline

1개의 요청을 2개 이상의 객체에서 처리할 수 있다.

사슬에 속해있는 각 객체는 자기가 받은 요청을 검사해서 직접 처리하거나 사슬에 있는 다른 객체에게 넘긴다.

플라이웨이트 패턴

상태를 instance가 아닌 parent에서 관리한다. 실행 시에는 상태가 저장되어 있지 않은 인스턴스를 이용한다.

  • 메모리를 절약할 수 있다.

인터프리터 패턴

어떤 언어의 인터프리터를 만들 때 유용하다.

중재자 패턴

서로 관련된 객체 사이의 복잡한 통신과 제어를 한 곳으로 집중하고 싶다면 중재자 패턴을 사용한다.

  • 제어 로직을 한 군데 모아놓았으므로 관리가 수월하다.
  • 모든 객체를 서로 알고있을 필요 없이 중재자 객체만 알면 된다.
  • 하지만 중재자 객체가 너무 복잡해질 수 있다.

메멘토 패턴

상태를 따로 저장하는 객체를 메멘토 객체라고 부른다.

프로토타입 패턴

실제 인스턴스를 만드는 것이 아닌, 기존 인스턴스의 복사본을 이용하는 것을 말한다.

  • 클라이언트는 구체적인 형식과 과정을 몰라도 객체를 생성할 수 있다.

비지터 패턴

다양한 객체에 새 기능을 추가해야 할 때에 비지터 객체에게 맡기면 객체의 상태를 기반으로 새 기능을 구현할 수 있다.

'better code' 카테고리의 다른 글

리팩터링 - 마틴 파울러  (0) 2023.07.05
객체지향의 사실과 오해  (0) 2023.07.02
[TIL] copy-on-write 방법론 - js에서 퍼포먼스 테스트  (0) 2023.04.22
Clean Code (5/n)  (0) 2022.02.21
Clean Code (4/n)  (0) 2022.02.16