diff --git "a/jihyeon/05.\354\213\261\352\270\200\355\206\244_\355\214\250\355\204\264.md" "b/jihyeon/05.\354\213\261\352\270\200\355\206\244_\355\214\250\355\204\264.md" new file mode 100644 index 0000000..15fe9f6 --- /dev/null +++ "b/jihyeon/05.\354\213\261\352\270\200\355\206\244_\355\214\250\355\204\264.md" @@ -0,0 +1,375 @@ +# JavaScript에서의 싱글톤 패턴, 그리고 ES Module은 싱글톤인가 + +## 싱글톤 패턴이란 + +싱글톤 패턴은 GoF(Gang of Four)가 정의한 생성 패턴 중 하나로, 핵심은 간단하다. + +> 특정 클래스의 인스턴스가 오직 하나만 존재하도록 보장하고, 그 인스턴스에 대한 전역 접근점을 제공한다. + +이 패턴에는 세 가지 핵심 요소가 있다. + +1. **인스턴스를 하나로 제한하겠다는 명시적 의도** +2. **클래스 스스로가 자신의 인스턴스 생성을 통제** +3. **전역 접근점 제공** + +Java나 C++ 같은 언어에서는 `private constructor` + `static getInstance()` 조합이 전형적인 구현이다. JavaScript에서도 클래스 기반으로 구현할 수 있다. + +```ts +class ConfigManager { + private static instance: ConfigManager; + private config: Record = {}; + + private constructor() {} + + static getInstance() { + if (!ConfigManager.instance) { + ConfigManager.instance = new ConfigManager(); + } + return ConfigManager.instance; + } + + set(key: string, value: string) { + this.config[key] = value; + } + + get(key: string) { + return this.config[key]; + } +} + +const a = ConfigManager.getInstance(); +const b = ConfigManager.getInstance(); +console.log(a === b); // true +``` + +`private constructor`로 외부에서 `new`를 차단하고, `getInstance()`를 통해서만 접근하게 한다. 두 번 호출해도 같은 인스턴스가 반환된다. + +### 싱글톤 패턴의 특징 + +싱글톤 패턴에는 몇 가지 구조적 특징이 있다. + +**지연 초기화(Lazy Initialization)가 가능하다.** 인스턴스를 프로그램 시작 시점이 아니라 처음 요청되는 시점에 생성할 수 있다. 위의 `getInstance()` 예시가 바로 이 방식이다. 무거운 리소스(DB 커넥션, 설정 파일 파싱 등)를 실제로 필요할 때까지 미룰 수 있어 초기 로딩 비용을 줄인다. + +**전역 상태를 캡슐화한다.** 전역 변수를 직접 노출하는 대신, 인스턴스 내부에 상태를 감추고 메서드를 통해서만 접근하게 한다. 전역 변수보다는 구조적이다. + +**인스턴스 생성을 클래스 스스로가 통제한다.** 외부에서 `new`로 생성하는 것이 아니라, 클래스가 자신의 인스턴스를 관리한다. 이로 인해 인스턴스의 수명 주기를 한 곳에서 제어할 수 있다. + +### 싱글톤 패턴의 장점 + +**공유 리소스의 일관성을 보장한다.** 애플리케이션 전체에서 설정값, 로거, 캐시 등 하나만 존재해야 하는 리소스를 다룰 때 유용하다. 여러 곳에서 같은 인스턴스를 참조하므로 상태 불일치가 발생하지 않는다. + +```ts +// 로거 — 앱 전체에서 동일한 설정과 출력 대상을 공유 +const logger = Logger.getInstance(); +logger.setLevel("debug"); + +// 다른 파일에서 +const sameLogger = Logger.getInstance(); +sameLogger.info("이 로거는 위에서 설정한 debug 레벨을 그대로 사용한다"); +``` + +**불필요한 중복 생성을 방지한다.** 생성 비용이 큰 객체(DB 커넥션 풀, HTTP 클라이언트, 스레드 풀 등)를 매번 새로 만들지 않고 재사용한다. + +**전역 변수보다 낫다.** 상태를 전역 네임스페이스에 직접 노출하지 않으므로 이름 충돌 위험이 줄고, 접근 방식이 명시적이다. + +### 싱글톤 패턴의 단점 + +싱글톤은 편리한 만큼 남용하기 쉬운 패턴이기도 하다. 실무에서 자주 문제가 되는 단점들이 있다. + +**테스트가 어렵다.** 싱글톤은 전역 상태를 품고 있기 때문에, 테스트 간에 상태가 누적된다. 테스트 A에서 싱글톤의 상태를 변경하면 테스트 B에 영향을 미친다. 매 테스트마다 인스턴스를 초기화해야 하는데, 싱글톤의 구조 자체가 이를 어렵게 만든다. + +```ts +// 테스트에서 싱글톤을 초기화하려면 별도의 reset 메서드가 필요하다 +// 이 자체가 싱글톤의 "인스턴스를 하나로 제한한다"는 원칙과 모순된다 +class ConfigManager { + private static instance: ConfigManager | null; + + static resetForTesting() { + ConfigManager.instance = null; // 테스트를 위한 탈출구 + } +} +``` + +**숨겨진 의존성이 생긴다.** 함수나 클래스가 내부에서 `getInstance()`를 호출하면, 외부에서는 그 의존성이 보이지 않는다. 함수의 시그니처만 봐서는 싱글톤에 의존한다는 사실을 알 수 없다. + +```ts +// 이 함수가 ConfigManager에 의존한다는 사실이 시그니처에 드러나지 않는다 +function processOrder(order: Order) { + const config = ConfigManager.getInstance(); // 숨겨진 의존성 + const taxRate = config.get("taxRate"); + // ... +} + +// 의존성 주입 방식이 더 명확하다 +function processOrder(order: Order, config: ConfigLike) { + const taxRate = config.get("taxRate"); + // ... +} +``` + +**단일 책임 원칙(SRP)을 위반한다.** 싱글톤 클래스는 본래의 비즈니스 로직 외에 "자신의 인스턴스 수를 관리하는" 책임까지 함께 진다. + +**확장과 변경에 취약하다.** 나중에 인스턴스가 두 개 이상 필요해지면 (예: 멀티 테넌트 환경, 테스트 환경) 구조 전체를 수정해야 한다. 전역에 흩어진 `getInstance()` 호출을 모두 찾아 바꿔야 하므로, 변경 비용이 크다. + +**멀티스레드 환경에서 동기화 문제가 있다.** JavaScript는 싱글 스레드이므로 직접적인 문제는 아니지만, Java나 C++ 같은 언어에서는 두 스레드가 동시에 `getInstance()`를 호출했을 때 인스턴스가 두 개 생길 수 있어 별도의 동기화 처리가 필요하다. JavaScript에서도 Web Worker를 사용하면 각 Worker가 독립된 실행 컨텍스트이므로 싱글톤이 공유되지 않는다는 점은 알아둘 필요가 있다. + +### 그럼 언제 싱글톤을 쓰는 게 적절한가 + +이런 단점에도 불구하고 싱글톤이 적합한 경우가 있다. 인스턴스가 둘 이상 존재하면 논리적으로 모순이 되는 경우(로거, 앱 설정, 커넥션 풀)나, 생성 비용이 매우 크고 상태를 공유해야 하는 경우다. + +반면, 단순히 "전역에서 접근하고 싶다"는 이유만으로 싱글톤을 쓰는 것은 대부분 잘못된 선택이다. 그 경우에는 의존성 주입(DI)이 더 나은 대안이다. + +### JavaScript에서 싱글톤은 안티패턴인가? + +JavaScript 커뮤니티에서는 싱글톤을 **안티패턴**으로 보는 시각이 꽤 강하다. 그 이유는 언어의 특성에 있다. + +Java나 C++에서는 객체를 만들려면 반드시 클래스를 거쳐야 한다. 그래서 "인스턴스를 하나로 제한한다"는 의도를 코드로 강제하려면 `private constructor` + `static getInstance()` 같은 구조가 필요하다. 하지만 JavaScript는 클래스 없이도 객체를 직접 만들 수 있다. `{}` 하나로 이미 유일한 객체가 생긴다. + +```js +// JavaScript에서는 이것만으로 충분하다 +const config = { + apiUrl: "https://api.example.com", + timeout: 5000, +}; + +Object.freeze(config); +export default config; +``` + +클래스 기반 싱글톤의 모든 보일러플레이트(`private constructor`, `instance` 변수, `getInstance()` 메서드)가 JavaScript에서는 오버엔지니어링이 될 수 있다. 모듈 시스템이 이미 캡슐화와 단일 인스턴스를 보장하기 때문이다. + +### React에서의 대안: 전역 상태 관리 도구 + +React 환경에서는 싱글톤 대신 Redux나 React Context 같은 상태 관리 도구를 사용하는 것이 일반적이다. 둘 다 전역 상태를 제공한다는 점에서 싱글톤과 유사해 보이지만, 핵심적인 차이가 있다. + +싱글톤은 **mutable state**다. 어디서든 인스턴스의 프로퍼티를 직접 수정할 수 있고, 그 변경이 즉시 다른 곳에 반영된다. 누가 언제 상태를 바꿨는지 추적하기 어렵다. + +반면 Redux는 **read-only state**를 제공한다. 상태를 변경하려면 action을 dispatch하고, 순수 함수인 reducer를 거쳐야 한다. 변경의 흐름이 단방향이고 예측 가능하다. + +```js +// 싱글톤 — 어디서든 직접 수정 가능, 추적 어려움 +AppConfig.getInstance().set("theme", "dark"); + +// Redux — action → reducer를 거쳐야만 변경 가능, 추적 용이 +dispatch({ type: "SET_THEME", payload: "dark" }); +``` + +전역 상태의 단점이 마법처럼 사라지는 것은 아니지만, 최소한 상태 변경이 의도한 방식으로만 일어나도록 강제할 수 있다는 점에서 싱글톤보다 안전하다. + +--- + +JavaScript, 특히 ES Module 환경에서 개발하다 보면 이런 클래스 기반 싱글톤을 직접 구현할 일이 생각보다 적다. ES Module 자체가 싱글톤과 매우 유사하게 동작하기 때문이다. + +--- + +## ES Module은 싱글톤처럼 동작한다 + +### 모듈이 평가되는 과정 + +ES Module의 로딩은 세 단계로 이루어진다. + +- **Construction** — 파일을 fetch하고 파싱하여 Module Record를 생성한다. +- **Instantiation** — export/import binding을 메모리에 연결한다. +- **Evaluation** — 모듈의 top-level 코드를 실제로 실행한다. + +핵심은 Evaluation이 **모듈 specifier당 딱 한 번만 일어난다**는 점이다. + +```js +// counter.js +let count = 0; +export function increment() { + count++; +} +export function getCount() { + return count; +} +``` + +```js +// a.js +import { increment, getCount } from "./counter.js"; +increment(); +console.log(getCount()); // 1 +``` + +```js +// b.js +import { getCount } from "./counter.js"; +console.log(getCount()); // 1 — a.js에서 increment한 결과가 반영된다 +``` + +`counter.js`는 한 번만 평가되므로, `a.js`와 `b.js`가 바라보는 `count`는 동일한 메모리 공간의 동일한 binding이다. + +### Module Map에 의한 캐싱 + +이 동작이 보장되는 이유는 각 실행 환경(Realm)이 내부적으로 **Module Map**을 유지하기 때문이다. 이 맵의 키는 resolved module specifier(절대 URL 또는 절대 경로)이다. + +``` +Module Map +┌──────────────────────────────────┬──────────────────┐ +│ Key (resolved specifier) │ Value │ +├──────────────────────────────────┼──────────────────┤ +│ "https://example.com/counter.js" │ Module Record ① │ +│ "https://example.com/utils.js" │ Module Record ② │ +└──────────────────────────────────┴──────────────────┘ +``` + +같은 specifier로 import하면 Module Map에서 기존 Module Record를 반환한다. 새로 fetch하거나 새로 평가하지 않는다. + +ECMAScript 스펙의 `HostResolveImportedModule` 추상 연산은 이를 명확히 한다. + +> 동일한 (referencingScriptOrModule, specifier) 쌍에 대해 호출될 때마다, 매번 같은 Module Record 인스턴스를 반환해야 한다(MUST). + +현재 스펙은 리팩터링을 거쳐 `HostLoadImportedModule`로 hook 이름이 변경되었지만, 모듈 캐싱을 보장한다는 핵심 의미 자체는 동일하다. + +**스펙 레벨에서 "같은 Realm + 같은 specifier = 같은 Module Record"는 보장된다.** + +### Live Binding + +ES Module의 import는 값의 복사가 아니라 **live binding(참조 연결)** 이다. 이 점이 CommonJS와의 근본적인 차이다. + +```js +// CommonJS — 값 복사 +let count = 0; +module.exports = { count }; +// require하면 { count: 0 }의 복사본을 받는다 +// 원본 count가 변해도 복사된 값은 변하지 않는다 +``` + +```js +// ESM — live binding +export let count = 0; +export function increment() { + count++; +} +// import한 count는 원본 binding을 직접 참조한다 +// increment() 호출 시 import한 쪽에서도 변경된 값이 보인다 +``` + +이 live binding + 단일 평가 조합이 모듈 단위의 상태를 사실상 싱글톤으로 만든다. + +### 그래서 실무에서는 이런 코드가 자연스럽다 + +```js +// store.js — 별도의 싱글톤 패턴 없이 모듈 자체가 싱글톤 역할 +let instance = null; + +export function getStore() { + if (!instance) { + instance = createStore(); + } + return instance; +} +``` + +클래스로 `private constructor`를 만들 필요 없이, 모듈 스코프 자체가 캡슐화와 단일 인스턴스를 보장한다. + +모듈로 싱글톤 인스턴스를 export할 때는 `Object.freeze()`로 감싸면 소비 코드에서 프로퍼티를 실수로 덮어쓰는 것을 방지할 수 있다. + +```js +// config.js +const config = { + apiUrl: "https://api.example.com", + timeout: 5000, +}; + +export default Object.freeze(config); +``` + +`Object.freeze`된 객체는 프로퍼티 추가, 수정, 삭제가 모두 차단되므로, 싱글톤의 상태가 의도치 않게 변경되는 사고를 줄일 수 있다. + +--- + +## 그런데 ES Module이 "완벽한 싱글톤"은 아니다 + +ES Module이 싱글톤처럼 동작하는 것은 사실이지만, 이것이 깨지는 케이스들이 있다. + +### 서로 다른 Realm + +Realm은 독립된 JavaScript 실행 환경을 뜻한다. 각 Realm은 자신만의 전역 객체(`globalThis`), 빌트인 객체(`Array`, `Object` 등), 그리고 Module Map을 갖는다. + +브라우저에서 `