exports 한 줄, except 한 줄, 그리고 내가 닫은 PR 하나
NestJS TypeORM이 CommonJS에서 require되지 않던 문제와 Trail of Bits 스킬의 손상된 캐시를 각각 한 줄로 고쳤습니다. 테스트가 82개 통과했는데도 전제가 틀려서 직접 닫은 PR까지, 코드보다 검증이 컸던 기록입니다.
지난 며칠 동안 오픈소스 저장소 두 곳에 PR 세 개를 열었습니다. 병합된 두 건의 핵심 수정을 합치면 이렇습니다.
"exports": {
".": {
"types": "./dist/index.d.ts",
- "import": "./dist/index.js"
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
},
- except (json.JSONDecodeError, OSError):
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError):
한 줄씩입니다. 그런데 두 줄을 쓰기까지 한 일은 TypeORM 말고 @nestjs/* 형제 패키지 열세 개를 같은 트리에 설치해서 비교하고, 예외 계층을 따라가고, 잘못된 바이트를 직접 캐시에 심어보는 것이었습니다. 그리고 세 번째 PR은 테스트가 82개 통과했는데도 제가 직접 닫았습니다. 전제가 틀렸거든요.
require()가 패키지에 도달하지 못한 경우
@nestjs/typeorm@12.0.0을 설치하고 CommonJS에서 불러오면 이렇게 됩니다.
Error [ERR_PACKAGE_PATH_NOT_EXPORTED]: No "exports" main defined in
/.../node_modules/@nestjs/typeorm/package.json
at exportsNotFound (node:internal/modules/esm/resolve:314:10)
at packageExportsResolve (node:internal/modules/esm/resolve:605:13)
at resolveExports (node:internal/modules/cjs/loader:707:36)
code: 'ERR_PACKAGE_PATH_NOT_EXPORTED'
nest new로 만든 기본 프로젝트는 CommonJS로 컴파일됩니다. 기본 설정을 그대로 쓰는 쪽이 막히는 상황이었죠. v12.0.0 릴리스 노트는 반대로 말하고 있었습니다. "앱을 ESM으로 바꿀 필요가 없다, Node의 require(esm) 덕분에 CJS 앱도 그대로 동작한다"고요.
먼저 배제해야 할 가설은 빌드 결과물 쪽이었습니다. 패키지가 "type": "module"이니 ESM으로 나온 파일을 CJS가 못 읽는 거라면, 같은 설정을 쓰는 형제 패키지도 똑같이 막혀야 합니다.
스택은 해석 단계를 가리켰다
스택을 보면 resolveExports에서 끝납니다. 모듈 실행에 앞서 파일 경로를 정하는 단계입니다.
Node는 require()를 해석할 때 exports 맵에서 require 조건을 먼저 찾고, 없으면 default로 내려갑니다. 12.0.0의 맵에는 types와 import밖에 없었습니다. 둘 다 require 해석이 매칭할 수 없는 키입니다.
여기에 규칙이 하나 더 겹칩니다. exports가 존재하면 main은 아예 참조되지 않습니다. 그래서 "main": "./dist/index.js"가 멀쩡히 있는데도 폴백이 되지 않았습니다.
require(esm)은 Node 20.19.0부터 CJS가 ESM 모듈을 require할 수 있게 해주는 기능입니다. 릴리스 노트가 근거로 든 그 기능이 맞습니다. 다만 그건 모듈을 로드하는 단계의 기능이고, 이 실패는 그 앞의 해석 단계에서 났습니다. 도달할 수 있는 조건이 없으니 일할 차례가 오지 않았던 거죠.
비교로 경계를 확인했다
가설이 맞는지 보려면 ""type": "module"이면 다 깨지는가"를 확인해야 했습니다. 그래서 @nestjs/* 형제 패키지 열세 개를 TypeORM과 함께 한 트리에 설치하고 Node v24.18.1에서 전부 require해봤습니다. 열두 개는 v12 라인이고 @nestjs/graphql만 v14입니다.
OK @nestjs/common 114 exports
OK @nestjs/core 31 exports
OK @nestjs/config 6 exports
OK @nestjs/mongoose 15 exports
FAIL @nestjs/typeorm ERR_PACKAGE_PATH_NOT_EXPORTED
전부 "type": "module"이었습니다. common과 core는 조건 없이 문자열 하나("./index.js")를, config와 mongoose는 default를 가지고 있었습니다. exports 조건 맵의 모양은 패키지마다 달랐습니다.
default를 고른 이유
수정은 조건 하나 추가입니다. require를 넣어도 CJS는 해결됩니다. default는 import와 require 양쪽 해석에 모두 매칭하고, 이미 @nestjs/config와 @nestjs/jwt가 쓰고 있는 형태이기도 합니다. 같은 조직의 패키지들이 서로 다른 모양을 하고 있으면 다음 사람이 또 헷갈립니다.
수정 전후를 가르는 확인은 이 두 줄이었습니다.
node -e "require('@nestjs/typeorm')" # 전: ERR_PACKAGE_PATH_NOT_EXPORTED / 후: ok
node --input-type=module -e "import('@nestjs/typeorm')" # 전후 동일하게 ok
둘 다 같은 ./dist/index.js로 해석되고 같은 15개 named export를 내놓습니다. ESM 소비자 입장에서 바뀐 게 없다는 걸 보여야 "그냥 조건 하나 더 넣자"는 제안이 안전해집니다.
PR #2685는 2026-08-28에 병합됐습니다(merge commit b36b36c90a139db2bea927b4539b70497f346ee3). diff는 파일 하나, +2/-1입니다.
병합 시점에는 릴리스 여부를 몰랐는데, 지금 npm을 확인해보면 이렇게 나옵니다.
$ npm view @nestjs/typeorm version
12.0.1
$ npm view @nestjs/typeorm@12.0.1 exports --json
{
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js",
"default": "./dist/index.js"
},
"./package.json": "./package.json"
}
$ npm view @nestjs/typeorm@12.0.1 gitHead
b36b36c90a139db2bea927b4539b70497f346ee3
마지막 줄의 해시는 #2685의 merge commit과 같은 값입니다. npm에 올라간 12.0.1 tarball이 그 커밋에서 나왔다는 뜻이죠. 머지가 2026-08-28T07:18:57Z, 12.0.1 배포가 2026-08-28T07:21:51.299Z. 약 2분 54초 뒤입니다.
여기까지가 제가 확인한 전부입니다. 배포된 12.0.1이 그 머지 커밋이라는 것. 이 때문에 막혀 있던 사람이 몇 명인지, 릴리스 이후 무엇이 달라졌는지는 저한테 데이터가 없습니다.
이 저장소의 CI는 소스를 빌드하고 테스트합니다. 패킹된 패키지를 CJS/ESM 두 소비자 모드로 불러보는 검사는 CI에 없습니다. 그래서 같은 회귀가 다시 나도 CI는 초록색일 겁니다. npm pack 후 격리된 fixture 두 개에서 require와 import를 검사하는 스모크 테스트를 후속 PR로 내도 되겠냐고 댓글로 물어뒀는데 아직 답은 없습니다.
손상된 캐시가 스스로 회복되지 않았다
두 번째는 trailofbits/skills의 supply-chain-risk-auditor 스킬입니다. 의존성 취약점을 조회할 때 HTTP 응답을 디스크에 캐싱하는데, 한 번 실패한 뒤부터 매 실행 같은 traceback이 나오고 스스로 회복하지 못합니다.
캐시 읽기는 이렇게 생겼습니다.
def _read_cache(self, path: Path) -> CacheEntry | None:
if not path.exists():
return None
try:
stored = json.loads(path.read_text(encoding="utf-8"), strict=False)
except (json.JSONDecodeError, OSError):
# A run interrupted mid-write leaves a truncated file that would otherwise
# crash every later run. Drop it and refetch.
self.stats["errors"] += 1
path.unlink(missing_ok=True)
return None
손상 대비는 이미 있습니다. 주석까지 정확하고, 회복 경로도 설계되어 있었습니다. 그 회복 경로로 들어가려면 저 except 튜플이 예외를 받아야 합니다.
한 줄에 두 단계가 들어 있었다
path.read_text(encoding="utf-8")는 bytes를 str로 바꿉니다. json.loads는 str을 파싱합니다. 한 줄이지만 단계가 둘이고, 실패 지점도 둘입니다.
캐시 파일에 UTF-8로 디코드할 수 없는 바이트가 들어 있으면 read_text에서 UnicodeDecodeError가 납니다. JSON 파서는 호출조차 되지 않습니다. UnicodeDecodeError와 json.JSONDecodeError는 둘 다 ValueError의 하위지만 서로 형제라서, 한쪽을 잡는 except가 다른 쪽을 잡지 않습니다. 파일을 여는 데는 성공했으니 OSError로도 걸러지지 않습니다.
그래서 예외가 회복 경로를 그대로 지나쳐 raw traceback으로 올라옵니다. 그리고 path.unlink()가 실행되지 않으니 손상된 파일이 남고, 한 번 실패로 끝나지 않습니다. 다음 실행도 path.exists()를 통과하고, 같은 바이트를 읽고, 같은 지점에서 죽습니다. 사용자가 캐시 디렉터리를 손으로 지우기 전까지 그 URL에 대한 조회는 영구히 실패합니다.
원래 이 캐시는 실패해도 조회를 못 할 뿐이어야 합니다. 온라인이면 다시 받아오고, 오프라인이면 Unavailable로 떨어져서 "이 항목은 확인 못 했다"고 보고하면 되니까요. 그 정상적인 열화 경로가 예외 하나 때문에 닫혀 있었습니다.
수정과 테스트
수정은 튜플에 이름 하나 추가입니다. 디코드 실패를 손상으로 분류하면 기존 회복 로직이 그대로 동작합니다.
- except (json.JSONDecodeError, OSError):
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError):
수정 전후를 가르는 테스트는 이겁니다.
def test_non_utf8_cache_entry_is_dropped(tmp_path: Path):
"""Undecodable cache bytes are corruption, not a permanent client crash."""
http = Http(tmp_path, offline=True)
path = seed(http, "GET", "https://example.com/x", {"ok": 1})
path.write_bytes(b"\xff")
with pytest.raises(Unavailable):
http.get_json("https://example.com/x")
assert http.stats["errors"] == 1
assert not path.exists()
b"\xff"는 어떤 UTF-8 시퀀스로도 해석되지 않는 바이트입니다. 수정 전 소스에 이 테스트만 얹으면 이렇게 됩니다.
FAILED test_sources.py::test_non_utf8_cache_entry_is_dropped
UnicodeDecodeError: 'utf-8' codec can't decode byte 0xff in position 0: invalid start byte
1 failed, 104 passed
예외 이름 하나를 튜플에 넣으면 105 passed가 됩니다.
기존 테스트 중에 test_truncated_cache_entry_is_dropped가 이미 있었습니다. 잘린 JSON은 텍스트로 디코드는 되고 파싱에서 깨지니까 JSONDecodeError 쪽으로 갑니다. "손상된 캐시"라는 한 덩어리가 예외 계층에서는 두 가지로 갈라져 있었던 겁니다.
이건 제가 처음 찾아낸 문제가 아닙니다. 이틀 전에 이미 공개 리뷰에 적혀 있었습니다. 같은 저장소 PR #284에서 maintainer가 2026-08-27에 남긴 "Still open" 목록의 P3 항목입니다.
P3
sources.py:153— a cache entry with invalid UTF-8 crashes every later run. Probably the most user-hostile one left: it is persistent, and self-inflicted only after a bad write.
파일과 줄 번호까지 지목돼 있습니다. 제 #291 커밋은 2026-08-29에 작성됐습니다. 발견은 여기 있었고, 제가 한 건 그 한 줄을 재현 가능한 실패로 바꾸고, 회복 경로를 고치고, 회귀 테스트로 고정한 것입니다.
PR #291은 2026-08-31에 승인·병합됐습니다(merge commit 7e7a9b2a5d50e952ec38c0fbcf60c63c62f44cf9). diff는 +16/-3, 파일 4개인데 그중 둘은 plugin과 marketplace의 버전 올리기입니다. 실제 동작을 바꾸는 건 예외 이름 하나고, 나머지는 그걸 고정하는 테스트 13줄입니다.
Windows에서 같은 경로를 end-to-end로 확인하지 않았고, 병합 이후 실제 사용자 실패가 줄었는지도 모릅니다. 병합은 구현이 수용됐다는 증거지 배포된 영향의 측정이 아닙니다.
테스트가 82개 통과했는데 전제가 틀렸던 경우
같은 저장소에 같은 날 연 다른 PR이 #290입니다. 이건 제가 닫았습니다.
static-analysis 스킬의 Semgrep 워크플로는 언어를 탐지해 ruleset을 고릅니다. 탐지 지시문에 이런 문장이 있었습니다.
javascript vs typescript — javascript already carries the .ts/.tsx globs, so report
javascript for a mixed or TS-only tree. Reporting both scans the same
files twice under two rulesets.
그런데 같은 스킬의 ruleset 카탈로그는 .ts/.tsx에 p/typescript라는 별도 ruleset을 배정하고 있었습니다. 제 가설은 이랬습니다. TypeScript만 있는 저장소를 javascript 한 범주로 접으면 그 정보가 Select 단계 전에 사라지고, TypeScript ruleset은 선택되지 않습니다. 스캔은 깨끗하게 끝나는데 규칙 일부가 안 돌아간 상태로요.
그래서 탐지 범주를 분리하고, 혼합 트리에서 두 스캔 단위를 모두 보존하도록 고쳤습니다. 테스트도 붙였습니다.
bash tests/run_scan_tests.sh # 82 passed
제 가설의 근거는 카탈로그 문서였습니다. rulesets.md에는 이런 표가 있습니다.
| Detection | Primary Ruleset | Pro Rule Count |
|---|---|---|
.js, .jsx | p/javascript | 250+ (JS), 70+ (JSX) |
.ts, .tsx | p/typescript | 230+ |
숫자가 다릅니다. 이름도 다릅니다. 저는 여기서 "두 pack의 규칙 내용이 다르다"로 건너뛰었습니다.
maintainer는 registry에서 두 pack을 직접 가져와서 rule ID를 비교했습니다.
p/javascript 74 rules
p/typescript 74 rules
ID 집합이 같았습니다. 양방향 comm 결과가 모두 비어 있었고요. 두 YAML 파일 사이의 7,400줄짜리 raw diff는 규칙 차이가 아니라 직렬화 순서 차이였습니다. 저도 공개 registry 응답을 다시 받아 같은 결과를 재현했습니다.
리뷰에는 제가 확인하지 않았던 게 하나 더 있었습니다. Semgrep의 언어 dispatch는 한쪽으로만 흐릅니다. languages: [javascript]인 규칙은 .ts 파일에서도 발동하지만, languages: [typescript]인 규칙은 .js에서 발동하지 않습니다. 그러니까 TypeScript-only 트리를 typescript로 보고하게 바꾼 제 지시문은 coverage를 지킨 게 아니라 어느 쪽을 버릴지 바꾼 것이었습니다. 제가 고치려던 결함과 같은 모양의 구멍을 반대편에 낸 셈입니다.
결과만 놓고 보면 제 PR은 OSS registry 기준으로 누락된 coverage를 복구하지 않으면서, 혼합 트리에서 같은 74개 규칙을 TypeScript 파일에 두 번 돌립니다. 중복 결과는 SARIF 병합에서 제거되니, 비용은 스캔 시간 쪽에 남습니다.
82개의 테스트는 제가 제안한 dispatch 구조가 의도대로 동작하는지, 그러니까 "내가 짠 대로 짜였다"를 고정했습니다. 두 pack이 실제로 다른 규칙을 담고 있다는 전제는 코드 밖 registry에 있어서, 테스트가 닿는 범위 밖이었습니다. 저는 그걸 문서에서 읽었습니다.
rulesets.md의 250+/230+는 Pro 수치입니다. 저는 인증된 Pro 실행으로 그 숫자를 확인한 적이 없습니다. 그런데 OSS dispatch 동작을 바꾸는 근거로 썼죠.
그 숫자가 어디서 왔는지도 뒤늦게 따라가 봤습니다. git log -S'250+'가 가리키는 건 커밋 51e02cc, 2026-02-06, rulesets.md를 새로 추가한 PR #65입니다. 파일이 처음 생길 때 표와 함께 들어온 값이더군요. PR 본문에도 코멘트에도 리뷰에도 그 수치를 어느 Semgrep 버전에서 어떤 계정으로 언제 셌는지가 없고, rulesets.md 안에도 registry를 둘러보라는 링크만 있습니다. 저는 그 표를 실행 결과처럼 읽었습니다.
추측으로 분기를 남기지 않기로 했다
PR을 살릴 길은 있었습니다. "OSS면 하나, Pro면 두 개" 식으로 조건 분기를 넣는 것. 그러면 리뷰 지적은 피해가면서 코드는 남습니다.
넣지 않았습니다. Pro에서 두 pack이 다르다는 걸 저는 여전히 확인하지 못했고, 확인하지 못한 조건으로 분기를 만들면 나중에 그 분기를 읽는 사람은 그게 검증된 사실이라고 읽을 겁니다. 그래서 그 경계를 답변으로 남기고 닫았습니다. 인증된 Pro 실행에서 rule ID 집합이 갈리면 그때 다시 열면 됩니다.
세 경우가 같은 모양이었다
돌아보니 셋 다 "무엇이 어디까지 도달하는가"의 문제였습니다. exports 맵은 어떤 소비자가 entry point에 도달할 수 있는지를 정하고, except 튜플은 어떤 예외가 복구 경로에 도달할 수 있는지를 정합니다. 그리고 리뷰는 어떤 가설이 구현 단계에 도달할 자격이 있는지를 정합니다.
앞의 두 경계는 코드에 적혀 있어서 읽으면 보입니다. 세 번째는 안 적혀 있습니다.
105 passed와 82 passed는 화면에서 똑같이 생겼습니다. 앞의 것은 잘못된 바이트를 실제로 심어서 외부 상태가 어떻게 되는지 확인했고, 뒤의 것은 제가 만든 구조가 제 의도대로 생겼는지 확인했습니다.
남은 질문 하나
이번 작업에서 남은 질문은 전제를 문서에서 읽었는지 실행해서 봤는지입니다. #290에서는 표의 숫자와 pack 이름을 읽고 넘어갔습니다. #2685에서는 조건 맵 표를 만들었지만 거기서 멈추지 않고 형제 패키지 열세 개를 실제로 설치해서 require해봤고, #291에서는 b"\xff"를 파일에 직접 썼습니다. 코드가 얼마나 짧은지와는 상관없는 차이였습니다.
병합 두 건과 종료 한 건 중에서, 다음에 또 쓰게 될 판단이 나온 건 종료한 쪽입니다. 닫은 PR 자체는 손실입니다. 애초에 registry를 한 번 조회했으면 열지 않았을 PR이니까요. 그걸 조회해서 알려준 사람은 maintainer였습니다.
댓글 0
아직 댓글이 없습니다. 첫 번째 댓글을 작성해보세요!