"지난 글에서 썼듯이"가 진짜 링크가 되기까지 — 내부 링크와 용어 버블
"지난 글에서 썼듯이"를 쓸 때마다 독자에게 숙제를 내고 있었어요. 그래서 링크가 URL이 아니라 글 자체를 가리키게 만들었습니다. 주소가 바뀌어도 안 깨지는 링크와, 그 자리에서 열리는 용어 버블 이야기예요.

"지난 글에서 썼듯이"라고 쓸 때마다 사실 좀 찔렸어요. 독자에게 숙제를 내고 있었거든요. 그 지난 글, 알아서 찾아보세요 — 하고요.
그래서 링크를 달기로 했습니다. 그런데 여기서 예상 못 한 문제를 만났어요.
제 블로그의 주소는 못 믿을 것이었습니다
이 블로그 글들의 주소를 보면 이렇게 생긴 게 많아요.
/posts/new-post-1783998622269초기에 발행하면서 자동 생성된 slug가 그대로 남은 거예요. 언젠가 정리하고 싶었죠.
그런데 URL로 링크를 걸어두면 정리하는 순간 링크가 전부 깨집니다. 그러면 영영 정리를 못 하게 되고요.
실제로 이 글을 쓰는 동안에도 주소를 두 개 바꿨어요. 회사 이름이 slug에 들어간 걸 발견해서였는데, 그때 내부 링크가 URL 기반이었다면 손으로 다 찾아 고쳐야 했을 겁니다.
(주소는 사람이 보는 이름이고, 이름은 바뀝니다. 바뀌는 걸 참조 키로 쓰면 안 돼요.)
그래서 링크가 글 자체를 가리킵니다
저장되는 건 주소가 아니라 그 글의 불변 식별자예요.
// 외부 링크 — 주소를 그대로 저장
{ type: "link", href: "https://example.com" }
// 내부 링크 — 무엇을 가리키는지만 저장
{ type: "link", target: { typeId: "blog.post", resourceId: "content-..." } }그리고 렌더링 시점에 현재 주소로 번역합니다. 주소가 바뀌어 있으면 바뀐 주소가 나와요.
typeId가 같이 들어가는 것도 이유가 있어요. 글만 가리키는 게 아니라 시리즈나 카테고리도 가리킬 수 있거든요.
| typeId | 번역되는 주소 |
|---|---|
| blog.post | /posts/{slug} |
| series.collection | /series/{slug} |
| taxonomy.term | /posts/folders/{slug} |
깨진 참조를 어떻게 다루나
참조 방식에는 새로운 위험이 있어요. 가리키는 글이 없어지면요?
그래서 계약 함수를 이렇게 만들었습니다.
export const richTextLinkTarget = {
optional: {
/** typeId·resourceId가 모두 유효할 때만 target을 돌려준다. */
get(value: unknown): RichTextLinkTarget | undefined {
if (!isRecord(value)) return undefined;
const { typeId, resourceId } = value;
if (typeof typeId !== "string" || typeId.trim().length === 0) return undefined;
if (typeof resourceId !== "string" || resourceId.length === 0) return undefined;
return { typeId, resourceId };
},
},
};이름이 optional인 게 핵심이에요. 유효하지 않으면 undefined를 돌려주지 예외를 던지지 않습니다. 링크 하나가 깨졌다고 글 전체가 안 뜨면 안 되니까요.
그래서 깨진 참조는 링크가 아니라 그냥 글자로 렌더링됩니다. 독자는 클릭이 안 될 뿐 글은 읽을 수 있어요.
이건 일반적인 원칙이기도 해요. 장식이 깨졌다고 본문이 죽으면 안 됩니다.
링크를 어떻게 거나 — 만드는 쪽 이야기
설계는 깔끔한데, 실제로 글을 쓰면서 링크를 거는 경험이 나쁘면 아무도 안 씁니다. 저부터요.
그래서 이렇게 만들었어요. 텍스트를 선택하면 뜨는 도구 모음에서 링크를 고르고, 팝오버에서 글을 검색합니다. 주소를 붙여넣는 게 아니라 글을 고르는 거예요.
여기서 검색이 필요해지죠. 그리고 마침 ⌘K 검색을 만들면서 준비된 게 있었어요. 두 기능이 같은 조회를 씁니다.
(기능을 따로 만들었는데 재료가 겹치는 경우가 종종 있어요. 그럴 때 재료를 공유하면 둘 다 좋아집니다.)
그런데 마크가 겹치면 깨졌어요
실제로 만난 버그를 적어둘게요. 리치 텍스트에서 마크는 텍스트 조각에 붙습니다. 그래서 이런 상황이 생겨요.
// 문장 하나에 하이라이트가 걸려 있는 상태
{ text: "이 설계가 실제로 값을 했어요", marks: [{ type: "animation", preset: "highlight-swipe" }] }
// 여기서 "설계" 부분에만 링크를 걸면?
{ text: "이 ", marks: [{ animation }] }
{ text: "설계", marks: [{ animation }, { link }] } // ← 조각남
{ text: "가 실제로...", marks: [{ animation }] }하이라이트가 세 조각으로 나뉘면서 애니메이션이 따로 놉니다. 한 문장을 쓸어 넘기는 효과인데 세 번 나눠 실행되는 거죠.
그래서 규칙으로 막았어요. 애니메이션이 걸린 문장에는 링크를 걸지 않는다. 기술로 풀 수도 있었지만, 이건 원래 겹칠 필요가 없는 두 장식이거든요.
SSR이 링크를 해석합니다
저장된 참조가 실제 주소로 바뀌는 시점이 언제인지도 결정이 필요했어요.
| 시점 | 문제 |
|---|---|
| 저장할 때 주소로 변환 | 결국 URL을 저장하는 것과 같아짐 |
| 브라우저에서 변환 | 자바스크립트 오기 전엔 링크가 안 보임 — 크롤러도 못 봄 |
| 서버 렌더링에서 변환 | 매 요청마다 조회가 필요 ← 선택 |
세 번째를 골랐습니다. 서버가 HTML을 만들 때 참조를 현재 주소로 바꿔요.
<a href="/posts/arcac-blog-search" data-arc-internal-link="true">…</a>그래서 자바스크립트 없이도 링크가 동작하고, 검색 엔진도 연결 관계를 봅니다. 대신 렌더링할 때 참조를 푸는 비용이 붙어요.
그 비용은 나중에 공개 페이지 성능을 손볼 때 다시 만났습니다. 조회를 묶어서 줄이는 쪽으로 풀었어요.
그리고 반대 방향의 도구
링크는 독자를 다른 글로 보내는 도구예요. 그런데 보내고 싶지 않은 경우가 있습니다.
본문에 전문 용어가 나왔는데, 설명하자니 흐름이 끊기고 안 하자니 모르고 지나가는 상황이요.
그래서 그 자리에서 열리는 설명 버블을 만들었어요. 링크와 정반대 성격이죠. 링크는 내보내고 버블은 붙잡습니다.
버블에도 규칙이 필요했어요
처음엔 아무 용어에나 달았다가 금세 지저분해졌습니다. 그래서 기준을 정했어요.
| 규칙 | 이유 |
|---|---|
| 글당 최대 6개 | 많으면 본문이 밑줄투성이가 됨 |
| 첫 등장 한 곳만 | 같은 단어에 반복되면 읽기 방해 |
| 본문에서 이미 설명한 용어엔 금지 | 두 번 말하는 셈 |
| 링크·애니메이션 걸린 텍스트엔 금지 | 마크가 겹쳐서 표시가 깨짐 |
마지막 줄은 실제로 겪은 버그예요. 하이라이트가 걸린 문장 일부에 버블을 달았더니 하이라이트가 조각나서 이상하게 보였습니다.
버블 문장에도 규칙을 뒀어요
설명 문장 자체에도 기준이 필요했습니다. 안 그러면 사전처럼 딱딱해지거든요.
| 금지 | 대신 |
|---|---|
| "X는 ~를 뜻해요" | 그 용어가 하는 일을 말하기 |
| 버블 안에서 용어를 주어로 반복 | 바로 설명으로 들어가기 |
| 모든 버블이 같은 각도 | 하는 일 · 쓰임새 · 비유 · 성질 · 존재 이유를 번갈아 |
세 번째가 은근히 중요했어요. 다섯 개 버블이 전부 "~하는 기술이에요"로 끝나면 읽는 리듬이 죽습니다.
그리고 일괄 연결
기능을 만들고 나서 기존 글을 훑었어요. "지난 글에서", "앞에서 다룬" 같은 표현이 꽤 있었거든요.
17개 글에서 27곳을 찾아 내부 링크로 바꿨습니다. 그러면서 안 바꾼 것들도 있어요.
| 연결하지 않은 것 | 이유 |
|---|---|
| 자기 자신을 가리키는 참조 | 클릭하면 제자리 |
| "앞의 글들" 같은 모호한 참조 | 어느 글인지 특정 불가 |
| 아직 발행 안 된 글 | 깨진 링크가 됨 |
| 애니메이션 마크가 걸린 문장 | 마크가 조각남 |
나중에 진짜로 값을 했어요
이 기능의 값어치는 만든 날이 아니라 한참 뒤에 나왔습니다.
시리즈를 5개에서 13개로 재편하면서 글 순서와 소속을 전부 바꿨을 때예요. 시리즈 주소도 바뀌었고, 일부 글의 slug도 바뀌었어요.
그때 본문 링크는 한 곳도 안 고쳤습니다. 글을 가리키고 있었으니까요.
반대로 URL을 직접 적었던 두 곳은 손으로 찾아 고쳐야 했어요. 그 두 곳이 이 설계의 대조군이 된 셈입니다.
참조는 이름이 아니라 정체성을 가리켜야 해요. 이름은 언젠가 바뀌고, 바뀌는 게 정상이거든요.
글끼리는 이어졌어요. 남은 문제는 처음 온 사람입니다. ⌘K와 시리즈 페이지, 찾아오는 세 가지 문 — 다음 편이에요.


