한빛앤
경력근거: 한빛+ 앱/웹 기능 개발 및 유지보수
※ 실제 산출물이 아닌 AI 예측 시안입니다.
공고는 한빛앤을 '30년 이상의 역사를 가진 한빛미디어의 콘텐츠 전문성과 노하우를 바탕으로 설립된 기업' 으로 소개하고, AI/IT 전문 출판과 교육 콘텐츠를 기반으로 '디지털 학습 플랫폼 한빛+를 운영' 한다고 밝힙니다. 이 자리의 첫 업무는 '한빛+ 앱/웹 기능 개발 및 유지보수' 이고, 기술스택은 앱 'React Native', 프론트엔드 React·Next.js·zustand, 백엔드 'Kotlin, Java, Spring boot , PostgreSQL, JPA' 로 앱부터 서버까지 걸칩니다. 공고 원문은 앱 기능을 따로 적지 않지만 한빛+ 앱은 웹에서 산 IT 전자책을 폰과 태블릿에서 열어 필기를 남기는 뷰어이고, 책 옆에 정리해 둔 메모는 독자가 다시 쓰기 어려운 내용이라 덮어써 사라지면 손해가 큽니다. 오프라인 변경분을 서버 버전과 견줘 합치는 일은 React Native 앱의 큐와 zustand 상태, Spring·JPA 의 버전 관리, PostgreSQL 의 범위 겹침 조회가 한 흐름으로 이어지는 기능이라 이 자리의 스택을 앞뒤로 다 씁니다. 이 포트폴리오는 오프라인 필기 변경분의 동기화 판정과 충돌을 고르는 앱 화면을 보여줍니다. 전자책 표준이나 교육 콘텐츠 전문성은 주장하지 않습니다.
공고는 한빛앤이 한빛미디어의 콘텐츠로 디지털 학습 플랫폼 한빛+ 를 운영한다고 밝히고, 이 자리의 주요업무 첫 줄을 한빛+ 앱/웹 기능 개발 및 유지보수로 적습니다. 서비스 초기 단계부터 아이디어·설계·개발·출시·개선에 참여하는 자리라, 이 편은 독자가 앱에서 직접 만나는 필기 동기화 화면을 서버 판정부터 앱 화면까지 한 사람이 만드는 범위로 잡습니다.
기술스택은 앱 React Native, 프론트엔드 React·Next.js·TypeScript·zustand·Tailwind, 백엔드 Kotlin·Java·Spring boot·PostgreSQL·JPA, 인프라 AWS·Github Actions·Grafana 입니다. 오프라인 큐와 동기화 상태는 zustand 가, 버전 비교와 낙관적 잠금은 Kotlin·Spring·JPA 가, 같은 쪽 범위 겹침은 PostgreSQL 이 맡아 스택이 한 기능 안에서 이어집니다.
주요업무에 서비스 안정성 개선이 있고, 우대사항은 실제 유저가 쓰는 앱을 초기 단계부터 개발·런칭·운영해 본 경험을 꼽습니다. 고친 필기가 사라지지 않는 동기화는 앱 운영에서 독자 신뢰가 걸린 안정성 문제라, 이 편은 무작위로 끼워 넣은 변경분 재생 테스트로 사라지는 변경분을 셉니다.
한빛앤은 한빛미디어의 IT 전문 출판 콘텐츠로 디지털 학습 플랫폼 한빛+ 를 운영하고, 이 화면은 한빛+ 앱에서 오프라인으로 고친 전자책 필기가 다시 연결될 때 뜨는 동기화 확인 화면입니다. 기기가 올린 변경분(생성·수정·삭제)을 고친 시각 순서로 서버의 현재 필기와 견줘, 기기가 고칠 때 보던 서버 버전이 지금 버전과 같거나, 그 사이 서버를 쓴 기기가 자기 자신이면 그대로 합칩니다. 다른 기기가 먼저 고쳤거나 지운 필기, 다른 기기가 남긴 같은 종류 필기와 같은 쪽에서 글자 범위가 겹치는 새 필기는 반영하지 않고 걸러 두며, 독자는 걸린 필기마다 이 기기 내용과 다른 기기 내용을 좌우로 보고 남길 쪽을 고릅니다.
한빛+ 앱으로 IT 전자책을 폰과 태블릿에서 번갈아 읽으며 필기·메모를 남기는 개발자 독자. 오프라인으로 읽다 다시 연결되면 앱이 변경분을 합치고, 충돌로 걸린 필기만 두 내용을 나란히 보고 어느 쪽을 남길지 고른다
독자가 필기를 잃는 것은 동기화 기능이 없어서가 아닙니다. 마지막에 올라온 쪽을 그대로 쓰는 동기화라면, 지하철에서 폰으로 고친 메모와 그사이 태블릿에서 고친 같은 메모가 다시 만날 때 늦게 올라온 쪽이 앞의 메모를 덮고, 덮인 쪽은 어디에도 남지 않습니다. 변경분마다 어느 서버 버전을 보고 고쳤는지(base_version)와 그 뒤 누가 고쳤는지를 견줘 덮어쓸 자리만 독자에게 묻고 나머지는 그대로 합치면, 고친 필기가 말없이 사라지지 않습니다.
Spring Boot 동기화 API 가 폰이 올린 변경분 15행(14개)을 판정해, 그대로 합칠 11개 가운데 이미 반영된 1개를 뺀 10개는 PostgreSQL 의 서버 필기에 version 을 올려 씁니다. 독자가 React Native 앱의 동기화 확인 화면에서 걸린 3개를 이 기기·다른 기기 내용 좌우로 보고 하나를 고르면, 그 선택이 서버에 들어가 같은 필기가 다시 걸리지 않습니다. 서버와 앱은 로컬(Docker Compose)에서 돌고, 두 기기는 기기 id 를 바꾼 에뮬레이터 둘로 대신합니다.
두 기기의 오프라인 변경분을 무작위 순서로 끼워 넣어 올리는 재생 테스트 200회가 GitHub Actions 에서 매번 돌고, 쓰이지도 걸리지도 않고 사라진 변경분 수를 셉니다. 필기 기록 탭에서 독자가 필기 하나의 버전마다 어느 기기가 무엇을 바꿨는지 보고 이전 내용으로 되돌립니다. 2주차부터 AWS(ECS Fargate, RDS PostgreSQL)에 배포해 실제 폰과 태블릿 두 대로 오프라인 시나리오를 돌립니다.
따로 고친 메모에는 Claude Sonnet 4.5 가 두 메모의 낱말을 모두 담은 합친 안을 쓰고, 코드가 빠진 낱말이 없는지 센 뒤에만 합친 메모 남기기 선택지로 띄웁니다. 독자가 나중에 고르기로 미룬 충돌은 보류한 충돌 탭과 한빛+ 웹(Next.js)에 같이 뜨고, Grafana 에 묶음별 충돌 비율과 판정별 건수를 그립니다.
독자가 폰을 사흘 오프라인으로 쓰다 다시 연결하면, 동기화 확인 화면에 변경분 14개 가운데 그대로 합친 11개가 요약으로, 걸린 3개가 카드로 뜹니다. p.142 메모 카드에는 폰의 '학습률 0.01 부터' 와 태블릿이 먼저 고친 '학습률 0.1 이 더 빠름' 이 좌우로 뜨고, 독자가 고른 쪽만 서버에 남습니다.
p.210 처럼 태블릿 형광펜이 끝난 글자에서 폰 형광펜이 시작하면 겹침 0자로 그대로 합치고, 폰이 보고 고친 버전이 서버 버전과 같으면 태블릿이 마지막으로 쓴 필기여도 그대로 합칩니다. 큐에 두 번 들어간 dev-p7f3a:0398 은 한 번만 판정하고, 이미 반영된 것이라 서버에 다시 쓰지 않습니다.
base_version 이 null 인 새 필기, range 가 null 인 북마크, 이미 지워진 서버 필기와 겹치는 새 메모를 섞은 변경분 30종을 올려도 응답 형식이 깨지지 않고, 판정마다 근거 버전과 겹친 글자 수가 남습니다.
두 기기 변경분을 무작위로 끼워 넣은 재생 테스트 200회에서 쓰이지도 걸리지도 않고 사라진 변경분 수를 세어 화면의 자가채점 칸에 적습니다. 단, 정답과 데이터를 모두 작성자가 만든 합성 기준이라 성능 검증이 아니라 데모 조건 동작입니다.
위 결과는 더미데이터 자가채점이며, 실제 독자가 얼마나 자주 충돌을 만나는지, 고르기 화면을 귀찮아하지 않고 쓰는지는 한빛+ 앱의 실제 동기화 기록과 독자 반응이 있어야 측정됩니다. 이 포트폴리오는 그 측정을 바로 돌릴 수 있는 상태로 만듭니다.
재생 테스트에서 사라진 변경분이 0건으로 나와도, 폰 변경분 15행과 서버 필기 12행을 제가 짰고 그 둘이 어긋나는 자리도 제가 심은 것이라 그 0은 병합기가 제 시나리오를 통과했다는 뜻에서 멈춥니다. 독자가 사흘 만에 다시 연결할 때 변경분이 실제로 몇 개 쌓이고 그중 몇 개가 걸리는지는 한빛+ 앱의 동기화 기록에만 있습니다.
p.210 의 태블릿 형광펜(100~180)과 폰 형광펜(180~240)은 180번째 글자에서 맞닿기만 합니다. 범위 끝을 포함으로 읽는 순간 겹침 1자가 되어 같은 자리에 필기가 두 번 생김으로 넘어갑니다. p.12 형광펜은 태블릿이 마지막으로 썼지만 폰이 그 버전(v2)을 보고 고친 것이라 그대로 합쳐야 하는데, 버전을 안 보고 기기만 견주면 따로 고침으로 잘못 걸립니다. 큐에 두 번 들어간 dev-p7f3a:0398 은 edit_key 를 안 보면 base_version 4 가 서버 5 보다 낮아 보여 역시 따로 고침으로 갑니다. 이 세 변경분이 부등호 하나·비교 열 하나가 판정을 가르는 자리입니다.
다른 기기가 고친 뒤에 이 기기가 지운 필기는 계약된 네 판정 어디에도 없어 그대로 합칩니다. 서버가 deleted_at 만 찍는 소프트 삭제라 내용은 필기 기록에 남지만 독자에게 묻지는 않고, 이 경우를 보류한 충돌에 넣을지는 3주차에 정합니다.
글자 범위는 고정 쪽(PDF 형식) 전자책의 텍스트 레이어 글자 위치로 가정했습니다. 글자 크기에 따라 쪽이 바뀌는 리플로우(EPUB) 책은 쪽 번호가 기기마다 달라, 실제 도입에서는 한빛+ 뷰어가 쓰는 위치 표기(EPUB CFI 등)로 겹침 계산을 바꿔야 합니다.
서버 필기에 만든 기기 열이 없어 같은 자리 판정의 '다른 기기가 만든 필기' 를 지금 version 을 쓴 기기로 봅니다. 폰이 만들고 태블릿이 색만 바꾼 형광펜 위에 폰이 새 형광펜을 그으면 걸립니다. 겹친 필기를 놓치는 쪽보다 독자가 한 번 더 고르는 쪽으로 틀리도록 둔 선택입니다.
이 편은 오프라인 변경분의 버전 비교와 범위 겹침 판정, 충돌을 고르는 앱 화면을 다룹니다. 전자책 표준이나 교육 콘텐츠 전문성은 주장하지 않습니다.
① 인라인 샘플 · ② seed.json 다운로드 · ③ 증강 프롬프트. 세 층 모두 동일한 schema 에서 파생.
폰이 사흘 동안 오프라인으로 쌓았다가 10월 12일 08:40 에 다시 연결되며 한꺼번에 올린 변경분입니다. 한 행이 필기 하나에 대한 생성·수정·삭제 하나이고, 앱은 오프라인 동안 같은 필기를 여러 번 고쳐도 마지막 상태 하나만 올립니다. 새 필기는 서버 version 이 없어 base_version 이 null, 북마크는 글자 범위가 없어 range 가 null 입니다. 오프라인 직전에 응답을 못 받은 dev-p7f3a:0398 은 큐에 다시 넣어져 같은 행이 둘입니다.
| edit_key | device_id | annotation_id | book_id | page | kind | range_start | range_end | color | note_text | op | base_version | edited_at |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| dev-p7f3a:0398 | dev-p7f3a | ann_4801 | book_1207 | 75 | 메모 | 300 | 340 | 분홍 | 배치 크기 32 부터 | 수정 | 4 | 2026-10-09T07:48:30+09:00 |
| dev-p7f3a:0398 | dev-p7f3a | ann_4801 | book_1207 | 75 | 메모 | 300 | 340 | 분홍 | 배치 크기 32 부터 | 수정 | 4 | 2026-10-09T07:48:30+09:00 |
| dev-p7f3a:0401 | dev-p7f3a | ann_5021 | book_1207 | 142 | 메모 | 1,180 | 1,264 | 노랑 | 경사 하강법: 학습률 0.01 부터 | 수정 | 3 | 2026-10-09T08:12:00+09:00 |
| dev-p7f3a:0402 | dev-p7f3a | ann_4950 | book_1207 | 63 | 형광펜 | 510 | 590 | 파랑 | — | 수정 | 2 | 2026-10-09T08:20:00+09:00 |
| dev-p7f3a:0403 | dev-p7f3a | ann_p_0091 | book_1207 | 57 | 북마크 | — | — | — | — | 생성 | — | 2026-10-09T08:31:00+09:00 |
아래 JSON Schema 에 맞는 필기 변경분 행을 더 만들어 주세요. 한 기기의 오프라인 묶음 하나를 edited_at 순서로 만듭니다. {"type":"object","properties":{"edit_key":{"type":"string","description":"기기 id 와 그 기기의 변경 순번을 이은 변경분 식별자입니다. 응답을 못 받아 다시 보낸 변경분은 같은 값을 씁니다."},"device_id":{"type":"string"},"annotation_id":{"type":"string","description":"기존 필기는 서버 id, 오프라인에서 새로 만든 필기는 기기가 만든 id 입니다."},"book_id":{"type":"string"},"page":{"type":"integer","minimum":1,"description":"고정 쪽(PDF 형식) 전자책의 쪽 번호라 기기마다 같습니다."},"kind":{"type":"string","enum":["형광펜","메모","북마크"]},"range_start":{"type":["integer","null"],"minimum":0,"description":"그 쪽 텍스트 레이어에서 필기가 시작하는 글자 위치입니다. 북마크는 null 입니다."},"range_end":{"type":["integer","null"],"minimum":1,"description":"필기가 끝나는 글자 위치이며 그 글자는 포함하지 않습니다(반열린 구간). 북마크는 null 입니다."},"color":{"type":["string","null"],"enum":["노랑","초록","파랑","분홍",null]},"note_text":{"type":["string","null"],"description":"메모 본문입니다. 형광펜·북마크는 null 입니다."},"op":{"type":"string","enum":["생성","수정","삭제"]},"base_version":{"type":["integer","null"],"minimum":1,"description":"기기가 이 필기를 고칠 때 보고 있던 서버 version 입니다. 새로 만든 필기는 null 입니다."},"edited_at":{"type":"string","format":"date-time","description":"기기에서 고친 시각입니다. 오프라인에서 고친 필기도 그때 시각입니다."}},"required":["edit_key","device_id","annotation_id","book_id","page","kind","range_start","range_end","color","note_text","op","base_version","edited_at"]} 쉬운 데이터만 만들지 말 것. 다음 하드 케이스를 의도적으로 섞어라: (1) 응답을 못 받아 다시 보낸 탓에 edit_key 와 내용이 똑같은 행 두 개, (2) base_version 이 서버 version 보다 1~2 낮고 그사이 다른 기기가 고친 메모 수정, (3) 다른 기기가 지운 필기를 모르고 고친 수정, (4) 다른 기기 형광펜과 같은 쪽에서 글자 범위가 맞닿기만 하는 새 형광펜과 몇 글자 겹치는 새 형광펜, (5) range 가 null 인 새 북마크, (6) 이미 지워진 서버 필기와 범위가 겹치는 새 메모, (7) range_start 가 0 인 쪽 맨 앞 형광펜. 생성한 행은 반드시 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"edit_key":{"type":"string","description":"기기 id 와 그 기기의 변경 순번을 이은 변경분 식별자입니다. 응답을 못 받아 다시 보낸 변경분은 같은 값을 씁니다."},"device_id":{"type":"string"},"annotation_id":{"type":"string","description":"기존 필기는 서버 id, 오프라인에서 새로 만든 필기는 기기가 만든 id 입니다."},"book_id":{"type":"string"},"page":{"type":"integer","minimum":1,"description":"고정 쪽(PDF 형식) 전자책의 쪽 번호라 기기마다 같습니다."},"kind":{"type":"string","enum":["형광펜","메모","북마크"]},"range_start":{"type":["integer","null"],"minimum":0,"description":"그 쪽 텍스트 레이어에서 필기가 시작하는 글자 위치입니다. 북마크는 null 입니다."},"range_end":{"type":["integer","null"],"minimum":1,"description":"필기가 끝나는 글자 위치이며 그 글자는 포함하지 않습니다(반열린 구간). 북마크는 null 입니다."},"color":{"type":["string","null"],"enum":["노랑","초록","파랑","분홍",null]},"note_text":{"type":["string","null"],"description":"메모 본문입니다. 형광펜·북마크는 null 입니다."},"op":{"type":"string","enum":["생성","수정","삭제"]},"base_version":{"type":["integer","null"],"minimum":1,"description":"기기가 이 필기를 고칠 때 보고 있던 서버 version 입니다. 새로 만든 필기는 null 입니다."},"edited_at":{"type":"string","format":"date-time","description":"기기에서 고친 시각입니다. 오프라인에서 고친 필기도 그때 시각입니다."}},"required":["edit_key","device_id","annotation_id","book_id","page","kind","range_start","range_end","color","note_text","op","base_version","edited_at"]}폰이 다시 연결된 시각의 서버 필기 현재 상태입니다. 폰이 오프라인이던 사이 태블릿이 고치거나 지우거나 새로 그은 필기가 들어 있고, 지운 필기는 deleted_at 만 찍힌 채 남습니다. 오프라인 직전에 폰이 보낸 dev-p7f3a:0398 은 이미 서버에 들어가 ann_4801 의 last_edit_key 에 남아 있습니다.
| annotation_id | book_id | page | kind | range_start | range_end | color | note_text | version | updated_by_device | updated_at | last_edit_key | deleted_at |
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| ann_5021 | book_1207 | 142 | 메모 | 1,180 | 1,264 | 노랑 | 경사 하강법: 학습률 0.1 이 더 빠름 | 4 | dev-t2c91 | 2026-10-10T22:15:00+09:00 | dev-t2c91:0217 | — |
| ann_4877 | book_1207 | 88 | 메모 | 402 | 455 | 노랑 | 과대적합 예시: 훈련 정확도만 높음 | 3 | dev-t2c91 | 2026-10-10T21:14:00+09:00 | dev-t2c91:0214 | 2026-10-10T21:14:00+09:00 |
| ann_5110 | book_1207 | 201 | 형광펜 | 2,210 | 2,388 | 초록 | — | 1 | dev-t2c91 | 2026-10-11T19:40:00+09:00 | dev-t2c91:0221 | — |
| ann_5133 | book_1207 | 57 | 북마크 | — | — | — | — | 1 | dev-t2c91 | 2026-10-11T20:05:00+09:00 | dev-t2c91:0223 | — |
| ann_4950 | book_1207 | 63 | 형광펜 | 510 | 590 | 노랑 | — | 2 | dev-p7f3a | 2026-10-08T23:10:00+09:00 | dev-p7f3a:0391 | — |
아래 JSON Schema 에 맞는 서버 필기 행을 더 만들어 주세요. version 은 1 부터 쓰기 횟수만큼 오르고, updated_by_device·last_edit_key 는 그 version 을 쓴 기기와 변경분입니다. {"type":"object","properties":{"annotation_id":{"type":"string"},"book_id":{"type":"string"},"page":{"type":"integer","minimum":1},"kind":{"type":"string","enum":["형광펜","메모","북마크"]},"range_start":{"type":["integer","null"],"minimum":0,"description":"그 쪽 텍스트 레이어에서 필기가 시작하는 글자 위치입니다. 북마크는 null 입니다."},"range_end":{"type":["integer","null"],"minimum":1,"description":"필기가 끝나는 글자 위치이며 그 글자는 포함하지 않습니다(반열린 구간). 북마크는 null 입니다."},"color":{"type":["string","null"],"enum":["노랑","초록","파랑","분홍",null]},"note_text":{"type":["string","null"],"description":"메모 본문입니다. 형광펜·북마크는 null 입니다."},"version":{"type":"integer","minimum":1,"description":"서버에 쓸 때마다 1씩 오릅니다. 삭제도 한 번 쓰기라 version 을 올립니다."},"updated_by_device":{"type":"string","description":"지금 version 을 쓴 기기입니다."},"updated_at":{"type":"string","format":"date-time"},"last_edit_key":{"type":"string","description":"지금 version 을 만든 변경분의 edit_key 입니다."},"deleted_at":{"type":["string","null"],"format":"date-time","description":"지운 시각입니다. 서버는 행을 지우지 않고 이 값만 찍어 내용이 필기 기록에 남습니다."}},"required":["annotation_id","book_id","page","kind","range_start","range_end","color","note_text","version","updated_by_device","updated_at","last_edit_key","deleted_at"]} 쉬운 데이터만 만들지 말 것. 다음 하드 케이스를 의도적으로 섞어라: (1) deleted_at 이 찍힌 메모(삭제로 version 이 하나 오른 상태), (2) 같은 쪽에 서로 다른 기기가 그은 형광펜 여럿, (3) 마지막으로 쓴 기기가 다른 기기인 필기, (4) range 가 null 인 북마크, (5) 오프라인 기기가 보낸 변경분이 이미 반영되어 last_edit_key 에 남은 필기. 생성한 행은 반드시 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"annotation_id":{"type":"string"},"book_id":{"type":"string"},"page":{"type":"integer","minimum":1},"kind":{"type":"string","enum":["형광펜","메모","북마크"]},"range_start":{"type":["integer","null"],"minimum":0,"description":"그 쪽 텍스트 레이어에서 필기가 시작하는 글자 위치입니다. 북마크는 null 입니다."},"range_end":{"type":["integer","null"],"minimum":1,"description":"필기가 끝나는 글자 위치이며 그 글자는 포함하지 않습니다(반열린 구간). 북마크는 null 입니다."},"color":{"type":["string","null"],"enum":["노랑","초록","파랑","분홍",null]},"note_text":{"type":["string","null"],"description":"메모 본문입니다. 형광펜·북마크는 null 입니다."},"version":{"type":"integer","minimum":1,"description":"서버에 쓸 때마다 1씩 오릅니다. 삭제도 한 번 쓰기라 version 을 올립니다."},"updated_by_device":{"type":"string","description":"지금 version 을 쓴 기기입니다."},"updated_at":{"type":"string","format":"date-time"},"last_edit_key":{"type":"string","description":"지금 version 을 만든 변경분의 edit_key 입니다."},"deleted_at":{"type":["string","null"],"format":"date-time","description":"지운 시각입니다. 서버는 행을 지우지 않고 이 값만 찍어 내용이 필기 기록에 남습니다."}},"required":["annotation_id","book_id","page","kind","range_start","range_end","color","note_text","version","updated_by_device","updated_at","last_edit_key","deleted_at"]}| TypeScript | 앱과 웹이 쓰는 판정 결과 타입입니다. Spring 이 내는 OpenAPI 문서에서 output_schema 와 같은 타입을 뽑아 두 쪽이 같은 정의를 씁니다. |
| React Native | 한빛+ 앱의 동기화 확인 화면입니다. 합침 요약, 이 기기·다른 기기 내용을 좌우로 둔 충돌 카드, 선택 버튼, 하단 탭을 만들고, 오프라인 변경분 큐를 MMKV 에 둡니다. |
| zustand | 앱의 동기화 상태 스토어입니다. 오프라인 큐, 올리는 중, 고를 것 목록, 고른 값을 담고 persist 로 앱을 다시 켜도 큐와 미처 못 보낸 선택이 남습니다. |
| React | 3주차 한빛+ 웹의 보류한 충돌 화면에서 앱과 같은 충돌 카드 구조를 웹 컴포넌트로 씁니다. |
| Next.js | 3주차에 Next.js 15 App Router 의 보류한 충돌 페이지를 서버 컴포넌트로 그려, 웹에서 읽는 독자도 미룬 충돌을 고릅니다. |
| Tailwind | 웹 화면 스타일이고, 앱 화면은 NativeWind 로 같은 클래스 이름을 씁니다. |
| Kotlin | 동기화 판정 서비스입니다. edited_at 순서 처리, 같은 edit_key 거르기, last_edit_key 로 이미 반영된 변경분 알아보기, 버전 비교, 같은 쪽 반열린 범위 겹침을 순수 함수로 짜고 판정 테스트를 붙입니다. |
| Java | Spring Boot·Hibernate 가 도는 JDK 21 런타임이고, 동기화 API 통합 테스트는 Java 라이브러리인 Testcontainers 로 PostgreSQL 을 띄워 돌립니다. |
| Spring Boot | POST /sync/annotations 가 변경분 묶음을 받아 판정 결과 목록을 돌려주고, POST /sync/annotations/{edit_key}/choice 가 독자가 고른 선택을 받아 서버 필기에 씁니다. |
| PostgreSQL | server_annotations 를 담고, range_start·range_end 를 int4range 로 묶은 생성 열에 GiST 색인을 걸어 같은 쪽에서 겹치는 필기 후보를 && 연산자로 찾습니다. |
| JPA | 서버 필기 엔티티의 version 을 @Version 낙관적 잠금으로 둡니다. 폰과 태블릿이 같은 필기를 동시에 올려 OptimisticLockException 이 나면 그 변경분만 새 version 으로 다시 판정합니다. |
| AWS | 2주차부터 ECS Fargate 와 RDS PostgreSQL 에 배포해 실제 폰·태블릿 두 대로 오프라인 시나리오를 돌립니다. |
| Github Actions | 판정 테스트, 끼워 넣기 재생 테스트 200회, 앱 타입 검사를 커밋마다 돌리고 AWS 배포를 잇습니다. |
| Grafana | 3주차에 묶음별 충돌 비율, 판정별 건수, 변경분이 올라와 반영되기까지 걸린 시간을 그립니다. |
| AI 활용 | 3주차 합친 메모 안은 Claude Sonnet 4.5 가 쓰고, 판정 서비스와 앱 화면 개발은 Claude Code 로 합니다. |
✓ pass → 합치고 고르는 흐름이 돌면 그 자체로 독자가 쓰는 동기화 화면입니다. 더 키우려면 끼워 넣기 재생 테스트로 사라지는 변경분이 없는지 매번 세고, AWS 에 올려 기기 두 대로 돌리는 단계로 갑니다.
✓ pass → 2주차까지로도 완결입니다. 더 키우려면 합친 메모 안을 셋째 선택지로 두고, 미룬 충돌을 웹에서도 고르게 하는 단계로 갑니다.
위 예측 UI 는 3주차까지 완주했을 때의 화면입니다. 1주차에 실제로 만드는 것은 아래 표시된 화면 하나입니다.
verdict사유 라벨 · edit_key + 한 줄 요약(근거 수치 포함)화면·코드·면접에서 이 단어를 그대로 씁니다. 키는 출력 스키마의 enum 과 같습니다.
그대로 합침그대로 합침두 기기에서 같은 필기를 따로 고침따로 고침다른 기기에서 지운 필기를 다시 올림지운 필기 다시 올림같은 자리에 필기가 두 번 생김같은 자리 두 번기기가 올린 변경분 하나의 필기 동기화 판정과 반영 결과, 독자에게 띄울 선택지를 남기는 형식입니다. 판정·버전·겹침은 규칙이 정하고, merged_memo 만 3주차에 Claude Sonnet 4.5 가 씁니다.
{"type":"object","properties":{"batch_id":{"type":"string","description":"기기가 다시 연결되어 한 번에 올린 묶음입니다."},"edit_key":{"type":"string","description":"판정한 변경분입니다. 화면의 줄 키입니다."},"copies_in_batch":{"type":"integer","minimum":1,"description":"같은 묶음에서 이 edit_key 가 나온 행 수입니다. 처음 행만 판정합니다."},"device_id":{"type":"string","description":"변경분을 올린 기기입니다."},"annotation_id":{"type":"string"},"book_id":{"type":"string"},"page":{"type":"integer","minimum":1},"op":{"type":"string","enum":["생성","수정","삭제"]},"verdict":{"type":"string","enum":["그대로 합침","두 기기에서 같은 필기를 따로 고침","다른 기기에서 지운 필기를 다시 올림","같은 자리에 필기가 두 번 생김"]},"applied":{"type":"boolean","description":"이 묶음에서 서버에 썼는지입니다. 이미 반영된 변경분과 걸린 변경분은 거짓입니다."},"base_version":{"type":["integer","null"]},"server_version":{"type":["integer","null"],"description":"판정하는 순간 같은 annotation_id 서버 필기의 version 입니다. 앞선 변경분이 반영된 뒤의 값입니다."},"versions_behind":{"type":["integer","null"],"minimum":0,"description":"server_version - base_version 입니다. 둘 중 하나가 null 이면 null 입니다."},"server_updated_by":{"type":["string","null"]},"server_deleted_at":{"type":["string","null"],"format":"date-time"},"overlap_with":{"type":["string","null"],"description":"같은 book_id·page 에서 kind 가 같고 deleted_at 이 null 이며 updated_by_device 가 다른 서버 필기 가운데 겹친 글자가 가장 많은 필기입니다. 겹친 글자가 없으면 null 입니다."},"overlap_chars":{"type":["integer","null"],"minimum":0,"description":"min(range_end) - max(range_start) 를 0 아래로 내리지 않은 값입니다. 견줄 서버 필기가 없거나 이 변경분의 range 가 null 이면 null 입니다."},"new_version":{"type":["integer","null"],"minimum":1,"description":"서버에 쓴 뒤의 version 입니다. 생성은 1, 수정·삭제는 server_version + 1 입니다."},"choices":{"type":"array","items":{"type":"string","enum":["이 기기 내용 남기기","다른 기기 내용 남기기","둘 다 남기기","합친 메모 남기기","다시 살려 이 기기 내용 남기기","지운 채 두기","하나로 합치기","둘 다 두기","이 기기 필기 버리기"]},"description":"독자에게 띄울 선택지입니다. 그대로 합침이면 빈 배열입니다."},"mine":{"type":["object","null"],"properties":{"kind":{"type":"string","enum":["형광펜","메모","북마크"]},"range_start":{"type":["integer","null"],"minimum":0,"description":"그 쪽 텍스트 레이어에서 필기가 시작하는 글자 위치입니다. 북마크는 null 입니다."},"range_end":{"type":["integer","null"],"minimum":1,"description":"필기가 끝나는 글자 위치이며 그 글자는 포함하지 않습니다(반열린 구간). 북마크는 null 입니다."},"color":{"type":["string","null"],"enum":["노랑","초록","파랑","분홍",null]},"note_text":{"type":["string","null"],"description":"메모 본문입니다. 형광펜·북마크는 null 입니다."}},"description":"이 기기가 올린 내용입니다."},"theirs":{"type":["object","null"],"properties":{"kind":{"type":"string","enum":["형광펜","메모","북마크"]},"range_start":{"type":["integer","null"],"minimum":0,"description":"그 쪽 텍스트 레이어에서 필기가 시작하는 글자 위치입니다. 북마크는 null 입니다."},"range_end":{"type":["integer","null"],"minimum":1,"description":"필기가 끝나는 글자 위치이며 그 글자는 포함하지 않습니다(반열린 구간). 북마크는 null 입니다."},"color":{"type":["string","null"],"enum":["노랑","초록","파랑","분홍",null]},"note_text":{"type":["string","null"],"description":"메모 본문입니다. 형광펜·북마크는 null 입니다."}},"description":"걸린 변경분과 견준 서버 필기의 내용입니다. 그대로 합침이면 null 입니다."},"merged_memo":{"type":["string","null"],"description":"3주차에 Claude Sonnet 4.5 가 mine·theirs 두 메모의 낱말을 모두 담아 쓴 한 줄입니다. 두 기기에서 같은 필기를 따로 고침이고 양쪽 note_text 가 모두 있을 때만 씁니다."},"evidence":{"type":"array","items":{"type":"object","properties":{"dataset":{"type":"string","enum":["annotation_edits","server_annotations"]},"field":{"type":"string"},"agg":{"type":"string","enum":["count","sum","avg","min","max"]},"where":{"type":"string"},"value":{"description":"agg 가 없으면 수록된 행의 그 컬럼 값 그대로입니다."}},"required":["dataset","field","value"]}}},"required":["batch_id","edit_key","copies_in_batch","device_id","annotation_id","book_id","page","op","verdict","applied","base_version","server_version","versions_behind","server_updated_by","server_deleted_at","overlap_with","overlap_chars","new_version","choices","mine","theirs","merged_memo","evidence"]}[
{
"batch_id": "sync-dev-p7f3a-20261012T0840",
"edit_key": "dev-p7f3a:0401",
"copies_in_batch": 1,
"device_id": "dev-p7f3a",
"annotation_id": "ann_5021",
"book_id": "book_1207",
"page": 142,
"op": "수정",
"verdict": "두 기기에서 같은 필기를 따로 고침",
"applied": false,
"base_version": 3,
"server_version": 4,
"versions_behind": 1,
"server_updated_by": "dev-t2c91",
"server_deleted_at": null,
"overlap_with": null,
"overlap_chars": null,
"new_version": null,
"choices": [
"이 기기 내용 남기기",
"다른 기기 내용 남기기",
"둘 다 남기기",
"합친 메모 남기기"
],
"mine": {
"kind": "메모",
"color": "노랑",
"note_text": "경사 하강법: 학습률 0.01 부터",
"range_start": 1180,
"range_end": 1264
},
"theirs": {
"kind": "메모",
"color": "노랑",
"note_text": "경사 하강법: 학습률 0.1 이 더 빠름",
"range_start": 1180,
"range_end": 1264
},
"merged_memo": "경사 하강법: 학습률 0.01 부터 시작, 0.1 이 더 빠름",
"evidence": [
{
"dataset": "annotation_edits",
"field": "op",
"value": "수정"
},
{
"dataset": "annotation_edits",
"field": "base_version",
"value": 3
},
{
"dataset": "server_annotations",
"field": "version",
"value": 4
},
{
"dataset": "server_annotations",
"field": "updated_by_device",
"value": "dev-t2c91"
},
{
"dataset": "annotation_edits",
"field": "device_id",
"value": "dev-p7f3a"
},
{
"dataset": "annotation_edits",
"field": "note_text",
"value": "경사 하강법: 학습률 0.01 부터"
},
{
"dataset": "server_annotations",
"field": "note_text",
"value": "경사 하강법: 학습률 0.1 이 더 빠름"
}
]
},
{
"batch_id": "sync-dev-p7f3a-20261012T0840",
"edit_key": "dev-p7f3a:0404",
"copies_in_batch": 1,
"device_id": "dev-p7f3a",
"annotation_id": "ann_4877",
"book_id": "book_1207",
"page": 88,
"op": "수정",
"verdict": "다른 기기에서 지운 필기를 다시 올림",
"applied": false,
"base_version": 2,
"server_version": 3,
"versions_behind": 1,
"server_updated_by": "dev-t2c91",
"server_deleted_at": "2026-10-10T21:14:00+09:00",
"overlap_with": null,
"overlap_chars": null,
"new_version": null,
"choices": [
"다시 살려 이 기기 내용 남기기",
"지운 채 두기"
],
"mine": {
"kind": "메모",
"color": "노랑",
"note_text": "과대적합 예시: 검증 손실이 다시 오름",
"range_start": 402,
"range_end": 455
},
"theirs": {
"kind": "메모",
"color": "노랑",
"note_text": "과대적합 예시: 훈련 정확도만 높음",
"range_start": 402,
"range_end": 455
},
"merged_memo": null,
"evidence": [
{
"dataset": "annotation_edits",
"field": "op",
"value": "수정"
},
{
"dataset": "annotation_edits",
"field": "base_version",
"value": 2
},
{
"dataset": "server_annotations",
"field": "version",
"value": 3
},
{
"dataset": "server_annotations",
"field": "deleted_at",
"value": "2026-10-10T21:14:00+09:00"
},
{
"dataset": "server_annotations",
"field": "updated_by_device",
"value": "dev-t2c91"
},
{
"dataset": "annotation_edits",
"field": "note_text",
"value": "과대적합 예시: 검증 손실이 다시 오름"
}
]
},
{
"batch_id": "sync-dev-p7f3a-20261012T0840",
"edit_key": "dev-p7f3a:0405",
"copies_in_batch": 1,
"device_id": "dev-p7f3a",
"annotation_id": "ann_p_0093",
"book_id": "book_1207",
"page": 201,
"op": "생성",
"verdict": "같은 자리에 필기가 두 번 생김",
"applied": false,
"base_version": null,
"server_version": null,
"versions_behind": null,
"server_updated_by": null,
"server_deleted_at": null,
"overlap_with": "ann_5110",
"overlap_chars": 88,
"new_version": null,
"choices": [
"하나로 합치기",
"둘 다 두기",
"이 기기 필기 버리기"
],
"mine": {
"kind": "형광펜",
"color": "노랑",
"note_text": null,
"range_start": 2300,
"range_end": 2460
},
"theirs": {
"kind": "형광펜",
"color": "초록",
"note_text": null,
"range_start": 2210,
"range_end": 2388
},
"merged_memo": null,
"evidence": [
{
"dataset": "annotation_edits",
"field": "op",
"value": "생성"
},
{
"dataset": "annotation_edits",
"field": "range_start",
"value": 2300
},
{
"dataset": "annotation_edits",
"field": "range_end",
"value": 2460
},
{
"dataset": "server_annotations",
"field": "range_start",
"value": 2210
},
{
"dataset": "server_annotations",
"field": "range_end",
"value": 2388
},
{
"dataset": "server_annotations",
"field": "updated_by_device",
"value": "dev-t2c91"
},
{
"dataset": "server_annotations",
"field": "page",
"value": 201
}
]
},
{
"batch_id": "sync-dev-p7f3a-20261012T0840",
"edit_key": "dev-p7f3a:0398",
"copies_in_batch": 2,
"device_id": "dev-p7f3a",
"annotation_id": "ann_4801",
"book_id": "book_1207",
"page": 75,
"op": "수정",
"verdict": "그대로 합침",
"applied": false,
"base_version": 4,
"server_version": 5,
"versions_behind": 1,
"server_updated_by": "dev-p7f3a",
"server_deleted_at": null,
"overlap_with": null,
"overlap_chars": null,
"new_version": null,
"choices": [],
"mine": {
"kind": "메모",
"color": "분홍",
"note_text": "배치 크기 32 부터",
"range_start": 300,
"range_end": 340
},
"theirs": null,
"merged_memo": null,
"evidence": [
{
"dataset": "annotation_edits",
"field": "edit_key",
"value": "dev-p7f3a:0398"
},
{
"dataset": "annotation_edits",
"field": "edit_key",
"agg": "count",
"where": "edit_key 가 dev-p7f3a:0398 인 행",
"value": 2
},
{
"dataset": "server_annotations",
"field": "last_edit_key",
"value": "dev-p7f3a:0398"
},
{
"dataset": "annotation_edits",
"field": "base_version",
"value": 4
},
{
"dataset": "server_annotations",
"field": "version",
"value": 5
}
]
},
{
"batch_id": "sync-dev-p7f3a-20261012T0840",
"edit_key": "dev-p7f3a:0403",
"copies_in_batch": 1,
"device_id": "dev-p7f3a",
"annotation_id": "ann_p_0091",
"book_id": "book_1207",
"page": 57,
"op": "생성",
"verdict": "그대로 합침",
"applied": true,
"base_version": null,
"server_version": null,
"versions_behind": null,
"server_updated_by": null,
"server_deleted_at": null,
"overlap_with": null,
"overlap_chars": null,
"new_version": 1,
"choices": [],
"mine": {
"kind": "북마크",
"color": null,
"note_text": null,
"range_start": null,
"range_end": null
},
"theirs": null,
"merged_memo": null,
"evidence": [
{
"dataset": "annotation_edits",
"field": "op",
"value": "생성"
},
{
"dataset": "annotation_edits",
"field": "kind",
"value": "북마크"
},
{
"dataset": "annotation_edits",
"field": "range_start",
"value": null
},
{
"dataset": "server_annotations",
"field": "annotation_id",
"value": "ann_5133"
},
{
"dataset": "server_annotations",
"field": "range_end",
"value": null
}
]
},
{
"batch_id": "sync-dev-p7f3a-20261012T0840",
"edit_key": "dev-p7f3a:0406",
"copies_in_batch": 1,
"device_id": "dev-p7f3a",
"annotation_id": "ann_p_0094",
"book_id": "book_1207",
"page": 210,
"op": "생성",
"verdict": "그대로 합침",
"applied": true,
"base_version": null,
"server_version": null,
"versions_behind": null,
"server_updated_by": null,
"server_deleted_at": null,
"overlap_with": null,
"overlap_chars": 0,
"new_version": 1,
"choices": [],
"mine": {
"kind": "형광펜",
"color": "노랑",
"note_text": null,
"range_start": 180,
"range_end": 240
},
"theirs": null,
"merged_memo": null,
"evidence": [
{
"dataset": "annotation_edits",
"field": "op",
"value": "생성"
},
{
"dataset": "annotation_edits",
"field": "range_start",
"value": 180
},
{
"dataset": "annotation_edits",
"field": "range_end",
"value": 240
},
{
"dataset": "server_annotations",
"field": "annotation_id",
"value": "ann_5140"
},
{
"dataset": "server_annotations",
"field": "range_start",
"value": 100
},
{
"dataset": "server_annotations",
"field": "range_end",
"value": 180
}
]
},
{
"batch_id": "sync-dev-p7f3a-20261012T0840",
"edit_key": "dev-p7f3a:0402",
"copies_in_batch": 1,
"device_id": "dev-p7f3a",
"annotation_id": "ann_4950",
"book_id": "book_1207",
"page": 63,
"op": "수정",
"verdict": "그대로 합침",
"applied": true,
"base_version": 2,
"server_version": 2,
"versions_behind": 0,
"server_updated_by": "dev-p7f3a",
"server_deleted_at": null,
"overlap_with": null,
"overlap_chars": null,
"new_version": 3,
"choices": [],
"mine": {
"kind": "형광펜",
"color": "파랑",
"note_text": null,
"range_start": 510,
"range_end": 590
},
"theirs": null,
"merged_memo": null,
"evidence": [
{
"dataset": "annotation_edits",
"field": "op",
"value": "수정"
},
{
"dataset": "annotation_edits",
"field": "base_version",
"value": 2
},
{
"dataset": "server_annotations",
"field": "version",
"value": 2
},
{
"dataset": "server_annotations",
"field": "updated_by_device",
"value": "dev-p7f3a"
},
{
"dataset": "annotation_edits",
"field": "color",
"value": "파랑"
}
]
},
{
"batch_id": "sync-dev-p7f3a-20261012T0840",
"edit_key": "dev-p7f3a:0407",
"copies_in_batch": 1,
"device_id": "dev-p7f3a",
"annotation_id": "ann_4712",
"book_id": "book_0931",
"page": 12,
"op": "수정",
"verdict": "그대로 합침",
"applied": true,
"base_version": 2,
"server_version": 2,
"versions_behind": 0,
"server_updated_by": "dev-t2c91",
"server_deleted_at": null,
"overlap_with": null,
"overlap_chars": null,
"new_version": 3,
"choices": [],
"mine": {
"kind": "형광펜",
"color": "파랑",
"note_text": null,
"range_start": 40,
"range_end": 120
},
"theirs": null,
"merged_memo": null,
"evidence": [
{
"dataset": "annotation_edits",
"field": "op",
"value": "수정"
},
{
"dataset": "annotation_edits",
"field": "base_version",
"value": 2
},
{
"dataset": "server_annotations",
"field": "version",
"value": 2
},
{
"dataset": "server_annotations",
"field": "updated_by_device",
"value": "dev-t2c91"
},
{
"dataset": "annotation_edits",
"field": "color",
"value": "파랑"
}
]
}
][자동 검증 · 코드가 판정합니다] 1. verdict 가 그대로 합침·두 기기에서 같은 필기를 따로 고침·다른 기기에서 지운 필기를 다시 올림·같은 자리에 필기가 두 번 생김 네 값 중 하나인지, op 와 choices 가 각자 정한 목록에 있는지, evidence 의 agg 가 count·sum·avg·min·max 중 하나인지 확인합니다. 2. batch_id·edit_key·copies_in_batch·device_id·annotation_id·op·verdict·applied·base_version·server_version·versions_behind·overlap_chars·new_version·choices·mine·theirs·evidence 필수 필드가 모두 존재하는지 확인합니다. 3. 처리 순서를 다시 계산합니다. annotation_edits 를 edited_at 순서로 하나씩 보고, 앞에서 이미 본 edit_key 는 판정하지 않고 그 출력의 copies_in_batch 만 올립니다. 그대로 합침이고 applied 가 참이면 서버 필기에 바로 써서 version 을 올린 뒤 다음 변경분을 봅니다. 걸린 변경분은 쓰지 않습니다. 4. versions_behind 는 server_version - base_version 차이와 일치해야 하고, base_version 이 null 인 생성이면 null 입니다. 5. op 가 수정이고 server_deleted_at 이 null 이 아니며 versions_behind 가 1 이상이면 verdict 는 다른 기기에서 지운 필기를 다시 올림입니다. 지운 필기라도 versions_behind 가 0 이면 이 판정은 나오지 않습니다. 6. op 가 수정이고 versions_behind 가 1 이상이며 server_updated_by 가 device_id 와 다르고 server_deleted_at 이 null 이면 verdict 는 두 기기에서 같은 필기를 따로 고침입니다. server_updated_by 가 device_id 와 같거나 versions_behind 가 0 이면 이 판정은 나오지 않습니다. 7. overlap_chars 를 같은 book_id·page·kind 의 서버 필기와 min(range_end) - max(range_start) 로 다시 계산합니다. op 가 생성이고 overlap_chars 가 1 이상이면 verdict 는 같은 자리에 필기가 두 번 생김이고, 0 이면(맞닿기만 한 범위) 이 판정은 나오지 않습니다. 8. 서버 필기의 last_edit_key 가 이 edit_key 와 같으면 이미 반영된 변경분이라 verdict 는 그대로 합침, applied 는 거짓, new_version 은 null 입니다. 9. new_version 을 다시 계산합니다. applied 가 참이면 생성은 1, 수정·삭제는 server_version + 1 이고, applied 가 거짓이면 null 입니다. new_version 은 1 이상 범위입니다. 10. verdict 가 그대로 합침이면 choices 는 빈 배열이고 theirs 는 null 이며, 걸린 세 판정이면 choices 가 그 판정의 고정 선택지 목록에 있는지 확인합니다. merged_memo 가 null 이 아니면 choices 에 합친 메모 남기기가 들어 있어야 합니다. 11. merged_memo 가 null 이 아니면 mine.note_text 와 theirs.note_text 를 공백으로 자른 낱말이 하나도 빠짐없이 그 안에 그대로 들어 있는지, 길이가 두 메모를 더한 길이의 1.5배 이하인지 확인합니다. 12. evidence 의 field 이름이 두 데이터셋에 실재하는 컬럼인지 대조합니다. 13. agg 가 없는 evidence value 가 수록된 행의 원문 값과 같은지 확인합니다. [수동 검증 · 사람이 봅니다] 1. 폰과 태블릿 두 대로 오프라인 시나리오를 돌려, 걸린 카드의 왼쪽(이 기기)과 오른쪽(다른 기기)이 실제로 무엇을 고르는지 헷갈리지 않는지 사람이 봅니다. 2. merged_memo 가 두 메모 사이에 없던 관계를 지어내지 않았는지(학습률 0.01 과 0.1 중 어느 쪽이 맞다고 단정하지 않았는지) 사람이 읽고 표시합니다. [판정 순서 · 한 변경분에 여러 조건이 맞을 때] 같은 묶음에서 이미 본 edit_key → 서버 last_edit_key 가 같은 이미 반영된 변경분 → 다른 기기에서 지운 필기를 다시 올림 → 두 기기에서 같은 필기를 따로 고침 → 같은 자리에 필기가 두 번 생김(생성에만) → 그대로 합침. 지운 필기를 고친 수정은 버전만 보면 따로 고친 수정과 모양이 같지만, 독자 앞에 놓일 선택이 살리기·지운 채 두기로 달라 먼저 가립니다. 같은 자리 판정은 생성에만 걸고, 기존 필기의 범위를 넓히는 수정은 버전 비교로만 봅니다. [걸리거나 걸리지 않는 까닭 · 하드 케이스] dev-p7f3a:0398 은 오프라인 직전에 응답을 못 받아 큐에 두 번 들어간 수정입니다. 서버 ann_4801 의 last_edit_key 가 이미 이 값이라 다시 쓰지 않고, 둘째 행은 copies_in_batch 2 로만 남습니다. edit_key 를 보지 않으면 base_version 4 가 서버 version 5 보다 낮아 보이지만, 그 version 을 쓴 기기가 폰 자신이라 따로 고침으로도 걸리지 않습니다. dev-p7f3a:0401 은 폰이 v3 을 보고 고친 메모인데 태블릿이 10월 10일 22:15 에 먼저 v4 로 고쳐 따로 고침입니다. dev-p7f3a:0404 는 태블릿이 10월 10일 21:14 에 지운 ann_4877 을 폰이 v2 를 보고 고친 것입니다. dev-p7f3a:0405 의 새 형광펜 2300~2460 은 태블릿 ann_5110 의 2210~2388 과 88자 겹칩니다. dev-p7f3a:0406 의 180~240 은 태블릿 ann_5140 의 100~180 과 180번째 글자에서 맞닿기만 해 겹침이 0자이고, 범위 끝을 포함으로 읽으면 1자가 겹쳐 같은 자리 판정으로 뒤집힙니다. dev-p7f3a:0407 은 태블릿이 마지막으로 쓴 ann_4712 를 폰이 그 버전(v2)을 보고 고쳐 그대로 합칩니다. 기기만 견주고 버전을 보지 않으면 따로 고침으로 잘못 걸립니다. dev-p7f3a:0403 과 0412 북마크는 range 가 null 이라 겹침을 따지지 않고, 같은 쪽 태블릿 북마크 ann_5133 이 있어도 그대로 합칩니다. dev-p7f3a:0410 의 새 메모는 폰 자신이 10월 5일에 지운 ann_4655 와 범위가 겹치지만, 지운 필기는 견주지 않아 그대로 합칩니다. dev-p7f3a:0411 은 range_start 가 0 인 쪽 맨 앞 형광펜입니다. [계약 판정식 밖에서 정한 규칙] 세 판정식은 카드 그대로 씁니다. server_annotations 에 필기를 만든 기기 열이 없어, 같은 자리 판정의 '다른 device_id 가 만든 필기' 는 지금 version 을 쓴 기기(updated_by_device)로 봅니다. 글자 범위는 반열린 구간이라 끝 글자를 포함하지 않습니다. 지운 서버 필기(deleted_at 있음)는 겹침을 견주지 않습니다. 같은 자리 판정은 kind 가 같은 필기끼리만 견줍니다 — 태블릿이 형광펜을 그은 구절에 폰이 메모를 남기는 것은 같은 필기가 두 번 생긴 것이 아니라 다른 필기라, 합치기 선택지를 띄우지 않습니다. 삭제 변경분은 base_version 이 낮아도 그대로 합치는데, 서버가 deleted_at 만 찍는 소프트 삭제라 내용이 필기 기록에 남기 때문입니다. 수정 변경분의 base_version 이 서버 version 과 같으면 마지막으로 쓴 기기가 누구든 그대로 합칩니다. 처음 보는 annotation_id 의 생성만 생성으로 다루고, 앱은 오프라인 동안 같은 필기를 여러 번 고쳐도 마지막 상태 하나만 올립니다. [출처 · 공고에 없는 낱말] 공고 원문이 밝히는 것은 디지털 학습 플랫폼 한빛+, 한빛+ 앱/웹 기능 개발, React Native·zustand·Kotlin·Spring boot·PostgreSQL·JPA 스택까지입니다. 한빛+ 앱이 전자책을 오프라인으로 열고 필기·메모·북마크를 기기 사이에 맞춘다는 것은 앱 소개에서 가져온 각도이고, 변경분·버전·글자 범위의 모양은 이 편이 가정한 데이터입니다. [평가 러너 · 2주차] 폰과 태블릿의 오프라인 변경분을 무작위 순서로 끼워 넣어 올리는 재생 테스트 200회를 매번 돌립니다. 회마다 올린 변경분이 서버에 쓰였거나 걸려 독자 선택을 기다리는 둘 중 하나인지, 그 어느 쪽에도 없이 사라진 변경분이 몇 개인지 셉니다. 맞닿은 범위와 1자 겹친 범위, base_version 이 서버와 같거나 하나 낮은 수정, 같은 edit_key 두 행, 지운 필기 위 수정을 짝으로 둔 경계 사례도 함께 돕니다.