any와의 최종 결별 선언: .d.ts 타입 선언 파일 직접 만들기
1. 요즘 세상에 .d.ts를 직접 만드는 이유
"요즘 잘나가는 라이브러리는 타입 지원이 다 잘 되는데, 굳이 .d.ts 파일을 직접 만들 줄 알아야 할까요?"
나도 가졌던 의문이다. 아니, 내가 가졌던 의문이라고 해야 정확할 것이다. 그리고 그 말은 상당 부분 사실이기도 하다. 실제로 잘 관리되는 대부분의 라이브러리는 자체적으로 타입 선언 파일을 포함하거나 @types 패키지를 통해 완벽한 타입 지원을 제공한다.
하지만 우리의 개발 여정이 항상 그렇게 이상적인 코드 위에서만 이루어지는 것은 아니다. 실무에서는 여전히 .d.ts 파일이 타입스크립트 개발자의 강력한 '비장의 무기'가 되는 결정적인 순간들이 있다. 바로 이런 경우들.
- 오래되었지만 대체 불가능한 라이브러리: 수년째 업데이트가 없지만 우리 서비스의 핵심 기능을 담당하는 차트 라이브러리나 하드웨어 연동 스크립트가 있다면 어떨까? 이런 코드에는 타입 지원이 없는 경우가 대부분일 것이다.
- 회사 내부의 자바스크립트 자산: 수많은 회사에는 타입스크립트 시대 이전에 만들어진, 잘 동작하는 자체 유틸리티 함수나 UI 컴포넌트 라이브러리가 있다. 이 소중한 자산에 타입이라는 새 생명을 불어넣어 재활용하려면 .d.ts 파일 작성이 필수이다.
- 기존 타입 확장 및 수정 (Module Augmentation): 때로는 기성복을 내 몸에 맞게 수선하듯, 라이브러리가 제공하는 기존 타입을 내 프로젝트에 맞게 확장해야 한다. express 라이브러리의 Request 객체에 user 정보를 추가하거나, cy.log() 같은 테스트 라이브러리 명령어를 추가하는 것이 대표적인 예이다.
- 전역 스크립트 및 변수 인식: 카카오 지도 API처럼 script 태그로 불러온 외부 스크립트는 window.kakao 같은 전역 변수를 만든다. 타입스크립트는 이 '갑자기 나타난' 전역 변수를 모르기 때문에, 우리가 직접 .d.ts 파일을 통해 "window 객체에는 kakao라는 것도 있어" 라고 알려줘야 한다.
결론적으로 .d.ts 작성 능력은 단순히 '타입 없는 라이브러리를 위한 고육지책'이 아니라, 타입스크립트와 자바스크립트의 경계를 자유자재로 제어하는 고급 기술이라고 할 수 있다. 이 기술을 손에 넣는다는 것은, 어떤 상황에서도 any와 타협하지 않고 타입 안정성을 지켜낼 수 있는 자신감을 얻는 것과 같습니다.
즉, .d.ts 파일 작성은 자바스크립트 환경을 타입스크립트의 통제 아래 두는 강력한 방법이다. any와 타협할 필요가 없어지는 것이다. 이제 그 통제의 시작인 declare 키워드에 대해 알아보자.
2. .d.ts 파일의 기본: declare 키워드의 역할
타입스크립트 프로젝트를 진행하다 보면, 이런 에러를 마주할 때가 있다.
"Cannot find name 'myGlobalVar'."
분명히 이 변수는 존재하는데, 왜 타입스크립트는 찾을 수 없다고 할까?
문제의 원인: 타입스크립트의 세계 vs 런타임의 세계
좀 더 쉽게 비유해서, 이 문제를 이해하려면 두 개의 다른 '세계'가 있다고 상상해야 한다.
- 타입스크립트의 세계 (컴파일 타임): 타입스크립트는 우리가 작성한 .ts 파일만 보고 타입을 검사한다. 이 세계에는 HTML 파일에 script 태그로 어떤 자바스크립트가 추가될지, 브라우저에 어떤 내장 기능이 있는지에 대한 정보가 전혀 없다.
- 자바스크립트의 세계 (런타임): 브라우저는 HTML 파일을 읽어 script 태그로 로드된 자바스크립트 파일을 실행한다. 이 세계에서는 myGlobalVar 같은 전역 변수가 실제로 존재하고 동작한다.
에러는 타입스크립트의 세계에 myGlobalVar의 정보가 없어서 발생한다. 타입스크립트는 "내가 모르는 건 any 처리하거나, 그냥 에러로 간주할게!"라고 말하는 것과 같다.
해결책 declare: 두 세계를 연결하는 다리
declare 키워드는 바로 이 두 세계를 연결하는 다리 역할을 한다. .d.ts 파일 안에서 declare를 사용하면, 타입스크립트에게 이렇게 말해줄 수 있다.
"타입스크립트, 네 세계에는 없어 보이지만, 런타임 세계에는 myGlobalVar라는 게 분명히 존재해. 내가 장담할게. 그리고 그건 이런 모양이야."
이 '설명서' 또는 '약속'을 통해 타입스크립트는 자신이 모르던 존재를 알게 되고, 더 이상 에러를 발생시키지 않는다.
예를 들어, HTML에 아래와 같은 스크립트가 로드되어 있다고 가정해보자.
<script src="some-library.js"></scrip> <script src="app.js"></cript>
우리는 app.ts에서 myGlobalVar를 사용하고 싶다. 이때 .d.ts 파일에 아래와 같이 한 줄을 추가한다.
// file: global.d.ts
// 'myGlobalVar'라는 상수가 전역 스코프에 존재하며,
// 그 타입은 { version: string } 이라고 선언(declare)한다.
declare const myGlobalVar: {
version: string;
};
이제 app.ts에서 myGlobalVar를 사용하면, 타입스크립트는 더 이상 에러를 내보내지 않는다. 심지어 myGlobalVar.version 이라고 입력하면 IDE에서 자동완성까지 해줄것이다! declare를 통해 우리가 알려준 모양 그대로 타입을 추론하기 때문이다.
이것이 declare의 핵심 역할이다. 런타임에는 존재하지만 타입스크립트가 인지하지 못하는 코드의 형태를 '선언'하여, 타입 시스템의 감시 아래에서 안전하게 사용할 수 있도록 만들어주는 것.
declare의 기본 개념을 알았으니, 이제 실제 프로젝트에서 마주할 법한 첫 번째 실전 예제를 다뤄보려고 한다.
3. 실전 예제 1: 간단한 JavaScript 유틸리티 함수 타입 정의하기
가장 흔한 시나리오 중 하나인, 프로젝트 내부에 있는 간단한 자바스크립트 유틸리티 파일을 타입스크립트에서 안전하게 사용하는 방법이다.
아래와 같이 날짜를 포맷팅하는 간단한 자바스크립트 유틸리티 함수가 프로젝트 내부에 있다고 가정하자.
// file: /utils/formatDate.js
/**
* Date 객체를 'YYYY-MM-DD' 형식의 문자열로 변환합니다.
* @param {Date} date
* @returns {string}
*/
function formatDate(date) {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
}
module.exports = { formatDate };
이제 이 함수를 타입스크립트 파일 app.ts에서 사용하려고 한다.
// file: app.ts
// tsconfig.json에서 "allowJs": true 옵션이 필요하다.
import { formatDate } from './utils/formatDate.js';
const today = new Date();
// 에러: Could not find a declaration file for module './utils/formatDate.js'.
// 'formatDate' implicitly has an 'any' type.
const formattedDate = formatDate(today);
console.log(formattedDate);
타입스크립트는 formatDate.js 파일이 존재한다는 것은 알지만, 그 안의 formatDate 함수가 어떤 매개변수를 받고 어떤 값을 반환하는지에 대한 타입 정보가 전혀 없기 때문에 오류를 발생시킨다.
해결책: .d.ts 파일 생성하기
이 문제를 해결하기 위해, formatDate.js 파일 바로 옆에 똑같은 이름의 formatDate.d.ts 파일을 만들어 주자. 타입스크립트는 이 파일을 발견하고 formatDate.js의 타입 설명서로 자동 인식한다.
파일 구조:
/project
├── utils/
│ ├── formatDate.js
│ └── formatDate.d.ts <-- 이 파일을 추가!
└── app.ts
이제 새로 만든 formatDate.d.ts 파일에 formatDate 함수의 타입을 '선언'해준다. 모듈의 타입을 선언할 때는, 그 모듈이 export하는 것의 타입을 그대로 작성해주면 된다.
// file: /utils/formatDate.d.ts
// 이 모듈은 'formatDate'라는 함수를 export 하고,
// 그 함수의 시그니처는 Date 타입을 받아 string 타입을 반환한다고 선언한다.
export function formatDate(date: Date): string;
참고: 모듈 내부의 타입을 선언할 때는, 전역 변수와 달리 declare 키워드를 생략하는 경우가 많다. 모듈 자체가 선언의 맥락이기 때문이다.
결과 확인
.d.ts 파일을 추가하는 즉시, app.ts에서 발생하던 오류가 사라진다.
// file: app.ts
import { formatDate } from './utils/formatDate.js';
const today = new Date();
// 이제 IDE에서 formatDate 함수에 마우스를 올려보면
// (date: Date) => string 타입으로 완벽하게 추론된다.
const formattedDate = formatDate(today);
// 물론, 잘못된 타입을 넣으면 바로 에러를 잡아줄 것이다.
// Argument of type 'string' is not assignable to parameter of type 'Date'.
// const wrongDate = formatDate("2024-04-28");
console.log(formattedDate);
이것으로 우리는 단 한 줄의 타입 선언을 통해, 외부 자바스크립트 모듈을 any 없이 타입스크립트 프로젝트에 통합했다.
좋습니다. 모듈 형태의 JS 파일에 타입을 입혔으니, 이번에는 더 까다로운 상대인 전역(global) 라이브러리를 다뤄보겠습니다.
4. 실전 예제 2: 전역 변수 및 라이브러리 타입 정의하기
많은 외부 서비스(지도, 결제, 분석 등)는 여전히 script 태그로 직접 로드하여 사용하는 방식을 쓴다. 이때 생성되는 전역 변수나 객체를 타입스크립트에서 어떻게 인식시킬 수 있는지 알아보자.
이번에는 카카오 지도 API를 웹페이지에 추가하는 상황을 가정해 보려고 한다. index.html 파일은 아래와 같다.
<!DOCTYPE html>
<html>
<head>
<title>Map Example</title>
</head>
<body>
<div id="map" style="width:500px;height:400px;"></div>
<script type="text/javascript" src="//dapi.kakao.com/v2/maps/sdk.js?appkey=YOUR_APP_KEY"></script>
<script src="app.js"></script>
</body>
</html>
위 sdk.js 파일이 로드되면, 브라우저의 전역 공간(window)에 kakao라는 객체가 생성된다. 이제 app.ts에서 지도를 생성하는 코드를 작성해 보자.
// file: app.ts
const container = document.getElementById('map');
const options = {
center: new kakao.maps.LatLng(33.450701, 126.570667), // 에러: Cannot find name 'kakao'.
level: 3
};
const map = new kakao.maps.Map(container, options); // 에러: Cannot find name 'kakao'.
예상대로 타입스크립트는 kakao의 존재를 전혀 알지 못해 에러를 발생시킨다.
해결책: 전역 네임스페이스 선언하기
이 문제를 해결하기 위해, 타입스크립트에게 kakao라는 전역 객체(네임스페이스)와 그 하위 구조에 대해 설명해주는 .d.ts 파일을 작성해야 한다. 파일 이름은 kakao.d.ts 또는 global.d.ts 등 자유롭게 만들 수 있다.
지금은 사용할 maps.LatLng과 maps.Map 클래스의 형태만 최소한으로 선언해 보려고 한다.
// file: types/kakao.d.ts
// (tsconfig.json의 "include" 배열에 "types" 폴더가 포함되어야 한다.)
declare namespace kakao.maps {
// LatLng 클래스의 '모양'을 선언한다.
// 생성자(constructor)와 공개된 메서드(public method)의 타입을 정의한다.
export class LatLng {
constructor(lat: number, lng: number);
getLat(): number;
getLng(): number;
}
// Map을 생성할 때 필요한 옵션 객체의 타입을 인터페이스로 정의한다.
export interface MapOptions {
center: LatLng;
level: number;
}
// Map 클래스의 '모양'을 선언한다.
export class Map {
constructor(container: HTMLElement | null, options: MapOptions);
}
}
declare namespace는 kakao라는 전역 객체 아래에 maps라는 속성이 있고, 그 안에 또 다른 클래스와 인터페이스가 있는 중첩된 구조를 설명하기에 아주 적합한 방법이다.
결과 확인
이제 app.ts로 돌아가면 모든 에러가 사라지고, 타입스크립트의 모든 지원을 받을 수 있게 된다.
// file: app.ts
// 이제 'kakao'는 타입스크립트가 인식할 수 있는 존재가 되었다.
const container = document.getElementById('map');
const options = {
// kakao.maps.LatLng의 생성자 타입에 따라
// number가 아닌 값을 넣으면 에러가 발생합니다.
center: new kakao.maps.LatLng(33.450701, 126.570667),
level: 3
};
const map = new kakao.maps.Map(container, options);
이처럼 .d.ts 파일을 이용하면, 타입스크립트 생태계에 포함되지 않은 외부 자바스크립트 라이브러리나 서비스도 타입 시스템을 사용할 수 있게 된다.
5. 결론
.d.ts 파일을 직접 작성하는 법을 알아보았다. .d.ts 작성은 단순히 오래된 코드를 위한 기술이 아니라, 자바스크립트와 타입스크립트의 경계를 이해하고 제어하는 핵심 역량임을 깨달았다. 타입 시스템에 수동적으로 의존하는 것을 넘어, 타입을 직접 설계하고 더 견고한 코드를 만들어나가는 개발자가 되기 위해 노력해야할 것이다.