diff --git a/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index c3f502a1..00000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,8 +0,0 @@ -# 디폴트 무시된 파일 -/shelf/ -/workspace.xml -# 에디터 기반 HTTP 클라이언트 요청 -/httpRequests/ -# Datasource local storage ignored files -/dataSources/ -/dataSources.local.xml diff --git a/.idea/aws.xml b/.idea/aws.xml deleted file mode 100644 index 99de319b..00000000 --- a/.idea/aws.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml deleted file mode 100644 index 17478f36..00000000 --- a/.idea/dataSources.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - postgresql - true - org.postgresql.Driver - jdbc:postgresql://aws-0-ap-northeast-2.pooler.supabase.com:5432/postgres - - - - - - $ProjectFileDir$ - - - \ No newline at end of file diff --git a/.idea/fe-lab.iml b/.idea/fe-lab.iml deleted file mode 100644 index d6ebd480..00000000 --- a/.idea/fe-lab.iml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/.idea/git_toolbox_blame.xml b/.idea/git_toolbox_blame.xml deleted file mode 100644 index 7dc12496..00000000 --- a/.idea/git_toolbox_blame.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/git_toolbox_prj.xml b/.idea/git_toolbox_prj.xml deleted file mode 100644 index 02b915b8..00000000 --- a/.idea/git_toolbox_prj.xml +++ /dev/null @@ -1,15 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index 54ab0139..00000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/jsLibraryMappings.xml b/.idea/jsLibraryMappings.xml deleted file mode 100644 index d23208fb..00000000 --- a/.idea/jsLibraryMappings.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/misc.xml b/.idea/misc.xml deleted file mode 100644 index 8253e27a..00000000 --- a/.idea/misc.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 71a656c3..00000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/prettier.xml b/.idea/prettier.xml deleted file mode 100644 index eb37fa7a..00000000 --- a/.idea/prettier.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - - - diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 4c6280eb..00000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.tool-versions b/.tool-versions index 33a30f1d..3decac0d 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,2 @@ pnpm 11.6.0 -nodejs 24.6.0 +nodejs 24.17.0 diff --git a/apps/blog/posts/THUMBNAIL_LOG.md b/apps/blog/posts/THUMBNAIL_LOG.md index c14b6342..53a55261 100644 --- a/apps/blog/posts/THUMBNAIL_LOG.md +++ b/apps/blog/posts/THUMBNAIL_LOG.md @@ -47,6 +47,13 @@ - **프롬프트**: > A minimalist, flat-design blog cover image for 'JavaScript Bundler Concepts'. Visualizing the evolution of code modules from chaos to order. Simple geometric shapes representing code blocks connecting together. Soft blue, purple, and white color palette. Clean lines, abstract representation of module linking and magic string transformation. Professional, tech-oriented. 16:9 aspect ratio. +### 6. TypeScript 6 마이그레이션 (설정 이야기) + +- **대상**: `typescript/typescript-6-migration-thumb.png` (글: TypeScript 6 업그레이드인 줄 알았는데, 문제는 "설정"이었습니다) +- **생성 방식**: AI 이미지가 아니라 **satori + resvg 코드 렌더**(블로그 OG 스택과 동일). 코드 글꼴 Monaco(monospace) + 본문 Pretendard. +- **디자인**: soft 블루–라벤더 그라데이션 위에 flat한 `tsconfig.json` 코드 카드. diff로 글의 3대 변경을 그대로 표현 — `− "baseUrl": "."`(제거, 취소선/red), `+ "rootDir": "./src"`·`+ "types": ["node"]`(추가, green). 상단 TS 워드마크, 하단 핵심 문구 “업그레이드가 아니라, ‘올바른 설정’”. +- **비고**: 이 글은 `/og` 자동 카드 대신 커스텀 썸네일을 사용한다 — `thumbnail`이 `/og/`로 시작하지 않으면 `generate-og-images.ts`가 생성을 skip한다. + --- ## 🚀 사용 방법 diff --git a/apps/blog/posts/typescript/typescript-6-migration-thumb.png b/apps/blog/posts/typescript/typescript-6-migration-thumb.png new file mode 100644 index 00000000..2385b578 Binary files /dev/null and b/apps/blog/posts/typescript/typescript-6-migration-thumb.png differ diff --git "a/apps/blog/posts/typescript/typescript-6-\354\227\205\352\267\270\353\240\210\354\235\264\353\223\234-\354\202\275\354\247\210\352\270\260.md" "b/apps/blog/posts/typescript/typescript-6-\354\227\205\352\267\270\353\240\210\354\235\264\353\223\234-\354\202\275\354\247\210\352\270\260.md" index 7e848885..888d3768 100644 --- "a/apps/blog/posts/typescript/typescript-6-\354\227\205\352\267\270\353\240\210\354\235\264\353\223\234-\354\202\275\354\247\210\352\270\260.md" +++ "b/apps/blog/posts/typescript/typescript-6-\354\227\205\352\267\270\353\240\210\354\235\264\353\223\234-\354\202\275\354\247\210\352\270\260.md" @@ -1,28 +1,37 @@ --- -title: 'TypeScript 6 업그레이드: breaking change 3종을 PR diff까지 추적하며 격파한 기록' -date: '2026-06-16' -status: 'draft' +title: 'TypeScript 6 업그레이드인 줄 알았는데, 문제는 "설정"이었습니다' +date: '2026-07-02' +status: 'scheduled' +scheduledDate: '2026-07-02T09:00:00+09:00' slug: 'typescript-6-migration-troubleshooting' -excerpt: '공부 삼아 모노레포의 TypeScript를 5.8.3에서 6.0.3으로 올렸더니, baseUrl deprecation·types 기본값 변경·rootDir 요구라는 breaking change 3종이 차례로 빌드를 무너뜨렸다. 각각이 "왜" 그렇게 바뀌었는지 공식 릴리스 노트와 이슈를 넘어 실제 microsoft/TypeScript PR diff까지 파고들어 하나씩 격파한 기록. 보너스로, 빌드에서 마주친 진짜 빌런 — 유지보수가 멈춘 tsup을 tsdown으로 갈아치운 이야기까지.' -tags: ['TypeScript', 'tsdown', 'monorepo', 'troubleshooting', 'breaking-change'] +excerpt: 'TypeScript 6으로 올리자 baseUrl·rootDir·types 기본값이 차례로 빌드를 깨뜨렸다. 각 변경의 "왜"를 microsoft/TypeScript PR diff까지 추적해 보니, 이건 버전을 올리는 이야기가 아니라 "올바른 설정"에 도달하는 이야기였다 — baseUrl은 TS7로 가는 청소, types는 순수 성능 개선. 게다가 이 셋은 6을 안 올려도 5에서 오늘 당장 적용할 수 있다.' +thumbnail: 'typescript-6-migration-thumb.png' +tags: [ 'TypeScript', 'TypeScript 6', 'typescript-7', 'tsgo', 'baseUrl', 'rootDir', 'tsdown', 'tsup', 'monorepo', 'breaking-change' ] --- -이 글은 작업 중 남긴 임시(draft) 기록을 다듬는 중입니다. 환경은 2026-06 기준 TypeScript 6.0.3 / pnpm 11.6.0 / tsdown 0.22.2 / tsup 8.5.1이며, 버전에 따라 동작이 달라질 수 있습니다. 인용한 이슈·PR 번호는 모두 microsoft/TypeScript 기준입니다. +TypeScript 6 릴리스 노트의 breaking change 목록을 여는데, 매일 tsconfig에 적던 이름들이 줄줄이 눈에 들어왔다 — `baseUrl`, `types`, `rootDir`. 멀쩡히 쓰던 옵션들을 6은 왜 굳이 건드리는 걸까? -## 0. TL;DR +그 `왜`가 궁금해서, 릴리스 노트만 훑고 넘기는 대신 마침 굴리던 개인 모노레포에 6을 먼저 올려 하나씩 따라 바꿔봤다. + +따라가 보니 이건 단순한 '6의 새 규칙들'이 아니었다. 세 변경을 관통하는 줄기는 하나 — 비싸거나 모호한 기본 동작을 걷어내는 '청소'. 다만 그 청소가 향하는 곳은 셋이 제각각이었다. -> "catalog에 박힌 TypeScript 5.8.3을 6 최신으로 올려줘." — 공부 삼아 한 줄로 시작했지만, TypeScript 6는 메이저 버전답게 곳곳에서 빌드를 무너뜨렸다. +그래서 '6 업그레이드 기록'으로 시작한 이 글은, 따라가다 보니 정체가 달라졌다. 손에 남은 건 버전 숫자가 아니라 _'그래서 무엇이 올바른 설정인가'_ 라는 질문이었다. -이 글은 Turborepo + pnpm 워크스페이스 모노레포에서 TypeScript `5.8.3 → 6.0.3` 업그레이드를 하며 만난 breaking change들을, **"왜 그렇게 바뀌었는지"를 릴리스 노트 → 이슈 → 실제 PR diff까지 추적하면서** 하나씩 격파한 기록이다. 단순 해결 로그가 아니라 "그 변경의 근거"를 파는 게 목적이다. +## 0. TL;DR -| # | 증상 | 왜 TS6가 이렇게 바꿨나 (근거) | 해결 | +이 글은 Turborepo + pnpm 워크스페이스 모노레포에서 TypeScript `5.8.3 → 6.0.3`으로 올리며 만난 breaking change들을, **"왜 그렇게 +바뀌었는지"를 릴리스 노트 → 이슈 → 실제 PR diff까지 따라가며** 정리한 기록이다. 아래 표가 그 전부다 — 그 변경의 이유, 해결책 한 줄씩. 바쁘면 표만 +보고 가도 된다. + +| # | 에러 | 왜 TS6가 이렇게 바꿨나 | 해결 | |:-------|:----------------------------------|:--------------------------------------------------------------------------------------------------------------------------------------------------------------|:---------------------------------------------------------| | #1 | `TS5101: 'baseUrl' is deprecated` | baseUrl의 숨은 2번째 역할(bare specifier look-up root)이 런타임에 안 맞는 import를 통과시킴 → deprecate ([#62509](https://github.com/microsoft/TypeScript/pull/62509)) | 직접 쓴 곳(react 앱)의 `baseUrl` 제거 (paths는 4.1부터 baseUrl 불필요) | -| #2 | "빌드는 되는데 타입이 빠진 것 같은데?" | `types` 기본값이 "모든 `@types` 자동 포함" → `[]`. flattened `node_modules`에서 수백 개가 전이로 끌려와 빌드 20–50% 낭비 ([#63054](https://github.com/microsoft/TypeScript/pull/63054)) | 전역이 필요한 곳은 이미 `types` 명시돼 있었음 | -| #3 | `TS5011: 'rootDir' must be set` | 추론된 `rootDir`는 입력 파일 집합에 따라 흔들려 출력 레이아웃이 비결정적 → tsconfig 디렉터리로 고정 ([#62418](https://github.com/microsoft/TypeScript/pull/62418)) | `"rootDir": "./src"` 한 줄 | -| 🎁 보너스 | 빌드 때 **안 쓴** baseUrl로 또 `TS5101` | tsup이 dts 빌드에 `baseUrl \|\| '.'`를 **주입**하는데, tsup은 이미 유지보수 중단 | **tsup → tsdown 전환** | +| #2 | `TS5011: 'rootDir' must be set` | 추론된 `rootDir`는 입력 파일 집합에 따라 흔들려 출력 레이아웃이 비결정적 → tsconfig 디렉터리로 고정 ([#62418](https://github.com/microsoft/TypeScript/pull/62418)) | `"rootDir": "./src"` 한 줄 | +| #3 | "빌드는 되는데 타입이 빠진 것 같은데?" | `types` 기본값이 "모든 `@types` 자동 포함" → `[]`. flattened `node_modules`에서 수백 개가 전이로 끌려와 빌드 20–50% 낭비 ([#63054](https://github.com/microsoft/TypeScript/pull/63054)) | 전역이 필요한 곳은 이미 `types` 명시돼 있었음 | +| 보너스 | 빌드 때 **안 쓴** baseUrl로 또 `TS5101` | tsup이 dts 빌드에 `baseUrl \|\| '.'`를 **주입**하는데, tsup은 이미 유지보수 중단 | **tsup → tsdown 전환** | -먼저 한 가지 정정부터. 위 표의 `TS5101`은 baseUrl **전용 에러가 아니다.** TypeScript의 **범용 "deprecated option" 진단**이고, 6.0이 baseUrl을 그 경로에 태웠을 뿐이다. 이 디테일이 왜 중요한지는 #1에서 PR diff로 확인한다. +먼저 한 가지 정정부터. 위 표의 `TS5101`은 baseUrl **전용 에러가 아니다.** TypeScript의 **범용 "deprecated option" 진단**이고, +6.0이 baseUrl을 그 경로에 태웠을 뿐이다. 이 디테일이 왜 중요한지는 #1에서 PR diff로 확인한다. --- @@ -32,7 +41,7 @@ tags: ['TypeScript', 'tsdown', 'monorepo', 'troubleshooting', 'breaking-change'] `baseUrl`이 deprecated된다는 건 이미 알고 있었다. 내 코드에서 `baseUrl`을 실제로 쓰는 곳은 React 앱 하나뿐이었고, `paths`의 접두사 용도였다. -```jsonc +```json // apps/react/tsconfig.app.json { "compilerOptions": { @@ -47,33 +56,49 @@ tags: ['TypeScript', 'tsdown', 'monorepo', 'troubleshooting', 'breaking-change'] } ``` -그냥 지웠다. `paths`는 [TypeScript 4.1부터 `baseUrl` 없이 동작](https://www.typescriptlang.org/tsconfig/baseUrl.html)한다. +그냥 지웠다. `paths`는 [TypeScript 4.1부터`baseUrl` 없이 동작](https://www.typescriptlang.org/tsconfig/baseUrl.html)한다. > "As of TypeScript 4.1, `baseUrl` is no longer required to be set when using `paths`." -여기서 한 가지 흔한 오해를 짚자. "`moduleResolution: bundler`라서 baseUrl이 필요 없어진 것"이 **아니다.** `paths`가 baseUrl을 요구하지 않게 된 건 moduleResolution 종류와 무관하게 4.1부터의 일이다. 그러니 어떤 resolution 모드든 `paths`만 쓴다면 baseUrl은 그냥 지우면 된다. - -### 왜 deprecated 됐나: baseUrl의 "숨은 두 번째 일" +### 왜 deprecated 됐나: 아무도 모르던 baseUrl의 두 번째 역할 -해결은 쉬웠지만, **왜** 멀쩡히 동작하던 옵션을 6.0이 하드 에러로 막는지가 궁금했다. [릴리스 노트](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html)와 도입 이슈 [#62207](https://github.com/microsoft/TypeScript/issues/62207)을 보면 이유가 명확하다. baseUrl은 두 가지 일을 한다. +해결은 쉬웠지만, **왜** 멀쩡히 동작하던 옵션을 6.0이 하드 에러로 막는지가 궁금했다. [릴리스 노트](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html)와 도입 이슈 [#62207](https://github.com/microsoft/TypeScript/issues/62207)을 보면 이유가 명확하다. baseUrl은 두 가지 역할을 한다. > "Today, `baseUrl` performs two functions: > - it acts as a prefix for all entries in `paths` > - it acts as a potential resolution point for all bare paths > -> But almost nobody realizes that last part." — [issue #62207](https://github.com/microsoft/TypeScript/issues/62207) +> But almost nobody realizes that last +> part." — [issue #62207](https://github.com/microsoft/TypeScript/issues/62207) + +문제는 두 번째, 인용문이 말한 _"bare paths"_다. baseUrl이 켜져 있으면 `./`로 시작하지 않는 import까지 — 예컨대 `import { Button } from +"components/Button"`처럼 **패키지인지 내 파일인지 모호한 경로**까지 — baseUrl 폴더 안에서 찾아준다. 그 바람에 **번들러/런타임에선 절대 동작하지 +않을 import를 타입체커만 "괜찮다"고 통과**시킨다. + +> "...it often meant that many import paths that would never have worked at runtime are considered +> \"just fine\" by TypeScript." — 릴리스 노트 + +즉 첫 번째 역할(paths 접두사)은 4.1 이후 `paths`가 직접 대체할 수 있으니, 위험한 두 번째 역할을 없애기 위해 baseUrl 자체를 걷어내는 것이다. 7.0에서는 +아예 제거된다. + +> "In TypeScript 7.0, we are not reimplementing `baseUrl`. ... In TypeScript 6.0, we will be +> deprecating this behavior. Using `baseUrl` will lead to an error which can only be resolved by +> applying one of the above fixes, or using +`--ignoreDeprecations`." — [issue #62207](https://github.com/microsoft/TypeScript/issues/62207) -문제는 두 번째다. baseUrl이 모든 bare specifier의 암묵적 해석 지점이 되면서, **번들러/런타임에선 절대 동작하지 않을 import를 타입체커만 "괜찮다"고 통과**시킨다. +### 사실 이 분리는 6년 전에 시작됐다 -> "...it often meant that many import paths that would never have worked at runtime are considered \"just fine\" by TypeScript." — 릴리스 노트 +그런데 "두 역할을 떼어낸다"는 발상은 6.0이 처음이 아니다. 이미 2019년, 한 사용자가 이슈 [#31869](https://github.com/microsoft/TypeScript/issues/31869)에서 똑같은 불편을 제기했다 — `paths`는 타입체킹용 별칭으로만 쓰고 싶은데, baseUrl이 딸려 보내는 _두 번째 역할(bare 이름의 암묵적 해석)_ 까지 떠안긴 싫다는 것이었다. -즉 첫 번째 일(paths 접두사)은 4.1 이후 `paths`가 직접 대체할 수 있으니, 위험한 두 번째 일을 없애기 위해 baseUrl 자체를 걷어내는 것이다. 7.0에서는 아예 제거된다. +이 요청은 PR [#40101](https://github.com/microsoft/TypeScript/pull/40101)로 구현돼 **4.1(2020년)** 에 출시됐고, 그때부터 baseUrl 없이도 `paths`가 동작한다. -> "In TypeScript 7.0, we are not reimplementing `baseUrl`. ... In TypeScript 6.0, we will be deprecating this behavior. Using `baseUrl` will lead to an error which can only be resolved by applying one of the above fixes, or using `--ignoreDeprecations`." — [issue #62207](https://github.com/microsoft/TypeScript/issues/62207) +그러니 6.0의 deprecation은 갑자기 튀어나온 breaking change가 아니다. **2019년 문제 제기(#31869) → 2020년 4.1 구현(#40101) → 2026년 6.0 정리(#62207·#62509)** — baseUrl의 두 역할을 떼어내는 일은 6년에 걸친 청소의 마지막 단계인 셈이다. ### 구현 레벨: TS5101은 baseUrl 전용이 아니다 -여기서 PR [#62509 "Deprecate baseUrl"](https://github.com/microsoft/TypeScript/pull/62509)의 실제 diff를 보면 재미있는 사실이 드러난다. baseUrl을 위한 **새 에러 코드는 만들어지지 않았다.** 기존 범용 deprecation 진단(우리가 보는 `TS5101`)을 재사용하고, 거기에 마이그레이션 안내용 신규 메시지(코드 `5111`)만 체이닝한다. +여기서 PR [#62509 "Deprecate baseUrl"](https://github.com/microsoft/TypeScript/pull/62509)의 실제 diff를 보면 +재미있는 사실이 드러난다. baseUrl을 위한 **새 에러 코드는 만들어지지 않았다.** 기존 범용 deprecation 진단(우리가 보는 `TS5101`)을 재사용하고, 거기에 +마이그레이션 안내용 신규 메시지(코드 `5111`)만 체이닝한다. 먼저 타입 선언에 `@deprecated`를 단다. @@ -101,7 +126,8 @@ tags: ['TypeScript', 'tsdown', 'monorepo', 'troubleshooting', 'breaking-change'] }); ``` -`checkDeprecations("6.0", "7.0", ...)` — 6.0에서 deprecate, 7.0에서 제거라는 의미가 함수 인자에 그대로 박혀 있다. 그래서 우리가 보는 에러는 이렇게 생겼다. +`checkDeprecations("6.0", "7.0", ...)` — 6.0에서 deprecate, 7.0에서 제거라는 의미가 함수 인자에 그대로 박혀 있다. 그래서 우리가 +보는 에러는 이렇게 생겼다. ```text error TS5101: Option 'baseUrl' is deprecated and will stop functioning in TypeScript 7.0. @@ -109,93 +135,58 @@ error TS5101: Option 'baseUrl' is deprecated and will stop functioning in TypeSc Visit https://aka.ms/ts6 for migration information. ``` -`TS5101`은 "Option '{0}' is deprecated..."라는 **범용 메시지**의 코드고, `{0}`에 `baseUrl`이, 안내 링크(`Visit https://aka.ms/ts6 ...`)에 신규 코드 `5111`이 related로 붙은 것이다. 그러니 `ignoreDeprecations: "6.0"`으로 한시적으로 끌 수는 있지만, 그건 그저 문제를 7.0으로 미루는 일이다. - -참고로 에러가 가리키는 https://aka.ms/ts6은 마이그레이션 가이드 이슈(#62508)로 리다이렉트되는데, 이 글을 쓰는 시점엔 본문이 아직 Placeholder만 들어 있는 빈 페이지다. 에러는 친절하게 안내하지만 정작 안내처는 공사 중인 셈. - ---- - -## 2. types 기본값이 []가 된 이유 (그리고 왜 우리 빌드는 멀쩡했나) - -### 의심: 타입이 다 빠졌어야 하는 거 아닌가? - -작업 중간에 의문이 들었다. "TS6에선 `types: []`가 기본이라던데, 그럼 전역 타입이 다 빠졌을 텐데 왜 빌드가 다 되지?" 이건 에러가 아니라 **에러가 안 난 게 더 수상한** 경우였다. - -### 무엇이, 왜 바뀌었나 +`TS5101`은 "Option '{0}' is deprecated..."라는 **범용 메시지**의 코드고, `{0}`에 `baseUrl`이, 안내 링크( +`Visit https://aka.ms/ts6 ...`)에 신규 코드 `5111`이 related로 붙은 것이다. 그러니 `ignoreDeprecations: "6.0"`으로 +한시적으로 끌 수는 있지만, 그건 그저 문제를 7.0으로 미루는 일이다. -[릴리스 노트](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html)와 제안 이슈 [#62195](https://github.com/microsoft/TypeScript/issues/62195)이 명확하다. 기존 `types`의 기본값은 사실상 "`node_modules/@types`를 전부 열거"였다. +참고로 에러가 가리키는 https://aka.ms/ts6은 마이그레이션 가이드 +이슈(#62508)로 리다이렉트되는데, 이 글을 쓰는 시점엔 +본문이 아직 Placeholder만 들어 있는 빈 페이지다. 에러는 친절하게 안내하지만 정작 안내처는 공사 중인 셈. -> "for convenience, TypeScript would also include all packages in `node_modules/@types` by default... This can be _very_ expensive, as a normal repository setup these days might transitively pull in hundreds of `@types` packages, especially in multi-project workspaces with flattened `node_modules`." — 릴리스 노트 - -flattened `node_modules`를 쓰는 모노레포에서는 이게 특히 치명적이다. 수백 개의 불필요한 `.d.ts`가 전이적으로 프로그램에 끌려와 파싱/체크 비용을 잡아먹는다. 그래서 기본값을 `[]`로 바꿨고, 효과는 수치로 제시돼 있다. - -> "Many projects we've looked at have improved their build time anywhere from 20-50% just by setting `types` appropriately." — 릴리스 노트 ([원 출처는 #54500](https://github.com/microsoft/TypeScript/issues/54500)) - -### 구현 레벨: "기본값 = []" 대입문은 없다 - -PR [#63054 "Set default `types` array to `[]`; support `\"*\"` wildcard"](https://github.com/microsoft/TypeScript/pull/63054)을 까보면, 흥미롭게도 `options.types = []` 같은 **명시적 기본값 대입은 어디에도 없다.** 자동 열거를 담당하던 `getAutomaticTypeDirectiveNames`의 early-return 조건이 뒤집힌 게 전부다. - -```diff -// src/compiler/moduleNameResolver.ts - export function getAutomaticTypeDirectiveNames(options, host): string[] { -- // Use explicit type list from tsconfig.json -- if (options.types) { -- return options.types; -+ if (!usesWildcardTypes(options)) { -+ return options.types ?? []; - } - // ... 여기 아래(typeRoots 열거)는 이제 "*"가 있을 때만 실행된다 -- return result; -+ return deduplicate(flatten(options.types.map(t => t === "*" ? wildcardMatches : t)), equateValues); - } -``` +### `paths`도 끝이 아니다: 진짜 대안은 package.json `imports` -핵심은 `if (options.types)` → `if (!usesWildcardTypes(options))`로 바뀐 한 줄이다. 예전엔 "`types`를 명시 안 하면(undefined) typeRoots를 뒤져 모든 `@types`를 자동 포함"했는데, 이제는 **`types` 배열에 `"*"`가 있을 때만** 열거하고, 그 외에는 `options.types ?? []`를 그대로 돌려준다. `[]` 기본값은 별도 코드가 아니라 이 early-return의 자연스러운 결과다. +`baseUrl`을 지우고 `paths`로 갈아탔지만, `paths`도 완전한 답은 아니다. **`paths`는 타입체커 전용**이라 `tsc`가 내보내는 JS엔 반영되지 않아, +`baseUrl`이 6.0에서 막힌 그 이유(런타임에 안 맞는 import를 타입체커만 통과)와 정도만 다를 뿐 같은 괴리를 안는다. 그 괴리가 구조적으로 없는 대안이 * +*Node가 런타임에 직접 읽는 package.json `imports`** (`#` subpath)다 — 타입과 런타임이 같은 매핑을 보니 어긋날 수 없고, TS 핸드북도 +이를 _"a standard replacement for convenience `paths` aliases"_ 로 부른다. -판정 헬퍼도 새로 추가됐다. +
+옮길 때 알아둘 것 — 설정·버전·번들러 (펼치기) -```diff -// src/compiler/utilities.ts -+export function usesWildcardTypes(options: CompilerOptions): options is CompilerOptions & { types: string[] } { -+ return some(options.types, t => t === "*"); -+} +```json +// package.json — tsconfig paths 대신 여기에 +{ + "imports": { + "#/*": "./src/*" + } +} ``` -옛 동작(모든 `@types` 자동 포함)으로 되돌리고 싶으면 `"types": ["*"]`를 쓰면 된다. 위 diff에서 `options.types.map(t => t === "*" ? wildcardMatches : t)` — `"*"`가 있던 **위치에** 열거 결과를 펼쳐 넣어 순서까지 보존한다. - -### 핵심: types가 통제하는 범위는 좁다 - -가장 중요한 포인트. 이 변경은 **`node_modules/@types` 읽기를 중단하는 게 아니다.** import해서 쓰는 타입은 전혀 영향이 없고, 오직 **import 없이 전역(ambient)으로 들어오던 `@types`**만 영향을 받는다. - -> "...this does not mean we will stop reading from `node_modules/@types`, just that the files won't be brought in unless imported, explicitly listed in your `tsconfig.json`'s `types` array... Typically this will only affect users relying on global values and module names, like those brought in from `@types/node` (e.g. the `"fs"` module is globally defined), or a testing framework." — [issue #62195](https://github.com/microsoft/TypeScript/issues/62195) - ```ts -import { foo } from 'some-pkg'; // ← types 설정과 무관하게 타입 붙음 -process.env.NODE_ENV; // ← @types/node 전역. types에 'node' 없으면 에러 +import { foo } from '#/utils/foo'; // 타입도, Node 런타임도 같은 매핑으로 해석 ``` -### 왜 우리 레포는 멀쩡히 빌드됐나 +- **TypeScript는 4.7부터** `imports`를 해석하고, `moduleResolution`이 `node16`·`nodenext`·`bundler`일 때 동작한다( + 레거시 `node`는 안 된다). +- **`#`는 강제다.** `@/` 같은 임의 별칭은 못 쓴다. 다만 **`#/` prefix는 TS 6.0 + Node 20부터** 허용돼 `@/`에 가까운 컨벤션을 쓸 수 + 있다 — 단 `#/` 패턴은 `nodenext`·`bundler`에서만이고 `node16`은 제외다. +- **번들러 지원은 제각각.** Vite 4.2+, esbuild 0.13.9+는 지원하고, Jest는 네이티브로 안 돼 `moduleNameMapper`가 필요하다. +- 참고로 `baseUrl` deprecation 이슈(#62207·#62508) 자체는 `imports`가 아니라 "prefix를 `paths`에 직접 박기"를 권한다. + `imports`를 표준 대체재로 부르는 건 핸드북·릴리스 노트 쪽 라인이다. -답은 싱겁게도 "이미 잘 명시돼 있어서"였다. 전역(ambient)이 필요한 패키지는 전부 `types`를 명시하고 있었다. - -| 패키지 | `types` | TS6 영향 | -|:------------------------|:-----------------------------|:------------------------------| -| next.js | `["node", "vitest/globals"]` | 없음 (명시) | -| socket-server / bundler | `["node"]` | 없음 (명시) | -| react / typescript | `["vitest/globals"]` | 없음 (명시) | -| ui / 공유 base config | 미지정 | 이제 `[]` (그러나 ambient 전역을 안 씀) | - -`types`를 명시 안 한 곳(ui, 공유 base)은 애초에 ambient 전역에 의존하지 않거나, Next.js처럼 `next-env.d.ts`의 `/// `로 충당돼서 통과했다. 즉 "에러가 안 난 것"은 운이 아니라, **TS6가 강제하기 전부터 옳게 적혀 있었던** 덕이다. +> +출처: [TS 핸드북 Modules Reference](https://www.typescriptlang.org/docs/handbook/modules/reference.html) · [TS 4.7](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7.html) · [TS 6.0 릴리스 노트](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html) -ignoreDeprecationsbaseUrl 같은 deprecated 옵션용이지, types 기본값 변경을 되돌리는 옵션이 아니다. 옛 동작이 필요하면 "types": ["*"]를 쓰면 된다 — 이건 glob이 아니라 "전부 열거"를 뜻하는 special token이다. +
--- -## 3. rootDir를 명시하라 (TS5011) +## 2. rootDir를 명시하라 (TS5011) ### 마주침: 빌드의 dts emit 단계 -`@package/sample-lib`의 빌드는 `minibundler && tsc --emitDeclarationOnly` 두 단계다. 앞 단계(번들)는 통과했는데 `tsc`가 깨졌다. +`@package/sample-lib`의 빌드는 `minibundler && tsc --emitDeclarationOnly` 두 단계다. 앞 단계(번들)는 통과했는데 `tsc`가 +깨졌다. ```text tsconfig.json:10:5 - error TS5011: The common source directory of 'tsconfig.json' @@ -206,29 +197,76 @@ tsconfig.json:10:5 - error TS5011: The common source directory of 'tsconfig.json 해결은 한 줄. -```jsonc +```json // packages/@package/sample-lib/tsconfig.json { "compilerOptions": { "emitDeclarationOnly": true, "outDir": "./dist", - "rootDir": "./src", // ← 추가 - "allowJs": true + "rootDir": "./src", // ← 추가 } } ``` +그런데 방금 추가한 `rootDir: "./src"` 한 줄은 정확히 뭘 할까? `rootDir`은 **출력 폴더(`dist`)의 모양을 어디서부터 베낄지** 정하는 '깃발'이라 +보면 된다. tsc는 깃발 **아래**의 폴더 구조를 그대로 `dist`에 복제한다. + +``` +my-lib/ +├── tsconfig.json +└── src/ + ├── index.ts + └── utils.ts +``` + +깃발을 `src/`에 꽂으면 → `dist/index.js`, `dist/utils.js`. 한 칸 위(`my-lib/`)에 꽂으면 → `dist/src/index.js`로 `src/`가 딸려 +들어온다. **위치 한 끗이 산출물 모양을 가른다.** + ### 왜 추론하던 걸 이제 명시하라고 하나 -[릴리스 노트](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html)와 이슈 [#62194](https://github.com/microsoft/TypeScript/issues/62194)의 논거는 두 가지다. +[릴리스 노트](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html)와 +이슈 [#62194](https://github.com/microsoft/TypeScript/issues/62194)의 논거는 두 가지다. + +> "Previously, if you did not specify a `rootDir`, it was inferred based on the common directory of +> all non-declaration input files. But this often meant that it was impossible to know if a file +> belonged to a project without trying to load and parse that project. It also meant that TypeScript +> had to spend more time inferring that common source directory by analyzing every file path in the +> program." — 릴리스 노트 + +요점은 **결정성**이다. 옛 TS는 깃발을 "내 파일을 전부 담는 가장 깊은 공통 폴더"에 **자동으로** 꽂았다. 위 예제는 파일이 다 `src/`에 있으니 +깃발은 `src/`. 그런데 루트에 파일 하나(`my-lib/build.ts`)만 더하면 공통 폴더가 `my-lib/`로 **점프**하고, `dist/index.js`였던 출력이 +통째로 `dist/src/index.js`로 밀린다. **파일 하나 더했을 뿐인데 산출물 구조가 바뀐다** — 이 들쭉날쭉함이 비결정성이다. + +비용은 하나 더 있다. "이 파일이 어느 `tsconfig` 소속인가"를 알려면 그 공통 폴더부터 계산하느라 프로젝트를 통째로 로드·파싱해야 했다. 깃발을 +`tsconfig.json` 폴더(configDir)에 못 박으면 이걸 **경로만 보고 즉답**한다 — 제안 이슈의 표현 그대로 _"trivially determine whether +a file could belong to another `tsconfig.json`"_ +([#62194](https://github.com/microsoft/TypeScript/issues/62194)). 1장 baseUrl이 7.0에서 _아예 사라지는_ 동작이었던 것과 +달리, rootDir은 동작은 남고 그 **계산만 입력 파일 목록에서 떼어낸** 것이다. 그래도 향하는 곳은 같다 — Go로 다시 쓴 7.0(`tsgo`)의 빠른 언어 +서비스다. -> "Previously, if you did not specify a `rootDir`, it was inferred based on the common directory of all non-declaration input files. But this often meant that it was impossible to know if a file belonged to a project without trying to load and parse that project. It also meant that TypeScript had to spend more time inferring that common source directory by analyzing every file path in the program." — 릴리스 노트 +그럼 왜 기본값만 슬쩍 바꾸지 않고 굳이 **에러**를 던질까? 기본 위치를 말없이 옮기면 빌드는 성공하는데 산출물이 `dist/file.js`에서 +`dist/src/file.js`로 소리 없이 밀리고, 그걸 `import`하던 패키지가 영문도 모르고 깨진다. 조용한 사고보다 시끄러운 멈춤이 낫다 — 그래서 옛 추론값과 +configDir이 어긋나면 **멈추고 명시를 요구한다**(그 비교 로직이 다음 절의 `TS5011`이다). -요점은 **결정성(determinism)**이다. 추론된 rootDir는 입력 파일 집합에 따라 움직인다 — 파일 하나를 추가/제거하면 공통 디렉터리가 이동하고, 그러면 `outDir` 안의 출력 경로 레이아웃이 통째로 달라진다. 게다가 "이 파일이 어느 프로젝트 소속인가"를 알려면 프로젝트를 로드·파싱해봐야만 했다(언어 서비스 성능에도 불리). 그래서 6.0은 기본 rootDir를 **tsconfig.json이 있는 디렉터리로 고정**한다. +충격은 모노레포에서 더 컸다. 구현 PR의 생태계 테스트에서 mui-docs는 에러가 **0 → 11,385개**로 튀었고, TS 팀의 jakebailey도 _"Ouch, +looking bad for pyright and mui-docs"_ 라 적었다. 원인은 rootDir을 추론에 맡긴 채 프로젝트 참조 대신 _"every project emit each +other's files into their own dist"_ 하던 구조였다([#62418](https://github.com/microsoft/TypeScript/pull/62418)). + +여기서 증상이 갈린다. **같은 변경(기본 rootDir = `configDir`)인데, 깃발이 어디로 가느냐에 따라 두 얼굴**로 나타난다. + +| | 내 경우 (작은 라이브러리) | 모노레포 (mui 같은) | +|:--------|:-----------------------------|:----------------------------------| +| 소스 위치 | 전부 `src/` **안** | 다른 패키지(루트 **밖**) 파일을 끌어다 씀 | +| 깃발을 옮기면 | 출력 위치만 밀림 | 파일이 새 깃발 **바깥**에 놓임 | +| 터지는 에러 | **`TS5011`** ("rootDir 명시해") | **`TS6059`** ("이 파일 rootDir 밖이야") | + +뿌리는 하나 — 깃발을 자동 추측에서 `tsconfig` 폴더로 못 박은 것이다. 내 프로젝트는 '출력이 밀리는' 얼굴로, 큰 모노레포는 '파일이 루트 밖으로 튕겨나가는' +얼굴로 나타났을 뿐이다. ### 구현 레벨: 한 줄짜리 조건 완화 + 신·구 비교 -PR [#62418 "Assume rootDir is the current configuration directory"](https://github.com/microsoft/TypeScript/pull/62418)은 81개 파일을 건드린 큰 PR이지만, 핵심 로직은 **동일한 한 줄이 세 군데에서 완화된 것**이다. +PR [#62418 "Assume rootDir is the current configuration directory"](https://github.com/microsoft/TypeScript/pull/62418) +은 81개 파일을 건드린 큰 PR이지만, 핵심 로직은 **동일한 한 줄이 세 군데에서 완화된 것**이다. ```diff // src/compiler/emitter.ts — getCommonSourceDirectory() @@ -238,9 +276,11 @@ PR [#62418 "Assume rootDir is the current configuration directory"](https://gith commonSourceDirectory = getDirectoryPath(normalizeSlashes(options.configFilePath)); ``` -예전엔 `composite` 프로젝트만 "configDir를 공통 소스 디렉터리로" 썼는데, 이제 `configFilePath`만 있으면(=tsconfig 기반 빌드면) 항상 그렇게 한다. 같은 패턴이 `utilities.ts`, `moduleNameResolver.ts`에도 동일하게 적용됐다. +예전엔 `composite` 프로젝트만 "configDir를 공통 소스 디렉터리로" 썼는데, 이제 `configFilePath`만 있으면(=tsconfig 기반 빌드면) 항상 +그렇게 한다. 같은 패턴이 `utilities.ts`, `moduleNameResolver.ts`에도 동일하게 적용됐다. -그럼 TS5011은 언제 던지나? `program.ts`에 새로 추가된 블록이 **옛 방식과 새 방식의 공통 디렉터리를 비교**해서, 출력 레이아웃이 달라질 때만 에러를 낸다(아래는 핵심만 발췌·정리). +그럼 TS5011은 언제 던지나? `program.ts`에 새로 추가된 블록이 **옛 방식과 새 방식의 공통 디렉터리를 비교**해서, 출력 레이아웃이 달라질 때만 에러를 낸다( +아래는 핵심만 발췌·정리). ```diff // src/compiler/program.ts @@ -257,158 +297,195 @@ PR [#62418 "Assume rootDir is the current configuration directory"](https://gith +} ``` -여기서 에러 메시지의 친절함이 설명된다. 메시지의 `'{1}'`(=`'./src'`)은 바로 `dir59` — **옛 방식으로 계산한 공통 디렉터리**다. 우리 sample-lib는 소스가 `./src`에 있어 옛 추론값이 `./src`였는데, 새 기본값은 tsconfig 위치(`.`)라 둘이 어긋난다. 그래서 6.0은 "전엔 `./src`로 잡혔으니, 그 값을 `rootDir`에 명시하라"고 **정답을 알려주며** 멈춘 것이다. `rootDir: "./src"`는 그 안내를 그대로 따른 것뿐이다. +여기서 에러 메시지의 친절함이 설명된다. 메시지의 `'{1}'`(=`'./src'`)은 바로 `dir59` — **옛 방식으로 계산한 공통 디렉터리**다. 우리 +sample-lib는 소스가 `./src`에 있어 옛 추론값이 `./src`였는데, 새 기본값은 tsconfig 위치(`.`)라 둘이 어긋난다. 그래서 6.0은 "전엔 +`./src`로 잡혔으니, 그 값을 `rootDir`에 명시하라"고 **정답을 알려주며** 멈춘 것이다. `rootDir: "./src"`는 그 안내를 그대로 따른 것뿐이다. --- -## 4. 1차 검증: 여기까지 check-types는 통과 +## 3. types 기본값이 []가 된 이유 (그리고 왜 우리 빌드는 멀쩡했나) -세 가지 규칙을 다 맞추고 나니 타입 체크는 깨끗했다. +### 의심: 타입이 다 빠졌어야 하는 거 아닌가? -- `pnpm check-types` → **5/5 통과** +baseUrl도 rootDir도 빨간 에러로 멈춰 세웠는데, types는 이상하리만치 조용했다. 그래서 오히려 의문이 들었다. "TS6에선 `types: []`가 기본이라던데, 그럼 전역 타입이 다 빠졌을 텐데 왜 빌드가 다 되지?" 이건 에러가 아니라 **에러가 안 난 게 더 +수상한** 경우였다. -자, TypeScript 자체가 바꾼 규칙들은 다 막아냈다. 그런데 전체 빌드(`pnpm build`)를 돌리자, **정작 내가 손대지도 않은 곳**에서 다시 터졌다. 그것도 방금 작별했다고 생각한 그 이름, `baseUrl`로. +### 무엇이, 왜 바뀌었나 ---- +먼저 결을 분명히 하자. 1장(`baseUrl`)·2장(`rootDir`)이 7.0(`tsgo`)을 앞둔 정리였다면, **이 `types` 변경의 동기는 순수하게 빌드 성능** +이다 — +제안 이슈 [#62195](https://github.com/microsoft/TypeScript/issues/62195)도 이 변경을 네이티브 컴파일러(7.0)와 묶이지 않은, +6.0에 +독립적으로 들어가는 성능 개선으로 다룬다. 그 이슈와 +[릴리스 노트](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-6-0.html)를 보면 이유가 +명확하다: +기존 `types`의 기본값은 사실상 "`node_modules/@types`를 전부 열거"였다. -## 5. 🎁 보너스: 빌드에서 튀어나온 진짜 빌런 — tsup +> "for convenience, TypeScript would also include all packages in `node_modules/@types` by +> default... This can be _very_ expensive, as a normal repository setup these days might +> transitively +> pull in hundreds of `@types` packages, especially in multi-project workspaces with flattened +`node_modules`." — 릴리스 노트 -### 안 쓴 baseUrl이 왜 또? +flattened `node_modules`를 쓰는 모노레포에서는 이게 특히 치명적이다. 수백 개의 불필요한 `.d.ts`가 전이적으로 프로그램에 끌려와 파싱/체크 비용을 +잡아먹는다. 그래서 기본값을 `[]`로 바꿨고, 효과는 수치로 제시돼 있다. -`pnpm build`가 디자인 시스템 패키지의 dts 빌드에 다다른 순간이었다. +> "Many projects we've looked at have improved their build time anywhere from 20-50% just by setting +`types` appropriately." — 릴리스 +> 노트 ([원 출처는 #54500](https://github.com/microsoft/TypeScript/issues/54500)) -```text -@design-system/ui build: DTS Build start -@design-system/ui build: error TS5101: Option 'baseUrl' is deprecated and will - stop functioning in TypeScript 7.0. Specify compilerOption '"ignoreDeprecations": - "6.0"' to silence this error. -@design-system/ui build: DTS Build error +### 구현 레벨: "기본값 = []" 대입문은 없다 + +PR [#63054 "Set default `types` array to `[]`; support +`\"*\"` wildcard"](https://github.com/microsoft/TypeScript/pull/63054)을 까보면, 흥미롭게도 +`options.types = []` 같은 **명시적 기본값 대입은 어디에도 없다.** 자동 열거를 담당하던 `getAutomaticTypeDirectiveNames`의 +early-return 조건이 뒤집힌 게 전부다(아래 diff는 핵심만 발췌·정리). + +```diff +// src/compiler/moduleNameResolver.ts + export function getAutomaticTypeDirectiveNames(options, host): string[] { +- // Use explicit type list from tsconfig.json +- if (options.types) { +- return options.types; ++ if (!usesWildcardTypes(options)) { ++ return options.types ?? []; + } + // ... 여기 아래(typeRoots 열거)는 이제 "*"가 있을 때만 실행된다 +- return result; ++ return deduplicate(flatten(options.types.map(t => t === "*" ? wildcardMatches : t)), equateValues); + } ``` -또 `TS5101`. 그런데 `@design-system/ui/tsconfig.json`에는 `baseUrl`이 **없다.** #1에서 정리했듯 TS5101은 범용 deprecation 진단이니, 누군가가 내 빌드에 baseUrl을 **주입**하고 있다는 뜻이다. +핵심은 `if (options.types)` → `if (!usesWildcardTypes(options))`로 바뀐 한 줄이다. 예전엔 "`types`를 명시 안 하면( +undefined) typeRoots를 뒤져 모든 `@types`를 자동 포함"했는데, 이제는 **`types` 배열에 `"*"`가 있을 때만** 열거하고, 그 외에는 +`options.types ?? []`를 그대로 돌려준다. `[]` 기본값은 별도 코드가 아니라 이 early-return의 자연스러운 결과다. + +판정 헬퍼도 새로 추가됐다. + +```diff +// src/compiler/utilities.ts ++export function usesWildcardTypes(options: CompilerOptions): options is CompilerOptions & { types: string[] } { ++ return some(options.types, t => t === "*"); ++} +``` -### 진단: 범인은 tsup +옛 동작(모든 `@types` 자동 포함)으로 되돌리고 싶으면 `"types": ["*"]`를 쓰면 된다. 위 diff에서 +`options.types.map(t => t === "*" ? wildcardMatches : t)` — `"*"`가 있던 **위치에** 열거 결과를 펼쳐 넣어 순서까지 +보존한다. -범인은 dts 번들러로 쓰던 **tsup**이었다. tsup의 소스 [`src/rollup.ts`](https://github.com/egoist/tsup/blob/main/src/rollup.ts)를 직접 까보면: +### 핵심: 사라지는 건 import 안 한 '전역' 타입뿐 + +기본값이 `[]`로 바뀌었다니, 내 타입이 우수수 빠지는 건 아닐까? 다행히 **영향 범위는 훨씬 좁다.** 이 변경은 `node_modules/@types` **읽기를 멈추는 +게 아니다.** 내가 직접 `import`해서 쓰는 타입은 전혀 영향이 없고, 오직 **import 없이 전역(global)으로 깔리던 `@types`** — 예컨대 +`@types/node`의 `process`·`Buffer`, 테스트 프레임워크의 `describe`/`expect` 같은 것 — 만 끊긴다. + +> "...this does not mean we will stop reading from `node_modules/@types`, just that the files won't +> be brought in unless imported, explicitly listed in your `tsconfig.json`'s `types` array... +> Typically this will only affect users relying on global values and module names, like those +> brought in from `@types/node` (e.g. the `"fs"` module is globally defined), or a testing +> framework." — [issue #62195](https://github.com/microsoft/TypeScript/issues/62195) ```ts -// tsup/src/rollup.ts — dts 빌드 컴파일러 옵션 구성부 -compilerOptions: { - ...compilerOptions, - baseUrl: compilerOptions.baseUrl || '.', // ← 내 tsconfig에 없어도 '.'를 강제 주입 - declaration: true, - // ... -} +import { foo } from 'some-pkg'; // ← types 설정과 무관하게 타입 붙음 +process.env.NODE_ENV; // ← @types/node 전역. types에 'node' 없으면 에러 ``` -즉 내 tsconfig에 `baseUrl`이 없어도 tsup이 `'.'`로 채워 넣는다. TS 5.x에선 무해했지만, TS6에선 이 주입된 한 줄이 곧장 `TS5101` 하드 에러가 된다. 똑같은 증상이 tsup 이슈 트래커에도 올라와 있다([#1388 "DTS Build error TS5101"](https://github.com/egoist/tsup/issues/1388), 재현 환경 tsup 8.5.1 + TypeScript 6.0.2). +### 답: 빠질 게 없었다 — 이미 명시돼 있어서 -### 그런데 tsup이… 유지보수 중단? +답은 싱겁게도 "이미 잘 명시돼 있어서"였다. 전역이 필요한 패키지는 전부 `types`를 명시하고 있었다. -"최신 tsup으로 올리면 고쳐졌으려나" 하고 저장소에 갔다가, README 최상단에서 답을 봤다. +| 패키지 | `types` | TS6 영향 | +|:------------------------|:-----------------------------|:------------------------------| +| next.js | `["node", "vitest/globals"]` | 없음 (명시) | +| socket-server / bundler | `["node"]` | 없음 (명시) | +| react / typescript | `["vitest/globals"]` | 없음 (명시) | +| ui / 공유 base config | 미지정 | 이제 `[]` (그러나 ambient 전역을 안 씀) | -> "This project is not actively maintained anymore. Please consider using tsdown instead." — [egoist/tsup README](https://github.com/egoist/tsup/blob/main/README.md) +`types`를 명시 안 한 곳(ui, 공유 base)은 애초에 ambient 전역에 의존하지 않거나, Next.js처럼 `next-env.d.ts`의 +`/// `로 충당돼서 통과했다. 즉 "에러가 안 난 것"은 운이 아니라, **TS6가 강제하기 전부터 옳게 적혀 있었던** 덕이다. -저자(egoist) 본인이 박아둔 문구였다. 실제로 "TypeScript 6 지원" 요청 이슈([#1389](https://github.com/egoist/tsup/issues/1389))는 여전히 열려 있고, 마지막 릴리스 `8.5.1`은 2025-11-12로 한참 전이다. 흥미로운 건 **npm에는 아직 `deprecate` 플래그가 안 걸려 있어서**(그걸 요청하는 이슈가 [#1391](https://github.com/egoist/tsup/issues/1391)) — `pnpm install` 단계에선 아무 경고도 못 보고, 빌드가 깨지고 나서야 README를 보고 알게 됐다는 점이다. +ignoreDeprecationsbaseUrl 같은 deprecated 옵션용이지, +types 기본값 변경을 되돌리는 옵션이 아니다. 옛 동작이 필요하면 "types": ["*"]를 쓰면 된다 — 이건 glob이 아니라 "전부 +열거"를 뜻하는 special token이다. -선택지는 둘이었다. (a) `ignoreDeprecations: "6.0"`으로 경고를 한시적으로 끄거나, (b) 권고대로 도구를 갈아치우거나. baseUrl 주입은 내 코드가 아니라 도구의 문제고, 그 도구가 더는 고쳐지지 않는다면 답은 정해져 있었다. +--- -### tsdown으로 +## 4. 1차 검증: 여기까지 check-types는 통과 -[tsdown](https://tsdown.dev)은 [Rolldown](https://rolldown.rs)(Rust 기반 번들러) 위에서 도는 tsup의 후속 격 도구다. 공식 문서가 관계를 이렇게 정리한다. +세 가지 규칙을 다 맞추고 나니 타입 체크는 깨끗했다. -> "tsdown is the spiritual successor to tsup, powered by Rolldown instead of esbuild." — [tsdown FAQ](https://tsdown.dev/guide/faq) +- `pnpm check-types` → **5/5 통과** -결정적으로, dts를 [`rolldown-plugin-dts`](https://github.com/sxzz/rolldown-plugin-dts)로 생성하기 때문에 **tsup처럼 baseUrl을 주입하지 않는다.** 그리고 peer dependency로 `typescript: "^5.0.0 || ^6.0.0"`을 선언해 **TS6를 공식 지원**한다(tsup은 아직 open 이슈). tsup을 쓰던 패키지는 둘(`@design-system/ui`, `@package/bundler`)뿐이라 둘 다 전환했다. 공식 마이그레이션 도구도 있다. +자, TypeScript 자체가 바꾼 규칙들은 다 막아냈다. 그런데 전체 빌드(`pnpm build`)를 돌리자, **정작 내가 손대지도 않은 곳**에서 다시 터졌다. 그것도 방금 +작별했다고 생각한 그 이름, `baseUrl`로. -```bash -npx tsdown-migrate # 단일 디렉터리 -npx tsdown-migrate packages/* # 모노레포 glob -npx tsdown-migrate --dry-run # 변경 미리보기 (-d) -``` +--- -config는 `import`만 바꾸면 거의 그대로다. +## 5. 보너스: 다시 튀어나온 baseUrl — 이번엔 내 코드가 아니었다 + +`pnpm build`가 디자인 시스템 패키지의 dts 빌드에 다다르자 **또 `TS5101`**이 떴다. 그런데 `@design-system/ui`의 tsconfig엔 +`baseUrl`이 **없다.** #1에서 봤듯 TS5101은 범용 진단이니, 누군가 내 빌드에 baseUrl을 **주입**하고 있다는 뜻이다. + +범인은 dts 번들러로 쓰던 **tsup**이었다. [소스](https://github.com/egoist/tsup/blob/main/src/rollup.ts)의 dts 빌드 옵션 +구성부에 이 한 줄이 있다. ```ts -// packages/@package/bundler/tsdown.config.ts -import { defineConfig } from 'tsdown'; - -export default defineConfig({ - entry: ['src/index.ts', 'src/cli.ts'], - format: ['esm', 'cjs'], - platform: 'node', - target: 'node24', // ← platform과 다른 축 (함정 ② 참고) - clean: true, - dts: true, - sourcemap: true, -}); +baseUrl: compilerOptions.baseUrl || '.', // ← 내 tsconfig에 없어도 '.'를 강제 주입 ``` -전환 직후, `TS5101`은 **사라졌다.** 근본 원인(baseUrl 주입)이 없어졌기 때문이다. +TS 5.x에선 무해했지만 TS6에선 이 주입이 곧장 하드 에러다([tsup #1388](https://github.com/egoist/tsup/issues/1388), 재현 환경 +tsup 8.5.1 + TS 6.0.2). 게다가 tsup은 **이미 유지보수 중단** — README 최상단에 박혀 있다. -### 함정 ①: 출력 확장자가 다르다 +> "This project is not actively maintained anymore. Please consider using tsdown +> instead." — [egoist/tsup README](https://github.com/egoist/tsup/blob/main/README.md) -다만 공짜는 아니었다. tsdown은 `platform: 'node'`에서 기본적으로 **`.mjs` / `.cjs` / `.d.mts` / `.d.cts`** 확장자로 출력한다(`fixedExtension`). tsup의 `.js` / `.d.ts`와 달라서, `package.json`의 `exports`·`bin`·`main`·`types`가 존재하지 않는 파일을 가리키게 됐다. 실제 산출물에 맞춰 전부 정정해야 했다. +baseUrl 주입은 내 코드가 아니라 도구의 문제이고, 그 도구가 더는 고쳐지지 않으니 답은 정해져 있었다 — 후속 도구 [tsdown](https://tsdown.dev)으로 +갈아탔다. [Rolldown](https://rolldown.rs) 기반이라 **baseUrl을 주입하지 않고**, peer로 +`typescript: "^5.0.0 || ^6.0.0"`을 선언해 **TS6를 공식 지원**한다. 마이그레이션 도구(`npx tsdown-migrate`)로 config도 거의 +그대로 옮겨졌고, 전환 직후 `TS5101`은 사라졌다. -```jsonc -// @package/bundler/package.json — 실제 산출물에 맞게 수정 -{ - "exports": { - ".": { - "import": { "types": "./dist/index.d.mts", "default": "./dist/index.mjs" }, - "require": { "types": "./dist/index.d.cts", "default": "./dist/index.cjs" } - } - }, - "bin": { "minibundler": "./dist/cli.mjs" } // ← cli.js 가 아니라 cli.mjs -} -``` - -`@design-system/ui`도 마찬가지로 `main`/`types`를 `./dist/index.mjs`·`./dist/index.d.mts`로 맞췄다(이 정정은 별도 커밋으로 떨어졌다 — 산출물 확장자가 바뀌면 이런 메타데이터가 줄줄이 따라온다는 걸 잊지 말 것). +다만 공짜는 아니었다. 전환하며 밟은 함정 셋: -### 함정 ②: `platform: 'node'`은 `target`이 아니다 +- **출력 확장자.** tsdown은 `platform: 'node'`에서 `.mjs`/`.cjs`/`.d.mts`/`.d.cts`로 낸다(tsup은 `.js`/`.d.ts`). + `package.json`의 `exports`·`bin`·`main`·`types`를 산출물에 맞춰 전부 정정해야 했다. +- **`platform` ≠ `target`.** `platform: 'node'`는 의존성 외부화·출력 확장자 힌트일 뿐, ES 문법 타겟은 `target`이 따로 통제한다. + Node 24 문법까지 내리려면 `target: 'node24'`를 함께 명시. +- **빌드 도구는 `devDependencies`에.** `tsdown`·`typescript`는 런타임 의존성이 아니다. `@design-system/ui`가 `tsup`을 + `dependencies`에 두고 있어 뒤늦게 옮겼다. -tsup의 `target: 'node24'`를 옮기며 무심코 `platform: 'node'`로 바꿔 적었는데, 둘은 **다른 축**이다. `platform`은 "이 번들은 Node에서 돈다"는 힌트로 의존성 외부화·조건 해석·기본 출력 확장자(`fixedExtension`) 등에 영향을 주고, **ES 문법 트랜스파일 타겟**은 `target`이 따로 통제한다. `target`을 생략하면 tsdown 기본값이 쓰이므로, Node 24 문법까지 의도대로 내리려면 `target: 'node24'`를 **함께** 명시해야 한다. +- `pnpm build` (전체) → **9/9 통과** (blog 정적 빌드 포함) -### 함정 ③: 빌드 도구는 `devDependencies`에 +--- -tsup 시절 `tsup`이 `dependencies`에 들어가 있었고, tsdown으로 바꾸며 그 자리를 그대로 물려줬다. 하지만 빌드 도구(`tsdown`)와 `typescript`는 런타임 의존성이 아니라 **`devDependencies`**에 있어야 한다 — 안 그러면 패키지를 퍼블리시할 때 불필요한 의존성이 딸려간다. `@package/bundler`는 처음부터 devDependencies였는데 `@design-system/ui`만 `dependencies`에 남아 있어 뒤늦게 맞춰 옮겼다. +## 6. 정리: TypeScript 6 마이그레이션 체크리스트 -```jsonc -// @design-system/ui/package.json -{ - "dependencies": { - "@design-system/ui-lib": "workspace:^" // 런타임 의존성만 - }, - "devDependencies": { - "tsdown": "0.22.2", // ← 빌드 도구 - "typescript": "catalog:" // ← 빌드 타임 - // ... - } -} -``` +이번 삽질을 한 줄짜리 체크리스트로 압축하면(1~4는 앞에서 다룬 것, 5는 버전을 일괄 갱신할 때 함께 챙기는 곁가지다): -- `pnpm build` (전체) → **9/9 통과** (blog 정적 빌드 포함) +1. **`baseUrl`을 쓰는가?** → 직접 쓰면 제거(`paths`는 4.1부터 baseUrl 불필요). 빌드 도구가 주입한다면 도구를 점검하라. `TS5101`은 + baseUrl 전용이 아니라 **범용 deprecated-option 진단**임을 기억할 것. +2. **`emitDeclarationOnly`/`outDir`로 emit하는데 소스가 tsconfig보다 깊은가?** → `rootDir`을 명시. 에러 메시지의 `'{1}'`이 + 곧 넣어야 할 값이다. +3. **`@types/node` 같은 전역에 의존하는가?** → `types: ["node", ...]`로 명시. TS6 기본은 `[]`이고, 이는 **ambient 전역에만** + 영향을 준다(import 타입은 무관). 옛 동작은 `["*"]`. --- -## 6. 정리: TypeScript 6 마이그레이션 체크리스트 +## 7. 정작 가장 큰 수확: 6을 올리지 않아도 됐다 -이번 삽질을 한 줄짜리 체크리스트로 압축하면: +표의 #1~#3, 그리고 보너스까지 따라오며 깨달은 게 있다. 이 변화들의 공통점은 결국 '비싸거나 모호한 기본값을 걷어내는 청소'였다 — baseUrl·rootDir은 TS7(Go)을 향한 정리, types는 순수 성능 개선. 그리고 **그 청소는 6을 올려야만 할 수 있는 게 아니다.** +baseUrl·rootDir·types 셋 다 5에서도 오늘 당장 적용할 수 있는 모범 설정이다. 그러니 정작 올릴 첫 커밋은 "6 업그레이드"가 아니라 이 정리다. -1. **`baseUrl`을 쓰는가?** → 직접 쓰면 제거(`paths`는 4.1부터 baseUrl 불필요). 빌드 도구가 주입한다면 도구를 점검하라. `TS5101`은 baseUrl 전용이 아니라 **범용 deprecated-option 진단**임을 기억할 것. -2. **`@types/node` 같은 전역에 의존하는가?** → `types: ["node", ...]`로 명시. TS6 기본은 `[]`이고, 이는 **ambient 전역에만** 영향을 준다(import 타입은 무관). 옛 동작은 `["*"]`. -3. **`emitDeclarationOnly`/`outDir`로 emit하는데 소스가 tsconfig보다 깊은가?** → `rootDir`을 명시. 에러 메시지의 `'{1}'`이 곧 넣어야 할 값이다. -4. **dts 번들러가 tsup인가?** → tsup은 유지보수가 멈췄고 dts에 baseUrl을 주입한다. tsdown으로 전환하고(`npx tsdown-migrate`), ⓐ 출력 확장자(`.mjs`/`.d.mts`)에 맞춰 `exports`/`bin`/`main`/`types`를 고치고, ⓑ `platform`과 `target`은 다른 축이니 ES 타겟이 필요하면 `target`을 따로 명시하고, ⓒ 빌드 도구(`tsdown`)·`typescript`는 `devDependencies`에 두라. -5. **버전을 일괄 갱신했는가?** → 하위 패키지에 박힌 `packageManager`·`engines` 필드가 루트와 어긋나 있지 않은지 확인하라. (루트는 `pnpm@11.6.0`인데 한 패키지에 `pnpm@10.4.1`이 남아 있었다.) +**6을 안 올려도, 5에서 지금 할 일:** -곁가지 — 작업 중 node_modules와 빌드 산출물을 수없이 지웠던 터라, 외부 의존성 없이 macOS find로 도는 clean 스크립트를 루트에 넣어뒀다. 핵심은 -name node_modules -prune으로 node_modules 내부를 가지치기하는 것 — 안 그러면 의존성 안의 수많은 dist까지 매칭돼 느리고 위험하다. +- **`baseUrl` 제거** → `paths`만 남기거나, 런타임까지 일치하는 package.json `imports`로. +- **`rootDir` 고정** → 명시해 출력 레이아웃을 결정적으로 묶는다. +- **`types` 좁히기** → `types: ["node", ...]`로 명시해 자동 `@types` 열거 비용을 미리 던다. -```jsonc -"clean:dist": "find . -name node_modules -prune -o -type d '(' -name dist -o -name .next -o -name out -o -name .turbo ')' -prune -exec rm -rf {} +", -"clean:modules": "find . -name node_modules -type d -prune -exec rm -rf {} +" -``` +세 줄 다 5.x tsconfig에서 오늘 커밋할 수 있다. 6은 이걸 '강제'했을 뿐 '발명'한 게 아니다. -> 메이저 업그레이드는 버전 숫자 하나 바꾸는 일처럼 보여도, 그 숫자가 건드리는 **기본값과 빌드 파이프라인의 가정들**을 전부 다시 확인하게 만든다. 이번엔 그 가정이 `baseUrl`, `types`, `rootDir` 세 군데에 숨어 있었고, 마지막 하나는 TypeScript가 아니라 **유지보수가 멈춘 내 빌드 도구** 안에 있었다. +> 시작은 "이걸 왜 굳이 바꾸지?"라는 호기심 한 줄이었다. 세 옵션의 '왜'를 PR diff까지 따라가 보니, 답은 늘 같은 곳을 가리켰다 — Go로 다시 쓰인 7.0. +`baseUrl`·`types`·`rootDir`도, 마지막에 튀어나온 `tsup`도, 전부 그 길목을 미리 쓸어두는 일이었다. 그리고 가장 김빠지면서도 든든한 깨달음은 따로 +> 있었다. 이 길, 6을 올려야만 걸을 수 있는 게 아니다. 5에서 그대로, 그것도 빌드가 빨라지는 채로 갈 수 있다. 버전 숫자를 올리는 일과 더 나은 설정으로 가는 일은, 생각보다 자주 별개다. --- @@ -416,6 +493,7 @@ tsup 시절 `tsup`이 `dependencies`에 들어가 있었고, tsdown으로 바꾸 - TypeScript 6.0 릴리스 노트 — - baseUrl deprecation: [issue #62207](https://github.com/microsoft/TypeScript/issues/62207) · [PR #62509](https://github.com/microsoft/TypeScript/pull/62509) +- `baseUrl`/`paths`의 런타임 안전 대안 package.json `imports`: [TS 핸드북 Modules Reference](https://www.typescriptlang.org/docs/handbook/modules/reference.html) · [Node.js`imports` 필드](https://nodejs.org/api/packages.html) · [TS 4.7 릴리스 노트(imports 지원)](https://www.typescriptlang.org/docs/handbook/release-notes/typescript-4-7.html) - types 기본값 `[]`: [issue #62195](https://github.com/microsoft/TypeScript/issues/62195) · [PR #63054](https://github.com/microsoft/TypeScript/pull/63054) · [원 rationale #54500](https://github.com/microsoft/TypeScript/issues/54500) - rootDir 기본값: [issue #62194](https://github.com/microsoft/TypeScript/issues/62194) · [PR #62418](https://github.com/microsoft/TypeScript/pull/62418) - tsup: [README](https://github.com/egoist/tsup/blob/main/README.md) · [TS5101 이슈 #1388](https://github.com/egoist/tsup/issues/1388) · [TS6 지원 #1389](https://github.com/egoist/tsup/issues/1389) diff --git a/apps/blog/web/.gitignore b/apps/blog/web/.gitignore index 9fb704b9..c042dcf3 100644 --- a/apps/blog/web/.gitignore +++ b/apps/blog/web/.gitignore @@ -21,6 +21,7 @@ /public/rss.xml /public/sitemap.xml /public/search-index.json +/public/admin-posts-index.json /public/llms-full.txt # production diff --git a/apps/blog/web/public/admin-posts-index.json b/apps/blog/web/public/admin-posts-index.json deleted file mode 100644 index 49441155..00000000 --- a/apps/blog/web/public/admin-posts-index.json +++ /dev/null @@ -1,464 +0,0 @@ -[ - { - "slug": "typescript-domain-modeling-without-optional", - "title": "[Typescript로 설계하는 프로젝트] id?: string을 버려라. 상태가 다르면 타입도 달라야 한다.", - "date": "2026-03-16", - "excerpt": "1. 프롤로그: 우리가 숨 쉬듯 작성하는 \"거짓말\" 팀원: \"OO님, Post 타입에서 id가 없다고 에디터에서 빨간 줄이 뜨는데, 새 글 작성할 땐 서버 id가 없으니까 그냥 ? 붙여도 될까요?\" 프론트엔드 개발을 하다 보면 슬랙이나 코드 리뷰에서 심심치 않게 보게 되는 질문입니다. ...", - "tags": [ - "TypeScript", - "Architecture", - "Data Modeling" - ], - "series": "[Typescript로 설계하는 프로젝트]", - "status": "published", - "scheduledDate": null - }, - { - "slug": "no-one-asked-library-bundler-04-sourcemap", - "title": "[누가 시키지도 않았는데 번들러 만들기] 4. 소스맵: 번들된 코드에서 원본의 흔적 찾기", - "date": "2026-03-03", - "excerpt": "수천 줄로 합쳐진 번들 파일에서 에러가 나면 어떻게 원본 위치를 찾을까요? 소스맵의 VLQ 인코딩 원리와 magic-string이 변환 이력을 관리하는 방식을 직접 구현하며 이해합니다.", - "tags": [ - "bundler", - "javascript", - "sourcemap", - "vlq", - "magic-string", - "security" - ], - "series": "bundler", - "status": "scheduled", - "scheduledDate": "2026-03-03T09:00:00+09:00" - }, - { - "slug": "no-one-asked-library-bundler-03-bundling-scope", - "title": "[누가 시키지도 않았는데 번들러 만들기] 3. 번들링과 스코프: 파일 합치기의 기술", - "date": "2026-02-23", - "excerpt": "단순히 파일을 합치는 것과 번들링은 다릅니다. 변수 충돌을 막기 위한 함수 스코프 전략과 브라우저를 위한 런타임 구현 과정을 깊이 있게 다룹니다.", - "tags": [ - "bundler", - "javascript", - "iife", - "scope", - "cjs", - "ast" - ], - "series": "bundler", - "status": "published", - "scheduledDate": null - }, - { - "slug": "no-one-asked-library-bundler-02-ast-graph", - "title": "[누가 시키지도 않았는데 번들러 만들기] 2. 코드를 데이터로 보는 법 (AST Graph)", - "date": "2026-02-17", - "excerpt": "정규표현식으로 import 문을 찾으려다 실패한 경험, 그리고 AST 추상 구문 트리를 통해 코드를 데이터로 바라보는 관점을 소개합니다. 파일 시스템을 그래프로 변환하는 과정을 직접 구현해봅니다.", - "tags": [ - "bundler", - "javascript", - "ast", - "graph", - "dfs", - "algorithm" - ], - "series": "bundler", - "status": "published", - "scheduledDate": null - }, - { - "slug": "no-one-asked-library-bundler-00-prologue", - "title": "[누가 시키지도 않았는데 번들러 만들기] 0. 프롤로그: 질문이 구현이 되는 순간", - "date": "2026-02-02", - "excerpt": "수많은 소스 코드가 어떻게 하나의 결과물로 합쳐질까요? 당연하게 사용하던 번들러의 블랙박스를 열고, 직접 손으로 구현하며 그 답을 찾아봅니다.", - "tags": [ - "bundler", - "library", - "build", - "tooling", - "javascript", - "architecture" - ], - "series": "bundler", - "status": "published", - "scheduledDate": null - }, - { - "slug": "no-one-asked-library-bundler-01-concept", - "title": "[누가 시키지도 않았는데 번들러 만들기] 1. 개념과 도구: 번들러의 등장 배경", - "date": "2026-02-02", - "excerpt": "코드를 번들링해야 하는 기술적 이유와 자바스크립트 모듈 시스템의 발전 과정을 살펴봅니다. 또한 번들러 구현의 기반이 되는 Magic String 라이브러리를 소개합니다.", - "tags": [ - "bundler", - "javascript", - "history", - "esm", - "magic-string" - ], - "series": "bundler", - "status": "published", - "scheduledDate": null - }, - { - "slug": "payment-system-architecture", - "title": "결제 시스템 리팩토링: if문 지옥에서 디자인 패턴(Factory, Adapter)으로 탈출하기", - "date": "2026-01-24", - "excerpt": "\"만약 내일 당장 '프랑스'가 서비스 국가로 추가된다면? 그리고 한국에서 '사업자 유형'에 따라 결제 수단이 달라져야 한다면?\" 이 질문들이 우리 팀의 결제 시스템 설계를 완전히 바꿔놓았습니다. --- 🎭 Prologue: \"팀장님의 무리한(?) 요구\" 어느 날 오후, 코드 리뷰 중에...", - "tags": [ - "TypeScript", - "Design Pattern", - "Refactoring", - "Architecture", - "Zod", - "Clean Code" - ], - "series": "아키텍처", - "status": "published", - "scheduledDate": null - }, - { - "slug": "2025-retrospect", - "title": "2025 회고: 글 33개, PR 38회, 발표 3회로 '보여주는 개발자'가 되다", - "date": "2025-12-21", - "excerpt": "2025 회고: 글 33개, PR 38회, 발표 3회로 '보여주는 개발자'가 되다 2025년은 \"숨어서 잘하는 개발자\" 에서 \"보여주고 나누는 개발자\" 로 방향을 튼 해였습니다. 시작점은 2024년 12월 23일, 한 개발자의 피드백이었습니다. \"평소 기술 관련 답변을 잘 해주시는데, ...", - "tags": [], - "series": "회고/2025", - "status": "published", - "scheduledDate": null - }, - { - "slug": "2025-teoconf-presentation", - "title": "2025 Teoconf 발표 후기", - "date": "2025-12-11", - "excerpt": "🎤 발표 후기: ‘내가 하고 싶은 말’ 대신 ‘청중이 가져갈 한 문장’을 찾기까지 이번 테오콘 발표는 내게 하나의 도전이었다. 디자인 시스템 배포라는, 나에게 깊은 경험이지만 청중에게는 다소 생소할 수 있는 주제를 어떻게 풀어낼 것인가. 그리고 그 과정에서 “발표는 기술을 나열하는 자...", - "tags": [], - "series": "회고/2025", - "status": "published", - "scheduledDate": null - }, - { - "slug": "npm-deploy-series-0-prologue", - "title": "하루 만에 끝날 줄 알았던 디자인 시스템 배포가 3주 걸린 이유", - "date": "2025-11-30", - "excerpt": "하루 만에 끝날 줄 알았던 NPM 배포가 3주 걸린 이유 프롤로그: \"별거 아니겠지\"의 착각 타팀 기획자님이 내 자리로 다가왔다. \"개발자님, 저희 팀에서도 그 디자인 시스템 쓸 수 있을까요? 버튼이랑 인풋 컴포넌트가 정말 예쁘던데...\" 당시 디자인 시스템은 완성해둔 참이었다. 모노레...", - "tags": [ - "NPM", - "디자인 시스템", - "배포", - "Panda CSS", - "tsup", - "빌드" - ], - "series": "design-system-lib-deploy", - "status": "published", - "scheduledDate": null - }, - { - "slug": "typescript-project-type-safe-refactor-with-typescript", - "title": "[Typescript로 설계하는 프로젝트] 타입 한 줄로 552개 파일을 2주 만에 안전하게 수정한 방법", - "date": "2025-11-06", - "excerpt": "\"회원 구조가 바뀌었습니다. 552개 파일을 수정해야 합니다.\" 보통은 이렇게 됩니다 - 어디를 수정해야 하는지 찾느라 1주 - 수정하다가 놓친 곳 때문에 버그 발생 - 회귀 테스트에 또 1주 - QA에서 엣지 케이스 발견 - 결국 한 달... 하지만 우리는 2주 만에, 사이드 이펙트 ...", - "tags": [], - "series": "[Typescript로 설계하는 프로젝트]", - "status": "published", - "scheduledDate": null - }, - { - "slug": "react-error-deign", - "title": "React 에러 구조 설계: throw만으로 선언적 에러 핸들링 하기", - "date": "2025-10-14", - "excerpt": "0. 프롤로그 throw를 보던중 문득 깨달았다. typescript try { throw new Error('error'); } catch (error) { throw error; // 상위로 전파된다 } 에러는 계층을 따라 전파된다. 그럼 에러 클래스도 계층 구조로 설계할 수 있지 ...", - "tags": [], - "series": "우아하게 에러 핸들링 하기", - "status": "published", - "scheduledDate": null - }, - { - "slug": "ai-opensource-contribution", - "title": "구글 개발자가 인정한 성능 최적화 기여 후기 - AI와 함께한 gemini-cli 오픈소스 여정", - "date": "2025-09-07", - "excerpt": "구글 개발자에게 인정받은 성능 최적화: gemini-cli 기여 후기 이 글을 읽고 나면 - AI와 협업해서 오픈소스 기여하는 방법을 알게 됩니다 - 성능 최적화 PR이 더 좋은 평가를 받는 이유를 이해합니다 - 구글같은 대기업 프로젝트에 기여하는 전략을 배웁니다 - 실제 머지된 PR ...", - "tags": [], - "series": "open-source", - "status": "published", - "scheduledDate": null - }, - { - "slug": "feconf-2025-lightning-speaker", - "title": "feconf 2025 발표 후기", - "date": "2025-09-02", - "excerpt": "시작 마이크를 잡는 순간 정말 내가 말해도 되는건가? 나 해도되는건가? 이 생각이 든 건 몇 달 전부터였다. 어느 모임에서 feconf 2024 라이트닝 연사자 분을 만나게 되었다. 재미있게 이야기 하던 도중 나에게 날아든 질문이 있었다. \"상욱님도 이거 잘 하실 수 있을 것 같아요! ...", - "tags": [], - "series": "feconf", - "status": "published", - "scheduledDate": null - }, - { - "slug": "nodejs-contribution", - "title": "4시간 만에 Node.js PR 승인받기", - "date": "2025-08-10", - "excerpt": "4시간 만에 Node.js PR 승인받기 AI 페어 프로그래밍으로 오픈소스 진입 장벽 낮추기 “무조건 Node.js에 내 코드를 넣고 말겠다!” 오픈소스 기여가 점점 재미있어지고, 자신감도 차오르던 시기였다. 하지만 매일같이 이슈를 살펴봐도 여전히 모르는 부분이 많았다. C++ 코드가 ...", - "tags": [], - "series": "open-source", - "status": "published", - "scheduledDate": null - }, - { - "slug": "nextjs-contributor", - "title": "Next.js 오픈소스 기여: 2번 실패 후 성공 후기", - "date": "2025-08-05", - "excerpt": "Next.js 오픈소스 기여: 2번 실패 후 성공 후기 들어가며 \"띠링\" 새벽 2시, 내 핸드폰에 또 다른 GitHub 알림이 울렸다. 발신자: vercel/next.js [[제목] : Re:[vercel/next.js] Fix remote pattern (80294) (PR 80428...", - "tags": [], - "series": "open-source", - "status": "published", - "scheduledDate": null - }, - { - "slug": "react-component-context-api-dropzone", - "title": "Context API를 활용한 DropZone 구현하기", - "date": "2025-07-30", - "excerpt": "[React Component] Context API를 활용한 DropZone 구현하기 평소처럼 Mantine의 Dropzone을 사용하고 있었는데, 문득 내부 구현이 궁금해졌습니다. 어떻게 이렇게 깔끔한 API를 제공할 수 있을까? tsx // Mantine Dropzone 사용 중....", - "tags": [], - "series": "react/component", - "status": "published", - "scheduledDate": null - }, - { - "slug": "react-component-type-guard-button", - "title": "Type Guard를 활용한 여러가지 Button 구현하기", - "date": "2025-06-30", - "excerpt": "[React Component] Type Guard를 활용한 여러가지 Button 구현하기 드디어 디자인 시스템 프로젝트가 시작되었습니다! 팀에서 가장 먼저 구현하기로 한 컴포넌트는 Button입니다. 디자인 시스템에서 Button은 특별합니다. 가장 많이 사용되고, 가장 다양한 형태를...", - "tags": [], - "series": "react/component", - "status": "published", - "scheduledDate": null - }, - { - "slug": "react-component-toast", - "title": "15줄에서 2줄로: useSyncExternalStore 기반 React Toast 시스템 설계법", - "date": "2025-06-30", - "excerpt": "들어가며 안녕하세요, 여러분! 프론트엔드 개발자라면 누구나 한 번쯤 만들어보는 토스트 UI. 간단해 보이지만, 막상 구현하다 보면 \"이걸 어떻게 앱 어디서든 쉽게 호출하지?\", \"종류별로 다른 스타일은 어떻게 관리하지?\" 같은 고민에 빠지게 됩니다. 최근 mantine의 토스트 컴포넌트...", - "tags": [], - "series": "react/component", - "status": "published", - "scheduledDate": null - }, - { - "slug": "2025-mid-year-review", - "title": "개발자에서 기여자로: 기술 블로그와 오픈소스가 바꾼 나의 2025년 상반기", - "date": "2025-06-17", - "excerpt": "들어가며 2025년 상반기를 돌아보니, 가장 크게 변화한 것은 \"나만 알고 있던 지식을 세상과 나누기 시작 했다\"는 점이다. 그동안 개발자로서 쌓아온 경험들과 고민을 글로 정리하고, 오픈소스에 기여 하면서 진정한 의미의 성장을 경험한 상반기였다. --- 🌟 기술 블로그: 지식 공유의 ...", - "tags": [], - "series": "회고/2025", - "status": "published", - "scheduledDate": null - }, - { - "slug": "typescript-project-domain-design", - "title": "[Typescript로 설계하는 프로젝트] \"같은 로직 또 복사했어요?\" Domain 모델로 책임 분리하기", - "date": "2025-06-15", - "excerpt": "들어가며 오후 4시, 또 다시 울리는 슬랙 알림... [기획자]: \"저번에 만든 사용자 상태 로직 잘 쓰고 있어요! 그런데 이번엔 사용자가 특정 액션을 할 수 있는지 검증하는 로직도 추가해 주세요.\" [나]: \"네, 어떤 검증 로직인가요?\" [기획자]: \"사용자가 글을 작성할 수 있는지...", - "tags": [], - "series": "[Typescript로 설계하는 프로젝트]", - "status": "published", - "scheduledDate": null - }, - { - "slug": "typescript-project-service-design", - "title": "[Typescript로 설계하는 프로젝트] \"원래 있던 기능이니 금방 하시죠?\" 당하지 않는 Service Layer 설계 전략", - "date": "2025-06-08", - "excerpt": "들어가며 겨우 백엔드의 요청을 쳐내고 다시 피곤함에전 모습으로 테스크를 마무리하려는 순간 이번엔 제 슬랙이 울립니다. [기획자]: \"급하게 기획이 바뀌어서요. 이거 프론트에서 추가로 수정해 주셔야 할 것 같아요. 아직 마무리 다 안 하신 거 맞죠?\" [나]: \"네 아직 마무리 안 했어요...", - "tags": [], - "series": "[Typescript로 설계하는 프로젝트]", - "status": "published", - "scheduledDate": null - }, - { - "slug": "typescript-project-service-di-design", - "title": "[Typescript로 설계하는 프로젝트] \"원래 있던 기능이니 금방 하시죠?\" 당하지 않는 Service Layer 설계 전략 With Di", - "date": "2025-06-08", - "excerpt": "들어가며 겨우 백엔드의 요청을 쳐내고 다시 피곤함에전 모습으로 테스크를 마무리하려는 순간 이번엔 제 슬랙이 울립니다. [기획자]: \"급하게 기획이 바뀌어서요. 이거 프론트에서 추가로 수정해 주셔야 할 것 같아요. 아직 마무리 다 안 하신 거 맞죠?\" [나]: \"네 아직 마무리 안 했어요...", - "tags": [], - "series": "[Typescript로 설계하는 프로젝트]", - "status": "published", - "scheduledDate": null - }, - { - "slug": "typescript-project-api-design", - "title": "[Typescript로 설계하는 프로젝트] Type 설계의 시작: 견고한 서버 API Type 설계하기", - "date": "2025-06-01", - "excerpt": "들어가며 피곤에 쩔은 모습으로 하나의 테스크를 마무리 하는 순간 백엔드 개발자가 다가옵니다. \"죄송하지만 이번에 만든 api 중에 하나의 리스폰스가 변동 될 것 같습니다.\" \"User로 정의한 스키마 중에 name이 빠질 것 같아요.\" 순간 머릿속에서 여기저기 대강 흩어져 있는 User...", - "tags": [], - "series": "[Typescript로 설계하는 프로젝트]", - "status": "published", - "scheduledDate": null - }, - { - "slug": "typescript-project-api-di-design", - "title": "[Typescript로 설계하는 프로젝트] Type 설계의 시작: 견고한 서버 API Type 설계하기 With DI", - "date": "2025-06-01", - "excerpt": "들어가며 피곤에 쩔은 모습으로 하나의 테스크를 마무리 하는 순간 백엔드 개발자가 다가옵니다. \"죄송하지만 이번에 만든 api 중에 하나의 리스폰스가 변동 될 것 같습니다.\" \"User로 정의한 스키마 중에 name이 빠질 것 같아요.\" 순간 머릿속에서 여기저기 대강 흩어져 있는 User...", - "tags": [], - "series": "[Typescript로 설계하는 프로젝트]", - "status": "published", - "scheduledDate": null - }, - { - "slug": "typescript-project-design", - "title": "[Typescript로 설계하는 프로젝트] 당신의 Type, 어디까지 연결되어 있나요?", - "date": "2025-05-05", - "excerpt": "[타입으로 설계하는 프로젝트] 당신의 Type, 어디까지 연결되어 있나요? 들어가며 플래닝이 끝나고 난 뒤 저에게 주어진 테스크를 살펴 봅니다. \"뭐시기 뭐시기 기능 수정 및 삭제\" 큰 기능 아니니 그냥 지워도 될거라는 저의 생각은 조만간 큰 버그를 가져왔습니다. 테스트 서버에 올려보니...", - "tags": [], - "series": "[Typescript로 설계하는 프로젝트]", - "status": "published", - "scheduledDate": null - }, - { - "slug": "pnpm-eslint-not-working", - "title": "pnpm 10 업그레이드 후 ESLint 설정이 사라졌어요?! (feat. 호이스팅)", - "date": "2025-05-01", - "excerpt": "0. 프롤로그 pnpm 9 → 10 업그레이드 후 monorepo에서 eslint가 동작하지 않는 문제를 겪은 경험을 공유 합니다. --- 1. 상황설명 저희 회사는 pnpm을 패키지 매니저로 사용하고 있습니다. 최근 pnpm 9에서 pnpm 10으로 업그레이드를 진행했습니다. 업그레이...", - "tags": [], - "series": null, - "status": "published", - "scheduledDate": null - }, - { - "slug": "first-open-source-contribution", - "title": "오픈소스 첫 발걸음: Mantine PR 기여 후기", - "date": "2025-04-22", - "excerpt": "0. 프롤로그 \"내가 자주 사용하는 오픈 소스에 기여 해보고 싶다.\" 늘 마음 한켠에 있었지만, 다른 사람의 기여 후기를 보거나 기여 방법을 보아도 막막 했습니다. 그러던 중, 회사 프로젝트에서 Slider를 구현해야 할 일이 생겼습니다. 디자이너님께서 전달해주신 피그마 시안을 보자마자...", - "tags": [], - "series": "open-source", - "status": "published", - "scheduledDate": null - }, - { - "slug": "aws-ecs-rollback", - "title": "🚨3분만에 Next.js 서비스 장애 대응 대응하기: GitHub Actions + AWS ECS + CodeDeploy 롤백 시스템", - "date": "2025-04-03", - "excerpt": "0. 프롤로그 이번글에서는 지난번까지 구축한 ECS 환경에서, 오류 발생 시 애플리케이션을 안정적으로 롤백하는 방법에 대해 다뤄보겠습니다. 특히 GitHub Actions, AWS CodeDeploy를 활용하여 자동화된 배포 및 롤백 프로세스를 구현하는 방법을 중심으로 설명드릴 예정입니...", - "tags": [], - "series": "nextjs deploy", - "status": "published", - "scheduledDate": null - }, - { - "slug": "next-js-ecs-deploy", - "title": "ecs와 code deploy를 활용한 next.js 배포하기", - "date": "2025-04-01", - "excerpt": "0. 프롤로그 이전에는 next.js의 standalone과 turborepo의 --docker 옵션을 활용한 도커라이징 하는 방법을 정리하였습니다. 이번에는 aws의 ecs를 통해 컨테이너관리를 하고 code deploy를 통해 배포 하는 방법에 대해서 정리 해보겠습니다. --- 1....", - "tags": [], - "series": "nextjs deploy", - "status": "published", - "scheduledDate": null - }, - { - "slug": "turborepo-next.js-docker", - "title": "Turborepo 에서 next.js 도커라이징 하기", - "date": "2025-03-22", - "excerpt": "0. 프롤로그 이 글은 뮤즈라이브에서 웹 서비스 3개를 모노레포 구조에서 Turborepo의 공식 문서를 참조하여 Next.js의 standalone 모드와 Docker를 활용한 모노레포 환경 설정 및 최적화 방법을 설명합니다. 목표 - Next.js standalone 모드 설명 - ...", - "tags": [], - "series": "nextjs deploy", - "status": "published", - "scheduledDate": null - }, - { - "slug": "nextjs-error", - "title": "Next.js로 우아하게 Error 핸들링하기", - "date": "2025-03-03", - "excerpt": "0. 프롤로그 이전까지는 javascript의 try catch과 React의 ErrorBoundary로 error 객체의 전파와 핸들링하는 방법에 대해 알아 보았습니다.
- isValidElement(node) && typeof node.type !== 'string'; + p({ children, node: _node, ...props }) { const hasBlockChild = Array.isArray(children) - ? children.some(isBlockElement) - : isBlockElement(children); + ? children.some(isBlockMarkdownChild) + : isBlockMarkdownChild(children); if (hasBlockChild) { return
{children}
; } @@ -284,7 +283,7 @@ export default function PostClient({ /> ); }, - table({ children, ...props }) { + table({ children, node: _node, ...props }) { return ( ); }, - li({ className, children, ...props }) { + li({ className, children, node: _node, ...props }) { const isTaskList = className?.includes('task-list-item'); if (isTaskList) { const childrenArray = Children.toArray(children); diff --git a/apps/blog/web/src/app/posts/[...slug]/markdownBlocks.test.ts b/apps/blog/web/src/app/posts/[...slug]/markdownBlocks.test.ts new file mode 100644 index 00000000..3c2e854b --- /dev/null +++ b/apps/blog/web/src/app/posts/[...slug]/markdownBlocks.test.ts @@ -0,0 +1,89 @@ +import { createElement } from 'react'; +import { describe, expect, test } from 'vitest'; + +import { isBlockMarkdownChild } from './markdownBlocks'; +import { Callout } from '@/src/components/post/markdown/Callout'; +import { Figure } from '@/src/components/post/markdown/Figure'; +import { FileTree } from '@/src/components/post/markdown/FileTree'; + +// react-markdown이

로 감싼 자식을 isBlockMarkdownChild가 어떻게 분류하는지 검증. +// true면 PostClient가

로 교체해 무효 중첩(

) → hydration +// mismatch를 막는다. 각 케이스는 실제 렌더 시 child가 갖는 type/props 형태를 흉내낸다. +describe('isBlockMarkdownChild', () => { + describe('직접 매핑된 커스텀 블록 컴포넌트는 identity로 블록 판정', () => { + test('Callout → block', () => { + expect(isBlockMarkdownChild(createElement(Callout))).toBe(true); + }); + test('Figure → block', () => { + expect(isBlockMarkdownChild(createElement(Figure))).toBe(true); + }); + test('FileTree → block', () => { + expect(isBlockMarkdownChild(createElement(FileTree))).toBe(true); + }); + test('Set에 없는 커스텀 컴포넌트는 블록 아님', () => { + const Inline = () => null; + expect(isBlockMarkdownChild(createElement(Inline))).toBe(false); + }); + }); + + // 이미지는 closure(relativeDir) 때문에 인라인 래퍼로 매핑돼 child.type이 래퍼 + // 함수다(MarkdownImage identity로는 못 잡는다). MarkdownImage가 의 블록 + //
를 렌더하므로 공개 prop src로 블록 판정한다. + describe('이미지: src 공개 prop으로 블록 판정', () => { + test('네이티브 img 자식(src 보유) → block', () => { + expect( + isBlockMarkdownChild(createElement('img', { src: '/a.png' })), + ).toBe(true); + }); + test('인라인 래퍼 함수 타입 + src → block (실제 렌더 형태)', () => { + const ImgRenderer = (_props: { src?: string }) => null; + expect( + isBlockMarkdownChild(createElement(ImgRenderer, { src: '/a.png' })), + ).toBe(true); + }); + test('src 없으면 블록 아님', () => { + expect(isBlockMarkdownChild(createElement('img', {}))).toBe(false); + }); + }); + + // 인라인 code와 fenced code는 같은 code 핸들러(동일 identity)라 className으로만 + // 구분된다. fenced(language-*)는 블록
, 인라인 backtick은 phrasing. + describe('코드: language-* className만 fenced(block)', () => { + test('fenced code (language-ts) → block', () => { + expect( + isBlockMarkdownChild( + createElement('code', { className: 'language-ts' }), + ), + ).toBe(true); + }); + test('인라인 code (className 없음) → 블록 아님', () => { + expect(isBlockMarkdownChild(createElement('code', {}))).toBe(false); + }); + test('language- 접두 아닌 className → 블록 아님', () => { + expect( + isBlockMarkdownChild( + createElement('code', { className: 'hljs token' }), + ), + ).toBe(false); + }); + }); + + describe('phrasing 콘텐츠·비요소는 블록 아님', () => { + test('인라인 네이티브 요소(strong, a)', () => { + expect(isBlockMarkdownChild(createElement('strong'))).toBe(false); + expect(isBlockMarkdownChild(createElement('a', { href: '/x' }))).toBe( + false, + ); + }); + test('문자열 텍스트 노드', () => { + expect(isBlockMarkdownChild('그냥 텍스트')).toBe(false); + }); + test('숫자', () => { + expect(isBlockMarkdownChild(42)).toBe(false); + }); + test('null / undefined', () => { + expect(isBlockMarkdownChild(null)).toBe(false); + expect(isBlockMarkdownChild(undefined)).toBe(false); + }); + }); +}); diff --git a/apps/blog/web/src/app/posts/[...slug]/markdownBlocks.ts b/apps/blog/web/src/app/posts/[...slug]/markdownBlocks.ts new file mode 100644 index 00000000..687c9960 --- /dev/null +++ b/apps/blog/web/src/app/posts/[...slug]/markdownBlocks.ts @@ -0,0 +1,26 @@ +import { isValidElement, type ElementType } from 'react'; + +import { Callout } from '@/src/components/post/markdown/Callout'; +import { Figure } from '@/src/components/post/markdown/Figure'; +import { FileTree } from '@/src/components/post/markdown/FileTree'; + +// 직접 매핑돼(`callout: Callout`) child.type으로 식별 가능한 블록 컴포넌트. +const BLOCK_MARKDOWN_COMPONENTS = new Set([ + Callout, + Figure, + FileTree, +]); + +//

안에 블록 요소가 들어가면 무효 중첩(

)이 되어 브라우저가 +//

를 조기 종료 → hydration mismatch. 직접 매핑된 블록은 identity로, 인라인 +// 래퍼(img/code)를 거치는 것은 공개 prop으로 식별한다(react-markdown 내부 node 비의존). +export function isBlockMarkdownChild(child: unknown): boolean { + if (!isValidElement<{ className?: string; src?: string }>(child)) + return false; + if (BLOCK_MARKDOWN_COMPONENTS.has(child.type as ElementType)) return true; + const { className, src } = child.props; + // img → MarkdownImage가 의 블록

를 렌더하므로

에 둘 수 없다. + if (typeof src === 'string') return true; + // 인라인/fenced code는 같은 핸들러라 language-* className으로만 구분된다. + return typeof className === 'string' && /\blanguage-/.test(className); +}