ICEFiction — AI와 함께 쓰는 소설 집필 데스크톱 앱 2026.07.29

ICEFiction 개발기 ⑦: 3장에서 삽화를 불렀는데 프롤로그를 그렸다 (v0.10.0)

커서는 3장에 있는데 그림은 1장 첫 문단을 그렸다. 버그가 아니라 문서 앞 600자를 근거로 삼던 코드였다. 여덟 개를 고치는 동안, 새 테스트가 두 번이나 사람보다 먼저 문제를 짚었다.

3장 한가운데에 커서를 두고 삽화를 불렀다. 나온 그림은 프롤로그의 빗속 골목이었다.

버그가 아니었다. 코드는 이렇게 돼 있었다.

if (body) parts.push(body.slice(0, 600))

문서 앞 600자. 처음 이걸 쓸 때는 캐릭터 시트를 그리려던 거였다. 시트는 짧으니 앞 600자가 곧 전부다. 그런데 같은 함수가 챕터 삽화에도 쓰이면서, 20장짜리 원고 어디에서 불러도 1장 첫 문단이 근거가 됐다.

v0.10.0은 이런 것 여덟 개를 고친 판이다. 새 기능 세 개, 잘못 도는 것 세 개, 군더더기 두 개. 그중 기록해 둘 만한 것만 적는다.

1. 커서를 모르는 그림

고치는 방향은 분명했다. 커서가 놓인 문단을 근거로 삼는다.

export function sceneAtCursor(body: string, cursor: number, cap = SCENE_CAP): string {
  const para = paragraphAt(doc, pos)
  const end = para ? Math.min(para.to, pos + FORWARD_CAP) : pos
  const head = doc.slice(Math.max(0, end - cap), end)
  ...
}

Math.min(para.to, pos + FORWARD_CAP)가 나중에 붙은 부분이다. 처음엔 그냥 para.to였다. 커서가 든 문단 전체를 담는 게 맞다고 생각했으니까.

그런데 테스트를 쓰다 걸렸다.

AssertionError: 커서를 옮겼는데 마지막 문단이 그대로 실림

빈 줄 없이 길게 쓴 원고에서는 문단 하나가 수천 자다. 그런 원고의 맨 앞에 커서를 두면 "커서가 든 문단의 끝"은 저 아래 수천 자 뒤다. 커서 기준으로 고쳤는데 결과는 여전히 커서와 상관없는 대목이었다.

문단은 단위지 경계가 아니다. 커서보다 400자 이상 앞서가지 않도록 막았다.

2. 표지를 캔버스에 얹으려다 만난 '한 쌍'

챕터에도 표지를 붙이기로 했다. 책 표지에서 쓰던 방식 그대로 — AI는 글자 없는 그림만 그리고, 제목은 앱이 내장 글꼴로 얹는다. 그림을 따로 보관하니 제목·글꼴·위치는 다시 그리지 않고 고칠 수 있다.

챕터 표지가 깔린 원고 갤러리

책 표지 코드를 그대로 가져다 문서에 붙였더니 그림이 안 떴다. 콘솔에는 이렇게 찍혔다.

Cross origin requests are only supported for protocol schemes: http, https, data…

crossOrigin='anonymous'를 뗐다. 이번엔 그림은 뜨는데 저장이 막혔다.

SecurityError: Failed to execute 'toDataURL' on 'HTMLCanvasElement':
Tainted canvases may not be exported.

붙이면 안 뜨고, 떼면 저장이 안 된다. 원인은 스킴 권한이었다.

// main/index.ts
{ scheme: 'ice-asset',
  privileges: { standard: true, secure: true, supportFetchAPI: true, stream: true } },
{ scheme: 'ice-cover',
  privileges: { standard: true, secure: true, supportFetchAPI: true,
                corsEnabled: true,  // ← 여기
                stream: true } }

자료를 나르는 ice-asset에는 corsEnabled가 없다. 표지를 나르는 ice-cover에만 있다. 작년에 책 표지를 만들며 이미 겪고 주석까지 달아 둔 문제였는데, 새 기능에서 스킴만 바꿔 붙였다가 똑같이 밟았다.

corsEnabled 권한과 응답의 Access-Control-Allow-Origin 헤더는 한 쌍이다. 둘 중 하나만 있으면 로드가 깨지거나 캔버스가 오염된다. 그래서 ice-asset의 권한을 건드리는 대신, 이미 그 조합이 검증된 ice-cover에 host를 하나 더 냈다.

ice-cover://doc/<프로젝트 상대경로>

권한이 검증된 통로가 이미 있으면 새로 뚫지 말고 그리로 흘려보내는 게 낫다.

3. 빈 줄에 간격이 두 번 붙는다

"문단 간격이 없어서 엔터를 두 번 친다"는 말을 들었다. 보기 설정에 문단 간격·들여쓰기·내어쓰기를 넣었다.

보기 설정 — 문단 간격과 첫 줄 모양

마크다운에서 한 문단은 한 줄(.cm-line)이라, 줄 아래 여백이 곧 문단 간격이다. 그런데 넣고 보니 기존 원고가 두 배로 벌어졌다. 당연했다. 마크다운 원고에는 문단 사이에 이미 빈 줄이 있다. 그 빈 줄에도 간격이 붙으니 빈 줄 한 개 + 간격 두 개가 된다.

빈 줄만 골라 간격을 0으로 되돌렸다.

const blankLine = Decoration.line({ class: 'cm-blank-line' })

이건 라인 데코라 ViewPlugin에서 안전하다. 예전에 이 프로젝트에서 데코 세트를 통째로 날려 먹은 건 block: true replace 데코였다. 같은 '데코'라도 종류에 따라 규칙이 다르다.

4. 자동 연결에 상한이 필요했다

"켤 때마다 AI가 연결 안 됨으로 뜬다"는 것도 요청에 있었다. 설정은 저장되고 있었다. 그런데 시작할 때 연결 확인을 아무도 부르지 않았다. connnull이니 패널은 미연결로 판단해 설정 폼을 강제로 펼쳤다. 한 줄 추가로 끝날 일이었다.

끝날 줄 알았는데 E2E가 터졌다.

page.waitForSelector: Timeout 5000ms exceeded.
  - waiting for locator('.ai-setup') to be visible

확인이 5초 안에 안 끝났다. 이유를 보니 그 테스트가 AI 설정 경로를 지정하지 않아서 내 개발 기계의 진짜 설정(CLI 에이전트)으로 확인이 돌고 있었다. 테스트 환경의 사고처럼 보였지만 아니었다. CLI 확인은 원래 몇 초 걸리고, 응답 없는 서버라면 영영 안 끝난다. 사용자에게도 똑같이 일어날 일이었다.

2.5초가 지나면 확인이 끝나지 않아도 설정 폼을 내주도록 했다. 자동으로 해주는 기능이 사용자의 조작을 막으면 안 된다.

하나 더. 연결에 실패해도 저장된 설정을 기본값으로 덮어쓰지 않는다. 사유를 보여주고 되돌리기 단추만 내준다. 잘 쓰던 설정이 잠깐의 네트워크 문제로 지워지면 다시 입력해야 하니까.

5. 정렬을 뒤집었다

바인더가 엉망이라는 지적도 있었다. 특히 문체 섹션에서 문체지침.mdsamples/ 폴더 밑으로 밀려 있었다. 정렬이 폴더 우선이었기 때문이다.

// 전
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
// 후
if (a.isDir !== b.isDir) return a.isDir ? 1 : -1

부호 하나다. 폴더를 위에 올리면 그 섹션의 알맹이가 서랍에 밀려 한참 아래로 내려간다. 섹션을 열었을 때 "이 섹션이 무엇인지" 알려주는 문서가 먼저 보이고, 묶음은 그 아래 놓이는 편이 읽기 쉽다.

바인더 계층

섹션 머리도 11px 대문자에서 14px 굵게로 키우고 글리프와 구분선을 넣었다. 폴더는 아이콘과 왼쪽 안내선으로 묶었다. 여기서 한 번 더 걸린 게 있는데, 자식을 감싸는 요소에 margin-left를 주면서 줄마다 있던 depth * 14를 그대로 뒀더니 들여쓰기가 이중으로 밀렸다. 들여쓰기는 한 곳에서만 줘야 한다.

6. 지시는 선택이어야 한다

/이어쓰기에 "어떤 내용으로 이어써달라"를 넣을 수 있게 해달라는 요청이 있었다. 조건이 붙어 있었다 — 넣을 수도 있고 넣지 않을 수도 있게.

슬래시 명령에 한 줄 지시

명령을 고르면 본문 아래에 입력 막대가 뜬다. 비우고 Enter를 치면 지시 없이 그대로 실행된다. 모달로 만들지 않은 이유는, 슬래시 명령의 요점이 "패널로 시선을 옮기지 않고 쓰던 자리에서 부른다"이기 때문이다. 그 기능을 부르는 데 모달을 띄우면 스스로 요점을 깨는 셈이다.

넣은 지시는 요청문 맨 끝에 붙인다. 실제 과제 바로 옆이어야 모델이 흘리지 않는다. 문체 지침은 그대로 최우선이다 — 지시는 '무엇을 쓸지'이지 '어떤 문체로 쓸지'가 아니다.

남은 것과 검증

인스펙터에서 POV와 목표 글자수를 뺐다. 다만 프론트매터 파서는 그대로 뒀다. 화면에서 뺐다고 파일에 적힌 값까지 지우면 안 된다. 저장 한 번에 기존 원고의 데이터가 사라지는 건 다른 종류의 사고다.

재시작하면 마지막에 쓰던 문서·커서·스크롤로 돌아간다. 기록은 원고 폴더가 아니라 기기에 둔다. 클라우드로 서재를 공유하면 여러 대가 서로의 커서를 덮어쓰기 때문이다.

검증은 늘 하던 대로 했다.

항목결과
유닛114 통과 (104 → 114)
E2E65 통과 (에디터 32 · 검색 9 · 책장 5 · 이미지 9 · 폴더 10)
typecheck / build0 errors / 성공
실기 확인13항목 + 스크린샷, 콘솔 오류 0건

이번 판에서 새 테스트가 실제로 두 번 일했다. 커서 장면의 400자 상한과 자동 연결의 2.5초 상한은 둘 다 테스트가 먼저 터져서 알게 된 것이다. 사람이 눈으로 봤으면 "그럴듯하네" 하고 넘어갔을 자리였다.

v0.10.0은 Windows 설치 파일과 macOS dmg(서명·공증 완료)로 받을 수 있다.

  • 다운로드: <https://github.com/icenovel-rgb/ICEFiction/releases/latest>
  • 서비스 페이지: <https://icenovel.com/service/icefiction>

ICEFiction — AI와 함께 쓰는 소설 집필 데스크톱 앱 프로젝트로