본문으로 건너뛰기

Pointly 07. 편집 엔진 port 분리와 AI 접근 티어 결정

·29 min read·7 / 7

Pointly는 개발자가 아닌 사이트 오너가 바꾸고 싶은 부분을 클릭하고, 원하는 결과를 말로 적고, 미리 보고, 배포하는 도구다. 이번 작업은 이틀에 걸쳐 했다. 버그를 고치려다 편집 결과를 판정하는 구조까지 바꾸게 됐고, 이틀째에는 약관을 확인하고 AI 접근 방식을 정했다.

"바꿀 내용을 찾지 못했어요"가 답이 되어 돌아왔다

오너가 이렇게 적었다.

이거 대체 뭔 ui를 만든거야?? 설명좀

돌아온 답은 이랬다.

바꿀 내용을 찾지 못했어요.

실행은 정상 종료했다. 모델은 답도 썼다. 그런데 서버의 성공 판정이 "파일이 변했나"여서, 아무것도 안 고친 실행은 전부 실패였다. 모델이 쓴 설명은 그 자리에서 버려졌고 터미널에도 남지 않아 되찾을 수 없었다.

판정 로직과 엔진 구조를 함께 살펴봐야 해서 설계서를 먼저 썼다. 정할 것은 두 가지였다.

  • 성공 여부를 누가 정할 것인가. 서버가 디스크를 보고 성공을 정하면, "고치지 않는 것이 정답인 요청"은 표현할 방법이 없다.
  • 엔진이 어디에 있는가. 오너는 비개발자라 자기 AI 계정을 갖지 않는다. 지금은 운영자 로그인으로 로컬 claude CLI를 띄우는데, 그 방식을 제품에 그대로 쓸 수 없다.

두 문제가 서로 연결돼 있어 작업을 네 단계로 나눴다.

flowchart LR
    A["0단계 후퇴 (동작이 바뀌는 안전장치)"] --> B["1단계 port (순수 리팩터)"]
    B --> C["2단계 판정과 마커"]
    C --> D["3단계 호스티드 어댑터"]

리뷰를 받고 설계서를 두 번 고쳤다. 먼저 행 번호를 실제 파일에 맞추고, 편집 이벤트 타입이 jobs.tsdev-ai.ts에 각각 정의돼 있다는 내용을 추가했다. 다음 리뷰에서는 어댑터가 보내는 실행 결과와 서버가 판단한 최종 결과를 별도 타입으로 나눴다.

마커 파서에서 changed까지 확인하면 판정과 검증이 다시 섞인다. 나중에 두 로직을 합치지 않도록 설계서에 분리한 이유를 적어 뒀다.

자식 프로세스에 넘길 환경 변수를 제한했다

리팩터링 계획에 동작을 바꾸는 수정 세 가지가 섞여 있었다. 먼저 처리할 수 있도록 0단계로 따로 뺐다.

env: process.env는 npm·GitHub·클라우드 자격 증명을 자식 프로세스에 통째로 줬다. 그 프로세스는 오너가 적은 문구로 돈다. 오너의 요청은 신뢰할 수 없는 입력이고, 편집에 필요 없는 자격 증명까지 넘길 이유가 없다.

허용 목록에 있는 환경 변수만 넘기기로 했다. 셸에 자격 증명이 추가돼도 목록에 직접 넣기 전까지는 자식 프로세스에 전달되지 않는다.

HOME은 남겼다. 개발자의 claude 설정 디렉터리인 ~/.claude를 찾으려면 HOME이 필요하다. 운영자의 CLAUDE.md·훅·에이전트 정의는 오너의 편집 작업에 영향을 주지 않도록 --setting-sources ''로 차단했다. 개발자 개인 설정이 남의 사이트를 고치는 방식을 바꿀 이유가 없다.

~/.claude에 있는 것은 로그인이 아니라 설정이다

engine-env.ts 주석에 "개발자의 claude 로그인이 ~/.claude에 있다"고 적어 뒀는데, macOS에서 자격 증명은 Keychain에 있다. 이 머신에 ~/.claude/.credentials.json은 없고 Keychain 항목(Claude Code-credentials)은 있다. .credentials.json은 Linux·Windows의 저장 위치이거나 Keychain 쓰기가 거부될 때의 폴백이다. HOME을 남겨야 하는 것 자체는 맞았다. 다만 로그인 정보와 설정 위치를 혼동해 이유를 잘못 적었다.

--setting-sources ''의 동작은 같은 프롬프트를 두 번 실행해 확인했다. 기본 설정으로 실행하면 # oh-my-claudecode …가 답변에 나오고, 이 플래그를 주면 NONE이 나온다. 다만 CLI 문서에는 쉼표로 구분한 user,project,local 목록만 나와 있고, 빈 문자열을 넣었을 때의 동작은 설명돼 있지 않다. bogus는 검증 에러가 나지만 ''는 통과한다. 문서에 없는 동작을 사용하고 있다는 점도 기록해 뒀다.

멈춘 것과 기다리는 것을 구분한다

멈춘 CLI는 close를 내지 않는다. 종료 이벤트가 발생하지 않으니 SSE(서버가 한 방향으로 이벤트를 흘려보내는 연결)가 열린 채로 남고, 오너 화면은 진행 중 상태에서 멈췄다.

마지막 응답 이후 얼마나 지났는지를 기준으로 타임아웃을 뒀다. 응답이 올 때마다 타이머를 갱신하므로 오래 걸려도 계속 응답하는 편집은 종료하지 않는다. 타이머가 만료되거나 정지한 뒤의 touch는 무시한다. 늦게 도착한 stdout 때문에 종료 처리가 두 번 실행되는 것을 막기 위해서다.

두 아이디어 모두 putree에서 가져왔다(AGENT_TIMEOUT_MS, buildClaudeEnv). 가져온 코드의 출처는 커밋에 적었다.

기존 동작을 유지하면서 엔진을 분리했다

dev-ai.ts에서 spawnstream-json 파싱을 직접 처리하고 있었다. 이 구현을 분리하고 EditEngine이라는 port로 호출하게 했다. port/어댑터 패턴은 구현을 갈아 끼울 수 있게 인터페이스를 경계로 세우는 방식이다.

port를 구현하는 어댑터가 지켜야 할 조건도 정했다.

  • cwd 밖에 쓰지 않는다
  • 셸을 쓰지 않는다
  • 정확히 한 번 resolve한다
  • 응답이 끊기면 실행을 종료한다

지키는 방법은 어댑터마다 다르다. engine-cli.ts가 첫 어댑터고, engine-fake.ts가 테스트용이다.

dev-ai.ts에서 219줄을 덜어냈지만 판정 로직은 그대로 뒀다. 기존 테스트 703개가 모두 통과해야 리팩터링을 마친 것으로 봤다.

판정 로직도 고치고 싶었지만 다음 작업으로 미뤘다. 함께 바꾸면 문제가 생겼을 때 리팩터링 때문인지 판정 변경 때문인지 구분하기 어려웠다.

타입과 연결 종료 처리도 함께 정리했다.

  • EngineEvent는 SSE의 AgentEvent와 다른 타입이다. 어댑터에서 SSE 형식을 직접 사용하면 엔진 구현이 화면에 보내는 이벤트 형식에 의존하게 된다. 두 타입 사이의 변환은 dev-ai.ts에서 맡는다.
  • req.on('close')await 앞으로 옮겼다. 뒤에 두면 실행이 끝난 다음에야 붙어서, 오너가 창을 닫아도 신호가 가지 않는다.

parseLineengine-cli.ts로 갔지만 dev-ai.ts가 다시 내보낸다. 기존 호출부의 import 경로를 바꾸지 않기 위해서다.

연결 종료 이벤트는 여전히 동작하지 않았다

다음 날 확인해 보니 await 앞으로 옮긴 req.on('close')는 동작하지 않았다. 이벤트 대상과 리스너를 붙인 시점에 각각 문제가 있었다.

이벤트 대상이 틀렸다. Node v16부터 IncomingMessage'close'는 소켓이 닫힐 때가 아니라 요청이 완료될 때 난다. 응답 완료 전 연결 단절을 알리는 것은 ServerResponse'close'다.

그래서 await 앞으로 옮긴 것으로도 부족했다. dev-ai.ts:254readBody(req)for await로 스트림을 끝까지 소비하고, 리스너는 그 뒤인 315행에 붙는다. 'close'는 이미 지나갔다.

같은 순서를 재현한 서버에서 확인했다. 클라이언트가 abort했을 때 res'close'만 나고 req'close'는 4초 동안 한 번도 나지 않았다. aborted.abort()는 도달하지 않는 코드였다. 이때 멈춘 실행을 끊는 것은 앞서 붙인 무응답 타이머뿐이었다. 창을 닫아도 즉시 종료되지 않았다.

고쳤다. 바꾼 것은 reqres 하나다. readBody 앞으로 더 옮길 필요는 없었다. 254~315행 사이에 await가 없어서(전부 동기 fs) 그 구간에 이벤트 루프가 돌지 않고, res'close'는 리스너보다 먼저 날 수 없다. 위치는 그대로 두는 게 맞다.

대신 if (settled) return 가드가 필요하다. res'close'는 정상 end() 뒤에도 나므로, 없으면 매 실행 끝에 이미 끝난 엔진을 향해 abort가 한 번 더 나가고 되돌릴 것을 지운다.

이 저장소에서는 "배선은 실제 요청이 답한다"는 규칙을 두고 있었다. 그런데도 잘못된 연결 종료 처리가 검증을 통과했다. 이번에는 실제로 연결을 끊는 테스트를 추가했다. port를 세우면서 엔진을 주입 가능하게 만들어 둔 덕에 실제 CLI 없이 진짜 http 서버에 미들웨어를 얹고 클라이언트를 끊어 볼 수 있었다. 돌연변이 테스트(코드를 일부러 한 군데씩 바꿔 테스트가 오류를 잡는지 보는 방식)로도 확인했다. resreq로 되돌렸을 때와 가드를 지웠을 때 각각 테스트 1건이 실패했다.

물어보면 답한다

이제 엔진이 실행 결과를 표시하도록 바꿨다. 프롬프트에서 [완료] / [답변] / [확인 필요] 중 하나를 붙이도록 했다. 서버는 그 선언으로 판정한 뒤 변경 목록으로 검증한다. 고쳤다는 선언과 답만 했다는 선언이 각각 이렇게 갈린다.

flowchart TD
    A["엔진이 마커로 선언"] --> B{"어느 마커인가"}
    B -->|"완료"| C{"디스크에 변경이 있나"}
    B -->|"답변"| D{"파일이 바뀌었나"}
    C -->|"있다"| E["성공"]
    C -->|"없다"| F["실패"]
    D -->|"안 바뀜"| G["답변으로 전달"]
    D -->|"바뀜"| H["되돌리고 그 사실을 알린다"]

이렇게 나누면 모델이 답변만 했다고 표시해도 실제 파일 변경을 확인해 되돌릴 수 있다.

마커만 한 줄에 쓰거나, 줄 맨 앞에 마커를 쓰고 바로 뒤에 본문을 붙인 경우를 모두 인식하게 했다. [답변]은 본문과 같은 줄에 나오는 경우가 가장 흔했다. 마커만 따로 쓴 줄만 인식하면 답변을 놓치는 버그가 그대로 남는다. 문장 중간에 나오는 마커는 실행 결과를 표시한 것으로 보지 않는다.

마커를 못 찾고 변경도 없으면 모델의 원문을 그대로 내보낸다. 마커를 놓쳐도 오너가 답변을 읽을 수 있게 했다.

상태 설계에서는 answereddone과 갈랐다. done에는 변경을 되돌릴 스냅샷이 있지만 답변에는 되돌릴 것이 없다. 같은 상태로 처리하면 답변에도 되돌리기 버튼이 표시된다. 변경을 승인할 필요도 없으므로 review 상태로 보내지 않는다.

실제 CLI로 확인했다. 같은 요청을 보내니 파일 경로나 선택자 대신 화면에 보이는 내용을 설명하는 답변이 나왔다.

질문할 수 있다는 안내도 화면에 추가했다

질문 기능은 동작했지만 화면에는 편집 안내만 남아 있었다. 입력창 힌트가 "Enter로 적용", 예시가 "제목을 더 짧게", 안내가 "변경할 수 있어요". 이 안내만 보면 질문도 할 수 있다는 것을 알기 어려웠다.

"적용"은 편집만 뜻하므로 "보내기"로 바꿨다. 예시는 고치는 것과 묻는 것을 나란히 보여준다. 두 파일 13줄짜리 변경인데, 질문 기능이 있다는 것을 사용자가 알게 하려면 이 안내도 필요했다.

계약이 어긋난 순간을 드러낸다

provider.ts가 SSE 프레임을 JSON.parse(...) as AgentEvent로 캐스팅하고 있었다.

AgentEvent는 브라우저와 개발 서버에 두 벌 있다. server/가 앱 번들 밖에서 실행돼 따로 정의했지만, 한쪽만 수정하면 두 타입이 달라져도 알아채기 어렵다. 서버에서 보낸 이벤트를 화면에서 처리하지 못하면 reduceJobdefault 분기로 넘어가 작업이 진행 중 상태에서 멈춘다. 이제는 이런 이벤트가 오면 터미널에 기록하고 해당 프레임을 버린다.

protocol.ts의 검증과는 목적이 다르다. 그쪽은 아무 창에서나 오는 postMessage를 막는 방어이고, 이쪽은 우리 서버의 same-origin SSE라 계약이 어긋난 순간을 드러내는 장치다. 나중에 목적이 다른 두 검증을 합치지 않도록 커밋 본문에 이유를 남겼다.

prompt-fragments.ts를 붙이기 전에 프롬프트에 마크업부터 넣기로 했다. 답변 품질에 직접 영향을 주는 작업이라 먼저 하기로 했고, 계획을 바꾼 이유도 기록했다.

엔진에게 무엇을 알려줄 것인가

판정이 정리되니 남은 문제는 답의 품질이었다. 프롬프트에 가던 것은 selectorlabel뿐이었다.

프롬프트에 마크업을 함께 넣기로 했다. 엔진은 "선택한 영역"이라는 이름과 CSS 경로만 받고, 무엇이 들어 있는지 알려면 사이트를 뒤졌다. 답이 늦고, 넓게 읽는 만큼 비용이 든다. 실측으로 Glob+Read 여러 번이 Read 한 번으로 줄었다.

브릿지가 아니라 디스크에서 읽는다.

  • 엔진이 고치는 것은 파일이다. 브라우저의 DOM은 렌더 뒤의 모습이라 스크립트가 만든 것이 섞인다. 그것을 고치라고 하면 파일에 없는 것을 찾는다.
  • cross-origin으로 임의의 HTML을 실어 보내는 통로를 만들지 않는다.
  • 남의 사이트에 얹히는 브릿지에 일을 더하지 않는다.

삽입 요청에는 마크업을 넘기지 않는다. 그때 selector는 기준 컨테이너이고, 그 안을 다 보여주면 엔진이 넣는 대신 고치려 든다.

계산된 스타일이 아니라 규칙이다

마크업만 넘겨서 실행해 보니 엔진은 "스타일이 전혀 없다"까지만 말하고 값은 말하지 못했다. 규칙을 함께 주니 답이 8px 간격, 55% 반투명, 600px 이하에서 10px→6px처럼 실제 값으로 바뀌었다.

파일을 수정하려면 어떤 CSS 규칙을 바꿔야 하는지 알아야 한다. 브라우저에서 계산된 스타일 값을 가져오려면 브릿지에 전달 기능을 추가해야 하는데, 그 값만으로는 수정할 규칙을 찾기 어렵다.

그래서 CSS를 직접 파싱하지 않고 문서에 심어 CSSOM으로 읽는다. CSSOM은 브라우저가 파싱해 둔 스타일시트를 객체로 읽는 API다. 규칙 문법은 우리가 다시 구현할 것이 아니고, 직접 파싱하다 잘못 처리하면 엔진에 틀린 규칙을 넘길 수 있다.

@media 안까지 CSSMediaRule로 들어가 조건과 함께 담는다. 반응형이 빠지면 "왜 모바일에서 다르게 보이나"에 답할 수 없다. 영역 밖의 요소에만 적용되는 규칙은 빼고, 조상을 거쳐 안쪽 요소에 적용되는 것(.hero .orb)은 그 요소의 모양을 정하므로 담는다.

둘 다 상한이 있다. 마크업 4000자, CSS 3000자. 넓은 선택자가 있으면 사이트 CSS가 거의 다 걸린다. 호스티드 티어에서는 이 토큰 비용을 Pointly가 부담한다.

아직 반영하지 못한 곳도 있다. /previewagent-sim.tsanswered를 내지 않는다. 로그인 전 방문자는 이날 만든 기능을 볼 수 없다.

한 번도 정한 적 없던 것

AI 접근 정책이 정해진 적이 없었다. 그동안은 로컬 claude CLI를 운영자 로그인으로 띄우는 한 경로뿐이었다. 로컬 개발 서버에서는 잘 돌았고, 그래서 아무도 묻지 않았다. 어제 엔진을 port 뒤로 넣으면서 "호스티드 어댑터"를 3단계로 적어 두긴 했는데, 정작 그 티어에서 AI를 어떻게 쓰는지는 비어 있었다.

Anthropic 문서에는 이 방식을 금지한다고 나와 있었다.

Unless previously approved, Anthropic does not allow third party developers to offer claude.ai login or rate limits for their products, including agents built on the Claude Agent SDK.

or rate limits가 핵심이다. 로그인 화면을 안 붙이면 되는 문제가 아니라, 구독의 사용 한도를 자기 제품 사용자에게 되파는 것이 금지 대상이다. 운영자 계정으로 CLI를 띄워 오너의 편집을 처리하는 것이 정확히 그 모양이다.

그래서 티어를 둘로 갈랐다.

티어실행 위치AI 접근Pointly 원가과금
cli개발자 머신(셀프호스트)개발자가 설치·로그인한 CLI0무료 또는 시트
hostedPointly 호스팅Pointly의 Anthropic API 키토큰구독료·크레딧

같은 코드라도 어디서 도는지가 허용 여부를 바꾼다. cli는 개발자 머신에서만 성립한다. Pointly 서버에서 남의 OAuth 토큰으로 CLI를 띄우면 그것이 로그인 중개가 되어 그 금지에 걸린다.

똑같은 engine-cli.ts가 한쪽에서는 정상이고 다른 쪽에서는 위반이다. 이건 코드로 표현할 수 없는 종류의 제약이라, 결정 로그에 남겼다.

안 하기로 한 것: BYOK

개발자가 자기 API 키를 호스팅에 붙이는 것은 넣지 않는다. 이를 지원하려면 키 저장·암호화·유출 방지·회수까지 관리해야 한다. 자기 키로 쓰려는 개발자는 대부분 cli로도 사용할 수 있다고 봤다.

요청이 실제로 오면 그때 켠다. 새 port가 아니라 호스티드 어댑터 안의 키 출처 문제이므로 나중에도 붙일 수 있다. 다만 그때는 키가 프롬프트·로그·에러 detail로 새는 경로를 다시 훑어야 한다.

이번에 넣지 않은 이유와 나중에 추가할 때 확인할 사항을 함께 적어 뒀다.

설정값으로 실행 티어를 고르게 했다

POINTLY_ENGINE으로 티어를 고른다. 53줄짜리 순수 함수를 만들고 서버가 시작할 때 호출하게 했다. 결정은 세 개인데, 셋 다 기본값을 어디에 두느냐의 문제였다.

기본값은 cli다. 이 저장소의 유일한 런타임이 로컬 개발 서버이므로, hosted를 기본으로 두면 다음 pnpm dev에서 개발 서버가 실행되지 않는다. 별도 설정 없이 실행되는 것도 직접 확인했다.

지원하지 않는 설정값이 들어오면 기본값으로 대체하지 않는다. hosted로 띄우려던 배포가 오타 하나로 조용히 개발자 모드가 되면, 운영자 로그인으로 오너 편집을 처리하는 금지된 모양이 된다. byok도 마찬가지다. 지원하지 않는 값이므로 오류로 처리한다. 다른 모드로 실행되면 설정이 잘못됐다는 사실을 알기 어렵다.

설정이 잘못됐으면 서버를 시작할 때 오류를 낸다. POINTLY_ENGINE=hosted인데 ANTHROPIC_API_KEY가 없으면 그 자리에서 멈춘다. 서버를 그대로 띄우면 오너는 편집할 때마다 실패를 겪고, 개발자는 로그에서 원인을 찾아야 한다. API 키가 없다는 것을 시작 단계에서 알려 개발자가 설정을 고치게 했다.

설정 오류도 타입에 포함했다.

export type EngineMode =
  | { mode: 'cli' }
  | { mode: 'hosted' }
  | { mode: 'unusable'; reason: string }

설정이 잘못된 경우를 unusable 모드로 표현했다. 예외만 던지면 호출부에서 오류 처리를 빠뜨릴 수 있다. 유니온 타입에 포함하고 switchdefault에서 never로 검사하면, 처리하지 않은 모드가 있을 때 타입 오류가 난다.

미터링은 ROADMAP.md Phase 2, 호스티드 어댑터(engine-agent-sdk.ts)는 설계서 3단계에 남아 있다.

9/3의 주제는 "누가 무엇을 알아야 하는가"였다.

  • 자식 프로세스는 npm 토큰을 몰라야 한다
  • 어댑터는 디스크를 몰라야 한다
  • 엔진은 화면(SSE 모양)을 몰라야 한다
  • 서버는 성공 여부를 혼자 정하지 말아야 한다
  • 대신 엔진은 마크업과 CSS 규칙을 알아야 한다

각 모듈에 필요한 정보만 넘기도록 정리했다. 9/4에는 그 엔진을 누구의 계정으로 실행할지 정했다.

전날 상한 4000/3000자를 걸면서 "그 토큰은 호스티드 티어에서 Pointly가 낸다"고 적었다. 티어를 정하고 나니 이 상한이 운영 비용과도 연결된다는 점이 분명해졌다.

순서가 재밌다. 나는 엔진을 port 뒤로 넣고, 판정을 옮기고, 문맥을 싣고 나서야 "그런데 이건 누구 계정으로 도는 거지"를 물었다. 그 질문이 먼저 왔다면 전날 코드의 절반은 다르게 생겼을지도 모른다. 다행히 port가 있어서 티어는 그 뒤에 갈아 끼우는 문제로 남았지만, port를 만든 이유 자체가 이 질문이었다는 걸 생각하면 운이 좋았다.

틀린 채로 적혀 있던 서술 둘을 찾은 것도 일지를 쓰는 도중이었다. 커밋 메시지를 다시 읽으면서 문장으로 옮기려니 어긋난 자리가 보였다.

AI 접근 방식을 정할 때는 코드뿐 아니라 약관도 확인해야 했다. 이번에는 로그인 정보의 저장 위치와 연결 종료 이벤트를 잘못 설명한 부분도 발견했다. 결정 이유를 기록하되, 실제 동작과 맞는지 다시 확인할 필요가 있었다.