Opkle(옵클) - 창작자를 위한 앱과 시스템
옵클(Opkle)은 창작자를 위한 다양한 앱과 시스템을 제공하는 개발사입니다. 전자책 에디터 앱 'Opkle editor'를 출시했고, 관련 전자책 클래스를 제공하고 있습니다.
EDITOR
CLASS
BLOG
LOGIN
표현한다는 것의
무한한 가능성,
새로운 형태로
담아내다.
새로운 형태의 콘텐츠
Opkle은 코드 없이 웹을 마음껏 만들고, 누구나 자기 화면을 그릴 수 있도록 에디터를 만들고, 그 결과물을 어디서나 즐길 수 있게 돕는 팀입니다.
텍스트와 화면과의 조화를 통해, 웹을 짓는다는 것이 그저 단순한 코딩이 아닌, 상상을 펼치고 감각을 깨우는 과정이 될 수 있도록 좋은 도구를 만들어 냅니다.
옵클 에디터 개발기: 표 이미지를 만들기 위한 스프레드시트
dev
17
옵클 에디터 개발기: 같은 데이터를 표와 보드, 캘린더로
dev
18
옵클 에디터 개발기: 책을 웹에서 그대로 읽게 하는 퍼블리셔
dev
19
옵클 에디터 개발기: 편집 화면을 그대로 웹에 게시하기
dev
20
텍스트힙이란 무엇인가
texthip
1
9
10
11
12
13
...
19
옵클 에디터 개발기: 책을 웹에서 그대로 읽게 하는 퍼블리셔
테이블 에디터까지 만든 뒤에는 완성된 콘텐츠가 독자에게 도착하는 마지막 경로를 다시 보게 되었습니다. EPUB 파일을 내려받게 하거나 유통사에 등록하는 방식만으로는 부족했습니다. 편집기에서 만든 책을 별도의 웹사이트 개발 없이 바로 브라우저에 게시하고, 모바일과 데스크탑에서 책의 구조와 동작을 그대로 보여줄 수 있어야 했습니다.
나는 EPUB을 HTML 몇 장으로 변환해 올리는 기능을 만들 생각은 없었습니다. 그 방식은 본문은 남겨도 편집기에서 설정한 페이지 넘김, 스프레드, 다크 모드, 챕터 탐색, 여백과 레이아웃을 잃습니다. 벡터와 레이아웃 에디터에서 만든 고정판형 문서는 더 직접적입니다. SVG와 CSS animation, 페이지 비율이 보존되지 않으면 웹 게시본은 원본과 다른 문서가 됩니다.
그래서 퍼블리셔를 하나의 독립된 문서 에디터로 만들었습니다. 원본 패키지를 받아 읽기 순서와 공개 범위를 편집하고, 실제 공개 페이지에서 사용할 레이아웃을 미리 조정하며, 그 상태 전체를 게시 가능한 스냅샷으로 확정하는 에디터입니다. 편집 화면의 왼쪽은 이미 게시된 웹 문서와 같은 렌더 엔진이고, 오른쪽은 그 렌더 엔진에 들어갈 상태를 만드는 제어 계층이 되었습니다.
Publication document
퍼블리셔는 EPUB 에디터 안에서 열리는 종속 에디터로 구성했습니다. 부모는 현재 프로젝트 ID와 게시 문서 ID를 localStorage의 일회성 토큰에 넣고 iframe을 열며, 자식은 토큰을 읽은 즉시 지워 새로고침이나 재진입으로 빈 문서가 중복 생성되지 않게 했습니다. 인증은 로컬 세션을 먼저 확인하고, 부족하면 부모 iframe에 세션을 요청한 뒤 마지막에만 로그인 흐름으로 넘어갑니다.
새 게시 문서를 만들 때 부모는 현재 책을 이미지와 폰트까지 포함한 자기완결 패키지로 묶어 둡니다. 퍼블리셔가 프로젝트 저장소와 렌더 영역을 모두 준비한 뒤 준비 완료 메시지를 보내면 그때 Blob을 전달합니다. iframe이 먼저 뜨기도 전에 큰 바이너리를 보내거나, 이전에 예약한 Blob이 다음 문서에 잘못 주입되는 일을 막기 위해 주입 예약은 에디터를 여는 함수 한 곳에서 매번 새로 확정했습니다.
벡터와 레이아웃 에디터도 같은 경로에 연결했습니다. 두 에디터는 현재 아트보드나 페이지들을 웹 게시용 패키지로 굽고, EPUB 에디터를 중계자로 사용해 퍼블리셔를 엽니다. 게시 편집기를 닫으면 원래 작업하던 벡터 또는 레이아웃 프로젝트로 돌아가게 했습니다. 사용자는 서로 다른 프로그램을 오가는 것이 아니라 하나의 문서 그래프 안에서 원본, 게시 설정, 공개 결과를 왕복합니다.
부모와 자식의 세션, 프로젝트 ID, 주입할 Blob, 돌아갈 에디터를 각각 별도의 메시지 계약으로 나누었습니다. 이 경계를 잡아 두니 EPUB뿐 아니라 벡터 한 장과 수백 페이지 레이아웃도 같은 퍼블리셔를 사용할 수 있었습니다.
세 층의 문서 상태
게시 프로젝트의 저장 상태는 세 층으로 나누었습니다. 첫 번째 층은 프로젝트 이름과 이 문서가 소유한 폰트, 이미지, 원본 패키지 Blob의 ID를 가진 자산 map입니다. 두 번째 층은 읽기 spine, 테마, 페이지 방식, 여백, 챕터 박스, 최근 게시 URL과 원본 에디터 연결 정보를 가진 문서 snapshot입니다. 세 번째 층은 챕터 순서와 공개 여부, 사용자가 바꾼 제목만 보관하는 chapter overlay입니다.
이들을 한 JSON에 전부 넣지 않은 이유는 생명주기가 다르기 때문입니다. 자산 map은 어떤 Blob이 이 프로젝트에 속하는지 결정하고 export와 garbage collection의 기준이 됩니다. 문서 snapshot은 공개 페이지의 전체 렌더 설정이며, chapter overlay는 원본 EPUB의 spine이 갱신되어도 안정적인 key로 다시 병합되어야 합니다. 같은 프로젝트 ID를 공유하되 서로 다른 행으로 두면 독립적으로 조회하고 복구할 수 있습니다.
저장할 때는 이름, 자산 ID 배열, 문서 snapshot, chapter overlay를 합쳐 dirty key를 만들었습니다. 직전 저장과 완전히 같으면 데이터베이스 write를 생략하고, 저장 중에 두 번째 요청이 들어오면 병렬 write를 시작하지 않고 후속 저장 플래그만 남겼습니다. 게시 직전에는 lock이 풀릴 때까지 기다린 뒤 저장 stamp가 실제로 전진했는지 확인했습니다.
오래된 문서에는 새 설정 필드가 없을 수 있으므로 로드 시 모든 값을 정규화했습니다. enum은 허용한 값만 받고, 색상은 6자리 hex를 검사하며, 여백과 크기는 각 UI가 감당할 수 있는 범위로 clamp했습니다. 모바일·데스크탑 챕터 박스가 분리되기 전의 문서는 기존 flat 값을 데스크탑 설정으로 옮기고 모바일은 하단 배치를 기본으로 만들었습니다. 데이터 migration이 별도 일회성 스크립트가 아니라 로드 경로 자체에 들어가게 했습니다.
Flow와 graphic
퍼블리셔가 받는 패키지는 두 종류입니다. 흐름형 문서는 일반 EPUB처럼 XHTML과 CSS가 페이지 폭에 맞춰 reflow됩니다. 그래픽형 문서는 벡터 또는 레이아웃 에디터가 만든 것으로, 챕터 하나가 자기완결 SVG 한 장이고 애니메이션도 SVG 안의 CSS로 실행됩니다. 같은 확장자를 사용하지만 렌더 방식은 완전히 다릅니다.
패키지를 주입할 때 앞부분의 magic byte를 읽어 표준 ZIP인지 자체 압축 포맷인지 먼저 판별했습니다. `PK`로 시작하면 일반 unzip을 사용하고, 그렇지 않으면 WASM 압축 해제기를 통과시킵니다. 압축을 푼 뒤 루트 manifest에 그래픽 문서 선언이 있으면 출처 에디터, 원본 프로젝트 ID, 아트보드 크기와 챕터 제목을 읽습니다. manifest가 없거나 파싱에 실패하면 기존 EPUB과의 하위호환을 위해 흐름형으로 처리합니다.
이 판별은 렌더할 때마다 하지 않았습니다. Blob을 처음 흡수하거나 원본을 교체하는 시점에 한 번만 수행하고 결과를 문서 snapshot에 저장했습니다. 공개 뷰어도 같은 값을 metadata로 받기 때문에 패키지를 다시 풀어 종류를 추측하지 않습니다. 수백 페이지 고정판형 문서를 설정 하나 바꿀 때마다 압축 해제하는 비용이 없어졌습니다.
렌더용 Blob도 ID뿐 아니라 객체 참조를 캐시했습니다. 브라우저 데이터베이스에서 같은 Blob을 다시 읽어도 매번 다른 객체가 만들어지는데, 리더의 패키지 cache는 객체 동일성으로 hit를 판단합니다. 원본 ID가 같으면 같은 Blob 객체를 반환하고, 패키지를 교체하는 경로에서만 cache를 무효화했습니다. 이 작은 경계가 미리보기 재렌더를 압축 해제 작업이 아니라 레이아웃 재계산으로 만들었습니다.
Preview geometry
미리보기는 단순한 고정 크기 카드가 아닙니다. 실제 렌더 영역의 `getBoundingClientRect()`를 기준으로 모바일, 단일 페이지, 스프레드를 매번 판정합니다. 전체 폭에서 외곽 여백과 챕터 박스가 차지하는 공간을 빼고, 두 페이지를 놓았을 때 한 페이지 폭이 최소 500px 이상이면 스프레드를 사용합니다. 사용자가 페이지 모드를 고르면 폭이 충분해도 한 장으로 고정합니다.
단일 페이지는 챕터 박스를 제외한 슬롯의 중앙에 놓았습니다. 박스가 오른쪽이면 컨테이너 왼쪽부터 박스 왼쪽 경계까지를 하나의 영역으로 보고, 그 안에서 페이지 좌우 간격이 같아지도록 위치를 계산합니다. 박스를 왼쪽으로 옮기면 같은 식의 기준점만 반대로 바뀝니다. 상단이나 하단 박스는 가로 공간을 먹지 않으므로 전체 폭을 슬롯으로 사용합니다.
모바일 판정 폭은 760px로 두고 페이지와 챕터 영역을 full bleed로 나눴습니다. 챕터 박스가 좌우에 있으면 폭을 reserve한 세로 panel이 되고, 위아래에 있으면 높이를 reserve한 가로 bar가 됩니다. 디자인 패널에서 강제로 모바일 뷰를 선택했을 때도 같은 계산을 사용합니다. 모바일에서 숫자 style이 공용 DOM 생성기의 `vw` 단위로 바뀌지 않도록, width와 padding 같은 차원 속성은 명시적인 px 문자열로 정규화했습니다.
흐름형 문서는 넘기기와 세로 스크롤을 모두 지원했습니다. 스크롤 mode에서는 위아래 콘텐츠 padding을 고정 밴드로 남기지 않고 콘텐츠 margin으로 바꾸어 처음과 끝의 여백이 스크롤과 함께 움직이게 했습니다. 그래픽 문서는 스크롤할 본문이 없고 모바일에서 wheel 기반 챕터 이동도 사용할 수 없으므로 항상 paged로 고정했습니다.
Artboard fitting
고정판형 문서는 SVG의 비율과 페이지 박스의 비율이 다르면 흰 letterbox가 생깁니다. 여기서 맞춰야 하는 대상은 바깥 박스가 아니라 콘텐츠 padding을 뺀 안쪽 영역입니다. 아트보드 비율을 `r`, 좌우 padding 합을 `Px`, 위아래 padding 합을 `Py`라고 하면 단일 페이지의 안쪽 폭과 높이가 `innerW / innerH = r`을 만족하도록 contain fit을 계산하고, 마지막에 padding을 다시 더해 실제 박스 크기를 만들었습니다.
스프레드는 한 단계 더 나갑니다. 한 페이지 높이를 `h`, 페이지 간격을 `g`라고 할 때 페이지 폭은 `Px + r × (h - Py)`이고, 전체 펼침 폭은 그 값을 두 배 한 뒤 `g`를 더한 값입니다. 가용 폭과 가용 높이에서 만들 수 있는 두 후보를 비교해 더 작은 쪽을 선택했습니다. 이후 줄어든 폭과 높이의 절반만큼 mount 위치를 이동해 페이지 묶음이 원래 슬롯 중앙에 남게 했습니다.
레이아웃 문서는 여러 페이지가 있으므로 세로 판형일 때 실제 두 페이지 펼침을 만들 수 있습니다. 왼쪽에 현재 챕터, 오른쪽에 다음 챕터를 놓습니다. 반면 벡터 문서는 일반적으로 한 아트보드뿐이라 스프레드가 의미 없고, 가로 또는 정방형 판형은 두 장을 놓으면 페이지가 지나치게 작아지므로 단일 페이지를 유지했습니다. 문서 종류, 출처, 페이지 수, 아트보드 비율이 스프레드 가능 여부를 함께 결정합니다.
표지와 후면 표지는 CSS의 `object-fit`만 믿지 않았습니다. Shadow DOM 안의 실제 콘텐츠 box 크기와 이미지의 natural width·height를 읽고 두 비율을 비교해 폭 맞춤과 높이 맞춤을 직접 선택했습니다. 반올림 오차로 가장자리가 잘리지 않게 최대 폭과 높이도 실제 box 값으로 고정했습니다.
Reading order
웹 게시본의 목차는 파일 이름을 정렬해서 만들 수 없습니다. EPUB의 spine에는 표지, 서지 정보, 작가 소개, 목차, 본문, 후면 표지와 탐색용 nav가 함께 들어갑니다. 나는 리더가 파싱한 spine metadata를 그대로 사용하고, `linear="no"`이거나 nav 역할인 항목만 독자용 챕터 박스에서 제외했습니다. 버튼이 보여주는 순서와 실제 load에 사용하는 spine index는 따로 유지했습니다.
본문 제목은 XHTML의 첫 heading에서 가져오고, 제목이 없으면 장 번호로 fallback했습니다. 그래픽 문서는 SVG 안에서 heading을 찾을 수 없으므로 패키지 manifest의 챕터 제목을 사용합니다. 사용자가 제목을 직접 바꾼 경우에는 chapter overlay의 override가 언제나 가장 높은 우선순위를 가집니다.
챕터의 영구 식별자는 배열 index가 아니라 spine의 `idref`입니다. 원본 패키지가 갱신되어 중간에 챕터가 추가되거나 빠져도 기존 overlay를 key로 병합하고, 새 항목은 원본 순서대로 뒤에 붙여 기본 공개 상태로 만듭니다. 사라진 key는 다음 overlay 확정 때 제거됩니다. 재배열과 숨김을 적용한 결과에서 visible인 실제 spine index만 뽑아 최종 reading order를 만들었습니다.
이 순서는 목차 표시뿐 아니라 첫 렌더와 다음·이전 페이지 이동에도 적용됩니다. 표지를 숨기거나 1장을 맨 앞으로 옮기면 공개 페이지의 최초 진입도 그 챕터에서 시작합니다. 설정 변경으로 재렌더할 때는 현재 보던 spine index가 여전히 공개 대상인지 확인해 그대로 복원하고, 방금 숨긴 페이지라면 새 reading order의 첫 항목으로 이동시켰습니다.
챕터 박스 편집
챕터 박스는 세로 목록과 가로 strip 두 형태를 같은 데이터에서 만들었습니다. 데스크탑의 좌우 배치는 세로 scroll panel이 되고, 상하 배치와 모바일은 가로 chip bar가 됩니다. 선택 색, 배경, 폭, 버튼 높이, radius와 font size를 바꿀 수 있고, 모바일과 데스크탑은 위치와 크기를 별도 variant로 저장합니다. 데스크탑 오른쪽 설정을 바꿔도 모바일 하단 bar가 같이 흔들리지 않습니다.
에디터 mode에서는 챕터 버튼을 drag해 순서를 바꿀 수 있습니다. 세로 목록은 pointer의 y좌표와 각 버튼 중점을 비교하고, 가로 목록은 x좌표를 사용해 삽입할 위치를 계산합니다. drop이 성공하면 DOM의 안정 key 순서를 overlay에 기록하고, 영역 밖에서 끝났다면 drag 전 순서로 복원합니다. 우클릭에서는 표시 제목을 바꾸거나 공개 여부를 끌 수 있습니다.
숨긴 챕터는 편집기에서 opacity와 취소선으로 남겨 다시 켤 수 있게 했지만, 공개 뷰어에서는 목록 자체에서 제거합니다. 다크 모드를 빠르게 전환할 때 이전 색이 남지 않도록 렌더 시작 시 theme snapshot을 한 번 만들고 페이지와 챕터 박스가 함께 읽게 했습니다. 렌더가 끝나면 현재 DOM을 snapshot 기준으로 다시 칠해 비동기 재렌더 사이의 stale style도 제거했습니다.
레이아웃 문서에는 수백 개의 챕터가 곧 수백 페이지입니다. 이를 목차처럼 전부 나열하지 않고 표지, 현재 페이지, 후면 표지 세 버튼으로 바꿨습니다. 가운데 버튼에는 현재 `Page N`을 표시하고 누르면 원하는 쪽수를 입력해 이동합니다. 스프레드의 마지막 펼침처럼 왼쪽 index와 실제 보이는 후면 표지가 다른 경우도 오른쪽 페이지까지 계산해 활성 상태를 맞췄습니다.
Render serialization
디자인 패널의 테마, 미디어 뷰, 스프레드, 전체·팝업, 스크롤·넘기기, 여백과 패딩은 대부분 미리보기 전체를 다시 그립니다. 빠르게 설정을 누르면 압축 해제, Shadow DOM 생성과 mount 교체가 겹칠 수 있습니다. 그래서 미리보기 렌더는 한 번에 하나만 실행하고, 실행 중 들어온 요청은 마지막 의도만 queue에 남겨 종료 직후 한 번 더 수행했습니다.
resize 재렌더도 별도의 lock과 pending force를 가집니다. 페이지 geometry만 달라지면 기존 챕터 selector를 유지하고 위치만 갱신하며, 테마나 챕터 박스 크기처럼 selector 자체가 달라지는 요청은 강제 재빌드 의도를 다음 실행까지 보존합니다. 오래된 렌더가 새 설정 위에 mount되는 경합을 없애면서 챕터 박스가 불필요하게 깜빡이지 않게 했습니다.
챕터를 전환할 때는 미리보기 mount의 opacity를 0으로 두되 layout은 계속 측정할 수 있게 했습니다. 새 Shadow DOM을 넣고 표지 비율을 맞춘 뒤 내부 이미지가 load 또는 error로 끝나기를 기다리고, 두 번의 `requestAnimationFrame`으로 layout settle까지 확인한 다음에만 화면을 다시 노출합니다. 이미지가 응답하지 않아도 전환이 영원히 막히지 않도록 전체 timeout을 두었습니다.
페이지 넘김 animation 직후에는 리더의 click cooldown이 남을 수 있어 잠깐 기다린 뒤 챕터를 load했습니다. 다음 챕터의 폰트는 현재 페이지가 보이는 동안 background에서 미리 사용 상태로 만들어 첫 프레임의 fallback font 교체도 줄였습니다. 재렌더와 챕터 전환, 이미지와 폰트 준비가 모두 하나의 노출 시점으로 모였습니다.
원본을 갱신하는 방식
원본 EPUB을 수정한 뒤 퍼블리셔에서 다시 받으면 새 Blob을 append하지 않았습니다. 프로젝트가 가진 첫 코어 Blob의 data만 제자리에서 교체해 ID를 유지했습니다. 그래야 spine과 자산 map의 참조, 디자인 설정, 챕터 overlay, 이미 발급된 공개 URL이 전부 끊기지 않습니다. 기존 행이 유실된 예외에서만 새 Blob을 만들고 첫 위치를 복구했습니다.
교체 뒤에는 패키지 종류와 아트보드 metadata를 다시 판별하고 렌더 cache를 비웠습니다. 새 패키지로 미리보기가 실제 settle할 때까지 기다린 다음 chapter overlay를 새 spine과 병합해 추가·삭제된 챕터를 반영하고 다시 저장했습니다. 그래픽 문서는 EPUB 부모의 최신 책을 받으면 원본이 뒤바뀌므로 이 갱신 경로를 막고, 반드시 벡터나 레이아웃 원본에서 다시 보내도록 했습니다.
그래픽 패키지에는 자신을 만든 원본 프로젝트 ID가 들어 있습니다. 퍼블리셔는 자기 게시 문서 ID가 확정된 뒤 원본 map에 역방향 링크를 기록합니다. 다음에 원본 에디터에서 게시를 누르면 새 퍼블리셔 문서를 만들지 않고 이 링크로 기존 문서를 열어 코어 Blob만 교체합니다. 편집 설정과 슬러그를 가진 게시 문서가 원본과 계속 연결된 채 갱신됩니다.
흐름형 EPUB, 단일 벡터, 다중 페이지 레이아웃, 세로·가로 판형, 스프레드와 단일, 스크롤과 넘기기, 라이트·다크, 모바일·데스크탑 챕터 설정, 숨김과 재배열, 연속 재렌더, 원본 교체와 프로젝트 재열기를 모두 반복 검수했습니다. 보던 페이지와 읽기 순서, 페이지 geometry와 저장 snapshot이 매번 일치하는 것을 확인했습니다. 책의 구조를 웹 읽기 경험으로 편집하는 퍼블리셔 엔진은 완전하게 구현되고 안정화되었습니다.
이전글
목록으로
다음글
저작권 고시
Copyright Notice
본 웹사이트의 모든 디자인 결과물 및 영상에 대한 저작권은 Abstract Cloud에 있으며, 저작권법 및 관련 법령에 의해 보호받습니다. 웹, 영상, 본문, 표지, 내지 디자인을 포함한 모든 콘텐츠는 저작권자의 자산으로, 사전 동의 없이 무단 복제, 배포, 2차 저작물 제작, 온라인 공유 등을 금지합니다. 이를 위반할 시, 저작권법에 따라 민형사상 책임을 질 수 있습니다. 정당한 구매와 저작권 보호는 창작자의 권리를 지키며, 더 나은 작품으로 보답할 힘이 됩니다.
저작권자: Abstract Cloud | 대표자: 배창규(uragen)
© Abstract Cloud. All Rights Reserved.
HOME
FAQ
이용 약관
개인정보 이용방침
help@opkle.app
010-2747-3403
상호 :
추상적 형상 디자인(Abstract cloud) |
대표자 :
배창규
사업자등록번호 :
249-74-00533
통신판매업 신고번호 :
2025-의정부송산-0634
주소 :
경기도 의정부시 부용로 49, 108동 402호
웹의 모든 콘텐츠, 디자인, 소스 코드에 대한
저작권은 Opkle에게 있습니다.