메모는 남았는데, 왜 적었는지 기억나지 않을 때
웹에서 좋은 문장을 발견하면 복사해서 메모 앱에 붙여 넣곤 해요. 그날은 분명 중요해 보였는데, 며칠 뒤 다시 보면 왜 저장했는지 잘 떠오르지 않을 때가 있어요. 출처 링크를 눌러도 긴 글 속에서 같은 문장을 다시 찾아야 하고요.
ContextLayer는 이 불편을 줄이려고 만들고 있는 Chrome 확장 프로그램이에요. 웹에서 문장을 선택하고 바로 옆에 메모를 적어두면, 다음에 같은 페이지를 열었을 때 원래 문장을 찾아서 표시해 줘요. 메모 내용과 함께 그 생각이 시작된 자리를 남기는 도구인 셈이에요.

실제 bblog 이력서의 Summary 문장을 선택한 화면이에요. 이력서는 SduiDocumentViewer가 렌더링하고, 메모는 방문한 웹페이지와 분리된 extension-origin iframe 안에서 편집해요.
먼저 알아두면 좋은 브라우저 배경지식
일반 웹사이트와 Chrome 확장 프로그램은 실행되는 방식이 조금 달라요. ContextLayer 화면은 하나처럼 보이지만, 실제로는 여러 공간이 서로 메시지를 주고받으며 움직여요.
- 웹페이지는 지금 읽고 있는 기사나 문서예요.
- Content Script는 확장 프로그램이 웹페이지 안에서 실행하는 작은 코드예요. 선택한 문장을 읽거나 하이라이트를 그릴 수 있어요.
- Background Service Worker는 화면 뒤에서 저장과 권한 처리를 맡아요. 웹페이지를 닫아도 확장 프로그램의 중심 역할을 해요.
- IndexedDB는 브라우저 안에 있는 데이터베이스예요. ContextLayer의 메모와 문서는 기본적으로 여기에 저장해요.
이처럼 사용자 기기의 데이터를 먼저 기준으로 삼는 방식을 local-first라고 불러요. 인터넷이 연결돼야만 메모를 읽을 수 있는 구조와 달리, ContextLayer의 기본 데이터는 내 브라우저에 있어요.
문장만 복사하지 않고 돌아올 자리까지 저장해요
사용자가 문장을 선택하면 Content Script가 그 순간의 Range를 읽어요. Range는 브라우저가 “이 글의 여기부터 여기까지 선택했다”고 기억하는 좌표예요.
저장 버튼을 누를 때까지 기다리지 않고 선택 즉시 읽는 이유가 있어요. 뉴스나 채팅 페이지는 화면 내용이 계속 바뀔 수 있어요. 몇 초만 지나도 처음 선택했던 위치가 달라질 수 있거든요.
선택한 위치는 Web Annotation 형식의 anchor로 바꿔서 저장해요. anchor는 나중에 같은 자리를 찾기 위한 단서 묶음이라고 보면 돼요.
TextQuoteSelector
├─ exact: 선택한 문장
├─ prefix: 문장 바로 앞의 내용
└─ suffix: 문장 바로 뒤의 내용
TextPositionSelector
├─ start: 문장이 시작된 위치
└─ end: 문장이 끝난 위치
주소만 저장하는 책갈피보다 단서가 많아요. 페이지가 조금 수정되더라도 선택한 문장과 앞뒤 내용을 함께 비교해 원래 위치를 다시 찾을 수 있어요.
비슷한 문장이 많을 때는 함부로 붙이지 않아요
같은 문장이 한 페이지에 세 번 나온다고 생각해 볼게요. 문장 내용만 비교하면 어느 위치에 메모를 붙여야 할지 알 수 없어요. ContextLayer는 문장 일치 정도와 앞뒤 문맥, 예전 위치와의 거리를 함께 봐요.
exact 근거 0.30
context 근거 0.18
position 근거 0.07
페이지마다 사용할 수 있는 단서가 다르기 때문에, 실제로 존재하는 근거끼리 점수를 다시 계산해요. 원래 위치에서 1,000자 이상 멀어지면 위치 점수는 0으로 처리하고요.
최종 판정은 꽤 조심스럽게 해요.
score ≥ 0.90 확실함, 자동 하이라이트
score ≥ 0.78 가능성은 있지만 자동으로 붙이지 않음
score < 0.78 찾지 못함
1·2등 차이 < 0.08 후보가 모호하므로 붙이지 않음
후보를 하나 고른 뒤에는 실제 화면의 Range에서 글자를 다시 읽어 한 번 더 확인해요. 메모가 엉뚱한 문장에 붙으면 사용자는 잘못된 정보를 믿을 수 있어요. 그래서 애매할 때는 “찾지 못했어요”라고 말하는 쪽을 택했어요.
하이라이트는 CSS Custom Highlight API로 그려요. 쉽게 말해 페이지의 HTML을 잘라서 <mark> 태그로 감싸지 않고, 브라우저의 별도 표시 기능을 이용하는 거예요. 방문한 사이트의 React 화면이나 클릭 이벤트를 망가뜨릴 가능성을 줄일 수 있어요.
화면 하나 뒤에 네 개의 실행 공간이 있어요
ContextLayer의 전체 실행 구조는 다음과 같아요.
방문한 웹페이지
└─ Content Script
├─ 선택 영역 읽기
├─ 문장 위치 다시 찾기
└─ 하이라이트와 메모 표시
확장 프로그램 전용 화면
├─ 페이지 위 메모 작성 창
├─ Side Panel
└─ 메모 Workspace
Background Service Worker
├─ 메시지 검사
├─ 작업 순서 조율
└─ IndexedDB 저장
이 공간들은 메모리 속 객체를 바로 공유할 수 없어요. 그래서 RPC라는 방식으로 대화해요. RPC는 다른 공간에 “이 작업을 실행해 줘”라고 구조화된 메시지를 보내는 방법이에요.
예를 들어 메모 저장 메시지에는 작업 이름과 저장할 데이터가 들어가요. Background는 받은 값을 바로 믿지 않고 Zod schema로 검사해요. Zod는 실행 중에 데이터 모양을 확인해 주는 도구예요. TypeScript 타입은 앱을 빌드할 때 도움을 주지만, 실제 브라우저 밖에서 들어온 메시지까지 막아주지는 못하거든요.
DDD가 필요한 이유부터 이야기해 볼게요
기능이 적을 때는 메모 저장 코드 한곳에서 모든 일을 해도 괜찮아요. 그런데 문장 위치, 문서 본문, 리마인더와 검색이 함께 들어오기 시작하면 작은 변경 하나가 엉뚱한 데이터를 건드리기 쉬워져요.
예를 들어 “웹페이지에서 하이라이트만 삭제한다”는 동작을 생각해 볼게요. 연결된 메모 문서까지 지워야 할까요? 예약해 둔 알림은요? 이런 질문에 매번 화면 코드가 제각각 답하면 데이터가 금방 꼬여요.
그래서 ContextLayer에는 DDD(Domain-Driven Design)를 적용했어요. DDD는 어려운 이름이지만 출발점은 단순해요. 프로그램을 기술 종류가 아니라, 사용자가 실제로 하는 일과 그 규칙을 중심으로 나누는 방법이에요.
동네 장터를 예로 들면 이해하기 쉬워요. 상품 등록, 채팅과 결제는 서로 연결돼 있지만 같은 장부를 쓰지는 않아요. 각 영역에는 자기만의 규칙이 있고, 거래가 진행될 때 필요한 영역만 협력해요. ContextLayer도 비슷하게 나눴어요.
Bounded Context: 각자 맡은 규칙의 경계
DDD에서는 같은 단어와 규칙을 공유하는 영역을 Bounded Context라고 불러요. 여기서는 “이 선 안에서는 이 모델이 책임자예요”라는 뜻으로 사용했어요.
ContextLayer의 핵심은 세 Context예요.
Annotation Context ── noteDocumentId ──▶ Document Context
Reminder Context ──── noteDocumentId ──▶ Document Context
Article record ────── sourceKey ───────▶ Annotation Context
- Annotation Context는 웹 문장의 위치와 연결 상태를 맡아요.
- Document Context는 사용자가 작성한 메모 본문을 맡아요.
- Reminder Context는 언제 다시 알려줄지를 관리해요.
- Article record는 원문 페이지의 제목과 주소를 다시 찾기 위한 출처 기록이에요.
한 Context가 다른 Context의 속사정을 직접 들여다보지 않게 했어요. Annotation은 Document 전체를 들고 있지 않고 noteDocumentId만 알아요. 주민등록번호 대신 택배 송장 번호로 물건을 찾는 모습과 비슷해요. 연결할 식별자만 알면 상대 영역의 내부 구조가 바뀌어도 영향을 덜 받아요.
Aggregate: 한 번에 일관성을 지켜야 하는 묶음
Bounded Context 안에서도 모든 데이터를 한꺼번에 수정하지는 않아요. 한 작업에서 규칙을 함께 지켜야 하는 데이터 묶음을 Aggregate라고 해요. Aggregate는 자기 상태를 바꾸는 규칙을 한곳에 모아 둬요.
ContextLayer에는 Annotation, Document와 Reminder Aggregate가 있어요.
Annotation Aggregate는 연결만 책임져요
Annotation은 웹 원문과 메모 문서를 이어주는 연결 정보예요.
Annotation
├─ Annotation ID
├─ 원본 URL과 sourceKey
├─ 선택 위치를 나타내는 anchor
├─ noteDocumentId
└─ active | deleted 상태
새 Annotation을 만들 때는 유효한 anchor가 꼭 필요해요. 삭제할 때는 DB 행을 바로 없애지 않고 active에서 deleted로 상태를 바꿔요. 이렇게 상태만 바꾸는 삭제를 soft delete라고 해요. 실수로 연결을 끊었을 때 원본 기록을 보존하기 좋고, 일반 목록에서는 deleted 상태를 걸러내면 돼요.
Annotation을 삭제해도 연결된 메모 문서는 지우지 않아요. 웹 문장 위의 표시를 없애는 것과 내가 쓴 메모 자체를 버리는 일은 다르기 때문이에요. Inbox에 만들어진 문서 링크도 그대로 남겨요.
메모 본문은 Annotation에 복사하지 않아요. 본문의 원본은 Document 하나뿐이에요. 같은 문장을 두 군데에 저장하면 어느 쪽이 최신인지 계속 맞춰야 하거든요.
resolved, ambiguous 같은 복원 결과도 저장하지 않아요. 이 값은 지금 열어둔 페이지에서만 유효해요. 사이트 내용이 바뀌면 다음 방문에 다시 계산하는 편이 맞아요.
Document Aggregate는 메모 내용과 편집 순서를 지켜요
Document는 실제 메모 본문을 맡아요. 본문은 SDUI Document라는 블록 문서 형식으로 저장해요. SDUI는 문단, 인용문과 링크 같은 화면 구성을 JSON 문서로 표현하는 방식이에요.
트리 구조가 실제 문서에서도 유지되는지 확인하려고 이 사이트의 이력서를 편집 모드에서 내보낸 뒤, 실제 ContextLayer 확장 프로그램의 Workspace에 저장해 봤어요. 가운데 편집기는 같은 sdui-document-react로 문서를 렌더링해요. 내보낸 JSON에는 88개 블록이 있었고, 가장 깊은 블록은 root에서 네 단계 아래에 있었어요.

실제 ContextLayer 확장 프로그램을 실행한 화면이에요. 가운데 편집기에서 Work Experience 아래 bullet이 다시 자식 bullet을 품는 트리 구조를 들여쓰기로 확인할 수 있어요.
예를 들어 경력 한 항목은 columnList 아래에 기간을 표시하는 column과 경력 내용을 담는 column을 자식으로 가져요. 경력 내용 안에서는 bullet이 다른 bullet을 자식으로 품을 수 있고요. 화면에서는 일반적인 이력서처럼 보이지만, 편집기는 부모와 자식이 연결된 JSON 트리로 다뤄요. 읽기 모드의 SduiDocumentViewer와 편집 모드의 SduiDocumentEditor가 같은 SduiDocumentContent를 사용한다는 것도 함께 확인했어요.
Document
├─ id / workspaceId
├─ 제목
├─ SDUI 문서 본문
├─ version
├─ 검색용 plainText
└─ active | archived | trashed 상태
문서를 저장할 때는 expectedVersion을 함께 보내요. 예를 들어 서버에 version 4가 있는데 사용자가 version 3을 기준으로 수정했다면 저장을 거절해요. 오래된 창에서 최신 내용을 덮어쓰지 않게 막는 거예요. 이런 방식을 optimistic locking, 낙관적 잠금이라고 해요.
저장에 성공하면 version은 정확히 1 올라가요. 충돌이 생겨도 자동 재시도하지 않아요. 오래된 version으로 똑같이 다시 보내 봐야 해결되지 않고, 운이 나쁘면 다른 창에서 쓴 내용까지 잃을 수 있어요.
검색용 plainText는 별도의 원본으로 관리하지 않아요. 문서를 저장할 때 SDUI 본문에서 다시 만들어요. 이런 값을 projection이라고 해요. 원본을 보기 편한 모양으로 가공한 복사본이라서, 문제가 생기면 원본으로 다시 만들 수 있어요.
문서 사이의 링크도 projection이에요. 본문에 있는 page block과 inline link를 읽어 edge를 다시 만들어요. 자기 자신을 가리키는 링크는 빼고, 같은 연결이 여러 번 나오면 하나로 합쳐요.
Inbox도 새로운 Aggregate를 만들지 않았어요. inbox라는 고정 ID를 가진 특별한 Document예요. 새 메모를 만들면 Inbox 본문에 해당 메모로 가는 page block을 하나 추가해요.
archive와 trash는 의미가 달라요. archive는 평소 목록에서 잠시 치워두는 상태라 Annotation이나 Reminder를 건드리지 않아요. trash는 다른 Context에도 영향이 갈 수 있어서 Document 혼자 결정하지 않고 Application Layer가 조율해요.
Reminder Aggregate는 예약의 원본을 지켜요
Reminder는 “이 메모를 언제 다시 알려줄지”를 맡아요. 메모 본문에 알림 상태를 섞지 않고 noteDocumentId로 Document를 가리켜요.
현재는 미래 시점 한 번만 울리는 one-shot 알림을 지원해요. 같은 alarm event가 두 번 도착하더라도 scheduled에서 fired로 한 번만 바뀔 수 있어요. 문서를 archive해도 알림은 유지하고, trash로 보내면 아직 울리지 않은 알림을 canceled로 바꿔요.
여기서 원본 데이터는 IndexedDB의 Reminder record예요. Chrome의 alarms API는 예약 시간이 되면 신호를 주는 장치일 뿐이에요. 브라우저가 다시 시작되면 Reminder record를 읽어 alarm을 다시 만들 수 있어요. 이처럼 원본에서 다시 만들 수 있는 값도 projection이라고 볼 수 있어요.
Application Layer는 동네 반장처럼 순서를 조율해요
Context를 잘 나눠도 여러 Context를 함께 바꿔야 하는 순간이 있어요. 새 메모 생성이 대표적이에요.
Annotation만 저장되고 Document 저장이 실패하면 눌러도 열리지 않는 하이라이트가 남아요. 반대라면 출처를 잃은 메모가 생기고요. 각 Aggregate는 자기 규칙만 지키기 때문에, 여러 Aggregate의 작업 순서는 Application Use Case가 조율해요.
CreateAnnotation
↓ 같은 requestId가 이미 처리됐는지 확인
↓ Inbox 조회 또는 생성
↓ Annotation 생성 규칙 실행
↓ Note Document 생성 규칙 실행
↓ Inbox 연결과 edge 계산
↓
ContextLayerUnitOfWork.run()
├─ Annotation
├─ Note Document
├─ Inbox Document
├─ Document Edge
└─ Idempotency Record
requestId는 저장 요청마다 붙는 고유 번호예요. 네트워크나 브라우저 사정으로 같은 요청이 두 번 도착해도 이미 처리한 번호라면 문서를 또 만들지 않아요. 이를 idempotency, 멱등성이라고 해요.
마지막 저장은 Unit of Work로 묶어요. Unit of Work는 “이 작업들은 모두 성공하거나 모두 취소한다”는 경계예요. Dexie Adapter가 이를 하나의 IndexedDB transaction으로 실행해요. transaction 중 하나라도 실패하면 앞에서 저장한 내용도 되돌려요.
덕분에 Domain은 Dexie 사용법을 몰라도 되고, Annotation과 Document의 모델은 서로 분리된 채로 남아요. 저장할 때만 Application Layer가 한 묶음으로 조율해요.
Port와 Adapter를 콘센트처럼 사용해요
DDD의 안쪽 규칙이 Chrome API나 DB 라이브러리에 묶이면 테스트와 교체가 어려워져요. 그래서 외부 기능과 만나는 곳에는 Port와 Adapter를 사용했어요.
Port는 콘센트 모양처럼 “어떤 기능이 필요하다”는 계약이에요. Adapter는 그 콘센트에 꽂는 실제 플러그예요.
React 화면 / Chrome 이벤트 / 웹페이지 DOM
↓
Application Use Case
↓
Domain
↑
Port 계약
↑
Dexie / Chrome / DOM Adapter
예를 들어 Domain은 “현재 시각이 필요하다”는 것만 알아요. 실제 코드에서는 Clock: () => timestamp처럼 작은 함수를 받아요. 제품에서는 Date를 연결하고 테스트에서는 항상 같은 시간을 돌려주는 함수를 연결하면 돼요. ID도 crypto.randomUUID()를 바로 부르지 않고 같은 방식으로 주입해요.
DOM Adapter는 선택 영역을 읽고 anchor 계산을 @lodado/web-annotation에 맡겨요. Chrome Adapter는 툴바와 사이트 권한을 다뤄요. Dexie Adapter는 Domain 객체와 DB record를 변환하고 transaction을 실행해요.
모든 함수에 interface를 붙이지는 않았어요. 실제로 기술이 바뀔 수 있거나 브라우저 밖의 효과가 생기는 경계에만 Port를 뒀어요. 구조를 위한 구조는 만들지 않으려고 했어요.
CQRS는 필요한 만큼만 나눴어요
CQRS는 데이터를 바꾸는 Command와 읽는 Query를 분리하는 설계 방식이에요. ContextLayer는 Command용 DB와 Query용 DB까지 완전히 나누지는 않았어요. 지금 규모에서는 관리할 것이 더 늘어나기 때문이에요.
대신 UI에 Domain Aggregate를 통째로 보내지 않아요. 화면에 필요한 모양으로 만든 read model을 반환해요.
- 메모 목록에는
AnnotationListItem - 문서 목록에는
DocumentSummary - 상세 화면에는
DocumentDetail - 현재 페이지의 복원 결과에는
PageResolution
화면이 Domain 내부 구조에 기대지 않으니, Aggregate가 바뀌어도 UI까지 한꺼번에 고칠 일이 줄어요.
FSD는 화면 코드의 교통정리를 맡아요
DDD가 업무 규칙의 경계를 정한다면 FSD(Feature-Sliced Design)는 화면 코드의 참조 방향을 정해요.
entrypoints → app/bootstrap
app/bootstrap → views / features / entities / adapters
views → features → entities → shared
app/adapters → features / entities / shared
views에는 메모 작성 창과 Side Panel, Workspace가 있어요. features에는 메모 캡처와 문서 저장 같은 Use Case가 있고요. entities에는 앞에서 설명한 Domain 규칙이 들어가요.
바깥 레이어는 안쪽을 사용할 수 있지만 안쪽 Domain이 React 화면이나 Dexie를 import하지 못하게 했어요. 각 slice 밖에서는 index.ts에 공개한 API만 사용해요. 어느 화면에서 시작한 요청이 어떤 규칙을 거쳐 저장되는지 따라가기 쉬워져요.
메모 창을 웹페이지와 분리한 이유
페이지 위에 보이는 메모 창도 두 공간으로 나눴어요.
- 창 테두리와 드래그, 크기 조절은 Web Component와 Shadow DOM이 맡아요.
- 실제 메모 본문과 SDUI editor는 extension-origin iframe 안에서 실행해요.
Shadow DOM은 웹페이지 스타일과 확장 프로그램 스타일이 서로 섞이지 않게 작은 울타리를 만들어 줘요. iframe은 한 단계 더 분리된 문서 공간이에요.
메모 창 전체를 커다란 iframe으로 만들면 투명한 부분까지 웹페이지 클릭을 막을 수 있어요. 본문까지 Shadow DOM에 넣으면 사용자 메모가 방문한 페이지와 같은 실행 영역에 가까워지고요. 그래서 창 모양과 편집 영역의 책임을 나눴어요.
두 공간은 메모마다 만든 MessageChannel로 대화해요. 창 좌표와 저장 상태는 전달하지만 메모 본문이나 SDUI 문서 전체는 보내지 않아요. 같은 메모를 다시 열면 iframe을 새로 만들지 않고 기존 창을 앞으로 가져와요. 다른 메모라면 여러 창을 함께 열 수 있어요.
캡처한 메모가 작은 지식 문서로 이어져요
메모 하나는 Document 하나로 저장돼요. 처음에는 선택한 인용문과 원문 링크, 사용자가 적은 메모 블록으로 시작해요. Workspace에서는 이 문서를 다시 편집하고 다른 메모와 연결할 수 있어요.

왼쪽에서 메모를 찾고 가운데에서 내용을 편집해요. 오른쪽에서는 이 문서를 가리키는 메모와 연결 그래프를 볼 수 있어요.
편집 내용이 바뀌면 400ms 동안 입력이 더 없는지 기다렸다가 저장해요. 타자를 칠 때마다 DB를 호출하지 않기 위한 debounce예요. Background에서는 문서 내용을 다시 검증하고 version, 검색용 글과 문서 연결을 한 transaction으로 저장해요.
Workspace에는 Inbox와 문서 검색, Articles와 Graph가 있어요. 태그와 달력, 리마인더도 한곳에서 볼 수 있어요. JSON 내보내기는 로컬 파일만 만들고, 전체 삭제는 확인 문구를 직접 입력해야 실행돼요.
개인정보는 기능이 아니라 경계에서 지켜요
기본 모드는 사용자가 툴바를 누른 현재 탭에서만 동작해요. 특정 사이트에서 항상 실행하려면 사용자가 그 사이트의 권한을 직접 허용해야 해요. 허용 여부는 별도 DB에 복사하지 않고 Chrome permissions를 기준으로 확인해요.
Content Script는 IndexedDB에 직접 접근하지 않아요. 저장은 검증된 RPC를 거쳐 Background에서만 처리해요. 메모 본문은 방문한 웹페이지의 DOM이나 postMessage에 넣지 않아요.
사용자가 적은 글을 innerHTML로 렌더링하지 않고, eval이나 원격 script도 사용하지 않아요. 전체 데이터 내보내기는 네트워크가 아니라 내 컴퓨터의 파일로 저장해요.
테스트도 레이어별로 나눴어요
Domain 테스트는 브라우저나 DB 없이 규칙만 확인해요. 제목 길이의 경계와 version 증가, 자기 자신을 가리키는 링크 제거 같은 테스트가 여기에 있어요.
Application 테스트에서는 시간이 멈춘 가짜 Clock과 고정 ID를 사용해요. 같은 저장 요청이 두 번 온 경우와 transaction이 실패한 경우를 재현할 수 있어요.
Adapter 테스트는 Dexie transaction과 RPC 검증, Chrome 권한 처리를 확인해요. 마지막 E2E 테스트는 빌드한 확장 프로그램을 실제 Chromium에 설치해서 실행해요. E2E는 사용자가 처음부터 끝까지 사용하는 흐름을 확인하는 테스트예요.
같은 문장이 반복되는 페이지, 선택 영역이 여러 HTML 요소를 가로지르는 경우도 fixture로 만들었어요. 원문이 조금 수정된 상황과 모호해서 복원하면 안 되는 상황도 확인해요. 메모에 <script> 같은 문자열을 넣었을 때 실행되지 않는지도 브라우저에서 검사하고요.
현재 개발 중이에요
문장 선택과 메모 작성, 페이지를 다시 방문했을 때의 anchor 복원은 구현되어 있어요. Side Panel과 로컬 Workspace, 여러 메모 창과 리마인더도 현재 코드에 들어가 있어요.
지금은 사용자가 직접 켜는 opt-in 클라우드 동기화와 제품 마감 작업을 진행하고 있어요. 동기화를 켜기 전에는 기존 로컬 메모를 서버에 자동으로 올리지 않는 방향이에요.
아직 Chrome Web Store에 배포된 완성 제품은 아니에요. 기능을 넓히는 동안에도 “메모가 어디에 저장되고 어떤 코드가 읽을 수 있는가”는 흐리지 않으려고 해요.