Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
375 changes: 375 additions & 0 deletions jihyeon/05.싱글톤_패턴.md
Original file line number Diff line number Diff line change
@@ -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<string, string> = {};

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을 갖는다.

브라우저에서 `<iframe>`은 별도의 Realm을 생성한다. 따라서 메인 페이지와 iframe이 같은 `counter.js`를 import해도 각각 독립적으로 평가된다.

```
메인 페이지 Realm → Module Map A → counter.js (인스턴스 1)
iframe Realm → Module Map B → counter.js (인스턴스 2)
```

두 인스턴스는 상태를 공유하지 않는다.

### Node.js에서의 경로 해석 차이

Node.js는 module specifier를 파일 시스템의 절대 경로로 resolve한다. 같은 패키지가 symlink나 중복 설치로 인해 다른 절대 경로로 resolve되면, 별개의 모듈로 취급된다.

```
node_modules/
├── package-a/
│ └── node_modules/
│ └── shared-lib/ ← /project/node_modules/package-a/node_modules/shared-lib
└── shared-lib/ ← /project/node_modules/shared-lib
```

이 경우 `shared-lib`의 top-level 코드가 두 번 실행되고, 각각 독립된 상태를 갖는다. npm, yarn, pnpm 등 패키지 매니저의 hoisting 전략에 따라 이 동작이 달라질 수 있다.

### 번들러의 개입

Webpack, Vite, Rollup 등 번들러는 ESM 스펙을 그대로 따르지 않고 자체적인 모듈 해석과 스코프 처리를 한다. 번들러도 내부 캐시를 통해 모듈당 한 번만 실행하므로 일반적으로는 싱글톤 특성이 유지되지만, 코드 스플리팅이나 마이크로 프론트엔드, Module Federation 등을 사용하면 동일 모듈이 서로 다른 청크에 중복 포함되어 독립 인스턴스가 생길 수 있다.

### 동적 import의 쿼리 파라미터

```js
const mod1 = await import("./counter.js");
const mod2 = await import("./counter.js?v=2");
// mod1 !== mod2 — 다른 specifier이므로 별개의 Module Record
```

쿼리 파라미터가 다르면 Module Map의 키가 달라져서 같은 파일이 두 번 평가될 수 있다.

---

## 정리: 싱글톤 패턴과 ES Module의 관계

| 조건 | 싱글톤으로 동작하는가? |
| ---------------------------------------------- | ---------------------- |
| 같은 Realm + 같은 specifier | **Yes** (스펙 보장) |
| 같은 Realm + 다른 specifier (쿼리 파라미터 등) | No |
| 서로 다른 Realm (iframe, Web Worker 등) | No |
| Node.js에서 같은 패키지가 다른 경로로 resolve | No |
| 번들러에서 동일 청크 내 | 대체로 Yes |
| 번들러에서 분리된 청크 / MFE 간 | No (구성에 따라 다름) |

정리하면 이렇게 된다.

**싱글톤 패턴**은 "인스턴스를 하나로 제한하겠다"는 개발자의 **명시적 설계 의도**다. **ES Module의 단일 평가 특성**은 언어 스펙의 **런타임 메커니즘**에 의해 결과적으로 싱글톤처럼 동작하는 것이다.

둘은 결과가 유사하지만 본질이 다르다. ES Module은 싱글톤 패턴을 "구현"한 것이 아니라, 모듈 시스템의 설계 결과로 싱글톤과 유사한 특성을 갖게 된 것이다.

JavaScript에서 싱글톤이 필요한 대부분의 경우, 모듈 레벨 상태만으로 충분하다. 클래스 기반 싱글톤 패턴은 런타임 환경이 복잡해지거나(마이크로 프론트엔드, 다중 Realm), 인스턴스 생성 시점을 명시적으로 제어해야 할 때 비로소 의미를 갖는다.

---

## 부록: 방어적 싱글톤이 필요할 때

모듈 레벨 싱글톤이 깨질 수 있는 환경에서 방어적으로 싱글톤을 보장하려면, `globalThis`와 `Symbol.for()`를 조합할 수 있다.

```js
const STORE_KEY = Symbol.for("my-app/config-store");

if (!globalThis[STORE_KEY]) {
globalThis[STORE_KEY] = createStore();
}

export const store = globalThis[STORE_KEY];
```

`Symbol.for()`는 Global Symbol Registry를 사용하는데, 이 레지스트리는 Realm 단위가 아니라 에이전트(agent) 단위로 공유된다. 따라서 `Symbol.for('my-app/config-store')`는 어떤 Realm에서 호출하든 동일한 Symbol을 반환한다.

다만 `globalThis` 자체는 Realm마다 다르기 때문에, iframe 같은 Cross-Realm 환경에서 진짜 싱글톤이 필요하다면 객체 참조를 명시적으로 전달해야 한다.

```js
// iframe 내부에서 부모의 인스턴스를 직접 참조
const STORE_KEY = Symbol.for("my-app/config-store");
const store = window.parent[STORE_KEY];
```

일반적인 SPA에서는 Realm이 하나이므로 모듈 레벨 싱글톤으로 충분하다. 이런 방어적 패턴은 마이크로 프론트엔드나 iframe 기반 통합처럼 런타임 환경이 복잡해질 때 고려하면 된다.