hooks 기반 이벤트 드리븐 위젯을 만들고, 그 과정에서 왜 Electron을 선택했는지, hook과 기록 파일이라는 두 신호를 어떻게 교차 검증했는지 정리했습니다.
Claude Code를 여러 탭에 띄워놓고 병렬로 작업하는 경우가 많았습니다. 한쪽에서는 코드 리뷰를 돌려놓고, 다른 탭에서는 별개의 작업을 하는 식이었습니다. 문제는 세션이 권한 승인을 기다리며 멈춰 있어도 그걸 바로 알 방법이 없다는 점이었습니다. 어떤 탭은 도구를 실행하며 잘 돌아가고 있고, 어떤 탭은 권한 승인을 기다리며 아무 소리 없이 멈춰 있고, 어떤 탭은 이미 끝나 있었습니다. 터미널을 하나씩 열어보는 것 말고는 방법이 없었고, 승인을 기다리는 세션은 한참 뒤에야 발견되곤 했습니다.
계기가 하나 더 있었습니다. Claude Code의 소스가 유출된 적이 있었는데, 터미널 UI를 React Ink로 구현했다는 점이 인상적이었습니다. Anthropic 같은 기업도 쓸 만큼 안정적인 라이브러리라는 뜻이었고, React가 웹을 넘어 터미널까지 커버하는 생각보다 넓은 생태계라는 걸 실감했습니다. 평소에는 업무와 직접 연관이 없으면 사이드 프로젝트를 잘 벌이지 않는 편인데, 마침 필요한 앱이 있었기에 이 계기로 직접 만들어보기로 했습니다.
만든 것은 화면 위에 항상 떠 있는 macOS 데스크탑 위젯입니다. 실행 중인 모든 Claude Code 세션의 상태를 카드로 보여주고, 권한 요청이 발생하면 바로 알 수 있게 합니다.
| 기간 · 규모 | 3일 · 105 커밋 (개인) |
| 배포 | Homebrew |
| 링크 | GitHub |
| 구분 | 스택 |
|---|---|
| 앱 | |
| 상태 · 데이터 | |
| 빌드 · 배포 |
React Ink도 써보고 싶었지만, 평소 일하는 환경에서 상시로 켜둘 도구라면 터미널 안에 갇힌 형태보다 데스크탑 앱이 더 적합하다고 판단했습니다. Electron은 Chromium과 Node.js를 함께 패키징하는 프레임워크로, React 앱이 Chromium 위에서 돌아가고 Node.js가 OS와 소통하는 구조입니다.
여기서 처음의 전제 하나가 실제로는 틀렸다는 걸 나중에 깨닫게 됩니다. React Ink를 보고 "React가 웹을 벗어나 훨씬 넓은 곳까지 간다"고 생각해 Electron을 골랐는데, 막상 만들어보니 Electron의 Renderer는 Chromium 그 자체였습니다. React가 웹을 벗어난 게 아니라, 웹이 데스크탑 안으로 들어온 것에 가까웠습니다. 실제로 이 프로젝트에서 웹과 달랐던 지점은 React가 아니라 그 바깥 — 프로세스 분리, IPC, 파일 접근 권한 — 이었습니다.
Electron 공식 문서의 Security 페이지는 이 차이를 명확히 짚습니다.
As web developers, we usually enjoy the strong security net of the browser... When working with Electron, it is important to understand that Electron is not a web browser. It allows you to build feature-rich desktop applications with familiar web technologies, but your code wields much greater power. JavaScript can access the filesystem, user shell, and more.
브라우저는 많은 보안을 대신 처리해주지만 Electron의 Chromium은 그렇지 않습니다. 파일 시스템과 OS API에 직접 접근할 수 있는 만큼, 그 권한을 어떻게 나누느냐가 설계의 핵심이 됩니다. 그래서 Electron은 Main(Node.js)과 Renderer(Chromium) 프로세스를 구조적으로 분리합니다. Main이 파일 시스템·네트워크·OS API를 담당하고, Renderer는 샌드박스 안에서 React UI만 그립니다. 이 프로젝트에서는 ~/.claude/projects/ 아래 세션 파일을 읽어야 했으므로, 그 접근은 전부 Main에서 처리하고 Renderer는 IPC로 결과만 받는 구조를 세웠습니다.
가장 먼저 정해야 했던 설계 결정이었습니다. Claude Code는 자신의 상태를 알려주는 API를 제공하지 않습니다. 두 갈래의 실마리가 있었습니다.
Hook — 도구 실행 전후, 권한 요청, 세션 종료 등 특정 시점에 외부 명령을 실행해주는 Claude Code의 기능입니다. 빠르고 정확하지만, 위젯 앱이 켜지기 전에 시작된 세션은 알 수 없고 hook이 항상 오는 것도 아닙니다.
세션 기록 파일 — ~/.claude/projects/ 아래 세션마다 쌓이는 JSONL 파일입니다. 느리지만 모든 세션에 대해 항상 존재하는 근거입니다.
각각 약점이 뚜렷했기 때문에, 하나만 고르는 대신 둘 다 쓰고 한 곳에서 합치는 쪽을 선택했습니다. hook은 stdin으로 받은 이벤트를 hook-cli를 거쳐 hook 서버의 메모리 상태(Map)에 남기고, 세션 기록 파일은 별도의 watcher가 변경을 감지해 파싱합니다. 이 두 경로는 scanProjects라는 한 곳으로 모여, 기록 파일을 파싱한 결과와 hook이 남긴 메모리 상태를 세션 ID로 join한 뒤 IPC로 Renderer에 전달됩니다.
처음에는 ~/.claude/projects/의 jsonl 파일을 주기적으로 읽는 폴링 방식이었습니다. 이때는 Renderer가 먼저 요청하는 ipcRenderer.invoke + ipcMain.handle 패턴(요청-응답)만 있으면 충분했습니다. 하지만 폴링 주기 때문에 실제 이벤트 발생과 위젯 반영 사이에 딜레이가 생겼고, 이 문제를 hook 기반 이벤트 드리븐 방식으로 바꾸며 흐름이 역전됐습니다. Main이 외부 이벤트를 먼저 받아 webContents.send로 Renderer에 push하는 구조가 필요해진 것입니다. SSE처럼 서버가 클라이언트에 이벤트를 미는 패턴과 같습니다.
Renderer가 ipcRenderer를 직접 다루는 것은 보안상 권장되지 않으므로, preload 스크립트가 contextBridge로 안전하게 노출할 API만 선택적으로 정의합니다.
// preload/index.ts
contextBridge.exposeInMainWorld('claudePet', {
getSessions: () => ipcRenderer.invoke('GET_SESSIONS'),
onSessionsUpdate: (cb) => {
ipcRenderer.on('SESSIONS_UPDATE', (_, sessions) => cb(sessions))
return () => ipcRenderer.off('SESSIONS_UPDATE', cb)
},
})
hook으로 실행되는 스크립트는 Claude Code가 띄우는 짧은 수명의 별개 프로세스입니다. 이 스크립트가 Electron 앱의 HTTP 서버가 어느 포트에서 대기 중인지 알아낼 표준적인 방법이 없었습니다. 포트를 고정하면 간단하지만, 다른 프로그램이 이미 그 포트를 쓰고 있으면 충돌이 납니다.
hook은 사용자의 실제 작업 세션 안에서 실행됩니다. 여기서 에러가 나거나 응답이 오래 걸리면, 위젯 하나 때문에 사용자의 진짜 작업이 지연되는 상황이 생깁니다.
파일이 바뀔 때마다 전체를 다시 읽으면 앱이 점점 느려지는 구조였습니다.
권한 요청이 오면 hook이 상태를 waiting_permission으로 바꾸고, 사용자가 거부하면 PermissionDenied hook이 와서 다시 풀어줘야 합니다. 그런데 일부 도구는 이 hook 자체를 보내지 않았습니다. 그 결과 실제로는 이미 끝난 세션이 화면에서는 계속 "승인 대기 중"으로 표시됐습니다.
특히 로그인 항목으로 자동 실행될 때마다 경고가 반복됐습니다.
앱이 켜질 때 포트 0으로 서버를 열어 OS가 빈 포트를 골라주게 하고(listen(0), 로컬 주소에만), 그 번호를 ~/.claude/.pet-hook-port 파일에 적어둡니다. hook 스크립트는 이 파일을 읽어 어디로 요청을 보낼지 알아냅니다. 앱이 종료되면 파일을 지우고, 앱이 꺼져 있으면 hook 스크립트는 파일이 없는 것을 확인하고 아무것도 하지 않은 채 즉시 종료합니다.
Claude Code는 이벤트 데이터를 stdin으로 hook 스크립트에 넘겨줍니다. 이 스크립트는 esbuild로 단독 실행 파일로 만든 hook CLI이며, 읽은 데이터를 Electron 앱 내부의 HTTP 서버로 전달하는 역할만 합니다.
hook 스크립트는 성공하든 실패하든 타임아웃되든 항상 exit(0)으로 종료합니다. 등록할 때도 비동기·5초 타임아웃으로 걸어 둡니다.
// 어떤 경로로 끝나든 exit(0) — Claude Code에 에러를 노출하지 않는다
req.on("error", () => process.exit(0));
req.on("timeout", () => process.exit(0));
사용자가 직접 settings.json을 수정하게 하는 대신, 앱이 시작될 때 자동으로 hook 명령어를 등록하도록 했습니다. 이미 등록된 항목은 태그로 필터링해 중복 등록을 막았습니다.
파일이 바뀔 때마다 전체를 다시 읽는 대신, 어디까지 읽었는지 바이트 위치를 기억해뒀다가 새로 늘어난 부분만 읽습니다. 파싱도 이전 결과에 새 줄만 이어 붙이는 방식입니다.
// 파일이 줄었으면 잘린 것이므로 처음부터 다시 읽는다
const start = size < lastOffset ? 0 : lastOffset;
파일이 갑자기 작아지는 경우(재작성·회전)를 따로 처리하지 않으면 엉뚱한 위치부터 읽어 깨진 JSON을 만나게 됩니다. 이 분기 하나로 그 문제를 막았습니다.
hook만으로는 이 버그를 고칠 방법이 없었습니다. 그래서 hook이 준 waiting_permission 상태를, 기록 파일에 남은 실제 이벤트로 다시 확인하도록 했습니다.
도구 실행 결과가 기록에 남았다는 것은 그 호출이 어떤 식으로든 끝났다는 뜻입니다. hook이 오지 않아도 이걸로 판단할 수 있습니다. 두 출처를 합친 설계가 여기서 값을 했습니다. 하나만 썼다면 이 stuck 버그는 고칠 방법이 없었습니다.
상태는 네 가지(working, waiting_permission, done, aborted)이고, 정렬은 시간순이 아니라 우선순위순입니다.
const PRIORITY = {
waiting_permission: 0,
working: 1,
done: 2,
aborted: 3,
};
시간순 정렬이면 방금 끝난 세션이 맨 위로 올라오는데, 그건 굳이 볼 필요가 없는 정보입니다. 정작 봐야 할 "승인 대기"는 아래로 밀려납니다. 위젯을 만든 목적 자체가 승인 대기를 놓치지 않는 것이었으므로, 나를 필요로 하는 상태가 항상 맨 위에 오도록 했습니다.
Apple 개발자 계정 없이 서명·공증하는 정식 해결책을 쓰기엔 개인 프로젝트에 연 99달러는 부담이었습니다. 대신 앱이 시작할 때 자기 자신에게 붙은 격리 표시(quarantine attribute)를 스스로 떼도록 했습니다. 근본 해결이 아니라, 조건이 바뀌면(서명하게 되면) 걷어내야 하는 코드라는 걸 그대로 기록해두었습니다.
3일, 105개 커밋으로 만들었고 코드는 1,700줄 남짓, 파일 하나가 120줄을 넘지 않는 규모입니다. 만들면서 남은 것은 크게 세 가지입니다.
하나, 믿을 수 없는 신호가 있으면 믿을 수 있는 다른 신호와 교차 확인할 수 있는 구조를 만들어두는 편이 낫다는 것입니다. hook만 썼다면 승인 대기 무한 루프를 고칠 방법이 없었고, 기록 파일만 썼다면 반응이 느렸을 겁니다.
둘, 남의 프로세스 안에서 도는 코드는 실패하더라도 조용히 실패해야 한다는 것입니다. 제 위젯이 안 뜨는 것보다, 그것 때문에 Claude Code 자체가 느려지는 게 훨씬 나쁩니다.
셋, 시작할 때 품었던 전제가 틀렸다는 것을 직접 확인했습니다. React Ink를 보고 "React가 웹을 벗어나 훨씬 넓은 곳까지 간다"고 생각해 Electron을 골랐지만, 실제로 만들어보니 Electron의 Renderer는 Chromium이었고, React가 웹을 벗어난 게 아니라 웹이 데스크탑 안으로 들어온 것에 가까웠습니다. 이 프로젝트에서 진짜 새로웠던 지점은 React가 아니라 그 바깥 — 프로세스 분리, IPC, 파일 접근 권한 — 이었습니다. 정작 이 여정의 계기가 됐던 React Ink는 아직 써보지 못했고, 여전히 숙제로 남아 있습니다.
특정 요청이 아니라 아무 요청에서나 502가 났습니다. 커넥션을 재사용하는 쪽과 받는 쪽의 타임아웃이 서로를 모른 채 돌고 있었고, 그 틈에서 죽어가는 소켓이 재사용되고 있었습니다.
메타 태그를 다 넣었는데도 검색 결과는 파란 링크 한 줄뿐이었습니다. 검색엔진이 페이지를 '읽는' 것과 '이해하는' 것은 다른 문제였고, 그 사이를 메우는 게 구조화된 데이터였습니다.
전역 Suspense로 감싸면 경고는 사라지지만, 페이지의 정적 마크업까지 placeholder로 대체됩니다. Next.js 소스코드를 따라가 BailoutToCSRError가 CLIENT_RENDERED 경계로 어떻게 흐르는지 살펴봅니다.
문서에는 "queued"라고만 적혀 있습니다. 호출 단위인지 컴포넌트 단위인지 탭 전체인지 알 수 없어서, 30개 요청을 세 가지 방식으로 직접 재봤습니다.
참조형 데이터는 내용이 같아도 주소가 다르면 다른 값으로 취급됩니다. 이 특성이 useEffect 의존성 배열과 React.memo에서 왜 문제가 되는지, useCallback과 useMemo가 무엇을 해결하는지, 그리고 왜 모든 곳에 쓰면 안 되는지 정리합니다.
인앱 브라우저에서 기능이 깨지는 문제와 대용량 목록 렌더링 성능 문제, 두 가지를 실제로 부딪히고 풀어낸 기록입니다.