[CS] 소프트웨어 디자인 패턴 - 기본편

wha1e·2025년 1월 17일
0

TIL

목록 보기
7/8
post-thumbnail

📍 디자인 패턴이란?

소프트웨어 디자인 패턴(software design pattern)은 소프트웨어 공학의 소프트웨어 디자인에서 특정 문맥에서 공통적으로 발생하는 문제에 대해 재사용 가능한 해결책이다.
출처 : 위키피디아 - 소프트웨어 디자인 패턴

디자인 패턴 유형

생성(Creational) 패턴구조(Structural) 패턴행동(Behavioral) 패턴
Abstract FactoryAdapterChain of Responsibility
BuilderBridgeCommand
Factory MethodCompositeInterpreter
PrototypeDecoratorIterator
SingletonFacadeMediator
FlyweightMemento
ProxyObserver
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


📍 생성 패턴 (Creational Pattern) 대표적 요소 살펴보기

(1) 싱글톤 패턴 (Singleton Pattern)

“싱글톤 패턴(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의 경우, 단위 테스트를 주로 진행한다. 이때 싱글톤 패턴은 하나의 인스턴스를 기반으로 구현하기 때문에 각 테스트마다 독립적인 인스턴스를 만들기가 어렵다.

또한, 생성된 객체를 여러 프로세스가 동시에 참조할 수 없으며, 모듈간의 결합을 강하게 만든다는 단점이 존재한다. 효과적인 모듈 설계를 위해서는 결합도는 줄이고 응집도는 높이는 방식을 채택해야 하기 때문에, 싱글톤 패턴을 이용하고자 할 때에는 아래와 같은 상황인지 고려해보아야 한다.

💡 언제 싱글톤 패턴을 사용해야 할까?

  • 프로그램 내에서 하나의 객체만 존재해야 하는 상황인가?
  • 프로그램 내에서 여러 부분에서 해당 객체를 공유해야 하는 상황인가?
  • 메모리, 시간, 데이터 일관성을 고려했을 때, 싱글톤 패턴의 이점이 더 큰 상황인가?

(2) 팩토리 패턴 (Factory Pattern)

“팩토리 패턴(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

[실행 결과]

!

위와 같이 LatteAmericano 클래스에서는 CoffeeBase라는 클래스에 의존을 보이고 있다. 예를 들어, CoffeeBasebase 메소드에 얼만큼의 샷을 추가할 것인지 ‘샷’ 이라는 인수를 추가하게 되면 어떻게 될까?

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 이상으로 다양한 메뉴들을 고려해야 하는 경우에는 클래스의 수가 엄청나게 늘어나게 된다.

결론적으로 코드가 복잡해지므로, 이 또한 사용하는 경우를 고려하여 디자인 패턴을 선정하는 것이 필요하다.

💡 팩토리 패턴 디자인을 사용해야 하는 경우?

  • 클래스 생성과 사용 로직을 분리해야 하는 경우인가?
  • 캡슐화를 통해 정보 은닉을 고려해야 하는 상황인가?
  • 기존 객체를 재사용하는 것이 재구성하는 것보다 리소스를 절약할 수 있는가?

📍 구조 패턴 (Structural Pattern) 대표적 요소 살펴보기

(1) 프록시 패턴 (Proxy Pattern)

“프록시 패턴(proxy pattern)은 대상 객체(subject)에 접근하기 전 그 접근에 대한 흐름을 가로채 해당 접근을 필터링하거나 수정하는 등의 역할을 하는 계층이 있는 디자인 패턴입니다.”
출처 : 면접을 위한 CS 전공 지식 노트 p.44

프록시 패턴의 경우, 객체의 속성, 변환 등을 보완하며 보안, 데이터 검증, 캐싱, 로깅 등에서 사용이 가능하다. 특히, 네트워크 과정에서 특정 지역에서 접근이 불가하게 하거나, DDOS 방어와 같은 목적으로 CloudFlare 서비스를 웹 서버 앞단에 프록시 서버로 두는 것도 프록시 패턴이라고 볼 수 있다. 이와 같이 프록시 패턴은 서버에서 자주 사용되며, 아래와 같은 목적으로 사용할 수 있다.

  • Access Control
  • Validation
  • Caching
  • Logging

프록시 패턴은 금융 업계에서 많이 사용되는데, 실제 금액을 저장하고 있는 계정(계좌)에 직접 접근하지 않고, 신용 카드 혹은 체크 카드와 같은 요소를 활용하여 결제하는 시스템도 일종의 프록시 패턴이라고 볼 수 있다. 계좌가 실제 객체라면 카드와 같은 요소가 Proxy 객체라고 볼 수 있는 것이다.

프록시 패턴을 자바스크립트에 적용할 때에는 보통 프록시 객체를 사용한다.

const proxy = new Proxy(target, handler)

target은 감싸질 객체이자 Proxy의 대상이 되는 객체, handlertarget의 여러 트랩을 정의한다.

handler로 가로챌 수 있는 동작은 아래와 같다.

핸들러 메서드작동 시점
get프로퍼티를 읽을 때
set프로터티에 값을 쓸 때
hasin 연산자가 작동할 때
deletePropertydelete 연산자가 작동할 때
apply함수를 호출할 때
constructornew 연산자가 작동할 때
getPrototypeOfObject.getPrototypeOf
setPrototypeOfObject.setPrototypeOf
isExtensibleObject.isExtensible
preventExtensionsObject.preventExtensions
getOwnPropertyDescriptorObject.getOwnPropertyDescriptor
ownKeysObject.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.useapiClient.interceptors.response.use를 활용하여 응답을 보내기 전과 후에 요청을 가로챈다. 즉, 중간 제어 역할과 위임, 주요 기능 확장 등의 패턴에서 유사하다고 볼 수 있다.

다만, 자바스크립트에서 객체 메서드에 호출을 감싸는 경우에 직접적인 프록시 패턴이라고 부른다고 볼 수 있으며, Axios 인터셉터는 HTTP 요청/응답 흐름에 적용되어 네트워크 레벨의 프록시 역할을 한다고 보는 것이 더 적합할 수 있다.

💡 프록시 패턴 디자인을 사용해야 하는 경우?

  • Access Control / Validation 과정을 중간에서 확인해야 하는 경우
  • Caching / Logging을 구축해야 하는 경우
  • 객체의 속성, 변환 등을 보완하기 위해 Proxy 객체를 두어야 하는 경우

📍 행동 패턴 (Behavioral Pattern) 대표적 요소 살펴보기

(1) 옵저버 패턴 (Observer Pattern)

“옵저버 패턴(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에 있는 값이 변경되는데, 프록시 객체를 이용하여 옵저버 패턴을 구현한 것이라고 한다.


참고자료

profile
상상을 현실로 만드는 FE

0개의 댓글

관련 채용 정보