문서 위키
- 작성일: 2026-10-20
- 작성: 메인 세션, 브랜치
feature/m2-rule-engine - 관련 문서: WBS와 마일스톤 (6.5), API 연동 규격서 14장, 문서 색인
목표
사용자 지시 (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/폴더가 없어도 위키가 동작한다. 배포 이미지에 문서를 따로 복사할 필요가 없다. - 요청마다 마크다운을 렌더링하지 않는다.
- 대가: 문서를 고치면 다시 컴파일해야 보인다. 다음 항목으로 해결했다.
- URL 의
- 다시 컴파일할 때를 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/...) 은 GitHubmain으로 간다. ../는 가짜 루트 (/) 를 기준으로 펼쳐 저장소 밖으로 나가지 않는다.- 위키 링크에는
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파이프라인에 둔다.
- iframe 의
- 검색은 렌더링한 평문을 대상으로 한다. 처음에는 마크다운 원문에서 찾았는데 스니펫에
|,**, 링크 문법이 그대로 보였다. 렌더링한 HTML 에서 태그를 뺀 평문 (text) 을 블록마다 한 줄로 만들어 검색한다. 제목 일치를 먼저 보이고 결과는 30개까지 낸다. 검색어는 앞뒤 공백을 자르고 100자로 제한한다. - 위키는 로그인 없이 연다. 홈과 같은
live_session :current_user에 둔다. 문서에는 합성 데이터와 공개 예정 저장소의 내용만 있고, 면접관이 계정 없이 읽을 수 있어야 한다. 로그인한 사용자에게는 같은 상단 바와 사용자 메뉴가 보인다. - 문서를 바꾸면 맨 위나 앵커로 스크롤한다 (colocated hook). patch 는 스크롤 위치를 그대로 둔다. 긴 문서 중간에서 다른 문서로 가면 새 문서의 중간이 보인다.
.DocScrollhook 은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: 문서 위키 페이지와 상단 바 링크