카카오톡 대화를 터미널에서 읽고, 따라가고, 보내는 CLI를 두 벌 만들었습니다.
윈도우용이 kanao-cli, 맥용이 kamado-cli 입니다.
둘 다 의존성이 0개이고, 파이썬 표준 라이브러리와 각 운영체제가 이미 가진 API만 씁니다.
시작은 단순한 불편이었습니다.
대화 내용은 내 컴퓨터에 있는데, 그걸 읽으려면 반드시 카카오톡 창을 열고 스크롤해야 합니다.
터미널에서 grep 한 번이면 끝날 일을요.
열쇠는 실행 중인 클라이언트 안에 있다
대화는 로컬에 SQLite로 저장되지만 SQLCipher로 암호화되어 있습니다.
파일만 있어서는 아무것도 못 읽습니다.
열쇠는 디스크에 없습니다. 실행 중인 카카오톡 프로세스의 메모리 안에만 있습니다.
그래서 두 도구 모두 클라이언트 프로세스를 읽기 전용으로 열어 SQLCipher의 codec_ctx 구조를 찾고, 거기서 열쇠를 꺼내 데이터베이스를 복호화합니다.
파일에 쓰지 않고, 클라이언트를 변조하지도 않습니다.
여기서 중요한 성질이 하나 따라옵니다. 카카오톡이 한 번도 연 적 없는 방은 읽을 수 없습니다. 열쇠가 메모리에 없기 때문입니다. 결과가 비어 있다는 건 "그런 대화가 없다"가 아니라 "그 방을 클라이언트가 연 적 없다"는 뜻일 수 있습니다.
도구가 이걸 구분해서 말해주는 게 생각보다 중요했습니다.
빠르게 만드는 일이 대부분이었다
기능을 붙이는 것보다 매 명령이 답하기 전에 치르는 비용을 깎는 데 시간이 더 들었습니다.
전부 추측이 아니라 측정으로 잡았습니다.
- 힙 스캔을 기억한다. 프로세스에서 열쇠를 찾는 스캔이 조용한 클라이언트에서 426ms, 작업 집합이 크면 2.8초였습니다. 찾은 주소만 캐시해두고 다음엔 0.8ms에 다시 읽습니다. 열쇠와 솔트는 절대 저장하지 않습니다.
- 프로필을 glob 하지 않고 걷는다.
rglob("*")은 미디어와 캐시까지 전부 stat 합니다. 298개 데이터베이스에 1.36초.os.walk로 바꿔 92.8ms. - 합쳐서
status2.43초 → 0.30초,rooms2.54초 → 0.63초. 출력은 바이트 단위로 동일합니다. - 메시지가 도착했다고 파일을 다시 복호화하지 않는다. SQLite는
-wal에 덧붙이고 본체는 건드리지 않으므로, 복호화된 페이지를 옆에 두고 로그만 덮어씁니다. 48.3MB 방이 431.5ms → 100.2ms, 결과는 전체 복호화와 바이트 단위로 동일했습니다.
지금 보이는 이름이, 그때 그 이름은 아니다
이건 성능보다 훨씬 중요한 문제였습니다.
Profile과 talkUser 테이블은 덮어쓰기됩니다.
그래서 예전 닉네임으로 보낸 메시지도 지금 닉네임으로 표시됩니다. 오픈채팅처럼 서로 이름을 바꿔 쓰는 방에서는, 발신자 칸을 그대로 믿는 순간 대화를 엉뚱한 사람에게 귀속시키게 됩니다.
실제로 한 번 겪었고, 잘못된 요약이 방에 올라가고 나서야 발견했습니다.
바꿀 수 없는 건 userId 하나뿐입니다. 이름은 두 계정이 글자까지 똑같이 쓸 수 있지만, id는 겹치지 않습니다.
그래서 이름 이력을 따로 모읍니다. 개명은 어디에도 기록되지 않지만, type = 0인 멤버십 피드에 {"feedType": 4, "members": [{"userId": …, "nickName": …}]} 형태로 흔적이 남습니다. 한 오픈채팅에서 피드 6,037건 중 156건이 누군가의 이름을 담고 있었고, 72명과 개명 18건을 복원할 수 있었습니다.
/real 닉네임 한 줄이면 답합니다 — "이 이름을 쓴 계정 2개". 둘 중 누가 원래 그 이름이었고 누가 나중에 가져다 썼는지. 한 사람의 과거를 묻는 것보다, 같은 이름 둘 중 누가 사칭인지를 묻는 쪽이 실제로 필요한 질문이었습니다.
터미널 인터페이스
읽기만 하는 게 아니라 대화방·대화·친구 목록·요약을 터미널 안에서 봅니다.
여러 대화를 나란히 열 수 있고, 창이 좁아지면 방을 닫는 게 아니라 숨깁니다 — 창이 작아진 건 대화를 그만 보겠다는 결정이 아니니까요.
/sum 30m 은 구간 요약입니다. 다만 이 프로세스 안에는 모델이 없으므로, 요약한다고 말하지 않습니다. 건수·참여자·키워드·대표 문장을 뽑아줄 뿐입니다.
여기서 배운 것 하나. 한글은 두 칸을 차지하고 모든 줄에 이스케이프가 섞여 있어서, len()으로 글자 폭을 재면 안 됩니다. 이걸 틀리면 예외가 나는 게 아니라 모든 열이 조용히 어긋납니다.
맥 쪽은 같은 문제, 다른 답
kamado-cli는 같은 발상의 macOS 판입니다. 화면과 명령은 거의 같지만, 밑바닥은 상당히 다릅니다.
- 열쇠 유도 방식이 다릅니다. 맥은
{user_id, uuid}에서 PBKDF2로 SQLCipher 열쇠를 유도합니다. 유도한 DB 파일명이 디스크의 78자리 16진수 파일명과 일치하는지로 검증할 수 있어서, 고정 테스트 벡터 없이도 구현이 맞는지 확인됩니다. - 방 이름이 있는 곳이 다릅니다. 맥은 이름을 바꾸면
NTChatRoom.chatName이 비고 메타 테이블에 들어갑니다. 윈도우는chatRoomTitle에 그대로 씁니다. 같은 코드를 옮겼다가는 조용히 틀린 이름을 보여줍니다. - 전송 방식이 다릅니다. 맥은 Accessibility로, 윈도우는 Win32 메시지로 입력창에 글자를 넣습니다. 둘 다 사람이 키보드로 하는 것과 같은 경로이고, 실제 전송은 로그인된 공식 클라이언트가 합니다.
두 저장소는 손으로 기능을 주고받습니다. 그래서 가져오지 않은 것과 그 이유를 문서에 남깁니다. 안 그러면 같은 질문을 볼 때마다 다시 답하게 되더군요. 예를 들어 맥의 "코어 병렬 복호화"는 윈도우에 가져오지 않았습니다 — 윈도우는 프로세스를 spawn으로 띄우는데 CNG 키 핸들이 프로세스 경계를 못 넘어갑니다. 이식이 아니라 재설계이고, 그만한 이득이 측정되지 않았습니다.
가장 최근 작업 — 사진을 터미널에 띄우기
이부분은 스크린샷이 아직 없어요 🫠
대화에 사진이 오면 [사진] 이라고만 보이던 걸, 터미널 안에 실제로 그리도록 했습니다.
먼저 로컬에서 푸는 길을 찾다가 실패했습니다. 사진 캐시(.cng)는 16바이트 블록 암호로 암호화되어 있고, 클라이언트가 읽을 수 있는 메모리 703MB 전체에서 AES 키 스케줄을 뽑아(256비트 21개, 128비트 4개) 전부 대입해도 하나도 열리지 않았습니다. 대신 확정한 것은 있습니다: 고정 키·고정 IV이고, 평문은 이미지 파일 그 자체이며(카카오 자체 헤더가 없습니다), 따라서 PNG 헤더와 DB가 알려주는 가로·세로를 known-plaintext 오라클로 쓸 수 있습니다. 그 오라클로도 안 열렸으니, 최소한 "AES 키는 메모리에 없다"는 건 분명합니다.
그래서 사진은 CDN에서 받아옵니다. 클라이언트가 사진을 픽셀이 아니라 주소로 저장하기 때문입니다. 이건 이 도구가 네트워크를 여는 유일한 지점이라, 원래 내걸었던 "네트워크 0회"라는 문구를 코드와 같은 커밋에서 같이 고쳤습니다. 약속을 슬쩍 바꾸는 것보다 명시적으로 바꾸는 편이 낫습니다.
그리는 방법은 터미널마다 다릅니다.
- kitty 그래픽 (kitty·Ghostty·WezTerm): 터미널이 그림을 들고 있어서, 사진이 움직여도 다시 보내지 않습니다.
- iTerm2 인라인: 이미지 파일을 그대로 먹습니다.
- sixel (Windows Terminal 1.22+): 파일이 아니라 픽셀을 받습니다.
sixel이 문제였습니다. 픽셀을 만들려면 CDN이 주는 JPEG을 디코딩해야 하는데, 맥은 sips 명령으로 하지만 윈도우엔 그런 게 없습니다. 답은 윈도우가 이미 가지고 있는 코덱이었습니다 — WIC(Windows Imaging Component)를 ctypes로 호출하면 JPEG·WebP·GIF·PNG가 전부 픽셀로 나옵니다. 의존성은 여전히 0개입니다.
그리고 한 번 크게 틀렸습니다. 사진을 처음 띄우는 순간 화면이 20초쯤 멈췄습니다. "ssh라서 느린 거 아니냐"는 말이 나왔는데, 재보니 아니었습니다.
inline_escape 19.735 s ← 그중 색상 양자화가 18.957 s
sixel은 256색 팔레트를 써야 해서 색을 줄이는데, median-cut이 색마다 팔레트 전체를 훑어 최근접을 찾고 있었습니다. 5만 7천 색 × 256 = 4,400만 번. 그런데 median-cut은 각 색이 어느 상자에 들어갔는지 이미 알고 있습니다. 그 소속을 그대로 쓰고, 색이 4,096개를 넘으면 채널을 4비트로 먼저 접었습니다. 19.7초 → 1.12초. 만든 결과는 캐시하니 두 번째부터는 즉시입니다.
마지막으로 여는 방법이 갈렸습니다. 맥은 사진 줄을 클릭해서 엽니다. 그런데 클릭을 받으려면 터미널에 마우스 보고를 신청해야 하고, 그 순간 터미널은 자기 드래그 선택과 링크 클릭을 접습니다. 윈도우 쪽은 그래서 Ctrl+P(또는 /photo)로 열도록 했습니다. 지금은 맥도 이 방식으로 맞추는 중입니다.
보내기는 조심스럽게
읽기만이 아니라 보낼 수도 있습니다. 다만 이미 열려 있는 대화방 창의 입력란에 글자를 넣고 엔터를 보내는 것이 전부입니다. 카카오 서버와 직접 말하지 않고, 프로토콜을 구현하지도 않습니다. 최소화된 창으로도 나가고, 포커스나 마우스 커서를 건드리지 않습니다.
대신 정직하게 말해야 하는 것들이 있습니다.
submitted: true는 엔터가 큐에 들어갔다는 뜻이지 서버가 받았다는 뜻이 아닙니다. 확인하려면 다시 읽어야 하고, 불확실한 전송을 자동 재시도하면 같은 말이 두 번 나갑니다.- 방을 창 제목으로 찾기 때문에, 빗나가면 엉뚱한 방이 잠깐 열리고 그 방은 읽음 처리됩니다.
- 입력란에 쓰다 만 초안이 있으면 지우고 씁니다. 알려주기는 하지만 돌아오지는 않습니다.
못 하는 것
- 로컬에 있는 것만. 서버 전체 이력을 내려받지 않습니다.
- 첨부는 사진만. 동영상·파일·이모티콘은 종류만 표시합니다.
- 글자만 보냅니다. 첨부 전송, 로그인 자동화, 기록 수집은 없습니다.
- 클라이언트가 바뀌면 멈출 수 있습니다. 열쇠 탐색도 창 조작도 실측값에 기대고 있습니다.
마지막으로
이 도구들은 카카오와 무관한 개인 프로젝트입니다.
카카오가 만들지도, 검토하지도, 승인하지도 않았습니다. "카카오톡"과 "KakaoTalk"은 카카오의 상표입니다.
비공개 API를 호출하지 않는다는 사실이 약관 준수를 뜻하지는 않습니다. 자기 계정, 자기 컴퓨터, 자기가 접근 권한을 가진 대화에만 쓰는 것을 전제로 합니다. 읽어낸 대화를 어디까지 보관하고 공유할지, 어느 방에 무엇을 보낼지는 전부 쓰는 사람 책임입니다.
이 글의 화면들은 전부 합성 데이터입니다. 실제 대화로 스크린샷을 찍지 않으려고 목업 생성기를 따로 만들어 썼습니다.
플랫폼을 넘나들며 작업하는게 고통스러운 부분도 있었지만
OmO 의 도움을 받고, 몇몇분들의 PR덕에 재밌는 프로젝트였습니다.
지속적으로 발전시켜보겠습니다.
긴글 읽어주셔서 감사합니다.
상구너-크로아상생쥐 올림.
'Develop' 카테고리의 다른 글
| 한국어 IME 조합 버그를 고쳐 Windows Terminal에 기여한 이야기 (0) | 2026.07.29 |
|---|---|
| Vercel 보안 이슈 정리 (0) | 2026.04.20 |
| Zlack: Slack을 더 가볍게 쓰기 위한 Tauri 데스크톱 클라이언트 (0) | 2026.04.15 |
| 구너 영상 플레이어 15.11 (1) | 2018.05.30 |
| mWeb Mac버전 배포 (4) | 2016.11.22 |








