TYPESCRIPT · 심층 가이드
TypeScript 패턴 완전 정리
GoF 패턴을 TypeScript로 옮기면 상당수가 클래스 계층 대신 함수 타입과 판별 유니온으로 줄어들어, 원래 의도만 남고 골격은 훨씬 가벼워집니다.
구조적 타이핑과 일급 함수가 있는 언어에서는 패턴의 무게중심이 달라집니다. 전략을 담기 위해 인터페이스와 구현 클래스를 세 개 만들 필요 없이 함수 시그니처 하나면 충분한 경우가 많고, 상태 전이는 클래스 대신 태그 필드를 가진 유니온으로 표현하면 컴파일러가 누락된 분기를 대신 찾아 줍니다. 자바 계열 교재를 그대로 옮겨 적으면 실제로는 아무것도 얻지 못한 껍데기만 남는 이유가 여기 있습니다.
설정 객체로 옵션을 하나의 타입으로 묶는 감각을 먼저 잡고, 동작 자체를 인자로 넘기는 전략 패턴으로 넘어가면 뒤따르는 패턴 대부분이 그 변형으로 읽힙니다. 생성 책임만 떼어 내는 팩터리 메서드, 같은 형태의 함수를 겹겹이 쌓는 미들웨어 체인, 그리고 그 조립 과정을 한곳에 모으는 의존성 주입 컨테이너가 차례로 이어집니다. 저장소 패턴은 여기에 데이터 접근 경계를 더해, 앞의 세 조각이 실제 애플리케이션에서 어떻게 맞물리는지를 보여 줍니다.
직접 만든 DI 컨테이너에서 흔히 놓치는 부분은 등록소가 Map<string, unknown>이 되는 순간 타입 안전성이 사라진다는 점입니다. 꺼낼 때마다 단언을 붙이게 되고, 잘못 등록한 의존성은 실행 중에야 드러납니다. 키를 문자열 대신 타입을 실어 나르는 토큰으로 두면 조회 시점에 타입이 따라옵니다. 빌더에서 this를 반환하는 체이닝도 상속과 만나면 부모 타입으로 좁혀지니 주의가 필요합니다.
01설정 객체
이름별로 함수를 담은 strategies 객체를 만들고 percent를 골라 100에 적용해 90을 얻습니다. 분기 대신 키로 동작을 고르고 싶을 때 쓰는 최소 구조입니다. 이 객체의 키 타입은 "percent" | "flat"으로 좁게 추론되므로, 임의의 string 변수로 접근하려면 keyof typeof strategies로 좁혀 줘야 합니다.
TypeScript code
// 설정 객체
const strategies = { percent: (value: number) => value * 0.9, flat: (value: number) => value - 5 };
console.log(strategies.percent(100));keyof typeof strategies로 키 타입을 뽑아 두면 전략 이름을 인자로 받을 때 없는 이름이 컴파일 단계에서 걸립니다.
임의의 string으로 접근하면 인덱스 시그니처가 없어 오류가 나고, any로 우회하면 없는 키에서 undefined를 호출하게 됩니다.
02전략 패턴
동일한 시그니처를 가진 두 계산식이 한 객체 안에 있고, 호출부는 어느 쪽을 쓸지만 정합니다. 할인 계산처럼 규칙이 늘어나는 자리에 새 항목만 추가하면 되니 조건문이 커지지 않습니다. 그런데 두 함수의 반환 타입이 자동으로 number로 추론될 뿐 공통 계약이 강제되지는 않아, 시그니처를 맞추고 싶다면 별도 함수 타입을 명시해야 합니다.
TypeScript code
// 전략 패턴
const listeners = [];
const subscribe = (fn) => listeners.push(fn);
const publish = (value: string) => listeners.forEach(fn => fn(value));
subscribe(console.log);
publish("update-2");구독 함수의 타입을 (value: string) => void로 정해 두면 등록 시점에 시그니처가 검사되어, 발행 때 인자 개수가 어긋나는 일이 없습니다.
const listeners = []는 any[]로 시작하므로 어떤 값이든 등록되고, 함수가 아닌 값이 섞이면 발행 시점에 호출 오류가 납니다.
03옵저버
listeners 배열에 콜백을 모으고, publish가 저장된 함수 전부에 값을 순서대로 전달합니다. 발신자가 수신자를 몰라도 되게 만들 때의 기본형입니다. 여기서 const listeners = []와 (fn)은 타입이 비어 있어 noImplicitAny 환경에서는 오류가 나며, ((v: string) => void)[]처럼 명시해야 합니다.
TypeScript code
// 옵저버
const cache = new Map();
const getUser = (id: number) => { if (!cache.has(id)) cache.set(id, { id, name: "user" }); return cache.get(id); };
console.log(getUser(3));new Map<number, User>()처럼 타입 인자를 주면 get의 반환이 User | undefined가 되어 존재 확인을 강제합니다.
타입 인자 없는 new Map()은 Map<any, any>가 되어, 저장한 값의 필드 오타가 조회 이후에도 전혀 검사되지 않습니다.
04데코레이터
미들웨어 두 개를 배열에 담고 run이 인덱스를 올려 가며 next로 다음 단계를 부르며, ctx.steps에 a와 b가 순서대로 쌓입니다. index 검사 덕분에 같은 단계에서 next()를 두 번 부르면 예외가 납니다. 이 중복 호출 감지가 없으면 후속 단계가 조용히 두 번 실행돼 원인 찾기가 어려워집니다.
TypeScript code
// 데코레이터
const middlewares = [(ctx, next) => { ctx.steps.push("a"); return next(); }, (ctx, next) => { ctx.steps.push("b"); return next(); }];
const ctx = { steps: [] };
let index = -1;
const run = (i) => { if (i <= index) throw new Error("twice"); index = i; const mw = middlewares[i]; if (mw) return mw(ctx, () => run(i + 1)); };
run(0);
console.log(ctx.steps);감싸는 방식은 원래 함수를 건드리지 않고 앞뒤에 동작을 덧붙일 수 있어, 로깅이나 시간 측정을 나중에 끼워 넣기 좋습니다.
ctx와 next에 타입이 없어 암묵적 any이므로, 미들웨어가 없는 필드를 건드려도 실행 전에는 아무것도 걸리지 않습니다.
05어댑터
strategies의 각 함수는 숫자를 받아 숫자를 돌려주는 같은 모양이라, 호출부는 내부 계산 방식을 몰라도 됩니다. 서로 다른 계산 규칙을 하나의 호출 형태로 맞출 때 이 구조가 유용합니다. 다만 실제 외부 API를 감쌀 때는 인자 개수나 예외 형태까지 통일해야 하며, 반환 타입만 같다고 교체 가능해지지는 않습니다.
TypeScript code
// 어댑터
const strategies = { percent: (value: number) => value * 0.9, flat: (value: number) => value - 5 };
console.log(strategies.percent(100));여러 구현을 한 형태로 맞출 때는 type Strategy = (value: number) => number처럼 함수 타입을 먼저 정의하고 객체에 적용하는 편이 안전합니다.
반환 타입만 같고 실패를 알리는 방식이 다르면 교체 시 호출부가 깨지므로, 예외를 던질지 값을 돌려줄지까지 통일해야 합니다.
06커맨드
subscribe로 실행할 함수를 등록해 두었다가 publish 시점에 "update-6"을 넘겨 한꺼번에 실행합니다. 실행 요청을 값처럼 보관했다가 나중에 처리하는 형태로 볼 수 있습니다. 여기서 forEach는 반환값을 버리므로, 등록된 함수가 Promise를 돌려주더라도 완료를 기다리지 않고 그대로 지나갑니다.
TypeScript code
// 커맨드
const listeners = [];
const subscribe = (fn) => listeners.push(fn);
const publish = (value: string) => listeners.forEach(fn => fn(value));
subscribe(console.log);
publish("update-6");실행할 동작을 값으로 보관해 두면 순서를 바꾸거나 되돌리기 목록에 넣는 확장을, 구조를 바꾸지 않고 나중에 붙일 수 있습니다.
forEach는 콜백의 반환값을 버리므로, 등록된 함수가 프로미스를 돌려줘도 완료를 기다리지 않고 다음으로 넘어갑니다.
07상태 패턴
Map을 하나 두고 getUser가 키가 없을 때만 객체를 만들어 저장한 뒤 항상 저장된 값을 돌려줍니다. 같은 입력에 같은 인스턴스를 유지하고 싶을 때 쓰는 형태입니다. 타입 인자를 주지 않은 new Map()은 Map<any, any>로 추론되므로, get의 결과에 대한 타입 검사가 사실상 사라집니다.
TypeScript code
// 상태 패턴
const cache = new Map();
const getUser = (id: number) => { if (!cache.has(id)) cache.set(id, { id, name: "user" }); return cache.get(id); };
console.log(getUser(7));같은 키에 늘 같은 인스턴스를 돌려주면 참조 비교만으로 동일성을 판단할 수 있어, 상태 변화를 추적하기가 단순해집니다.
캐시된 객체를 그대로 돌려주면 호출한 쪽이 내용을 바꿀 수 있어, 다음 호출자가 이미 변형된 상태를 받아도 원인을 찾기 어렵습니다.
08팩터리 메서드
생성 대신 실행 흐름이 주제입니다. 배열에 담긴 함수들을 run(0)부터 재귀적으로 이어 붙여 ctx를 공유하며 단계를 진행합니다. 파이프라인 단계를 데이터로 다루고 싶을 때 쓰는 구조입니다. 재귀로 연결되기 때문에 중간 단계가 next()를 호출하지 않으면 뒤쪽 미들웨어는 전혀 실행되지 않고 조용히 건너뛰어집니다.
TypeScript code
// 팩터리 메서드
const middlewares = [(ctx, next) => { ctx.steps.push("a"); return next(); }, (ctx, next) => { ctx.steps.push("b"); return next(); }];
const ctx = { steps: [] };
let index = -1;
const run = (i) => { if (i <= index) throw new Error("twice"); index = i; const mw = middlewares[i]; if (mw) return mw(ctx, () => run(i + 1)); };
run(0);
console.log(ctx.steps);각 단계가 next()의 반환을 그대로 돌려주면 마지막 결과가 첫 호출까지 이어지므로, 나중에 비동기로 바꿔도 흐름이 유지됩니다.
중간 미들웨어가 next()를 부르지 않으면 뒤쪽 단계가 조용히 건너뛰어지고, 오류가 아니라 결과 누락으로만 드러납니다.
09빌더 패턴
단계적 조립 없이, 전략 두 개를 담은 객체를 리터럴로 한 번에 완성합니다. 구성 요소가 고정되어 있으면 빌더보다 이런 리터럴이 짧고 안전합니다. 게다가 리터럴로 쓰면 각 키의 존재가 컴파일 시점에 확정되어, 없는 이름을 부르는 실수를 실행 전에 잡을 수 있습니다.
TypeScript code
// 빌더 패턴
const strategies = { percent: (value: number) => value * 0.9, flat: (value: number) => value - 5 };
console.log(strategies.percent(100));객체 리터럴로 정의하면 각 키가 타입에 그대로 드러나 자동 완성이 되고, satisfies를 붙이면 값의 모양까지 함께 검사됩니다.
나중에 항목을 실행 중에 추가하도록 바꾸면 키 타입이 넓어져, 리터럴이 주던 컴파일 시점 검사가 통째로 사라집니다.
10캐시 래퍼
구독자 목록을 배열에 쌓아 두고 발행 시 전부 호출하는 코드로, 값을 저장했다 재사용하는 캐시와는 목적이 다릅니다. 이 형태는 결과 보관이 아니라 이벤트 전파에 씁니다. 주의할 점은 등록 해제 수단이 없다는 것으로, 구독만 계속 늘면 배열이 커져 메모리에 남고 같은 콜백이 여러 번 호출됩니다.
TypeScript code
// 캐시 래퍼
const listeners = [];
const subscribe = (fn) => listeners.push(fn);
const publish = (value: string) => listeners.forEach(fn => fn(value));
subscribe(console.log);
publish("update-10");구독을 해제하려면 subscribe가 제거 함수를 돌려주게 만들면 되고, 호출부는 그 함수 하나만 보관하면 됩니다.
해제 수단 없이 계속 구독하면 배열이 커지면서 같은 콜백이 여러 번 호출되고, 참조가 남아 메모리도 회수되지 않습니다.
11retry 유틸
재시도 로직은 없고, 실패 없이 한 번에 성공하는 Map 기반 조회가 있습니다. 캐시에 값이 없을 때만 만들고 이후에는 저장된 값을 그대로 돌려줍니다. 재시도를 얹을 때는 실패한 호출까지 캐시에 넣지 않도록 조심해야 하는데, 오류 값을 저장해 두면 이후 요청이 모두 같은 실패를 그대로 되돌려 줍니다.
TypeScript code
// retry 유틸
const cache = new Map();
const getUser = (id: number) => { if (!cache.has(id)) cache.set(id, { id, name: "user" }); return cache.get(id); };
console.log(getUser(11));성공한 결과만 저장하도록 시점을 정해 두면, 일시적인 실패가 영구 결과로 굳어 이후 요청까지 망가뜨리는 일을 막을 수 있습니다.
진행 중인 프로미스를 저장하지 않으면 같은 키에 대한 동시 요청이 각각 실행되어, 캐시가 있어도 중복 호출이 그대로 발생합니다.
12미들웨어 체인
체인의 핵심은 index 변수입니다. run(i)가 호출될 때마다 진행 위치를 기록해 두고, 이미 지난 인덱스를 다시 부르면 twice 오류를 던져 중복 진행을 차단합니다. 배열 끝에 도달하면 mw가 undefined라 재귀가 자연스럽게 멈추므로 별도 종료 조건이 필요 없습니다.
TypeScript code
// 미들웨어 체인
const middlewares = [(ctx, next) => { ctx.steps.push("a"); return next(); }, (ctx, next) => { ctx.steps.push("b"); return next(); }];
const ctx = { steps: [] };
let index = -1;
const run = (i) => { if (i <= index) throw new Error("twice"); index = i; const mw = middlewares[i]; if (mw) return mw(ctx, () => run(i + 1)); };
run(0);
console.log(ctx.steps);중복 진행을 예외로 바꿔 두면 잘못된 미들웨어가 뒤 단계를 조용히 두 번 실행하는 대신, 문제를 만든 자리에서 즉시 실패합니다.
index가 모듈 수준 변수라 이 체인은 한 번밖에 쓸 수 없고, run(0)을 다시 부르면 두 번째부터는 전부 예외가 납니다.
13검증기 조합
검증기 대신 계산 전략이 모여 있지만 조합 방식은 같습니다. 작은 함수를 키로 모아 두면 호출부에서 필요한 것만 골라 적용할 수 있습니다. 검증에 쓸 때 유의할 점은 각 함수가 boolean만 반환하면 어떤 규칙이 왜 실패했는지 알 수 없어, 실패 사유를 값으로 함께 돌려주는 형태가 실전에서 더 쓸모 있다는 것입니다.
TypeScript code
// 검증기 조합
const strategies = { percent: (value: number) => value * 0.9, flat: (value: number) => value - 5 };
console.log(strategies.percent(100));검사 함수가 실패 사유를 값으로 돌려주면 여러 규칙의 결과를 모아 한 번에 보여 줄 수 있고, 문구도 호출부에서 정할 수 있습니다.
boolean만 돌려주는 검사기를 여러 개 조합하면 어떤 규칙이 왜 실패했는지 알 수 없어, 사용자에게 보여 줄 메시지를 만들지 못합니다.
14포매터 파이프
포맷 변환 파이프 대신, 등록된 함수들에 같은 문자열을 뿌리는 발행 코드입니다. 구독자들은 서로 독립적으로 같은 입력을 받습니다. 파이프처럼 앞 단계 결과를 뒤로 넘기려면 forEach가 아니라 reduce로 값을 이어받아야 하며, 지금 구조는 각 함수의 반환값이 어디에도 쓰이지 않습니다.
TypeScript code
// 포매터 파이프
const listeners = [];
const subscribe = (fn) => listeners.push(fn);
const publish = (value: string) => listeners.forEach(fn => fn(value));
subscribe(console.log);
publish("update-14");앞 단계 결과를 뒤로 넘기려면 reduce로 값을 이어받아야 하며, 각 단계의 입력과 출력 타입을 같게 맞추면 순서를 자유롭게 바꿀 수 있습니다.
forEach로 이어 붙인 코드는 각 함수의 반환값이 버려져, 포맷 결과가 어디에도 반영되지 않고 원본이 그대로 남습니다.
15페이지네이션
페이지 단위 조회 대신 단건 조회 캐시가 구현되어 있습니다. cache.has(id)로 존재를 확인하고 없을 때만 새 객체를 만들어 저장합니다. Map의 키 비교는 값이 아니라 동일성 기준이라 숫자 15와 문자열 "15"는 서로 다른 항목이 되므로, 키 타입을 섞어 쓰면 캐시가 조용히 두 배로 늘어납니다.
TypeScript code
// 페이지네이션
const cache = new Map();
const getUser = (id: number) => { if (!cache.has(id)) cache.set(id, { id, name: "user" }); return cache.get(id); };
console.log(getUser(15));페이지 캐시는 번호와 크기를 함께 키로 삼아야, 크기를 바꾼 요청이 이전 크기의 결과를 잘못 재사용하지 않습니다.
Map은 키를 동일성으로 비교하므로 숫자 15와 문자열 '15'가 다른 항목이 되어, 캐시가 두 배로 늘고 적중률이 떨어집니다.
16데이터 매퍼
여기서 매핑에 해당하는 부분은 ctx를 미들웨어들이 차례로 변형하는 흐름입니다. 각 단계가 같은 객체를 받아 steps에 자기 표시를 남깁니다. 공유 객체를 직접 수정하는 방식이라 단계 순서가 결과에 그대로 반영되고, 어느 단계가 무엇을 바꿨는지는 실행해 봐야 알 수 있다는 대가가 따릅니다.
TypeScript code
// 데이터 매퍼
const middlewares = [(ctx, next) => { ctx.steps.push("a"); return next(); }, (ctx, next) => { ctx.steps.push("b"); return next(); }];
const ctx = { steps: [] };
let index = -1;
const run = (i) => { if (i <= index) throw new Error("twice"); index = i; const mw = middlewares[i]; if (mw) return mw(ctx, () => run(i + 1)); };
run(0);
console.log(ctx.steps);각 단계가 새 객체를 돌려주게 바꾸면 무엇이 달라졌는지 값으로 비교할 수 있어, 중간 상태를 로그로 남기기 훨씬 쉬워집니다.
공유 ctx를 직접 수정하면 단계 순서에 따라 결과가 달라지고, 한 단계가 필드를 지우면 뒤 단계가 이유 없이 실패합니다.
17저장소 패턴
데이터 접근 계층 대신 이름으로 동작을 찾는 객체가 있습니다. strategies.percent(100)처럼 키 하나로 구현을 선택하는 구조입니다. 저장소를 이런 식으로 만들면 메서드 목록이 타입에 그대로 드러나 자동 완성이 되지만, 키를 런타임 문자열로 조립하는 순간 그 이점이 사라집니다.
TypeScript code
// 저장소 패턴
const strategies = { percent: (value: number) => value * 0.9, flat: (value: number) => value - 5 };
console.log(strategies.percent(100));메서드를 객체에 모아 두면 이름 목록이 타입에 드러나 자동 완성과 참조 찾기가 동작하므로, 구조를 바꿀 때 사용처를 놓치지 않습니다.
키를 실행 중에 문자열로 조립하면 타입이 넓어져 자동 완성과 검사가 모두 사라지고, 오타가 런타임까지 그대로 살아남습니다.
18의존성 주입 컨테이너
컨테이너 없이, 배열에 함수를 담아 두고 나중에 꺼내 실행하는 가장 단순한 등록 구조입니다. 의존성 등록도 결국 이렇게 이름이나 순서로 무언가를 보관했다 꺼내는 일입니다. 다만 여기에는 식별자가 없어 등록한 함수를 골라 꺼내거나 교체할 수 없고, 등록 순서대로 전부 실행되는 것이 전부입니다.
TypeScript code
// 의존성 주입 컨테이너
const listeners = [];
const subscribe = (fn) => listeners.push(fn);
const publish = (value: string) => listeners.forEach(fn => fn(value));
subscribe(console.log);
publish("update-18");식별자로 꺼내려면 배열 대신 Map이나 객체에 이름을 키로 담아야 하고, 그래야 등록한 항목을 골라 교체할 수 있습니다.
등록 순서에만 의존하는 구조는 항목을 하나 추가하는 순간 이후 인덱스가 전부 밀려, 관련 없는 코드까지 함께 깨집니다.
19템플릿 메서드
템플릿 메서드의 골격 대신, 조회 절차를 getUser 함수 하나에 담았습니다. 캐시 확인, 생성, 반환이 항상 같은 순서로 실행됩니다. 절차는 고정하되 일부만 바꾸고 싶다면 생성 부분을 인자로 받는 함수로 빼면 되는데, 상속으로 훅을 여는 방식보다 이 편이 타입 추적이 쉽습니다.
TypeScript code
// 템플릿 메서드
const cache = new Map();
const getUser = (id: number) => { if (!cache.has(id)) cache.set(id, { id, name: "user" }); return cache.get(id); };
console.log(getUser(19));고정된 절차 중 일부만 바꿔야 한다면 그 부분을 함수 인자로 받는 편이, 상속으로 훅을 여는 방식보다 타입 추적과 테스트가 쉽습니다.
절차를 상속으로 나누면 하위 클래스가 어느 훅을 언제 덮어쓰는지 코드만으로 알기 어려워, 실행 순서를 착각한 구현이 들어가기 쉽습니다.
20파사드
복잡한 실행 순서를 run(0) 호출 하나로 감춘 형태입니다. 바깥에서는 시작 지점만 알면 되고 단계 연결, 중복 방지, 종료 판단은 안에서 처리됩니다. 대신 예외가 중간 미들웨어에서 발생하면 재귀 호출 스택을 타고 run(0) 지점까지 올라오므로, 어느 단계에서 났는지는 스택 추적을 봐야 구분됩니다.
TypeScript code
// 파사드
const middlewares = [(ctx, next) => { ctx.steps.push("a"); return next(); }, (ctx, next) => { ctx.steps.push("b"); return next(); }];
const ctx = { steps: [] };
let index = -1;
const run = (i) => { if (i <= index) throw new Error("twice"); index = i; const mw = middlewares[i]; if (mw) return mw(ctx, () => run(i + 1)); };
run(0);
console.log(ctx.steps);복잡한 절차를 함수 하나로 감추면 호출부는 시작 지점만 알면 되고, 내부 구조를 바꿔도 공개된 표면은 그대로 유지됩니다.
감춘 절차에서 예외가 나면 호출부는 어느 단계에서 실패했는지 알 수 없으므로, 단계 이름을 오류 메시지에 담아 두어야 조사할 수 있습니다.
정리하며
- 전략이 함수 하나로 표현된다면 인터페이스와 구현 클래스를 따로 만들지 않습니다.
- 상태 전이는 판별 유니온으로 두어 분기 누락을 컴파일러가 잡게 합니다.
- DI 컨테이너의 키는 문자열 대신 타입을 실은 토큰으로 두어 단언을 없앱니다.
- 미들웨어는 입력과 출력 형태를 통일해야 순서를 바꿔도 조합이 유지됩니다.
더 깊이 들어가고 싶다면 TypeScript 학습 라이브러리에서 다른 주제 가이드를 이어서 보거나, 언어 비교에서 같은 개념이 다른 언어에서 어떻게 표현되는지 확인해 보세요.