별도 /edit 없이, 같은 URL에서 편집 모드로
보던 화면을 그대로 편집한다 — 그런데 로그인 세션은 다른 도메인에 있었어요. 복제 화면 2,000줄을 만들었다 지우고, __Host- 쿠키와 게이트웨이로 같은 주소에서 편집 모드를 켠 기록입니다.

글을 하나 고치려고 편집 화면을 열었다가, 묘하게 낯선 화면과 마주쳤어요. 같은 글인데 같은 화면이 아니었죠.
이 플랫폼의 제품 철학 중 하나는 "보고 있는 사이트를 그대로 편집한다"예요. 관리자 페이지로 건너가서 목록에서 글을 찾는 방식 말고, 읽던 그 자리에서 바로 고치는 것.
그런데 배포하고 나니 벽이 있었습니다.
두 개의 도메인 문제
| 주소 | 가진 것 | |
|---|---|---|
| 플랫폼(관리) | arcacore.com | 로그인 세션 |
| 블로그(공개) | blog.arcacore.com | 고치고 싶은 글 |
브라우저는 당연히 다른 origin의 세션을 읽게 해주지 않아요. 보안상 그게 맞고요.
(옆 건물 출입증으로 이 건물 문이 안 열리는 것과 같아요.)
그러니까 blog.arcacore.com은 "지금 이 사람이 로그인했는지"를 알 방법이 없었습니다.
첫 번째 시도 — 복제 화면
급한 대로 플랫폼 안에 편집 전용 화면을 만들었어요. 플랫폼 도메인 안이니 세션 문제가 없고, 권한 경계도 이미 있었거든요.
동작은 했습니다. 그런데 곧 근본적인 문제가 보였어요.
편집 화면이 실제 공개 사이트와 분리된 복제 화면이 된 거예요. 공개 사이트가 바뀔 때마다 편집 화면도 같이 고쳐야 했습니다. 안 고치면 둘이 서서히 달라지고요.
그리고 이건 시간이 갈수록 나빠지는 종류의 부채예요. 화면이 늘어날수록 복제할 것도 늘어나니까요.
결국 이 방향을 접고 편집 라우트와 관련 코드 약 2,000줄을 통째로 지웠습니다.
(같은 날 오후에 공들여 복구한 코드를 두 시간 만에 지웠어요. 네, 아까웠습니다. 그래도 복제 화면을 계속 끌고 가는 비용보단 쌌어요.)
다른 길들도 다 위험했어요
| 방법 | 왜 안 했나 |
|---|---|
| 토큰을 URL로 넘기기 | 주소창·기록·리퍼러에 남음 |
| postMessage로 토큰 전달 | 받는 쪽 검증이 조금만 느슨해도 유출 |
| 도메인 공유 쿠키 | 하위 도메인 전체가 토큰을 봄 |
| 공개 페이지에 편집기 통째로 싣기 | 익명 방문자가 편집기 코드를 다운로드 |
앞의 셋은 공통점이 있어요. 토큰의 노출 범위가 넓어집니다. 편의를 위해 열쇠를 여러 곳에 복사하는 셈이죠.
결론 — 주소는 그대로, 세션만 옮긴다

별도 편집 주소를 만들지 않기로 했어요. 주소는 "어떤 콘텐츠인가"만 말하고, 편집 여부는 그 호스트에 있는 세션과 권한이 정합니다.
그래서 필요한 건 하나였어요. 플랫폼의 로그인 상태를 블로그 호스트의 세션으로 한 번만 옮기는 것.
쿠키 이름이 곧 정책입니다
const authoringCookieName = "__Host-arcac-authoring";
const authoringRefreshCookieName = "__Host-arcac-authoring-refresh";`__Host-` 접두사가 붙으면 브라우저가 규칙을 강제해요.
| 브라우저가 강제하는 것 | 막는 사고 |
|---|---|
| Secure 필수 | 평문으로 새지 않음 |
| Domain 지정 불가 (host-only) | 다른 하위 도메인이 못 봄 |
| Path=/ 고정 | 경로를 좁혀서 헷갈리게 만들 수 없음 |
규칙을 서버 코드가 아니라 쿠키 이름에 넣은 거예요. 우리가 실수해도 브라우저가 거절합니다.
설치는 좁게, 확인은 넓게
쿠키를 심는 경로와 상태를 확인하는 경로를 다르게 뒀어요.
| 설치 (POST) | 확인 (GET) | |
|---|---|---|
| 누가 부르나 | 플랫폼 origin에서만 | 아무나 |
| 필요한 것 | 토큰 | 쿠키 |
| 검증 실패 시 | 거절 | 만료 쿠키를 내려 스스로 정리 |
마지막 줄이 실무적으로 중요했어요. 죽은 세션이 남아 있으면 "로그인한 것 같은데 아무것도 안 되는" 상태가 되거든요. 그래서 확인 요청이 검증에 실패하면 조용히 쿠키를 지웁니다.
(실제로 이 글을 쓰는 세션에서도 만료를 만났어요. 설치는 플랫폼 origin에서만 되기 때문에, 블로그 쪽에서 직접 심으려던 시도가 403으로 막혔습니다. 설계대로 동작한 거죠.)
익명 방문자에게 편집기 코드는 0바이트
성능 쪽 원칙은 하나였어요. 익명 방문자의 초기 다운로드에 편집기 코드가 1바이트도 들어가면 안 된다.
그래서 편집기를 별도 앱으로 빌드하고, 세션이 확인된 뒤에만 불러옵니다.
// 익명 방문자가 받는 것
공개 페이지 HTML + 공개 런타임 JS
세션 확인 GET 1회 (쿠키 없으면 즉시 끝)
// 로그인한 사람이 추가로 받는 것
편집기 번들 (그때 처음 요청)그리고 편집기가 로드돼도 새 페이지로 이동하지 않아요. 지금 그려진 화면 위에 편집 모드를 얹습니다. 그래서 읽던 위치도, 스크롤도 그대로예요.
세션을 어떻게 넘기나 — 실제 흐름
추상적으로만 말했으니 실제 순서를 적을게요.
1. 사용자가 arcacore.com 에 로그인 → 플랫폼 도메인에 세션 생성
2. blog.arcacore.com 방문
3. 블로그가 세션 확인 GET 호출 → 쿠키 없음 → 익명으로 렌더
4. 사용자가 플랫폼에서 "이 사이트 편집" 진입
5. 플랫폼 origin 에서 설치 POST 호출 → __Host- 쿠키 심김
6. 이제 블로그의 세션 확인 GET 이 통과 → 편집기 로드5번이 이 설계의 관문이에요. 이 요청은 플랫폼 origin에서 온 POST만 통과합니다. 다른 곳에서 부르면 거절돼요.
그래서 공격자가 사용자를 유인해서 쿠키를 심게 만들 수 없습니다. 심으려면 이미 플랫폼에 로그인해 있어야 하니까요.
토큰은 쿠키로만 삽니다
한 가지 더 신경 쓴 게 있어요. 이 토큰은 자바스크립트가 읽을 수 없습니다.
HttpOnly로 심기 때문에 `document.cookie`로 안 보여요. 그래서 페이지에 스크립트 하나가 잘못 들어와도 토큰을 훔쳐갈 수 없습니다.
대신 불편한 점이 있어요. 우리 코드도 토큰 값을 못 읽습니다. 그래서 "지금 로그인 상태인가"를 알려면 매번 서버에 물어봐야 해요. 그 요청이 앞에서 말한 세션 확인 GET입니다.
(읽을 수 없는 열쇠가 안전한 열쇠예요. 우리도 못 읽는 게 정상입니다.)
그리고 게이트웨이가 하는 일
블로그 호스트에는 인증 서버가 없어요. 그럼 이 쿠키를 누가 검증할까요.
const authoringRootPath = "/__arcac/authoring";
const authoringSessionPath = `${authoringRootPath}/session`;
const authoringApiSitePrefix = `${authoringRootPath}/api/sites/`;공개 Worker에 이 경로들이 있어요. 여기로 오는 요청은 게이트웨이가 받아서 API로 넘깁니다.
그러니까 블로그 호스트는 저작 기능을 구현하지 않고 전달만 해요. 권한 판정도, 데이터 변경도 전부 API 쪽에서 일어납니다.
이 구조 덕에 사이트가 몇 개로 늘어나도 저작 기능은 한 벌이에요. 캘린더도 같은 경로를 씁니다.
접두사가 왜 __arcac 인가
사소해 보이는 결정 하나를 적어둘게요. 저작용 경로에 전부 같은 접두사를 붙였어요.
이유는 충돌 방지예요. 이 플랫폼 위에는 어떤 사이트든 올라올 수 있고, 그 사이트가 `/session`이나 `/api` 같은 경로를 쓰고 싶을 수 있거든요.
플랫폼이 쓰는 경로를 한 접두사 아래로 모아두면, 사이트가 쓸 수 있는 주소 공간이 그만큼 넓어집니다. 플랫폼은 자기 자리를 최대한 좁게 차지해야 해요.
숨긴 버튼은 보안 경계가 아닙니다
여기서 착각하기 쉬운 게 있어요. 편집 버튼이 안 보이면 안전한 것 같잖아요.
아니에요. 화면에 버튼이 없어도 요청은 만들 수 있습니다. 그래서 이 플랫폼의 불변 조건 중 하나가 이거예요.
숨긴 버튼은 보안 경계가 아니다.
최종 권한 판정은 여전히 서버의 명령 경계에서 일어납니다. 쿠키는 그 판정을 요청할 자격일 뿐이고, 판정 자체가 아니에요.
그래서 편집 UI가 떠 있어도 권한이 없으면 저장이 거절됩니다. 화면은 편의고 경계는 서버에 있습니다.
치른 값
| 불편해진 것 | 실제로 겪은 일 |
|---|---|
| 호스트마다 세션 핸드셰이크가 따로 | 블로그와 캘린더에 각각 심어야 함 |
| 로그아웃도 호스트별 | 한 곳에서 나가도 다른 곳은 남아 있음 |
| 세션 만료가 작업 중에 옴 | 60분 무입력이면 끊김 — 재핸드셰이크 필요 |
마지막 줄은 이 글을 쓰는 동안에도 만났어요. 긴 작업 중에 세션이 끊기면 다시 심어야 합니다. 편의를 위해 수명을 늘리고 싶은 유혹이 있는데, 그건 그냥 위험을 늘리는 거라 안 했어요.
후일담 — 편집은 목록으로도 번졌습니다
같은 주소 원칙이 상세 화면에서 멈추지 않았어요.
이제 글 목록과 사이드바에서도 마우스를 올리면 편집 장치가 나오고, 시리즈는 행을 끌어서 순서를 바꿉니다.
물론 공개 방문자에게는 이 장치들이 아예 렌더링되지 않아요. 보이느냐는 화면이 아니라 권한 플래그가 정합니다.
(지금 보고 계신 이 글도, 제가 로그인하면 그 자리에서 바로 편집돼요.)
쓰는 화면은 확보됐어요. 다음 문제는 그 안에서 굴러가는 에디터였습니다. TipTap을 플랫폼 밖으로 한 발짝도 못 나가게 가둔 이야기 — 다음 편이에요.


