Study
12장. 타입스크립트 프로젝트 관리
타입스크립트 프로젝트에서 유용하게 활용할 수 있는 개념과 팁 알아보기
<details>
<summary>목차</summary>
<ul markdown="1">
<li>
<a href="#121-앰비언트-타입-활용하기">12.1 앰비언트 타입 활용하기</a>
</li>
<li>
<a href="#122-스크립트와-설정-파일-활용하기"
>12.2 스크립트와 설정 파일 활용하기</a
>
</li>
<li>
<a href="#123-타입스크립트-마이그레이션"
>12.3 타입스크립트 마이그레이션</a
>
</li>
<li><a href="#124-모노레포">12.4 모노레포</a></li>
</ul>
</details>
<br />
12.1 앰비언트 타입 활용하기
(1) 앰비언트 타입 선언
타입스크립트 타입 선언은 .ts .tsx 확장자를 가진 파일에서 할 수도 있지만 .d.ts 확장자를 가진 파일에서도 선언할 수 있다.
앰비언트 타입 선언
.d.ts 확장자를 가진 파일에서는 타입 선언만 할 수 있고 값은 표현할 수 없다. 값을 포함하는 일반적인 선언과 구별하기 위해 .d.ts 파일에서 하는 타입 선언을 앰비언트(ambient) 타입 선언이라고 부른다.
앰비언트 타입 선언으로는 값을 직접 정의할 수 없기 때문에 대신 declare
키워드를 사용해 어딘가에 값이 존재한다는 사실을 알린다. 단순히 존재 여부만 알려주기 때문에 컴파일 대상은 아니다.
대표적인 앰비언트 타입 선언 활용 사례
타입스크립트는 기본적으로 .ts와 .js파일만 이해하기 때문에 다른 파일 형식은 인식하지 못해 모듈로 가져오려하면 에러가 발생한다. 이런 경우에 declare
키워드를 사용하여 특정 형식을 모듈로 선언할 수 있다.
declare module "*.png" {
const src: string;
export default src;
}
👉 declare 키워드는 이미 존재하지만 타입스크립트가 알지 못하는 부분을 컴파일러에 '이러한 것이 존재해'라고 알려주는 역할을 한다.
자바스크립트로 작성된 라이브러리
자바스크립트로 작성된 npm 라이브러리는 타입 선언이 존재하지 않기 때문에 any로 추론될 것이다. 때문에 만일 tsconfig.json에서 any를 사용하지 못하게 설정했다면 프로젝트가 빌드되지 않을 것이다.
이런 경우에 자바스크립트 라이브러리 내부 함수와 변수의 타입을 앰비언트 타입으로 선언하면 타입스크립트는 자동으로 .d.ts 파일을 검색하여 타입 검사를 진행하게 되므로 문제없이 컴파일 된다. VSCode와 같은 에디터도 .d.ts 파일을 해석해 코드를 작성할 때 유용한 타입 힌트를 제공한다.
예시로 @types/react를 설치하면 node_modules/@types/react에 index.d.ts와 global.d.ts가 설치된다. 이러한 파일에는 리액트의 컴포넌트와 훅에 대한 타입이 정의되어 있다.
👉 앰비언트 타입 선언은 타입스크립트에게 '자바스크립트 코드 안에는 이러한 정보들이 있어'라고 알려주는 도구이다.
자바스크립트 어딘가에 전역 변수가 정의되어 있음을 타입스크립트에 알릴 때
타입스크립트로 직접 구현하지 않았지만 실제 자바스크립트 어딘가에 전역 변수가 정의되어 있는 상황을 타입스크립트에 알릴 때 앰비언트 타입 선언을 사용한다.
예를 들어 웹뷰를 개발할 때 네이티브 앱과의 통신을 위한 인터페이스를 네이티브 앱이 Window 객체에 추가하는 경우가 많다. 이러한 전역 객체인 Window에 변수나 함수를 추가하면 타입스크립트에서 직접 구현하지 않았더라도 실제 런타임 환경에서 해당 변수를 사용할 수 있다.
declare global {
interface Window {
devideId: string | undefined;
appVersion: string;
}
}
// 전역 객체인 Window의 타입을 지정해 해당 속성이 정의되어 있음을 알림
(2) 앰비언트 타입 선언 시 주의점
타입스크립트로 만드는 라이브러리에는 불필요
tsconfig.json의 declaration을 true로 설정하면 타입스크립트 컴파일러가 .d.ts 파일을 자동으로 생성해주기 때문에 수동으로 작성할 필요가 없다. 따라서 타입스크립트로 라이브러리를 개발할 때는 앰비언트 타입 선언을 사용할 필요가 없다.
전역으로 타입을 정의하여 사용할 때 주의해야 할 점
- 서로 다른 라이브러리에서 동일한 이름의 앰비언트 타입 선언을 하지 않도록 주의한다.
- 충돌이 발생하면 어떤 타입 선언이 적용될지 알기 어렵다.
- 충돌이 발생하면 의도한 대로 동작하지 않을 수 있다.
- 나중에 앰비언트 타입 선언을 변경할 때 어려움을 겪을 수 있다.
- 명시적인 import나 export가 없기 때문에 코드의 의존성 관계가 명확하지 않기 때문이다.
(3) 앰비언트 타입 선언을 잘못 사용했을 때의 문제점
앰비언트 변수는 .ts 파일 내부에서는 잘 선언하지 않는다. 왜냐하면 앰비언트 타입은 명시적인 import나 export 없이도 코드 전역에서 사용할 수 있는데 의존성 관계는 보이지 않아, 변경에 의한 영향 범위를 파악하기 어렵기 때문이다.
.d.ts 파일이 아닌 .ts .tsx 파일 내에서 앰비언트 타입 선언을 하기 위해서 declare
키워드를 사용한다.
// src/index.tsx : 최상위 파일
import React from "react";
import ReactDOM from "react-dom";
import App from "App";
// Window 전역 객체의 확장 표시
delcare global {
interface Window {
Example: string;
}
}
const SomeComponent = () => {
return <div>앰비언트 타입 선언은 .tsx 파일에서도 가능</div>;
};
위처럼 선언된 앰비언트 타입은 아래와 같이 다른 곳에서 import 없이 사용할 수 있다.
// src/test.tsx
window.Example; // 타입 에러 없음
이렇듯 앰비언트 변수 선언은 어느 곳에서나 영향을 줄 수 있기 때문에 일반 타입 선언과 섞이게 되면 앰비언트 선언이 어떤 파일에 포함되어 있는지 파악하기 어려워진다는 문제를 갖는다. 위는 그나마 src/index.tsx라는 최상위 파일에서 앰비언트 변수 선언을 했기 때문에 파악하기 쉬운 예시다. 만일 작은 컴포넌트에서 앰비언트 변수 선언이 이루어졌었다면 찾기 어려워진다.
일종의 개발자 간 약속으로 앰비언트 타입 선언은 .d.ts 파일 내에서 하도록 한다. 타입 선언 위치가 명확해야 가독성이 높아지고 유지보수도 편하게 할 수 있다.
(4) 앰비언트 타입 활용하기
타입스크립트 컴파일러에 타입 정보를 알려주는 declare
키워드를 더 효과적으로 활용할 수 있는 방법을 살펴보자.
타입을 정의하여 임포트 없이 전역으로 공유
.d.ts 파일에서의 앰비언트 타입 선언은 전역 변수와 같은 역할을 한다. 따라서 앰비언트 타입을 선언하면 모든 코드 내에서 임포트하지 않고 사용할 수 있다.
예를 들어 유용한 유틸리티 타입을 작성했다면, 앰비언트 타입으로 유틸리티 타입을 선언해 모든 코드에서 해당 타입을 사용하도록 할 수 있다. 마치 내장 타입 유틸리티 함수를 사용하는 것과 같은 모양새다.
// src/index.d.ts
type Optional<T extends object, K extends keyof T = keyof T> = Omit<T, K> &
Partial<Pick<T, K>>;
// src/components.ts
type Props = { name: string; age: number; visible: boolean };
type OptionalProps = Optional<Props>; // Expect: { name?: string; age?: number; visible?: boolean };
declare type 활용하기
보편적으로 많이 사용하는 커스텀 유틸리티 타입을 declare type으로 선언하여 사용할 수 있다. 아래 예시처럼 Nullable 타입을 선언해서 어디에서든 쉽게 사용할 수 있다.
declare type Nullable<T> = T | null;
const name: Nullable<string> = "woowa";
declare module 활용하기
CSS-in-JS 라이브러리 중 하나인 styled-components는 기존의 폰트 크기, 색상 등을 객체로 관리한다. 이렇게 정의된 theme의 인터페이스 타입을 확장하여 theme 타입이 자동으로 완성되도록 하는 기능을 지원하고 있다.
const fontSizes = {
xl: "30px",
/* ... */
};
const theme = {
fontSizes,
/* ... */
};
declare module "styled-components" {
type Theme = typeof theme; // 정의된 theme에서 스타일 값을 가져옴
export interface DefaultTheme extends Theme {} // 기존 인터페이스 타입과 통합하여 theme 타입을 자동 완성
}
이외에도 로컬 이미지나 SVG같이 외부로 노출되어 있지 않은 파일을 모듈로 인식하여 사용할 수 있게끔 만들 수 있다.
declare module "*.gif" {
const src: string;
export default src;
}
declare namespace 활용하기
Node.js 환경에서 .env 파일을 사용할 때, declare namespace
를 활용하여 process.env로 설정값을 손쉽게 불러오고 환경변수의 자동 완성 기능을 쓸 수 있다.
// .env
API_URL = "localhost:8000";
declare namespace NodeJS {
interface ProcessEnv {
readonly API_URL: string;
}
}
console.log(process.env.API_URL);
// namespace를 활용하여 process.env 타입을 보강해주지 않은 경우는 as 단언 사용
// console.log(process.env.API_URL as string);
declare global 활용하기
declare global
키워드는 전역 변수를 선언할 때 사용한다.
declare global {
interface Window {
newProperty: string;
}
}
예를 들어 위처럼 전역 변수인 Window 객체에 newProperty 속성을 추가한 것과 같은 동작 구현이 가능하다. 대표적으로 네이티브 앱과의 통신을 위한 인터페이스를 Window 객체에 추가할 때 앰비언트 타입 선언을 활용할 수 있다.
아래는 iOS 웹뷰에서 자바스크립트로 네이티브 함수를 호출하기 위한 함수를 정의한 것으로, 함수를 호출할 때 자동 완성 기능을 활용해 실수를 줄일 수 있다.
declare global {
interface Window {
webkit?: {
messageHandlers?: Record<
string,
{ postMessage?: (parameter: string) => void }
>;
};
}
}
// window.webkit?. 까지 입력 시 messageHandler? 가 자동 완성됨
(5) declare와 번들러의 시너지
const color = {
white: "#ffffff",
black: "#000000",
} as const;
type ColorSet = typeof color;
declare global {
const _color: ColorSet;
}
위와 같이 전역에 _color
라는 변수가 존재함을 타입스크립트 컴파일러에 알리면 해당 객체를 활용할 수 있다.
const white = _color["white"];
하지만 타입스크립트 에러는 발생하지 않지만, 아직 ColorSet
타입을 가지고 있는 _color
객체의 실제 데이터가 존재하지 않아 코드 실행 시 기대하는 동작과 다르게 동작할 수 있다.
이를 해결하기 위한 방법 중 하나가 번들 시점에 번들러릍 통해서 해당 데이터를 주입하는 것이다. declare global
로 전역 변수를 선언하는 과정과 번들러를 통해 데이터를 주입하는 절차를 함께 활용하면 시너지를 낼 수 있다.
아래는 롤업(rollup) 번들러의 inject 모듈로 데이터를 주입하는 예시이다. inject 모듈은 import문의 경로를 분석하여 데이터를 가져오는 역할을 한다.
프로젝트 레포 구조
┣ 📂node_modules
┣ 📜data.ts --------------- 색상 정의
┣ 📜index.ts
┣ 📜package-lock.json
┣ 📜package.json
┣ 📜rollup.config.js
┗ 📜type.ts --------------- 타입 정의
// data.ts : 색상 정의
export const color = {
white: "#ffffff",
black: "#000000",
} as const;
// type.ts : 타입 정의
improt { color } from "./data";
type ColorSet = typeof color;
declare global { // declare global로 전역 변수 _color가 존재함을 알림
const _color: ColorSet;
}
// index.ts
console.log(_color["white"]);
// rollup.config.js
import inject from "@rollup/plugin-inject";
import typescript from "@rollup/plugin-typescript";
export default [
{
input: "index.ts",
output: [
{
dir: "lib",
format: "esm",
},
],
plugins: [typescript(), inject({ _color: ["./data", "color"] })],
// inject 모듈을 사용하여 _color에 해당되는 데이터를 삽입
},
];
위 설정을 통해 번들러를 통해 만들어진 결과물을 실행하면 #ffffff
가 정상적으로 출력된다.
12.2 스크립트와 설정 파일 활용하기
(1) 스크립트 활용하기
실시간으로 타입 검사하기
기본적으로 에디터가 타입 에러를 감지해주지만 컴퓨터 성능이 떨어지거나 프로젝트 규모가 커지면 이 속도가 느려진다. 에디터에서는 에러가 없다고 확인했지만 husky를 통해 타입 에러를 발견하기도 한다. 이런 경우에 아래 스크립트를 사용해 실시간 에러를 확인할 수 있다.
$ yarn tsc --noEmit --incremental -w
# noEmit: 자바스크립트로 된 출력 파일을 생성하지 않음
# incremental: 증분 컴파일(변경 사항이 있는 부분만 컴파일)을 활성화하여 컴파일 시간을 단축시킴
# w: 변경사항 모니터링
해당 스크립트를 통해 파일이 변경될 때마다 tsc를 실행. 어디에서 타입 에러가 발생했는지를 실시간 추적할 수 있다.
+) npm에서 사용하는 방법 (ChatGPT (opens in a new tab))
package.json에서 명령어로 스크립트를 추가한 뒤 사용한다.
// package.json
{
"scripts": {
"type-check": "tsc --noEmit --incremental -w"
}
}
$ npm run type-check
타입 커버리지 확인하기
프로젝트의 모든 부분이 타입스크립트 통제하에 돌아가고 있는지를 정량적으로 판단하기 위해 아래 스크립트를 사용할 수 있다.
$ npx type-coverage --detail
확인 결과로 타입으로 지정되어있는 변수의 퍼센트를 보여주는데 남은 퍼센트는 any로 선언되어 있다는 의미이다. (ex. 87% => 13%는 any)
타입스크립트로 마이그레이션 중인 프로젝트나 레거시 코드가 많은 프로젝트를 다룰 때 더 나은 코드로 리팩토링을 위한 지표로 사용할 수 있다.
(2) 설정 파일 활용하기
타입스크립트 컴파일 속도 높이기
tsconfig의 incremental 속성을 true로 설정하면 증분 컴파일(변경 사항이 있는 부분만 컴파일) 이 활성화된다. 이로써 모든 대상을 컴파일하지 않아도 되므로 컴파일 타임을 줄일 수 있다.
// tsconfig.json
{
"compilerOptions": {
"incremental": true
}
}
tsconfig 설정 또는 스크립트 사용 가능하다.
$ yarn tsc --noEmit --incremental -diagnostics
# noEmit: 자바스크립트로 된 출력 파일을 생성하지 않음
# incremental: 증분 컴파일 활성화하여 컴파일 시간을 단축시킴
# diagnostics: 디버깅을 위한 진단 정보 출력
(3) 에디터 활용하기
에디터에서 종종 정의된 타입이 있는 객체인데도 import되지 않거나 자동 완성 기능이 동작하지 않는데, 이 경우 타입스크립트 서버를 재실행하면 된다.
- VSCode: 명령어 팔레트 열고
Restart TS server
- VSCode 명령어 팔레트 열기
- Mac) Command + Shift + P
- Window) Ctrl + Shift + P
- VSCode 명령어 팔레트 열기
- WebStorm: TypeScript tool window에서
Restart TypeScript Service
- TypeScript tool window 위치 : 에디터 오른쪽 하단 바 TypeScript 메뉴
12.3 타입스크립트 마이그레이션
(1) 타입스크립트 마이그레이션의 필요성
타입스크립트를 새로운 기술 스택으로 도입하는 것을 결정하면 아래 두 작업 중 선택이 필요하다.
- 기존 프로젝트를 타입스크립트로 마이그레이션
- 타입스크립트 프로젝트로 새로 구축
프로젝트 규모와 특성 및 내외부 여건을 종합적으로 고려하며 어떤 방식이 나을지 신중하게 따져봐야한다.
(2) 점진적인 마이그레이션
일반적으로 작은 부분부터 시작하여 점차 범위를 넓혀가며 마이그레이션을 진행하게 된다. 이 점진적인 시작이 가능하다는 점이 진입 장벽을 낮추고 프로젝트의 전반적인 동작을 안정적으로 유지할 수 있게 한다.
(3) 마이그레이션 진행하기
1️⃣ 타입스크립트 개발 환경 설정 + 빌드 파이프라인에 타입스크립트 컴파일러를 통합함
2️⃣ tsconfig.json 파일 설정
// tsconfig.json
{
"compilerOptions": {
"allowJS": true,
// 타입스크립트에서 자바스크립트 함수 import 허용
// 자바스크립트에서 타입스크립트 함수 import 허용
"noImplicityAny": false
// 암시적 any 타입이 있을 때 오류가 발생하지 않도록 함
}
}
3️⃣ 타입스크립트로 변환해 감. 필요한 타입과 인터페이스를 하나씩 정의하며 함수 시그니처를 추가해 나감
4️⃣ tsconfig.json 파일 설정 : 2의 설정 해제. 타입이 명시되지 않은 부분이 없는지 점검
12.4 모노레포
여러 프로젝트를 관리하는 경우지만, 각 프로젝트의 공통된 요소를 찾아낼 수 있다면 통합 레포지토리를 사용해 조금 더 효율적으로 관리할 수 있다. 이런 통합된 레포지토리를 모노레포라고 칭한다.
(1) 분산된 구조의 문제점
일반적으로는 개별 프로젝트마다 별도의 레포지토리를 생성해 관리한다. 이는 Jest, Babel, 등의 설정 파일도 별도, 빌드 파이프라인도 별도, 모든 내용들이 독립적으로 관리된다는 것.
이러한 상황에서 서로 다른 프로젝트가 공통으로 필요한 기능이 있으면 복사/붙여넣기를 활용하는데, 이런 경우 해당 기능 유지 보수 시 반복 작업이 필요하다. 이는 개발자 경험(DX) 저하를 불러올 수 있다.
이런 분산된 구조가 야기하는 문제에서 벗어나기 위해 반복되는 코드를 함수화하여 통합하듯이 한 곳에서 프로젝트를 관리할 수 있도록 통합해야 한다.
(2) 통합할 수 있는 요소 찾기
먼저 프로젝트 내에서 공통으로 통합할 수 있는 요소를 찾아야 한다.
A 프로젝트 레포
📂utils
┣ 📜clipboard.ts
┣ 📜permission.ts
┗ 📜validation.ts
B 프로젝트 레포
📂utils
┣ 📜clipboard.ts
┣ 📜storage.ts
┗ 📜validation.ts
위의 경우 clipboard, validation 파일을 통합할 수 있는 파일로 우선 보고, 각 파일의 소스코드가 같지 않다면 통합을 위해 일부 수정해야한다.
(3) 공통 모듈화로 관리하기
소스코드 수정 후 모듈화를 통해 통합할 수 있다.
npm과 같은 패키지 관리자를 활용하여 공통 모듈을 생성하고 관리한다면 각 프로젝트에서 간편하게 모듈고 의존성을 맺고 사용할 수 있게 된다. 새로운 프로젝트를 시작하더라도 모듈을 통해 코드를 재사용할 수 있고 유지보수도 쉬워진다.
공통 모듈화를 통해 단순 코드 복사 작업은 줄지만 공통 모듈에 변경이 발생한다면 해당 모듈을 사용하는 프로젝트에서도 추가 작업이 필요할 수 있는 등의 아쉬운 점은 아직 있다.
(4) 모노레포의 탄생
모노레포(Monorepo) 란 버전 관리 시스템에서 여러 프로젝트를 하나의 레포지토리로 통합하여 관리하는 소프트웨어 개발 전략이다.
모노레포를 사용하면 개발 환경 설정도 통합할 수 있어 더 효율적인 관리가 가능해진다.
다른 소프트웨어 개발 전략
- 모놀리식(Monolithic) : 다양한 기능을 가진 프로젝트를 하나의 레포지토리로 관리하는 구조. 이전에 인기있던 기법이나 코드 간의 직접적인 의존이 발생하는 문제가 있어 효율적이지 못했음
- 폴리레포(Polyrepo) : 거대한 프로젝트를 작은 프로젝트의 집합으로 나누어 관리하는 구조
모노레포의 장점
- Lint, CI/CD 등 개발 환경 설정도 통합하여 관리하기 때문에 불필요한 코드 중복을 줄여준다.
- 별도의 패키지 관리를 통해 모듈을 게시하지 않아도 된다.
- 기능 변화를 쉽게 추적하고 의존성을 관리할 수 있게 된다.
모노레포의 단점
- 시간이 지나면서 레포지토리가 거대해질 수 있다.
- 하나의 레포지토리에 여러 팀의 이해관계가 얽혀있다면 소유권과 권한 관리가 복잡해질 수 있다.
👉 각 프로젝트나 모듈의 소유권을 명확히 정의하고 규칙을 설정하는 과정이 별도로 필요하다.