linetta 1.9 앱에서 Linetta Works로 회차 게시하기
Linetta Works는 linetta로 쓴 소설을 읽는 사이트다. 앞선 단계(Phase 1)에서는 가져오기 도구로 작품 네 편을 사이트에 올려 두었고, 2026-10-18에 낸 v1.9.0부터는 앱에서 회차를 바로 올릴 수 있다. 게시 기능을 켜기 전까지는 앱이 사이트로 아무것도 보내지 않아서, 계정을 연결하기 전에도 보내지 않고 연결한 뒤에도 작가가 고른 회차만 보낸다.
회차를 엔진에서 판별하기
먼저 “회차”가 무엇인지 엔진이 알아야 했다. 지금까지 회차 판별은 화면 코드(outlineEpisode.ts)에만 있었는데, 게시는 엔진이 하므로 같은 규칙을 Go로 옮겨 internal/episode 패키지를 만들었다. 노드에서 위로 올라가며 회차 라벨이 붙었거나 최상위가 아닌 컨테이너(여러 노드를 담는 묶음)인 첫 노드를 회차로 보고, 회차 구조가 하나도 없는 단편은 작품 전체를 회차 하나로 본다. 화면 쪽 테스트 사례 다섯 개를 Go로 옮기고 단편과 프리셋 차이 사례를 더했다.
게시용 본문은 회차의 씬을 순서대로 Markdown으로 바꿔 * * *로 잇고, 멘션의 @와 노트 표시는 남기지 않는다. 기존 내보내기는 건드리지 않으려고 문서를 게시용으로 고친 뒤 원래 변환기에 넘기는 방식으로 만들었다. 본문 해시는 최종 Markdown의 SHA-256이다. Phase 1에서 가져온 네 작품을 로컬 라이브러리 사본으로 대조해 보니 회차 8개의 노드 ID와 해시가 가져오기 도구의 결과와 모두 같아서, 앱에서 게시 상태를 조회하면 이미 올라간 회차가 “공개됨”으로 나올 것이라고 봤다.
브라우저에서 확인 코드로 연결하기
설정 > 게시에서 먼저 전송 안내를 읽고 동의해야 연결 버튼이 눌린다. 안내에는 게시한 회차의 제목과 본문, 작품 정보가 사이트 서버에 저장되어 공개되고, 사이트가 수위 검사를 위해 본문을 Anthropic API로 보내며, 게시하지 않은 원고는 기기를 떠나지 않는다는 내용이 들어 있다. 엔진도 동의 플래그가 없으면 연결을 시작하지 않는다.
연결 버튼을 누르면 브라우저에 사이트가 열리고, 앱과 브라우저에 같은 확인 코드가 뜨는지 보고 허용하면 앱에 필명이 뜬다. 연결 방식은 Codex 로그인과 같은 모양이라서 시작하자마자 확인 코드와 주소를 돌려주고, 기한을 두고 뒤에서 승인을 기다린다. 받은 토큰은 macOS 키체인이나 Windows 자격 증명 관리자에 두고, 보안 저장소를 쓸 수 없는 Linux에서는 본인만 읽을 수 있는 publish-token 파일에 둔다. 토큰은 오류 메시지나 로그에 들어가지 않고, settings.json에는 필명과 사이트 주소만 남는다. 웹 프로필에서 기기를 해제하면 다음 요청이 401로 돌아오는데, 이때 저장된 토큰을 지우고 “다시 연결” 상태로 바꾼다. 이 기능은 데스크톱 전용이라 iOS에서는 설정 항목과 메뉴를 숨긴다.
아웃라인에서 회차 게시하기
아웃라인의 회차 줄 메뉴에 “이 회차 게시…”를 넣었다. 씬의 메뉴에서 골라도 그 씬이 속한 회차가 올라가고, 여러 회차를 담은 권에는 이 메뉴가 없다. 작품을 처음 올릴 때는 독자가 볼 소개글(필수)과 연재 상태, 작품 주소(선택, 한 번 정하면 못 바꾼다)를 받고, 제목과 장르는 작품에서 가져온다. 게시 창을 열기 전에 저장 대기 중인 글을 먼저 저장해서 마지막 몇 글자가 빠진 본문이 올라가지 않게 했다.
사이트에 작품을 등록하고 나면 아웃라인의 회차 줄에 공개됨, 수정됨, 검토 중, 반려, 검토 대기, 비공개 배지가 붙는다. 상태는 작품을 열 때와 게시 직후에만 조회하고 주기적으로 묻지 않으며, 오프라인이면 마지막으로 받아 둔 사본으로 보여 준다. 고친 회차를 다시 올리면 같은 주소의 본문이 바뀐다. 회차 번호는 개요 순서를 따라서, 회차를 옮기거나 사이에 끼워 넣으면 뒤 회차의 번호가 다시 매겨지고 예전 주소는 새 주소로 넘어간다.
게시가 실패하면 원고와 노드 상태, 로컬에 저장한 게시 상태를 하나도 바꾸지 않고, 게시 창에 이유를 번역해 보여 준 뒤 다시 시도할 수 있게 했다. 게시 실패 사유 코드는 18개인데, 사이트에서 오는 한국어 문장이 영어나 일본어 화면에 그대로 나오지 않게 사유 코드가 세 언어로 빠짐없이 번역됐는지 확인하는 테스트도 넣었다.
엔진은 사이트 API를 그대로 따르는 가짜 사이트(httptest로 띄운 메모리 서버)에 붙여 테스트했다. v1.9.0 태그의 engine 디렉터리에서 그중 몇 개를 골라 돌린 결과다. 연결하지 않은 앱이 사이트로 요청을 보내지 않는지, 첫 게시 뒤 고치면 수정됨이 되고 다시 올려도 주소가 그대로인지, 사이에 회차를 끼우면 번호가 다시 매겨지는지, 실패해도 로컬 상태가 그대로인지, 게시용 본문에서 편집 흔적이 빠지는지를 본다.
$ GOTOOLCHAIN=go1.26.9 CGO_ENABLED=0 go test ./internal/publish/ ./internal/export/ \
-run 'TestService_firstPublishThenModifiedThenRepublishKeepsTheAddress|TestService_insertingBeforePublishedEpisodesRenumbers|TestService_failuresChangeNothingLocally|TestService_notConnectedSendsNothing|TestExportEpisodeMarkdown_dropsEditingMarks' -v -count=1
=== RUN TestService_notConnectedSendsNothing
--- PASS: TestService_notConnectedSendsNothing (0.02s)
=== RUN TestService_firstPublishThenModifiedThenRepublishKeepsTheAddress
--- PASS: TestService_firstPublishThenModifiedThenRepublishKeepsTheAddress (0.02s)
=== RUN TestService_insertingBeforePublishedEpisodesRenumbers
--- PASS: TestService_insertingBeforePublishedEpisodesRenumbers (0.02s)
=== RUN TestService_failuresChangeNothingLocally
--- PASS: TestService_failuresChangeNothingLocally (0.02s)
PASS
ok github.com/devlikebear/linetta/engine/internal/publish 0.087s
=== RUN TestExportEpisodeMarkdown_dropsEditingMarks
--- PASS: TestExportEpisodeMarkdown_dropsEditingMarks (0.02s)
PASS
ok github.com/devlikebear/linetta/engine/internal/export 0.026s
에이전트에게 게시 맡기기
MCP 툴도 두 개 더했다. linetta_publish_status는 회차별 게시 상태와 주소를 읽고, 연결하지 않았으면 사이트에 묻지 않고 로컬로 답한다. linetta_publish_episode는 회차 하나를 게시하거나 사이트의 본문을 갱신하는데, 쓰기 권한을 다 연 full 모드에서만 등록되고 앱의 게시 창과 같은 서비스를 쓴다. 계정 연결과 작품 등록은 작가가 앱에서 직접 해야 하는 일이라고 판단해서 그 둘을 하는 툴은 일부러 두지 않았다. 그래서 연결이나 작품 등록이 안 됐으면 에이전트는 대신할 수 없다는 안내와 함께 거절된다. 내장 에이전트도 같은 툴을 받고, 게시할 때마다 활동 로그에 남는다. 두 툴의 설명과 출력 스키마는 줄여서 합계 1,958바이트로 맞추고, 툴 설명 크기 테스트에는 이 둘의 허용량을 따로 뒀다.
README에 게시 절을 새로 쓰고, 개인정보처리방침(한·영·일)에 3.5항을 넣었다. 개발자가 운영하는 서버로 원고가 가는 경로는 이것 하나라서 무엇이 어디로 가는지, 수위 검사로 Anthropic API를 거친다는 점, 토큰을 어디에 두는지, 연결 해제와 삭제를 적었다.
쌓은 PR이 main을 비껴간 일
작업은 회차 판별, 게시 엔진, 화면, MCP 툴과 문서 순서로 PR 네 개(#202~#205)를 쌓아 올렸다. 그런데 #202를 main에 squash 병합한 뒤에도 #203의 base가 앞 PR의 브랜치로 남아 있어서 #203이 그 브랜치로 병합됐고, 게시 엔진이 main에 들어가지 않았다. #204와 #205도 같은 식으로 각자 앞 브랜치로 들어갔다. 그래서 #203의 커밋을 main 위로 옮겨 #206으로 다시 열었고(#204의 화면 변경도 여기에 함께 들어갔다), #205는 #207로 옮겼다. 옮긴 뒤의 main 트리는 앞서 make ci를 통과한 트리와 같다. 다음부터는 PR을 쌓으면 아래 PR이 병합될 때마다 다음 PR의 base를 main으로 바꾸기로 작업 규칙에 적어 두었다.
Phase 3 완료 기록도 main에 남겼다.
확인한 것과 남은 것
자동 검증은 main에서 make ci와 make test-mobile-engine이 통과했다. 작업지시서의 수동 확인 여덟 가지(확인 코드 일치, 이미 올라간 회차가 공개됨으로 보이는지, 새 회차 게시와 본문의 편집 흔적, 수정 뒤 같은 주소로 재게시, 비공개 전환 뒤 404, 네트워크를 끊었을 때 원고 보존과 재시도, 에이전트 게시와 활동 로그, 웹에서 기기를 해제한 뒤 다시 연결 안내)는 운영 사이트에 대고 엔진 단위로 확인했고, 데스크톱 개발 빌드의 화면은 따로 눈으로 봤다. 시험용으로 만든 작품은 비공개로 돌려 두었다.
남은 일도 있다. 회차 판별 규칙이 아직 TS와 Go 두 곳에 있어서 한쪽으로 합치는 일은 따로 남겼다. 사이트 가입은 아직 열지 않아서 지금은 다른 작가가 게시할 수 없고, 가입 개방과 신고·계정 삭제 같은 공개 준비는 사이트 쪽 다음 단계에서 한다.
데스크톱 앱은 brew install --cask devlikebear/tap/linetta로 설치하고, 직접 받은 앱은 메뉴의 업데이트 확인으로 올릴 수 있다.