Opkle(옵클) - 창작자를 위한 앱과 시스템
옵클(Opkle)은 창작자를 위한 다양한 앱과 시스템을 제공하는 개발사입니다. 전자책 에디터 앱 'Opkle editor'를 출시했고, 관련 전자책 클래스를 제공하고 있습니다.
EDITOR
CLASS
BLOG
LOGIN
표현한다는 것의
무한한 가능성,
새로운 형태로
담아내다.
새로운 형태의 콘텐츠
Opkle은 코드 없이 웹을 마음껏 만들고, 누구나 자기 화면을 그릴 수 있도록 에디터를 만들고, 그 결과물을 어디서나 즐길 수 있게 돕는 팀입니다.
텍스트와 화면과의 조화를 통해, 웹을 짓는다는 것이 그저 단순한 코딩이 아닌, 상상을 펼치고 감각을 깨우는 과정이 될 수 있도록 좋은 도구를 만들어 냅니다.
옵클 에디터 개발기: 표 이미지를 만들기 위한 스프레드시트
dev
17
옵클 에디터 개발기: 같은 데이터를 표와 보드, 캘린더로
dev
18
옵클 에디터 개발기: 책을 웹에서 그대로 읽게 하는 퍼블리셔
dev
19
옵클 에디터 개발기: 편집 화면을 그대로 웹에 게시하기
dev
20
텍스트힙이란 무엇인가
texthip
1
9
10
11
12
13
...
20
옵클 에디터 개발기: 편집 화면을 그대로 웹에 게시하기
퍼블리셔의 미리보기와 디자인 상태를 완성한 다음에는 그 화면을 실제 공개 URL로 옮기는 게시 시스템을 만들었습니다. 여기서 목표는 파일을 서버에 올리는 것보다 더 분명했습니다. 에디터 왼쪽에서 확인한 작업영역이 브라우저의 공개 페이지에서도 같은 크기 계산과 같은 챕터 이동, 같은 테마와 반응형 동작으로 실행되어야 했습니다.
일반적인 웹 내보내기는 편집 데이터를 읽어 새로운 HTML template을 생성합니다. 그러나 template을 따로 만들면 에디터와 공개본은 그 순간부터 두 개의 renderer가 됩니다. 한쪽의 스프레드 계산이나 모바일 챕터 bar를 고칠 때 다른 쪽도 같은 수정을 반복해야 하고, 조금만 어긋나도 미리보기에서 승인한 결과와 실제 링크가 달라집니다.
나는 게시를 별도의 변환 작업으로 두지 않았습니다. 미리보기가 읽은 원본 Blob과 렌더 설정을 확정된 publication snapshot으로 보내고, 공개 페이지는 에디터와 같은 렌더 코어를 읽기 전용으로 부팅합니다. 서버는 콘텐츠를 다시 해석하거나 HTML을 재조판하지 않고, 안전한 저장과 공개 문서 identity, 검색·공유용 HTML shell만 담당하도록 경계를 나누었습니다.
Published snapshot
게시 버튼을 누르면 흐름형 문서는 먼저 부모 EPUB 에디터에 최신 패키지를 요청합니다. 사용자가 본문을 수정하고 퍼블리셔를 다시 열지 않았더라도 게시 순간의 책이 들어가게 하기 위해서입니다. 부모 프로젝트 ID가 현재 문서와 맞는지 확인한 뒤 이미지와 폰트를 포함한 패키지를 다시 만들고, 퍼블리셔는 기존 코어 Blob을 같은 ID로 교체합니다. 요청이 실패하면 기존 미리보기 내용으로 게시를 계속할 수 있게 했습니다.
그래픽 문서는 이 자동 갱신을 사용하지 않습니다. 부모가 가진 것은 EPUB 전체이고, 그것으로 벡터나 레이아웃 패키지를 덮으면 문서 종류 자체가 바뀝니다. 그래픽 원본의 갱신은 해당 에디터가 새 패키지를 보내는 경로만 허용했습니다. 문서 종류에 따라 최신 원본의 권위가 어디에 있는지 명확히 나눈 것입니다.
최종 게시 snapshot에는 프로젝트 이름, theme, flow, spread, display, 외곽 여백과 콘텐츠 padding, corner radius, 기기별 챕터 박스 설정이 들어갑니다. 여기에 숨김과 재배열이 반영된 reading order, 챕터 key·공개 여부·제목 override, 문서 종류와 출처, 아트보드 비율과 그래픽 챕터 제목을 함께 넣었습니다.
바이너리와 metadata는 역할을 분리했습니다. `content.opkle`은 이미지와 폰트, XHTML 또는 SVG를 가진 실제 문서이고, JSON snapshot은 그 문서를 어떤 방식으로 보여줄지 결정합니다. 공개 뷰어는 둘을 함께 받아 에디터 미리보기와 같은 상태를 재구성합니다.
표지까지 들어간 번들
게시 전송 단위는 원본 패키지와 표지 JPEG를 담은 하나의 ZIP으로 만들었습니다. 콘텐츠는 미리보기가 렌더한 바로 그 Blob을 사용합니다. 중간에 EPUB을 다시 조립하거나 공개용으로 압축 방식을 바꾸지 않으므로 편집기에서 확인하지 않은 다른 결과가 생길 여지가 없습니다.
표지는 Open Graph image로 쓰기 위해 원본 패키지에서 추출했습니다. 패키지 앞부분이 `PK`인지 확인해 일반 ZIP과 자체 압축 포맷을 나누고, 후자는 WASM 해제기를 사용했습니다. OPF의 cover metadata와 EPUB3의 `cover-image` property를 모두 확인하고, 찾지 못하면 `cover` 이름의 이미지와 이미지 폴더의 첫 파일 순서로 fallback했습니다.
OPF의 href는 현재 OPF 디렉터리를 기준으로 정규화했습니다. `.`과 `..`, 역슬래시와 대소문자를 정리한 전체 경로로 먼저 찾고, 오래된 패키지처럼 경로가 조금 어긋난 경우에는 basename으로 한 번 더 찾았습니다. PNG나 WebP처럼 alpha가 있는 원본은 흰 배경 canvas에 그린 뒤 최대 폭 1200px, 품질 0.85의 JPEG로 만들었습니다.
표지를 얻지 못해도 게시 자체는 중단하지 않았습니다. 콘텐츠 ZIP에는 항상 `content.opkle`이 들어가며 표지는 best effort로 추가됩니다. 공개 HTML은 표지가 없을 때 Opkle의 기본 공유 이미지를 사용합니다. 공유용 부가 자산의 실패가 본문 publication을 막지 않는 구조입니다.
Two-step upload
전송은 raw file upload와 publish registration의 두 단계로 분리했습니다. 첫 요청은 ZIP bytes를 임시 저장소의 지정 경로에 그대로 올립니다. multipart를 해석하거나 metadata를 섞지 않습니다. 두 번째 요청이 문서 ID, 공개 slug, 사용자, 제목과 publication snapshot을 보내면 서버가 앞서 올라간 ZIP을 확인하고 등록 작업을 시작합니다.
이 구조에서는 큰 바이너리 전송과 데이터베이스 transaction의 책임이 섞이지 않습니다. 업로드가 끝나기 전에 metadata row가 생기지 않고, 등록 요청은 서버 파일이 실제로 존재하는지 확인한 뒤에만 진행됩니다. 정적 storage는 콘텐츠 bytes를 담당하고 PostgreSQL은 공개 문서의 identity와 URL, metadata pointer를 담당합니다.
서버는 slug가 영문과 숫자로 이루어진 지정 형식인지 먼저 검사하고, 전달받은 ZIP 경로도 허용한 문자와 `.zip` 확장자를 확인합니다. 웹 서버 계정이 만든 임시 파일을 Node 프로세스가 읽을 수 있도록 소유권과 mode를 조정한 뒤 압축을 풉니다. 결과 디렉터리는 임시 위치에서 완성한 다음 공개 storage 아래의 slug 폴더로 이동합니다.
같은 문서를 다시 게시하면 기존 공개 폴더를 새 결과로 교체하고 database row는 insert가 아니라 update합니다. 임시 ZIP 정리는 게시 성공과 분리해 처리했습니다. cleanup이 실패해도 이미 이동된 콘텐츠와 metadata는 유효합니다. bytes의 배치와 metadata upsert가 명확한 순서로 끝납니다.
세 개의 식별자
게시 시스템에는 서로 다른 세 식별자가 필요했습니다. 부모 project ID는 어떤 EPUB 작업공간에 속하는지를 나타냅니다. publisher document ID는 그 안에 만들어진 개별 게시 문서를 가리킵니다. publish ID는 독자가 보게 되는 공개 URL의 slug입니다. 이 세 값을 하나로 취급하면 여러 게시 문서가 있는 순간 관계가 무너집니다.
하나의 EPUB 프로젝트 안에는 EPUB 웹 버전, 벡터 콘텐츠, 레이아웃 문서처럼 여러 publication이 공존할 수 있습니다. 공개 상태를 부모 project ID로 조회하면 아직 게시하지 않은 문서가 같은 프로젝트의 다른 slug를 가져올 수 있고, 재게시 때 그 공개 페이지를 덮게 됩니다. 그래서 공개 상태 조회와 metadata row의 실질적인 문서 key는 publisher document ID로 두었습니다.
publish ID는 첫 게시에서만 생성하고 이후에는 문서 snapshot에 저장한 값을 재사용합니다. 내용과 제목, 표지, 디자인을 바꿔도 공개 링크는 유지됩니다. 이전 database row에 문서 ID가 없는 경우에는 로컬 snapshot이 기억하는 publish ID로 자기 row를 찾고, 그 자리에서 문서 ID를 backfill하도록 했습니다. 부모 project ID로 모호하게 fallback하지 않았습니다.
원본 벡터와 레이아웃 프로젝트에도 publisher document ID를 역방향으로 기록했습니다. 원본에서 다시 게시하면 같은 퍼블리셔 문서와 같은 publish ID가 열리고 코어 패키지만 갱신됩니다. source project, publication document, public URL이 각각 하나의 안정적인 edge로 연결되었습니다.
Ownership gate
공개 slug는 URL에 노출되므로 그 값만으로 게시와 취소 권한을 줄 수 없습니다. 등록이 publish ID 기준 upsert이고 공개 폴더도 같은 이름이라면, 소유권 검증 없이 다른 사람의 slug를 보낸 요청이 콘텐츠와 OG metadata를 통째로 바꿀 수 있습니다. 취소 요청도 같은 방식으로 타인의 공개 페이지를 내릴 수 있습니다.
그래서 게시와 취소가 하나의 ownership gate를 통과하게 했습니다. 해당 slug의 기존 row가 없으면 첫 게시로 허용하고, row가 있으면 저장된 사용자 정보와 요청 사용자가 같은지 확인합니다. 양쪽에 publisher document ID가 있다면 그것도 같아야 합니다. 같은 사용자라도 다른 문서가 실수로 기존 slug를 들고 온 경우에는 덮어쓰지 못합니다.
이 검증은 압축 해제나 공개 폴더 삭제보다 먼저 실행합니다. 권한이 없는 요청이 파일시스템을 조금이라도 변경한 뒤 거부되는 순서를 허용하지 않았습니다. 검증 과정에서 database 오류가 나면 통과시키지 않고 닫힌 상태로 실패합니다.
게시 취소는 metadata row를 삭제하지 않습니다. 상태를 private로 바꾸고 공개 storage 폴더를 제거해 페이지와 콘텐츠 직접 URL을 함께 내립니다. slug와 document relation은 남아 있으므로 같은 사용자가 나중에 다시 게시하면 같은 링크를 복구할 수 있습니다. 공개 여부와 문서 identity를 분리한 결과입니다.
Metadata storage
서버의 publication table은 최초 게시 때 존재 여부를 확인해 멱등으로 생성했습니다. 공개 slug에는 unique constraint를 두고, 부모 프로젝트, 게시 문서, 사용자, 제목, 설명, 표지 URL, 콘텐츠 URL, JSON snapshot, 공개 상태와 생성·수정 시각을 저장했습니다. 바이너리 자체는 database에 넣지 않고 정적 storage의 URL만 기록합니다.
기존 table에 게시 문서 ID column이 없으면 첫 게시나 조회 시 schema를 확인해 추가했습니다. 오래된 row를 한꺼번에 중단시키는 migration 대신, 새로운 조회 계약을 적용하면서 필요한 데이터를 점진적으로 보강했습니다. 생성 시각은 최초 publication을 나타내므로 재게시에서도 보존하고 수정 시각만 갱신합니다.
공개 상태를 확인할 때 database를 권위로 사용했습니다. 로컬 snapshot에 공개 URL이 남아 있어도 서버에 public row가 없다면 private 상태로 내립니다. 반대로 public row를 찾으면 현재 URL을 snapshot에 다시 채웁니다. 에디터가 새로고침된 뒤에도 게시 버튼이 다시 게시하기로 바뀌고, 링크 열기와 복사가 정확한 상태로 복원됩니다.
이미 공개 중인 그래픽 원본이 새 패키지를 보내 기존 코어 Blob을 교체한 경우에는 같은 publish ID로 자동 재게시하도록 연결했습니다. 단, 취소된 문서를 임의로 다시 공개하지 않도록 상태가 public일 때만 실행합니다. 세션이 아직 확정되지 않았다면 자동 갱신을 보류하고 사용자가 다시 게시하기로 재시도할 수 있게 했습니다.
Server-rendered shell
독자가 공개 URL에 들어오면 서버는 먼저 public 상태인 metadata row를 조회합니다. 존재하지 않거나 취소된 문서는 Opkle로 redirect하고, 유효한 문서에는 완성된 HTML shell을 반환합니다. JavaScript가 실행되기 전에도 title, description, canonical URL, Open Graph와 Twitter card가 들어 있으므로 검색 엔진과 메신저 preview가 문서를 이해할 수 있습니다.
제목과 설명은 HTML text와 attribute에 들어가기 전에 escape했습니다. publication snapshot은 JSON으로 직렬화한 뒤 `<` 문자를 script-safe escape로 바꾸어 전역 boot object에 넣었습니다. 서버가 metadata를 임의로 해석해 새 구조를 만들지 않고, 검증된 JSON 전체를 공개 reader에 전달합니다.
초기 배경은 snapshot의 background와 theme에서 먼저 계산해 inline style로 넣었습니다. 공개 reader bundle과 원본 패키지가 load되기 전에 흰 화면이 번쩍이지 않고 편집기에서 정한 배경으로 시작합니다. JavaScript가 없는 환경에는 Opkle로 가는 최소 링크를 남겼습니다.
재게시해도 콘텐츠와 표지의 정적 URL은 같습니다. 브라우저나 CDN이 이전 파일을 재사용하지 않도록 database의 수정 시각을 query version으로 붙였습니다. 공개 reader bundle도 별도의 version 값을 URL에 넣어 렌더 코어를 배포한 뒤 오래된 module cache가 새 그래픽 문서를 잘못 해석하지 않게 했습니다.
Shared render core
공개 reader를 새로 작성하되 렌더러는 새로 만들지 않았습니다. 퍼블리셔의 미리보기 helper를 그대로 상속하고 콘텐츠 Blob을 가져오는 메서드만 교체했습니다. 에디터에서는 브라우저 database의 첫 코어 Blob을 반환하고, 공개 reader에서는 storage URL에서 fetch한 Blob을 반환합니다. 그 아래의 패키지 파싱과 페이지 geometry, 챕터 박스와 theme 처리는 같은 코드입니다.
공개 reader는 시작과 동시에 read-only flag를 켭니다. 챕터 drag and drop, 우클릭 이름 변경과 숨김, 저장, 부모 iframe으로 보내는 editor message를 모두 건너뜁니다. 숨긴 챕터는 편집기처럼 흐리게 보여주지 않고 목록에서 완전히 제외합니다. 편집 기능만 닫고 읽기와 페이지 이동은 그대로 남겼습니다.
디자인 패널과 toolbar, 프로젝트 관리 UI는 렌더 코어 바깥에 두었기 때문에 공개 bundle에는 작업영역만 나타납니다. 화면 전체를 fixed viewport container로 만들고 그 안에서 미리보기와 같은 renderer를 실행합니다. 오른쪽 아래에는 공개 문서를 만든 도구로 돌아가는 작은 Opkle badge만 두고, 위치도 챕터 박스 방향과 외곽 padding을 따라 계산했습니다.
이 구조에서는 미리보기와 공개본의 차이를 CSS로 흉내 낼 필요가 없습니다. 스프레드가 단일로 바뀌는 폭, 그래픽 아트보드 fit, 표지 contain, 챕터 선택 색과 다크 배경, 페이지 전환 timing까지 같은 함수가 결정합니다. 한쪽을 수정하면 양쪽이 함께 갱신되는 renderer가 되었습니다.
Public reader boot
공개 reader는 HTML shell의 boot object에서 콘텐츠 URL과 snapshot을 읽습니다. 설정은 에디터와 같은 normalize 경로로 통과시키고, 챕터 metadata도 key와 visibility, title을 검증해 다시 만듭니다. 문서가 그래픽형이면 metadata의 kind와 source, 아트보드 비율을 그대로 채택합니다. 부팅할 때 원본 패키지를 다시 풀어 manifest를 찾지 않습니다.
콘텐츠 fetch가 성공하면 에디터에서 사용하던 전역 reset과 base CSS를 먼저 주입합니다. 챕터 UI에 필요한 font는 공개 storage의 절대 URL에서 불러오고, EPUB 본문 폰트는 패키지 안의 resource를 리더가 처리합니다. 에디터 전역 CSS가 빠진 상태에서 body와 이미지의 기본값이 달라지는 일을 막기 위해 순서도 동일하게 맞췄습니다.
모바일 여부는 viewport width로 판정하고, 모바일이면 에디터 디자인 패널에서 모바일 미리보기를 선택했을 때와 같은 상태를 적용합니다. width가 바뀌면 즉석에서 일부 geometry만 고치지 않고 300ms debounce 뒤 페이지를 reload해 boot 경로 전체가 새 기기 mode를 다시 선택하게 했습니다. 모바일 주소창처럼 높이만 바뀌는 resize는 무시해 읽던 중 새로고침되지 않습니다.
reader는 content Blob, font, 이미지와 Shadow DOM이 준비될 때까지 기존 미리보기와 같은 settle 절차를 거칩니다. 공개 URL의 첫 화면부터 편집기에서 확인한 첫 reading order가 나오고, 챕터를 넘길 때도 같은 loading guard와 animation이 적용됩니다.
같은 링크로 갱신
재게시에서 중요한 것은 새 파일을 올리는 것이 아니라 기존 publication identity를 유지하는 일이었습니다. 이미 publish ID가 있으면 같은 slug와 같은 database row를 사용하고 공개 폴더의 content만 바꿉니다. canonical URL과 공유 링크가 움직이지 않으므로 이전에 배포한 주소와 검색 기록을 계속 사용할 수 있습니다.
원본 에디터에서 퍼블리셔로 돌아오는 연결도 같은 기준을 따릅니다. 벡터와 레이아웃 프로젝트는 자신과 연결된 publisher document ID를 유지하고, 퍼블리셔는 새 패키지를 받으면 Blob ID를 보존한 채 data만 바꿉니다. 공개 상태라면 권한과 세션을 확인한 뒤 같은 slug로 새 snapshot과 bytes를 등록합니다. 원본 수정에서 공개 결과 갱신까지 하나의 닫힌 흐름이 되었습니다.
반대로 사용자가 게시를 취소하면 public 상태와 storage bytes만 내리고 slug는 버리지 않습니다. 다시 게시할 때 같은 URL이 살아나며, database의 updated timestamp가 새 콘텐츠와 표지의 cache key가 됩니다. 게시, 갱신, 취소, 복구가 서로 다른 임시 URL을 만들지 않습니다.
흐름형 EPUB과 고정 그래픽 문서, 신규 게시와 같은 링크 재게시, 표지 유무, 표준 ZIP과 자체 압축 패키지, 모바일·데스크탑, 스프레드·단일·전체·팝업, 챕터 숨김과 재배열, 공개 취소와 복구, 오래된 metadata migration, cache 갱신과 권한 거부까지 모두 반복 검수했습니다. 에디터 미리보기의 layout과 공개 URL의 화면, reading order와 interaction이 같은 것을 확인했습니다. 편집한 책을 변환 손실 없이 웹 문서로 공개하는 퍼블리셔 시스템은 완전하게 구현되고 안정화되었습니다.
이전글
목록으로
다음글
저작권 고시
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에게 있습니다.