소프트웨어 디자인 패턴(software design pattern)
은 소프트웨어 공학의 소프트웨어 디자인에서 특정 문맥에서 공통적으로 발생하는 문제에 대해 재사용 가능한 해결책이다.
출처 : 위키피디아 - 소프트웨어 디자인 패턴
생성(Creational) 패턴 | 구조(Structural) 패턴 | 행동(Behavioral) 패턴 |
---|---|---|
Abstract Factory | Adapter | Chain of Responsibility |
Builder | Bridge | Command |
Factory Method | Composite | Interpreter |
Prototype | Decorator | Iterator |
Singleton | Facade | Mediator |
Flyweight | Memento | |
Proxy | Observer | |
State | ||
Strategy | ||
Template Method | ||
Visitor |
🪛 생성
Abstract Method
: 서로 의존, 연관하는 객체들의 그룹으로 생성하여 추상적으로 표현한 패턴Builder
: 생산 단계를 캡슐화하여 구축 공정을 동일하게 이용하도록 한 패턴으로, 작게 분리된 인스턴스를 건축하듯이 조합하여 객체 생성Prototype
: 원본 객체를 복제하는 방법으로 객체를 생성Factory Method
: 객체 생성을 서브클래스에서 처리하도록 분리하여 캡슐화한 패턴으로 가상 생성자 패턴이라고도 부름Singleton
: 유일한 하나의 인스턴스를 보장하도록 하는 패턴🏗️ 구조
Adapter
: 인터페이스를 다른 클래스가 재사용할 수 있도록 변환Bridge
: 서로가 독립적으로 확장할 수 있도록 구성한 패턴으로 추상과 구현을 분리Composite
: 개별 객체와 복합 객체를 클라이언트에서 동일하게 사용하도록 하는 패턴으로 복합 객체와 단일 객체를 구분없이 다루고자 할 때 사용Decorator
: 소스를 변경하지 않고 기능을 확장하도록 하는 패턴으로 부가 기능 구현을 위해 다른 객체를 덧붙임Facade
: 하나의 인터페이스를 통해 느슨한 결합을 제공하는 패턴으로 복잡한 서브 클래스를 피해 더 상위에 인터페이스를 구성Flyweight
: 대량의 작은 객체들을 공유하는 패턴으로 가능한 한 인스턴스를 공유하여 메모리를 절약함Proxy
: 대리인이 대신 그 일을 처리하는 패턴으로 객체 사이에 인터페이스를 구성함🕺🏻 행위
Chain of Responsibility
: 객체들끼리 연결 고리를 만들어 내부적으로 전달하는 패턴으로 한 객체가 요청을 처리하지 못하면 다음 객체로 넘어감Command
: 요청 자체를 캡슐화하여 파라미터로 넘기는 패턴으로 재이용하거나 취소할 수 있도록 요청에 필요한 정보를 저장Interpreter
: 언어 규칙 클래스를 이용하는 패턴Iterator
: 내부 표현은 보여주지 않고 순회하는 패턴으로 이터레이터를 사용하여 컬렉션의 요소에 접근하며, 여러 가지 자료형의 구조와 상관없이 순회가 가능Mediator
: 객체 간 상호작용을 캡슐화한 객체로 정의한 패턴Memento
: 상태 값을 미리 저장해 두었다가 복구하는 패턴으로 특정 시점으로 돌아가야하는 Ctrl + Z 같은 기능에 주로 사용Observer
: 상태가 변할 때 의존자들에게 알리고, 자동 업데이트하는 패턴으로 상속되어 있는 다른 객체들에게 상태 변화를 전달함State
: 객체 내부 상태에 따라서 행위를 변경하는 패턴Strategy
: 다양한 알고리즘 캡슐화하여 알고리즘 대체가 가능하도록 한 패턴Template Method
: 알고리즘 골격의 구조를 정의한 패턴으로 하위 클래스에서 세부 처리를 구체화함Visitor
: 오퍼레이션을 별도의 클래스에 새롭게 정의한 패턴[디자인 패턴 구분과 세부 내용에 많은 참조를 한 게시물 👍🏻]
https://velog.io/@poiuyy0420/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4-%EA%B0%9C%EB%85%90%EA%B3%BC-%EC%A2%85%EB%A5%98
“싱글톤 패턴(singleton pattern)은
하나의 클래스에 오직 하나의 인스턴스만 가지는 패턴
입니다.”
출처 : 면접을 위한 CS 전공 지식 노트 p.17
일반적으로 하나의 클래스를 기반으로 여러 개의 개별적인 인스턴스를 만들어서 사용한다. 하지만, 데이터베이스 연결 모듈
과 같이 연결을 위한 인스턴스 생성 비용이 비싼 경우에는 싱글톤 패턴을 사용하여 인스턴스 생성 비용을 아끼며 메모리, 속도, 데이터 공유 측면
에서 이득을 볼 수 있다.
const URL = 'mongodb://localhost:27017/testapp';
const createConnection = (url) => ({"url" : url});
class DB {
constructor(url) {
if (!DB.instance) {
DB.instance = createConnection(url);
}
return DB.instance;
}
connect() {
return this.instance;
}
}
const a = new DB(URL);
const b = new DB(URL);
console.log(a === b) // true
위와 같이DB.instance
라는 하나의 인스턴스를 기반으로 a, b 생성 과정에서 드는 연결 비용을 아낄 수 있게 된다.
자바스크립트에서는 리터럴 {}
또는 new Object
로 객체를 생성하고, 이 때 다른 객체와 다르기 때문에 이 자체만으로도 싱글톤 패턴이라고 할 수 있다.
const a = {
single: "hello"
}
const b = {
single: "hello"
}
console.log(a === b); //false
위와 같은 형식으로 구성하는 싱글톤 패턴은 인스턴스를 다른 모듈들과 공유하며 사용하기 때문에, 생성 비용이 줄어든다는 장점이 가장 큰 디자인 패턴이다.
하지만, 의존성이 높아진다는 단점이 존재하며, TDD(Test Driven Development)
과정에 어려움이 있다. TDD의 경우, 단위 테스트를 주로 진행한다. 이때 싱글톤 패턴은 하나의 인스턴스를 기반으로 구현하기 때문에 각 테스트마다 독립적인 인스턴스를 만들기가 어렵다.
또한, 생성된 객체를 여러 프로세스가 동시에 참조할 수 없으며, 모듈간의 결합을 강하게 만든다는 단점이 존재한다. 효과적인 모듈 설계를 위해서는 결합도는 줄이고 응집도는 높이는 방식을 채택해야 하기 때문에, 싱글톤 패턴을 이용하고자 할 때에는 아래와 같은 상황인지 고려해보아야 한다.
💡 언제 싱글톤 패턴을 사용해야 할까?
- 프로그램 내에서 하나의 객체만 존재해야 하는 상황인가?
- 프로그램 내에서 여러 부분에서 해당 객체를 공유해야 하는 상황인가?
- 메모리, 시간, 데이터 일관성을 고려했을 때, 싱글톤 패턴의 이점이 더 큰 상황인가?
“팩토리 패턴(factory pattern)은 객체를 사용하는 코드에서
객체 생성 부분을 떼어내 추상화한 패턴
이자 상속 관계에 있는 두 클래스에서 상위 클래스가 중요한 뼈대를 결정하고하위 클래스에서 객체 생성에 관한 구체적인 내용을 결정하는 패턴
입니다.”
출처 : 면접을 위한 CS 전공 지식 노트 p.24
팩토리 패턴은 팩토리 메소드 패턴(Factory Method Pattern)
혹은 가상 생성자 패턴(Virtual Constructor Pattern)
이라고 불리기도 하며, 객체 생성을 서브 클래스에서 처리하도록 분리하여 캡슐화한 패턴이다.
일반적으로 동작 과정을 명시하는 상위 클래스와 객체 생성을 담당하는 하위 클래스로 분리되기 때문에 느슨한 결합을 가진다는 장점이 존재한다. 또한, 상위 클래스에서는 인스턴스 생성과 관련된 부분을 고려하지 않아도 되므로, 유연하게 코드 작성이 가능하며, 객체 생성 로직이 분리되어 있기 때문에 유지 보수성이 높아진다.
여기서 객체 생성 부분을 떼어내며, 분리했을 때 느슨한 결합을 가진다는 장점과 로직 분리로 인한 유지 보수성이 높아진다는 장점은 어떤 의미인지 조금 더 깊게 살펴보자.
class CoffeeBase {
base() {
return 'Espresso';
}
}
class Latte {
constructor() {
this.coffeeBase = new CoffeeBase();
}
make() {
return `Latte = ${this.coffeeBase.base()} with milk`;
}
}
class Americano {
constructor() {
this.coffeeBase = new CoffeeBase();
}
make() {
return `Americano = ${this.coffeeBase.base()} with water`;
}
}
const latte = new Latte();
console.log(latte.make()); // Output: Latte = Espresso with milk
const americano = new Americano();
console.log(americano.make()); // Output: Americano = Espresso with water
[실행 결과]
!
위와 같이 Latte
와 Americano 클래스
에서는 CoffeeBase라는 클래스
에 의존을 보이고 있다. 예를 들어, CoffeeBase
의 base
메소드에 얼만큼의 샷을 추가할 것인지 ‘샷’ 이라는 인수를 추가하게 되면 어떻게 될까?
class CoffeeBase {
base(shot) {
return `Espresso ${shot} shot`;
}
}
class Latte {
constructor() {
this.coffeeBase = new CoffeeBase();
}
make() {
return `Latte = ${this.coffeeBase.base(2)} with milk`;
}
}
class Americano {
constructor() {
this.coffeeBase = new CoffeeBase();
}
make() {
return `Americano = ${this.coffeeBase.base(3)} with water`;
}
}
const latte = new Latte();
console.log(latte.make()); // Output: Latte = Espresso 2 shot with milk
const americano = new Americano();
console.log(americano.make()); // Output: Americano = Espresso 3 shot with water
[실행 결과]
위와 같이 shot이라는 인수를 추가했을 때, 모든 클래스에 값을 추가해주며 코드를 수정해야 한다. 이렇듯 높은 의존성과 유지보수의 불편함을 개선할 때, 팩토리 패턴을 사용할 수 있다.
class CoffeeFactory {
static createCoffee(type) {
const factory = factoryList[type];
return factory.createCoffee();
}
}
class Latte {
constructor() {
this.name = "Latte";
this.shot = 2;
this.extra = "Milk";
}
}
class Americano {
constructor() {
this.name = "Americano";
}
}
class LatteFactory extends CoffeeFactory {
static createCoffee() {
return new Latte();
}
}
class AmericanoFactory extends CoffeeFactory {
static createCoffee() {
return new Americano();
}
}
const factoryList = { LatteFactory, AmericanoFactory };
const main = () => {
const coffee = CoffeeFactory.createCoffee("LatteFactory");
console.log(
`${coffee.name} = Espresso ${coffee.shot ?? 1} shot with ${
coffee.extra ?? "water"
}`
);
};
main();
[실행 결과]
위와 같이 객체 생성의 경우, 다른 객체 구성을 가지는 자율성과 유연성을 가질 수 있고, main 함수에서 캡슐화한 클래스를 사용할 수 있기 때문에, 객체 생성의 과정이 훨씬 자유로워지는 것을 볼 수 있다. 뿐만 아니라, createCoffee
의 경우에는 static
키워드를 통해 메서드를 정적 메서드로 선언하였기 때문에, 메모리 할당을 한 번만 하여 메모리 관점으로도 장점이 될 수 있다.
하지만, 위 코드와 같이 팩토리 패턴은 직관적인 코드와는 멀게 느껴지기도 한다. 코드의 유지보수에 있어서는 변화가 필요한 부분만 변경하면 되지만, Latte, Americano 이상으로 다양한 메뉴들을 고려해야 하는 경우에는 클래스의 수가 엄청나게 늘어나게 된다.
결론적으로 코드가 복잡해지므로, 이 또한 사용하는 경우를 고려하여 디자인 패턴을 선정하는 것이 필요하다.
💡 팩토리 패턴 디자인을 사용해야 하는 경우?
- 클래스 생성과 사용 로직을 분리해야 하는 경우인가?
- 캡슐화를 통해 정보 은닉을 고려해야 하는 상황인가?
- 기존 객체를 재사용하는 것이 재구성하는 것보다 리소스를 절약할 수 있는가?
“프록시 패턴(proxy pattern)은
대상 객체(subject)에 접근하기 전
그 접근에 대한흐름을 가로채 해당 접근을 필터링하거나 수정
하는 등의 역할을 하는 계층이 있는 디자인 패턴입니다.”
출처 : 면접을 위한 CS 전공 지식 노트 p.44
프록시 패턴의 경우, 객체의 속성, 변환 등을 보완하며 보안, 데이터 검증, 캐싱, 로깅 등에서 사용이 가능하다. 특히, 네트워크 과정에서 특정 지역에서 접근이 불가하게
하거나, DDOS 방어
와 같은 목적으로 CloudFlare
서비스를 웹 서버 앞단에 프록시 서버로 두는 것도 프록시 패턴
이라고 볼 수 있다. 이와 같이 프록시 패턴은 서버에서 자주 사용되며, 아래와 같은 목적으로 사용할 수 있다.
프록시 패턴은 금융 업계에서 많이 사용되는데, 실제 금액을 저장하고 있는 계정(계좌)에 직접 접근하지 않고, 신용 카드 혹은 체크 카드와 같은 요소를 활용하여 결제하는 시스템도 일종의 프록시 패턴이라고 볼 수 있다. 계좌가 실제 객체라면 카드와 같은 요소가 Proxy 객체라고 볼 수 있는 것이다.
프록시 패턴을 자바스크립트에 적용할 때에는 보통 프록시 객체를 사용한다.
const proxy = new Proxy(target, handler)
target은 감싸질 객체이자 Proxy의 대상이 되는 객체, handler
는 target
의 여러 트랩을 정의한다.
handler로 가로챌 수 있는 동작은 아래와 같다.
핸들러 메서드 | 작동 시점 |
---|---|
get | 프로퍼티를 읽을 때 |
set | 프로터티에 값을 쓸 때 |
has | in 연산자가 작동할 때 |
deleteProperty | delete 연산자가 작동할 때 |
apply | 함수를 호출할 때 |
constructor | new 연산자가 작동할 때 |
getPrototypeOf | Object.getPrototypeOf |
setPrototypeOf | Object.setPrototypeOf |
isExtensible | Object.isExtensible |
preventExtensions | Object.preventExtensions |
getOwnPropertyDescriptor | Object.getOwnPropertyDescriptor |
ownKeys | Object.getOwnPropertyNamesObject.getOwnPropertySymbols |
Proxy 객체를 통해 작성하는 코드는 후술될 옵저버 패턴
에서 심화적으로 등장하므로, 존박을 현대식 한국식 이름으로 바꾸는 코드를 통해 Proxy 객체를 간단하게 구현해보면 아래와 같다.
const koreanNameHandler = {
get: function (target, name) {
return name === 'name' ? `${target.last} ${target.first}` : target[name];
},
};
const nameChanger = new Proxy({ first: 'John', last: 'Park' }, koreanNameHandler);
console.log(nameChanger.name);
프록시 패턴은 서버에서 자주 사용되지만, 프로젝트를 하다보면 종종 마주칠 수 있는 비슷한 부분이 있다.
export const apiClient = axios.create({ baseURL: `${API_URI}/api` });
apiClient.interceptors.request.use(
// Authorization header 요청에 공통헤더 set
(config) => {
if (!isClient) return config;
const accessToken = getAccessToken();
if (accessToken) config.headers.Authorization = `Bearer ${accessToken}`;
return config;
},
);
...
위 코드는 실제 프로젝트에서 axios를 이용하여 Authorization header에 공통적으로 사용되는 헤더를 세팅하는 과정으로, apiClient.interceptors.request.use
와 apiClient.interceptors.response.use
를 활용하여 응답을 보내기 전과 후에 요청을 가로챈다. 즉, 중간 제어 역할과 위임, 주요 기능 확장 등의 패턴에서 유사하다고 볼 수 있다.
다만, 자바스크립트에서 객체 메서드에 호출을 감싸는 경우에 직접적인 프록시 패턴이라고 부른다고 볼 수 있으며, Axios 인터셉터는 HTTP 요청/응답 흐름에 적용되어 네트워크 레벨의 프록시 역할을 한다고 보는 것이 더 적합할 수 있다.
💡 프록시 패턴 디자인을 사용해야 하는 경우?
- Access Control / Validation 과정을 중간에서 확인해야 하는 경우
- Caching / Logging을 구축해야 하는 경우
- 객체의 속성, 변환 등을 보완하기 위해 Proxy 객체를 두어야 하는 경우
“옵저버 패턴(observer pattern)은
주체가 어떤 객체(subject)의 상태 변화를 관찰
하다가 상태 변화가 있을 때마다 메서드 등을 통해 옵저버 목록에 있는옵저버들에게 변화를 알려주는 디자인 패턴
입니다.”
출처 : 면접을 위한 CS 전공 지식 노트 p.34
여기서 주체
는 객체의 상태 변화를 보고 있는 관찰자
옵저버
는 객체의 상태 변화에 따라 전달되는 메서드 등을 기반으로 추가적인 변화가 생기는 객체
들을 의미한다.
X, 인스타그램과 같은 서비스가 옵저버 패턴을 활용한 사례라고 볼 수 있으며, 특정 계정이라는 주체
를 팔로우 했을 때, 주체가 게시물을 올리면 팔로워에게 알림이 가는 형태라고 볼 수 있다.
자바스크립트에서는 위에서 설명한 프록시 객체
를 이용하여 옵저버 패턴을 구현할 수 있다.
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="utf-8" />
<title>Proxy Name Change Observer Pattern</title>
</head>
<body>
<label id="name_label">이름</label>
<br />
<input id="name_input" type="text" />
<button id="change_button">바꾸기</button>
<script src="./main.js"></script>
</body>
</html>
// 변경사항 감지용 Proxy 객체 생성 함수
function createReactiveObject(target, callback) {
const proxy = new Proxy(target, {
set(obj, prop, value) {
if (value !== obj[prop]) {
const prev = obj[prop];
obj[prop] = value;
callback(`${prop}가 [${prev}] >> [${value}]로 변경되었습니다.`);
}
return true;
},
});
return proxy;
}
// DOM 요소 가져오기
const nameLabel = document.getElementById('name_label');
const nameInput = document.getElementById('name_input');
const changeButton = document.getElementById('change_button');
// 초기 데이터 객체
const state = createReactiveObject({ name: nameLabel.textContent }, (message) => {
console.log(message);
});
// 이벤트 리스너 등록
changeButton.addEventListener('click', () => {
const newName = nameInput.value.trim();
if (newName) {
state.name = newName; // Proxy가 변경사항을 감지하여 콜백 실행
nameLabel.textContent = state.name; // DOM 업데이트
nameInput.value = ''; //value값 초기화
} else {
alert('이름을 입력하세요!');
}
});
위와 같은 코드로 구성되어 있어, input 내부에 “이름 바꾸기” 라고 텍스트를 입력하고 버튼을 클릭하면 아래와 같이 DOM이 업데이트 되는 것을 볼 수 있다.
이 때, 단순히 업데이트만 구성하지 않고, 이벤트 리스너에서 중간 객체인 Proxy에서 state의 변경사항을 감지하여 콜백을 실행하고, console을 통해 변경된 상태를 확인할 수 있다.
실제로 Vue.js 3.0에서는 ref나 reactive로 정의하면 해당 값이 변경되었을 때 자동으로 DOM에 있는 값이 변경되는데, 프록시 객체를 이용하여 옵저버 패턴을 구현한 것이라고 한다.
주홍철 (2023). 면접을 위한 CS 전공지식 노트 (초판 6쇄). (주)도서출판 길벗