4 min read

psl에서 tldts로 갈아탄 이유

제가 담당하는 프로젝트에는 웹 SDK를 설치할 도메인을 등록하는 폼이 있습니다. 사용자가 app.example.co.kr 같은 걸 입력하면 이게 유효한 도메인인지, TLD가 실제로 존재하는지, subdomain과 SLD가 제대로 분리되는지 프론트에서 검증합니다.

여기서 핵심은 co.kr이 TLD라는 걸 정확히 알아야 한다는 점입니다. 단순히 마지막 . 뒤를 TLD로 보면 kr만 잡히고, co.kr은 SLD처럼 취급돼버려요. co.uk, com.au, ne.jp 같은 다단계 TLD가 수백 개 있는데, 이걸 정규식이나 하드코딩으로 커버하는 건 불가능합니다. 그래서 Mozilla가 관리하는 Public Suffix List 기반 파서가 필요했고, psl 패키지를 쓰고 있었습니다.

잘 돌아가고 있었는데, tsconfig의 moduleResolutionnode에서 bundler로 바꾸면서 터졌습니다.

error TS7016: Could not find a declaration file for module 'psl'.

@types/psl 분명히 설치되어 있는데.

왜 갑자기 타입을 못 찾나

moduleResolution: "node"에서는 단순합니다. import { parse } from "psl" 하면 node_modules/psl/index.js를 찾고, 타입은 자동으로 node_modules/@types/psl/index.d.ts에서 매칭.

근데 bundler 모드는 package.jsonexports 필드를 먼저 봅니다. psl의 package.json을 열어보면:

{
  "main": "./index.js",
  "exports": {
    ".": "./index.js"
  }
}

exports가 존재하는 순간, TypeScript는 @types/* 자동 매칭을 안 합니다. exports 안에 types 조건이 명시되어 있어야 하는데, psl에는 없어요. 그래서 타입이 사라진 겁니다.

이게 psl만의 문제가 아니고, exports 필드가 있으면서 types 조건이 없는 패키지 + 별도 @types/*를 쓰는 모든 조합에서 발생할 수 있습니다.

시도한 것들

처음엔 tsconfig types 필드에 "psl" 넣어봤는데 효과 없었습니다. 이건 global ambient 선언용이지 module resolution이랑은 무관하더라고요.

그 다음 shims.d.ts에 직접 declare module "psl" 써서 타입을 수동으로 정의했습니다:

declare module "psl" {
  interface ParsedDomain {
    tld: string | null;
    sld: string | null;
    domain: string | null;
    subdomain: string | null;
  }
  function isValid(domain: string): boolean;
  function parse(domain: string): ParsedDomain;
}

동작하긴 하는데, @types/psl이 업데이트되면 따라갈 수 없고, 수동 유지보수가 필요해서 임시 방편이었습니다.

tldts로 교체

결국 패키지를 바꿨습니다. tldts는 TypeScript로 작성되어서 타입이 내장이고, exportstypes 조건도 포함. bundler moduleResolution과 완전 호환됩니다.

API가 약간 다른데, 매핑은 직관적이었어요:

// Before (psl)
import { parse, isValid } from "psl";
const urlInfo = parse(value) as ParsedDomain;
if (!urlInfo.sld) { /* ... */ }
if (!isValid(value)) { /* ... */ }

// After (tldts)
import { parse } from "tldts";
const urlInfo = parse(value);
if (!urlInfo.domainWithoutSuffix) { /* ... */ }
if (!urlInfo.isIcann) { /* ... */ }

as ParsedDomain 단언이 사라진 게 제일 마음에 들었습니다. pslparse는 반환 타입이 좀 모호해서 항상 단언을 붙여야 했는데, tldts는 반환 타입이 정확해요.

번들 사이즈도 psl(43kB)보다 tldts(38kB) 가 약간 작고, devDependency에서 @types/psl도 빠지니까 의존성도 줄었습니다.

이후로 확인하게 된 것

이후로 @types/*를 쓰는 패키지가 있으면 한 번 확인하게 됐습니다:

cat node_modules/<pkg>/package.json | jq '.exports'

exports가 있는데 types 조건이 없으면 bundler 모드에서 깨질 수 있어요. 선택지는:

  • 타입 내장 대체 패키지로 교체 (가장 깔끔)
  • declare module로 임시 대응
  • 해당 패키지에 PR 올리기 (exports에 types 조건 추가)

moduleResolution: "bundler"가 Vite, Rsbuild 같은 현대 번들러랑 맞추려면 올바른 방향인 건 맞는데, @types/* 의존 패키지에서 이렇게 깨지는 케이스가 있다는 걸 미리 알았으면 좋았을 것 같습니다. 결과적으로는 타입 내장 패키지로 갈아타는 계기가 됐고, 장기적으로 더 안전한 선택이었다고 생각합니다.

참고