개발 기록 2026-10-20 docs/devlog/2026-10-20-15-문서-위키.md GitHub에서 보기

문서 위키

목표

사용자 지시 (10/20): 저장소 문서를 한 곳에서 읽을 수 있는 페이지를 만들고, 상단 바에서 누르면 그 위키로 가게 한다. 참고 예시는 사용자의 다른 프로젝트 (Next.js 대시보드) 의 /about 페이지다.

  • 대상: docs/ 아래 md 와 html 전부, 루트의 README.md, TASKS.md, AGENTS.md.
  • 면접관이 저장소를 받지 않고 배포 주소에서 바로 기획, 설계, devlog 를 읽을 수 있게 하는 것이 목적이다.

한 일

/docs 는 문서 하나를 보여주는 LiveView 다.

  • 왼쪽 사이드바: 분류별 목록과 검색. 분류는 프로젝트, 기획, 설계, 디자인, 도메인, 개발 기록이다.
  • 가운데: 본문.
  • 오른쪽: 목차 (h2, h3).
  • HTML 디자인 문서는 sandbox iframe 으로 띄운다.
  • 상단 바에 "문서" 링크를 더했다. 로그인 전에도 보인다.
파일 역할
lib/tickmark_web/docs.ex 문서 목록 (컴파일 시점), 분류, 검색과 스니펫
lib/tickmark_web/docs/loader.ex 파일 목록, 위키 ID, 분류, 날짜와 시퀀스, 제목, 검색용 평문
lib/tickmark_web/docs/markdown.ex MDEx 렌더링, 상대 링크 재작성, 목차
lib/tickmark_web/docs/document.ex, not_found_error.ex 문서 구조체, 404 예외
lib/tickmark_web/live/docs_live.ex, docs_live/components.ex 화면, 사이드바, 검색 결과, 목차, colocated hook
lib/tickmark_web/controllers/doc_file_controller.ex HTML 문서를 CSP sandbox 와 함께 내려줌
router.ex, layouts.ex, app.css, config/runtime.exs, mix.exs 라우트 3개, 상단 바 링크, 본문 스타일 (.doc-prose), 문서 변경 시 live reload, mdex 의존성
테스트 3개 파일 렌더링과 링크 재작성 (markdown_test), 목록과 검색 (docs_test), 화면과 404, CSP (docs_live_test). 모두 21개

결정과 트레이드오프

  • 문서 목록을 컴파일 시점에 만든다. 참고 예시는 요청마다 파일을 읽는다. 그래서 허용 목록 (whitelist), 파일명 정규식, path.resolve 와 path.relative 로 저장소 밖을 막는 검사가 필요했다. Tickmark 는 TickmarkWeb.Docs 가 컴파일될 때 문서를 전부 읽고 렌더링해 모듈 속성 @documents 에 넣는다.
    • URL 의 *id 는 그 목록에서 찾는 키로만 쓰고 파일 경로로 쓰지 않는다. 요청이 디스크에 닿지 않으므로 ../ 같은 경로 조작이 원리상 불가능하다.
    • 릴리스에 docs/ 폴더가 없어도 위키가 동작한다. 배포 이미지에 문서를 따로 복사할 필요가 없다.
    • 요청마다 마크다운을 렌더링하지 않는다.
    • 대가: 문서를 고치면 다시 컴파일해야 보인다. 다음 항목으로 해결했다.
  • 다시 컴파일할 때를 Mix 에 알린다. 기존 파일은 경로마다 @external_resource 를 달아, 고치면 mix compile 이 이 모듈을 다시 컴파일한다. 새 파일 추가나 삭제는 이 방법으로 알 수 없다. 그래서 __mix_recompile__?/0 에서 지금의 파일 목록과 컴파일 때의 목록 (@paths) 을 비교한다. 개발 서버에서는 config/runtime.exs 의 live reload 패턴에 docs/ 를 더해 문서를 저장하면 브라우저가 새로 고쳐진다.
  • 마크다운 라이브러리는 MDEx 다. Elixir 에서 흔히 쓰던 Earmark 는 Hex 에서 모든 릴리스가 retired 상태였다. MDEx 는 Rust 의 comrak (CommonMark 와 GFM) 을 NIF 로 감싸고, 미리 빌드한 바이너리를 받아 Rust 도구 없이 설치된다. 표, 작업 목록, 제목 앵커를 지원하고 AST 를 고칠 수 있다. 대가는 NIF 의존성 하나와 mix.lock 변경이다.
  • 원시 HTML 은 버린다 (render: [unsafe: false]). 렌더링한 HTML 을 raw/1 로 넣는다. 그래서 문서에 <script> 나 javascript: 링크가 있어도 실행되지 않아야 한다. comrak 은 unsafe: false 에서 원시 HTML 을 주석으로 바꾸고 위험한 URL 을 비운다. 참고 예시처럼 렌더링 뒤에 정규식으로 태그를 지우는 방식보다 확실하다. 우리 문서에는 원시 HTML 이 없어 잃는 것도 없다.
  • 상대 링크는 AST 단계에서 바꾼다. MDEx.traverse_and_update/2 로 Link 노드의 URL 을 바꾼다.
    • 목록에 있는 문서는 /docs/<id> 로 가고, 그 밖의 저장소 파일 (예: lib/...) 은 GitHub main 으로 간다.
    • ../ 는 가짜 루트 (/) 를 기준으로 펼쳐 저장소 밖으로 나가지 않는다.
    • 위키 링크에는 data-phx-link="patch" 를 붙여 LiveView 연결을 끊지 않고 문서를 바꾼다.
    • 렌더링한 HTML 을 정규식으로 고치는 것보다 코드 블록 안 문자열을 건드릴 위험이 없다.
  • 위키 ID 는 경로에서 만든다. ID 는 docs/ 를 뺀 상대 경로에서 확장자만 뗀 것이다 (예: devlog/2026-10-20-15-문서-위키). 루트 파일 4개는 고정 ID (index, readme, tasks, agents) 를 쓴다. 파일명 규칙 (AGENTS.md 3.2) 이 유일성을 보장하므로 따로 slug 표를 둘 필요가 없다. 규칙이 깨져 두 파일의 ID 가 같아지면 컴파일을 멈추고 두 파일을 알려준다 (ensure_unique!).
  • HTML 디자인 문서는 sandbox iframe 과 CSP sandbox 로 띄운다. 화면설계서 시안 같은 HTML 문서는 자체 스크립트가 있어 마크다운처럼 정리할 수 없다.
    • iframe 의 sandbox="allow-scripts" (allow-same-origin 없음) 와 응답 헤더 Content-Security-Policy: sandbox allow-scripts 가 문서에 불투명한 origin 을 준다.
    • 문서 스크립트는 돌지만 앱의 쿠키, localStorage, LiveView 소켓에 접근하지 못한다.
    • CSP 헤더는 사용자가 "새 탭에서 열기" 로 직접 열 때도 같은 격리를 준다.
    • 이 라우트는 세션을 읽지 않는 :doc_files 파이프라인에 둔다.
  • 검색은 렌더링한 평문을 대상으로 한다. 처음에는 마크다운 원문에서 찾았는데 스니펫에 |, **, 링크 문법이 그대로 보였다. 렌더링한 HTML 에서 태그를 뺀 평문 (text) 을 블록마다 한 줄로 만들어 검색한다. 제목 일치를 먼저 보이고 결과는 30개까지 낸다. 검색어는 앞뒤 공백을 자르고 100자로 제한한다.
  • 위키는 로그인 없이 연다. 홈과 같은 live_session :current_user 에 둔다. 문서에는 합성 데이터와 공개 예정 저장소의 내용만 있고, 면접관이 계정 없이 읽을 수 있어야 한다. 로그인한 사용자에게는 같은 상단 바와 사용자 메뉴가 보인다.
  • 문서를 바꾸면 맨 위나 앵커로 스크롤한다 (colocated hook). patch 는 스크롤 위치를 그대로 둔다. 긴 문서 중간에서 다른 문서로 가면 새 문서의 중간이 보인다. .DocScroll hook 은 data-doc-id 가 바뀌면 URL 해시의 제목으로, 없으면 맨 위로 간다. LiveView 는 퍼센트 인코딩된 해시를 그대로 찾아 한글 제목 앵커를 놓치므로 hook 이 decodeURIComponent 로 풀어 찾는다. 같은 hook 이 IntersectionObserver 로 지금 보는 제목을 목차에 표시한다.

막힌 점과 해결

  • 테스트에서 ~s(...) 안에 괄호를 겹쳐 썼다. 마크다운 링크 [a](b) 를 넣은 ~s(...) 가 첫 ) 에서 끝나 컴파일 오류가 났다. 일반 문자열로 바꿨다.
  • patch 뒤 page_title 에 접미사가 없다. 루트 레이아웃의 .live_title suffix=" | Tickmark" 는 첫 렌더에서 붙는다. patch 로 바뀐 제목은 LiveViewTest 의 page_title/1 에 접미사 없이 돌아온다. 테스트를 =~ doc.title 로 바꿨다.
  • mix.lock 이 바뀐 뒤 개발 서버가 500 을 냈다. 실행 중인 서버는 새 의존성 (MDEx NIF) 을 불러오지 못한다. 서버를 다시 띄웠다.
  • HTML 문서 iframe 이 좁았다. 본문 폭 max-w-4xl 이 iframe 에도 걸렸다. 폭 제한은 마크다운에만 주고, 목차가 없는 문서 (HTML) 는 세 번째 열을 만들지 않게 했다.
  • 스니펫에 마크다운 문법이 보였다. 위 "검색" 결정으로 해결했다.
  • header_ids 옵션 경고. MDEx 0.14 에서 header_ids 는 deprecated 라 header_id_prefix: "" 로 바꿨다. 경고 0 원칙 때문에 바로 고쳤다.

Elixir 학습 포인트

  • 모듈 속성은 컴파일 시점에 평가된다. @documents Loader.build(@root, @paths) 는 컴파일할 때 한 번 실행되고, 결과 (구조체 리스트) 가 BEAM 파일에 상수로 들어간다. 그래서 런타임의 Docs.all/0 는 파일을 읽지 않는다. 같은 이유로 Loader 는 Docs 보다 먼저 컴파일되어야 하는데, Elixir 컴파일러가 의존성을 보고 순서를 맞춘다.
  • @external_resource 와 __mix_recompile__?/0. @external_resource 는 "이 파일이 바뀌면 나를 다시 컴파일하라" 는 표시다. 새 파일은 이 목록에 없으므로 Mix 가 매 컴파일 때 부르는 __mix_recompile__?/0 에서 직접 판단한다. true 를 돌려주면 다시 컴파일한다.
  • handle_params/3 와 live patch. live "/docs" 와 live "/docs/*id" 는 같은 LiveView 다. 링크가 patch 면 mount/3 없이 handle_params/3 만 다시 불린다. 사이드바 상태 (검색어, 열림) 는 그대로 두고 문서만 바꾼다. *id 는 경로 조각 리스트 (["devlog", "2026-..."]) 로 들어와 Enum.join/2 로 ID 를 만든다.
  • colocated hook. LiveView 1.1 부터 <script :type={Phoenix.LiveView.ColocatedHook} name=".DocScroll"> 로 hook 을 LiveView 파일 안에 둔다. 이름이 . 으로 시작하면 모듈 이름으로 범위가 정해져 다른 hook 과 겹치지 않는다. assets/js/app.js 를 고치지 않아도 된다.
  • 예외로 404 내기. TickmarkWeb.Docs.NotFoundError 는 plug_status: 404 를 가진 예외다. LiveView 와 컨트롤러 모두에서 raise 하면 Phoenix 가 404 오류 화면을 그린다. 테스트는 assert_error_sent 404, fn -> ... end 로 확인한다.

검증

  • mix precommit: 584 tests, 0 failures. 컴파일 경고 0.
  • 브라우저 (Chrome, localhost:4000/docs)
    • 사이드바 링크로 문서를 바꾸면 맨 위로 간다.
    • 다른 문서의 한글 제목 앵커 링크는 그 제목으로 간다.
    • 디자인 HTML 이 iframe 안에서 스크립트와 함께 동작한다.
    • "Oban" 검색은 24건이다.
    • 콘솔 오류는 없다.
  • curl -I 로 /docs/files/... 응답의 CSP 헤더를 확인했다.

다음 할 일

  • 배포 뒤 공개 주소에서 위키를 확인한다 (5.4).
  • 문서가 많아져 컴파일 시간이 늘면 렌더링을 모듈 속성 밖으로 옮기는 것을 검토한다. 지금은 문서 폴더가 1.2MB 이고 체감 지연이 없다.

관련 커밋

  • 1a2ef4b: 문서 위키 페이지와 상단 바 링크