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

원문: One Thing Nobody Explained To You About TypeScript

저는 타입스크립트를 4년 넘게 사용해 왔으며, 전반적으로 그 경험은 매우 좋았습니다. 시간이 지남에 따라 타입스크립트 사용에 대한 어려움이 거의 없어져, 타입을 작성하거나 문제를 타입 우선으로 접근할 때 생산성이 훨씬 더 높아졌습니다. 저는 진정한 타입 마법사까지는 아니지만, 타입 기본기 단련, 조건부 타입, 중첩된 제네릭, typeinterface의 신성한 차이에 대해 고민하면서 감히 타입스크립트에 능숙해졌다고 생각합니다. 솔직히 저는 타입스크립트를 꽤 잘 이해하고 있다고 생각했습니다.

하지만 그 순간이 오기 전까지는 알지 못했습니다. 타입스크립트에 대해 제가 완전히 잘못 이해한 부분이 하나 있으며, 저뿐만 아니라 여러분도 마찬가지일 것으로 생각합니다. 그리고 이것은 여러분이 들어본 적도 없고 앞으로도 사용하지 않을 것 같은 인위적인 코너 케이스가 아닙니다. 오히려, 여러분과 다른 모든 타입스크립트 개발자가 수백 번 직접 상호작용한 것 중 하나로, 항상 우리 주변에 존재해 왔던 것입니다.

그것은 바로 tsconfig.json입니다.

그리고 이것은 타입스크립트가 얼마나 복잡해질 수 있는지에 대한 이야기가 아닙니다(솔직히 targetmodule에 대해 주저함 없이는 설명할 수 없습니다.). 대신, 상대적으로 간단하다고 볼 수 있는, tsconfig.json실제로 무엇을 하는지에 관한 것입니다.

“물론, 그것은 설정 파일이며, 타입스크립트를 구성하는 거죠.” 라고 하신다면 맞습니다! 하지만 여러분이 예상하는 방식은 아닙니다. 제가 보여드리겠습니다.

라이브러리, 테스트, 그리고 진실

모든 위대한 발견 뒤에는 훌륭한 사례가 있습니다. 저는 이 두 가지가 모두 이루어질 수 있도록 최선을 다하겠습니다.

간단한 프런트엔드 애플리케이션을 작성해 봅시다. 말 그대로 프레임워크나 종속성도 없이 간단하게 만들어 보겠습니다.

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

document.body.appendChild(greetingText);

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

하지만 document는 어디서 오는 걸까요? 자바스크립트의 전역 변수라고 말할 수 있고, 그것은 물론 맞는 말입니다. 하지만, 한 가지 문제가 있습니다. 우리는 현재 자바스크립트에 있지 않습니다. 아직은 아니죠. 우리는 IDE에서 타입스크립트 코드를 보고 있습니다. 이 코드가 자바스크립트가 되려면 컴파일되어 브라우저에 도착하고, 브라우저가 document를 전역에 노출해야 합니다. 그렇다면 타입스크립트는 document와 그것의 존재, 메서드를 어떻게 알 수 있을까요?

타입스크립트는 lib.dom라고 불리는 기본 정의 라이브러리를 로드하여 이를 수행합니다. 이것은 자바스크립트 전역 객체를 설명하기 위한 여러 타입이 포함된 .d.ts 파일이라고 생각하면 됩니다. 왜냐하면 정확하게 그런 목적의 파일입니다. CMD(Windows의 경우 CTRL)를 누른 상태에서 document 객체를 클릭하면 직접 확인할 수 있습니다. 이제 미스터리가 풀렸습니다.

우리 애플리케이션은 당연히 최고의 것이기 때문에, 자동화된 테스트를 추가하지 않으면 안 되겠죠? 이 단계에서는 조금 더 편하게 설명하기 위해 Vitest라는 테스트 프레임워크를 설치하겠습니다. 다음으로 테스트를 작성해 보겠습니다.

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

이 테스트를 실행하려고 하면 타입스크립트가 오류를 발생시킵니다.

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

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

편리하게 테스트 프레임워크의 문서에서 제공하는 섹션을 따라가서 tsconfig.json을 수정하여 it을 전역으로 활성화하겠습니다.

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

compilerOptions.types를 사용함으로써 타입스크립트에게 추가적인 타입을 로드하도록 요청하며, 이 경우에는 vitest/globals에서 전역 it 함수를 선언하는 타입을 로드하도록 합니다. 컴파일러는 우리의 노력을 인식하고 테스트를 통과시켜 주어, 우리 자신과 이 엄격한 타입 언어 시련에 대해 좋은 느낌이 들게 합니다.

하지만, 아직도 조급해서는 안됩니다.

이슈

잠깐 옆길로 샐 것이지만, 결국 모든 것이 이해될 것이라고 약속드립니다.

여기서 한 가지를 질문하겠습니다. 타입스크립트에서 존재하지 않는 코드를 참조하면 어떻게 되나요? 네, 물결 모양의 빨간색 선과 Cannot find name라는 타입 오류가 발생합니다. 조금 전에 테스트에서 it()을 호출하려고 할 때 이 오류를 보았습니다.

app.ts 모듈로 돌아가서, test라는 존재하지 않는 전역 변수에 대한 참조를 추가해 보겠습니다.

// src/app.ts
// ...앱 내의 코드

test;

test를 정의하지 않았습니다. 이것은 브라우저 전역 변수도 아니며, 확실히 타입스크립트 기본 라이브러리 중 어디에도 존재하지 않습니다. 이것은 실수이고, 버그이므로 에러가 발생해야 합니다.

그러나 실제로는 에러가 발생하지 않았습니다. 빨간색 물결선이 코드 아래에 나타나지 않는다면, 안도감을 느끼는 대신 혼란스러워집니다. 설상가상으로 타입스크립트는 여기서 오류를 발생시키지 않을 뿐만 아니라, 실제로 도움이 되려고 노력하며 타입 test를 제안하고, 호출 시그니처를 보여주며, 어떤 TestApi 네임스페이스에서 가져온 것이라고 말합니다. 하지만 그것은 Vitest에서 가져온 타입인데, 어떻게 이럴 수 있을까요?

이 코드가 컴파일될까요? 물론입니다. 그럼, 브라우저에서 작동할까요? 아닙니다. 당연하게도 예외없이 에러를 던질 것입니다. 왜 그럴까요? 타입스크립트를 사용하는 주요 목적은 이러한 실수를 방지하는 것 아니었나요?

여기서 test는 제가 유령적인 정의(ghostly definition)라고 부르는 것입니다. 존재하지 않는 것을 설명하는 유효한 타입 정의입니다. 또 다른 타입스크립트의 속임수라고 할 수 있습니다. 성급하게 도구를 탓하지 마세요. 여기서 무슨 일이 벌어지고 있는지에 대해 알려드리겠습니다.

(하나 이상의) 모든 것을 다루는 구성 파일

app.test.ts 모듈을 src 디렉터리에서 새로 만든 test 디렉터리로 이동하세요. 그리고 해당 파일을 열어보세요. 혹시 또 it 타입 오류가 발생했나요? tsconfig.jsonvitest/globals를 추가함으로써 이미 수정하지 않았었나요?

문제는 타입스크립트가 test 디렉터리에 대해 어떻게 처리해야 하는지 모른다는 것입니다. 사실, tsconfig.json에서 가리키는 것은 src뿐이므로, 타입스크립트는 test 디렉터리의 존재조차 모릅니다.

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

앞서 언급했듯이 타입스크립트 구성이 작동하는 방식은 (적어도 저에게는) 완전히 명확하지 않습니다. 오랜 시간 동안 저는 include 옵션이 컴파일에 포함할 모듈을 의미하고, exclude 옵션은 제외할 모듈을 의미한다고 생각했었습니다. 이 문제에 대해 타입스크립트 문서를 확인하면 다음과 같은 설명이 있습니다.

include는 프로그램에 포함할 파일명 또는 패턴의 배열을 지정합니다.

제가 이해한 include가 하는 일은 문서에 명시된 것과 약간 다르며 더 구체적입니다.

include 옵션은 이 타입스크립트 구성을 적용할 모듈을 제어합니다

올바르게 읽으셨군요. 만약 타입스크립트 모듈이 include 옵션에 나열된 디렉터리 외부에 있는 경우, 해당 tsconfig.json해당 모듈에 전혀 영향을 미치지 않습니다. 마찬가지로, exclude 옵션을 사용하면 현재 구성의 영향을 받지 않아야 하는 파일 패턴을 필터링할 수 있습니다.

그럼 우리가 includetest를 추가하면, 어떤 문제가 발생할까요?

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

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

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

사실, 그래야 합니다. 다만 모든 디렉터리가 아니라, 코드가 실행되도록 의도된 각 환경마다 만들어야 합니다.

런타임 및 우려 사항

최신 웹 애플리케이션의 이면에는 다양한 모듈이 조화롭게 어우러져 있습니다. 앱의 즉각적인 소스는 컴파일, 최소화되며, 코드 분할되고, 번들링되며 사용자에게 제공되도록 의도되어 있습니다. 그런 다음에는 컴파일되거나 누구에게도 전송되지 않는 테스트 파일도 있는데, 이 역시 타입스크립트 모듈이며 컴파일되거나 누구에게도 제공되지 않습니다. 또한 스토리북 스토리, Playwright 테스트, 무언가를 자동화하기 위한 사용자 정의 *.ts 스크립트 한두 개도 있을 수 있습니다. 이 모든 것은 도움이 되며, 모두 서로 다른 의도를 가지고 있고 서로 다른 환경에서 실행되게 되어 있습니다.

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

그리고 tsconfig.json을 통해 그 의도를 전달합니다. 하지만 루트 수준만이 아닙니다. 타입스크립트는 중첩된 구성을 매우 효과적으로 처리할 수 있습니다. 그렇게 하도록 설계되었기 때문입니다. 여러분이 의도를 명확하게 전달하기만 하면 됩니다.

이를 위해 프로젝트 전체에 tsconfig.json 파일을 전략적으로 배치하여 의도를 전달합니다. 다음은 예시입니다.

# 일반적인 옵션을 나열하는 루트 수준 구성입니다.
# 다른 모든 구성은 여기에서 확장됩니다.
# "noEmit": true, "declarations": false를 설정하려고 합니다,
# 그리고 "skipLibCheck": true를 설정하려고 합니다.
- tsconfig.json

# 실제 애플리케이션 빌드를 위한 루트 수준 구성입니다.
# 모듈과 타입 선언을 방출하려는 곳입니다.
- tsconfig.build.json

- /e2e
  # 브라우저 런타임에서 실행되거나 브라우저 런타임을 설명하기 위한
  # 엔드투엔드 테스트 파일에 대한 구성입니다.
  # 여기에는 "lib" 라이브러리와
  # "dom.iterable"과 같은 추가 타입이 필요할 수 있습니다.
  - tsconfig.json
  - Login.test.ts

- /src
  # 이번에는 소스 코드에 대한 또 다른 구성입니다.
  # 이것은 기본 구성으로 건너뛸 수 있습니다.
  # 이 소스를 커버하지만, 특정 상황에서 소스에
  # 추가 타입이 필요할 수 있습니다.
  - App.tsx
  - util.ts

- /test
  # 테스트 파일에 대한 타입스크립트 구성입니다.
  # 우리는 "vitest/globals", Node.js globals 및
  # 기타 테스트 관련 타입을 추가합니다.
  - tsconfig.json
  - App.test.tsx

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

예를 들어, src 파일에 대한 타입스크립트 구성은 다음과 같습니다.

// src/tsconfig.json
{
  // 루트 수준 구성을 확장하여 공통 옵션을 재사용하세요.
  "extends": "./tsconfig.json",
  "compilerOptions": {
    // 브라우저에서 실행되는 코드로 컴파일합니다.
    "target": "es2015",
    "module": "esnext",
    // JSX 지원, 여기서는 React를 실행 중입니다.
    "jsx": "react"
  },
  // 이 구성은 소스 파일에만 적용합니다.
  "include": ["src"],
  "exclude": ["node_modules"]
}

반대로 test 디렉터리에 있는 통합 테스트를 위한 구성은 다음과 같습니다.

// test/tsconfig.json
{
  "extends": "../tsconfig.json",
  "compilerOptions": {
    // 여기서는 트랜스파일링하지 않고, 원본를 유지합니다.
    "target": "esnext",
    "module": "esnext",
    // 통합 테스트는 Node.js에서 실행됩니다.
    // 테스트 러너의 전역에도 추가해 봅시다.
    "types": ["@types/node", "vitest/globals"]
  },
  // 여기서는 테스트 파일에만 관심이 있습니다.
  "include": ["**/*.test.ts"]
}

타입스크립트 구성을 작성할 때, 이 점을 기억하세요.

프로젝트의 레이어 수만큼 소스 코드, Node.js 테스트, 인브라우저 테스트, 써드파티 툴링 등 다양한 타입스크립트 구성이 있어야 합니다.

타입스크립트는 타입 검사하는 모듈의 가장 가까운 tsconfig.json을 자동으로 선택하므로 필요한 경우 확장하여 일정 부분에서 다르게 설정할 수 있게 해 줍니다.

실용적인 측면

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

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


🚀 한국어로 된 프런트엔드 아티클을 빠르게 받아보고 싶다면 Korean FE Article(https://kofearticle.substack.com/)을 구독해주세요!


Written by@[Ykss]
고이게 두지 않고 흘려보내는 개발자가 되자.

GitHubInstagramLinkedIn