의존성 주입과 IoC 컨테이너
객체가 필요한 의존성을 직접 만들지 않고 바깥에서 받는 생성자 주입과 메서드 주입, 세터 주입을 비교하고, 조립을 한곳에 모으는 조립 루트, 토큰과 수명으로 객체 그래프를 풀어 주는 작은 IoC 컨테이너를 직접 만든 뒤, 데코레이터를 쓰는 컨테이너 라이브러리와의 차이를 정리합니다.
앞의 두 글에서 결제 처리기와 장바구니는 쓸 로거나 결제 전략을 직접 만들지 않고 바깥에서 넘겨받았습니다. 이렇게 객체가 필요로 하는 다른 객체, 곧 의존성(dependency)을 스스로 만들거나 찾지 않고 바깥에서 받는 방식을 의존성 주입(dependency injection)이라고 합니다. 어떤 구현을 쓸지 정하는 일이 그 객체에서 바깥으로 옮겨 가므로, 이를 제어의 역전(inversion of control)이라고도 부릅니다.
이 글은 의존성을 넘겨받는 세 가지 방법을 비교하고, 객체를 만들고 잇는 일을 한곳에 모으는 조립 루트를 봅니다. 이어서 그 조립을 대신해 주는 작은 IoC 컨테이너를 직접 만들어 토큰과 객체의 수명이 무엇인지 확인하고, 마지막으로 데코레이터를 쓰는 컨테이너 라이브러리와 비교합니다.
의존성을 넘겨받는 세 가지 방법
다음 UserService는 기록할 때 쓸 로거를 세 가지 방법으로 받을 수 있게 만든 것입니다. 로거는 AppLogger 인터페이스만 지키면 되므로, UserService는 받은 것이 ConsoleLogger인지 FileLogger인지 모른 채 log()만 부릅니다. 맨 아래의 네 줄이 세 가지 방법을 하나씩 써 봅니다.
interface AppLogger {
log(message: string): void;
}
class ConsoleLogger implements AppLogger {
log(message: string): void {
console.log(`[Console Log]: ${message}`);
}
}
class FileLogger implements AppLogger {
log(message: string): void {
console.log(`[File Log]: ${message}`);
}
}
class UserService {
constructor(private logger: AppLogger) {}
registerUser(username: string, logger: AppLogger = this.logger): void {
logger.log(`Registering user: ${username}`);
}
setLogger(logger: AppLogger): void {
this.logger = logger;
}
processUserData(data: unknown): void {
this.logger.log(`Processing user data: ${JSON.stringify(data)}`);
}
}
const service = new UserService(new ConsoleLogger());
service.registerUser("Alice");
service.registerUser("Charlie", new FileLogger());
service.setLogger(new FileLogger());
service.processUserData({ id: 3, name: "David" });[Console Log]: Registering user: Alice
[File Log]: Registering user: Charlie
[File Log]: Processing user data: {"id":3,"name":"David"}생성자 주입(constructor injection)은 객체를 만들 때 생성자로 의존성을 받는 방법이어서, 첫 줄처럼 만든 직후부터 객체가 완전한 상태입니다. 메서드 주입(method injection)은 둘째 줄처럼 특정 호출에서만 다른 의존성을 넘기는 방법이고, 세터 주입(setter injection)은 셋째 호출처럼 만든 뒤에 의존성을 바꾸는 방법입니다.
세 방법 가운데 기본으로 쓰는 것은 생성자 주입입니다. 필요한 의존성이 생성자의 매개변수로 드러나므로 빠뜨리면 컴파일러가 알려 주고, 의존성을 readonly로 두어 만든 뒤에 바뀌지 않게 할 수도 있습니다. 세터 주입은 객체가 의존성 없이 존재하는 시간이 생기고 언제 바뀌었는지 추적하기 어려우므로, 정말로 실행 중에 바꾸어야 할 때만 씁니다.
조립 루트
의존성을 바깥에서 넘긴다면, 결국 누군가는 실제 객체를 만들어 이어야 합니다. 이 일을 프로그램의 시작 지점 한곳에 모아 두는 것을 조립 루트(composition root)라고 합니다.
const logger: AppLogger = process.env.NODE_ENV === "test" ? new FileLogger() : new ConsoleLogger();
const userService = new UserService(logger);
userService.registerUser("Grace");UserService는 어떤 로거가 들어올지 모르고, 환경에 따라 로거를 고르는 판단은 조립 루트에만 있습니다. 테스트에서는 같은 자리에서 기록을 배열에 모으는 로거를 넘기면 되므로, 서비스의 코드를 고치지 않고 동작을 확인할 수 있습니다.
위 코드는 process를 쓰므로 9장에서 본 것처럼 "types": ["node"]가 필요합니다. 또 AppLogger와 두 로거, UserService를 import 없이 쓰는데, 이 선언들은 injection-styles.ts에 있습니다. main.ts만 컴파일하면 그 이름을 찾지 못한다는 진단이 나옵니다. "type": "commonjs"인 ts-first에서는 import와 export가 없는 두 파일이 전역 스크립트로 검사되어 함께 컴파일하면 검사는 통과하지만, Node.js는 출력된 파일을 하나씩 따로 실행하므로 node dist/main.js에서는 그 클래스를 찾지 못합니다. 그래서 따라 할 때는 injection-styles.ts의 선언과 한 파일에 둡니다.
DI / OBJECT COMPOSITION
조립 코드가 로거를 선택해 UserService에 주입한다
생성자 주입에서는 UserService가 로거를 직접 만들지 않습니다. 바깥에서 Console/File/test 로거를 고르고 ILogger 계약으로 넘깁니다. 컨테이너는 이 조립을 관리하는 선택 가능한 도구입니다.
REQUIRED
생성자 주입
필수 logger를 객체 생성 때 넘깁니다. 서비스는 ILogger 계약을 사용하며 어떤 구체 로거를 만들지 선택하지 않습니다.
PER CALL
메서드·setter 주입
교재 registerUser의 선택 인자가 있으면 그 logger를 이번 호출에 사용합니다. setLogger는 저장된 의존성을 바꿉니다. 두 변경 범위를 구별합니다.
OPTIONAL TOOL
수동 조립도 DI다
작은 그래프는 new UserService(logger)로 충분합니다. IoC 도구를 쓰면 등록·해결·수명 관리 책임을 조립 경계에 모을 수 있습니다.
ISOLATION
실제 Inversify 예제의 경계
앞 예제의 UserService에는 @inject와 Symbol token이 있습니다. 컨테이너 관련 선언까지 완전히 분리한 구조는 아니므로 필요한 격리 수준을 선택합니다.
그림의 화살표는 객체 생성·주입입니다. 소스 의존 방향을 설명하는 DIP 그림과 구별합니다.
위 그림처럼 조립 코드가 환경에 맞는 로거를 골라 만들고, 그 로거를 UserService의 생성자에 넘깁니다. 업무 정책을 담은 UserService는 로거를 만들지 않으며, 객체를 만들고 잇는 일과 업무 규칙이 서로 다른 곳에 놓입니다. 그림이 구별하는 데코레이터 기반의 예는 이 글의 뒤에서 봅니다.
작은 IoC 컨테이너 만들기
객체가 몇 개 안 된다면 조립 루트에서 직접 new를 써도 충분합니다. 객체가 많아지고 서로 얽히면, 무엇을 만들지와 몇 개를 만들지를 등록해 두고 필요할 때 대신 만들어 주는 IoC 컨테이너(IoC container)가 조립을 맡습니다. 그 원리를 보기 위해 작은 컨테이너를 직접 만들어 봅니다.
type Token<T> = symbol & { readonly __type?: T };
function createToken<T>(description: string): Token<T> {
return Symbol(description) as Token<T>;
}
type Factory<T> = (container: Container) => T;
class Container {
private providers = new Map<symbol, { factory: Factory<unknown>; singleton: boolean }>();
private singletons = new Map<symbol, unknown>();
register<T>(token: Token<T>, factory: Factory<T>, options: { singleton?: boolean } = {}): void {
if (this.providers.has(token)) {
throw new Error(`Already registered: ${token.description}`);
}
this.providers.set(token, { factory, singleton: options.singleton ?? false });
}
resolve<T>(token: Token<T>): T {
const provider = this.providers.get(token);
if (provider === undefined) {
throw new Error(`No provider for ${token.description}`);
}
if (!provider.singleton) {
return provider.factory(this) as T;
}
if (!this.singletons.has(token)) {
this.singletons.set(token, provider.factory(this));
}
return this.singletons.get(token) as T;
}
}인터페이스는 실행 중에 사라지므로, 컨테이너가 "로거를 달라"는 요청을 알아보려면 실행 중에도 남는 표시가 필요합니다. 그 표시가 토큰(token)이며, 여기서는 Symbol로 만들고 타입 매개변수 T로 그 토큰이 가리키는 값의 타입을 함께 기억하게 했습니다. register는 토큰마다 객체를 만드는 함수와 수명을 기록하고, resolve는 그 함수를 불러 객체를 만들되 singleton으로 등록한 것은 처음 만든 객체를 저장해 두었다가 다시 돌려줍니다.
다음 코드도 앞의 선언들을 가져오지 않고 그대로 씁니다. 아래의 출력을 확인하려면 injection-styles.ts에서 맨 아래의 실행 코드 다섯 줄을 뺀 선언과 container.ts를 이 코드와 한 파일에 이어 붙여 실행합니다.
const LOGGER = createToken<AppLogger>("Logger");
const USER_SERVICE = createToken<UserService>("UserService");
let createdLoggers = 0;
const container = new Container();
container.register(LOGGER, () => {
createdLoggers += 1;
return new ConsoleLogger();
}, { singleton: true });
container.register(USER_SERVICE, (c) => new UserService(c.resolve(LOGGER)));
const first = container.resolve(USER_SERVICE);
const second = container.resolve(USER_SERVICE);
console.log(first === second, createdLoggers);
first.registerUser("Grace");false 1
[Console Log]: Registering user: GraceUserService는 수명을 정하지 않았으므로 resolve할 때마다 새로 만들어져 두 서비스는 서로 다른 객체이고, 로거는 singleton으로 등록했으므로 한 번만 만들어져 두 서비스가 함께 씁니다. USER_SERVICE를 만드는 함수가 안에서 c.resolve(LOGGER)를 부르므로, 컨테이너는 요청받은 객체가 필요로 하는 의존성을 차례로 풀어 완성된 객체를 돌려줍니다.
IoC 컨테이너는 token 그래프와 scope를 순서대로 해석한다 — 상태 추적
토큰 등록 뒤 get 시 의존성 탐색과 생성/캐시/객체 반환 상태가 달라진다. 컨테이너와 객체 정체성으로 추적할 가치가 있다.
IOC / REGISTER → RESOLVE
등록을 마친 컨테이너가 token 의존을 풀어 객체를 반환한다
bootstrap은 token/provider를 등록하고 caller는 root token을 요청합니다. 이후 의존 조회와 scope 판단은 컨테이너 내부에서 이루어지며, bootstrap에 다시 의존 정보를 요청하지 않습니다.
REGISTER
런타임 token을 등록
interface는 JS에서 사라집니다. 교재는 TYPES.ILogger·TYPES.UserService Symbol을 구현체에 바인딩합니다.
RESOLVE
의존 token을 해석
get(UserService)를 받으면 생성자의 @inject(TYPES.ILogger) 메타데이터를 읽고 ConsoleLogger를 주입합니다.
SCOPE
재사용 여부는 수명 정책
transient는 새 값을, singleton은 같은 값을 재사용합니다. request scope는 같은 root resolve 요청 안의 재사용입니다. HTTP 요청 수명과 자동으로 같지는 않습니다.
LESSON SCOPE
교재 바인딩은 기본 수명
예제는 singleton/request scope를 지정하지 않았습니다. scope cache가 항상 사용되는 실행 결과처럼 읽지 않습니다. 라이브러리 버전·데코레이터 설정도 함께 확인합니다.
같은 계약의 구현 선택과 cache 적용 범위를 구별하고, 등록되지 않은 token은 실제 해결 오류로 확인합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 모바일의 첫 흐름은 Bootstrap이 token과 provider를 Container에 등록하는 순서를 보여 준다. 이어지는 두 번째 흐름에서 Caller의 root 요청을 해결한다. |
| 원도표 설명 | interface는 JS에서 사라집니다. 교재는 TYPES.ILogger·TYPES.UserService Symbol을 구현체에 바인딩합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| binding 등록 | container: bindings 있음 / 객체: 아직 생성 안 될 수 있음 | 토큰을 구현 생성 규칙과 scope에 연결한다 | 등록과 실제 객체 생성을 구분한다 |
| get과 의존 탐색 | 요청: UserService / 의존: ILogger binding | 컨테이너가 UserService 요청을 받아 ILogger 의존 토큰을 해석한다 | 실제 생성자는 해석된 객체를 받는다 |
| scope 적용 | 객체 identity: scope 규칙에 따름 / consumer: 주입된 실제 객체 | scope에 따라 기존 객체를 재사용하거나 새로 만든 뒤 반환한다 | 토큰이라는 런타임 interface 객체를 생성하는 것은 아니다 |
IoC 컨테이너는 token 그래프와 scope를 순서대로 해석한다 — 상태 추적
토큰 등록 뒤 get 시 의존성 탐색과 생성/캐시/객체 반환 상태가 달라진다. 컨테이너와 객체 정체성으로 추적할 가치가 있다.
IOC / REGISTER → RESOLVE
등록을 마친 컨테이너가 token 의존을 풀어 객체를 반환한다
bootstrap은 token/provider를 등록하고 caller는 root token을 요청합니다. 이후 의존 조회와 scope 판단은 컨테이너 내부에서 이루어지며, bootstrap에 다시 의존 정보를 요청하지 않습니다.
REGISTER
런타임 token을 등록
interface는 JS에서 사라집니다. 교재는 TYPES.ILogger·TYPES.UserService Symbol을 구현체에 바인딩합니다.
RESOLVE
의존 token을 해석
get(UserService)를 받으면 생성자의 @inject(TYPES.ILogger) 메타데이터를 읽고 ConsoleLogger를 주입합니다.
SCOPE
재사용 여부는 수명 정책
transient는 새 값을, singleton은 같은 값을 재사용합니다. request scope는 같은 root resolve 요청 안의 재사용입니다. HTTP 요청 수명과 자동으로 같지는 않습니다.
LESSON SCOPE
교재 바인딩은 기본 수명
예제는 singleton/request scope를 지정하지 않았습니다. scope cache가 항상 사용되는 실행 결과처럼 읽지 않습니다. 라이브러리 버전·데코레이터 설정도 함께 확인합니다.
같은 계약의 구현 선택과 cache 적용 범위를 구별하고, 등록되지 않은 token은 실제 해결 오류로 확인합니다.
이 예의 조건
- 원도표의 전체 구조와 설명은 위 관계도와 표에서 유지합니다. 아래 인쇄 표에도 원도표의 관계·조건·라벨·설명·주석을 보존합니다.
- 두 관찰 칸의 위치는 고정입니다. 관찰 상태는 해당 순간의 값이며, 동작·이유는 그 조건에서의 판정과 변화 원인입니다. 독립 비교와 실패 분기는 별도 조건으로 읽고 모든 분기를 한 요청의 필수 실행 순서로 이어 읽지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 전체 관계·조건 설명 | 모바일의 첫 흐름은 Bootstrap이 token과 provider를 Container에 등록하는 순서를 보여 준다. 이어지는 두 번째 흐름에서 Caller의 root 요청을 해결한다. |
| 원도표 설명 | interface는 JS에서 사라집니다. 교재는 TYPES.ILogger·TYPES.UserService Symbol을 구현체에 바인딩합니다. |
| 장면 | 현재 관찰값 | 설명 | 변화 이유 |
|---|---|---|---|
| binding 등록 | container: bindings 있음 / 객체: 아직 생성 안 될 수 있음 | 토큰을 구현 생성 규칙과 scope에 연결한다 | 등록과 실제 객체 생성을 구분한다 |
| get과 의존 탐색 | 요청: UserService / 의존: ILogger binding | 컨테이너가 UserService 요청을 받아 ILogger 의존 토큰을 해석한다 | 실제 생성자는 해석된 객체를 받는다 |
| scope 적용 | 객체 identity: scope 규칙에 따름 / consumer: 주입된 실제 객체 | scope에 따라 기존 객체를 재사용하거나 새로 만든 뒤 반환한다 | 토큰이라는 런타임 interface 객체를 생성하는 것은 아니다 |
위 그림은 이 과정을 단계별로 보여 줍니다. 시작 코드가 토큰과 만드는 규칙을 먼저 등록하고, 쓰는 쪽이 UserService를 요청하면 컨테이너가 안에서 로거의 의존성을 찾아 수명의 규칙에 따라 새로 만들거나 저장해 둔 것을 쓴 뒤, 완성된 객체를 돌려줍니다. 등록이 끝나면 컨테이너는 의존 관계를 다시 시작 코드에 묻지 않습니다.
데코레이터를 쓰는 컨테이너 라이브러리
InversifyJS나 TSyringe 같은 라이브러리와 NestJS 프레임워크는 위의 컨테이너를 더 발전시켜, 클래스에 붙인 데코레이터(decorator)로 등록과 주입을 적게 합니다. 예를 들어 InversifyJS에서는 다음과 같은 모양이 됩니다.
import { Container, inject, injectable } from "inversify";
const TYPES = { Logger: Symbol.for("Logger") };
@injectable()
class InjectedConsoleLogger implements AppLogger {
log(message: string): void {
console.log(`[Console Log]: ${message}`);
}
}
@injectable()
class InjectedUserService {
constructor(@inject(TYPES.Logger) private logger: AppLogger) {}
registerUser(username: string): void {
this.logger.log(`Registering user: ${username}`);
}
}
const inversify = new Container();
inversify.bind<AppLogger>(TYPES.Logger).to(InjectedConsoleLogger);
inversify.bind(InjectedUserService).toSelf();
inversify.get(InjectedUserService).registerUser("Grace");@inject(TYPES.Logger)가 생성자 매개변수에 어떤 토큰의 객체를 넣을지 알려 주므로, 위에서 직접 썼던 만드는 함수를 쓰지 않아도 됩니다. 대신 서비스 클래스가 컨테이너 라이브러리의 데코레이터에 의존하게 되어, 앞의 순수한 생성자 주입과 달리 업무 코드에도 조립을 위한 표시가 들어갑니다. 생성자 매개변수에 붙이는 데코레이터는 TypeScript 5.0의 표준 데코레이터에 없는 기능이어서, 이런 라이브러리는 experimentalDecorators 같은 예전 방식의 옵션과 라이브러리가 요구하는 설정을 따로 켜야 하며, Node.js의 타입 제거 실행으로는 데코레이터를 실행할 수 없습니다. 정확한 설정과 API는 사용하는 버전의 InversifyJS 공식 문서를 확인합니다.
컨테이너는 의존성 주입에 꼭 필요한 도구가 아닙니다. 객체가 많지 않다면 조립 루트에서 직접 만들어 넘기는 편이 읽기 쉽고 실행 중의 비용도 없습니다. 컨테이너는 객체의 수가 늘어 조립 코드가 복잡해지고, 수명의 규칙을 한곳에서 관리해야 할 때 도입을 검토합니다.
더 깊이: 예전 데코레이터와 표준 데코레이터
타입스크립트의 experimentalDecorators는 데코레이터 제안의 아주 오래된 판을 본뜬 기능입니다. TypeScript 5.0 릴리스 노트에 따르면 5.0부터는 이 옵션 없이도 표준 제안을 따르는 데코레이터를 쓸 수 있는데, 두 방식은 타입 검사 규칙과 출력이 크게 달라서 기존의 데코레이터 함수가 표준 방식에서 그대로 동작할 가능성은 낮습니다. 또 표준 데코레이터는 매개변수에 붙일 수 없고 emitDecoratorMetadata와도 함께 쓸 수 없습니다. 생성자 매개변수에 @inject를 붙이는 컨테이너가 여전히 예전 옵션을 요구하는 것은 이 때문입니다.
emitDecoratorMetadata는 예전 방식의 데코레이터가 붙은 선언마다, 매개변수와 반환값의 타입을 실행 중에 읽을 수 있는 값으로 남기는 옵션입니다. TSConfig 레퍼런스의 예에서는 number 매개변수를 받는 메서드에 __metadata("design:paramtypes", [Number]) 같은 코드가 출력되고, 이 정보는 reflect-metadata 모듈과 함께 읽습니다. 컨테이너는 이런 정보로 생성자가 어떤 클래스를 받는지 알아낼 수 있지만, 인터페이스는 실행 중에 남지 않으므로 인터페이스 타입의 매개변수에는 이 글의 TYPES.Logger 같은 토큰을 따로 적어야 합니다.
표준 쪽에도 TypeScript 5.2부터 데코레이터 메타데이터(decorator metadata)가 들어왔습니다. 데코레이터가 받는 문맥 객체의 metadata 속성에 값을 적어 두면, 클래스의 모든 데코레이터가 실행된 뒤 클래스[Symbol.metadata]로 그 객체를 읽을 수 있습니다. 다만 이 객체는 데코레이터가 직접 채우는 빈 사전일 뿐 컴파일러가 타입 정보를 넣어 주지는 않으며, 릴리스 노트가 나올 당시에는 대부분의 런타임이 지원하지 않아 Symbol.metadata의 폴리필과 esnext.decorators 라이브러리 설정이 필요했습니다.
정리
- 의존성 주입은 객체가 필요한 의존성을 바깥에서 받는 방식이며, 어떤 구현을 쓸지 정하는 일이 바깥으로 옮겨 갑니다.
- 생성자 주입은 만든 직후부터 객체가 완전하고 빠뜨린 의존성을 컴파일러가 알려 주므로 기본으로 쓰고, 세터 주입은 실행 중에 바꿀 때만 씁니다.
- 객체를 만들고 잇는 일은 조립 루트 한곳에 모아, 업무 코드가 구현의 선택을 모르게 합니다.
- IoC 컨테이너는 실행 중에도 남는 토큰과 만드는 규칙, 수명을 등록받아 요청된 객체의 의존성을 차례로 풀어 줍니다.
- 데코레이터를 쓰는 컨테이너 라이브러리는 등록을 줄이는 대신 업무 코드에 조립의 표시가 들어가며, 작은 프로그램은 컨테이너 없이도 충분합니다.
연습 문제
1. 같은 설명의 다른 토큰
container.resolve(createToken<AppLogger>("Logger"))를 부르면 어떻게 되나요? 설명 문자열은 등록할 때와 같은데 왜 그런가요?
해설 보기
No provider for Logger라는 오류를 던집니다. Symbol("Logger")는 부를 때마다 새로운 값을 만들므로, 설명 문자열이 같아도 등록할 때 쓴 LOGGER와는 다른 토큰입니다. 그래서 토큰은 한 번 만들어 상수로 두고, 등록하는 쪽과 요청하는 쪽이 같은 상수를 함께 써야 합니다.
2. 수명을 바꾸었을 때
USER_SERVICE도 { singleton: true }로 등록하고, 로거는 수명을 정하지 않은 채로 바꾸었습니다. first === second와 createdLoggers는 각각 무엇이 되나요?
해설 보기
UserService가 한 번만 만들어지므로 first === second는 true이고, 그 한 번에 로거도 한 번 만들어지므로 createdLoggers는 1입니다. 로거를 매번 새로 만들도록 바꾸었어도, 로거를 요청하는 UserService가 한 번만 만들어지니 로거도 한 번만 만들어지는 것입니다. 수명은 객체마다 정하지만, 오래 사는 객체가 짧게 살아야 할 객체를 붙잡고 있으면 그 객체도 함께 오래 산다는 점을 보여 줍니다.
3. 두 번 등록하기
같은 LOGGER 토큰에 ConsoleLogger를 등록한 뒤 FileLogger를 한 번 더 등록하면 이 컨테이너는 어떻게 되나요? 왜 마지막 등록을 조용히 쓰지 않도록 만들었을까요?
해설 보기
두 번째 register에서 Already registered: Logger라는 오류를 던집니다. 마지막 등록을 조용히 쓰게 만들면, 실수로 같은 토큰을 두 번 등록했을 때 어느 구현이 쓰이는지 알기 어렵고 그 실수가 드러나지도 않습니다. 구현을 정말 바꾸어야 한다면 바꾸는 의도를 드러내는 별도의 메서드를 두는 편이 안전합니다.
다음 글 테스트 주도 개발 (TDD)에서는 테스트를 먼저 쓰고 그 테스트를 통과하는 코드를 만드는 순서로, Jest를 써서 작은 문자열 함수를 개발하는 방법을 다룹니다.