tsc 정확성 ≠ 런타임 정확성
출처: Dev.to
아마도 한 번쯤은 본 적이 있을 TypeScript 오류입니다.
src/Header.tsx:3:10 - error TS2614: Module '"./logo.svg"' has no exported member 'ReactComponent'. Did you mean to use 'import Logo from "./logo.svg"' instead?
tsc가 친절하게 해결 방법을 알려줍니다. 편집기에서는 원클릭 퀵픽스로 제안합니다. 코드 수정을 담당하는 LLM도 이 제안을 그대로 적용합니다. 수정 후 코드는 다음과 같습니다.
import Logo from "./logo.svg";
// ← tsc는 이제 초록색
// ← 개발 서버가 이제 크래시
수정된 파일은 타입 검사를 통과합니다. 하지만 앱은 깨집니다. Logo가 이제 React 컴포넌트가 아니라 에셋 URL 문자열이 되어서, 페이지가 렌더링되는 순간 “Logo is not a function” 오류가 발생합니다.
이것이 LLM 기반 코드 수리가 매일 마주치는 격차이며, tsfix가 해결하고자 하는 문제입니다.
실패 원인 3가지
다음 세 가지 요소가 모두 존재해야 이 LLM 수리 실패가 일어납니다.
-
라이브러리 제작자
vite-plugin-svgrv4는 SVG를 React 컴포넌트로 가져오는 방식을 바꿨습니다. 기본 가져오기는 이제 에셋 URL이며, React 컴포넌트로 가져오려면?react쿼리 접미사를 붙여야 합니다.import Logo from "./logo.svg?react";이 선택은 합리적입니다. 플러그인의 기본 동작을 다른 Vite 에셋 가져오기와 일치시키고,
?react접미사를 명시적으로 표시합니다. 작은 깨지는 변화이지만 마이그레이션 가이드에 정확히 명시되어 있습니다. -
TypeScript 팀
TS는 Vite 플러그인을 알 수 없습니다. 눈에 보이는 것은*.svg에 대한 ambient 모듈 선언뿐입니다. 선언에export const ReactComponent: …가 있다면,{ ReactComponent }를 이름으로 가져오는 것은 존재하지 않는 항목이므로 TS2614 오류가 발생합니다. TS의 퀵픽스 로직은 같은 모듈에서 가능한 대체 가져오기를 찾고, 기본 내보내기를 제안합니다. 즉import Logo from "./logo.svg"를 제안합니다. 타입 관점에서는 깔끔한 제안이며 프로그램이 타입 검사를 통과하게 합니다.TS는 타입에 대해서는 옳지만 런타임 의미에 대해서는 틀립니다. 다른 선택을 할 방법이 없습니다.
-
LLM
LLM은 “컴파일되는 코드”가 압도적으로 많은 코퍼스로 학습되었습니다. v4 이전vite-plugin-svgr는ReactComponent를 이름 내보내기로 제공했기 때문에, 수백만 토큰이 여전히 그렇게 작동한다고 학습했습니다. v4 문서는 존재하지만 데이터에 비해 얇습니다.tsc가 “기본 가져오기를 사용하라”고 말하면, LLM도 동일하게 동의하고 같은 수정을 제안합니다.LLM이 게으른 것이 아니라, 학습 분포와 컴파일러 힌트가 모두 잘못된 방향을 가리키고 있기 때문입니다.
tsfix의 접근법
tsfix는 매 Layer‑2 호출 시 package.json을 읽습니다. 의존성에 vite-plugin-svgr v4 이상이 있으면, 모델이 오류 파일을 보기 전에 시스템 프롬프트 헤드라인에 다음 정보를 삽입합니다.
### library-migrations
- vite-plugin-svgr: v4 requires the `?react` query suffix to import an SVG
as a React component. `import Logo from "./logo.svg"` returns the asset URL.
### task
Library migration: vite-plugin-svgr
그뿐입니다. 파인튜닝도, 에이전트 루프도, 검색 파이프라인도 없습니다. package.json을 조회하고 네 줄의 프롬프트를 추가하는 정도입니다.
우리 벤치마크에서는 ?react 마이그레이션 케이스가 이 변경으로 0/3 → 3/3 로 성공률이 상승했습니다. 모델은 이미 ?react에 대해 알고 있었지만, tsc의 힌트를 무시하도록 허가받은 것이 차이였습니다.
동일한 패턴은 다른 라이브러리에도 적용됩니다.
next@15–params와searchParams가 이제Promise이며await가 필요함ai@v3/v6–generateTextAPI 재작성drizzle-orm– 문자열 연결이 아닌 파라미터화된 템플릿 리터럴 사용
프롬프트 위치가 결과를 바꾼 사례
첫 번째 버전은 MendContext.featureSpecText라는 자유형 Markdown 섹션에 위 두 문장을 넣었습니다. 모델은 거의 아무것도 하지 않았고, 여전히 tsc의 퀵픽스를 따랐습니다.
같은 두 문장을 시스템 지시문 바로 뒤, 파일 내용 앞에 위치한 taskDescription 헤드라인으로 옮기니 결과가 뒤바뀌었습니다. 동일한 내용, 다른 위치, 반대 결과.
이는 긴 컨텍스트 주의력 감소와 Claude가 “task” 프레이밍을 해석하는 방식과 일치합니다. 모델은 헤드라인을 실제 작업으로 인식하고, 나머지 프롬프트를 그에 맞게 가중합니다. “Library migration: vite-plugin-svgr”는 “사용자가 이 마이그레이션을 알고 있다; tsc가 제안하는 퀵픽스는 마이그레이션 때문에 무시한다”는 의미로 받아들여집니다. 이 단일 재프레이밍이 “tsc가 X라고 말한다”는 중력을 무력화합니다.
보안 영역에서의 동일한 실패 형태
svgr 사례는 개발 서버가 바로 크래시되므로 눈에 띄는 실패입니다. 같은 논리를 보안 문제에 적용하면 실패가 조용해집니다.
사례 1 – dangerouslySetInnerHTML을 회피용으로 사용
LLM에게 사용자 제어 HTML을 React 컴포넌트에 렌더링하라고 하면, 컴포넌트 시그니처는 children: string이고 입력은 HTML 문자열이며, tsc가 불평합니다. 가장 쉬운 회피는
<div dangerouslySetInnerHTML={{ __html: input }} />
로 바꾸는 것입니다. 타입 오류는 사라지지만 XSS 구멍이 생깁니다. 여기서도 타입 시스템은 “HTML 문자열은 React 노드가 아니다”라고 정확히 경고하고, tsc는 이를 강제하지만 LLM은 타입 검사를 만족시키는 회피책을 선택합니다. 런타임에 안전한 해결책은 JSX {input}(자동 이스케이프) 혹은 DOMPurify로 정화하는 것이지만, 타입 시스템은 어느 쪽을 원하는지 알 수 없습니다.
사례 2 – bcrypt 대신 crypto.subtle.digest 사용
레포의 package.json에 bcrypt가 명시돼 있고, 소스에서 이를 import 했지만 LLM이 리팩터링 과정에서 import 라인을 실수로 삭제합니다. tsc는 “Cannot find name ‘bcrypt’. Did you mean ‘crypto’?” 라고 제안하고, LLM은 이를 받아들여
await crypto.subtle.digest("SHA-256", encoder.encode(password))
로 바꿉니다. 타입 검사는 통과하지만, 이제 모든 사용자 비밀번호가 솔트 없이 SHA‑256 해시로 전송됩니다. tsc의 추론은 완전히 맞지만, 런타임 의미는 재앙적입니다.
두 경우 모두 svgr 사례와 동일한 구조이며, 결과는 페이지 리로드가 아니라 사람 해고나 보안 사고가 됩니다. tsc는 정적 타입 시스템이고, 프로그램은 실제 동작해야 하는 동적 시스템입니다. 첫 번째를 우선시하는 수리는 빌드는 초록색이지만 보안 보고서는 빨간색이 됩니다.
tsfix는 이러한 패턴을 명시적으로 프롬프트 수준에서 차단하는 규칙을 추가했습니다(예: as keyof T를 사용해 인덱스 시그니처 오류를 무시하고, 인자를 생략해 TS2554를 무시). 마법은 아니지만 prior를 바꿉니다. 벤치 전체에서 해당 패턴을 포함한 사례는 0/3 → 3/3 으로 “기능적이고 안전한” 결과를 얻었습니다.