🗓️ 2024. 11. 24
⏱️ 24

TypeScript Namespace Deep Dive

사라지지 않는다면 나름의 이유가 있다

개요

TypeScript에는 namespace라는 키워드가 있습니다.
TypeScript 공식 문서에서는 namespace 대신 모듈을 사용할 것을 권하고, eslint에서는 아예 outdated된 방식이라 말합니다.

It is also worth noting that, for Node.js applications, modules are the default and we recommended modules over namespaces in modern code.
TypeScript: Documentation - Namespaces and Modules

Namespaces are an outdated way to organize TypeScript code. ES2015 module syntax is now preferred (import/export).
no-namespace | typescript-eslint

ES2015 모듈을 권장하는 eslintES2015 모듈을 권장하는 eslint

그럼에도 우린 이따금 namespace 키워드를 접하게 됩니다.
여전히 쓰이고 있는데 대체 왜 쓰지 말라고 하는 걸까요?
사용되는 곳에선 권장되지 않음에도 왜 여전히 사용중인 걸까요?
아니, 애초에 모듈 시스템이 있는데 namespace 기능은 왜 만들어졌던 걸까요?

이같은 의문들을 품은 채 그간의 역사를 되짚어나갔고, 제 나름의 결론을 지어 정리해보았습니다.

이 글에선 namespace의 문법적인 기초를 다루지 않습니다.
대신 보편적인 개념과 TypeScript에서의 등장 배경, 왜 권장되지 않는지, 사용한다면 언제가 좋을지를 다룹니다.

Namespace

우선 Namespace의 기본적인 개념부터 짚고 넘어가겠습니다.

Namespace를 직역하면 '이름공간'입니다. 너무 추상적이라 조금 더 풀어내면 '이름이 갖는 공간' 정도가 되겠네요.
이름마다 고유한 공간을 갖게 되는데, 다른 공간에 있다면 이름이 같더라도 서로 구분된다는 의미입니다.

대표적인 예는 파일 시스템입니다.

$ cat /Documents/hello.txt
$ cat /Documents/draft/hello.txt
$ cat /Documents/final/hello.txt

세 개의 경로에 저장되어 있는 hello.txt는 이름만 같을 뿐 서로 다른 파일입니다.
이처럼 같은 이름이더라도 공간이 다르면 다른 것으로 간주하는 것이 Namespace의 기본 개념입니다.

이름이 같아도 공간이 다르면 서로 구분된다이름이 같아도 공간이 다르면 서로 구분된다

Namespace는 프로그래밍 언어에서도 동일한 개념으로 사용됩니다.
우리는 변수, 클래스, 함수 등을 선언할 때 이름을 붙입니다.
만약 우리가 붙인 이름이 다른 곳에서도 흔히 쓰이는 이름이라면 충돌이 발생할 수 있습니다.

// 여기저기서 흔히 사용되는 이름들

const value = 1234;

class Event { }

function process(event: Event) {}

앞선 파일 시스템의 예시에서는 경로가 충돌 문제를 해결해주었습니다.
프로그래밍 언어에서는 모듈이 이를 해결해줍니다.

Java에서는 package, C++와 C#에서는 namespace가 그 역할을 해주고 있죠.
TypeScript 역시 namespace로 이름 충돌 문제를 해결할 수 있습니다.
(C# 설계자가 TypeScript를 설계했기 때문에 TypeScript는 C#의 영향을 많이 받았습니다)

어? 잠깐만요, 모듈 시스템이라면 CJS(CommonJS)나 ESM(ES Module)이 있지 않나요?
생각해보면 우리는 이미 namespace 키워드 없이도 Namespace 개념을 익숙하게 다루고 있습니다.

ESM을 기준으로 간단히 확인해보겠습니다.

// a.ts
export const value = 1234;
export function process() { }

// b.ts
export const value = 1234;
export function process() { }
// main.ts
import { value as aValue } from "./a";
import { value as bValue } from "./b";

import * as A from "./a";
import * as B from "./b";

console.log(aValue);
console.log(bValue);

console.log(A.process())
console.log(B.process())

import/export 구문으로 같은 이름을 구분짓고 있습니다.
이름이 같더라도 별도의 파일로 분리되어있다면 각각을 별도의 모듈로 간주하고 구분지을 수 있습니다.
즉, 파일이 곧 모듈이며 모듈 그 자체로 Namespace가 존재하는 것입니다. 이는 CJS에서도 마찬가지입니다.

모듈 시스템에선 파일이 곧 모듈이면서 하나의 Namespace가 된다모듈 시스템에선 파일이 곧 모듈이면서 하나의 Namespace가 된다

이미 Namespace 개념이 있는데 TypeScript는 왜 굳이 namespace 기능을 만들 걸까요?

탄생 배경

TypeScript의 namespace 탄생 배경은 당시 JavaScript와 모듈 시스템의 발전 상황과 깊이 연관되어 있습니다.

JavaScript의 공식 모듈 시스템인 ES Module은 ES6(ECMA 2015)가 발표된 2015년 6월에 등장했습니다.
TypeScript는 2012년에 등장했는데, 아직 공식적인 모듈 시스템이 채택되지 않은 시기였습니다.

그래서 TypeScript팀은 자체적인 모듈 시스템을 만들었고, 이를 module이라는 키워드로 제공했습니다.
module은 직접 정의한 모듈로서 내부 모듈(internal module)이라 불렸고, 외부에서 불러온 라이브러리는 외부 모듈(external module)이라 불렸습니다.

그러던 2015년 6월, ES6가 발표되면서 JS 생태계에 공식적인 모듈 시스템이 도입됐습니다.
그에 발맞춰 2015년 7월에 TypeScript 팀은 1.5 버전을 배포하며 ES6 모듈 지원과 함께 혼란을 피하고자 용어를 변경합니다.
내부 모듈은 Namespace로, 외부 모듈은 ES6와 동일한 용어인 module로 바뀌었죠.
호환성 유지를 위해 내부 모듈을 만드는 데 사용됐던 module 키워드 역시 namespace로 바뀌었습니다.

// ES6 등장 이전
module MyApp { }

// ES6 등장 이후
namespace MyApp { }

결과적으로 TypeScript 1.5 버전부터는 아래와 같이 ES Module과 namespace를 함께 사용할 수 있게 됐습니다.

export namespace MyApp {}

그런데... 둘 다 모듈 시스템이라면 두 개의 모듈 시스템이 혼용되는 게 이상하지 않나요?
한 프로젝트에서 ESM과 CJS를 동시에 사용할 수는 없잖아요?
이게 어떻게 가능한 걸까요?

사실은 JavaScript Object

ES6 이전에는 저마다의 방식으로 모듈 시스템을 적용했습니다.
대표적인 방식이 전역 변수 사용, IIFE 패턴, CJS를 비롯한 비표준 모듈 시스템이었습니다.

TypeScript는 그중에서 IIFE 패턴을 채택했습니다.
아시다시피 TypeScript는 타입 지원뿐만 아니라 트랜스파일까지 수행합니다.
실제로 namespace를 사용한 TypeScript 코드를 빌드해보면 IIFE 패턴으로 변환되는 것을 확인할 수 있습니다.

// 타입스크립트 코드
export namespace MyApp {
    export const value = 1234;
    export function process() {}
}
// 변환된 ESM 코드
export var MyApp;
(function (MyApp) {
    MyApp.value = 1234;
    function process() { }
    MyApp.process = process;
})(MyApp || (MyApp = {}));
// 변환된 CJS 코드
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.MyApp = void 0;
var MyApp;
(function (MyApp) {
    MyApp.value = 1234;
    function process() { }
    MyApp.process = process;
})(MyApp || (exports.MyApp = MyApp = {}));

앞서 언급했듯이 IIFE 패턴은 TypeScript가 나타나기 이전부터 JavaScript 생태계에서 모듈을 구현하는 방식 중 하나였습니다.
TypeScript는 일종의 syntax sugar를 제공할 뿐이었던 거죠. 빌드 후에 사라지는 타입과는 달리 런타임에 실체가 있는 기능입니다.

// namespace를 단순화하면 IIFE 패턴으로 캡슐화한 Object가 된다
export const MyApp = (function () {
  let _message = '';

  function setMessage(message: string) {
    _message = message;
  }

  function printMessage() {
    return `Message: ${_message}`;
  }

  return {
    setMessage,
    printMessage,
  };
})();

MyApp.printMessage();

namespace는 사실상 IIFE 패턴으로 만들어진 JavaScript Object이므로 다른 언어에서 사용되는 Namespace와 완전히 같을 순 없습니다. 빌드 이후에 런타임에선 결국 JavaScript Object가 되니까요. 때문에 CJS나 ESM같은 모듈 시스템과 혼용되는 것도 당연합니다.

이같은 캡슐화는 당시 JavaScript의 고질적인 문제였던 전역 스코프 오염전역 변수 사용을 효과적으로 방지해주었습니다.
하지만 캡슐화된 JavaScript Object가 여전히 전역 스코프에 존재한다는 단점은 해결할 수가 없었습니다.
어디까지나 코드 레벨에서의 캡슐화일뿐이지, 언어 차원에서 지원되는 네이티브 기능은 아니었으니까요.

결국 전역 스코프에 있는 변수를 사용할 때 발생하는 단점들을 여전히 떠안게 됩니다.

  1. 생명 주기가 긴 만큼 메모리를 오랫동안 점유
  2. 스코프 체인의 끝에 있어 검색 속도가 가장 느림

물론 모듈 시스템과 함께 사용한다면 이러한 단점들은 문제가 되지 않습니다.
모듈 입장에서는 일반적인 JavaScript Object를 불러오는 것과 다름없기 때문이죠.

하지만 공식적인 표준 모듈이 나타났기 때문에 굳이 namespace를 사용할 이유가 사라졌습니다.
(잠시 뒤에 살펴보겠지만 사실 namespace는 JavaScript Object이기 때문에 그 자체로 성능과 최적화 문제가 있기도 합니다.)

표준 모듈의 등장

표준 모듈이 등장하기 전까지 namespace는 과도기적 해결책으로서의 역할을 다 했습니다.
현대 JS 코드는 대부분 모듈 기반으로 작성되기 때문에 전역 라이브러리 사용이 드물고, 그에 따라 namespace의 필요성도 줄어든 상태입니다. 이제는 사실상 선택적 기능이 된 거죠. TypeScript 팀도 일반적인 상황에서는 표준 모듈을 권장하고 있습니다.

때문에 namespace는 주로 전역 객체에 타입을 지정하는 용도로만 사용되고 있습니다.

// 대표적인 예시(ambient namespace)
declare global {
  namespace NodeJS {
    interface ProcessEnv {
      NODE_ENV: 'development' | 'production' | 'test';
    }
  }
}

게다가 표준이 의미하는 바는 상상 이상으로 큽니다.
네이티브로 지원되기 때문에 생태계에 존재하는 여러 도구들의 호환성이 여기서 비롯하게 되니까요.

실제 전환 사례

표준 모듈이 지원됨에 따라 실제로 어떤 곳에서 namespace를 표준 모듈로 전환했는지 그 이유와 함께 살펴보겠습니다.
가장 대표적이면서도 상징적인 사례가 바로 TypeScript입니다. namespace를 만든 곳에서도 표준 모듈로 전환한 것이죠.

TypeScript 팀은 5.0 버전에서 namespace를 표준 모듈로 전환하면서 패키지 사이즈를 43% 감소 시켰고, 컴파일러의 속도 또한 10%~25% 상승시킬 수 있었습니다. 대체 어떤 문제가 있었길래 이렇게 큰 효과를 얻었을까요?

아까 미뤄뒀던 namespace가 JavaScript Object이기 때문에 생기는 문제들을 여기서 살펴보겠습니다.
상세한 내용은 TypeScript 팀의 표준 모듈 전환 과정에 나와있지만 간략히 요약하면 다음과 같습니다.

  1. 모든 namespace마다 IIFE 실행이 요구되므로 오버헤드가 생김
  2. namespace는 JavaScript Object로 변환되는데, 객체의 속성으로 접근해서 함수를 호출하는 건 그냥 함수를 호출하는 것보다 속도가 느림(일반적인 경우엔 무시 가능한 수준이나 컴파일러 레벨에서는 무시할 수 없는 수준)
  3. 모듈이 아니라 JavaScript Object이기 때문에 트리 쉐이킹 불가
  4. 같은 이유로 증분 빌드가 어려움

추가적으로, esbuild를 만든 Evan Wallacenamespace가 변경 가능한(Mutable) JavaScript Object이기 때문에 안전하지 않다고도 지적했습니다.

표준이 아닌 점, 실질적으로 성능과 최적화에 영향을 미치는 점을 미루어보아 권장되지 않는 이유는 확실히 알 것 같네요.

deprecated?

그럼 namespace는 장차 deprecated 될까요?

그렇지 않습니다!

이 지점이 가장 오해가 많은데요, TypeScript 팀은 namespace가 legacy인지 묻는 이슈에서 명확히 답변해주었습니다.

  • v1.0 이후의 어떠한 문법도 제거하지 않을 것
  • 인터넷에 떠도는 타입스크립트의 향후 계획을 믿지 말고, 공식적인 로드맵을 확인해달라
  • 기왕이면 표준 모듈을 권장한다
  • 그럴 일은 거의 없겠지만 그럼에도 namespace가 더 유용하다고 느낀다면 사용해도 괜찮다
  • namespace가 타입 계층을 나타내는 데 유용하기 때문에 적어도 namespace를 완전히 제거할 일은 없을 것이다

개인적인 결론

그렇다면 namespace는 언제 유용할까요?
성능과 최적화 문제를 확인했음에도 불구하고 사용해도 괜찮을까요?

제가 내린 결론은 다음과 같습니다.

  1. 타입만 사용할 땐 적극적으로 쓰자
  2. 프로젝트 규모가 TypeScript 정도가 아니라면 미미한 최적화보다 편의성을 챙기자
  3. 트리 쉐이킹이 필요하다면 사용하지 말자

각각의 상황을 하나씩 살펴보겠습니다.

타입만 사용할 땐 적극적으로 쓰자

TypeScript 팀이 언급한 바와 같이 namespace는 타입 계층을 나타내는 데 굉장히 편리합니다.
타입만 존재한다면 런타임에 아무런 영향을 미치지 않으니 성능과 최적화에서도 아무 문제가 없습니다.

namespace로 타입 계층을 표현하면 중복되는 네이밍과 별도의 타입 import 과정을 줄일 수 있습니다.

// Modal.tsx
const Modal = ({ message }: Modal.Props) => {
  return <div>{message}</div>
}

namespace Modal {
   export interface Props {}
}

export default Modal

// App.tsx
import Modal from "./Modal"

const App = () => {
  const props: Modal.Props = {
    message: "hello"
  }

  return <Modal {...props} />
}

이같은 패턴은 openainode api에서도 사용되고 있습니다.

// src/index.ts
export class OpenAI extends Core.APIClient {
  // ...
}

export declare namespace OpenAI {
  // ...
}
// src/resources/chat/completions.ts
export namespace ChatCompletion {
  export namespace Choice {
    // ...
  }

  // ...
}

물론 모듈로도 계층적으로 타입을 묶을 수 있지만, 모듈 특성상 일반적으로 개별 파일 단위로 관리되기 때문에 매번 index.ts를 만들고 reexport를 해줘야 하는 등 수고로움을 빈번하게 감수해야 합니다.

미미한 최적화보다 편의성을 챙기자

TypeScript 팀이 언급했듯이 컴파일러 수준의 프로젝트가 아니라면 namespace로 인한 성능 문제는 미미한 수준입니다.
따라서 성능보다 namespace가 제공하는 편의성을 챙기는 편이 더 좋다면 사용해도 좋습니다.

표준 모듈의 다소 불편한 점들을 살펴보겠습니다.

equals라는 함수를 내보내는 두 개의 모듈이 있습니다.

// crypto.ts
export const hash = (text: string): string => {}
export const equals = (plain: string, hashed: string): boolean => {}
// auth-code.ts
export const equals = (input: string, correct: string): boolean => {}

equals 함수를 호출할 때, IDE의 자동 완성으로 불러오면 두 개의 equals가 import 후보로 나타납니다.
이는 개발자가 실수할 여지가 있기 때문에 주의가 필요합니다.

원하는 모듈을 제대로 불러왔는지 주의하기 위해 alias 기능을 사용할 수도 있습니다.

import * as Crypto from "./crypto"
import * as AuthCode from "./auth-code"

하지만 aliasimport할 때마다 매번 이름을 지정해주어야 한다는 불편함이 따릅니다.
Crypto나 AuthCode만 입력해서는 자동 완성되지 않기 때문입니다.

게다가 여러 개발자가 동시에 작성할 경우 이름이 일관적이지 않을 수도 있습니다.

// a.ts
import * as Crypto from "./crypto"

// b.ts
import * as CryptoModule from "./crypto"

물론 다음과 같이 Object로 묶어서 내보낸다면 IDE의 자동 완성 기능을 지원받을 수 있는데요,
이미 눈치채셨겠지만 이러면 namespace를 사용해서 Object를 내보내는 것과 다를 바 없이 번거로워지기만 합니다.

// crypto.ts
const hash = (text: string) => {}
const equals = (plain: string, hashed: string) => {}

const Crypto = {
  hash,
  equals
}

export default Crypto

index.ts 파일을 이용하는 정석적인 방법도 있습니다.

// crypto/core.ts
export const hash = (text: string) => {}
export const equals = (plain: string, hashed: string) => {}

// crypto/index.ts
export * as Crypto from "./core"

이렇게 하면 위에서 차례대로 살펴본 단점들이 모두 해소됩니다!
하지만 모든 모듈마다 index.ts 파일을 만들어주고, 모듈에 혹시 계층이라도 있다면 그 번거로움은 상상만해도 끔찍합니다.

이럴 땐 namespace를 사용하는 게 오히려 가독성이 좋고, 유지보수도 편합니다.

export namespace Crypto {
  export const hash = (text: string) => {}
  export const equals = (plain: string, hashed: string) => {}
}

추가적으로, 반드시 쌍으로 따라오는 HTTP 요청과 응답 클래스같은 경우도 namespace로 묶으면 편리합니다.

export namespace SignUp {
    export class Request { }
    export class Response { }
}

트리 쉐이킹이 필요하면 사용하지 말자

트리 쉐이킹이 사실상 필수적으로 요구되는 프론트엔드나 라이브러리를 개발할 때는 쓰지 않는 게 좋습니다.
반면 서버 사이드 애플리케이션처럼 트리 쉐이킹이 불필요한 환경에서 개발한다면 괜찮습니다.
런타임 환경에 따라 선택이 갈리는 건 어찌보면 당연한 거니까요.
물론 타입만 사용한다면 트리 쉐이킹이 필요한 곳에서도 문제없겠죠!

마무리

도움이 되셨나요? 제가 내린 결론에는 찬반이 갈릴 거라 생각합니다.
하지만 이 글을 통해 namespace에 대한 오해가 해소되셨다면 무척이나 기쁠 것 같습니다.
어디선가 같은 오해를 발견하신다면 그 곳에서 이 글이 도움되길 바랍니다.

참고

돌아가기
© 2025 VERYCOSY.