
grep은 문자열을 찾을 뿐 참조를 찾지 못합니다 — Strapi의 magic-string 문제
요약
grep과 같은 텍스트 기반 검색 도구가 코드 내의 간접 참조나 메서드 호출을 추적하지 못하는 한계를 설명합니다. 특히 Strapi와 같이 문자열 UID를 사용하는 환경에서 발생하는 참조 추적의 어려움과 에디터의 타입 추론 한계를 다룹니다.
핵심 포인트
- grep은 리터럴 문자열은 찾지만 변수에 할당된 간접 참조는 찾지 못함
- 메서드 호출(method call)과 실제 서비스 간의 관계를 파악하기 어려움
- Strapi의 'any' 타입 반환으로 인해 TypeScript 에디터의 자동 참조 추적도 제한됨
- 단순 텍스트 검색이 아닌 코드 그래프(Graph) 기반의 분석 필요성 강조
얼마 전 Strapi 서비스를 이름을 변경했다가, 그로 인해 망가진 모든 호출 지점(call-sites)을 찾아내느라 다음 한 시간을 허비했습니다. 제 에디터는 단 하나도 표시해주지 않았습니다. grep은 일부는 찾아냈지만 다른 것들은 놓쳤고, 실제 결과물들을 관련 없는 일치 항목 더미 아래에 파묻어 버렸습니다. 그날 오후의 경험이 기본적으로 제가 이 포스트에서 다룰 도구를 만들게 된 이유입니다.
하지만 잠시 도구 이야기는 접어두겠습니다. 먼저 여러분의 프로젝트에서 직접 시도해 보세요:
api::article.article을 사용하는 코드는 무엇인가요?
직접 답을 찾아보세요. 여러분은 grep을 사용하려 하겠지만, grep은 동시에 두 가지 방식으로 여러분을 실망시킬 것입니다.
grep은 문자열을 찾습니다. 참조(reference)를 찾지 못합니다.
먼저 grep에 대해 공정하게 말해봅시다. 만약 정확한 UID인 api::article.article을 검색한다면, 그것은 리터럴 문자열(literal string)이기 때문에 많은 것을 잡아낼 수 있습니다. 다른 콘텐츠 타입(content type)의 스키마에 있는 관계(relation)도 나타납니다:
// src/api/comment/content-types/comment/schema.json
"article": { "type": "relation", "relation": "manyToOne", "target": "api::article.article" }
라우트 핸들러(route handler)도 마찬가지입니다:
{ method: 'GET', path: '/articles/latest', handler: 'api::article.article.find' }
grep은 이것들을 찾아냅니다. 인정할 건 인정해야죠. 문제는 리터럴을 포함하지 않는 참조(references)에서 시작되며, 여기에는 끊임없이 발생하는 두 가지 종류가 있습니다.
첫 번째는 간접 참조(indirection)입니다. UID를 상수로 추출하면 — 기본적으로 거의 모든 코드베이스가 결국 그렇게 합니다 — 호출 지점이 검색에서 사라집니다:
const ARTICLE = 'api::article.article'
strapi.service(ARTICLE).findLatest() // UID를 grep하면 const 라인은 찾지만, 이 라인은 절대 찾지 못합니다
두 번째는 메서드(method)입니다:
strapi.service('api::article.article').findLatest()
// ^^^^^^^^^^^ findLatest()를 호출하는 것은 무엇인가요?
findLatest는 건초더미 속의 바늘과 같으며, grep은 이것이 해당 서비스에 속해 있다는 사실을 전혀 알지 못합니다. 순수 이름(bare name)으로 grep을 하면 결과에 압도당하고, UID로 grep을 하면 메서드 호출을 절대 볼 수 없습니다.
심지어 grep이 찾아낸 결과물에 대해서도, 그것이 관계 (relation)인지, 서비스 호출 (service call)인지, 아니면 라우트 핸들러 (route handler)인지조차 알려주지 못합니다. 즉, 여러분이 실제로 궁금해하는 질문들, 즉 무엇이 사용되지 않는지, 무엇이 고장 났는지, 무엇이 이것에 의존하고 있는지에 대해서는 답을 줄 수 없습니다. 그것은 그래프 (graph)의 영역입니다. grep은 텍스트 (text)를 다룰 뿐입니다.
좋습니다, 하지만 저는 TypeScript를 사용 중입니다. 에디터가 찾아주지 않을까요?
저도 그렇게 가정했습니다. 하지만 에디터는 찾아내지 못하며, 그 이유는 기묘할 정도로 구체적입니다. Strapi가 제공하는 타입을 살펴보십시오:
strapi.service(uid: string): any
이것은 any를 반환합니다. any 타입에 .findLatest()를 입력하는 순간, TypeScript는 검사를 통과해 버립니다. 메서드를 완성할 수도 없고, 참조 (references)를 찾을 수도 없으며, 이름을 변경할 수도 없습니다. 여러분의 "모든 참조 찾기 (Find All References)" 결과는 빈 값으로 돌아옵니다. 이는 findLatest를 호출하는 곳이 없어서가 아니라, 컴파일러가 any를 만난 순간 추적 경로를 놓쳐버렸기 때문입니다.
앞서 언급한 target 관계 (relation)는 어떨까요? 그것은 훨씬 더 절망적입니다. 그것은 .json 파일 안에 존재합니다. 애초에 JSON에는 TypeScript가 없으므로, 분석할 것도 없고 점프할 곳도 없습니다.
결국 여러분이 보통 신뢰하는 두 도구 모두 서로 다른 이유로 여기서 눈이 먼 상태입니다:
- grep은 텍스트는 이해하지만 의미 (meaning)는 이해하지 못하므로, 너무 많이 찾거나 너무 적게 찾습니다.
- TypeScript는 타입 (types)은 이해하지만, Strapi의 매직 스트링 (magic strings)은 의도적으로 타입 시스템 (type system)
외부에 존재합니다 —any반환값, JSON 스키마 (schemas), 라우트 설정 (route configs) 내의 스트링 핸들러 (string handlers) 등이 그러합니다.
Strapi는 이 모든 것을 런타임 (runtime)에 해결합니다. 여러분의 에디터는 이를 결코 해결하지 못합니다. 이것이 바로 전체적인 간극입니다.
해결책은 지루합니다: 그냥 스키마를 읽으세요
가장 짜증 났던 점은 이것입니다 — 이 중 그 어떤 것도 AI를 필요로 하지 않으며, 코드 생성 (codegen)도 필요하지 않습니다. 정답은 이미 레포지토리 (repo) 안에 있습니다. 실제 schema.json 파일들, 라우트 (routes), 서비스 (services) 및 컨트롤러 (controllers)가 그것입니다. 그저 Strapi 자체가 부팅 시에 하는 방식대로, 그것들을 읽어서 참조 그래프 (reference graph)를 구축할 무언가가 필요할 뿐입니다.
그래서 제가 그것을 만들었습니다. DevKit for Strapi는 여러분의 프로젝트에 있는 실제 Content-types (콘텐츠 유형), Components (컴포넌트), Services (서비스), Controllers (컨트롤러), Policies (정책), Middlewares (미들웨어), 그리고 Routes (라우트)를 읽어 들여, 매직 스트링 (magic strings)을 그것들이 가리키는 실제 코드처럼 취급하는 VS Code 확장 프로그램 (Cursor, Windsurf, Antigravity 및 VSCodium 포함)입니다. 이 도구는 정규 표현식 (regex)이 아닌 TypeScript 컴파일러 API를 사용하여 이를 분류하므로, 패턴 매칭 (pattern-matching)이 아닌 구조를 읽습니다.
자동 완성 (Autocomplete) 기능은 커서가 위치한 곳에 대해 실제 값을 제공합니다:
strapi.service('api::|') // 프로젝트 내의 모든 실제 콘텐츠 유형 UID
진단 (Diagnostics) 기능은 존재하지 않는 항목에 밑줄을 긋고, "혹시 이것을 의미하셨나요?"라는 수정 제안을 제공합니다:

그리고 제가 이 모든 것을 시작한 이유 — strapi.service('x').findLatest() 호출과 TypeScript가 볼 수 없는 라우트 핸들러 (route handlers)를 포함하여, 정의 바로 위에 카운트된 실제 '모든 참조 찾기 (Find All References)' 기능입니다:
모든 검색 결과에는 해당 엔티티 (entity)에 도달하는 '방법'이 태그로 붙습니다: service 호출, documents() 호출, db.query(), schema 관계 (relation), 또는 route 핸들러 등입니다. 그 "방법"이야말로 grep이 절대 제공할 수 없는 부분입니다. 왜냐하면 grep은 관계의 target (대상)과 서비스 호출이 서로 다른 옷을 입고 있는 동일한 참조라는 사실을 알지 못하기 때문입니다.
여기에 일반적인 정의로 이동 (go-to-definition) 및 호버 (hover) 기능도 포함되어 있지만, 솔직히 말해서 제가 매일 사용하는 핵심 요소는 참조 그래프 (reference graph)입니다.
여러분의 AI 에이전트도 정확히 똑같은 문제에 걸려 넘어집니다
이 부분은 실제로 일어나는 것을 보기 전까지는 예상하지 못했던 지점입니다. 만약 여러분이 Claude Code, Copilot의 에이전트 모드(agent mode), 또는 Cursor를 사용하여 코딩한다면, 여러분의 에이전트도 여러분과 똑같은 벽에 부딪힙니다. 에이전트에게 무언가를 이동하거나 이름을 바꾸라고 요청하면, 에이전트는 참조를 찾기 위해 grep을 실행합니다. 그러면 똑같은 노이즈가 발생하고, 똑같이 any 타입으로 지정된 호출들을 놓치게 됩니다. 그러고는 그 절반만 맞은 그림을 바탕으로 코드를 재작성(rewrites code) 해버립니다. 이것이 바로 에이전트가 자신감 넘치는 모습으로 프로젝트를 망가뜨리는 방식입니다.
그래서 동일한 엔진이 MCP 서버로서 확장 프로그램에 포함되어 제공됩니다 (또는 단독으로 실행할 경우 npx devkit-for-strapi-mcp를 사용합니다). 에이전트는 grep 대신 실제 도구들을 사용하게 됩니다: '누가 이것을 사용하는지'를 확인하기 위한 find_references 및 dependents, 실제 필드 이름을 위한 get_schema, 쓰기 전에 UID를 확인하기 위한 validate_reference, 그리고 list_broken_refs와 의존성 그래프(dependency graph)가 그것입니다.

이 제안에 대해 솔직하게 말씀드리자면: 유능한 에이전트는 여러분의 스키마(schema) 파일을 열어서 읽을 수 있으므로, 이것이 마법 같은 일은 아니며 제가 없으면 모델이 무력할 것이라고 거짓말을 하려는 것도 아닙니다. 이 도구가 실제로 제공하는 이점은 더 좁고 실질적입니다. 매 세션마다 30개의 스키마 파일을 다시 읽는 대신 하나의 결정론적인 도구 호출(deterministic tool call)을 수행하며, 사용자가 조종하든 에이전트가 조종하든 grep으로는 구축할 수 없는 참조 그래프(reference graph)를 활용한다는 점입니다. 에이전트 역시 여러분이 방금 겪었던 것과 똑같은 any 반환값과 동일한 상수 간접 참조(constant indirection) 문제로 인해 발을 헛디디게 됩니다.
"Strapi v5는 이미 생성된 타입(generated types)으로 이 기능을 수행하지 않나요?"
댓글에서 이 질문이 나올 것을 예상했기에, 미리 답변하겠습니다. 공정하게 말하자면, Strapi v5는 UID 네임스페이스(namespace)를 추가했습니다. 따라서 API가 UID.ContentType에 대해 타입이 지정되어 있으면, .ts 코드에서 UID 리터럴(literal)에 대한 자동 완성(autocomplete) 기능을 사용할 수 있습니다. 이는 진정으로 유용하며, 존재한다는 사실이 기쁩니다.
하지만 세 가지 이유로 인해 여전히 격차는 해소되지 않습니다:
schema.json은 JSON입니다. 생성된 타입(Generated types)은 관계(relation)의target, 컴포넌트 참조(component references), 또는 다이내믹 존(dynamic zones)에 도달할 수 없습니다. 이는 구조적인 문제입니다. 여러분의 매직 스트링(magic strings) 중 절반은 TypeScript가 결코 살펴보지 않을 파일 안에 존재합니다.- 리터럴(literal)을 자동 완성하는 것은 참조(references)를 찾는 것이 아닙니다. TypeScript는
'api::article.article'을 기꺼이 자동 완성해주겠지만, 프로젝트 전체에서
참조를 찾는 것은 무료입니다. 만약 도구가 실제로 그것들을 당신을 대신해 _변경(change)_하기를 원한다면 — 즉, 모든 호출 지점(call-site), 라우트 핸들러(route handler), 그리고 관계 대상(relation target)에 걸쳐 전파되는 이름 변경(rename)을 원한다면 — 이를 위한 유료 티어가 있습니다. 하지만 "매직 스트링(magic strings)과의 싸움에서 더 이상 패배하지 않는 것"은 비용이 들지 않습니다.
만약 직접 사용해 보았는데 패턴을 놓치거나, v4/v5의 어떤 특이 사항(quirk) 때문에 문제가 발생한다면 저에게 알려주세요. 그것이 바로 제가 듣고 싶어 하는 내용입니다.
DEV의 가이드라인에 따른 공개 사항: 저는 이 포스트의 초안을 작성하고 편집하는 데 AI 어시스턴트를 사용했습니다. 도구, 코드, 제가 마주한 버그, 그리고 의견은 모두 저의 것입니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기