(번역) `tsconfig.json`: 타입스크립트에 대해 아무도 설명해주지 않은 한 가지

2023. 12. 17. 00:50Programming/Web

크롬 브라우저 기본 화면으로 사용하고 있는 daily.dev에 올라온, 내가 또 언젠가 나중에 읽겠지, 하고 저장해둔 글 중 하나다. ^^; 타입 스크립트, 그 중에서도 tsconfig.json에 관련된 글인데, 사실 어떤 환경이든 config는 검색을 하면 기본적으로 많이 쓰는 형태가 있기 때문에 어떤 항목이 무슨 역할을 하는지 눈여겨보기 쉽지 않다고 생각한다. 이 글을 읽으면서 환경설정 같은 부분을 더 신경써야겠다고 생각했다. 실행된다고 다가 아니다!

 


 

원본: https://kettanaito.com/blog/one-thing-nobody-explained-to-you-about-typescript

 

One Thing Nobody Explained To You About TypeScript

One of the most common mistakes in configuring TypeScript.

kettanaito.com

 

저는 타입스크립트를 사용한 지 4년이 넘었는데, 전반적으로 좋은 경험이었습니다. 시간이 지남에 따라 타입스크립트 사용 시 발생하는 어려움이 거의 없어져 타입을 작성하거나 문제에 접근할 때 생산성이 훨씬 더 높아졌습니다. 저는 타입 스크립트의 마법사는 아니어도 조건부 타입, 중첩 제네릭, 타입과 인터페이스의 차이에 대해 고민해오면서 감히 언어에 능숙해졌다고 생각합니다. 네, 솔직히 저는 언어를 꽤 잘 이해하고 있다고 생각했습니다.

하지만 아니었습니다. 타입스크립트에 대해 제가 완전히 틀린 점이 한 가지 있는데, 여러분도 틀릴 것이라고 생각합니다. 그것은 여러분이 들어본 적도 없고 앞으로도 사용하지 않을 것 같은 인위적인 코너 케이스가 아닙니다. 정반대입니다. 여러분과 다른 모든 타입스크립트 개발자들이 수백 번도 넘게 직접 상호작용한, 우리 코앞에 항상 존재해 왔던 문제입니다.

 

바로, tsconfig.json 입니다.

 

tsconfig.json가 얼마나 복잡할 수 있는지에 대한 이야기가 아닙니다(targetmodule은 제게도 지체없이 설명하기 어렵습니다). 그 대신 매우 간단한 내용입니다. tsconfig.json이 실제로 무엇을 하는지에 관한 것입니다.
"글쎄요, 구성 파일이고 TypeScript를 구성하는 거죠." 맞습니다! 하지만 여러분이 예상하는 방식은 아닙니다. 제가 보여드리겠습니다.

 

 

Libraries, tests, and the truth

모든 위대한 발견에는 훌륭한 사례가 있습니다. 저는 이 글에서 이 두 가지가 모두 이루어질 수 있도록 최선을 다할 것입니다.
간단한 프론트엔드 애플리케이션을 작성해 봅시다. 진심입니다. 프레임워크나 종속성이 없습니다. 간단합니다.

// src/app.ts
const greetingText = document.createElement('p')
greetingText.innerText = 'Hello, John!'

document.body.appendChild(greetingText)

단락 요소를 만들고 John에게 인사합니다. 간단합니다. 지금까지는 괜찮습니다.

 

하지만 document의 출처는 어디일까요? 자바스크립트의 전역 변수라고 말할 수 있을텐데요, 물론 맞는 말입니다. 그러나 한 가지 문제가 있습니다. 우리는 자바스크립트를 사용하지 않습니다. 엄밀히는, '아직'은 아니죠. 우리는 IDE에서 일부 타입스크립트 코드를 보고 있습니다. 이 코드가 자바스크립트가 되려면 컴파일되어야 하고, 브라우저에서 실행되어야 하며, 브라우저에서 document를 전역으로 노출해야 합니다. 그렇다면 TypeScript는 document, document의 존재나 메서드를 어떻게 알 수 있을까요?

타입스크립트는 lib.dom이라는 기본 definition library를 로드하여 이를 수행합니다. 자바스크립트의 global들을 설명하기 위한 여러 가지 타입이 들어 있는 .d.ts 파일이라고 생각하면 됩니다. CMD(Windows의 경우 CTRL)를 누른 상태에서 문서 개체를 클릭하면 직접 확인할 수 있습니다. 미스터리가 풀렸습니다.

 

우리 애플리케이션에 대한 몇 가지 자동화된 테스트를 추가해 보겠습니다. 이 단계에서는 단순함이라는 개념을 배신하고 Vitest라는 테스트 프레임워크를 설치하겠습니다. 다음으로 테스트 자체를 작성합니다:

// src/app.test.ts
it('greets John', async () => {
  await import('./app')
  const greetingText = document.querySelector('p')
  expect(greetingText).toHaveText('Hello, John!')
})

이 테스트를 실행하려고 하면 TypeScript가 오류를 일으킵니다:

Cannot find name 'it'. Do you need to install type definitions for a test runner?

인정하긴 싫지만 컴파일러의 말이 맞습니다. it는 어디서 오는 걸까요? document와 같은 전역 객체가 아니기 때문에 어딘가에서 불러와야 합니다. 사실, 테스트 프레임워크에서 명시적으로 import하지 않고도 각 테스트에서 접근할 수 있도록 전역 객체를 확장하고 it, expect와 같은 함수를 전역에 노출하는 것은 매우 일반적입니다.

 

저희는 편리하게 테스트 프레임워크의 설명서를 따라 tsconfig.json을 수정하여 전역화합니다:

// tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  },
  "include": ["src"]
}

compilerOptions.types를 사용하여, 우리는 TypeScript에 글로벌 it 함수가 선언된 추가 타입(이 경우 vitest/globals)을 로드하도록 요청하고 있습니다. 컴파일러는 우리의 노력에 미소를 지으며 테스트를 통과시켜주었고, 우리는 우리 자신과 이 엄격한 타입 언어의 시련에 특히 기분이 좋아졌습니다.

하지만 그렇지 않습니다. 아직 이릅니다.

 

 

The issue

한쪽으로 약간 기울어지겠지만 결국에는 모든 것이 합리적일 것이라고 약속드립니다.

하나만 물어보겠습니다: TypeScript에서 존재하지 않는 코드를 참조하면 어떻게 될까요? 네, 물결 모양의 빨간색 선과 Cannot find name 오류가 표시됩니다. 방금 전에 테스트에서 it()을 호출하려고 할 때 이 오류를 보았습니다.

 

app.ts 모듈로 돌아가서 test라는 존재하지 않는 전역 변수에 대한 참조를 추가하세요:

// src/app.ts
// ...application code.

test

test는 정의되지 않았습니다. test는 브라우저 전역이 아니며, 타입스크립트 기본 라이브러리에도 존재하지 않습니다. 실수나 버그이므로 빨간색으로 표시되어야 합니다.

 

하지만 그렇지 않습니다. 빨간색 물결 선이 코드 아래에서 드러나지 않으므로 전력이 사용자를 통과합니다. 혼란스럽네요. 설상가상으로, TypeScript는 여기서 오류를 생성하지 않을 뿐만 아니라, 도움말을 띄우면서 TestApi의 네임스페이스에서 온 test를 사용하라고 알려줍니다. 하지만 그건 Vitest의 타입인데 어떻게 이럴 수 있죠?..

이 코드가 컴파일될까요? 물론이죠. 브라우저에서 작동할까요? 아니요. 가장 컨디션이 좋은 날의 노련한 투수처럼 에러를 던질 것입니다. 왜 그럴까요? 타입스크립트를 사용하는 목적 자체가 이런 실수를 방지하는 것 아닌가요?

 

여기서 테스트는 제가 ghostly definition라고 부르는 것입니다. 존재하지 않는 것을 설명하는 유효한 타입 정의입니다. 또 다른 타입스크립트 헛소리라고 할 수 있습니다. 서둘러 도구를 탓하지 마시고, 제가 말씀드리겠습니다.

 

 

(More than) one config to rule them all

app.test.ts 테스트 모듈을 src 디렉터리에서 새로 만든 test 디렉터리로 옮깁니다. 열어보세요. 잠깐만요, 또 유형 오류가 발생했나요? tsconfig.jsonvitest/global을 추가하여 이미 수정하지 않았나요?

문제는 타입스크립트가 test 디렉터리에서 무엇을 해야 할지 모른다는 것입니다. 사실, tsconfig.json에서 우리가 가리키는 것은 src뿐이기 때문에 TypeScript는 test 디렉터리의 존재조차 모릅니다:

// tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  },
  "include": ["src"]
}

앞서 언급했듯이 TypeScript의 configuration이 작동하는 방식은 (적어도 저에게는) 완전히 명확하지 않습니다. 오랫동안 저는 include 옵션이 컴파일에 포함할 모듈을, exclude 옵션이 제외할 모듈을 각각 제어한다고 생각하곤 했습니다. 이 문제에 대해 타입스크립트 문서를 참조하면 다음과 같은 내용을 읽을 수 있습니다:

include, specifies an array of filenames or patterns to include in the program.

제가 include의 기능을 이해하는 방식은 문서에 명시된 것과는 약간 다르며 더 구체적입니다.

include 옵션은 이 TypeScript의 configuration을 적용할 모듈을 제어합니다.

제대로 읽으셨습니다. TypeScript 모듈이 include 옵션에 나열된 디렉터리 외부에 있는 경우, 해당 tsconfig.json은 해당 모듈에 전혀 영향을 미치지 않습니다. 각각 exclude 옵션을 사용하면 현재 구성의 영향을 받지 않아야 하는 파일 패턴을 필터링할 수 있습니다.

 

testinclude에 추가하고 하루 일과를 계속 진행하면 될텐데, 뭐가 문제일까요?

// tsconfig.json
{
  "compilerOptions": {
    "types": ["vitest/globals"]
  },
  "include": ["src", "test"]
}

 

대부분의 개발자가 완전히 잘못하는 부분이 바로 이 부분입니다. include에 새 디렉터리를 추가하면, 이 구성을 모든 디렉터리에 영향을 미치도록 확장하는 것입니다. 이렇게 변경하면 테스트 프레임워크의 타입 에러가 수정되지만, 타입들은 모든 src 모듈에 유출됩니다! 여러분들은 방금 전체 소스 코드를 하나의 유령 저택으로 만들어 수백 개의 유령 타입을 풀어놓은 것입니다. 존재하지 않는 것이 입력되고, 입력된 것이 다른 정의와 충돌할 수 있으며, 특히 시간이 지나 애플리케이션이 성장함에 따라 TypeScript 사용에 대한 전반적인 경험이 급격히 저하될 것입니다.

그렇다면 해결책은 무엇일까요? 모든 디렉토리에 대해 수많은 tsconfig.json을 만들어야 할까요?

 

사실, 그래야 합니다. 단, 모든 디렉터리가 아니라 코드가 실행될 모든 환경에 대해 만들어야 합니다.

 

 

Runtimes and concerns

최신 웹 애플리케이션의 이면에는 정교한 모듈 샐러드가 있습니다. 앱의 즉각적인 소스는 컴파일, 축소, 코드 분할, 번들링 및 사용자에게 제공되어야 합니다. 반면 TypeScript 모듈이나 테스트 파일들은 절대로 누구에게도 컴파일되거나 제공되지 않습니다. 또한 Storybook의 스토리, Playwright 테스트, 자동화를 위한 사용자 지정 *.ts 스크립트 등은 모두 다른 의도를 가지고 있고 서로 다른 환경에서 실행되도록 되어 있습니다.

 

하지만 모듈을 작성하는 목적이 중요합니다. 이는 타입스크립트에서도 마찬가지입니다. 왜 기본적으로 document 타입을 제공한다고 생각하시나요? 웹 앱을 개발할 가능성이 높다는 것을 알고 있기 때문입니다. 대신 Node.js 서버를 개발하시나요? 친절하게 그런 의도를 밝히고 @types/node를 설치하세요. 컴파일러가 추측할 수 없으므로 사용자가 원하는 것을 말해야 합니다.

그리고 tsconfig.json을 통해 그 의도를 전달합니다. 루트 레벨에서 하나만 할 필요는 없습니다. TypeScript는 중첩된 구성을 매우 잘 처리할 수 있습니다. 그렇게 하도록 설계되었기 때문입니다. 의도를 명확하게 표현하기만 하면 됩니다.

 

그렇게 하려면 프로젝트 전체에 tsconfig.json 파일을 전략적으로 배치하세요. 다음은 예시입니다:

# The root-level configuration to list the common options.
# All the other configurations will extend from it.
# We want to set "noEmit": true and "declarations": false,
# as well as "skipLibCheck": true in this one.
- tsconfig.json

# The root-level configuration for the actual application build.
# This is where we want to emit modules and type declarations.
- tsconfig.build.json

- /e2e
  # A configuration for end-to-end test files
  # that are meant to either run in or describe the browser
  # runtime. We'd want the "lib" library here, and perhaps
  # additional types, like "dom.iterable".
  - tsconfig.json
  - Login.test.ts

- /src
  # Another config, this time for the source code.
  # This can be skipped it the default configuration
  # covers the source, but the source may require additional
  # types in certain situations.
  - App.tsx
  - util.ts

- /test
  # TypeScript configuration for test files. This is where
  # we add things like "vitest/globals", Node.js globals,
  # and other, test-related types.
  - tsconfig.json
  - App.test.tsx

config들이 정말 많네요! 소스 파일부터 다양한 테스트 레벨, 프로덕션 빌드에 이르기까지 많은 의도가 담겨 있습니다. 모두 타입 안전성을 위한 것입니다. 그리고 루트에서 기본 ./tsconfig.json을 확장하는 디렉터리 범위의 config를 도입하여 타입 안전성을 확보할 수 있습니다.

 

예를 들어, src 파일에 대한 TypeScript config는 다음과 같습니다:

// src/tsconfig.json
{
  // Extend the root-level config to reuse common options.
  "extends": "./tsconfig.json",
  "compilerOptions": {
    // Compile to the code that runs in the browser.
    "target": "es2015",
    "module": "esnext",
    // Support JSX, we're running React here.
    "jsx": "react"
  },
  // Apply this config only to the source files.
  "include": ["src"],
  "exclude": ["node_modules"]
}

대조적으로, test 디렉터리에 대한 config는 다음과 같습니다:

// test/tsconfig.json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    // No transpiling here, let's stay on the edge.
    "target": "esnext",
    "module": "esnext",
    // Integration tests run in Node.js.
    // Let's also add those test runner's globals.
    "types": ["@types/node", "vitest/globals"]
  },
  // We care only about the test files here.
  "include": ["**/*.test.ts"]
}

타입스크립의 config를 작성할 때, 이것을 기억하세요.

프로젝트의 레이어 수만큼 소스 코드, Node.js 테스트, 브라우저 내 테스트, 타사 도구 등 다양한 TypeScript 구성이 있어야 합니다.

TypeScript는 타입을 검사하는 모듈에 가장 가까운 tsconfig.json을 자동으로 선택하므로 필요한 곳에서 벗어나면서 확장할 수 있습니다.

 

 

The practical aspect

좋든 나쁘든, 우리는 개발자 도구가 추상화되는 시대로 나아가고 있습니다. 선택한 프레임워크가 이러한 config 정글을 대신 처리해 주기를 기대하는 것은 당연한 일입니다. 실제로 일부 프레임워크는 이미 이 작업을 수행하고 있습니다. Vite를 예로 들어보겠습니다. 다른 어떤 프로젝트에서도 타입스크립트를 위한 다중 구성 설정을 찾을 수 있다고 확신합니다.

하지만 TypeScript는 추상화되었든 그렇지 않든 여전히 여러분의 도구이며, 이에 대해 더 많이 배우고, 더 잘 이해하고, 올바르게 사용한다면 좋은 결과를 얻을 수 있다는 점을 이해해 주셨으면 합니다.