Jev를 활용하여 Hotmail 계정에서 12GB 이상을 확보한 방법 (그리고 또 다른 구독 서비스를 건너뛴 이야기)
요약
본 글은 Jev 모델과 Next.js 15 스택을 활용하여 Hotmail 계정의 대용량 이메일을 효율적으로 정리하고 삭제하는 개인 개발 경험을 공유합니다. 사용자가 직접 검토할 수 있는 'Mail Organizer' 로컬 애플리케이션을 구축했으며, Microsoft Graph와 SQLite를 연동하여 메타데이터 기반으로 안전하게 작동하도록 설계했습니다.
핵심 포인트
- Jev 모델을 활용해 이메일 삭제 결정을 구조화하고 승인하는 과정을 구현함.
- Next.js 15, React, TypeScript 스택을 사용한 로컬 애플리케이션 개발 사례.
- Microsoft Graph와 SQLite를 연동하여 메타데이터만 처리하고 보안성을 높임.
- OAuth 및 PKCE 플로우를 사용하여 개인 계정의 인증과 동기화를 구현함.
이 글은 원래 my personal development blog: How I freed more than 12GB in my Hotmail account with Jev에 게시되었습니다. DEV Community를 위해 각색된 버전입니다.
그래서 저는 Mail Organizer라는 앱을 만들었습니다. 이 앱은 Outlook에서 삭제할 수 있는 이메일을 결정하고, 불확실한 사례들을 검토하며, 실제로 삭제 작업을 수행하는 로컬 애플리케이션입니다. 단순한 정리 작업을 유용한 검토 과정으로 바꾼 것은 TypeSafe의 System One 모델인 Jev 덕분이었습니다.
원래 결과 시각화 보기: 62,025개 이메일 삭제 및 8,058개 메시지 잔존.
목표는 AI가 내 받은 편지함을 비우는 것이 아니었습니다. 수천 개의 반복적인 결정을 내가 이해하고 승인할 수 있는 목록으로 바꾸는 것이었습니다.
제가 선택한 스택
저는 앱이 제 컴퓨터에서 실행되고, 비밀 정보(secrets)가 브라우저에 절대 노출되지 않기를 원했습니다. 그래서 Next.js 15 프로젝트를 기반으로 React 19, TypeScript, 그리고 인터페이스를 위한 Tailwind CSS를 선택했습니다. 이 프로젝트의 API 라우트들이 서버 로직을 처리합니다. 덕분에 별도의 서비스를 배포할 필요 없이, 검토 패널과 정리 작업을 같은 애플리케이션 내에서 구축할 수 있었습니다.
| 구성 요소 | 기술 | 프로젝트에서의 역할 |
|---|---|---|
| 인터페이스 및 서버 | Next.js App Router, React, TypeScript, Tailwind | 안내 흐름을 보여주고 Node.js에서 API 라우트를 실행합니다. |
| ... |
저는 작업 내용을 날짜 범위로 나누었습니다. 이 앱은 Microsoft Graph를 통해 Outlook에 연결하고 메시지를 분류하는 데 필요한 정보(보낸 사람, 제목, 짧은 미리보기, 날짜, 폴더, 첨부 파일 여부와 같은 몇 가지 신호)만 로컬 SQLite 데이터베이스에 저장합니다. 전체 본문이나 첨부 파일을 다운로드하거나 저장하지는 않습니다. 제가 메시지를 읽고 싶을 때 앱이 그때 Outlook에 본문을 요청합니다.
- React 패널: Next.js API 라우트로 액션을 전송합니다.
- API 라우트: MSAL과 로컬 세션을 사용하여 Microsoft Graph와 통신합니다.
- Microsoft Graph: 날짜 범위 메타데이터를 반환하며, API가 이를 SQLite에 저장합니다.
- SQLite → Jev: 각 쿼리는 하나의 이메일을 전송하고; Jev는 데이터베이스로 다시 들어가는 유형화된 판단(typed judgments)을 반환합니다.
- 보호 정책 (Protection policy): 패널에서 제가 보는 목록을 필터링합니다. 확인 시, API가 Graph에 영구 삭제를 요청합니다.
이러한 판단들을 SQLite에 저장하는 것이 중요했습니다. 저는 같은 메시지를 두 번 분류하지 않고도 중단하고 재개할 수 있었습니다. 또한 결정 임계값(decision thresholds)을 변경하고 Jev에게 다시 요청하지 않고 목록을 재구성할 수도 있었습니다.
인증 및 동기화 (Authentication and sync)
개인 Hotmail 계정의 경우, 저는 공용 클라이언트인 MSAL을 사용한 OAuth와 Authorization Code + PKCE 플로우를 사용했습니다. 이 앱은 위임된 메일 권한(delegated mail permissions)을 요청하고, 연결된 계정이 제가 설정한 계정인지 확인하며, 세션을 서버에 유지합니다. 절대로 제 비밀번호를 묻지 않으며 브라우저에서 _client secret_을 사용하지 않습니다. 이 로직은 auth.ts에 있습니다.
모든 동기화에는 날짜가 필요합니다. 내부적으로 저는 UI의 포함된 날짜(inclusive dates)를 [from, to) 간격으로 변환하여 Microsoft Graph에 페이지별로 쿼리합니다. 세션이 중단될 경우를 대비해 SQLite에 커서와 진행 상황을 저장합니다. 다시 동기화할 때, Jev가 이미 메시지를 분류했다는 사실을 지우지 않고 메타데이터만 업데이트합니다. 이 부분은 sync.ts 및 messages.ts에 있습니다.
SQLite는 또 다른 역할을 합니다. Outlook은 제 junk_probability 필드를 알지 못하기 때문에 Graph에서 이 값으로 직접 이메일을 필터링하거나 그룹화할 수 없습니다. 로컬 데이터베이스에서는 messages(메타데이터), judgments(Jev 답변), scores(정책 결과), jobs(진행 상황), 그리고 deletion_log(감사)로 분리했습니다. 임계값을 변경하면, 다른 분류 비용을 지불하지 않고도 judgments로부터 scores를 재계산할 수 있습니다.
장시간 작업 및 진행 상황 설명 UI
수천 개의 이메일을 동기화하고, 분류하고, 삭제하는 과정은 일반적인 웹 요청보다 시간이 오래 걸립니다. API 경로는 로컬 프로세스 내에서 백그라운드 작업을 시작하고 제어권을 패널에 반환합니다. jobs는 범위, 커서, 카운터를 저장하며, 분류된 각 이메일은 완료되는 즉시 영구적으로 저장됩니다. 서버가 재시작되어도 앱은 중단된 작업을 인식하고 처음부터 다시 시작할 필요 없이 계속 진행할 수 있습니다.
저는 인터페이스를 연결(connect), 날짜 선택(choose dates), 동기화(sync), 분류(classify), 검토(review)의 다섯 단계로 구성하고, 페이지 수신, 오류 및 재시도에 대한 활동 로그를 추가했습니다. 이는 분류 자체만큼 중요한 디자인 문제를 해결해 주었습니다. 즉, 장시간 작업 중 시스템이 진행 중인지, 할당량 대기 중인지, 아니면 저를 기다리고 있는지 알아야 했기 때문입니다.
Jev에게 요청한 것들
분류기(classify.ts)의 이 조각은 핵심 아이디어를 보여줍니다. 저는 자유 텍스트를 파싱하는 대신, 하나의 이메일 컨텍스트와 열 가지 질문을 함께 보내고 타이핑된 응답을 읽어 들입니다.
const response = await typeSafe().systemOne({
state: stateFor(message, folders, evaluatedAt),
model: "jev-latest",
...
다른 질문들은 맥락과 보호 기능을 추가합니다. 즉, 그것이 마케팅처럼 보이는지 아니면 자동 알림인지, 사람이 답장을 기다리고 있는지, 자격 증명(credentials)을 포함하는지, 재정적 또는 법적 기록인지, 그리고 유용성이 이미 만료되었는지 여부입니다. 시간 민감한 경우, Jev는 이메일 날짜와 평가 날짜를 모두 받습니다. 몇 년 전의 임시 코드와 같은 날짜의 청구서(invoice)는 동일하게 취급되어서는 안 됩니다.
이 앱은 이메일당 한 번의 Jev 요청을 하지만, 그 요청 안에 열 가지 질문을 모두 포함합니다. 아홉 개는 'Noul' 질문이고, 나머지 하나는 메시지를 잃었을 때 얼마나 고통스러울지 5단계 규모로 점수를 매깁니다. 저는 동시 요청 수를 15개로 제한하고 분당 시작 횟수를 900회로 설정했습니다. 일시적인 오류(transient errors)는 재시도되며, 계속 실패하는 메시지는 제가 다시 처리할 수 있도록 표시된 상태로 유지됩니다. no-reply와 같은 패턴은 단순히 메시지를 대기열(queue)의 더 앞쪽으로 이동시킬 뿐입니다. 스스로 스팸(junk)이라고 선언하지 않습니다.
| 판단 | 내가 취한 조치 |
|---|---|
| 75% 이상일 확률로 일회용이었음 | 검토할 준비가 된 **삭제 가능(deletable)**으로 표시되었습니다. |
| ... | |
| 그 백분율은 모델의 판단이며, 삭제가 안전하다는 보장은 아닙니다. 저는 별도의 제동 장치를 추가했습니다. 자격 증명, 재정적 또는 법적 문서, 개인 대화의 강력한 징후가 있는 이메일은 Jev가 일회용이라고 부르더라도 **보관(keep)**으로 이동합니다. 또한 허용 목록(allowlist)을 통해 발신자를 보호할 수도 있습니다. 대량 배치를 삭제하기 전에, 앱은 최대 100개의 이메일 샘플에 레이블을 지정하고 제가 내린 결정과 Jev의 결정을 비교하여 오탐지(false positives)를 포착할 수 있게 합니다. |
코드로 보면 정책(policy.ts)이 대략 다음과 같습니다 (축약됨):
const probability = judgment.is_disposable;
const protectedMail =
judgment.contains_access_credentials > 0.60 ||
...
그 분할은 의도적이었습니다: Jev가 판단을 제공하고, 결정론적 함수(deterministic function)가 저의 임계값과 보호 기능을 적용합니다. 모델 답변을 변경하지 않고 정책을 바꿀 수 있으며, 각 이메일이 어떤 목록에 놓였는지 설명할 수 있습니다.
실제로 공간을 확보한 단계
분류(Classification) 기능만으로는 저장 공간이 회복되지 않습니다. 저는 먼저 목록들을 검사하고, 발신자별로 이메일을 그룹화했으며, 불확실한 메시지들을 검토했습니다. 삭제를 하려면 앱에서 보호 블록을 보여주고 선택된 이메일의 정확한 개수를 입력하도록 요구합니다. 그래야만 Microsoft Graph에 **영구 삭제(permanent delete)**가 전송됩니다. 단순히 메시지를 _삭제된 항목(Deleted Items)_으로 이동시키는 것만으로는 할당량 문제를 해결하기에 충분하지 않았습니다.
비슷한 것을 구축하고 싶다면, 핵심 작업은 permanentDelete입니다. Microsoft Graph는 이를 POST /me/messages/{id}/permanentDelete를 통해 노출하며, 개인 계정의 경우 위임된(delegated) Mail.ReadWrite 권한이 필요합니다. 문서에 따르면 메시지는 복구 가능한 항목(Recoverable Items) 아래의 삭제 예정(Purges) 폴더로 이동합니다. 이것이 즉시 물리적으로 지워진다는 의미는 아닙니다: 보존 정책이 여전히 적용될 수 있습니다.
공식 Microsoft Graph 문서: 권한, 엔드포인트, 그리고 Microsoft Learn permanentDelete 가이드에서 Purges 폴더에서 무슨 일이 일어나는지 확인하세요.
Graph를 호출하기 전에 저는 deletion_log에 각 메시지에 대해 pending 행을 작성합니다. 그런 다음 변경 불가능한 식별자(immutable identifiers)를 사용하여 최대 20개 작업 단위로 permanentDelete를 전송합니다. 폴더 이동이 이메일 참조를 변경하지 않기 위함입니다. 성공적인 응답만이 해당 기록을 deleted로 표시하며, 404 또는 불확실한 응답은 검토하거나 재시도할 경우로 남겨둡니다. 이러한 흐름은 deletion.ts에 구현되어 있습니다.
대량 정리 과정 중 또 다른 제한 사항이 나타났습니다: Microsoft가 요청을 속도 제한(throttle)합니다. 할당량을 초과하자 삭제 속도가 느려졌습니다. 저는 10분마다 9,000개의 작업 예방 예산(preventive budget)을 추가하고, Microsoft가 Retry-After로 응답할 때 공유 대기(shared wait)를 구현했습니다. 이 과정은 이미 확인된 삭제는 반복하지 않고 실패한 항목만 재시도합니다. 패널에서는 할당량을 기다리는지 여부를 보여주며, 로그에는 각 이메일의 결과가 기록됩니다. 네트워크 오류는 성공적인 삭제로 보고되지 않습니다.
기록된 결과: 2026년 9월 22일 기준으로 로컬 히스토리는 62,025개의 고유한 이메일을 삭제했고 보류 또는 실패 건은 0건이었습니다. 로컬 데이터베이스에는 여전히 8,058개의 메시지가 남아 있었습니다. Jev는 100,737,845 토큰을 사용했으며, 비용은 $3.6393이었습니다.
정리할 수 없었던 받은 편지함(inbox)은 관리 가능한 흐름으로 바뀌었습니다: 날짜를 제한하고, Jev에게 명확한 판단을 요청하며, 중요한 것을 보호하고, 검토 및 삭제를 확인하는 방식입니다. Jev가 반복적인 분류 작업을 대신해주었습니다. 규칙과 인간의 검토가 최종 결정을 제 손에 남겨두었습니다.
추가 자료 (Further reading)
- Jev란 무엇이며 어떻게 작동하는지 - 공식 TypeSafe 문서 참고.
- Noul, Score, and Choice - 분류를 구축하는 데 사용한 구조화된 답변.
- System One API 참조 - 쿼리 계약(query contract) 관련.
- 메시지 영구 삭제 (Microsoft Graph) - 권한 및 Purges 폴더에 대한 공식 가이드.
소개 (About me)
저는 Fernando Peralta이며, 소프트웨어 개발, 웹 애플리케이션 및 기술 프로젝트를 다루는 웹 개발자입니다.
또한 멕시코에 위치한 웹 개발 에이전시인 Devstoremx에도 참여하고 있으며, 이곳에서 웹사이트, 맞춤형 애플리케이션, 이커머스 솔루션, 통합(integrations), 자동화 작업을 수행합니다.
저는 제 개인 웹사이트 및 소프트웨어 개발 블로그에서 개인 프로젝트, 기술 경험, 그리고 개발 아티클을 공유합니다.
이 프로젝트를 원래의 디자인과 일러스트레이션으로 탐색하고 싶으시다면, 블로그에서 전체 아티클을 읽어보실 수 있습니다.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기