diff --git "a/doeun/05.\354\213\261\352\270\200\355\206\244_\355\214\250\355\204\264" "b/doeun/05.\354\213\261\352\270\200\355\206\244_\355\214\250\355\204\264" new file mode 100644 index 0000000..7c3906d --- /dev/null +++ "b/doeun/05.\354\213\261\352\270\200\355\206\244_\355\214\250\355\204\264" @@ -0,0 +1,462 @@ +# 싱글톤 패턴 (Singleton Pattern) + +> "클래스의 인스턴스가 딱 하나만 만들어지도록 하고, 그 인스턴스에 대한 전역 접근을 제공한다." - GoF + +## 📌 싱글톤이란? + +싱글톤 패턴은 **클래스의 인스턴스가 단 하나만 존재하도록 보장하고, 어디서든 그 인스턴스에 접근할 수 있게 하는 디자인 패턴**이다. + +처음 들으면 "전역 변수 좀 고급스럽게 만드는 건가?" 싶은데, 맞기도 하고 틀리기도 하다. + +> **핵심: "인스턴스를 하나로 제한" + "전역 접근점 제공"** + +## 🎯 구조 + +```mermaid +classDiagram + class Singleton { + -static instance: Singleton + -constructor() + +static getInstance(): Singleton + +someMethod() + } + + class ClientA { + +doSomething() + } + + class ClientB { + +doSomething() + } + + class ClientC { + +doSomething() + } + + ClientA --> Singleton : getInstance() + ClientB --> Singleton : getInstance() + ClientC --> Singleton : getInstance() +``` + +**핵심 2요소:** + +1. **private constructor**: 외부에서 `new`로 생성하지 못하게 막는다 +2. **static getInstance()**: 인스턴스가 없으면 만들고, 있으면 기존 걸 돌려준다 + +## 🔧 기본 구현 + +### 가장 단순한 형태 + +```javascript +class Singleton { + static instance; + + constructor() { + if (Singleton.instance) return Singleton.instance; + Singleton.instance = this; + } +} + +const a = new Singleton(); +const b = new Singleton(); +console.log(a === b); // true +``` + +`constructor`에서 이미 인스턴스가 있으면 기존 걸 리턴한다. 몇 번을 `new`해도 같은 객체가 나온다. + +### 클로저를 활용한 방식 + +```javascript +const Singleton = (() => { + let instance; + + function createInstance() { + return { + config: {}, + getConfig(key) { + return this.config[key]; + }, + setConfig(key, value) { + this.config[key] = value; + } + }; + } + + return { + getInstance() { + if (!instance) { + instance = createInstance(); + } + return instance; + } + }; +})(); + +const a = Singleton.getInstance(); +const b = Singleton.getInstance(); +console.log(a === b); // true +``` + +IIFE + 클로저로 `instance`를 외부에서 접근 못하게 완전히 숨긴다. 첫 번째 방식보다 캡슐화가 강하다. + +### ES 모듈 방식 — JS에서 가장 자연스러운 싱글톤 + +사실 이게 가장 중요한 포인트인데, **ES 모듈은 최초 import 시 한 번만 평가되고 이후에는 캐싱된 결과를 돌려준다.** + +```javascript +// config.js +const config = { + apiUrl: process.env.API_URL ?? 'http://localhost:3000', + env: process.env.NODE_ENV ?? 'development', +}; + +export default config; +``` + +이걸 여러 파일에서 `import config from './config'` 해도 전부 같은 객체를 참조한다. 클래스로 싱글톤을 구현할 필요 없이, **모듈 자체가 싱글톤 역할을 한다.** + +```mermaid +flowchart LR + A[moduleA.js] -->|import config| C[config.js
최초 1회 평가] + B[moduleB.js] -->|import config| C + D[moduleC.js] -->|import config| C + C -->|같은 객체 반환| A + C -->|같은 객체 반환| B + C -->|같은 객체 반환| D +``` + +즉, JavaScript에서는 클래스 기반 싱글톤보다 **모듈 패턴이 사실상 표준**이다. + +## 🛠 실전 예제 + +### Axios 인스턴스 + +프론트엔드에서 가장 흔하게 볼 수 있는 싱글톤이다. + +```javascript +// api/client.js +import axios from 'axios'; + +const apiClient = axios.create({ + baseURL: import.meta.env.VITE_API_URL, + timeout: 10000, + headers: { + 'Content-Type': 'application/json', + }, +}); + +apiClient.interceptors.request.use((config) => { + const token = localStorage.getItem('accessToken'); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +apiClient.interceptors.response.use( + (response) => response, + (error) => { + if (error.response?.status === 401) { + // 토큰 만료 처리 + } + return Promise.reject(error); + } +); + +export default apiClient; // 어디서 import해도 같은 인스턴스 +``` + +인터셉터 설정이 한 곳에 모여있고, 앱 전체에서 동일한 설정으로 API를 호출한다. 이게 여러 인스턴스로 흩어져 있으면 인터셉터 관리가 지옥이 된다. + +### 로거 (Logger) + +```javascript +// logger.js +const LOG_LEVELS = { DEBUG: 0, INFO: 1, WARN: 2, ERROR: 3 }; + +const logger = (() => { + const logs = []; + let level = LOG_LEVELS.INFO; + + const format = (lvl, message) => { + const timestamp = new Date().toISOString(); + return `[${timestamp}] [${lvl}] ${message}`; + }; + + return { + setLevel(newLevel) { + level = LOG_LEVELS[newLevel] ?? LOG_LEVELS.INFO; + }, + debug(msg) { + if (level <= LOG_LEVELS.DEBUG) { + const entry = format('DEBUG', msg); + logs.push(entry); + console.log(entry); + } + }, + info(msg) { + if (level <= LOG_LEVELS.INFO) { + const entry = format('INFO', msg); + logs.push(entry); + console.log(entry); + } + }, + warn(msg) { + if (level <= LOG_LEVELS.WARN) { + const entry = format('WARN', msg); + logs.push(entry); + console.warn(entry); + } + }, + error(msg) { + if (level <= LOG_LEVELS.ERROR) { + const entry = format('ERROR', msg); + logs.push(entry); + console.error(entry); + } + }, + getLogs() { + return [...logs]; + } + }; +})(); + +export default logger; +``` + +**사용:** +```javascript +import logger from './logger'; + +logger.setLevel('DEBUG'); +logger.info('앱 시작'); // [2024-03-15T...] [INFO] 앱 시작 +logger.debug('디버그 정보'); // [2024-03-15T...] [DEBUG] 디버그 정보 +logger.error('에러 발생!'); // [2024-03-15T...] [ERROR] 에러 발생! + +console.log(logger.getLogs()); // 전체 로그 이력 +``` + +로거가 여러 개면 로그도 뿔뿔이 흩어진다. 앱 전체의 로그가 하나의 스트림으로 모여야 의미가 있다. + +### 상태 관리 Store + +이미 쓰고 있을 확률이 높다. + +```javascript +// Zustand +import { create } from 'zustand'; + +const useAuthStore = create((set) => ({ + user: null, + isAuthenticated: false, + login: (user) => set({ user, isAuthenticated: true }), + logout: () => set({ user: null, isAuthenticated: false }), +})); +``` + +Zustand, Redux, Jotai 같은 상태 관리 라이브러리의 store도 결국 싱글톤이다. 앱 전체에서 하나의 상태 트리를 공유하니까. + +### 캐시 매니저 + +```javascript +// cache.js +const cache = (() => { + const store = new Map(); + + return { + get(key) { + const item = store.get(key); + if (!item) return null; + + if (item.expiry && item.expiry < Date.now()) { + store.delete(key); + return null; + } + return item.value; + }, + + set(key, value, ttlMs = null) { + store.set(key, { + value, + expiry: ttlMs ? Date.now() + ttlMs : null, + }); + }, + + delete(key) { + store.delete(key); + }, + + clear() { + store.clear(); + }, + + get size() { + return store.size; + } + }; +})(); + +export default cache; +``` + +**사용:** +```javascript +import cache from './cache'; + +// 5분 TTL로 캐싱 +cache.set('user:123', userData, 5 * 60 * 1000); + +// 다른 모듈에서 +const cached = cache.get('user:123'); +if (cached) { + // 캐시 히트 +} +``` + +캐시가 여러 개면 캐시가 아니다. 같은 키로 조회했는데 다른 결과가 나오면 의미가 없으니까. + +## ❓ 전역 변수랑 뭐가 다른데? + +솔직히 "의도적으로 제한을 건 전역 상태"가 싱글톤이다. + +| | 전역 변수 | 싱글톤 | +|---|---|---| +| 인스턴스 수 제한 | ❌ 없음 | ✅ 하나로 제한 | +| 초기화 시점 제어 | ❌ 선언 시 즉시 | ✅ 첫 접근 시 (lazy) | +| 캡슐화 | ❌ 다 열려있음 | ✅ 클로저/private으로 보호 | +| 교체 가능성 | ❌ 어려움 | ✅ 인터페이스 뒤에 숨길 수 있음 | + +다만, 싱글톤을 무분별하게 쓰면 전역 변수랑 다를 바 없는 상태가 된다. 이게 싱글톤이 욕을 많이 먹는 이유다. + +## ⚠️ 싱글톤의 문제점 + +### 1. 테스트가 어렵다 + +```javascript +function calculateDiscount(price) { + const config = AppConfig.getInstance(); // 강한 결합 + const rate = config.get('DISCOUNT_RATE'); + return price * rate; +} +``` + +함수 시그니처만 보면 `price`만 넣으면 될 것 같은데, 실제로는 `AppConfig`에 의존한다. 테스트할 때 설정값을 바꾸고 싶어도, 싱글톤이 이미 초기화되어 있으면 까다롭다. + +### 2. 숨겨진 의존성 + +```javascript +// 이 함수가 뭘 의존하는지 시그니처만 봐서는 알 수 없다 +function processOrder(order) { + const logger = Logger.getInstance(); // 숨어있음 + const config = AppConfig.getInstance(); // 숨어있음 + const cache = Cache.getInstance(); // 숨어있음 + // ... +} +``` + +파라미터로 받으면 명시적인데, 싱글톤은 함수 내부에 숨어있다. 코드를 읽는 사람이 의존성을 놓치기 쉽다. + +### 3. SSR에서 요청 간 상태 공유 버그 + +```mermaid +flowchart LR + A[요청 A - 유저 김철수] -->|getInstance| S[Singleton
user: 김철수] + B[요청 B - 유저 이영희] -->|getInstance| S + S -->|응답| A + S -->|응답 - 김철수 정보 노출!| B +``` + +서버 사이드 렌더링 환경에서는 요청마다 독립된 상태가 필요한데, 싱글톤은 프로세스가 죽을 때까지 살아있다. **요청 A의 유저 정보가 요청 B에 노출되는** 치명적인 버그가 생길 수 있다. + +### 4. 단일 책임 원칙 위반 + +싱글톤 클래스는 "자기 본래의 역할" + "인스턴스가 하나임을 보장하는 역할" 두 가지를 동시에 담당한다. 인스턴스 관리 책임이 클래스 자체에 들어가 있는 셈이다. + +## ✅ 대안: 의존성 주입 + +싱글톤의 "하나만 존재" 자체가 나쁜 게 아니라, **접근 방식**이 문제다. 의존성을 외부에서 넣어주면 같은 목적을 달성하면서 단점을 피할 수 있다. + +### Before — 싱글톤 직접 참조 + +```javascript +function calculateDiscount(price) { + const config = AppConfig.getInstance(); // 강한 결합 + const rate = config.get('DISCOUNT_RATE'); + return price * rate; +} +``` + +### After — 의존성 주입 + +```javascript +function calculateDiscount(price, config) { + const rate = config.get('DISCOUNT_RATE'); + return price * rate; +} + +// 프로덕션 +calculateDiscount(10000, prodConfig); + +// 테스트 — 자유롭게 교체 +calculateDiscount(10000, { get: () => 0.5 }); +``` + +인스턴스를 하나만 만드는 건 **호출하는 쪽에서 제어**하면 된다. 클래스 스스로 "나는 하나만 존재해야 해"라고 강제할 필요는 없다. + +## 🤔 언제 사용할까? + +```mermaid +flowchart TD + A[인스턴스가 2개 이상이면
버그가 나는가?] -->|Yes| B[상태 변경이 없거나
매우 제한적인가?] + A -->|No| E[싱글톤 불필요
일반 모듈로 충분] + B -->|Yes| C[앱 전역에서
접근이 필요한가?] + B -->|No| F[주의해서 사용
상태 관리 복잡해질 수 있음] + C -->|Yes| D[싱글톤 사용 ✅] + C -->|No| G[모듈 스코프로 충분] +``` + +### ✅ 사용하면 좋은 경우 + +- **설정 관리**: 앱 전체가 같은 설정을 봐야 할 때 +- **로깅**: 로그가 하나의 스트림으로 모여야 할 때 +- **캐시**: 하나의 캐시 저장소를 공유해야 할 때 +- **커넥션 풀**: 리소스를 효율적으로 관리해야 할 때 + +### ❌ 사용하지 않아도 되는 경우 + +- **모듈로 충분할 때**: JS에서는 `export default`만으로 싱글톤 효과를 얻을 수 있다 +- **상태가 자주 바뀌는 경우**: 전역 mutable state는 디버깅 지옥의 시작 +- **테스트가 중요한 코드**: DI로 대체하는 편이 낫다 +- **SSR 환경**: 요청 간 상태 격리가 필수인 경우 + +## 📊 장단점 정리 + +### 장점 + +1. **인스턴스 하나 보장**: 리소스 낭비 방지, 상태 일관성 유지 +2. **전역 접근점**: 어디서든 동일한 인스턴스에 접근 가능 +3. **지연 초기화(Lazy)**: 필요한 시점에 생성할 수 있음 +4. **메모리 효율**: 무거운 객체를 한 번만 생성 + +### 단점 + +1. **테스트 어려움**: 전역 상태에 의존하면 모킹이 까다로움 +2. **숨겨진 의존성**: 코드만 봐서 의존 관계를 파악하기 어려움 +3. **단일 책임 위반**: 본래 역할 + 인스턴스 관리를 동시에 담당 +4. **동시성 이슈**: SSR 등에서 요청 간 상태가 섞일 수 있음 +5. **오버엔지니어링**: JS에서는 모듈로 충분한 경우가 대부분 + +## 💬 마무리 + +싱글톤은 디자인 패턴 중 가장 단순하다. 그래서 남용하기도 쉽다. + +JavaScript에서는 **모듈 시스템이 이미 싱글톤처럼 동작**하기 때문에, 클래스로 직접 구현할 일은 생각보다 많지 않다. axios 인스턴스, Zustand store, 설정 파일 — 이미 쓰고 있는 것들이 전부 싱글톤이라는 걸 인식하는 게 더 중요하다. + +**싱글톤을 쓸지 말지보다, "이 상태가 정말로 전역이어야 하는가?"를 먼저 질문하자.** 대부분은 스코프를 좁히는 쪽이 답이다. + +--- + +## 📚 참고 + +- Head First Design Patterns 5장 +- GoF Design Patterns