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의 moduleResolution을 node에서 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.json의 exports 필드를 먼저 봅니다. 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로 작성되어서 타입이 내장이고, exports에 types 조건도 포함. 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 단언이 사라진 게 제일 마음에 들었습니다. psl의 parse는 반환 타입이 좀 모호해서 항상 단언을 붙여야 했는데, 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/* 의존 패키지에서 이렇게 깨지는 케이스가 있다는 걸 미리 알았으면 좋았을 것 같습니다. 결과적으로는 타입 내장 패키지로 갈아타는 계기가 됐고, 장기적으로 더 안전한 선택이었다고 생각합니다.