본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 07. 12:37

AI 전 애인 채팅 만들기: OCR, 발신자 감지, 그리고 존재해서는 안 될 새벽 3시의 코드

요약

스크린샷 기반의 채팅 데이터를 구조화하기 위해 OCR과 위치 기반 감지 기술을 활용한 개발 경험을 다룹니다. Tesseract.js를 이용해 발신자를 구분하고 DeepSeek를 통해 페르소나를 추출하는 기술적 도전 과제를 설명합니다.

핵심 포인트

  • 스크린샷 이미지에서 발신자를 구분하기 위한 위치 기반 픽셀 스캔 방식 적용
  • Tesseract.js의 경계 상자(Bounding Box)를 활용한 데이터 구조화 시도
  • 다양한 메신저 UI(WeChat, Douyin 등)에 따른 OCR 인식의 한계와 변수
  • DeepSeek를 활용한 대화 패턴 및 페르소나 증류(Distillation) 과정

AI 전 애인 채팅 만들기: OCR, 발신자 감지, 그리고 존재해서는 안 될 새벽 3시의 코드

내가 만든 기능 중 가장 어려웠던 기능에 대한 기술적 심층 분석 — Tesseract.js 경계 상자 (bounding boxes), 위치 기반 발신자 감지 (position-based sender detection), DeepSeek 페르소나 증류 (persona distillation), 그리고 진짜 버그는 결코 코드에 있지 않았던 이유.

새벽 3시에 시작된 문제

내가 AI 전 애인 채팅 (AI Ex-Partner Chat)을 만든 이유는 잠을 이룰 수 없었기 때문입니다. 나는 계속해서 예전 WeChat 메시지들을 다시 읽고 있었고, 이런 생각이 들었습니다: 만약 그 메시지들로부터 그녀의 성격을 추출하여 내가 실제로 대화할 수 있는 무언가로 증류 (distill)할 수 있다면 어떨까? 대체품이 아니라 — 그저 그림자 같은 것 말입니다. 그녀가 글을 쓰던 방식, 반복하던 문구, 다정할 때와 조급해할 때의 어조를 반영한 결과물 말이죠.

하지만 개발을 시작하자마자, 나는 AI나 언어 모델 (language models)과는 전혀 상관없는 벽에 부딪혔습니다. 그것은 훨씬 더 오래되고 훨씬 더 어려운 문제였습니다: 누가 무엇을 말했는가?

채팅 내보내기 파일 — WeChat HTML 파일이나 Telegram JSON 파일 — 을 업로드할 때는 발신자가 명시적으로 표시되어 있습니다. 쉽죠. 하지만 대부분의 사람들은 깔끔한 내보내기 파일을 가지고 있지 않습니다. 그들에게는 스크린샷 (screenshots) 이 있습니다. 수백 장의 스크린샷 말입니다. 그리고 스크린샷은 그저 픽셀 (pixels)일 뿐입니다. 메타데이터 (metadata)도 없고, 발신자 필드도 없고, 구조화된 데이터 (structured data)도 없습니다. 그저 두 개의 텍스트 말풍선 열이 있는 이미지일 뿐이며, 당신은 어떤 것이 그녀의 것이고 어떤 것이 당신의 것인지 알아내야 합니다.

이 부분을 잘못 처리하면, AI 페르소나 (persona)는 키메라 (chimera) — 당신의 절반과 그녀의 절반이 섞여 완전히 혼란스러운 상태 — 가 되어버립니다.

  1. 이미지의 왼쪽 15%와 오른쪽 15% 영역을 스캔하여 배경이 아닌 픽셀 뭉치(아바타 블롭 (avatar blobs))를 찾습니다.
  2. 왼쪽에서 뭉치가 발견되면 해당 Y축 범위를 "상대방의 메시지"로 표시합니다. 오른쪽은 "나의 메시지"입니다.
  3. 각 메시지 구역을 OCR (광학 문자 인식) 하고 감지된 발신자 라벨을 할당합니다.

작동은 했습니다... 가끔은 말이죠. 아바타가 명확히 보이는 깨끗하고 표준적인 WeChat 스크린샷의 경우 정확도는 약 70–80%였습니다. 하지만 다음과 같은 상황에서는 무너졌습니다:

  • 아바타가 잘려 나간 크롭된 스크린샷 (Cropped screenshots)
  • 양쪽에 여러 아바타가 있는 그룹 채팅 (Group chats)
  • 아바타가 원형이며 때때로 메시지 말풍선과 겹치는 Douyin
  • 아바타를 동일한 방식으로 보여주지 않는 WhatsApp
  • 픽셀 스캐너를 혼란스럽게 만드는 스티커, 이미지, 또는 음성 메시지가 포함된 모든 스크린샷

대안은 말풍선 색상 감지 (bubble color detection) 였습니다. WeChat에서는 초록색 말풍선이 '나'를 의미하고, 흰색은 '상대방'을 의미합니다. Douyin에서는 파란색이 '나', 흰색이 '상대방'입니다. 하지만 말풍선 색상은 테마, 다크 모드 (dark mode), 그리고 Android 버전에 따라 달라집니다. 이는 또 다른 취약한 휴리스틱 (heuristic) 위에 쌓아 올린 취약한 휴리스틱이었습니다.

저는 근본적으로 다른 무언가가 필요했습니다.

버전 2: Tesseract 경계 상자 + 위치 기반 감지

Tesseract.js가 단순히 텍스트만 반환하는 것이 아니라, 인식된 모든 줄에 대한 경계 상자 (bounding boxes)를 반환한다는 사실을 깨달았을 때 돌파구가 마련되었습니다. 각 줄에는 해당 줄이 페이지의 어디에 위치하는지를 나타내는 픽셀 좌표인 bbox.x0, bbox.x1, bbox.y0, bbox.y1이 포함되어 있습니다.

이것은 게임 체인저 (game-changer)입니다. 왜냐하면 채팅 메시지는 공간적 구조 (spatial structure)를 가지고 있기 때문입니다:

  • 상대방의 메시지는 화면의 왼쪽에서 시작합니다.
  • 나의 메시지는 화면의 오른쪽에서 시작합니다.
  • 이는 WeChat, QQ, Douyin, WhatsApp, Telegram 등 사실상 거의 모든 채팅 앱에서 동일하게 적용됩니다.

따라서 아바타나 말풍선 색상을 감지하려고 시도하는 대신, 단순히 텍스트가 어디서 시작하는지를 확인하면 됩니다. 핵심 알고리즘은 다음과 같습니다:

// 이미지 중간 지점 가져오기
const MID_X = imageWidth / 2;

...

임계값(Thresholds) (0.6, 0.8, 0.75, 1.1)은 WeChat, QQ, Douyin, WhatsApp 등 약 50개의 실제 채팅 스크린샷을 테스트하여 보정되었습니다. 핵심 통찰은 중심점 대신 왼쪽 가장자리(x0)를 주요 신호로 사용하는 것입니다. 왜냐하면 채팅 말풍선은 텍스트 자체가 짧아서 말풍선 내에서 중앙에 위치하더라도, 항상 각자의 측면에서 시작되기 때문입니다.

이 방식은 다음과 같은 장점이 있습니다:

  • 테마 무관 (Theme-agnostic): 다크 모드, 말풍선 색상, 아바타 가시성에 영향을 받지 않습니다.
  • 앱 무관 (App-agnostic): WeChat, QQ, Douyin, WhatsApp, Telegram에서 모두 작동합니다.
  • 크롭에 강함 (Robust to cropping): 좌우 구조만 유지된다면 잘려나가더라도 작동합니다.
  • 빠름 (Fast): 픽셀 스캔이 필요하지 않습니다. Tesseract가 이미 제공하는 좌표에 대한 수학적 계산만 수행하면 됩니다.

쓰레기 필터: OCR 출력이 80%의 노이즈인 이유

채팅 스크린샷에 대한 OCR에 대해 아무도 말해주지 않는 사실이 있습니다. 출력값의 대부분은 쓰레기(Garbage)라는 점입니다. Tesseract는 다음과 같은 것들을 충실히 인식할 것입니다:

  • "16:24" 또는 "06/02 20:35"와 같은 타임스탬프 (Timestamps)
  • "发送消息" (메시지 전송) 및 "消息" (메시지)와 같은 UI 레이블 (UI labels)
  • 텍스트로 렌더링된 내비게이션 요소, 배터리 표시기, 신호 막대
  • 이미지 압축 아티팩트(Artifacts)로 인한 무작위 노이즈

위치 기반 탐지(Position-based detection)가 작동하기 전에, 반드시 이 노이즈를 걸러내야 합니다. 쓰레기 필터는 세 가지 규칙을 사용합니다:

// 규칙 1: 타임스탬프 건너뛰기
if (/^\d{1,2}[:\/]\d{2}/.test(text) && text.length < 12) continue;

...

규칙 3이 가장 중요합니다. "::::....::::"와 같은 줄은 명백히 채팅 메시지가 아니지만, Tesseract는 이를 텍스트로 인식할 것입니다. 최소 30%의 CJK(한중일) 문자 또는 라틴 문자를 요구함으로써, 실제 메시지("嗯" 또는 "ok"와 같은 짧은 메시지 포함)는 보존하면서 대부분의 노이즈를 제거합니다.

단계 1.5: 발신자 확인 단계

위치 기반 탐지는 훌륭하지만 완벽하지는 않습니다. 일부 스크린샷은 특이한 레이아웃을 가지고 있습니다. 어떤 메시지는 (시스템 알림처럼) 중앙에 위치하기도 합니다. 어떤 사용자들은 가로 모드로 스크린샷을 찍어 좌우 관례를 뒤집어 놓기도 합니다.

모든 예외 상황(edge case)을 알고리즘적으로 처리하려고 시도하는 대신, 저는 **단계 1.5: 발신자 확인 (Sender Confirmation)**을 추가했습니다. OCR 및 초기 발신자 라벨링(labeling)이 완료된 후, 도구는 일시 중지하며 감지된 첫 15개의 메시지와 할당된 발신자의 미리보기를 사용자에게 보여줍니다.

  • [对方] (전 애인)로 라벨링된 메시지는 왼쪽에 표시됩니다.
  • [我] (나)로 라벨링된 메시지는 오른쪽에 표시됩니다.
  • 발신자 전환 (Swap Senders) 버튼은 감지가 반대로 되었을 경우 모든 라벨을 뒤집습니다.
  • 확인 (Confirm) 버튼을 누르면 정제(distillation) 단계로 진행합니다.

이는 의도적인 설계 선택입니다. 알고리즘이 힘든 일을 처리하게 하되, 최종 결정에는 인간이 개입(human in the loop)하도록 하는 것입니다. 검토 과정이 없는 완전 자동화된 감지 방식은 페르소나(persona) 전체를 망가뜨리는 잘못된 결과를 조용히 만들어낼 것입니다. 10초간의 확인 단계는 왜곡된 대화로 인해 발생할 수 있는 몇 시간의 낭비를 방지합니다.

OCR 전처리: "想"과 "相"이 중요한 이유

중국어 OCR은 어렵습니다. 想 (그립다/생각하다)과 相 (외양/상호)의 차이는 가로 획 하나뿐입니다. 저해상도에서는 Tesseract가 이 둘을 빈번하게 혼동합니다. 전 애인과의 채팅 문맥에서 "我想你" (보고 싶어)를 "我相你" (의미 없는 문장)로 혼동하는 것은 단순한 오타를 만드는 것이 아니라, 페르소나 정제(persona distillation) 과정에서 AI를 혼란스럽게 만드는 **의미론적으로 망가진 메시지 (semantically broken message)**를 생성하게 됩니다.

해결책은 OCR 이전에 공격적인 전처리를 수행하는 것입니다:

  1. 2배 업스케일링 (2x upscale): 감지된 각 메시지 영역을 양선 보간법 (bilinear interpolation)을 사용하여 원래 크기의 두 배로 확대합니다. 이를 통해 Tesseract가 각 글자의 획을 처리할 때 더 많은 픽셀을 사용할 수 있게 합니다.
  2. 그레이스케일 변환 (Grayscale conversion): 색상 정보는 문자 인식에 무관하며 노이즈를 추가할 뿐입니다. 그레이스케일로 변환하면 인식 문제가 단순해집니다.
  3. 대비 확장 (Contrast stretching): 가장 어두운 픽셀은 순수한 검은색이 되고 가장 밝은 픽셀은 순수한 흰색이 되도록 픽셀 값을 재매핑합니다. 이를 통해 색상이 있는 채팅 말풍선 배경에 대해 글자의 가장자리를 선명하게 만듭니다.

전처리를 거친 후, 일반적인 WeChat 스크린샷에서의 문자 정확도는 약 85%에서 95% 이상으로 향상되었습니다. 남은 오류들은 DeepSeek OCR 교정 단계에서 포착되는데, 이 단계는 문맥을 사용하여 잘못 인식된 글자를 수정합니다 (예: 주변 대화 내용을 바탕으로 "我相你"를 "我想你"로 수정).

공유 Tesseract 워커: 분 단위에서 초 단위로

초기에 제가 저질렀던 성능상의 실수 하나를 말씀드리자면, 각 채팅 말풍선마다 개별적으로 Tesseract.recognize()를 호출했다는 점입니다. 호출할 때마다 새로운 WASM 워커(worker)를 생성하고, 언어 모델을 로드하고, 텍스트를 인식한 뒤, 워커를 종료합니다. 30개 이상의 메시지 영역이 있는 스크린샷의 경우, 이는 30번 이상의 워커 시작을 의미하며, 각 시작에는 510초가 소요되었습니다. 총 OCR 시간은 **스크린샷당 35분**이 걸렸습니다.

해결책은 공유 워커 패턴(shared worker pattern)을 사용하는 것이었습니다:

// 단 하나의 워커를 생성하여 모든 영역에서 재사용
const worker = await Tesseract.createWorker('chi_sim+eng');
const results = [];
...

단일 지속성 워커(persistent worker)를 사용하면 언어 모델이 한 번 로드되어 메모리에 유지됩니다. 이후의 각 recognize() 호출은 510초 대신 200500ms가 소요됩니다. 총 OCR 시간이 분 단위에서 초 단위로 단축되었습니다.

페르소나 증류 (Persona distillation): 6단계 파이프라인

깨끗하고 발신자가 라벨링된 메시지를 확보하고 나면, 증류 파이프라인(distillation pipeline)이 DeepSeek의 API(사용자의 키 사용)를 통해 실행됩니다:

  1. OCR 교정 + 발신자 ID (OCR Correction + Sender ID) — DeepSeek가 원본 OCR 텍스트를 검토하여 잘못 인식된 글자를 수정하고, 대화 문맥을 바탕으로 발신자 라벨을 확인합니다.
  2. 입력 (Intake) — AI가 모든 메시지를 읽고 주요 패턴, 주제, 감정적 역학을 요약합니다.
  3. 기억 추출 (Memory Extraction) — 핵심 기억, 공유된 경험, 그리고 중요한 순간들을 식별합니다.
  4. 페르소나 증류 (Persona Distillation) — 상세한 성격 프로필을 구축합니다: 말투, 감정적 성향, 반복되는 문구, 유머 스타일, 의사소통 습관 등.
  5. 시스템 프롬프트 생성 (System Prompt Generation) — 페르소나를 정체성 보호 규칙(identity guard rules)이 포함된 채팅 시스템 프롬프트로 변환합니다.
  6. 채팅 준비 완료 (Chat Ready) — 이제 사용자는 증류된 페르소나와 대화할 수 있습니다.

핵심적인 아키텍처 결정 사항은 다음과 같습니다: 모든 단계에서 (문맥을 위한) 전체 대화 내용과 (추출을 위한) 전 애인의 메시지만을 동시에 전달받는다는 점입니다. 모든 프롬프트에는 성격 특성이 반드시 [对方](상대방)의 메시지에서만 추출되어야 한다는 엄격한 규칙이 적용됩니다. [我](나)의 메시지는 문맥 용도로만 사용되며, 절대로 페르소나의 것으로 간주되어서는 안 됩니다.

이러한 분리가 없다면, AI는 두 사람의 말투를 하나로 섞어버려 혼란스러운 단일 페르소나를 만들어낼 것입니다. 전 애인의 메시지만을 엄격하게 추출하는 방식이야말로, 페르소나가 '두 사람 모두'가 아닌 '그 사람'처럼 느껴지게 만드는 핵심 요소입니다.

정체성 가드(Identity Guard): 페르소나 드리프트(Persona Drift) 방지

초기 테스트 단계에서 발견된 가장 기괴한 버그 중 하나는 **페르소나 드리프트 (Persona Drift)**였습니다. 긴 대화가 이어지면 AI가 서서히 사용자의 말투를 닮아가기 시작하는 현상입니다. 만약 사용자가 계속해서 "haha"나 "lol"이라고 말한다면, 페르소나 역시 이를 따라 하기 시작합니다. 사용자가 따뜻하고 애정 어린 태도를 보이면, 페르소나는 전 애인의 실제 성격과 상관없이 그 따뜻함을 거울처럼 반영하게 됩니다.

해결책은 모든 시스템 프롬프트에 '정체성 가드(Identity Guard)' 규칙을 추가하는 것이었습니다:

당신은 [이름]이며, 사용자가 아닙니다.
절대로 사용자의 말투를 모방하지 마십시오.
당신의 성격은 증류된 페르소나를 바탕으로 고정되어 있습니다.
...

이는 첫 번째 호출뿐만 아니라 모든 API 호출에 원본 페르소나 설명을 포함함으로써 강화됩니다. AI는 전통적인 의미에서 대화 기록을 "기억"하는 것이 아니라, 각 메시지에 전체 페르소나 문맥을 포함함으로써 점진적인 드리프트(drift)를 방지합니다.

IndexedDB: 페르소나가 살고 죽는 곳

모든 페르소나 데이터는 브라우저의 IndexedDB에 저장되며, 두 개의 오브젝트 스토어(Object Store)로 구성됩니다:

  • persona — 증류된 성격 프로필, 이름, 아바타, 메모리 요약, 수정 사항
  • chats — 자동 증가(auto-incrementing) ID가 포함된 대화 기록

왜 localStorage 대신 IndexedDB를 사용했을까요? 두 가지 이유가 있습니다:

  1. 용량 (Capacity): 아바타 이미지와 대화 기록을 포함한 완전한 페르소나 (persona)는 localStorage의 5MB 제한을 쉽게 초과할 수 있습니다. 반면 IndexedDB는 실질적인 용량 제한이 없습니다.
  2. 구조화된 저장 (Structured storage): IndexedDB는 JSON 직렬화 (serialization) 오버헤드 없이 이진 데이터 (binary data, 아바타 이미지)와 복잡한 객체 (complex objects)를 기본적으로 지원합니다.

그리고 모든 것을 삭제하는 명령어인 /let-go가 있습니다. 두 오브젝트 스토어 (object stores)가 모두 비워집니다. 백업도, 복구도, "정말 하시겠습니까?"라는 확인 대화 상자도 없습니다. 이러한 잔혹함이 바로 핵심입니다. 이 도구는 일시적인 용도로 설계되었습니다. 당신이 미련을 버릴 준비가 되었을 때, 두 단어를 입력하면 모든 것이 사라집니다.

동의 오버레이 (The consent overlay)

실존 인물의 AI 복제본을 만드는 것은 중대한 행위입니다. 저는 증류 (distillation)가 시작되기 전에 5초간의 동의 카운트다운을 추가했습니다. 사용자는 다음 문구를 보게 됩니다:

당신은 실제 채팅 기록을 기반으로 AI 페르소나 (persona)를 생성하려고 합니다. 이 페르소나는 다른 사람의 의사소통 스타일을 시뮬레이션할 것입니다. 이것이 당신이 진정으로 원하는 일인지 잠시 시간을 내어 숙고해 주십시오.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0