8년차 개발자로 지내면서 기술 아티클을 꽤 많이 읽어 왔는데, 바로 읽지 못하고 일단 어딘가에 저장해 두는 경우가 많았다. 그게 Notion이었다가 Chrome 북마크였다가 하면서 링크는 여기저기 쌓였는데(그래서 파편화됐지만…), 정작 읽은 내용은 어디에도 남지 않았다. 읽다가 멈춘 글은 어디까지 읽었는지 다시 찾기 어려웠고, 형광펜 친 문장은 어디에 뒀는지 잊어버렸고, 읽으면서 든 생각은 따로 적는 게 귀찮아서 그냥 흘려보내는 일이 반복됐다. 그래서 종이책 읽을 때 하던 것(밑줄 긋고, 여백에 메모하고, 책갈피 꽂고, 다음에 다시 펼치는 것)을 웹에서 그대로 하게 해 주는 Chrome 확장을 만들게 됐다. 이름은 Markpin이고, 무료이며 계정이 없다.
이번에 올린 1.2.0에서는 형광펜을 그은 다음에 일어나는 일을 바꿨다. 문장을 드래그해 색이나 기호를 고르면 메모 칸이 열리는데, 빈칸이 아니라 「왜 표시했나요?」 같은 질문이 먼저 적혀 있다. 이렇게 바꾼 데는 나름 근거가 있다. 여러 학습 방법을 비교한 정리(Dunlosky 외, 2013)에서 형광펜만 긋는 건 효과가 낮은 쪽으로 평가됐고, 스스로 "왜?"를 묻거나 자기 말로 설명해 보는 쪽이 더 높게 평가됐다. 또 Roediger와 Karpicke의 2006년 실험에서는 글을 기억나는 대로 떠올려 본 사람들이 다시 읽기만 한 사람들보다 일주일 뒤에 더 많이 기억했다(61% 대 40%). 메모 칸이 무엇을 묻는지, 그리고 「다 읽음」을 누르면 무슨 일이 일어나는지는 이 두 결과를 보고 정했다.
이 글은 "무엇을 만들었나"보다 "어떻게 만들었나"에 가깝다. 로컬 퍼스트 Chrome 확장을 만들면서 내린 결정 일곱 개를 순서대로 적어 보고자 한다(앞의 여섯 개는 velog에 먼저 썼던 개발기를 지금 코드에 맞게 고친 것이다).

1. 데이터 정본은 IndexedDB에, 설정만 chrome.storage에
우선 저장한 페이지와 클립(형광펜, 메모, 영역 클립)은 전부 확장의 IndexedDB에 둔다. chrome.storage에는 저장 위치, 플로팅 버튼 표시, 온디바이스 AI 켜기 같은 설정만 담는다. 이렇게 나눈 이유는 두 가지인데, 하나는 용량이다. 페이지 본문과 클립 이미지가 쌓이면 chrome.storage.local 기본 한도를 금방 넘어 버린다(unlimitedStorage 권한이 여기서 나온다). 다른 하나는 트랜잭션이다. 페이지와 그 클립들이 레코드 하나라서, 클립 하나를 더할 때도 읽고 고치고 다시 쓰는 과정 전체가 원자적이어야 했다. 사이드패널의 Zustand 스토어는 UI 상태만 들고 있고 데이터는 항상 IndexedDB에서 다시 읽는다. 백그라운드가 쓰고 나면 STORE_CHANGED를 브로드캐스트하고, 사이드패널은 자기가 쓴 뒤엔 스스로 다시 읽는다(sendMessage는 보낸 쪽에는 돌아오지 않기 때문이다).
2. 파일은 페이지당 Markdown 하나, 기계 데이터는 파일 끝 주석 블록에
파일 포맷에서 지키고 싶었던 건 두 가지였다. 사람이 Obsidian에서 열었을 때 깨끗해야 하고, 확장이 다시 읽을 때 손실이 없어야 한다는 것. 처음(v1)엔 별 고민 없이 frontmatter에 키를 전부 넣었는데, 막상 Obsidian에서 열어 보니 Properties 패널에 전부 노출되고 중첩 키(clips)는 JSON 한 줄로 보였다(이건 좀 보기 싫었다). 그래서 v2에서는 이렇게 나눴다.
---
title: 예시 글 제목
url: https://example.com/article
savedAt: 2026-09-16T10:00:00.000Z
tags:
- react
- state
summary: 한 줄 요약
---
## Clips
> 인용된 문장
내 메모
## Content
(본문)
<!-- markpin
{"v":2,"id":"…","rev":3,"updatedAt":"…","domain":"example.com","clips":[{"id":"…","type":"text","createdAt":"…","updatedAt":"…","text":"인용된 문장","note":"내 메모","selectors":[{"type":"TextQuoteSelector","exact":"…","prefix":"…","suffix":"…"},…],"color":"amber"}]}
-->
frontmatter에는 사람이 읽는 키만 남겼다. title, url, savedAt, tags는 늘 있고, 값이 있을 때만 summary, note, readAt, skimmedAt, deletedAt이 붙는다(summary는 Chrome 내장 모델이 요약을 만들었을 때만 생긴다). id rev updatedAt domain clips bookmark 같은 기계용 데이터는 파일 끝 <!-- markpin … --> 안에 JSON 한 줄로 넣었다(위 예시는 줄인 것이다). 블록을 끝에 둔 이유는 Obsidian Live Preview가 HTML 주석을 숨기지 않고 회색 글자로 보여 주기 때문인데(Reading view에서는 숨긴다), 끝에 있으면 적어도 본문을 읽는 데는 방해가 안 된다. 문자열 안에 -->가 들어가면 주석이 일찍 닫히지 않도록 그 JSON 줄에서는 --\u003e로 쓰고, JSON.parse가 원래대로 되돌린다.
모르는 frontmatter 키와 모르는 블록 키는 둘 다 보존하고, 키를 더하기만 하는 변경은 v를 올리지 않기로 했다. 파일 한 개 상한은 512KB로, 본문이 넘치면 잘라내고 contentTruncated를 남긴다. 파서는 v1(frontmatter markpin: 1)도 계속 읽는다. 옵션의 「기존 파일 다시 쓰기」는 전부 v2로 다시 쓰면서 rev만 올리고 updatedAt은 건드리지 않는데, 그래서 다른 기기에서 충돌 사본이 생기지 않는다.
3. 동기화는 서버 없이, 폴더나 Google Drive로
저장 위치는 셋 중 하나를 고른다. 브라우저만(기본), 내 컴퓨터의 폴더(File System Access API, showDirectoryPicker), 그리고 Google Drive(drive.file 스코프라 확장이 만든 파일만 볼 수 있다). 폴더와 Drive는 같은 인터페이스(list, read, write, remove) 뒤에 있는 어댑터 두 개이고, 엔진 하나가 둘 다 돌린다.
엔진은 마지막 쓰기 3초 뒤에, 그리고 chrome.alarms로 5분마다 동기화한다. 실패하면 재시도 간격을 1분부터 두 배씩 늘려 최대 30분까지 기다렸다가 다시 시도한다. 충돌 처리는 단순하게 했다. 동기화 이력이 없는 페이지는 updatedAt이 더 늦은 쪽을 고르고, 이력이 있는 페이지가 양쪽에서 바뀌었으면 이미 올라가 있는 쪽을 받되 이 기기에서 고친 쪽은 지우지 않고 .conflict- 사본으로 남긴다. 가장 똑똑한 방법은 아니겠지만 서버 없이 쓰는 개인 도구에서는 이 정도면 충분하다고 생각했다. 폴더 저장소에서는 삭제한 페이지를 Obsidian 휴지통 관례를 따라 .trash/로 옮기는 옵션도 있다(고른 폴더 안의 .trash/이고 기본은 꺼져 있다).
MV3 서비스 워커는 언제든 죽을 수 있어서, 주기 동기화와 재시도는 둘 다 알람으로 걸고 아직 올려야 할 것은 IndexedDB에 적어 둔다. 동기화가 도는 중에 또 요청이 들어오면 again 플래그를 세워 끝난 뒤 한 번 더 돌리고, 어댑터를 바꾸는 중에는 옛 어댑터가 끼어들지 못하게 막아 뒀다. 사실 이 서비스 워커 수명 문제가 MV3에서 제일 신경 쓰이는 부분인 것 같다.
4. 형광펜을 다시 그 자리에 그리기
페이지를 다시 열었을 때 형광펜이 같은 문장 위에 있어야 하는데, 이게 생각보다 까다로운 문제다. 텍스트 클립에는 W3C Web Annotation 셀렉터 세 가지를 같이 저장한다. 문장 그대로에 앞뒤로 최대 32자씩을 붙인 TextQuoteSelector, 그리고 TextPositionSelector와 CssSelector다. 영역 클립은 요소마다 CssSelector와 XPathSelector를 저장한다. 복원할 땐 먼저 quote를 페이지 전체에서 그대로 찾고, 없으면 CSS나 XPath로 요소를 찾아 그 안에서 공백을 정규화해 다시 찾고, 그래도 없으면 페이지 전체를 정규화해서 한 번 더 찾는다(TextPositionSelector는 다른 도구와의 호환용으로 저장만 하고 복원엔 쓰지 않는다). 전부 실패한 클립은 orphan으로 표시해서 목록에는 남기고 페이지에는 안 그린다. DOM이 조금 바뀌어도 quote 셀렉터가 대부분 잡아 주는 것 같다.
형광펜은 CSS Custom Highlight API로 칠해서 페이지 DOM을 건드리지 않는다. 나머지 페이지 안 UI(플로팅 버튼, 선택 툴바, 메모 팝오버, 레일)는 전부 #markpin-host 아래 Shadow DOM에 그리고, !important는 한 군데도 쓰지 않는다. 페이지 본문은 저장하거나 형광펜, 클립, 책갈피를 남길 때만 담고, 저장하지 않은 페이지에서는 아무것도 기록하지 않는다.
5. 요약과 태그는 Chrome 내장 모델로
요약과 태그 제안은 Chrome 내장 Prompt API(LanguageModel)를 쓴다. 모델이 기기 안에서 돌아서 본문이 밖으로 나가지 않고 API 키도 필요 없는데, 로컬 퍼스트라는 방향과 잘 맞는다고 생각했다. UI 언어가 en·ja·es·de·fr이면 expectedOutputs로 출력 언어를 선언하고, 아직 받아 주지 않는 ko·pt-BR은 프롬프트로만 언어를 정한다(한국어가 아직 이 목록에 없는 건 좀 아쉽다). 모델이 없는 기기에선 그냥 꺼지고, 옵션에서 끌 수도 있다.
6. MCP로 내 폴더를 Claude가 읽게 하고, 쓰기는 확인 뒤에
@markpin/mcp는 그 폴더 위에서 도는 stdio MCP 서버다(Node 20.1 이상). 기본은 읽기 전용 도구 4개(search get_page list_recent list_tags)만 있고, --allow-write를 주면 쓰기 도구 12개가 더 등록된다(add_clip set_clip_note set_bookmark update_tags …). 클라이언트가 elicitation을 지원하면 쓰기마다 확인을 먼저 받는데, 내 파일을 AI가 조용히 고쳐 놓는 건 원치 않았기 때문이다. 모든 도구에 readOnlyHint를, 쓰기 도구에는 destructiveHint도 달아 뒀다(true인 건 delete_pages 하나다). 폴더를 watch하고 있어서 브라우저에서 방금 저장한 페이지가 다음 질문에 바로 잡히는데, 이게 써 보면 꽤 재밌다. 서버 소스는 MIT로 공개했다.
claude mcp add markpin -- npx -y @markpin/mcp@latest --dir ~/Markpin --allow-write
7. 1.2.0에서 더한 기호와 한 줄, 그리고 파일에 쓰지 않는 읽던 자리
선택 툴바가 두 줄이 됐다. 윗줄은 [클립] ? ! × ★이고, 아랫줄은 나머지 형광펜 색 세 개의 점(클립 버튼은 지금 고른 색을 쓴다)과 오른쪽 끝의 책갈피다. 색이나 기호를 한 번 누르면 바로 클립이 만들어진다. 기호 네 개는 의문, 새로 안 것, 반박, 핵심인데, 클립 하나에는 색 하나와 기호 하나까지만 붙는다. 색은 분류용이고 기호는 반응용이라고 생각하면 편하다.
클립이 저장되면 열리는 메모 칸의 안내 문구도 기호에 따라 달라진다. 기호가 없으면 「왜 표시했나요?」, ?면 「무엇이 궁금한가요?」, !면 「무엇이 새로운가요?」, ×면 「왜 아닌가요?」, ★면 「왜 핵심인가요?」다. 막아서는 창이 아니라 안내 문구라서 비워 둬도 된다(적기 귀찮은 날도 있으니까).
다 읽은 글에서 책갈피를 눌러 「다 읽음」을 고르면 「이 글을 한 줄로」 칸이 하나 더 열린다. 안내 문구는 「기억나는 대로 한 줄」이고 500자까지 쓸 수 있다. 이 한 줄은 사이드패널 목록에서 제목 아래에 보이고 검색에도 걸린다. 책갈피와 읽음 옆에는 세 번째 상태인 「훑어봄」도 생겼다. 셋은 겹치지 않아서 한 글에는 하나만 붙고, 훑어봄은 한 줄을 묻지 않는다.
전부 키를 더하기만 한 변경이라 포맷은 여전히 v2다. 1.2.0 이후 같은 파일의 윗부분은 이렇게 생겼다.
---
title: 예시 글 제목
url: https://example.com/article
savedAt: 2026-09-16T10:00:00.000Z
tags:
- react
- state
summary: 한 줄 요약
note: 전역 상태에 넣어 둔 것 대부분은 사실 서버 캐시였다
readAt: 2026-10-12T08:30:00.000Z
---
## Clips
> 인용된 문장
× 모든 컴포넌트가 읽는 값일 때만 그렇다
기호는 형광펜 아래 메모 줄 앞에 붙고, 파일 끝 블록에는 클립마다 "kind":"disagree"처럼 들어간다(값은 question, insight, disagree, star). 한 줄은 frontmatter의 note 속성이 되고, 훑어본 글에는 readAt 자리에 skimmedAt이 ISO 시간으로 들어간다. 빈 값은 쓰지 않는다(tags만은 비어 있어도 tags: []로 쓴다).
저장한 페이지에서는 왼쪽 가장자리에 12px짜리 레일이 생긴다(마우스를 올리면 넓어지고, 보여 줄 게 없으면 숨는다). 클립은 그 색의 점으로, 책갈피는 리본으로, 읽던 자리는 회색 줄로 보이고, 점을 누르면 그 자리로 스크롤된다. 레일은 처음에 오른쪽 가장자리에 뒀는데, macOS 오버레이 스크롤바와 겹쳐서 거의 안 보이는 바람에 왼쪽으로 옮겼다(만든 나도 잘 못 찾았다…). 읽던 자리는 스크롤이 멈추고 1.5초 뒤에 기록되는데, 그 기기의 IndexedDB에만 두고 Markdown 파일에는 쓰지 않는다. 다시 돌아와도 토스트나 자동 스크롤은 없고, 읽던 자리는 레일에만 보인다.
사이드패널에서는 35일이 넘은 책갈피를 카드 하나로 묶어 「35일 넘은 책갈피 N개 · 아직 읽고 싶나요?」라고 한 번 묻는다. 글마다 「두기」, 「훑어봄」, 「책갈피 빼기」 중 하나를 고르면 되고, 아무것도 지워지지 않는다.
@markpin/mcp 0.9.0에서는 add_clip이 kind를 받고, 쓰기 도구가 두 개 늘었다. 한 줄을 쓰는 set_note(빈 문자열이면 지우고, 바뀌기 전과 후를 돌려준다)와 mark_skimmed다. 읽기 도구 중에서는 get_page만 kind, note, skimmedAt을 돌려주고, search와 list_recent는 그것들을 뺀 요약을 돌려준다.
권한과 외부 요청
권한은 storage unlimitedStorage sidePanel activeTab alarms identity에 호스트 권한 <all_urls>이고, 콘텐츠 스크립트는 http와 https 페이지에서 돈다. identity와 googleapis.com은 Drive를 연결할 때만 쓰고, 확장은 그 밖의 외부 요청을 하지 않는다. 분석 도구도 넣지 않았다.
묻고 싶은 것
여기까지가 지금 시점에서 내린 결정들이다. 다 만들고 보니 아직 부족한 부분도 보이고, 특히 아래 두 가지는 혼자 판단하기보다 써 본 분들의 이야기를 듣고 싶다. 이 페이지에는 댓글이 없어서 support@markpin.dev로 알려 주면 정말 고마울 것 같다.
- 파일 포맷 v2. 사람이 읽는 키는 frontmatter에, 기계 데이터는 파일 끝 주석 블록에 두는 이 분리를 Obsidian이나 다른 Markdown 도구에서 써 보면 불편한 지점이 있을지 궁금하다.
- 다음으로 보고 있는 건 읽은 것을 다시 보는 쪽이다. 하이라이트 복습, 내 메모 모아 보기, 로컬 읽기 통계 중 무엇이 먼저면 좋을지 듣고 싶다.
로컬 퍼스트 확장을 만들어 보려는 누군가에게 약간의 도움은 됐으면 좋겠다.