원문 : https://betterprogramming.pub/the-power-of-strategy-design-pattern-in-javascript-df1a17bc2c72
자바스크립트는 유연함으로 매우 잘 알려진 언어입니다. 아마도 유연함이 자바스크립트의 단점 중 하나라고 말하는 사람들이 있고 또는 완전히 반대로 단점이 아닌 장점이라고 말하는 사람도 있을 것입니다. 저는 후자에 더 가까운 편입니다. 왜냐하면 수년 전에는 불가능해 보였던 놀라운 일들을 유연함을 활용하여 해결할 수 있기 때문입니다.
이후에 개발된 리엑트라는 놀라운 도구는 자바스크립트의 유연함이 장점이라는 사실을 뒷받침하는 명백한 증거입니다. Visual studio Code, Figma와 같이 오늘날 급성장하는 기술을 지원하는 Electron도 마찬가지입니다.
모든 자바스크립트 라이브러리는 현대 자바스크립트 생태계에서 주목받는 한 가지 디자인 패턴을 사용합니다. 앞서 말한 이 디자인 패턴은 바로 전략 디자인 패턴입니다. 앞으로 설명하겠지만 자바스크립트의 유연함은 또한 전략 디자인 패턴을 강력하게 만듭니다.
전략 디자인 패턴은 작업을 수행하기 위해 하나 이상의 전략(또는 알고리즘)을 캡슐화하는 패턴입니다. 이러한 캡슐화된 전략들은 모두 동일한 서명을 가지고 있기 때문에 컨텍스트(인터페이스를 제공하는 주체)는 같거나 다른 객체(또는 전략)를 다룰 때 전혀 알지 못합니다. 즉, 프로그램은 사용하는 동안 전략이 바뀌는 것을 인식하지 않고 각 전략을 여러 번 교환할 수 있습니다.
전략 패턴에는 항상 다음 두 객체가 포함됩니다.
1. 컨텍스트
2. 전략
컨텍스트에는 항상 사용 중인 현재 전략에 대한 참조 또는 포인터가 있어야 합니다. 즉, 200개의 전략을 가지고 있다면 나머지 199개는 선택 사항입니다. 나머지 것들을 "비활성화(inactive)" 상태라고 생각할 수도 있습니다.
컨텍스트는 또한 호출자(caller)에게 인터페이스를 제공합니다. 호출자는 클라이언트입니다. 호출자는 자신의 업무를 수행하기 위해 어떤 전략이든 사용할 수 있으며, 또한 필요에 따라 언제든지 현재의 전략을 다른 전략으로 변환할 수 있습니다.
전략은 실행될 로직을 자체적으로 구현합니다.
일반적인 함수 구현에서 함수는 보통 무언가를 하고 값을 반환합니다. 전략 디자인 패턴에서 기본(컨텍스트) 클래스와 하나의 전략이 있을 때 이는 전략을 호출하고 결과를 반환하는 함수와 같습니다.(즉, 함수와 동일하게 동작합니다.)
하지만 두 개 이상의 전략이 있을 때, 포인트는 그 전략이 호출자에 의해 제어되는 많은 전략 중 하나가 될 수 있다는 것입니다.
여기서 가장 큰 이점은 우리가 원하는 만큼 많은 전략을 정의하고 패턴이 작성되어야 하는 방식으로 작성되는 한 코드의 동작에 어떠한 변화의 힌트도 주지 않고 각 전략을 원하는 시점에 원하는 것으로 변경하는(On-Demand) 방식으로 사용할 수 있다는 것입니다.
전략의 구현은 변경될 수 있지만 상황에 따라 동일한 서명을 유지하는 한 불필요한 코드 변경을 할 필요가 없습니다.
다음은 이 흐름을 설명하는 다이어그램입니다.
첫 번째 구현은 페칭(fetching)에 초점을 맞출 것입니다. 우리는 페처를 생성하는 인터페이스를 반환하는 createFetcher
함수를 정의할 것입니다. 이러한 페처는 클라이언트에 의해 생성될 수도 있습니다. 또한, URL을 가져오거나 검색을 하기도 하고 응답을 반환하는 등 원하는 대로 구현될 수 있습니다.
우리는 네트워크 요청에 사용되는 라이브러리인 axios, 노드 자체에 내장되어 있는 https 모듈과 node-fetch 라이브러리를 사용할 것입니다.
총 3가지의 전략이 있습니다.
const axios = require('axios').default
const https = require('https')
const fetch = require('node-fetch')
function createFetcher() {
const _identifer = Symbol('_createFetcher_')
let fetchStrategy
const isFetcher = (fn) => _identifer in fn
function createFetch(fn) {
const fetchFn = async function _fetch(url, args) {
return fn(url, args)
}
fetchFn[_identifer] = true
return fetchFn
}
return {
get fetch() {
return fetchStrategy
},
create(fn) {
return createFetch(fn)
},
use(fetcher) {
if (!isFetcher(fetcher)) {
throw new Error(`The fetcher provided is invalid`)
}
fetchStrategy = fetcher
return this
},
}
}
const fetcher = createFetcher()
const axiosFetcher = fetcher.create(async (url, args) => {
try {
return axios.get(url, args)
} catch (error) {
throw error
}
})
const httpsFetcher = fetcher.create((url, args) => {
return new Promise((resolve, reject) => {
const req = https.get(url, args)
req.addListener('response', resolve)
req.addListener('error', reject)
})
})
const nodeFetchFetcher = fetcher.create(async (url, args) => {
try {
return fetch(url, args)
} catch (error) {
throw error
}
})
fetcher.use(axiosFetcher)
createFetcher
함수 안에 const _identifer = Symbol('_createFetcher_')
를 생성하였습니다.
생성된 전략이 실제 전략인지 우리는 확인하고 싶기 때문에 이 코드는 중요합니다. 이 코드가 없다면 프로그램은 전달된 모든 객체를 전략으로 취급합니다. 어떤 것이든 전략으로 취급하는 것은 긍정적인 이점처럼 들릴 수 있지만, 전략의 유효성을 잃게 되며, 이는 코드가 더 쉽게 오류를 일으키게 되어 실수를 했을 때 디버깅 하기 어렵게 합니다.
Symbol
은 정의상 고유한 변수를 반환합니다. 또한 컨텍스트 구현 내에 숨겨져 있으므로 생성
함수 외부에서 생성된 객체가 전략으로 처리될 가능성은 없습니다. 그들은 컨텍스트에 의해 제공된 인터페이스로부터 공개적으로 만들어진 메서드를 사용해야 할 것입니다.
클라이언트가 use
를 호출 했을 때 현재의 전략을 사용하여 axiosFetcher
를 사용하고 클라이언트가 use
를 통해 다른 전략으로 교환할 때까지 참조로 바인딩됩니다.
이제 데이터를 검색하기 위한 세 가지 전략이 있습니다.
const url = 'https://google.com'
fetcher.use(axiosFetcher)
fetcher
.fetch(url, { headers: { 'Content-Type': 'text/html' } })
.then((response) => {
console.log('response using axios', response)
return fetcher.use(httpsFetcher).fetch(url)
})
.then((response) => {
console.log('response using node https', response)
return fetcher.use(nodeFetchFetcher).fetch(url)
})
.then((response) => {
console.log('response using node-fetch', response)
})
.catch((error) => {
throw error instanceof Error ? error : new Error(String(error))
})
만세! 이제 실제로 어떻게 코드로 구현되는지 살펴 봤습니다. 하지만 실제로 쓰이는 상황은 어떨지 생각해 볼 수 있을까요? 사실 어렵지 않습니다! 하지만 만약 여러분이 이 패턴을 처음 접했다면 조금은 어려울 수 있다는 것은 이해합니다.
이 글에서 살펴본 예제는 패턴의 구현을 보여주지만, 이 글을 읽는 사람 중에 "axios와 같은 것을 직접 사용하여 응답을 얻고 하루만에 끝낼 수 있는데 왜 굳이 3가지 전략으로 구현해야 하는가?" 하고 질문할 수 있습니다.
지금부터 전략 디자인 패턴이 확실히 필요한 시나리오를 살펴보겠습니다.
전략 패턴이 가장 빛을 보는 순간은 부분은 정렬(sorting) 같은 서로 다른 데이터 타입을 처리해야 할 때 입니다.
이전 예제에서는 특정 응답을 원했기 때문에 데이터 타입이 별로 중요하지 않았습니다. 하지만 우리가 어떤 컬렉션을 받아 분류 같은 일을 해야 한다면 어떻게 될까요? 또 적절하게 정렬해야 한다면 어떻게 될까요?
각각이 다른 데이터 타입의 컬렉션인 여러 컬렉션을 정렬해야 하는 경우 각 값이 "less"와 "greater" 측면에서 다르게 처리 될 수 있기 대문에 컬렉션에 내장된 .sort
메서드를 사용할 수 없습니다.
전략 패턴을 사용하여 런타임에서 쉽게 사용할 수 있는 다양한 정렬 알고리즘들을 정의하여 필요에 따라 서로 교환하며 사용할 수 있습니다.
다음 컬렉션을 고려해봅시다.
const nums = [2, -13, 0, 42, 1999, 200, 1, 32]
const letters = ['z', 'b', 'm', 'o', 'hello', 'zebra', 'c', '0']
const dates = [
new Date(2001, 1, 14),
new Date(2000, 1, 14),
new Date(1985, 1, 14),
new Date(2020, 1, 14),
new Date(2022, 1, 14),
]
// 높이에 따라 정렬할 필요가 있음
const elements = [
document.getElementById('submitBtn'),
document.getElementById('submit-form'),
...document.querySelectorAll('li'),
]
우리는 Sort
전략 클래스와 Sorter
컨텍스트 클래스를 생성할 수 있습니다.
이것들이 클래스일 필요는 없습니다. 조금 더 다양한 구현을 보여드리기 위해 클래스를 선택했습니다.
const sorterId = Symbol('_sorter_')
class Sort {
constructor(name) {
this[sorterId] = name
}
execute(...args) {
return this.fn(...args)
}
use(fn) {
this.fn = fn
return this
}
}
class Sorter {
sort(...args) {
return this.sorter.execute.call(this.sorter, ...args)
}
use(sorter) {
if (!(sorterId in sorter)) {
throw new Error(`Please use Sort as a sorter`)
}
this.sorter = sorter
return this
}
}
const sorter = new Sorter()
아주 간단합니다. Sorter
는 현재 사용되는 Sort
에 대한 참조를 유지합니다. 참조 되어 있는 값은 sort
를 호출할 때 선택되는 정렬 함수입니다. 각 Sort
인스턴스는 전략이며 use
로 전달됩니다.
Sorter
는 전략에 대한 것은 아무것도 모릅니다. 그것이 날짜를 정렬하는지, 숫자를 정렬하는지 등에 대한 내용은 모르고 단순히 Sort의 실행 메서드를 호출하기만 합니다.
하지만 클라이언트는 모든 Sort
인스턴스에 대해 알고 있으며 전략과 Sorter
를 제어합니다.
const sorter = new Sorter()
const numberSorter = new Sort('number')
const letterSorter = new Sort('letter')
const dateSorter = new Sort('date')
const domElementSizeSorter = new Sort('dom-element-sizes')
numberSorter.use((item1, item2) => item1 - item2)
letterSorter.use((item1, item2) => item1.localeCompare(item2))
dateSorter.use((item1, item2) => item1.getTime() - item2.getTime())
domElementSizeSorter.use(
(item1, item2) => item1.scrollHeight - item2.scrollHeight,
)
즉, 어떻게 처리하는 지는 전적으로 우리(클라이언트)에게 달려 있습니다.
function sort(items) {
const type = typeof items[0]
sorter.use(
type === 'number'
? numberSorter
: type === 'string'
? letterSorter
: items[0] instanceof Date
? dateSorter
: items[0] && type === 'object' && 'tagName' in items[0]
? domElementSizeSorter
: Array.prototype.sort.bind(Array),
)
return [...items].sort(sorter.sort.bind(sorter))
}
이제 우리는 4가지의 다른 종류의 컬렉션을 정렬할 수 있는 15줄의 함수를 갖게 되었습니다!
console.log('Sorted numbers', sort(nums))
console.log('Sorted letters', sort(letters))
console.log('Sorted dates', sort(dates))
이것이 자바스크립트의 전략 디자인 패턴의 힘입니다.
함수를 값으로 처리하는 자바스크립트의 특성 덕분에 이 코드 예제는 해당 기능을 장점으로 혼합하고 전략 패턴과 원활하게 작동합니다.
지금까지 전략 패턴에 대해 알아봤습니다. 이 정보가 도움이 되었길 바라며 앞으로도 더 유용한 팁을 전달하도록 노력하겠습니다!
🚀 한글로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!
오히려 전략패턴을 프론트에서 더 잘쓸것같다고 생각했는데 공감가는 내용이네요