TypeScript의 대안, JSDoc

sejin kim·2024년 4월 28일
2
post-thumbnail

들어가며

이번 글에서는 JavaScript 개발에 있어 TypeScript의 대안으로 활용할 수 있는 JSDoc에 관해 이야기해 보고자 합니다.

JSDoc이란 JavaScript 코드에 주석을 추가해 HTML 형식의 API 문서를 생성할 수 있는 마크업 언어입니다. 하지만 이러한 문서화 용도에 국한되지 않고, VSCodeIntelliJ 같은 코드 에디터/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

서두에서 언급했듯, 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 parameterdefault 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의 스펙은 아니고, TypeScriptTriple-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 */
}


유틸리티 타입

TypeScriptUtility TypeJSDoc에서도 사용할 수 있습니다. 단, 어디까지나 JSDoc의 스펙이 아니라 TypeScriptLSP에 의존하는 기능이기 때문에, 현재 사용하는 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 */


그 외 기타 태그

@example

예시를 제공하는 태그입니다. 해당 태그 뒤에 오는 내용은 하이라이트 처리됩니다.

/**
 * @param {number} a
 * @param {number} b
 * @returns {number}
 * @example
 * sum(1, 2) // 3
 */
const sum = (a, b) => a + b;

@description, @summary

@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의 경우 @this 태그를 사용하여 무엇을 참조하고 있는지 명시할 수 있습니다.

function Greeter(name) {
    setName.apply(this, name); 
}

/** @this Greeter */
function setName(name) {
    this.name = name; 
}

@namespace, @memberof

공통된 네임스페이스로 함수, 클래스, 타입 등을 그룹화할 때 사용합니다. @memberof 태그와 함께 활용하면 특정한 네임스페이스 하위에 속함을 지정하여 계층 관계를 명시할 수도 있습니다.

/** @namespace */
const Tools = {};

/** @memberof Tools */
const hammer = () => {
    // ...
};

Tools.hammer = hammer;

@ignore

해당 태그가 사용된 주석은 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 코드 어시스트를 활용하는 경우가 많다 보니 더 쉽고 빠르게 작성할 수 있어 실무적인 효용은 유의미한 수준이라고 할 수 있었습니다.






참고 문서

profile
퇴고를 좋아하는 주니어 웹 개발자입니다.

0개의 댓글

관련 채용 정보