프롤로그 - "이거 어디서 본 코드인데..."
3년 차 때 새 프로젝트에 투입됐다. 코드를 열어보는데... 묘한 기시감이 들었다.
"어? 이 구조... 저번 프로젝트에서 본 것 같은데?"
Database 연결 관리하는 방식, API 객체 생성하는 패턴, 이벤트 리스너 구조... 전혀 다른 회사, 다른 팀, 다른 프로젝트인데 코드 패턴이 이상하리만치 비슷했다.
더 신기했던 건, 예전에 공부할 때 봤던 C# 코드와 지금 보는 JavaScript 코드가 언어는 다른데 구조는 똑같다는 거였다.
시니어에게 물었습니다:
"이 코드 구조, 의도적으로 이렇게 짠 거예요?"
시니어: "당연하지. 싱글톤이라고. 디자인 패턴이야."
저: "디자인 패턴이요?"
시니어: "족보야, 족보. 선배들이 30년 동안 같은 문제 풀면서 만든 모범 답안. 언어는 달라도 문제는 똑같으니까. GoF 책 한 번 읽어봐."
그날 저녁, GoF(Gang of Four)의 "Design Patterns" 책을 주문했다. 그리고 책이 도착하자마자... 700페이지를 보고 후회했다.
왜 공부하게 되었나 - 삽질의 기록
처음엔 왜 필요한지 전혀 몰랐다.
"패턴? 그냥 내 마음대로 짜면 안 돼? 내가 만든 코드가 제일 직관적이잖아."
하지만 오픈소스 프로젝트에서 코드 리뷰를 받으면서 현실을 깨달았다.
첫 번째 리뷰:
// 제가 짠 코드
let dbConnection1 = createDatabaseConnection();
let dbConnection2 = createDatabaseConnection();
let dbConnection3 = createDatabaseConnection();
리뷰어: "DB 연결은 하나만 있어야 해요. 싱글톤 패턴 쓰세요."
저: "싱글톤이요?" (구글링 시작)
리뷰어: "getInstance() 메서드로 인스턴스 하나만 반환하도록..."
→ 30초 설명 듣고 겨우 이해
두 번째 리뷰 (한 달 후):
리뷰어: "여기도 싱글톤 쓰시죠."
저: "아, 넵!" (1초 만에 이해)
이 순간 깨달았다.
디자인 패턴은 "코드 짜는 방법"이 아니라 "개발자들의 공용 단어장"이었다.
같은 문제를 "싱글톤"이라는 한 단어로 압축해서 전달할 수 있는 거였다. 30초짜리 설명이 1초로 줄어드는 마법.
처음엔 뭐가 이해가 안 갔나
GoF 책을 펼쳤을 때 충격받았던 것들:
-
패턴이 23개나 된다고? "이거 다 외워야 해? 나 국비 과정도 아닌데..."
-
언제 어떤 패턴을 써야 하지? "Singleton이랑 Factory랑... 뭐가 달라?"
-
그냥 직관적으로 짜면 안 돼? "패턴 쓰니까 오히려 코드가 복잡해 보이는데?"
-
예제 코드가 너무 어렵다 "Shape, Circle, Rectangle... 현실에서 도형 그릴 일이 어디 있어?"
무엇보다 이해 안 갔던 건, "이게 진짜 실제로 쓰이긴 하나?"였다.
책 예제는 도형, 동물, 자동차... 너무 학문적이었다.
깨달음의 순간 - "요리 레시피"
한 달 동안 책만 보다가 실제 코드를 다시 봤다. 그리고 충격을 받았다.
"어? 이게 다 패턴이었네?"
- Redux Store → Observer 패턴
- React의
new Promise()→ Factory 패턴 - Axios Instance → Singleton 패턴
Array.map()→ Strategy 패턴
내가 이미 쓰고 있던 코드들이 전부 디자인 패턴이었다. 다만 이름을 몰랐을 뿐.
시니어의 비유가 그때 와닿았습니다:
"디자인 패턴은 요리 레시피야.
파스타를 처음 만들 때:
- 소금 얼마? 면 삶는 시간은?
- 물 양은? 오일은 언제 넣지?
레시피를 보면: '물 1L당 소금 10g, 끓으면 면 8분'
디자인 패턴도 마찬가지. '이 문제는 싱글톤 레시피. 저 문제는 팩토리 레시피.'
매번 새로 고민하지 말고, 검증된 레시피 따라 하면 실패가 적어."
결국 이거였다: 디자인 패턴은 "새로운 기술"이 아니라 "이미 쓰던 코드에 이름 붙이기"였다.
이름을 알면 의사소통이 빨라지는 거였다.
1. 꼭 알아야 할 3대장
내가 5년 동안 실제로 가장 많이 쓴 3가지를 정리해본다.
싱글톤 (Singleton) - "세상에 단 하나"
문제 상황 - 메모리 폭탄
제가 처음 만든 채팅 앱에서 이런 버그가 있었습니다:
// 메시지 보낼 때마다 DB 연결 생성
function sendMessage(text) {
const db = new Database(); // 새 연결 생성
db.insert({ message: text });
}
// 사용자가 메시지 100개 보내면...
// DB 연결이 100개 생성됨 💀
// 메모리 폭탄 + 서버 다운
테스트할 땐 괜찮았는데, 실제 사용자 100명이 동시에 쓰니까 서버가 다운됐습니다.
왜? DB 연결 객체를 매번 새로 만들어서, 메모리에 쌓이고 쌓여서 터진 겁니다.
해결 - getInstance()로 하나만 반환
class Database {
static instance = null;
constructor() {
if (Database.instance) {
return Database.instance; // 이미 있으면 기존 거 반환
}
Database.instance = this;
this.connection = this.createConnection();
console.log("Database 연결 생성됨");
}
static getInstance() {
if (!Database.instance) {
Database.instance = new Database();
}
return Database.instance;
}
createConnection() {
// 실제 DB 연결 로직
return { connected: true };
}
}
// 사용
const db1 = Database.getInstance();
const db2 = Database.getInstance();
const db3 = Database.getInstance();
console.log(db1 === db2); // true
console.log(db2 === db3); // true
// 아무리 많이 호출해도 인스턴스는 1개
// 로그: "Database 연결 생성됨" (한 번만 출력)
실제 사용 예시
1. 환경 설정 관리자
class Config {
static instance = null;
constructor() {
if (Config.instance) return Config.instance;
this.settings = {
apiUrl: process.env.API_URL,
apiKey: process.env.API_KEY,
timeout: 5000
};
Config.instance = this;
}
static getInstance() {
if (!Config.instance) {
Config.instance = new Config();
}
return Config.instance;
}
get(key) {
return this.settings[key];
}
}
// 어디서든 같은 설정 객체 사용
const config1 = Config.getInstance();
const config2 = Config.getInstance();
console.log(config1.get("apiUrl")); // 같은 설정
console.log(config1 === config2); // true
2. Logger (로그 파일 중복 방지)
class Logger {
static instance = null;
constructor() {
if (Logger.instance) return Logger.instance;
this.logs = [];
Logger.instance = this;
}
log(message) {
const timestamp = new Date().toISOString();
this.logs.push(`[${timestamp}] ${message}`);
console.log(`[${timestamp}] ${message}`);
}
getLogs() {
return this.logs;
}
}
// 여러 모듈에서 같은 Logger 사용
const logger1 = new Logger();
logger1.log("User logged in");
const logger2 = new Logger();
logger2.log("Data fetched");
console.log(logger1.getLogs()); // 두 로그 모두 포함
console.log(logger1 === logger2); // true
주의사항 - 테스트가 어렵다
싱글톤의 단점은 테스트가 까다롭다는 거다.
// ❌ 테스트 간 상태 공유 문제
test("첫 번째 테스트", () => {
const db = Database.getInstance();
db.insert({ id: 1 });
expect(db.count()).toBe(1);
});
test("두 번째 테스트", () => {
const db = Database.getInstance(); // 같은 인스턴스!
expect(db.count()).toBe(0); // ❌ 실패 (이전 테스트 데이터 남아있음)
});
해결책: reset() 메서드 추가
class Database {
// ...
static reset() {
Database.instance = null;
}
}
// 각 테스트 후 리셋
afterEach(() => {
Database.reset();
});
팩토리 (Factory) - "공장에 주문하기"
문제 상황 - if-else 지옥
제가 크로스 플랫폼 앱을 만들 때 겪은 일입니다:
// 플랫폼마다 다른 버튼 생성
function createButton(platform, text) {
let button;
if (platform === "ios") {
button = new IOSButton(text);
button.setStyle({ borderRadius: 10, shadow: true });
} else if (platform === "android") {
button = new AndroidButton(text);
button.setStyle({ elevation: 5, ripple: true });
} else if (platform === "web") {
button = new WebButton(text);
button.setStyle({ hover: true, transition: "0.3s" });
} else if (platform === "windows") {
button = new WindowsButton(text);
button.setStyle({ flat: true });
}
return button;
}
// 문제:
// 1. 새 플랫폼 추가 시 이 함수를 찾아서 수정해야 함
// 2. 버튼 생성하는 곳이 100곳이면? 100곳 다 수정
// 3. iOS 버튼 스타일 바뀌면? 모든 곳을 찾아다니며 수정
실제로 macOS 지원 추가할 때 200개 파일을 수정했습니다. 2주 걸렸습니다.
해결 - Factory에 주문하기
// 1단계: 버튼 인터페이스 통일
class Button {
constructor(text) {
this.text = text;
}
render() {
throw new Error("Must implement render()");
}
}
class IOSButton extends Button {
render() {
return `<button class="ios">${this.text}</button>`;
}
}
class AndroidButton extends Button {
render() {
return `<button class="android">${this.text}</button>`;
}
}
class WebButton extends Button {
render() {
return `<button class="web">${this.text}</button>`;
}
}
// 2단계: Factory 만들기
class ButtonFactory {
static createButton(platform, text) {
switch(platform) {
case "ios":
return new IOSButton(text);
case "android":
return new AndroidButton(text);
case "web":
return new WebButton(text);
default:
throw new Error(`Unknown platform: ${platform}`);
}
}
}
// 사용
const platform = detectPlatform(); // "ios", "android", "web"
const button = ButtonFactory.createButton(platform, "Submit");
button.render();
// 새 플랫폼 추가 시:
// 1. MacOSButton 클래스 만들기
// 2. ButtonFactory에 case 하나만 추가
// 3. 끝! (기존 코드 수정 없음)
실제 경험 - 결제 시스템
제가 이커머스 앱을 만들 때 결제 시스템을 Factory로 구현했습니다:
class PaymentFactory {
static createPayment(method) {
switch(method) {
case "creditCard":
return new CreditCardPayment();
case "paypal":
return new PayPalPayment();
case "kakao":
return new KakaoPayPayment();
case "toss":
return new TossPayment();
default:
throw new Error(`Unknown payment method: ${method}`);
}
}
}
// 사용
function checkout(amount, paymentMethod) {
const payment = PaymentFactory.createPayment(paymentMethod);
try {
const result = payment.process(amount);
if (result.success) {
console.log("결제 성공!");
}
} catch (error) {
console.error("결제 실패:", error);
}
}
checkout(10000, "kakao"); // 카카오페이로 결제
checkout(20000, "toss"); // 토스로 결제
장점:
- 새 결제 수단 추가 시 Factory만 수정
- 각 결제 클래스는 독립적으로 관리
- 테스트하기 쉬움 (Mock 객체 주입 가능)
팁:
나중에 네이버페이가 추가됐을 때, Factory만 수정하면 됐습니다:
class PaymentFactory {
static createPayment(method) {
switch(method) {
// ... 기존 코드
case "naver": // 이 한 줄만 추가
return new NaverPayment();
default:
throw new Error(`Unknown payment method: ${method}`);
}
}
}
기존 200개 파일을 수정하던 게 → 1개 파일만 수정으로 바뀌었습니다.
옵저버 (Observer) - "구독과 알림"
문제 상황 - 수동 업데이트 지옥
사용자 프로필을 만들 때 이런 코드를 짰습니다:
// 사용자 이름 변경
function updateUserName(newName) {
user.name = newName;
// 모든 UI를 수동으로 업데이트해야 함
updateHeaderUI(newName); // 헤더의 이름
updateProfileUI(newName); // 프로필 페이지
updateSidebarUI(newName); // 사이드바
updateChatUI(newName); // 채팅창
updateNotificationUI(newName); // 알림
sendAnalyticsEvent(newName); // 분석 이벤트
}
// 문제:
// 1. 새 UI 추가 시 여기도 수정해야 함
// 2. 하나라도 빠뜨리면 버그
// 3. 이름 외에 이메일, 프로필 사진도? 함수가 3배로 늘어남
실제로 겪은 버그: 채팅창 업데이트를 빠뜨려서, 채팅창에만 이전 이름이 표시되는 버그가 3주 동안 배포됐습니다.
해결 - 구독-알림 패턴
// Subject: 관찰 대상
class Subject {
constructor() {
this.observers = []; // 구독자 목록
}
subscribe(observer) {
this.observers.push(observer);
}
unsubscribe(observer) {
this.observers = this.observers.filter(o => o !== observer);
}
notify(data) {
this.observers.forEach(observer => {
observer.update(data);
});
}
}
// 사용자 클래스
class User extends Subject {
constructor(name) {
super();
this._name = name;
}
setName(name) {
this._name = name;
this.notify({ name }); // 자동으로 모든 구독자에게 알림
}
getName() {
return this._name;
}
}
// 구독자들 (Observer)
const headerUI = {
update: (data) => {
console.log(`Header updated: ${data.name}`);
document.querySelector("#header-name").textContent = data.name;
}
};
const profileUI = {
update: (data) => {
console.log(`Profile updated: ${data.name}`);
document.querySelector("#profile-name").textContent = data.name;
}
};
const chatUI = {
update: (data) => {
console.log(`Chat updated: ${data.name}`);
document.querySelector("#chat-name").textContent = data.name;
}
};
// 구독 설정
const user = new User("Alice");
user.subscribe(headerUI);
user.subscribe(profileUI);
user.subscribe(chatUI);
// 이름 변경
user.setName("Bob");
// 출력:
// Header updated: Bob
// Profile updated: Bob
// Chat updated: Bob
// 새 UI 추가? subscribe만 하면 끝
const sidebarUI = {
update: (data) => console.log(`Sidebar: ${data.name}`)
};
user.subscribe(sidebarUI);
user.setName("Charlie");
// 자동으로 sidebar도 업데이트됨
실제 사용 예시 - 주식 앱
제가 주식 시세 앱을 만들 때 옵저버 패턴을 썼습니다:
class StockMarket extends Subject {
constructor() {
super();
this.stocks = {};
}
updatePrice(symbol, price) {
this.stocks[symbol] = price;
this.notify({ symbol, price });
}
}
// 구독자: 가격 차트
const priceChart = {
update: (data) => {
console.log(`Chart: ${data.symbol} = $${data.price}`);
// 차트 그리기
}
};
// 구독자: 가격 알림
const priceAlert = {
update: (data) => {
if (data.price > 100) {
console.log(`Alert: ${data.symbol} exceeded $100!`);
}
}
};
// 구독자: 거래 자동화
const autoTrader = {
update: (data) => {
if (data.price < 50) {
console.log(`Auto-buy ${data.symbol} at $${data.price}`);
}
}
};
const market = new StockMarket();
market.subscribe(priceChart);
market.subscribe(priceAlert);
market.subscribe(autoTrader);
// 가격 변경
market.updatePrice("AAPL", 150);
// 출력:
// Chart: AAPL = $150
// Alert: AAPL exceeded $100!
market.updatePrice("TSLA", 45);
// 출력:
// Chart: TSLA = $45
// Auto-buy TSLA at $45
실제로 이미 쓰고 있는 옵저버 패턴
사실 이미 옵저버 패턴을 쓰고 있었다:
1. JavaScript의 이벤트 리스너
// addEventListener = subscribe
// dispatchEvent = notify
const button = document.querySelector("#btn");
button.addEventListener("click", () => {
console.log("Clicked!");
}); // 구독
button.click(); // 알림
2. React의 useState
const [count, setCount] = useState(0);
useEffect(() => {
console.log(`Count changed: ${count}`);
}, [count]); // count를 "구독"
setCount(1); // "알림" → useEffect 실행
3. Redux Store
store.subscribe(() => {
console.log("State changed:", store.getState());
}); // 구독
store.dispatch({ type: "INCREMENT" }); // 알림
이미 쓰던 패턴이었다. 이름만 몰랐을 뿐.
2. 나머지 패턴들 (실제 버전)
내가 실제로 가끔 쓰는 패턴들을 정리해본다.
빌더 (Builder) - "햄버거 커스텀 주문"
문제 상황 - 생성자 지옥
// ❌ 매개변수가 너무 많음
class User {
constructor(name, email, age, phone, address, city, country, postalCode, avatar) {
this.name = name;
this.email = email;
this.age = age;
this.phone = phone;
this.address = address;
this.city = city;
this.country = country;
this.postalCode = postalCode;
this.avatar = avatar;
}
}
// 사용할 때 헷갈림
const user = new User(
"Alice",
"alice@example.com",
25,
"010-1234-5678",
"123 Main St",
"Seoul",
"Korea",
"12345",
"avatar.png"
);
// 순서 기억 못함 💀
해결 - Builder 패턴
class UserBuilder {
constructor() {
this.user = {};
}
setName(name) {
this.user.name = name;
return this; // 체이닝을 위해 this 반환
}
setEmail(email) {
this.user.email = email;
return this;
}
setAge(age) {
this.user.age = age;
return this;
}
setPhone(phone) {
this.user.phone = phone;
return this;
}
setAddress(address, city, country, postalCode) {
this.user.address = { address, city, country, postalCode };
return this;
}
setAvatar(avatar) {
this.user.avatar = avatar;
return this;
}
build() {
return this.user;
}
}
// 사용 (직관적!)
const user = new UserBuilder()
.setName("Alice")
.setEmail("alice@example.com")
.setAge(25)
.setPhone("010-1234-5678")
.setAddress("123 Main St", "Seoul", "Korea", "12345")
.setAvatar("avatar.png")
.build();
// 선택적 필드도 쉽게 처리
const simpleUser = new UserBuilder()
.setName("Bob")
.setEmail("bob@example.com")
.build(); // 나머지는 생략 가능
실제 예시: HTTP Request Builder
class RequestBuilder {
constructor(url) {
this.url = url;
this.method = "GET";
this.headers = {};
this.body = null;
}
setMethod(method) {
this.method = method;
return this;
}
setHeader(key, value) {
this.headers[key] = value;
return this;
}
setBody(body) {
this.body = body;
return this;
}
async send() {
const response = await fetch(this.url, {
method: this.method,
headers: this.headers,
body: this.body
});
return response.json();
}
}
// 사용
const data = await new RequestBuilder("https://api.example.com/users")
.setMethod("POST")
.setHeader("Content-Type", "application/json")
.setHeader("Authorization", "Bearer token123")
.setBody(JSON.stringify({ name: "Alice" }))
.send();
이거 어디서 본 것 같지 않나? jQuery, Axios, Fetch API 전부 Builder 패턴이다.
// jQuery (Builder)
$.ajax({
url: "/api/users",
method: "POST",
headers: { "Authorization": "Bearer token" },
data: { name: "Alice" }
});
// Axios (Builder)
axios.post("/api/users", { name: "Alice" })
.then(response => console.log(response.data));
전략 (Strategy) - "결제 수단 바꿔 끼우기"
문제 상황 - if-else 또 나왔다
function processPayment(amount, method) {
if (method === "creditCard") {
// 신용카드 결제 로직
console.log("Processing credit card payment...");
// 100줄 코드
} else if (method === "paypal") {
// PayPal 결제 로직
console.log("Processing PayPal payment...");
// 100줄 코드
} else if (method === "kakao") {
// 카카오페이 결제 로직
console.log("Processing Kakao Pay...");
// 100줄 코드
}
// 새 결제 수단 추가 시 이 함수가 500줄 넘어감
}
해결 - 전략 교체 가능하게
// 전략 인터페이스
class PaymentStrategy {
pay(amount) {
throw new Error("Must implement pay()");
}
}
// 구체적 전략들
class CreditCardStrategy extends PaymentStrategy {
pay(amount) {
console.log(`Credit card payment: $${amount}`);
// 신용카드 결제 로직
}
}
class PayPalStrategy extends PaymentStrategy {
pay(amount) {
console.log(`PayPal payment: $${amount}`);
// PayPal 결제 로직
}
}
class KakaoPayStrategy extends PaymentStrategy {
pay(amount) {
console.log(`Kakao Pay: $${amount}`);
// 카카오페이 결제 로직
}
}
// Context: 전략을 사용하는 곳
class PaymentContext {
constructor(strategy) {
this.strategy = strategy;
}
setStrategy(strategy) {
this.strategy = strategy;
}
processPayment(amount) {
this.strategy.pay(amount);
}
}
// 사용
const payment = new PaymentContext(new CreditCardStrategy());
payment.processPayment(100); // Credit card payment: $100
// 나중에 전략 교체
payment.setStrategy(new PayPalStrategy());
payment.processPayment(200); // PayPal payment: $200
// 또 교체
payment.setStrategy(new KakaoPayStrategy());
payment.processPayment(300); // Kakao Pay: $300
실제 예시 - 정렬 알고리즘
class SortStrategy {
sort(data) {
throw new Error("Must implement sort()");
}
}
class BubbleSort extends SortStrategy {
sort(data) {
console.log("Using Bubble Sort");
// 버블 정렬 로직
return data.sort((a, b) => a - b);
}
}
class QuickSort extends SortStrategy {
sort(data) {
console.log("Using Quick Sort");
// 퀵 정렬 로직
return data.sort((a, b) => a - b);
}
}
class Sorter {
constructor(strategy) {
this.strategy = strategy;
}
sort(data) {
return this.strategy.sort(data);
}
}
// 사용
const data = [5, 2, 8, 1, 9];
const sorter = new Sorter(new BubbleSort());
console.log(sorter.sort(data)); // Using Bubble Sort
// 데이터가 크면 QuickSort로 변경
const bigData = Array.from({ length: 10000 }, () => Math.random());
sorter.strategy = new QuickSort();
console.log(sorter.sort(bigData)); // Using Quick Sort
실제로 이미 쓰는 곳:
Array.sort()(비교 함수를 주입 = 전략 주입)fetch()(Request 옵션 = 전략)- React의 Context Provider (전략 제공)
어댑터 (Adapter) - "플러그 변환기"
문제 상황 - API 변경
프로젝트에서 결제 API를 바꾸게 됐습니다. 문제는 기존 코드가 100개 파일에 퍼져있다는 거였습니다.
// 구 API
class OldPaymentAPI {
processTransaction(user, amount) {
return {
status: "success",
transactionId: "12345",
user: user,
amount: amount
};
}
}
// 신 API (형식이 완전히 다름)
class NewPaymentAPI {
pay(userId, amountInCents) {
return {
success: true,
id: "67890",
user_id: userId,
amount_cents: amountInCents
};
}
}
// 문제: 기존 코드 100곳에서 이렇게 쓰고 있음
const result = oldAPI.processTransaction("user123", 100);
if (result.status === "success") {
console.log(result.transactionId);
}
// 신 API로 바꾸려면 100곳 다 수정해야 함 💀
해결 - Adapter로 중간 다리
// Adapter: 구 API 형식을 신 API로 변환
class PaymentAdapter {
constructor(newAPI) {
this.newAPI = newAPI;
}
processTransaction(user, amount) {
// 신 API 호출 (cents 단위로 변환)
const result = this.newAPI.pay(user, amount * 100);
// 구 API 형식으로 변환해서 반환
return {
status: result.success ? "success" : "failed",
transactionId: result.id,
user: result.user_id,
amount: result.amount_cents / 100
};
}
}
// 사용
const newAPI = new NewPaymentAPI();
const adapter = new PaymentAdapter(newAPI);
// 기존 코드 그대로 사용 가능!
const result = adapter.processTransaction("user123", 100);
if (result.status === "success") {
console.log(result.transactionId); // 작동함!
}
// 100개 파일 수정 안 해도 됨!
실제 예시 - 라이브러리 변경
jQuery에서 React로 마이그레이션할 때 썼던 방법입니다:
// jQuery 코드를 React로 감싸기
class JQueryAdapter {
constructor(selector) {
this.element = document.querySelector(selector);
}
// jQuery 스타일 메서드
text(value) {
if (value === undefined) {
return this.element.textContent;
}
this.element.textContent = value;
return this;
}
addClass(className) {
this.element.classList.add(className);
return this;
}
removeClass(className) {
this.element.classList.remove(className);
return this;
}
on(event, handler) {
this.element.addEventListener(event, handler);
return this;
}
}
// 기존 jQuery 코드
// $("#header").text("Hello").addClass("active");
// Adapter로 점진적 마이그레이션
const $ = (selector) => new JQueryAdapter(selector);
$("#header").text("Hello").addClass("active");
// jQuery 없이도 작동!
실제 꿀팁: 라이브러리 변경할 때 Adapter 쓰면 점진적으로 마이그레이션 가능하다. 한 번에 다 바꾸려다가 망하는 것보다 훨씬 안전하다.
3. 주의사항 - 망치 증후군 (Golden Hammer)
"망치를 가진 사람에겐 모든 게 못으로 보인다."
내가 디자인 패턴을 처음 배웠을 때 한 실수를 공개한다.
Before (패턴 배우기 전):
// 간단한 계산기
function add(a, b) {
return a + b;
}
function subtract(a, b) {
return a - b;
}
console.log(add(5, 3)); // 8
After (패턴 배운 직후):
// ❌ 과도한 패턴 남용
class CalculationStrategy {
calculate(a, b) {
throw new Error("Must implement");
}
}
class AdditionStrategy extends CalculationStrategy {
calculate(a, b) {
return a + b;
}
}
class SubtractionStrategy extends CalculationStrategy {
calculate(a, b) {
return a - b;
}
}
class CalculatorFactory {
static createCalculator(type) {
switch(type) {
case "add":
return new AdditionStrategy();
case "subtract":
return new SubtractionStrategy();
}
}
}
class CalculatorContext {
constructor(strategy) {
this.strategy = strategy;
}
execute(a, b) {
return this.strategy.calculate(a, b);
}
}
// 사용 (복잡해짐)
const factory = CalculatorFactory;
const addStrategy = factory.createCalculator("add");
const calculator = new CalculatorContext(addStrategy);
console.log(calculator.execute(5, 3)); // 8
// 간단한 덧셈에 5개 클래스 + 50줄 코드 💀
시니어의 코드 리뷰:
"이게 뭐야? 간단한 덧셈에 왜 이렇게 복잡하게 만들었어?"
이때 받아들였다. 패턴은 도구일 뿐이다. 과하면 독이 된다.
언제 쓰지 말아야 할까?
내가 정리해본 기준이다:
1. YAGNI (You Ain't Gonna Need It)
// ❌ 미래를 대비한 과도한 설계
class UserFactory {
static createUser(name, type = "normal") {
switch(type) {
case "normal":
return new NormalUser(name);
case "admin": // 지금은 안 쓰는데 "나중에 필요할 것 같아서" 추가
return new AdminUser(name);
case "guest": // 이것도
return new GuestUser(name);
case "premium": // 이것도
return new PremiumUser(name);
}
}
}
// ✅ 지금 당장 필요한 것만
class User {
constructor(name) {
this.name = name;
}
}
const user = new User("Alice");
교훈: "나중에 필요할 것 같아서"는 대부분 필요 없다. 필요해지면 그때 추가하면 된다.
2. KISS (Keep It Simple, Stupid)
// ❌ 간단한 로거를 복잡하게
class LoggerSingleton {
static instance = null;
constructor() {
if (LoggerSingleton.instance) {
return LoggerSingleton.instance;
}
this.logs = [];
LoggerSingleton.instance = this;
}
log(message) {
this.logs.push(message);
}
}
// ✅ 그냥 console.log 쓰세요
console.log("Hello");
// 정말 로그 저장이 필요할 때만 싱글톤 쓰기
3. 성능이 중요한 곳
// ❌ 게임 루프에서 패턴 남용
class GameLoop {
update() {
// 60fps로 돌아가는 곳에서
const renderer = RendererFactory.createRenderer("webgl"); // 매 프레임마다 생성 💀
const physics = PhysicsFactory.createEngine("box2d"); // 느려짐
renderer.render();
physics.update();
}
}
// ✅ 한 번만 생성
class GameLoop {
constructor() {
this.renderer = new WebGLRenderer(); // 초기화 시 한 번
this.physics = new Box2DEngine();
}
update() {
this.renderer.render(); // 재사용
this.physics.update();
}
}
실제 망치 증후군 사례
제가 본 최악의 경우:
// 실제로 본 코드 (회사 이름은 비공개)
class SingletonFactoryObserverStrategyAdapterProxy {
// ...800줄
}
이 클래스는:
- Singleton (전역 상태)
- Factory (객체 생성)
- Observer (이벤트 구독)
- Strategy (알고리즘 교체)
- Adapter (API 변환)
- Proxy (접근 제어)
6개 패턴을 한 클래스에 다 넣었습니다.
결과:
- 아무도 이해 못함
- 버그 수정 불가능
- 결국 전체 리팩토링
교훈: 패턴 1개 = 좋음 패턴 2개 = 괜찮음 패턴 3개 이상 = 의심 패턴 6개 = 재설계 필요
4. 정리 - 패턴 선택 가이드
내가 5년 동안 정리한 가이드다:
| 문제 | 패턴 | 실제 예시 | 언제 쓰지 말까? |
|---|---|---|---|
| 객체가 프로그램에 1개만 필요 | Singleton | DB 연결, Config, Logger | 테스트가 많은 곳 (Mock 어려움) |
| 플랫폼/타입별 객체 생성 | Factory | 플랫폼별 UI, 결제 수단 | 타입이 2개 이하일 때 |
| 복잡한 객체 단계적 생성 | Builder | HTTP Request, Query Builder | 매개변수가 3개 이하일 때 |
| 데이터 변경 시 여러 곳 업데이트 | Observer | 이벤트, State 관리 | 구독자가 1~2개일 때 |
| 알고리즘을 바꿔 끼우고 싶음 | Strategy | 결제 수단, 정렬 알고리즘 | 알고리즘이 1개뿐일 때 |
| 인터페이스 불일치 해결 | Adapter | API 마이그레이션, 라이브러리 래핑 | 직접 수정 가능한 코드일 때 |
추가 기준:
쓰면 좋은 경우:
- 코드가 3곳 이상 중복될 때
- 요구사항이 자주 바뀔 때
- 여러 사람이 협업할 때
- 테스트가 필요할 때
쓰지 않아도 되는 경우:
- 코드가 10줄 이하일 때
- 한 번만 쓸 코드일 때
- 성능이 매우 중요할 때
- 혼자 쓸 코드일 때
마치며 - "공용 언어의 힘"
디자인 패턴을 공부하면서 가장 크게 깨달은 건, 패턴의 진짜 가치는 코드가 아니라 의사소통이라는 거였다.
Before (패턴 모를 때):
나: "이 클래스요, 프로그램 전체에서 하나만 존재해야 해서요,
생성자를 private으로 만들고,
static 변수에 인스턴스 저장하고,
getInstance() 메서드로 반환하게..."
시니어: "아, 그래서?"
나: "그러니까... 전역에서 접근 가능하면서도
인스턴스는 하나만 있게..."
시니어: "음..." (3분 경과)
After (패턴 알고 난 후):
나: "싱글톤으로 할게요."
시니어: "오케이." (1초)
이렇게 이해했다: 1시간짜리 설명이 1초로 줄어드는 마법.
실제 경험 - 회의 시간 단축
5년 차 때 신규 기능 회의에서:
PM: "사용자 알림 기능 어떻게 구현할까요?"
Before (패턴 모를 때):
나: "음... 사용자 데이터가 변경되면,
헤더, 프로필, 사이드바를 각각 업데이트하고..."
→ 30분 회의
After (패턴 알고 난 후):
나: "옵저버 패턴 쓰면 되겠네요.
User 객체를 Subject로,
각 UI를 Observer로 등록."
시니어: "그래, 그렇게 해."
→ 5분 회의
정리해본다: 디자인 패턴은 개발자들의 공용 단어장이다. 같은 언어를 쓰면 의사소통이 빨라진다.
처음 패턴 이름을 말했을 때
처음으로 코드 리뷰에서 "여기 팩토리 패턴 쓰면 어떨까요?"라고 제안했을 때, 시니어가 웃으며 말했다:
"이제 개발자 언어를 하는구나. 환영해."
그 순간, 결국 이거였다고 깨달았다.
디자인 패턴은 "코드를 잘 짜는 방법"이 아니라 "개발자로 인정받는 통과의례"였다.
"싱글톤", "팩토리", "옵저버" 같은 단어를 자연스럽게 쓰는 순간, 누군가 이렇게 말할 것이다:
"이제 진짜 개발자네."