트렌딩 딥다이브 · 2026-07-18 · OSSInsight

ILoveBingLu/CipherTalk 딥다이브
— 내 채팅 기록을 되찾아오는 Electron 리버스엔지니어링 교보재

密语(CipherTalk)내 PC에 이미 저장돼 있지만 암호화돼서 나조차 열 수 없는 위챗(WeChat) 채팅 DB를 복호화해 읽고, 검색하고, 내보내고, AI 에이전트로 분석하는 Electron 데스크톱 앱이다. 기능 설명만 들으면 평범한 뷰어 같지만, 이 저장소가 공부 자료로 값진 이유는 따로 있다. "남이 만든 포맷을 리버스엔지니어링해서 안정적으로 다루는 법"의 교과서이기 때문이다 — 네이티브 라이브러리가 죽어도 앱은 살아남는 3단 프로세스 격리, 버전마다 바뀌는 컬럼명을 견디는 방어적 파싱, use-after-free를 한 줄로 막는 비동기 큐 같은 패턴이 실전 코드로 깔려 있다. 동시에 핵심 복호화 로직은 컴파일된 DLL로만 배포되고 소스가 없다는 점, webSecurity:false가 전역이라는 점 등 비판적으로 읽어야 할 부분도 뚜렷하다. 이 문서는 둘 다 다룬다. (저장소: ILoveBingLu/CipherTalk · TypeScript · CC BY-NC-SA 4.0 · 별 1,244 · 포크 325 · 버전 2026.7.16)
목차
  1. 프로젝트 한 줄 요약
  2. 왜 주목받는가 — 데이터 주권이라는 프레임
  3. 기술 스택 전체 지도
  4. 아키텍처 심화 분석 — 암호화된 DB에서 화면까지
  5. 디렉토리 구조 해부
  6. 학습 포인트 — 기술별 배울 것
  7. 하드웨어 · 시스템 요구사항
  8. 직접 해볼 수 있는 실습 과제
  9. 관련 기술 심화 학습 로드맵
  10. 핵심 키워드 사전
  11. 참고 링크 · 한계와 주의사항

1프로젝트 한 줄 요약

이 저장소가 정확히 무엇인가
한 문장으로

CipherTalk는 "내 컴퓨터 안에 있는데 내가 못 읽는 데이터"를 되찾는 도구다. 위챗이 로컬에 암호화해 저장해 둔 SQLite DB의 키를 실행 중인 위챗 프로세스 메모리에서 꺼내와, 그 기록을 내 것으로 다시 열어준다.

메신저 앱은 대화 기록을 내 PC에 저장한다. 그런데 그 파일은 SQLCipher로 암호화돼 있고, 키는 앱만 안다. 즉 물리적으로는 내 디스크에 있지만 실질적으로는 접근할 수 없다. CipherTalk가 하는 일은 정확히 이 간극을 메우는 것이다.

동작 순서는 네 단계다. ① 위챗 데이터 폴더를 자동으로 찾고 → ② 실행 중인 Weixin.exe의 메모리를 스캔해 64자리 hex 키를 추출하고 → ③ 텐센트가 오픈소스로 공개한 DB 엔진 WCDB를 FFI로 불러 그 키로 DB를 열고 → ④ 파싱한 메시지를 React 화면·전문검색·AI 에이전트에 흘려보낸다.

용어
SQLCipher
SQLite 파일 전체를 AES로 암호화해 주는 확장. 파일을 열려면 비밀번호(키)가 있어야 하고, 없으면 그냥 깨진 바이너리로 보인다. 메신저·노트 앱이 로컬 DB를 보호할 때 흔히 쓴다.
용어
FFI (Foreign Function Interface)
한 언어에서 다른 언어로 컴파일된 함수를 직접 호출하는 다리. 여기서는 JavaScript(Node.js)가 C/C++로 만들어진 .dll 안의 함수를 부른다. CipherTalk는 koffi라는 라이브러리로 이 다리를 놓는다.

이 앱이 실제로 제공하는 기능

영역내용
열람세션 목록, 가상 스크롤 채팅 뷰, 날짜 점프, 이미지·영상·음성 재생
검색SQLite FTS5 키워드 검색 + 임베딩 벡터 검색 + RRF 하이브리드
내보내기메시지 7종(html/json/jsonl/txt/xlsx/sql/chatlab), 연락처 3종(json/csv/vcf), 모먼츠 3종
AI 에이전트27종 도구(통계·검색·타임라인·미디어·기억·이미지생성 등)를 쓰는 대화형 에이전트
음성SILK 코덱 디코딩, Sherpa-ONNX SenseVoiceSmall 로컬 STT, TTS, 실시간 음성통화
확장MCP 서버/클라이언트 양방향, 플러그인 시스템, 스킬 시스템, 데스크톱 펫
CLImiyu 명령 11종 — Electron 없이 터미널에서 같은 데이터에 접근
먼저 짚고 갈 것
이 문서는 "이 도구를 쓰라"는 안내가 아니다

CipherTalk는 자기 계정의 로컬 기록을 여는 용도를 전제한 도구이고, README도 학습·연구 목적임과 법규 준수를 명시한다. 남의 기기·남의 계정 데이터에 적용하면 통신비밀 침해가 되고, 나라에 따라 명백한 범죄다.

또한 위챗의 이용약관과 충돌할 소지가 있고, 프로세스 메모리를 읽는 동작은 백신이 차단할 수 있다. 이 문서의 목적은 거기 쓰인 소프트웨어 공학 기법을 배우는 것이다.

2왜 주목받는가

트렌딩 이유와 경쟁 제품 대비 위치

① "데이터 주권"이라는 서사가 기능보다 앞선다

README에서 기능 목록보다 먼저 나오는 건 개발자의 비전 세 문단이다. 요약하면 이렇다.

저장소가 내세우는 세 가지 이유

1. 그리움에 온도를 남긴다 — 가족이 세상을 떠나면 그 사람의 흔적은 휴대폰 속 기록에만 남는다. 그걸 가족의 디지털 자산으로 정리해 준다. (README는 영화 《코코》의 "죽음이 아니라 잊히는 것이 끝"이라는 대사를 인용한다.)

2. 불의에 증거를 남긴다 — 괴롭힘·협박·모욕을 당했을 때 방대한 기록에서 핵심을 찾아 추적 가능한 사실의 사슬로 정리한다. 실제로 코드에도 evidenceService.ts가 있다.

3. 기록을 사용자 손으로 — 기기 교체·계정 이상·시간 경과로 사라지는 대신, 기록이 진짜 사용자 것이 되게 한다.

기술 저장소가 이런 식으로 감정적 서사를 전면에 세우는 건 드물다. 그리고 이게 별 1,200개를 모은 실질적 동력이다. 배울 점은 여기 있다 — 오픈소스에서 "왜 만들었는가"는 "무엇을 만들었는가"만큼 배포력이 있다.

② 경쟁 제품 대비 — 뷰어를 넘어 "분석 워크스테이션"으로

비교축일반적인 채팅 백업 도구CipherTalk
범위DB 덤프 → HTML 출력열람 + 검색 + 분석 + 에이전트 + 확장
검색LIKE 쿼리FTS5 + 벡터 임베딩 + RRF 융합 + 리랭킹
미디어이미지 정도이미지 dat 복호화, SILK 음성 디코딩+STT, 영상, 이모티콘
AI없음AI SDK v7, 27종 도구, 로컬 임베딩, MCP 양방향
인터페이스GUI 단일GUI + 독립 CLI(miyu) + MCP 서버
안정성 설계단일 프로세스utilityProcess 격리 + 워커풀 + 네이티브/TS 이중 구현

특히 MCP 서버를 노출한다는 점이 요즘 맥락에서 중요하다. Claude Code 같은 외부 에이전트가 miyu mcp로 이 데이터에 붙을 수 있다는 뜻이고, 앱이 스스로를 "최종 UI"가 아니라 데이터 액세스 레이어로 포지셔닝했다는 신호다.

설계 관점
"앱"이 아니라 "레이어"로 만들면 수명이 길어진다

같은 데이터 계층 위에 GUI·CLI·MCP 서버 세 얼굴을 올렸다. 사용자가 어떤 방식으로 접근하든 아래는 하나다. 개인 프로젝트에서도 핵심 로직을 UI에서 떼어내면 나중에 CLI나 API를 붙이는 비용이 극적으로 줄어든다.

③ 그런데 왜 신중하게 봐야 하나

비판적 독해 1
핵심 로직이 오픈소스가 아니다

저장소는 "오픈소스"지만, 가장 중요한 세 가지 — DB 복호화(WCDB.dll, wcdb_api.dll), 키 추출(wechat_key_tool.dll), 이미지 네이티브 복호화(.node) — 는 컴파일된 바이너리로만 들어 있고 소스가 없다. package.json이 native/image-decrypt/Cargo.toml을 참조하지만 그 Rust 소스는 저장소에 없다.

이 앱을 소스만 보고 처음부터 다시 만들 수는 없다. 그리고 사용자 입장에서는 "내 위챗 프로세스 메모리를 읽는 검증 불가능한 바이너리"를 신뢰해야 한다는 뜻이기도 하다. 오픈소스 배지를 볼 때 무엇이 열려 있고 무엇이 닫혀 있는지 구분하는 훈련을 하기에 좋은 사례다.

비판적 독해 2
webSecurity: false 가 전역이다

모든 BrowserWindowwebSecurity:false로 뜬다. 로컬 파일·미디어를 편하게 로딩하려는 선택이지만, 동일 출처 정책(Same-Origin Policy)을 통째로 끄는 것이라 렌더러에 임의 콘텐츠가 들어오는 순간 방어선이 사라진다. 플러그인 시스템까지 있는 앱에서는 특히 위험하다.

올바른 대안은 이 앱이 이미 부분적으로 쓰고 있는 커스텀 프로토콜(local-image:// 등)로 미디어를 서빙하고 webSecurity는 켜 두는 것이다. "편의를 위해 보안 스위치를 끄는" 실제 사례로 기억해 두자.

비판적 독해 3
클라이언트에 박힌 개인키는 보안이 아니다

키 추출 DLL은 Ed25519 서명으로 호출자를 인증한다. 그런데 그 개인키가 XOR 0x5a로 난독화돼 소스에 하드코딩돼 있다. 코드를 읽을 수 있는 사람은 누구나 5분이면 복원한다.

이건 보안이 아니라 과속방지턱이다 — 그리고 그렇게 이해하면 정당한 설계일 수도 있다. 문제는 이걸 진짜 보안으로 착각하는 것. 클라이언트에 있는 비밀은 비밀이 아니다는 원칙의 교과서적 예시다.

3기술 스택 전체 지도

무엇으로 만들어졌고, 왜 그것을 골랐는가

메인 프로세스 (백엔드 역할)

기술버전역할과 선택 이유
Electron^39.6.0런타임. 로컬 파일 접근 + 네이티브 FFI + 웹 UI를 한 번에 필요로 하므로 사실상 유일한 선택지
koffi^2.9.0FFI. C 함수 시그니처를 문자열로 선언해 DLL 호출. node-gyp 빌드가 필요 없어 배포가 쉽다
better-sqlite3^12.5.0앱 자체 DB에만 사용(설정·기억·FTS 인덱스). 위챗 DB에는 안 쓴다. 동기 API라 코드가 단순
fzstd^0.1.1zstd 압축 해제. 위챗이 메시지 본문을 zstd로 눌러 저장하기 때문
silk-wasm^3.7.1SILK 음성 코덱 디코딩(WASM). 위챗 음성메시지가 SILK 포맷
sherpa-onnx-node^1.12.23로컬 음성인식(STT). SenseVoiceSmall 모델. 서버 전송 없이 기기에서 처리
sharp / ffmpeg-static^0.34.5 / ^5.3.0이미지 변환 / 영상·오디오 트랜스코딩
electron-store^10.0.0설정 영속화
electron-updater^6.3.9자동 업데이트

AI · 검색 계층

기술버전역할
ai (Vercel AI SDK)^7.0.19LLM 호출 추상화. 스트리밍·도구호출 표준화
@ai-sdk/anthropic / openai / google / openai-compatible^4.x / ^3.0.7프로바이더 어댑터. openai-compatible로 DeepSeek·Ollama 등 흡수
@modelcontextprotocol/sdk^1.27.1MCP 서버·클라이언트 양방향
SQLite FTS5(better-sqlite3 내장)전문검색 가상테이블 message_index_fts
OpenTelemetrysdk-trace-node ^2.9.0AI 호출 트레이싱 — 개인 앱치고 이례적으로 진지한 관측성
zod^4.1.12도구 파라미터 스키마 + 런타임 검증

렌더러 (프론트엔드)

기술버전역할과 특기사항
React^19.2.3UI. StrictMode 사용
react-router-dom^7.1.1HashRouter — Electron의 file:// 환경에서 BrowserRouter는 새로고침 시 깨지므로
zustand^5.0.2상태관리. 스토어 10개. Redux 없음, 전역 Context 없음
HeroUI^3.1.0실사용 UI 라이브러리(76개 파일이 import)
Tailwind CSS^4.3.0@tailwindcss/vite 플러그인 방식. PostCSS 설정 파일 없음
virtua^0.49.1가상 스크롤. 수십만 건 메시지를 렌더링하는 핵심
ECharts^6.1.0차트 — 단 2개 파일에서만 사용. AI가 만든 차트 스펙 렌더링 전용
streamdown + shiki^2.5.0 / ^4.2.0AI 스트리밍 마크다운 + 코드 하이라이팅
framer-motion / GSAP / Lottie^12.40 / ^3.15 / —애니메이션 3종 병행 (과하다고 볼 수도)
읽을 때 주의
package.json은 "설치된 것"이지 "쓰는 것"이 아니다

이 저장소에는 antd@6.4.3이 설치돼 있지만 from 'antd' import는 0건이다. react-window도 설치돼 있지만 채팅 리스트는 virtua를 쓴다. 과거에 쓰다 갈아탄 흔적이다.

남의 프로젝트 스택을 파악할 때 package.json만 읽으면 이렇게 틀린다. 반드시 grep -r "from 'antd'" src/실제 import를 세어 확인하는 습관을 들이자. 이건 이 문서를 쓰면서도 실제로 했던 검증이다.

구조적 특이점
React와 Electron이 devDependencies에 있다

보통 React는 dependencies에 둔다. 여기서 devDependencies에 있는 이유는 번들러가 렌더러 코드를 전부 하나로 묶어버리기 때문이다 — 최종 산출물에 node_modules/react가 들어갈 필요가 없다. Electron 앱에서는 합리적이지만, 라이브러리 프로젝트에서 이러면 사용자가 설치할 때 의존성이 빠진다. 배포 형태에 따라 정답이 다르다는 예시.

인프라 · 빌드

항목내용
번들러Vite 6 + vite-plugin-electron (electron-vite 아님). 엔트리 9개를 각각 번들
패키징electron-builder 25.1.8, compression: maximum
WindowsNSIS 설치본. requestedExecutionLevel: asInvoker(관리자 강제 안 함), oneClick:false
macOSdmg, hardenedRuntime:true, entitlements로 메모리 스캔 권한 요청. notarize 설정은 build 블록에 없음
네이티브 리빌드npmRebuild:false + 루트 postinstall: electron-rebuild로 직접 제어
asarUnpackbetter-sqlite3, sharp, koffi, ffmpeg-static, silk-wasm, sherpa-onnx-node, resources/** — 네이티브는 asar 밖으로 빼야 로드된다
CIGitHub Actions, windows-latest, Node 22.12.0. 태그와 package.json version 불일치 시 빌드 실패 처리
릴리스 노트AI로 자동 생성(AI_API_URL/AI_MODEL 변수)

4아키텍처 심화 분석

암호화된 파일에서 화면의 말풍선까지, 전 구간 추적

전체 시스템 구조도

┌──────────────────────────────────────────────────────────────────┐ │ RENDERER (src/) — Chromium, contextIsolation:true │ │ │ │ React 19 + HashRouter + zustand(10 stores) + HeroUI │ │ MessageListVirtual (virtua 가상 스크롤) │ │ │ │ │ │ window.electronAPI.chat.getMessages()│ └──────────────────────────┼───────────────────────────────────────┘ │ ┌──────────────────────────┼───────────────────────────────────────┐ │ PRELOAD (electron/preload.ts, 931줄) │ │ contextBridge.exposeInMainWorld('electronAPI', { ... }) │ │ ipcRenderer.invoke 303회 · 도메인별 네임스페이스 │ └──────────────────────────┼───────────────────────────────────────┘ │ IPC 'chat:getMessages' ┌──────────────────────────┼───────────────────────────────────────┐ │ MAIN (electron/main.ts + main/ipc/ 31개 핸들러) │ │ │ │ ┌────────────────┐ ┌─────────────────┐ ┌──────────────────┐ │ │ │ dbPathService │ │ wxKeyService │ │ windowManager │ │ │ │ 폴더 자동탐지 │ │ 메모리 키 스캔 │ │ BrowserWindow │ │ │ └───────┬────────┘ └────────┬────────┘ └──────────────────┘ │ │ │ 경로 │ 64-hex 키 │ │ └──────────┬─────────┘ │ │ ▼ │ │ ┌───────────────┐ │ │ │ wcdbService │ ← 부모측 프록시 (재시작 담당) │ │ └───────┬───────┘ │ └──────────────────────┼───────────────────────────────────────────┘ │ MessagePort {id, type, payload} ┌──────────────────────┼───────────────────────────────────────────┐ │ UTILITY PROCESS (wcdbUtilityProcess.ts) ★ 크래시 격리 구역 │ │ │ │ queue = queue.then(handleMessage) ← 전 요청 직렬화 │ │ │ │ │ wcdbCore (koffi FFI) │ │ │ │ │ wcdb_api.dll ──▶ WCDB.dll (SQLCipher) │ └──────────────────────┼───────────────────────────────────────────┘ ▼ ~/Documents/xwechat_files/<wxid>/db_storage/ session.db · contact.db · message_N.db [별도 워커들] imageDecryptWorker (worker_threads, 풀 2~4개) ── .dat 이미지 복호화 transcribeWorker (worker_threads) ── Sherpa-ONNX 음성인식 aiAgentUtilityProcess / exportUtilityProcess ── 장시간 작업 격리

1단계 — 위챗 폴더를 어떻게 찾는가

경로를 하드코딩하지 않는다. 후보를 모아 점수를 매기고 정렬한다. 사용자가 데이터 폴더를 옮겼거나 계정이 여러 개여도 견디는 방식이다.

// dbPathService.ts — 계정 디렉토리인지 판정
private isAccountDir(entryPath: string): boolean {
  return (
    existsSync(join(entryPath, 'db_storage')) ||            // 위챗 v4
    existsSync(join(entryPath, 'FileStorage', 'Image')) ||
    existsSync(join(entryPath, 'FileStorage', 'Image2')) ||
    existsSync(join(entryPath, 'msg', 'attach'))          // 위챗 v3
  )
}

// 점수 = 루트 보너스 + 계정수*10000 + 최근수정시각
//   xwechat_files(v4) = 30000 / WeChat Files(v3) = 20000 / mac 버전디렉 = 50000
// all·applet·backup·wmpf·app_data 로 시작하는 디렉토리는 제외
배울 패턴
후보 점수화 탐색 (scored candidate search)

"경로가 하나로 정해지지 않는" 모든 상황에 쓰는 일반 패턴이다. 확정하지 말고, 후보를 모으고, 점수를 매기고, 순서대로 시도하고, 실패 이유를 모두 기록한다. 설정 파일 탐색, 실행 파일 탐색, 백업 위치 탐색 등에 그대로 재사용된다.

2단계 — 키를 어떻게 꺼내는가 (핵심)

여기가 이 프로젝트에서 기술적으로 가장 흥미로운 지점이다. SQLCipher 키 유도(PBKDF2)를 JS로 구현하지 않는다. 대신 실행 중인 위챗 프로세스의 메모리에서 이미 풀려 있는 키를 찾아낸다.

비유

금고 비밀번호를 수학적으로 푸는 것과, 지금 금고를 열어놓고 일하는 주인의 책상 위에서 메모지를 찾는 것의 차이다. 앞은 사실상 불가능(AES는 안 뚫린다)하고, 뒤는 가능하다 — 단, 주인이 자리에 있어야만.

그래서 CipherTalk는 위챗이 실행 중이고 로그인된 상태를 요구한다. 위챗을 끄면 키를 못 얻는다. 이게 "복호화 도구"들이 공통으로 갖는 제약이다.

그리고 이 DLL은 아무나 부르지 못하게 Ed25519 챌린지-응답을 건다. DLL이 난수를 주면, 호출자가 개인키로 서명해 돌려줘야 진짜 작업을 해 준다.

// wxKeyService.ts — 내장 개인키 복원 (XOR 0x5a 난독화)
private getScanPrivateKey(): crypto.KeyObject {
  const obf = '6a74585b5a6a5f5c59713f2a5e785e7a...'
  const der = Buffer.from(Buffer.from(obf, 'hex').map(v => v ^ 0x5a))
  return crypto.createPrivateKey({ key: der, format: 'der', type: 'pkcs8' })
}

scanDbKeyDiag(contactDbPath: string): WxScanDiag | null {
  const wktChallenge = this.scanLib.func('int wkt_challenge(uint8_t*, size_t)')
  const wktDiag      = this.scanLib.func('void* wkt_scan_diag_auth(uint8_t*, size_t, str)')
  const wktFree      = this.scanLib.func('void wkt_free(void*)')

  const nonce = Buffer.alloc(32)
  if (wktChallenge(nonce, 32) !== 32) return null   // ① DLL이 난수 발급
  const sig = crypto.sign(null, nonce, this.getScanPrivateKey()) // ② Ed25519 서명
  const ptr = wktDiag(sig, sig.length, contactDbPath)             // ③ 인증+스캔

  const d = JSON.parse(String(koffi.decode(ptr, 'char', -1)).replace(/\0/g, ''))
  wktFree(ptr)                                            // ④ 네이티브 메모리 해제 필수
  return { key: d.key?.length === 64 ? d.key : null, auth: d.auth !== false,
           dbOk: d.db_ok !== false, pids: +d.pids||0, opened: +d.opened||0,
           bytes: +d.bytes||0, markers: +d.markers||0, candidates: +d.candidates||0 }
}
배울 패턴
실패를 "진단 가능하게" 설계하기

리턴값에 pids / opened / bytes / markers / candidates가 왜 들어 있을까? 실패 원인을 구분하기 위해서다.

pids:0 = 위챗이 안 켜져 있음 · opened:0 = 프로세스는 있는데 열 권한이 없음(관리자 필요) · bytes:0 = 열었는데 못 읽음 · candidates:0 = 다 읽었는데 키 패턴이 없음(위챗 버전 불일치).

불리언 하나로 "실패"만 돌려주면 사용자는 뭘 고쳐야 할지 모른다. 실패 경로에 계측을 심는 것은 사용자 지원 비용을 극적으로 줄인다. 개인 프로젝트에서도 따라 할 만한 습관이다.

3단계 — DLL을 어떻게 부르는가

koffi로 C 함수 시그니처를 문자열로 선언하면 그대로 호출 가능한 JS 함수가 된다. 컴파일 단계가 없어서 배포가 쉽다.

// wcdbCore.ts — FFI 바인딩
this.koffi = require('koffi')
this.lib = this.koffi.load(libraryPath)   // resources/wcdb_api.dll

this.wcdbInit        = this.lib.func('int32 wcdb_init()')
this.wcdbOpenAccount = this.lib.func(
  'int32 wcdb_open_account(const char* path, const char* key, _Out_ int64* handle)')
this.wcdbExecQuery   = this.lib.func(
  'int32 wcdb_exec_query(int64 handle, const char* kind, const char* path,' +
  ' const char* sql, _Out_ void** outJson)')

// ★ 심볼 능력 탐지 — DLL에 없으면 null로 두고 기능만 degrade
const tryBind = (decl: string): any => {
  try { return this.lib.func(decl) } catch { return null }
}
this.wcdbExecQueryWithParams = tryBind('int32 wcdb_exec_query_with_params(...)')
this.wcdbExportMessageChunk  = tryBind('int32 wcdb_export_message_chunk(...)')
배울 패턴
기능 탐지(feature detection) — 버전 스큐 흡수

앱과 DLL이 따로 업데이트되면 버전이 어긋난다. 구버전 DLL에 없는 함수를 부르면 크래시. tryBind()있으면 쓰고 없으면 null로 두어, 앱이 죽는 대신 그 기능만 비활성화되게 한다.

웹에서 if ('IntersectionObserver' in window)로 브라우저 기능을 확인하는 것과 정확히 같은 사고방식이다. "버전을 확인"하지 말고 "능력을 확인"하라는 원칙.

그리고 DB 파일을 열 때도 역시 단정하지 않는다. session.db 후보를 점수순으로 전부 시도하고, 실패 이유를 모아 둔다.

private tryOpenWithCandidates(sessionDbPaths: string[], hexKey: string) {
  for (const sessionDbPath of sessionDbPaths) {
    const handleOut = [0]
    const result = this.wcdbOpenAccount(sessionDbPath, hexKey, handleOut)
    if (result === 0 && handleOut[0] > 0)
      return { success: true, handle: handleOut[0], matchedPath: sessionDbPath }
    errors.push(`${sessionDbPath} => ${this.mapStatusCode(result)}`)
  }
  // 전부 실패 시 errors 배열이 그대로 사용자에게 노출된다
}

4단계 — 크래시 격리와 use-after-free 방어 (백미)

FFI로 네이티브를 부르면 C 쪽에서 죽을 때 Node 프로세스가 통째로 죽는다. try/catch로 못 잡는다. CipherTalk의 해법은 두 겹이다.

겹 1 — 프로세스 격리. WCDB 호출을 Electron utilityProcess에 몰아넣는다. 죽어도 그 프로세스만 죽고, 부모(wcdbService)가 대기 중인 요청을 reject한 뒤 재시작한다.

겹 2 — 요청 직렬화. 이게 진짜 핵심이다. 저장소 주석이 문제를 정확히 서술한다.

// wcdbUtilityProcess.ts
// 모든 요청을 직렬화: 각 요청(커서 open→fetch→close 전 과정)이 끝나야
// 다음이 시작된다. close/open/shutdown 이 어떤 커서 배치의 await 틈새에
// 끼어들어, 네이티브 핸들이 해제된 뒤에도 날아가던 fetch 가 그걸 쓰는
// 상황(use-after-free → koffi napi_throw → utility process fatal)을 막는다.
let queue: Promise<void> = Promise.resolve()

parentPort.on('message', (event: Electron.MessageEvent) => {
  const msg = event.data
  queue = queue.then(() => handleMessage(msg)).catch(() => undefined)
})
비유 — 왜 큐가 필요한가

도서관에서 A가 책을 펼쳐 놓고 읽는 중간에 B가 와서 그 책을 서가에 반납해 버리면, A는 사라진 책을 읽으려다 넘어진다. 이게 use-after-free다.

JS의 await는 "여기서 잠깐 다른 일 해도 돼"라는 뜻이다. 커서를 열고 데이터를 await로 가져오는 사이에 다른 요청의 close가 실행될 수 있다. 큐는 "한 사람이 책을 다 읽고 덮을 때까지 아무도 손대지 않는다"는 규칙이다.

단 세 줄이지만, 이게 없으면 앱은 재현 불가능한 랜덤 크래시에 시달린다.

프로토콜은 단순한 요청-응답이되, 특수 ID로 이벤트를 섞어 보낸다.

부모 → 자식 : { id: 1, type: 'query', payload: {...} } 자식 → 부모 : { id: 1, result: [...] } 또는 { id: 1, error: '...' } 특수 ID: id === 0, type: 'ready' → 자식 부팅 완료 신호 id === -1, type: 'monitor' → 네이티브 파이프에서 올라온 DB 변경 이벤트 (새 메시지 실시간 감지)

5단계 — IPC 계층 (메인 ↔ 렌더러)

보안 설정부터 보자. 모든 창이 동일하다.

webPreferences: {
  preload: join(__dirname, 'preload.js'),
  devTools: ctx.allowDevTools,
  contextIsolation: true,     // ✅ 렌더러와 preload의 JS 컨텍스트 분리
  nodeIntegration: false,     // ✅ 렌더러에서 require 금지
  webSecurity: false          // ⚠️ 동일 출처 정책 전역 해제 — 앞서 지적한 문제
}

통신은 세 가지 패턴을 쓴다.

패턴 1 — invoke/handle (요청-응답). 지배적 패턴. preload.tsipcRenderer.invoke가 303회 등장하고, 도메인별 네임스페이스로 묶여 window.electronAPI에 노출된다. 채널 이름은 도메인:동작 규약(config:get, wcdb:connect, plugin:invoke).

패턴 2 — send/on (이벤트 푸시). 여기서 배울 규약이 하나 있다. 리스너 등록 함수가 해제 함수를 리턴한다.

onChanged: (callback) => {
  const listener = (_: any, payload) => callback(payload)
  ipcRenderer.on('config:changed', listener)
  return () => { ipcRenderer.removeListener('config:changed', listener) }
}

// 덕분에 React에서 이렇게 쓸 수 있다 — 메모리 누수 원천 차단
useEffect(() => {
  return window.electronAPI.config.onChanged(setConfig)
}, [])
배울 패턴
구독 함수는 해제 함수를 반환하라

useEffect의 cleanup과 정확히 맞물린다. 이 규약이 없으면 개발자가 removeListener를 직접 호출해야 하는데, 리스너 참조를 따로 보관해야 해서 거의 항상 빠뜨린다. API가 올바른 사용법을 강제하도록 설계하는 좋은 예다.

패턴 3 — 커스텀 RPC. invoke를 쓸 수 없는 이른 시점(preload 초기화 중)에는 requestId로 응답 채널을 만들고 타임아웃을 건다.

const requestId = `${Date.now()}-${Math.random()}`
const responseChannel = `app:getMcpLaunchConfig:response:${requestId}`
const timeout = setTimeout(() => {
  ipcRenderer.removeAllListeners(responseChannel); resolve(null)   // 실패해도 진행
}, 600)
ipcRenderer.once(responseChannel, (_, payload) => {
  clearTimeout(timeout); resolve(payload ?? null)
})
ipcRenderer.send('app:getMcpLaunchConfig:request', { requestId })

600ms 안에 답이 없으면 null로 진행한다. 부가 기능 때문에 앱 부팅이 막히지 않게 하는 설계다.

6단계 — 데이터 파싱: 리버스엔지니어링의 현실

여기서부터가 "남이 만든 포맷"과 싸우는 구간이다. 먼저 테이블 이름부터 해시다.

// 메시지 테이블 이름 = 'msg_' + md5(sessionId)
crypto.createHash('md5').update(sessionId).digest('hex').toLowerCase()

// 역방향: 테이블 목록에서 메시지 테이블만 골라내기
String(tableName).match(/^msg_([0-9a-f]{32})$/i)
"SELECT name FROM sqlite_master WHERE type='table' AND lower(name) LIKE 'msg_%'"

게다가 컬럼 이름이 위챗 버전마다 다르다. 해법은 후보 배열 + 폴백 조회다.

const MSG_TYPE_COLUMNS = ['local_type', 'localType', 'type', 'Type',
  'msg_type', 'msgType', 'MsgType', 'message_type', 'messageType',
  'WCDB_CT_local_type']

// getRowField(row, names): 정확 매치 먼저 → 없으면 소문자 매치로 폴백
배울 패턴
방어적 파싱 — 스키마가 변한다는 전제로 쓰기

남의 포맷을 읽을 때 row.local_type이라고 쓰면, 상대가 앱을 업데이트하는 순간 undefined가 되고 조용히 망가진다. 후보 배열로 훑고, 못 찾으면 명시적으로 에러를 내는 편이 안전하다.

같은 원리가 외부 API 응답 파싱, CSV 헤더 처리, 설정 파일 마이그레이션에 그대로 적용된다.

본문 자체도 그냥 텍스트가 아니다. zstd 압축이 걸려 있을 수 있다.

// rowDecoders.ts — 매직 넘버로 zstd 판별
const magic = data.readUInt32LE(0)
if (magic === 0xFD2FB528) {                        // zstd 프레임 시그니처
  return Buffer.from(fzstd.decompress(data)).toString('utf-8')
}
// 실패 시: UTF-8 시도 → 대체문자(U+FFFD)가 20% 넘으면 latin1로 폴백
// hex/base64 문자열 추정은 16자 넘을 때만 (짧은 숫자열 오탐 방지)

메시지 타입은 정수 코드로 들어온다. 주요 코드를 정리하면:

local_type의미
1텍스트
3 / 43 / 34이미지 / 영상 / 음성
42 / 48명함 / 위치
47애니메이션 이모티콘
49appmsg — 링크·파일·미니앱·송금·인용이 전부 여기 (XML 서브타입으로 구분)
50 / 10000음성통화 / 시스템 메시지
244813135921인용 메시지 (64비트 확장 타입)

49 안의 XML <type> 서브코드: 2000=송금, 2001=홍바오, 115=선물, 6=파일, 19=대화기록 전달, 33/36=미니프로그램, 57=인용, 5/49=링크.

함정 사례 — 파싱 순서가 정확성을 좌우한다
HTML 엔티티 디코딩을 언제 하느냐

사용자가 XML 조각을 채팅에 붙여넣으면, 그 안의 &lt;title&gt;바깥 XML의 진짜 <title>로 오인돼 엉뚱한 값을 뽑는다. 저장소의 해법은 순서를 바꾸는 것이었다.

const rawContent = content              // ① 원본 보관
content = decodeHtmlEntities(content)   // ② 표시용은 디코딩

// ③ 필드 추출은 "디코딩 전 원본"에서 — 내층은 아직 &lt;title&gt; 상태라 안 걸린다
const title = decodeHtmlEntities(extractXmlValue(rawContent, 'title'))

7단계 — 이미지 복호화: 하이브리드 암호의 실제

위챗 이미지는 .dat 파일로 저장되고, 버전마다 방식이 다르다. 시그니처로 판별한다.

07 08 56 31 08 07 → V1 AES 키가 상수 ('cfcd208495d565ef') 07 08 56 32 08 07 → V2 AES 키를 메모리에서 스캔해야 함 (시그니처 없음) → V3 전체 바이트 단순 XOR (구버전) V4 (0x0F 헤더) 구조: ┌────────────┬──────────────┬───────────────┬──────────────┐ │ header 15B │ AES-128-ECB │ 평문 그대로 │ 꼬리만 XOR │ │ │ aesSize │ │ xorSize │ └────────────┴──────────────┴───────────────┴──────────────┘ header[6:10] = aesSize (LE int32) header[10:14] = xorSize (LE int32)
// datDecryptCore.ts — V4 복호화
const header = bytes.subarray(0, 0x0f)
const data   = bytes.subarray(0x0f)
const aesSize = bytesToInt32(header.subarray(6, 10))
const xorSize = bytesToInt32(header.subarray(10, 14))

// PKCS7: 이미 16의 배수여도 한 블록 더 붙는다 → +16 이 정답
const alignedAesSize = aesSize + (16 - ((aesSize % 16) + 16) % 16)

const decipher = crypto.createDecipheriv('aes-128-ecb', aesKey, null)
decipher.setAutoPadding(false)   // 패딩을 직접 제거해야 정확
unpadded = strictRemovePadding(Buffer.concat([decipher.update(aesData), decipher.final()]))

return Buffer.concat([unpadded, rawData, xoredData])

XOR 키는 어떻게 알아낼까? 썸네일 파일 여러 개의 마지막 2바이트 최빈값으로 역산한다 — JPEG는 항상 FF D9로 끝난다는 사실을 이용한 기지 평문 공격(known-plaintext attack)의 실용 버전이다.

복호화 후에도 컨테이너가 wxgf 포맷이면 여러 오프셋(0x10, 0x12, 0x14, 0x18, 0x20, 0xd0, 0x100)에서 PNG/JPG/GIF/WEBP 매직을 찾고, 그래도 없으면 앞 512바이트에서 ff d8 ff를 브루트포스한다. 포맷 리버싱의 현실적인 모습이다.

배울 패턴
네이티브 우선 + 순수 구현 폴백, 그리고 출처 태깅

이미지 복호화는 빠른 .node 네이티브와 느린 순수 TS 두 경로가 있고, 결과에 source: 'native' | 'ts'fallbackReason을 붙인다. 어느 경로로 처리됐는지 나중에 알 수 있다는 게 핵심 — 성능 문제나 결과 불일치를 디버깅할 수 있다.

같은 알고리즘을 메인 스레드와 워커가 공유하도록 순수 함수로 분리(datDecryptCore.ts)한 것도 좋은 구조다.

암호 재구현의 교훈
상수 하나가 전부를 깨뜨린다

이모티콘·채널 영상 복호화에 쓰이는 isaac64.ts(ISAAC64 난수생성기 재구현)의 주석은 이렇게 경고한다 — 황금비 상수가 0x9e3779b97f4a7c13이지, 흔히 쓰이는 ...7c15아니라는 것. 7c15를 쓰면 키스트림이 전부 달라져 복호화가 통째로 실패한다.

암호 알고리즘을 옮겨 쓸 때는 "대충 맞으면 대충 동작"이 없다. 100% 일치하거나 100% 쓰레기다. 반드시 참조 구현의 테스트 벡터로 검증할 것.

8단계 — 검색: 하이브리드 RAG

사용자 질의 │ ├──▶ FTS5 키워드 검색 ──▶ 순위 리스트 A │ (message_index_fts, better-sqlite3) │ └──▶ 임베딩 벡터 검색 ──▶ 순위 리스트 B (messageVectorService) │ ▼ RRF (Reciprocal Rank Fusion) ← retrieval/rrf.ts score = Σ 1 / (k + rank_i) │ ▼ 리랭킹 모델 (ai/rerankService) │ ▼ 최종 컨텍스트 → LLM
용어
RRF (Reciprocal Rank Fusion)
여러 검색 결과를 합치는 기법. 각 리스트에서의 순위(rank)만 보고 1/(k+rank)를 더한다. 점수 스케일이 다른 검색기들(키워드 점수 vs 코사인 유사도)을 정규화 없이 합칠 수 있어서 실무에서 매우 인기 있다. 보통 k=60.

키워드 검색은 "정확한 단어"에 강하고 벡터 검색은 "비슷한 의미"에 강하다. 둘을 합치면 "작년에 형이 보내준 그 식당 이름" 같은 애매한 질의도 잡힌다. 이게 요즘 RAG의 표준 구성이다.

성능 트릭
UNION ALL 200개 배치

메시지는 "여러 DB 파일 × 세션마다 별도 테이블"로 샤딩돼 있다. 통계를 내려면 수백 개 테이블을 훑어야 하는데, 매번 IPC를 왕복하면 느리다.

해법: 한 DB 안의 여러 테이블을 UNION ALL한 쿼리에 합치되 200개씩 끊는다. SQLite의 복합 SELECT 개수 상한을 피하면서 왕복 횟수를 수백 분의 1로 줄인다. "경계값을 알고 그 직전까지 배치"하는 전형적인 최적화다.

9단계 — 프론트엔드의 한 수: 시그널 정수

가상 스크롤 리스트를 "맨 아래로 스크롤" 시키려면 보통 ref.current.scrollToBottom()처럼 명령형으로 호출한다. 이 저장소는 다르게 했다.

/** ChatPage 가 "지금 맨 아래로 가야 한다"는 의도를 표현하는 신호.
    증가할 때마다 scrollToIndex 로 하단 이동이 한 번 실행된다. */
bottomSignal: number
/** "맨 위로" 의도 신호 (날짜 점프 등) */
topSignal: number

// 자식 쪽
useEffect(() => {
  if (bottomSignal > 0) virtualizerRef.current?.scrollToIndex(items.length - 1)
}, [bottomSignal])
왜 이게 더 나은가

ref로 자식 메서드를 부르면 부모가 자식의 내부 구현을 알아야 하고, 자식이 아직 마운트되지 않았을 때 타이밍 문제가 생긴다.

단조 증가하는 정수를 내려보내면 그냥 평범한 props다. React의 데이터 흐름을 거스르지 않고, 자식은 "값이 바뀌면 스크롤"이라는 규칙만 알면 된다. 부모는 setBottomSignal(n => n + 1)만 하면 끝.

"같은 값을 두 번 보내면?" 문제도 자동 해결된다 — 값이 계속 증가하니 항상 달라진다. (불리언 토글로는 이게 안 된다.)

5디렉토리 구조 해부

어디에 무엇이 있고, 어디부터 읽어야 하는가
CipherTalk/ ├─ electron/ ★ 메인 프로세스 (TypeScript) │ ├─ main.ts 앱 부트스트랩·스킴 등록·싱글인스턴스 락 (376줄) │ ├─ preload.ts contextBridge 단일 파일 (931줄, invoke 303회) │ ├─ mcp.ts 독립 MCP 서버 엔트리 (stdio) │ ├─ wcdbUtilityProcess.ts ★ WCDB 격리 프로세스 (111줄) — 먼저 읽을 것 │ ├─ imageDecryptWorker.ts worker_threads 이미지 복호화 │ ├─ transcribeWorker.ts Sherpa-ONNX 로컬 음성인식 워커 │ ├─ aiAgentUtilityProcess.ts 에이전트 실행 격리 │ ├─ exportUtilityProcess.ts 대용량 내보내기 격리 │ ├─ main/ │ │ ├─ ipc/ 31개 핸들러 (aiHandlers 92KB, pluginHandlers 33KB) │ │ ├─ windows/windowManager.ts 57KB — 모든 BrowserWindow 생성 │ │ ├─ protocols.ts local-image:// local-video:// ct-plugin:// │ │ └─ context.ts MainProcessContext (DI 컨테이너 역할) │ └─ services/ ★ 70+ 파일 — 이 프로젝트의 심장 │ ├─ wxKeyService.ts 윈도우 메모리 키 스캔 (464줄) │ ├─ wxKeyServiceMac.ts macOS 키 추출 │ ├─ wcdbCore.ts ★ koffi FFI 바인딩 (874줄) │ ├─ wcdbService.ts utilityProcess 부모측 프록시 (527줄) │ ├─ dbAdapter.ts all/get/exec + ? 파라미터 인라이닝 (106줄) │ ├─ dbPathService.ts 위챗 폴더 자동탐지 (272줄) │ ├─ datDecryptCore.ts .dat 이미지 복호화 순수함수 (205줄) │ ├─ isaac64.ts ISAAC64 PRNG 재구현 │ ├─ chat/ 17파일 — messageQueries(60KB), emoji(32KB), │ │ media(23KB), contentParsers, rowDecoders, │ │ messageMapper, tableResolver │ ├─ search/ chatSearchIndexService(FTS5, 48KB), │ │ messageVectorService(42KB) │ ├─ retrieval/ retrievalEngine, rrf.ts │ ├─ memory/ memoryDatabase(69KB), nightlyMemoryService, │ │ evidenceService │ ├─ agent/ engine(49KB), tools/ 27개, prompts(37KB), persona/ │ ├─ ai/ 프로바이더별 서비스, TTS(44KB), embedding, rerank │ └─ mcp/ readService(89KB), tools, dispatcher, proxyService │ ├─ src/ ★ 렌더러 (React 19) │ ├─ main.tsx HashRouter 진입점 │ ├─ App.tsx Routes 14개 │ ├─ pages/ 30+ 페이지 + chat/ export/ agent/ 서브트리 │ ├─ stores/ zustand 10개 (auth, chat, theme, image, plugin...) │ ├─ components/{ui,ai,ai-elements,settings}/ │ ├─ features/{aiagent,home,pets,plugins}/ │ └─ services/{config,database,ipc}.ts 렌더러측 얇은 IPC 래퍼 │ ├─ resources/ ⚠️ 클로즈드소스 네이티브 (소스 없음) │ ├─ WCDB.dll (9.6MB) 텐센트 WCDB — SQLCipher 기반 DB 엔진 │ ├─ wcdb_api.dll (229KB) 자체 C API 래퍼 │ ├─ wechat_key_tool.dll (479KB) 메모리 키 스캐너 (Rust 추정) │ ├─ wedecrypt/*.node 이미지 네이티브 복호화 │ └─ macos/ libWCDB.dylib, libwx_key.dylib, libdobby.dylib, │ xkey_helper, entitlements.mac.plist │ ├─ CipherTalk-CLI/ ★ 독립 npm 패키지 (miyu) — 데스크톱 빌드에서 제외 ├─ plugin-sdk/ 퍼블릭 플러그인 SDK (d.ts + js + cli.cjs) ├─ skills/ 내장 스킬 (ct-mcp-copilot, frontend-design) ├─ examples/plugins/ 플러그인 예제 ├─ evaluation/retrieval/ 검색 품질 평가 세트 ├─ scripts/ 30+ 빌드 스크립트 ├─ vite.config.ts ★ 엔트리 9개 정의 — 구조 파악의 지도 ├─ installer.nsh NSIS 커스텀 설치 스크립트 └─ .github/workflows/ release.yml, ciphertalk-cli.yml

읽기 순서 추천

순서파일왜 여기부터인가
1vite.config.ts엔트리 9개가 보인다 = 프로세스 구조의 전체 지도
2electron/main.ts부팅 순서. 376줄로 짧다
3wcdbUtilityProcess.ts111줄. 이 프로젝트에서 가장 밀도 높은 코드
4services/dbPathService.ts점수화 탐색 패턴. 272줄로 자기완결
5services/wcdbCore.tsFFI의 실제. 길지만(874줄) 앞 200줄이면 충분
6services/dbAdapter.ts106줄. 상태머신 파서 연습에 최적
7services/chat/rowDecoders.ts방어적 파싱의 집약
8electron/preload.ts931줄이지만 반복 구조. IPC 규약 확인용
구조 관찰
파일 크기가 설계 부채를 알려준다

aiHandlers.ts 92KB, mcp/readService.ts 89KB, memoryDatabase.ts 69KB, messageQueries.ts 60KB, windowManager.ts 57KB — 단일 파일이 50KB를 넘으면 대개 여러 책임이 뭉쳐 있다는 신호다.

반대로 rrf.ts, isaac64.ts, datDecryptCore.ts처럼 작고 순수한 파일들은 테스트하기 쉽고 재사용된다(워커와 메인이 공유). 남의 코드를 볼 때 ls -lS로 크기순 정렬만 해봐도 구조가 보인다.

CipherTalk-CLI — 별도로 볼 가치가 있는 서브프로젝트

같은 저장소 안에 있지만 완전히 독립된 npm 패키지다. 자체 package.json·lockfile·의존성·테스트·릴리스 워크플로를 갖고, 데스크톱 빌드에서는 "!CipherTalk-CLI/**/*"로 명시 제외된다. Electron을 전혀 쓰지 않는다.

항목내용
이름/버전ciphertalk-cli 0.1.3, 명령어는 miyu
빌드tsup 8.5, 테스트 vitest 4, 개발실행 tsx
의존성commander 14, chalk 5, ora 9, cli-table3, koffi, zod, MCP SDK
명령 11종init config status sessions messages contacts key search export moments mcp
출력 포맷--format json|jsonl|table|csv|markdown
설정 위치~/.miyu/config.json

재미있는 UX 설계가 하나 있다. 서브커맨드 없이 실행하고 TTY면 자동으로 전체화면 인터랙티브 셸에 진입하고, --quiet--format을 주면 파이프 모드로 동작한다. 초보자에게는 메뉴를, 스크립트에게는 JSON을 주는 이중 인터페이스다.

구조적 부채 — 실제 사례
코드 공유를 "복사 + 수동 동기화"로 한다

CLI는 데스크톱의 데이터 계층을 npm run sync:upstream 스크립트로 포팅(복사)한다. 그 결과 wcdbCore.ts, wcdbService.ts, dbAdapter.ts, messageDbScanner.ts양쪽에 중복 존재한다.

동기화 시점은 package.json의 비표준 필드에 기록돼 있다: "_upstream": { "ciphertalk": "6.0.0", "synced_at": "2026-05-14" }.

왜 이렇게 했나 — 데스크톱 코드가 Electron 모듈에 얽혀 있어 그대로 못 쓰기 때문이다. 더 나은 해법은 npm workspaces나 pnpm workspace로 @ciphertalk/core 공유 패키지를 만드는 것. 다만 그러려면 Electron 의존성을 코어에서 걷어내는 리팩터링이 선행돼야 한다. 모노레포 도구는 문제를 해결하지 않는다 — 의존성 분리가 먼저다.

실제 버그 발견
버전 번호가 두 군데에 하드코딩돼 어긋나 있다

package.json"version": "0.1.3"인데, src/cli.ts.version('0.1.0', ...)으로 하드코딩돼 있다. 즉 miyu -V를 치면 0.1.0이 나온다.

교훈: 버전 같은 단일 진실원(single source of truth)은 반드시 한 곳에서만 관리하고 나머지는 읽어 오게 해야 한다. Node라면 import pkg from '../package.json' 한 줄이면 끝날 일이다.

6학습 포인트

이 저장소에서 실제로 가져갈 것들

A. Electron — 프로세스 모델을 제대로 이해하기

Electron 튜토리얼은 보통 "메인 + 렌더러" 2개만 가르친다. 실전 앱은 4종류를 쓴다.

프로세스용도이 앱에서
Main앱 생명주기, 창 관리, OS 접근main.ts + ipc 핸들러 31개
RendererUI. Chromium 샌드박스React 앱
Preload둘 사이의 안전한 다리preload.ts 931줄
utilityProcess죽어도 되는 위험한 작업 격리WCDB, 에이전트, 내보내기
worker_threadsCPU 집약 작업 (같은 프로세스 내 스레드)이미지 복호화 풀, 음성인식

utilityProcess vs worker_threads 선택 기준이 이 저장소에 잘 나타난다.

언제 무엇을 쓰나

utilityProcess (별도 프로세스) — 죽을 수 있는 코드. 네이티브 FFI, 서드파티 바이너리. 프로세스가 분리돼 있어 크래시가 전파되지 않는다. 대신 통신 비용이 크다(직렬화 필요).

worker_threads (같은 프로세스의 스레드) — 안 죽지만 느린 코드. 순수 계산, 이미지 처리. 메모리를 공유할 수 있어 빠르지만, 크래시하면 프로세스 전체가 죽는다.

CipherTalk가 WCDB는 utilityProcess에, 이미지 복호화는 worker에 둔 이유가 바로 이것이다. 이미지 복호화는 순수 JS/네이티브 애드온이라 상대적으로 안전하다.

워커풀 관리도 배울 만하다.

// 코어 수에 맞춰 자동 조절, 상하한 고정
const POOL_SIZE = Math.max(2, Math.min(4, cpus().length - 2))

// 60초 동안 전원 유휴면 스레드 회수 (메모리 반납)
// 풀을 못 쓰면 null 반환 → 호출자가 인라인 복호화로 폴백

cpus - 2로 여유를 남기는 것(메인·렌더러 몫), 최소 2 최대 4로 묶는 것, 유휴 시 회수하는 것 — 데스크톱 앱은 사용자 머신을 독점하면 안 된다는 감각이 담겨 있다.

실습으로 확인할 것

빈 Electron 앱을 만들고 utilityProcess.fork()로 자식을 띄운 뒤, 자식에서 일부러 process.crash()를 호출해 보라. 메인 창이 살아 있는 걸 눈으로 확인하는 게 이 패턴을 체득하는 가장 빠른 길이다.

B. FFI — JS에서 네이티브 부르기

koffinode-gyp 컴파일 없이 문자열 선언만으로 C 함수를 부른다. 그래서 배포가 압도적으로 쉽다(사용자 기기에 빌드 툴체인이 필요 없다).

대신 지켜야 할 규칙이 있다.

규칙이유이 저장소의 대응
네이티브가 준 메모리는 반드시 해제GC가 관리하지 않는다 → 누수wktFree(ptr), wcdb_free 호출
핸들 생명주기를 직렬화use-after-free 방지Promise 큐
DLL 검색 경로 설정의존 DLL을 못 찾으면 로드 실패process.env.PATH 앞에 삽입 (mac은 DYLD_LIBRARY_PATH)
심볼 존재 여부 확인버전 스큐tryBind()
asar 밖으로 빼기asar 안의 파일은 네이티브가 못 연다asarUnpack 설정

C. SQL — 파라미터 바인딩을 직접 구현해 보기

구버전 DLL에 wcdb_exec_query_with_params 심볼이 없을 때를 대비해, ?를 SQL 리터럴로 직접 치환하는 폴백이 있다. 따옴표 상태머신으로 문자열 내부의 ?를 건너뛰는 게 핵심이다.

function inlineParams(sql: string, params: any[]): string {
  let quote: '"' | "'" | '`' | null = null
  for (let i = 0; i < sql.length; i++) {
    const ch = sql[i]
    if (quote) {                             // 문자열 안에 있는 중
      out += ch
      if (ch === quote) {
        if (sql[i+1] === quote) out += sql[++i]  // '' 이스케이프
        else quote = null                       // 문자열 종료
      }
      continue
    }
    if (ch === "'" || ch === '"' || ch === '`') { quote = ch; out += ch; continue }
    if (ch === '?' && index < params.length) { out += sqlLiteral(params[index++]); continue }
    out += ch
  }
  if (index !== params.length)
    throw new Error(`파라미터 개수 불일치: expected ${index}, got ${params.length}`)
}

function sqlLiteral(v: any) {
  if (Buffer.isBuffer(v)) return `X'${bufferToHex(v)}'`      // BLOB 리터럴
  return `'${String(v).replace(/'/g, "''")}'`            // 작은따옴표 이스케이프
}
오해 금지
이건 "SQL 인젝션 방어를 직접 짜라"는 뜻이 아니다

이 코드는 네이티브 바인딩을 못 쓸 때의 폴백이다. 실무에서는 언제나 진짜 파라미터 바인딩(prepared statement)을 써야 한다 — DB 엔진이 값과 코드를 물리적으로 분리해 주기 때문이다.

다만 이 코드를 읽는 것은 가치가 크다. "왜 문자열 연결이 위험한지", "이스케이프가 정확히 무엇을 하는지"를 코드 레벨에서 이해하게 된다. 그리고 상태머신 파서 작성 연습으로 훌륭하다.

D. React — 대용량 리스트와 상태 관리

주제배울 것
가상 스크롤virtuaVirtualizer. 수십만 항목을 DOM 수십 개로 렌더링
명령 전달ref 메서드 대신 단조 증가 정수 시그널
라우팅Electron에서는 HashRouter. file://에서 BrowserRouter는 새로고침 시 404
상태zustand 10개 스토어로 도메인 분리. 전역 Context 하나에 다 넣지 않기
스트리밍 UIuse-stick-to-bottom — AI 응답 스트리밍 중 자동 하단 고정
구독 정리IPC 리스너가 해제 함수를 반환 → useEffect cleanup에 직결
코드 고고학 — 지워진 흔적 읽기

App.tsx의 라우트 중 /analytics, /annual-report, /group-analytics-window는 전부 <Navigate to="/home" replace />다. 즉 과거에 있던 분석 페이지들이 제거되고 리다이렉트만 남았다.

그리고 그 기능들은 어디로 갔나? AI 에이전트의 도구로 흡수됐다(agent/tools/chatStats.ts). "전용 대시보드"에서 "에이전트가 필요할 때 계산"으로 옮겨간 것이다.

남의 코드에서 리다이렉트만 남은 라우트, sqlite-vec 제거 후 남은 마이그레이션 방어 코드 같은 걸 보면 프로젝트가 어디로 가는 중인지가 보인다. 커밋 로그보다 빠른 방향 파악법이다.

E. AI 애플리케이션 설계

이 저장소의 AI 구성은 요즘 "LLM 앱"의 표준 조립을 잘 보여준다.

┌─────────────────────────────────────────┐ │ AI SDK v7 (프로바이더 추상화) │ │ Anthropic · OpenAI · Google · │ │ OpenAI-compatible (DeepSeek/Ollama) │ └────────────────┬────────────────────────┘ │ ┌───────────────────┼───────────────────┐ ▼ ▼ ▼ 도구 27종 RAG 파이프라인 메모리 시스템 (zod 스키마) FTS5+벡터+RRF nightlyMemoryService chatStats (야간 배치 요약) searchMessages memoryDatabase 69KB semanticSearch querySql exportChat ... │ ▼ MCP 양방향 — 서버로 노출 + 외부 MCP를 도구로 흡수
설계 결정배울 점
프로바이더 추상화(AI SDK)모델을 갈아끼울 수 있게 — 벤더 락인 회피
zod로 도구 스키마타입 + 런타임 검증 + LLM용 JSON Schema를 한 정의로
OpenTelemetry 트레이싱LLM 호출은 비결정적 → 관측성 없으면 디버깅 불가능
스트림 정합성 보정 모듈"OpenAI 호환"을 표방하는 서버들이 실제로는 조금씩 다르다는 현실 대응
야간 배치 요약대화가 쌓이면 컨텍스트가 터진다 → 주기적 압축이 필수
MCP 서버 노출앱을 "최종 UI"가 아니라 데이터 레이어로 포지셔닝
특히 눈여겨볼 것
openaiCompatibleStreamSanitizer

"OpenAI 호환 API"라고 광고하는 서버들(로컬 Ollama, 각종 국산 모델 게이트웨이)이 실제로는 스트림 청크 형식이 미묘하게 다르다. 그래서 별도 정규화 모듈이 필요했다.

"표준을 따른다"는 주장을 곧이곧대로 믿으면 안 된다는 실전 교훈. 외부 API를 붙일 때는 정규화 레이어를 하나 두는 것이 거의 항상 이득이다.

F. 보안 — 무엇이 잘못됐는지 읽는 훈련

이 저장소는 좋은 패턴만큼이나 잘못된 선택도 명확하게 보여준다. 그래서 교보재로 좋다.

선택평가올바른 대안
contextIsolation: true✅ 정석
nodeIntegration: false✅ 정석
커스텀 프로토콜로 미디어 서빙✅ 좋은 방향
플러그인마다 독립 origin(ct-plugin://)✅ 훌륭
webSecurity: false 전역❌ 위험커스텀 프로토콜로 통일하고 webSecurity는 켜기
개인키 XOR 난독화 후 하드코딩⚠️ 보안 아님보안이 목적이면 서버 검증. 방지턱이 목적이면 OK(솔직히 문서화할 것)
핵심 로직 바이너리만 배포⚠️ 신뢰 문제최소한 재현 가능한 빌드나 해시 공개
버전 하드코딩 이중화❌ 버그package.json에서 읽기

7하드웨어 · 시스템 요구사항

실제로 돌리려면 무엇이 필요한가
항목요구사항비고
OSWindows 10 / 11README·배지 모두 Windows 단독 명시
macOS부분 지원 (진행 중)dylib·dmg 타깃·MACOS_PORT_GUIDE.md 존재. SIP 해제 요구
Linux❌ 미지원
Node.js22.12.0 이상README·CI 모두. engines 필드는 미선언
RAM4GB 이상 권장빌드 시 --max-old-space-size=4096 필요
위챗 버전4.x (Weixin.exe) 전용구버전 경로 인식 코드는 남아 있으나 키 추출은 v4만
위챗 상태실행 중 + 로그인 필수꺼져 있으면 메모리에서 키를 못 뽑는다
권한관리자 실행이 필요할 수 있음프로세스 메모리 읽기 권한 때문. 설치본 자체는 asInvoker
디스크클론 시 약 107MB네이티브 DLL 포함
환경 제약
한국 사용자 대부분에게는 실습 대상이 아니다

솔직하게 짚자. 위챗을 쓰지 않으면 이 앱은 실행조차 안 된다. 데이터 폴더가 없고, 스캔할 프로세스가 없다.

그러니 이 문서의 실습 과제(8장)는 "이 앱을 돌려보기"가 아니라 "이 앱의 패턴을 내 프로젝트에 옮겨 심기"로 설계했다. 그게 실제로 남는 학습이기도 하다.

보안 관련 현실
백신이 차단할 가능성이 높다

다른 프로세스의 메모리를 읽고 훅을 설치하는 동작은 정보 탈취 멀웨어와 기술적으로 구별되지 않는다. Windows Defender를 비롯한 백신이 차단하거나 격리할 수 있다.

그리고 "예외 처리하면 된다"고 가볍게 넘기면 안 된다 — 백신 예외 등록은 실제로 위험을 감수하는 행위다. 소스가 공개되지 않은 DLL이 관여한다는 점(2장)을 함께 고려해 판단해야 한다.

macOS 포팅 상태 (참고)

// wxKeyServiceMac — 에러 코드로 실패 원인이 드러난다
PROCESS_NOT_FOUND   // 위챗이 안 켜져 있음
ATTACH_FAILED       // 프로세스 attach 실패 → SIP 해제 필요
SCAN_FAILED         // 메모리는 읽었으나 키 패턴 못 찾음
HOOK_FAILED         // 훅 설치 실패
HOOK_TARGET_ONLY    // 대상 프로세스에서만 가능

macOS는 libdobby.dylib(오픈소스 훅 프레임워크 Dobby)를 쓰고, entitlements.mac.plist로 메모리 접근 권한을 요청하며, hardenedRuntime: true로 빌드된다. 다만 notarize 설정이 build 블록에 없어 서명·공증은 환경변수에 의존하거나 미서명일 가능성이 있다.

8직접 해볼 수 있는 실습 과제

위챗 없이도 할 수 있게 설계했다
과제 1난이도 ★☆☆ · 1~2시간

utilityProcess 크래시 격리를 눈으로 확인하기

목표 — Electron의 프로세스 격리가 실제로 무엇을 지켜주는지 체감한다.

할 일 — 빈 Electron 앱을 만들고 ① 버튼 A는 메인 프로세스에서 process.crash()를 호출, ② 버튼 B는 utilityProcess.fork()로 띄운 자식에서 크래시를 호출하게 한다. 각각 눌러보고 창이 어떻게 되는지 관찰한다.

확장 — 자식이 죽었을 때 부모가 감지해 자동 재시작하고, 대기 중이던 요청을 reject하는 로직을 붙여본다. (wcdbService.ts가 하는 일이 정확히 이것)

검증 — 자식을 10번 죽여도 메인 창이 살아 있고, 각 요청이 에러로 정상 종료되면 성공.

과제 2난이도 ★☆☆ · 2~3시간

SQL 파라미터 인라이너 직접 구현 + 부수기

목표 — SQL 인젝션이 왜 생기는지 코드 레벨로 이해한다.

할 일inlineParams(sql, params)를 백지에서 구현한다. 그다음 자기 구현을 깨는 입력을 찾는다. 힌트가 될 케이스들:

// 이것들이 전부 올바르게 처리되는가?
inlineParams("SELECT * FROM t WHERE a=? AND b='?'", [1])        // 문자열 안 ? 는 건너뛰어야
inlineParams("SELECT * FROM t WHERE a=?", ["O'Brien"])         // 작은따옴표 이스케이프
inlineParams("SELECT * FROM t WHERE a=?", ["'; DROP TABLE t;--"])
inlineParams("SELECT * FROM t WHERE a=?", [Buffer.from([0xde,0xad])])
inlineParams("SELECT ? ", [1, 2])                              // 개수 불일치 → throw

검증 — vitest로 케이스 15개 이상 작성해 전부 통과. 그리고 진짜 prepared statement와 성능·안전성을 비교해 보고, 왜 실무에서 후자를 쓰는지 한 문단으로 정리한다.

과제 3난이도 ★★☆ · 반나절

하이브리드 검색(FTS5 + 벡터 + RRF) 만들기

목표 — 요즘 RAG의 표준 구성을 손으로 조립한다.

할 일 — 아무 텍스트 데이터(내 메모, 블로그 글, 슬랙 export 등) 1,000건을 준비하고:

better-sqlite3로 FTS5 가상테이블을 만들어 키워드 검색
② 임베딩 모델(로컬 transformers.js나 OpenAI API)로 벡터 검색
rrf.ts처럼 score = Σ 1/(k + rank)로 두 결과를 융합 (k=60)

function rrf(lists: string[][], k = 60): Map<string, number> {
  const scores = new Map<string, number>()
  for (const list of lists)
    list.forEach((id, rank) =>
      scores.set(id, (scores.get(id) ?? 0) + 1 / (k + rank + 1)))
  return scores   // 내림차순 정렬해서 사용
}

검증 — 질의 20개를 만들고 정답을 라벨링한 뒤, 키워드만 / 벡터만 / RRF 융합 세 방식의 Recall@5를 비교한다. 융합이 이기는지, 진다면 왜인지 분석하는 게 진짜 학습이다.

과제 4난이도 ★★☆ · 하루

바이너리 포맷 리버싱 — 나만의 .dat 만들고 풀기

목표 — 하이브리드 암호 구조를 이해하고, 헤더 파싱·패딩 처리를 정확히 다룬다.

할 일 — CipherTalk의 V4 포맷을 모방한 인코더를 먼저 만든다(이게 핵심 — 만들 줄 알아야 풀 줄 안다).

[15바이트 헤더][AES-128-ECB 구간][평문 구간][XOR 구간] header[0:6] = 매직 시그니처 (직접 정하기) header[6:10] = aesSize (LE int32) header[10:14] = xorSize (LE int32) header[14] = 버전

그다음 디코더를 짜고, 다음 함정들을 직접 밟아 본다:

• PKCS7에서 이미 16의 배수여도 한 블록이 더 붙는다 (alignedAesSize 계산이 왜 저런지)
setAutoPadding(false)로 두고 직접 제거하지 않으면 왜 깨지는지
• 리틀엔디안/빅엔디안을 바꿔 읽으면 무슨 일이 생기는지

확장 — XOR 키를 모른다고 가정하고, JPEG가 항상 FF D9로 끝난다는 사실만으로 여러 파일에서 키를 역산해 본다(기지 평문 공격). 최빈값을 취하는 이유도 직접 확인.

검증 — 랜덤 크기 파일 100개를 인코드→디코드해서 원본과 바이트 단위로 일치. 경계 케이스(0바이트, 1바이트, 정확히 16바이트, 16의 배수)를 반드시 포함.

과제 5난이도 ★★★ · 2~3일

같은 코어 위에 GUI + CLI + MCP 세 얼굴 올리기

목표 — CipherTalk가 하려다 절반만 성공한 구조를 제대로 만들어 본다.

할 일 — 아무 데이터 소스(로컬 파일 인덱스, 북마크, 가계부 등)를 정하고 pnpm workspace로 4개 패키지를 만든다:

packages/ core/ ← 순수 로직. Electron·CLI 의존성 0 desktop/ ← Electron + React, core 를 import cli/ ← commander, core 를 import mcp-server/ ← MCP SDK, core 를 import

핵심 제약coreelectron을 절대 import하지 않는다. 파일 경로·설정 위치 같은 환경 의존성은 주입받는다:

// core/src/index.ts — 환경을 인터페이스로 추상화
export interface Env {
  configDir: string
  readFile(p: string): Promise<Buffer>
  log(msg: string): void
}
export function createEngine(env: Env) { /* ... */ }

// desktop: app.getPath('userData') 를 주입
// cli:     ~/.myapp 를 주입          ← 같은 코어, 다른 환경

왜 이게 어려운가 — CipherTalk가 "복사 + 수동 동기화"로 후퇴한 이유가 바로 이 분리를 미리 못 했기 때문이다. 직접 해보면 의존성 분리를 나중에 하는 비용을 체감하게 된다.

검증core의 package.json에 electron이 없고, 셋 다 동작하며, 로직을 한 번 고치면 세 곳에 동시 반영되면 성공.

9관련 기술 심화 학습 로드맵

6주 코스 — 주당 6~10시간 기준
1주차

Electron 프로세스 모델 완전 정복

읽을 것 — Electron 공식 문서의 Process Model, Context Isolation, Security 체크리스트 3편. 그리고 이 저장소의 main.ts(376줄) + wcdbUtilityProcess.ts(111줄).

만들 것 — 실습 과제 1. 추가로 contextIsolationfalse로 바꿔 렌더러에서 require('fs')가 되는 걸 확인한 뒤, 왜 그게 재앙인지 직접 설명해 본다.

도착점 — "Electron 앱에서 이 코드는 어느 프로세스에서 도는가?"를 즉답할 수 있다.

2주차

비동기 자원 생명주기 — 큐, 락, use-after-free

주제await 사이의 틈에서 무슨 일이 벌어지는가. Promise 큐, 뮤텍스, 세마포어, AbortController.

만들 것 — 파일 핸들이나 DB 커넥션을 다루는 클래스를 만들고, 일부러 경쟁 상태를 만들어 깨뜨린다. 그다음 queue = queue.then(...) 패턴으로 고친다.

심화 — 큐가 왜 .catch(() => undefined)를 달아야 하는지 생각해 보라. (하나 실패하면 체인 전체가 멈춘다.)

도착점 — "이 코드에 race condition이 있나?"를 코드만 보고 의심할 수 있다.

3주차

FFI와 네이티브 통합

읽을 것 — koffi 공식 문서. 이 저장소의 wcdbCore.ts 앞 200줄.

만들 것 — C로 간단한 라이브러리(문자열 반환, 구조체 out 파라미터, 메모리 할당+해제 쌍)를 만들고 koffi로 호출한다. 일부러 free를 빼먹고 메모리 사용량이 늘어나는 걸 관찰한다.

비교 — 같은 걸 node-gyp 네이티브 애드온과 napi-rs(Rust)로도 만들어 보고, 셋의 트레이드오프(배포 난이도 / 성능 / 타입 안전성)를 표로 정리한다.

도착점 — "이 작업은 FFI가 맞나, 네이티브 애드온이 맞나, 아니면 그냥 JS로 될 일인가"를 판단할 수 있다.

4주차

바이너리 포맷과 응용 암호

주제 — 엔디안, 매직 넘버, 헤더 파싱, 블록 암호 모드(ECB/CBC/GCM), 패딩(PKCS7), 스트림 암호와 키스트림.

만들 것 — 실습 과제 4. 추가로 ECB 모드의 유명한 취약점을 직접 재현한다 — 단색 영역이 큰 이미지를 ECB로 암호화하면 원본 윤곽이 그대로 보인다("ECB 펭귄"). CipherTalk가 ECB를 쓰는 이유(랜덤 액세스 필요)와 그 대가를 함께 이해한다.

도착점 — 암호 라이브러리를 쓸 때 모드와 패딩을 의식적으로 선택하게 된다.

5주차

검색과 RAG

주제 — 역색인, BM25, FTS5 문법, 임베딩, 코사인 유사도, ANN 인덱스(HNSW), RRF, 리랭킹.

만들 것 — 실습 과제 3. 여기에 평가 세트를 반드시 만든다 — 이 저장소에도 evaluation/retrieval/이 있는 이유다.

핵심 감각 — "체감상 좋아진 것 같다"는 검색 개선에서 가장 위험한 말이다. Recall@k, MRR 같은 숫자 없이는 개선인지 퇴보인지 알 수 없다.

도착점 — RAG 파이프라인을 조립하고 측정으로 개선할 수 있다.

6주차

에이전트, 도구, MCP

주제 — 도구 호출 루프, zod 스키마 설계, 컨텍스트 관리와 압축, MCP 서버/클라이언트, 관측성.

만들 것 — 실습 과제 5의 mcp-server 패키지를 완성한다. 도구 5개를 zod로 정의하고 Claude Code나 다른 MCP 클라이언트에 붙여본다.

심화 — OpenTelemetry로 도구 호출을 트레이싱하고, "왜 이 도구가 안 불렸나"를 트레이스로 진단해 본다. 도구 설명(description)을 한 문장 바꾸면 호출률이 바뀐다는 걸 실험으로 확인하는 게 이 주차의 백미다.

도착점 — 내 데이터를 에이전트가 쓸 수 있게 노출하는 전체 과정을 혼자 할 수 있다.

로드맵을 짧게 요약하면

1~2주차 = 안 죽는 앱 만들기 (프로세스·비동기 자원)
3~4주차 = 남의 포맷과 싸우기 (FFI·바이너리·암호)
5~6주차 = 데이터를 지능에 연결하기 (검색·에이전트)

이 세 축이 정확히 CipherTalk를 구성하는 세 축이기도 하다.

10핵심 키워드 사전

이 문서에 나온 용어 정리

Electron · 프로세스

용어
contextIsolation
preload 스크립트와 웹페이지의 JavaScript 실행 컨텍스트를 분리하는 설정. 켜 두면 웹페이지가 preload의 내부 변수를 건드릴 수 없다. 항상 true여야 한다.
용어
contextBridge
격리된 두 컨텍스트 사이에 안전한 통로를 뚫는 API. exposeInMainWorld('electronAPI', {...})내가 허용한 함수만 웹페이지에 노출한다. Node 전체를 열어주는 대신 필요한 것만 주는 방식.
용어
utilityProcess
Electron이 제공하는 별도 프로세스. 크래시가 메인으로 전파되지 않아 위험한 네이티브 코드를 가두는 용도로 쓴다. MessagePort로 통신한다.
용어
worker_threads
Node.js의 스레드. 같은 프로세스 안이라 메모리 공유가 가능해 빠르지만, 크래시하면 프로세스 전체가 죽는다. CPU 집약적이지만 안전한 작업에 적합.
용어
asar / asarUnpack
Electron이 앱 소스를 하나로 묶는 아카이브 포맷. 네이티브 모듈(.node, .dll)은 아카이브 안에서 로드할 수 없어 asarUnpack으로 밖에 꺼내야 한다. "패키징하니까 안 되는" 문제의 단골 원인.

암호 · 바이너리

용어
SQLCipher
SQLite 파일 전체를 AES로 암호화하는 확장. 키 없이는 파일 헤더조차 읽히지 않는다.
용어
WCDB
텐센트가 오픈소스로 공개한 모바일 DB 프레임워크. SQLCipher 기반이며 위챗이 실제로 쓴다. CipherTalk는 이 엔진을 그대로 불러 DB를 연다 — 암호를 깨는 게 아니라 정식 엔진에 정식 키를 주는 것이다.
용어
AES-128-ECB
블록 암호의 가장 단순한 모드. 각 16바이트 블록을 독립적으로 암호화한다. 같은 평문 블록 → 같은 암호문 블록이라 패턴이 새어나가는 치명적 약점이 있지만, 블록 단위 랜덤 액세스가 가능해 파일 일부만 읽을 때 쓰인다.
용어
PKCS7 패딩
블록 암호에서 마지막 블록을 채우는 규칙. n바이트가 부족하면 값이 n인 바이트를 n개 붙인다. 데이터가 이미 블록 크기의 배수여도 한 블록을 통째로 더 붙인다는 게 자주 틀리는 부분.
용어
매직 넘버 (magic number)
파일 맨 앞의 고정된 바이트열로 포맷을 식별하는 관례. JPEG는 FF D8 FF, PNG는 89 50 4E 47, zstd는 28 B5 2F FD(LE로 읽으면 0xFD2FB528). 확장자보다 신뢰할 수 있다.
용어
기지 평문 공격 (known-plaintext attack)
평문의 일부를 이미 알고 있을 때 그걸 이용해 키를 알아내는 공격. "JPEG는 항상 FF D9로 끝난다"를 이용해 XOR 키를 역산하는 게 정확히 이것이다. XOR 암호가 약한 근본 이유.
용어
ISAAC64
암호학적 의사난수 생성기(CSPRNG)의 일종. 시드가 같으면 항상 같은 키스트림을 만든다. 재구현 시 상수 하나만 틀려도 결과가 전혀 달라진다.
용어
엔디안 (endianness)
여러 바이트 숫자를 메모리에 놓는 순서. 리틀엔디안(LE)은 낮은 자리부터(0x123434 12), 빅엔디안(BE)은 높은 자리부터. 바이너리 포맷 파싱에서 가장 흔한 버그 원인.

시스템 · FFI

용어
FFI / koffi
다른 언어로 컴파일된 함수를 호출하는 방법. koffi는 C 시그니처를 문자열로 선언하면 되는 Node.js FFI 라이브러리로, node-gyp 컴파일이 필요 없어 배포가 쉽다.
용어
use-after-free
이미 해제된 메모리를 다시 사용하는 버그. C/C++의 대표적 취약점이며, FFI를 쓰면 JavaScript에서도 발생한다(GC가 네이티브 메모리를 모르기 때문). 증상이 랜덤 크래시라 재현이 매우 어렵다.
용어
기능 탐지 (feature detection)
"버전이 X 이상인가"가 아니라 "이 기능이 있는가"를 확인하는 방식. 버전 비교보다 견고하다. tryBind()가 DLL 심볼에 이걸 적용한 예.
용어
SIP (System Integrity Protection)
macOS의 보호 기능. 다른 프로세스의 메모리 접근이나 시스템 파일 수정을 막는다. 메모리 스캔 도구가 macOS에서 SIP 해제를 요구하는 이유이며, 해제는 시스템 전체의 보안을 낮추는 행위다.

데이터 · AI

용어
FTS5
SQLite에 내장된 전문검색(full-text search) 엔진. 가상 테이블을 만들어 역색인을 구축하고 MATCH 연산자로 검색한다. 별도 서버 없이 검색을 붙일 수 있어 로컬 앱에 최적.
용어
RRF (Reciprocal Rank Fusion)
여러 검색 결과를 합치는 기법. 점수가 아니라 순위만 사용해 1/(k+rank)를 더하므로, 스케일이 다른 검색기들을 정규화 없이 합칠 수 있다. 보통 k=60.
용어
리랭킹 (reranking)
1차로 넓게 후보를 뽑은 뒤, 더 무겁고 정확한 모델로 상위 몇십 개만 재정렬하는 2단계 전략. 정확도와 속도를 동시에 잡는 표준 패턴.
용어
MCP (Model Context Protocol)
AI 에이전트가 외부 도구·데이터에 붙는 표준 프로토콜. 서버가 도구를 노출하면 어떤 MCP 클라이언트든 쓸 수 있다. CipherTalk는 서버이면서 동시에 클라이언트다.
용어
zstd (Zstandard)
Facebook이 만든 압축 알고리즘. gzip보다 빠르면서 압축률도 좋아 최근 널리 채택됐다. 위챗이 메시지 본문 저장에 쓴다.
용어
SILK
스카이프가 개발한 음성 코덱(Opus의 조상 격). 저대역폭 음성에 최적화돼 있어 메신저 음성메시지에 널리 쓰인다. 위챗 음성이 이 포맷이라 재생하려면 별도 디코더가 필요하다.

설계 패턴

용어
방어적 파싱 (defensive parsing)
입력 스키마가 변할 수 있다고 가정하고 쓰는 파싱. 필드명 후보 배열, 대소문자 무시 폴백, 명시적 실패 처리가 핵심. 남의 포맷을 다룰 때는 선택이 아니라 필수.
용어
후보 점수화 탐색
경로나 설정을 하나로 확정하지 않고, 후보를 모아 점수를 매기고 순서대로 시도하는 패턴. 실패 이유를 모두 수집해 진단에 쓴다.
용어
단일 진실원 (single source of truth)
같은 정보를 여러 곳에 복제하지 않고 한 곳에서만 관리하는 원칙. 버전 번호가 두 군데에 하드코딩돼 어긋난 이 저장소의 CLI 버그가 위반 사례다.
용어
벤더 락인 (vendor lock-in)
특정 공급자에 종속돼 갈아타기 어려워지는 상태. AI SDK 같은 추상화 계층을 두는 이유가 이걸 피하기 위해서다.

11참고 링크 · 한계와 주의사항

더 볼 것과, 반드시 알아야 할 것

이 저장소

항목링크 / 내용
저장소github.com/ILoveBingLu/CipherTalk
CLI 패키지npm install -g ciphertalk-climiyu
라이선스CC BY-NC-SA 4.0 (데스크톱) — 상업적 이용 금지 / CLI는 MIT
버전2026.7.16 (날짜 기반 버전 체계)
규모별 1,244 · 포크 325 · TypeScript · 클론 약 107MB

배경 기술 문서

주제어디를 볼 것인가
Electron 보안공식 문서의 Security 체크리스트 — webSecurity 항목을 특히
Electron 프로세스Process Model, utilityProcess API 문서
koffi공식 문서의 Memory management 절 (해제 규칙)
WCDBTencent/wcdb 저장소 — 텐센트가 공개한 원본 엔진
SQLCipher공식 문서의 Design 문서 — 키 유도와 페이지 암호화 방식
SQLite FTS5SQLite 공식 문서 FTS5 절
RRFCormack et al., "Reciprocal Rank Fusion outperforms Condorcet..." (SIGIR 2009)
MCPmodelcontextprotocol.io — 스펙과 SDK
Vercel AI SDKsdk.vercel.ai — 도구 호출과 스트리밍

이 문서를 쓰면서 확인한 것과 확인하지 못한 것

확인함

package.json 두 개(루트·CLI)의 정확한 버전, 디렉토리 구조, vite.config.ts의 엔트리 9개, IPC 패턴과 invoke 호출 횟수, 보안 설정 값, 코드 인용 부분(wcdbUtilityProcess, wcdbCore, wxKeyService, dbAdapter, datDecryptCore, dbPathService), 메시지 타입 코드, 라우트 구성, CLI 명령 목록, electron-builder 설정, CI 워크플로. 실제 import를 세어 antd 미사용을 확인했고, 버전 하드코딩 불일치도 소스 대조로 찾았다.

확인 못 함

네이티브 바이너리 내부는 검증 불가능하다. WCDB.dll, wcdb_api.dll, wechat_key_tool.dll, ciphertalk-image-native-*.node의 소스가 저장소에 없다. 따라서 이 문서의 키 추출·복호화 설명은 JS 쪽 호출 코드와 주석에서 추론한 것이며, DLL이 실제로 무엇을 하는지(추가 통신 여부 등)는 확인할 수 없었다.

또한 실제 위챗 환경에서 동작을 검증하지 않았다. 동작 흐름 설명은 코드 독해에 기반한다.

마지막으로 — 이 도구를 실제로 쓰려 한다면

법적 · 윤리적 경계
"내 데이터"의 범위는 생각보다 좁다

내 계정의 채팅 기록이라도 대화 상대방의 발언이 함께 들어 있다. 개인적 보관은 대개 문제가 없지만, 제3자에게 공개·제공하는 순간 상대방의 개인정보·통신비밀 문제가 발생한다. 한국 통신비밀보호법은 대화 당사자가 아닌 자의 취득을 엄격히 금지하고, 당사자라도 공개에는 별도 판단이 필요하다.

남의 기기·계정에 적용하는 것은 명백한 범죄다. "가족 사이니까", "회사 기기니까" 같은 예외는 생각보다 좁게 인정된다.

법적 증거 용도를 고려한다면 반드시 변호사와 먼저 상의하라. 직접 추출한 기록은 증거능력 자체가 다투어질 수 있고, 취득 과정이 위법하면 오히려 불리해진다. (이 문서는 법률 자문이 아니다.)

기술적 위험
신뢰 결정을 미루지 말 것

이 앱을 실행하는 것은 소스가 공개되지 않은 바이너리에게 위챗 프로세스 메모리 접근을 허용하는 결정이다. 백신 예외 등록까지 하면 방어선이 하나 더 사라진다.

중요한 데이터가 있는 주 사용 기기라면, 최소한 ① 데이터 폴더를 먼저 백업하고 ② 네트워크를 차단한 상태에서 실행해 보고 ③ 방화벽으로 외부 통신을 관찰하는 정도의 절차는 밟는 게 합리적이다.

이 문서의 결론
쓰지 않아도 배울 게 많은 저장소다

CipherTalk의 진짜 가치는 기능이 아니라 "통제할 수 없는 외부 시스템과 안정적으로 일하는 법"의 실전 사례집이라는 데 있다. 크래시 격리, 비동기 자원 관리, 방어적 파싱, 기능 탐지, 진단 가능한 실패 — 이 패턴들은 위챗과 아무 상관 없는 프로젝트에서도 그대로 쓰인다.

동시에 webSecurity:false, 하드코딩된 개인키, 복사 기반 코드 공유, 버전 이중 관리 같은 잘못된 선택도 함께 읽는 것이 이 저장소를 공부하는 올바른 방법이다. 좋은 코드만 읽으면 왜 좋은지 모른다.