FSearch: 맥 디스크 전체를 미리 색인해 두는 검색 도구

ripgrep과 fd를 쓰기 시작하면 grep이나 find로는 돌아가기 어려운데요. 그래도 검색 범위를 홈 디렉터리 전체로 넓히면 이 둘도 몇 초씩 걸립니다. 매번 디스크를 처음부터 다시 훑기 때문이죠. 사람이야 1초쯤 기다려도 되지만, 코딩 에이전트는 작업 하나에 검색을 수십 번 돌리니 이 시간이 그대로 쌓입니다 😅
FSearch는 이 문제를 “미리 색인해 두기”로 풉니다. 상주하는 데몬(daemon)이 디스크 전체의 파일 이름과 텍스트 파일 내용을 색인해 두고, 파일이 바뀌면 그 부분만 바로 갱신합니다. 그래서 수백만 개 파일 중에서 이름을 찾는 데 1ms도 걸리지 않는다고 하는데요. 이번 글에서는 FSearch를 제 맥에 직접 설치해 이름 검색과 내용 검색을 써 보고, ripgrep과 fd를 상대로 속도를 재 본 뒤, Claude Code에 MCP 서버로 연결해 보겠습니다.
FSearch란?
FSearch는 2026년 10월에 공개된 macOS 전용 파일 검색 도구입니다. Rust로 작성됐고 MIT 라이선스를 따르며, 바이너리 하나를 네 가지 방식으로 쓸 수 있습니다. 터미널에서 쓰는 CLI, 색인을 들고 있는 데몬, 다른 Rust 프로그램에 넣어 쓰는 크레이트(crate), 그리고 코딩 에이전트용 MCP 서버입니다.
리눅스에도 같은 이름의 FSearch가 있는데요. 윈도우의 Everything을 본뜬 GTK 데스크톱 앱이라 이 글에서 다루는 도구와는 전혀 다른 프로젝트입니다. 검색해서 자료를 찾을 때 헷갈리지 않도록 저장소 주소(noahdunnagan/fsearch)를 기준으로 확인하세요.
설치
Rust 툴체인이 있다면 저장소를 내려받아 직접 빌드할 수 있고, 없다면 Apple Silicon용 릴리스 바이너리를 바로 받으면 됩니다. 이 글은 2026년 10월 12일에 나온 v0.2.1을 기준으로 합니다.
curl -L https://github.com/noahdunnagan/fsearch/releases/latest/download/fsearch-aarch64-apple-darwin.tar.gz | tar xz
./fsearch install
install은 바이너리를 ~/.local/bin에 복사하고, 코딩 에이전트에 연결하는 명령까지 친절하게 알려 줍니다.
installed ~/.local/bin/fsearch; the daemon starts on first use
for coding agents: claude mcp add -s user fsearch -- ~/.local/bin/fsearch mcp
codex mcp add fsearch -- ~/.local/bin/fsearch mcp
출력에 적힌 대로 데몬은 처음 검색할 때 저절로 뜹니다. 첫 실행에는 디스크 전체를 한 번 훑어야 하는데, 제 맥에서는 데몬 로그에 이렇게 남았습니다.
1792623614 indexed 5555744 entries in 16.38s
1792623614 replay done
1792623619 content: indexed 98821 files in 3.03s
파일과 폴더 555만 개의 이름을 16초 만에, 텍스트 파일 약 10만 개의 내용을 3초 만에 색인했네요. 색인은 ~/Library/Application Support/FSearch/에 저장됩니다. 이 폴더가 어떤 곳인지 궁금하다면 macOS 주요 디렉터리 글을 참고하세요. 현재 상태는 fsearch status로 확인합니다.
fsearch status
{
"content_bytes": 100236800,
"content_docs": 98848,
"content_pending": 0,
"content_segments": 28,
"dirs": 780923,
"entries": 5655009,
"event_id": 678609782,
"full_disk_access": true,
"id": null,
"index_bytes": 147274240,
"ok": true,
"overlay": 339,
"owner": true,
"removed": 153
}
한 가지 주의할 점이 있습니다. FSearch는 --version 같은 옵션을 따로 받지 않아서, fsearch --version이라고 치면 이름에 version이 들어간 파일 목록이 쏟아집니다. 버전은 GitHub 릴리스 페이지에서 확인하는 편이 낫습니다.
파일 이름으로 찾기
기본 사용법은 찾고 싶은 이름을 그대로 넘기는 것입니다. 검색 범위를 좁히고 싶다면 in: 필터로 폴더를 지정합니다.
fsearch 'ripgrep in:~/blog'
~/blog/src/content/posts/ripgrep.md
~/blog/dist/ripgrep
...
FSearch의 이름 검색은 퍼지(fuzzy) 매칭이라 오타를 너그럽게 봐줍니다. 철자를 일부러 틀려 riprgep이라고 쳐도 같은 파일이 맨 위에 나옵니다. 8글자 이상인 단어는 오타 두 개까지 허용한다고 하네요.
fsearch 'riprgep in:~/blog'
~/blog/src/content/posts/ripgrep.md
~/blog/dist/ripgrep
...
여러 단어를 띄어 쓰면 이 단어들이 모두 들어간 이름을 찾습니다. 하이픈으로 이어진 파일명을 단어 단위로 기억하고 있을 때 편리합니다.
fsearch 'claude code hooks in:~/blog'
~/blog/src/content/posts/claude-code-hooks.md
~/blog/dist/claude-code-hooks
...
반대로 퍼지 매칭이 방해될 때도 있습니다. 예를 들어 main.rs를 찾으면 main과 rs가 흩어져 들어간 이름까지 결과에 섞이는데요. 이럴 때는 앞에 작은따옴표를 붙여 'main.rs처럼 정확히 일치하는 이름만 찾게 합니다. 이 밖에도 ^로 시작 부분을, $로 끝부분을 고정하고, !로 특정 이름을 뺄 수 있습니다.
필터로 좁히기
FSearch 쿼리에는 이름:값 형태의 필터를 섞어 씁니다. 자주 쓰는 필터는 다음과 같습니다.
in:: 지정한 폴더 안에서만 찾습니다.ext:,type:: 확장자나 파일 종류(image등)로 거릅니다.size:,mtime:: 크기와 수정 시각으로 거릅니다.size:>5mb,mtime:<7d처럼 씁니다.git:: Git 상태로 거릅니다.modified,staged,untracked,ignored등을 지원합니다.grep:,regex:,sym:: 파일 내용을 검색합니다. 다음 섹션에서 자세히 살펴봅니다.
예를 들어 최근 사흘 안에 고친 마크다운 파일을 찾으려면 이렇게 씁니다.
fsearch 'ext:md mtime:<3d in:~/blog/src'
~/blog/src/content/posts/agents-md.md
~/blog/src/content/posts/claude-code-action.md
~/blog/src/content/posts/codex-code-review.md
~/blog/src/content/posts/codex.md
글로브 패턴(glob pattern)도 그대로 씁니다. src/**/*.astro !layouts/ card라고 하면 src 아래 .astro 파일 중 layouts 폴더를 빼고 이름에 card가 들어간 파일을 찾습니다. 다만 in:을 빼면 현재 디렉터리가 아니라 디스크 전체를 대상으로 찾는다는 점을 기억하세요. ripgrep이나 fd처럼 현재 디렉터리를 기준으로 생각하면 예상보다 결과가 훨씬 많이 나옵니다.
파일 내용 검색하기
FSearch는 이름뿐 아니라 텍스트 파일의 내용도 색인해 둡니다. grep: 필터에 찾을 문자열을 넘기면 ripgrep처럼 경로:줄 번호: 내용 형식으로 출력합니다.
fsearch 'ext:ts grep:getLeafTags in:~/blog'
~/blog/src/utils/tags.ts:107: export function getLeafTags(tags: string[], tagEntries: TagEntry[]): string[] {
정규 표현식(regular expression)이 필요하면 regex:를 씁니다. ripgrep과 마찬가지로 한 줄 안에서만 매칭하고, 대소문자는 스마트 케이스(smart case)로 처리합니다. 검색어가 모두 소문자면 대소문자를 무시하고, 대문자가 하나라도 섞이면 구분하는 방식입니다.
fsearch 'regex:export\s+function\s+get\w+Tags in:~/blog/src'
~/blog/src/utils/posts.ts:22: export function getAllTags(post: { data: { tags: string[] } }): string[] {
~/blog/src/utils/tags.ts:33: export function getTopTags(
~/blog/src/utils/tags.ts:82: export function getGroupTags(tagEntries: TagEntry[]): string[] {
~/blog/src/utils/tags.ts:87: export function getDescendantTags(
~/blog/src/utils/tags.ts:107: export function getLeafTags(tags: string[], tagEntries: TagEntry[]): string[] {
...
함수나 타입이 어디서 정의됐는지만 알고 싶다면 sym:이 편합니다. 범위를 지정하지 않아도 디스크 전체에서 정의 한 곳을 바로 짚어 줍니다.
fsearch 'sym:getLeafTags'
~/blog/src/utils/tags.ts:107: export function getLeafTags(tags: string[], tagEntries: TagEntry[]): string[] {
내용 검색에는 범위 제한이 있습니다. 색인 대상은 1MB 이하의 텍스트 파일이고, Git 저장소 안에서는 10MB까지 늘어나며, .gitignore가 무시하는 파일은 뺍니다. 잠금 파일(lockfile), 100KB 이상의 JSON, 압축된 번들 파일도 제외합니다. 같은 저장소의 Git 워크트리(worktree)도 내용 색인에서 빠지고 메인 체크아웃만 색인합니다. 이름 검색에는 워크트리 안의 파일도 나오지만, 같은 내용을 몇 번씩 색인하지 않으려고 내용 검색에서는 제외하는 것이죠.
출력 방식도 ripgrep과 조금 다릅니다. FSearch는 파일 하나당 매칭되는 줄을 최대 5개까지만 보여 주는데요. 한 파일에 같은 이름이 수십 번 나와도 결과가 그 파일로 도배되지 않게 하려는 것으로 보입니다.
ripgrep, fd보다 얼마나 빠를까?
그럼 실제로 얼마나 빠른지 재 볼까요? 제 맥에서 여러 저장소를 모아 둔 ~/code 폴더(약 42GB)를 대상으로, 이름이 정확히 main.rs인 파일 찾기와 LazyLock이 들어간 파일 찾기를 세 번씩 실행해 중앙값을 냈습니다. 디스크 캐시가 데워진 상태에서 잰 값입니다.
| 작업 | 명령 | 시간 |
|---|---|---|
| 이름 검색 (50개) | fsearch "'main.rs in:~/code" | 13ms |
| 이름 검색 (50개) | fd -HI -t f '^main\.rs$' ~/code | 759ms |
| 내용 검색 (136개) | fsearch 'grep:LazyLock in:~/code limit:500' | 13ms |
| 내용 검색 (136개) | rg -l LazyLock ~/code | 368ms |
두 도구가 찾은 파일 수는 정확히 같았고, 속도는 FSearch가 30~60배 빨랐습니다. 그런데 README에는 이름 검색이 0.1ms 수준이라고 적혀 있는데 왜 13ms가 나왔을까요? bench 명령으로 데몬 안에서 걸린 시간만 따로 재 보면 답이 나옵니다.
fsearch bench 'grep:LazyLock in:~/code'
first 976.46µs median 592.71µs min 428.29µs
색인 조회 자체는 0.6ms 남짓이고, 나머지 12ms 정도는 CLI 프로세스를 띄우고 유닉스 소켓으로 데몬과 주고받는 비용입니다. 셸에서 한 번 쓸 때는 체감 차이가 없지만, 데몬에 계속 붙어 있는 MCP 서버나 앱에서는 이 1ms 미만의 속도가 그대로 나옵니다.
왜 이렇게 빠를까?
ripgrep은 검색할 때마다 디렉터리를 순회하고 파일을 열어 읽습니다. 아무리 최적화해도 파일 수에 비례하는 일을 매번 되풀이할 수밖에 없습니다. FSearch는 이 일을 한 번만 하고, 이후에는 바뀐 것만 따라갑니다.
먼저 첫 실행 때 macOS의 getattrlistbulk 시스템 콜로 디스크를 훑습니다. 디렉터리 하나의 항목과 속성을 한 번에 여러 개씩 받아 오는 API라, 파일마다 stat을 부르는 것보다 훨씬 적은 시스템 콜로 끝납니다. 그 뒤로는 FSEvents로 파일 시스템 변경 알림을 받아 색인을 고칩니다. README에 따르면 변경이 검색 결과에 반영되기까지 15ms 정도 걸리고, 데몬을 다시 띄워도 꺼져 있던 동안 바뀐 부분만 다시 처리합니다.
파일 이름은 폴더 순서대로 파일 하나에 담겨 메모리 맵(mmap)으로 읽힙니다. 같은 폴더의 항목이 붙어 있으니 in: 필터는 연속된 구간 하나를 잘라 내는 일이 됩니다. 또 이름마다 어떤 글자가 들어 있는지를 비트맵으로 들고 있어서, 퍼지 점수를 계산하기 전에 글자 구성이 안 맞는 이름을 대부분 걸러 냅니다.
내용 검색은 트라이그램(trigram) 색인을 씁니다. 모든 텍스트를 세 글자 조각으로 잘라 “이 조각이 어느 파일에 있는지”를 기록해 두고, 검색어의 조각을 모두 가진 파일만 후보로 골라 실제로 읽는 방식입니다. tantivy 같은 풀텍스트 검색 엔진이 단어 단위로 만드는 역색인(inverted index)을 세 글자 단위로 만든 셈이죠. JSON API로 내용 검색을 하면 이 과정이 응답에 드러납니다.
echo '{"op": "grep", "pattern": "getLeafTags", "in": "~/blog/src"}' | fsearch stdio
{"candidates":2,"complete":true,"files":[...],"id":null,"indexing":0,"ok":true,"read":2,"source":"index","took_us":1018}
candidates와 read가 2입니다. ~/blog/src 아래 파일이 수백 개인데, 색인이 후보를 2개로 좁혀서 실제로는 그 2개만 열어 읽었다는 뜻입니다. 매칭된 줄은 이렇게 디스크에서 새로 읽어 오기 때문에, 색인이 아직 따라잡지 못한 방금 고친 파일도 결과가 틀리지 않습니다.
Claude Code에 MCP 서버로 연결하기
FSearch를 가장 잘 쓰는 방법은 아마 코딩 에이전트에 붙이는 것입니다. fsearch mcp가 표준 입출력(stdio)으로 동작하는 MCP 서버라서, install이 알려 준 명령 그대로 Claude Code에 등록하면 됩니다. MCP 서버 등록 방법 전반은 클로드 코드에서 MCP 서버 연동하기에서 자세히 다뤘습니다.
claude mcp add -s user fsearch -- ~/.local/bin/fsearch mcp
Codex를 쓴다면 다음과 같이 등록합니다.
codex mcp add fsearch -- ~/.local/bin/fsearch mcp
MCP 서버가 제공하는 도구(tool)는 세 가지입니다. 파일 이름을 찾는 find_files, 내용을 찾는 grep, 그리고 여러 문자열을 OR로 한꺼번에 찾는 multi_grep입니다. multi_grep은 PrepareUpload와 prepare_upload처럼 표기법이 다른 같은 이름을 한 번에 찾으라고 만든 도구인데요. 에이전트가 표기법을 바꿔 가며 검색을 여러 번 반복하는 습관을 한 번의 호출로 줄여 줍니다. 세 도구 모두 readOnlyHint가 붙어 있어서 파일을 바꾸지 않는다는 점이 명시돼 있습니다.
서버가 초기화 응답의 instructions로 에이전트에게 사용법을 직접 일러 준다는 점도 눈에 띕니다.
fsearch: indexed search over this project (or any folder, or the whole disk with path "/").
- grep: file contents; the default for any identifier or text. Definitions come first, with their first lines.
- find_files: file names (fuzzy, typo-tolerant), to locate a file or see what exists.
- multi_grep: several spellings at once (snake_case, CamelCase), OR'd.
...
Results are best first: read the top file instead of searching again.
마지막 줄의 “검색을 다시 하지 말고 맨 위 파일을 읽어라”가 핵심입니다. 실제로 grep 도구로 getLeafTags를 찾아 보면, 함수가 정의된 곳이 본문 몇 줄과 함께 맨 앞에 오고 사용처가 그 뒤를 잇습니다.
blog/src/utils/tags.ts
107:export function getLeafTags(tags: string[], tagEntries: TagEntry[]): string[] {
108-return tags.filter((tag) => {
109-const descendants = getDescendantTags(tag, tagEntries);
110-return !tags.some((other) => other !== tag && descendants.has(other));
111-});
112-}
blog/AGENTS.md
72:- Use `getLeafTags(tags, tagEntries)` to drop ancestor tags when another tag in the list is their descendant. ...
blog/src/components/Tags.astro
3:import { expandTagsWithKorean, getLeafTags } from "../utils/tags";
12:getLeafTags(tags, tagEntries),
에이전트는 정의를 검색하고 그 파일을 열어 함수 본문을 읽는 두 단계를 호출 한 번으로 끝냅니다. 검색 속도보다 오히려 이렇게 왕복 횟수를 줄여 주는 쪽이 Claude Code의 토큰과 시간을 더 아껴 줍니다.
쓰기 전에 알아 둘 점
우선 FSearch는 macOS 전용입니다. getattrlistbulk와 FSEvents에 기대는 설계라 리눅스나 윈도우에서는 돌아가지 않으니, 여러 운영체제를 오가는 스크립트에는 여전히 ripgrep과 fd가 맞습니다.
또한 권한 문제가 있습니다. 전체 디스크 접근 권한(Full Disk Access)이 있는 터미널에서 실행하면 디스크 전체를 색인하지만, 권한이 없으면 보호된 폴더를 묻지 않고 건너뜁니다. 로그인할 때 데몬이 저절로 뜨게 하는 fsearch install --login을 쓴다면 시스템 설정의 개인정보 보호 및 보안에서 ~/.local/bin/fsearch에 권한을 따로 줘야 하고, 직접 빌드했다면 다시 빌드할 때마다 권한을 새로 줘야 합니다. 디스크 전체를 읽는 강한 권한이니, Mole 글에서도 이야기했듯 소스가 공개된 도구인지 먼저 확인하고 주는 편이 좋습니다.
그리고 아직 나온 지 얼마 안 된 프로젝트라는 점도 염두에 두세요. 저장소를 연 지 나흘 만에 0.2와 0.2.1이 연달아 나왔을 정도로 빠르게 바뀌고 있어서, 쿼리 문법이나 MCP 도구의 인자가 이 글과 달라질 수 있습니다. README의 벤치마크도 이전 버전 대비 자체 측정치이니, 내 환경에서 직접 재 보는 것을 권합니다.
마치며
FSearch는 “검색할 때마다 디스크를 훑는다”는 전제를 버리고, 상주 데몬과 트라이그램 색인으로 디스크 전체 검색을 밀리초 단위로 끌어내린 도구입니다. 오타를 허용하는 이름 검색과 grep:, sym:까지 갖춘 내용 검색을 한 명령으로 다루고, MCP 서버로 붙이면 코딩 에이전트가 정의를 찾고 읽는 과정을 한 번에 끝내 줍니다.
macOS를 쓰고 홈 디렉터리 곳곳에 저장소가 흩어져 있다면 한번 설치해 보세요. 저장소 하나 안에서 검색할 때는 ripgrep으로도 충분히 빠르지만, “그 파일 어디 뒀더라?” 싶은 순간이나 에이전트가 검색을 수십 번 반복하는 작업에서는 차이가 분명하게 느껴집니다.
더 자세한 내용은 FSearch GitHub 저장소를 참고하세요.
This work is licensed under CC BY 4.0