전체 검색

글, 태그와 카테고리를 검색하고 바로 이동해요

"지난 글에서 썼듯이"가 진짜 링크가 되기까지 — 내부 링크와 용어 버블

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

2026. 07. 16. 15:10:355분 읽기프로젝트/arcaC/기술 노트
#내부 링크#TipTap#rich-text
조회 41
글 사이를 잇는 실과 용어 위 설명 말풍선 일러스트

"지난 글에서 썼듯이"라고 쓸 때마다 사실 좀 찔렸어요. 독자에게 숙제를 내고 있었거든요. 그 지난 글, 알아서 찾아보세요 — 하고요.

그래서 링크를 달기로 했습니다. 그런데 여기서 예상 못 한 문제를 만났어요.

제 블로그의 주소는 못 믿을 것이었습니다

이 블로그 글들의 주소를 보면 이렇게 생긴 게 많아요.

/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와 시리즈 페이지, 찾아오는 세 가지 문 — 다음 편이에요.

시리즈 이어읽기

찾아오게 만들기

1 / 2

이야기 나누기

반응을 남기고 새 글 소식을 받아보세요.

댓글0
서로를 존중하는 대화를 남겨주세요.

첫 댓글을 남겨 대화를 시작해보세요.

새 글 구독
새 글이 공개되면 이메일로 알려드려요.