이번 글에서는 JavaScript
개발에 있어 TypeScript
의 대안으로 활용할 수 있는 JSDoc
에 관해 이야기해 보고자 합니다.
JSDoc
이란 JavaScript
코드에 주석을 추가해 HTML 형식의 API 문서를 생성할 수 있는 마크업 언어입니다. 하지만 이러한 문서화 용도에 국한되지 않고, VSCode
나 IntelliJ
같은 코드 에디터/IDE와 결합하여(정확하게는 거기서 실행되는 tsserver
와 같은 Language Server Protocol(LSP)
과 결합하여), 마치 TypeScript
를 사용할 때와 유사한 수준의 타입 힌트, IntelliSense 기능 등을 사용할 수 있게 되므로, 일종의 TypeScript
의 대체 또는 보완적인 수단으로 활용되기도 합니다. TypeScript
의 컴파일러가 JSDoc
을 해석할 수 있고, 대부분의 스펙을 공식적으로 지원하고 있기 때문에 사용성이 우수합니다.
물론, 그럴 바엔 그냥 처음부터 TypeScript
를 쓰면 되지 않나 생각될 수도 있습니다. 그도 그럴 것이 이미 별다른 사유가 없지 않은 이상 TypeScript
는 거의 당연한 것이 되었거니와, 정 부담스럽다면 점진적으로 도입해 나가는 것도 가능하기 때문입니다.
하지만 그럼에도 불구하고, TypeScript
를 사용하기가 난감한 상황이 있을 수 있습니다. 대개 레거시 JavaScript
코드베이스에 도입하려고 할 때가 그런 경우인데, 변경이나 확장에 취약한 환경에서는 아무래도 부담으로 작용할 가능성이 있습니다.
가령 필자의 경우에는, 먼 과거(ES5
시절)에 개발된 바닐라 코드 기반의 레거시 프로젝트가 그러했습니다. 일단 JavaScript
코드 자체로만 놓고 봐도 퀄리티가 낮았고, 당장 번들러, 빌드 시스템 자체가 없는 환경인데 컴파일/트랜스파일 단계가 추가된다는 것도 난감하게 다가오는 측면이 있었습니다. 수많은 타입 에러에 고통받으면서도, 변경이 클 수록 그만큼 그 코드를 어쨌든 유지보수 해야 하는 다른 개발자들과의 합의를 이끌어내야 하는 등 비용적인 측면에서 일이 커질 수밖에 없다는 점이 발목을 잡았습니다.
JSDoc
은 이렇게 변경과 확장이 제한된 상황에서, 별도의 설정 없이 기존 코드에 단순히 주석을 추가하는 것으로 생산성과 개발자 경험을 보완할 수 있다는 점에서 꽤 유용하게 활용될 수 있습니다. 시스템적인 강제성은 없더라도, 일종의 타협적인 형태로 타입 시스템을 도입하는 도구로 활용하는 것이라고도 볼 수 있겠습니다.
여담으로, 작년 즈음
Ruby on Rails
의 개발자가Turbo
에서 TS를 퇴출했다며 자신의 블로그에 올렸던 글이나,Svelte
에서 리팩토링의 일환으로 타입 선언을.ts
파일에서JSDoc
주석을 단.js
파일로 옮겼다는 소식이 커뮤니티에서 꽤 화제가 되었던 적이 있습니다.
이것이 마치 '사실TypeScript
별로 안 좋은거 아니냐, 나중엔 버려야 되는 거 아니냐' 같은 얘기처럼 부풀려지면서 약간의 해프닝이 일어났던 것인데, 당연한 말이지만 이러한 결정을 한 엔지니어들의 생각과 배경을 파악하고 이해하여 기술적 지평을 넓히는 것이 중요하지, 무작정 휘둘리는 것은 현명한 행동이 아닐 것입니다.
서두에서 언급했듯, JSDoc
은 기존 JavaScript
코드에 주석을 추가하는 방식으로 사용합니다. 이때 주석은 일반적으로 대상이 되는 코드의 바로 앞에 배치되어야 하며, /** */
형태의 블록 주석으로 작성되어야 합니다. //
, /* */
같은 다른 형태의 주석은 허용되지 않으며 파서가 해석하지 못합니다.
주석의 내용에는 @
기호로 시작하는 고유의 '태그'를 사용하여 코드에 대한 상세한 설명을 추가합니다. 이때 태그는 '블록 태그'와 '인라인 태그'로 구분되는데, 블록 태그는 주석의 최상위 수준에서 사용되는 태그이며, 인라인 태그는 블록 태그 또는 설명 내에서 사용되는 태그입니다.
인라인 태그는 마치 HTML
의 앵커 태그와 같이 다른 문서와 연결하는 용도에 한정되어, 현재 스펙상 단 두 가지의 태그(@link
, @tutorial
)만 존재하며, 나머지는 모두 블록 태그로 구성됩니다.
이 중 주요하게 사용되는 태그의 종류와 사용례에 대해서는 아래에서 이어서 살펴보겠습니다.
TypeScript
에서는 type
또는 interface
키워드를 사용해 타입을 정의합니다.
type WindowStates = 'open' | 'closed' | 'minimized';
interface User {
name: string;
id: number;
}
JSDoc
에서는 @typedef
태그로 타입을 정의할 수 있습니다. 이렇게 정의한 타입은 아래에서 설명할 @type
이나 @param
태그에서 참조할 수 있습니다.
/** @typedef {'open' | 'closed' | 'minimized'} WindowStates */
/** @typedef {{ name: string, id: number }} User */
/** @param {WindowStates} states */
function setWindowStates(states) {
// ...
}
/** @type {User} */
const user = {
name: 'John',
id: 1
};
아래에 이어서 @property
태그를 함께 사용하면, 객체나 클래스에서 복합적인 하위 타입을 정의할 수 있습니다. @prop
태그도 동일한 역할을 합니다(동의어).
/**
* @typedef {object} Props
* @property {string} prop1
* @prop {number} prop2
*/
/** @type {Props} */
const props = {
prop1: 'foo',
prop2: 1
};
중첩된 프로퍼티라면 .
구분자를 사용하면 됩니다.
/**
* @typedef {object} User
* @property {object} name
* @property {string} name.first
* @property {string} name.last
*/
/** @type {User} */
const user = {
name: {
first: 'John',
last: 'Doe'
}
};
@type
태그는 string
, number
와 같은 원시 타입이나 @typedef
태그로 정의한 타입, 또는 TypeScript
에서 정의된 타입을 대상으로 참조할 수 있습니다. 또한 대부분의 JSDoc
구문과 TypeScript
구문은 물론, Google의 Closure Compiler
구문까지 여러 형태의 타입 표현식이 올 수 있습니다.
/** @type {number[]} */
const numbers = [1, 2, 3];
/** @type {(a: number, b: number) => number} */
const sum = (a, b) => a + b;
/** @type {HTMLElement} */
const element = document.querySelector(selector);
@param(@arg, @argument)
태그는 함수의 파라미터의 이름과 타입, 설명을 정의하는 데 사용되는 태그입니다. 타입은 중괄호로 감싸며, 그 뒤에는 파라미터 이름이 와야 합니다. 설명은 여기에 앞뒤 공백을 포함한 하이픈을 삽입하여 추가할 수 있습니다. 기본적으로 @type
과 동일한 구문을 사용할 수 있습니다.
/**
* @param {number} a - number a
* @param {number} b - number b
* @returns {number} sum of a and b
*/
function sum(a, b) {
return a + b;
}
리턴 타입은 위 예시와 같이 @returns
태그 또는 @return
태그를 사용하며, 제너레이터 함수라면(@generator
) 아래처럼 @yields
또는 @yield
태그를 대신 사용합니다.
단, @yields
태그의 경우에는 TypeScript
공식 문서에 따르면 지원되지 않는 태그로 분류되고 있습니다. JSDoc support for @yields
/**
* @generator
* @yields {number}
*/
function* fibonacci() {
// ...
}
optional parameter
와 default value
를 나타내려는 경우, TypeScript
에서는 아래와 같은 식으로 작성했다면,
function foo(a: number, b?: boolean) {
// ...
}
function bar(a: string, b = false) {
// ...
}
JSDoc
에서는 아래와 같이 이름을 대괄호로 감싸거나 타입 뒤에 =
을 붙이면 됩니다.
/**
* @param {number} a - param a
* @param {boolean} [b] - param b (optional)
* @param {string=} c - param c (optional)
*/
function foo(a, b, c) {
// ...
}
/**
* @param {string} a - param a
* @param {boolean} [b=false] - param b (with default value)
*/
function bar(a, b) {
// ...
}
Rest parameter
는 {...number}
와 같은 형태로 작성할 수 있습니다.
/** @param {...string} names */
function baz(...names) {
// ...
}
그밖에도 {?number}
와 같은 형태로 Nullable Type
을, {!number}
로는 Non-nullable Type
을 나타낼 수 있습니다.
그러나 이는 JSDoc
의 스펙으로, 만약 TypeScript
환경이라면 Nullable Type
의 경우 JSDoc
의 타입 시스템과의 차이로 인해 strictNullChecks
설정이 활성화된 경우에만 의도대로 동작하며, Non-nullable Type
은 그냥 무시되어 원래의 타입으로 처리됩니다.
/**
* @type {?number}
* if strictNullChecks true -- number | null
* if strictNullChecks false -- number
*/
let nullable;
/**
* @type {!number}
* Just number
*/
let num;
TypeScript
에서 타입 변환(캐스팅)은 아래와 같이 expression as T
또는 <T>expression
의 형태로 이루어집니다.
const numberOrString: number | string = Math.random() > 0.5 ? 1 : '1';
const assertedNumber = numberOrString as number;
정확한 용어는 '타입 단언(
type assertion
)'이겠으나, 현재 글의 맥락에서는JSDoc
에 초점을 맞추고 있으므로 관련 문서들에서의 용례를 따라 '타입 변환(type casting
)'으로 지칭하겠습니다.
JSDoc
에서는 표현식 앞에 @type
태그를 사용한 주석을 추가하는 식으로 이루어지는데, 이때 표현식은 반드시 괄호로 감싸야 합니다.
/** @type {number | string} */
const numberOrString = Math.random() > 0.5 ? 1 : '1';
const assertedNumber = /** @type {number} */ (numberOrString);
타입을 좁히고(narrowing
), 객체나 배열에 readonly
속성을 부여하는 등 유용하게 활용되는 const assertion
의 경우, JSDoc
에서도 const
타입으로 변환하는 방식으로 접근할 수 있습니다.
const hex = {
white: '#FFFFFF',
black: '#252525'
} as const;
괄호를 생략하면 파서가 정상적으로 해석하지 못하므로 유의합니다.
const hex = /** @type {const} */ ({
white: '#FFFFFF',
black: '#252525'
});
타입을 함수의 파라미터처럼 사용하는 제네릭 역시, TypeScript
의 기능이지만 JSDoc
에서도 @template
태그를 통해 유사한 형태로 사용할 수 있습니다. 예를 들어 TypeScript
에서 아래와 같이 제네릭 함수를 사용한다면,
function identity<T>(arg: T): T {
return arg;
}
identity<string>('Generic');
JSDoc
에서는 아래와 같이 할 수 있습니다.
/**
* @template T
* @param {T} arg
* @returns {T}
*/
function identity(arg) {
return arg;
}
identity(/** @type {string} */ ('Generic'));
만약 여기서 extends
로 제약 조건을 표현하려고 한다면, 마찬가지로 TypeScript
와 거의 같은 방식으로 접근할 수 있습니다.
interface Constraints {
length: number;
}
function identity<T extends Constraints>(arg: T): T {
console.log(arg.length);
return arg;
}
identity(1); // Argument of type 'number' is not assignable to parameter of type 'Constraints'.
identity('Generic'); // OK
identity({ length: 1, value: 'Generic' }); // OK
이때 @template
태그에는 @typedef
로 정의한 타입을 참조할 수 있으며, 상황에 따라서는 @typedef
로 분리하지 않고 @template
에 직접 타입 표현식을 사용하는 것도 가능합니다.
/** @typedef {{ length: number }} Constraints */
/**
* @template {Constraints} T
* @param {T} arg
* @returns {T}
*/
function identity(arg) {
console.log(arg.length);
return arg;
}
identity(1); // Argument of type 'number' is not assignable to parameter of type 'Constraints'.
identity(/** @type {string} */ ('Generic')); // OK
identity({ length: 1, value: 'Generic' }); // OK
아래처럼 @template
태그에도 default value
를 추가할 수 있는데, 이는 TypeScript
4.5에서 추가된 스펙입니다. 이런 식으로 TypeScript
진영에서는 꾸준히 JSDoc
사용자들을 위한 지원을 지속하고 있기도 합니다.
/**
* @template {string} [T="hello"]
* @typedef {T} Foo
*/
일반적으로 TypeScript
에서는 아래와 같은 방식으로 다른 모듈의 타입을 가져올 수 있습니다.
import type { Foo } from './module';
let foo: Foo;
let bar: import('./module').Bar;
JSDoc
에서는 import('path')
구문으로 가져올 수 있는데, TypeScript
와는 달리 다른 방식은 없고 아래의 같은 방식으로만 가능합니다.
/** @type {import('./module').Foo} */
let foo;
/** @typedef {import('./module').Bar} Bar */
/** @type {Bar} */
let bar;
이때 모듈이 아닌 일반 글로벌 스크립트라면 서로 액세스할 수 없으니 타입을 가져올 수 없지만, 아래와 같은 방법을 사용하는 경우에 한해 가져올 수 있게 됩니다. 다만 역시 JSDoc
의 스펙은 아니고, TypeScript
의 Triple-Slash Directive
를 통해 컴파일러에 파일을 직접 포함시키는 방식입니다. 이런 식으로 TypeScript
의 .d.ts
파일에서 타입 정의를 가져오는 것도 가능합니다.
// foo.js
/** @typedef {string} Foo */
// bar.js
/// <reference path="./foo.js" />
/** @type {Foo} */
let foo;
한편 JSDoc
자체에는 명시적으로 타입을 내보낼 수 있는 스펙이 구현되어 있지는 않습니다. 다만 모듈의 최상위 스코프에서 @typedef
태그로 정의된 타입에 한해 기본적(암시적)으로 내보내지는데, 이렇게 내보낸 타입은 다른 모듈에서 가져와 사용할 수 있습니다.
// 다른 모듈에서 가져올 수 있음
/** @typedef {string} Foo */
function foo() {
// 다른 모듈에서 가져올 수 없음
/** @typedef {number} Bar */
}
TypeScript
의 Utility Type
을 JSDoc
에서도 사용할 수 있습니다. 단, 어디까지나 JSDoc
의 스펙이 아니라 TypeScript
의 LSP
에 의존하는 기능이기 때문에, 현재 사용하는 IDE 및 환경에 따라 동작하지 않을 수 있다는 점에 유의합니다.
interface Todo {
title: string;
description: string;
completed: boolean;
}
type PartialTodo = Partial<Todo>;
type ReadonlyTodo = Readonly<Todo>;
type PriorityType = Record<'normal' | 'urgent', boolean>;
type TodoPreview = Pick<Todo, 'title' | 'completed'>;
type TodoWithoutDescription = Omit<Todo, 'description'>;
/** @typedef {{ title: string; description: string; completed: boolean }} Todo */
/** @typedef {Partial<Todo>} PartialTodo */
/** @typedef {Readonly<Todo>} ReadonlyTodo */
/** @typedef {Record<'normal' | 'urgent', boolean>} PriorityLevels */
/** @typedef {Pick<Todo, 'title' | 'completed'>} TodoPreview */
/** @typedef {Omit<Todo, 'description'>} TodoWithoutDescription */
예시를 제공하는 태그입니다. 해당 태그 뒤에 오는 내용은 하이라이트 처리됩니다.
/**
* @param {number} a
* @param {number} b
* @returns {number}
* @example
* sum(1, 2) // 3
*/
const sum = (a, b) => a + b;
@description
태그의 경우 상세한 설명을 제공하는 데 사용됩니다. HTML 마크업 및 Markdown 포맷도 허용되며, 만약 주석 시작 부분에 별도의 태그가 없는 일반 설명이 있다면 @description
태그로 추가된 설명이 우선 적용됩니다. @summary
태그는 간략한 설명을 추가할 때 사용합니다.
/**
* @param {number} a
* @param {number} b
* @returns {number}
* @description
* # Usage
* add **two** numbers.
* ``js
* sum(1, 2) // 3
* ``
*/
const sum = (a, b) => a + b;
혼동되기 쉬운 this
의 경우 @this
태그를 사용하여 무엇을 참조하고 있는지 명시할 수 있습니다.
function Greeter(name) {
setName.apply(this, name);
}
/** @this Greeter */
function setName(name) {
this.name = name;
}
공통된 네임스페이스로 함수, 클래스, 타입 등을 그룹화할 때 사용합니다. @memberof
태그와 함께 활용하면 특정한 네임스페이스 하위에 속함을 지정하여 계층 관계를 명시할 수도 있습니다.
/** @namespace */
const Tools = {};
/** @memberof Tools */
const hammer = () => {
// ...
};
Tools.hammer = hammer;
해당 태그가 사용된 주석은 JSDoc
에서 무시됩니다. 다른 모든 태그에 우선하여 적용되며, @namespace
태그를 사용한 곳에서는 모든 하위 관계의 네임스페이스들 각각에도 모두 추가되어야 합니다.
/**
* @namespace
* @ignore
*/
const Clothes = {
/**
* @class
* @ignore
*/
Jacket: function() {
this.color = null;
}
}
여러 태그를 사용해 타입을 정의하고 IDE와 연계하여 나름대로 타입 시스템을 활용할 수 있게는 되었지만, 힌트 및 자동완성 같은 IntelliSense 정도만 동작하지 기본적으로 타입 에러가 발생하지는 않는다는 것을 알 수 있습니다.
만약 TypeScript
에서처럼 엄격하게 타입 에러가 발생할 수 있도록 하려면 결국 추가 설정이 필요한데, 당장 간단하게는 스크립트 최상단에 @ts-check
주석을 추가하면 됩니다.
// @ts-check
/** @type {string} */
const str = 1; // Type 'number' is not assignable to type 'string'.
하지만 이런 방식은 스크립트 파일이 다수 존재할 경우 번거로워지므로, 설정 파일(jsconfig.json
)을 프로젝트 루트에 추가하여 컴파일러의 동작을 변경하는 편이 편리합니다. 만약 checkJs
외에도 다른 옵션을 추가하고자 한다면, TypeScript
프로젝트를 세팅할 때와 비슷하게 tsconfig
을 참고하면서 설정해 주면 됩니다.
{
"compilerOptions": {
"checkJs": true,
}
}
모든 내용을 다룬 것은 아니지만, 일반적인 상황에서 JSDoc
을 사용하는 사례를 살펴보았습니다.
개인적으로 어디까지나 JSDoc
은 그 특유의 낮은 코스트에 초점을 두고, 레거시 코드베이스와 같이 제한된 환경에서 단순한 목적으로 좁게 접근하여 활용하는 편이 적절하다고 보았습니다.
그동안 그다지 많이 활용해 본 것은 아니지만, 아무래도 주석이 추가되면서 조금 더 코드가 복잡하고 장황해지는듯한 느낌은 있었습니다. 하지만 막상 주석을 작성하는 것 자체는 생각보다 많은 공수가 들지 않기도 하고, 특히 최근에는 Copilot
, Codewhisperer
같은 AI 코드 어시스트를 활용하는 경우가 많다 보니 더 쉽고 빠르게 작성할 수 있어 실무적인 효용은 유의미한 수준이라고 할 수 있었습니다.