왜 먼저 디자인 패턴을 공부했는가
디자인 패턴은 자주 반복되는 설계 문제를 정리한 방법이다.
처음에는 면접 대비를 위해 패턴 이름부터 외웠다.
하지만 공부를 이어가면서 질문의 의도를 조금씩 이해하게 됐다.
면접관이 보고 싶은 건 암기량이 아니라 설계하는 방식이었다.
내가 정리한 기준은 아래 세 가지다.
- 책임을 어디에 둘지 설명할 수 있는가
- 결합도를 낮추는 선택을 했는가
- 변경이 생겼을 때 비용을 줄일 수 있는가
이 문서는 그 기준으로 다시 정리한 기록이다.
라이브러리와 프레임워크
패턴을 보기 전에 제어권부터 정리했다.
라이브러리
- 공통 기능을 모아둔 코드
- 내가 필요할 때 호출
- 제어권이 호출하는 쪽에 있음
프레임워크
- 구조와 실행 흐름을 제공
- 내 코드가 프레임워크 흐름 안에서 실행
- 제어권이 프레임워크에 있음 (IoC)
핵심은 누가 흐름을 잡는지다.
1. 싱글톤 패턴
하나의 인스턴스만 유지하는 패턴이다.
언제 썼는가
- WebSocket 연결
- Analytics SDK
- 앱 전역 설정
- Axios 인스턴스
예시 코드
class SocketManager {
private static instance: SocketManager;
private socket: WebSocket;
private constructor() {
this.socket = new WebSocket("wss://example.com");
}
static getInstance() {
if (!SocketManager.instance) {
SocketManager.instance = new SocketManager();
}
return SocketManager.instance;
}
getSocket() {
return this.socket;
}
}
const socket1 = SocketManager.getInstance();
const socket2 = SocketManager.getInstance();
console.log(socket1 === socket2); // true
그때의 판단, 지금의 기준
채팅 기능을 만들 때 컴포넌트마다 소켓을 새로 열어서 연결 문제가 났다.
그때 싱글톤으로 바꿔 문제를 막았다.
다만 이후 테스트를 붙이면서 단점이 분명해졌다.
전역 상태가 섞이면 테스트 격리가 어렵다.
지금은 이렇게 구분한다.
- 싱글톤: 연결, 클라이언트 같은 공유 리소스
- 상태 관리: 별도 레이어(Context, Zustand, Redux 등)
DI로 완화하기
function createService(logger: Logger) {
return {
doSomething() {
logger.log("event");
},
};
}
React의 props로 의존성을 전달하는 방식도 같은 맥락으로 본다.
2. 팩토리 패턴
객체 생성 책임을 분리하는 패턴이다.
왜 필요했는가
생성 로직이 퍼지면 중복이 늘고 수정 범위가 넓어진다.
생성 규칙을 한 곳에 모아두면 관리가 쉬워진다.
예시 코드
import axios from "axios";
type ClientType = "public" | "auth";
export function createApiClient(type: ClientType) {
const baseConfig = {
baseURL: "/api",
timeout: 5000,
};
if (type === "auth") {
return axios.create({
...baseConfig,
headers: {
Authorization: `Bearer ${localStorage.getItem("token")}`,
},
});
}
return axios.create(baseConfig);
}
얻은 점과 실수
처음에는 생성이 단순한 객체까지 팩토리로 감쌌다.
코드는 일관돼 보였지만 실제로는 복잡도만 늘었다.
지금은 기준을 두고 쓴다.
- 분기나 조합이 있는 생성
- 생성 정책이 자주 바뀌는 경우
- 테스트에서 대체 구현이 필요한 경우
면접에서 설명할 포인트
- 생성자와 달리, 팩토리는 조건에 따라 다른 구현을 반환할 수 있음
- 클라이언트가 생성 세부사항을 몰라도 되도록 경계를 만든다는 점
3. 전략 패턴
동작을 캡슐화하고 런타임에 교체하는 패턴이다.
if-else 분기를 줄이고 확장성을 확보할 때 쓴다.
예시 코드
interface LoggerStrategy {
log(event: string, payload?: any): void;
}
class ConsoleLogger implements LoggerStrategy {
log(event: string, payload?: any) {
console.log("[DEV]", event, payload);
}
}
class GaLogger implements LoggerStrategy {
log(event: string, payload?: any) {
window.gtag("event", event, payload);
}
}
class Logger {
constructor(private strategy: LoggerStrategy) {}
log(event: string, payload?: any) {
this.strategy.log(event, payload);
}
}
const logger =
process.env.NODE_ENV === "development"
? new Logger(new ConsoleLogger())
: new Logger(new GaLogger());
logger.log("button_click");
팩토리와의 차이
| 구분 | 팩토리 | 전략 |
|---|---|---|
| 목적 | 생성 분리 | 동작 교체 |
| 시점 | 생성 시점 | 실행 시점 |
내 사고 변화
처음에는 전략 패턴을 "클래스 문법"으로만 이해했다.
지금은 "바뀌는 축을 분리한다"는 원리로 기억한다.
그래서 함수 전달 방식으로도 같은 문제를 풀 수 있다고 본다.
4. 옵저버 패턴
상태 변경을 구독자에게 알리는 패턴이다.
예시 코드
type Listener<T> = (state: T) => void;
class Store<T> {
private state: T;
private listeners: Listener<T>[] = [];
constructor(initialState: T) {
this.state = initialState;
}
subscribe(listener: Listener<T>) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
setState(newState: T) {
this.state = newState;
this.listeners.forEach((listener) => listener(this.state));
}
getState() {
return this.state;
}
}
Redux, Zustand의 핵심 흐름도 이 구조와 닿아 있다.
놓치기 쉬운 문제
구독 해제를 빠뜨리면 메모리 누수로 이어질 수 있다.
React에서는 useEffect cleanup으로 정리한다.
이 부분은 실제로 한 번 놓쳤고, 그 뒤로는 구독/해제를 세트로 본다.
5. 프록시 패턴
실제 객체 접근을 중간에서 제어하는 패턴이다.
예시 코드
const user = { email: "" };
const userProxy = new Proxy(user, {
set(target, prop, value) {
if (prop === "email" && !value.includes("@")) {
throw new Error("Invalid email format");
}
target[prop as keyof typeof target] = value;
return true;
},
});
프론트엔드에서의 연결점
- 접근 제어
- 로깅, 캐싱 같은 횡단 관심사 삽입
6. 이터레이터 패턴
컬렉션의 내부 구조를 감추고 순회 인터페이스를 제공하는 패턴이다.
JavaScript는 이터레이터를 언어 차원에서 제공한다.
const arr = [1, 2, 3];
for (const value of arr) {
console.log(value);
}
내부적으로 Symbol.iterator가 동작한다.
제너레이터 예시
function* paginate(data: number[]) {
let index = 0;
while (index < data.length) {
yield data[index++];
}
}
const iterator = paginate([10, 20, 30]);
console.log(iterator.next()); // { value: 10, done: false }
console.log(iterator.next()); // { value: 20, done: false }
useInfiniteQuery 같은 기능을 볼 때도
"다음 데이터를 순차적으로 이어 붙인다"는 관점에서 이해하면 도움이 됐다.
패턴 요약
| 패턴 | 해결하는 문제 | 프론트 활용 |
|---|---|---|
| 싱글톤 | 인스턴스 하나 유지 | WebSocket, Axios 인스턴스 |
| 팩토리 | 생성 책임 분리 | API Client 생성 |
| 전략 | 동작 교체 | Logger, 결제 수단 |
| 옵저버 | 상태 변화 알림 | Redux, Zustand |
| 프록시 | 접근 제어 | Vue 3 반응성 |
| 이터레이터 | 순회 추상화 | 무한 스크롤/페이지네이션 |
패턴 조합 예시
팩토리 + 전략
function createLogger(env: string): Logger {
const strategy = env === "prod" ? new GaLogger() : new ConsoleLogger();
return new Logger(strategy);
}
const logger = createLogger(process.env.NODE_ENV || "development");
싱글톤 + 옵저버
class AppStore {
private static instance: AppStore;
private listeners: Array<(state: any) => void> = [];
private state: Record<string, unknown> = {};
static getInstance() {
if (!AppStore.instance) {
AppStore.instance = new AppStore();
}
return AppStore.instance;
}
subscribe(listener: (state: any) => void) {
this.listeners.push(listener);
return () => {
this.listeners = this.listeners.filter((l) => l !== listener);
};
}
setState(newState: Record<string, unknown>) {
this.state = newState;
this.listeners.forEach((listener) => listener(this.state));
}
}
흔한 안티패턴
React 컴포넌트를 싱글톤처럼 다루기
컴포넌트는 재사용 단위다.
리소스 싱글톤과 컴포넌트 역할을 섞으면 구조가 꼬인다.
단순 유틸까지 모두 팩토리/클래스로 감싸기
추상화 자체가 목적이 되면 유지보수성이 떨어진다.
복잡도를 줄이기 위한 추상화인지 먼저 확인한다.
반환 타입으로 구체 클래스를 노출하기
function createUser(type: string): User {
if (type === "admin") return new AdminUser();
return new RegularUser();
}
핵심은 User 인터페이스로 경계를 유지하는 것이다.
왜 6개만 먼저 다뤘는가
GoF 23개를 한 번에 다 이해하려고 하면 흐름을 잃기 쉬웠다.
그래서 프론트엔드에서 직접 자주 만나는 패턴부터 정리했다.
- 생성 패턴: 싱글톤, 팩토리 중심
- 구조 패턴: 프록시 중심
- 행동 패턴: 전략, 옵저버, 이터레이터 중심
다른 패턴(어댑터, 데코레이터, 커맨드 등)은
필요한 문제를 만났을 때 확장해도 늦지 않다고 판단했다.
패러다임과 함께 보기
디자인 패턴은 OOP 시절에 체계화됐지만,
지금은 함수형 사고와 함께 섞어서 쓰는 경우가 많다.
예를 들어 전략 패턴은 클래스로 구현할 수도 있고,
함수 전달로 더 간단하게 표현할 수도 있다.
function log(strategy: (msg: string) => void, msg: string) {
strategy(msg);
}
중요한 건 문법보다 경계다.
무엇이 바뀌고, 무엇을 고정할지를 나누는 방식이 핵심이다.
복습 질문
1) 아래 코드는 어떤 관점으로 설명할 수 있는가?
function render(component: Component) {
return component.render();
}
render 동작을 인터페이스 뒤로 숨긴다는 점에서
전략 패턴 관점으로 설명할 수 있다.
2) 싱글톤과 static method의 차이는?
싱글톤은 인스턴스 개수를 제어하는 패턴이고,static은 인스턴스 없이 호출되는 클래스 멤버다.
둘은 목적이 다르다.
3) if-else 분기만으로 전략을 관리하면 어떤 문제가 생기나?
- 전략 추가 시 기존 코드 수정이 반복됨
- 분기문이 길어져 읽기 어려움
- 특정 전략만 분리해 테스트하기 어려움
4) setInterval은 옵저버 패턴인가?
보통은 아니다.
상태 변화를 구독하는 구조가 아니라 시간 기반 반복 실행이기 때문이다.
정리
예전에는 패턴을 "정답 목록"처럼 다뤘다.
지금은 문제를 먼저 보고 패턴을 꺼내는 쪽으로 바뀌었다.
아직도 설계에서 흔들릴 때가 많다.
그래도 최소한 아래 질문을 먼저 확인하게 됐다.
- 어디가 자주 바뀌는가
- 어떤 의존성을 끊어야 하는가
- 테스트 가능한 구조인가
이 변화가 지금까지 공부하면서 얻은 가장 현실적인 차이다.