Skip to content
created by Aha00aAha00a at 2019-12-22
last modified by Aha00aAha00a at 2026-10-12
revision: 166

ToDo

=== ToDo (Low)
==== Admin - Export / Import ==== #cmpebhmf4hubz1f
===== Property
 * Creator: [User:Aha00a]
 * dateCreated: [2026-05-20]T17:09:52Z
===== Activity
 * [User:Aha00a] [2026-05-20]T17:09:52Z
  * Card Add - ["#cmpebhmf4hubz1f" #Admin - Export / Import]
=== ToDo
==== 삭제 권한 도입 ==== #cmpebn29sgylr3q
===== Description
이미 된 것으로 보인다. 카드를 만든 2026-05-20 다음 주에 Delete(32) action 과 WikiPermission.isDeletable 이 들어갔고(커밋 51deff34, f659ef31), 지금 삭제하는 네 경로가 전부 그것으로 막힌다 — 페이지 삭제, 마지막 revision 삭제, 첨부 삭제, API v1 삭제. 자세한 건 [AccessControl] 의 쓰기 권한 적용 지점.
그래서 이 카드가 그 일이면 Done 이고, 더 하려던 것(예: 휴지통, 복구)이 있으면 그것을 카드 제목으로 다시 적는 편이 낫다. 어느 쪽인지는 만든 사람만 안다 — 옮기지 않고 둔다. --[2026-09-25]
===== Property
 * Creator: [User:Aha00a]
 * dateCreated: [2026-05-20]T17:14:06Z
===== Activity
 * [User:Aha00a] [2026-05-20]T17:14:06Z
  * Card Add - ["#cmpebn29sgylr3q" #삭제 권한 도입]
=== Done

1. ToDo

1.1. CloudFront 적용

원래... AhaMark를 rendering한 결과물을 손수 caching하려 했는데, 뭐 그럴 필요 있을까 하는 생각이 문득 들어 끄적여본다.

CloudFront 뒤에 숨어서 옵션 잘 적용하고, Cache Invalidation만 제대로 해주면 되는 문제라는 생각이 드네..

--2021-09-28

아직 안 했다. 페이지 렌더 결과에 Cache-Control도 ETag도 없고, 메모리 캐시는 domain·permission·siteAdmin·links 뿐이다.

다만 "옵션만 잘 주면 되는 문제"라는 위 판단은 지금 기준으로 틀리다. 그 사이 읽기 권한이 Permission 행 기준으로 사용자마다 갈리게 됐다(AccessControl). 앞단 CDN 이 응답을 공유 캐시에 담으면 비공개 페이지가 다른 사람에게 그대로 나간다. 하려면 익명이 읽을 수 있는 페이지로 한정하거나 사용자별로 vary 해야 하고, 후자는 캐시 적중률을 거의 없앤다. 즉 캐시 무효화보다 무엇을 캐시해도 되는지가 먼저다. --2026-08-31

1.2. Bot

Bot을 만들어볼까 싶다. 문법 변화나 기타 노가다로 고쳐야 하는 것들을 자동으로 수정하고, minor edit으로 남긴다. 그 외 rename같은 action에는 rename하기 전의 문서로 연결된 문서들이 있는데 이를 고쳐주는 등의 자동화.

문법을 바꾸었을 경우 예전 페이지들에 대한 migration이 고민됐었는데, 나무위키의 경우 쿨하게 history는 건드리지 않는다. 나도 이쪽으로 마음이 살짝 기울었다.

--2020-01-06

아직도 결정을 못하겠다. 문법이 변화되었는데, history의 내용을 건드리지 않는다면 세월이 흐른 뒤 새 엔진에서 history를 통해 그 페이지를 본다면? 개판일텐데?

그럼 두가지 해결책이 있는데

  • 문법이 변하면 history를 포함하여 migration을 하는 방안과
  • 렌더러를 버전관리를 해서 datetime을 통해서 그 시점으로 interpreting하는 방법이 있겠다.

진짜 쓸데없는거로 고민하는 거 같긴 한데.. 뭐 이런게 내 취미라고 해두자. --2020-01-08

위에서 봇이 할 일로 꼽은 것 중 rename 은 봇 없이 해결됐다. rename 이 원래 자리에 #!redirect 페이지를 남기므로(Wiki.rename), 옛 이름으로 걸린 링크는 고치지 않아도 끊기지 않는다. rename 이 무엇을 바꾸는지는 AccessControl 에 있다.

봇이 쓸 기반도 이미 있다 — API v1(/api/v1/page/*name), API Key, 그리고 봇 편집을 구분해 기록하는 viaApi 다(Api). 문서 sync 가 그 경로로 돌고 있다.

그래서 남은 건 처음의 그 결정 하나다 — 문법이 바뀌었을 때 history 까지 마이그레이션할 것인가, 렌더러를 버전관리해 그 시점 문법으로 해석할 것인가. 아직 정하지 않았고, 정하기 전에는 봇을 만들 이유가 없다. --2026-08-31

2026-09-13 에 정해졌다 — history 까지 마이그레이션한다. [a b] 를 페이지 이름 하나로 읽게 바꾸면서, 옛 뜻으로 쓴 링크를 모든 사이트의 모든 리비전에서 DB 를 직접 고쳐 [a|b] 로 바꿨다. 소유자의 지시였고, 까닭은 위 2020-01-08 의 걱정 그대로다 — 옛 리비전도 오늘의 파서로 읽히니, API 로 고치면 새 리비전 하나만 맞고 그 앞의 리비전은 전부 틀리게 보인다. 렌더러 버전관리는 택하지 않았다. 무엇을 어떻게 바꿨는지는 InterpreterWiki 의 Link 절과 저장소의 scripts/README.md. 봇은 여전히 없다 — 이번 일은 한 번 도는 스크립트였다. --2026-09-14

1.3. InterpreterMap

현재 InterpreterMap에 너무 많은 기능이 들어있다.

  • 데이터를 보관
  • 데이터를 GoogleSpreadsheet로부터 읽어와서 갱신
  • 데이터를 표시
    • GoogleMaps
    • Table
    • List

이를 분리할까 하고 고민해본다.

또 다양한 옵션을 지원하기 위해서 Interpreter의 Argument로 JSON을 받아들일까 싶기도..


일반 맵

일반 테이블
#!Table tsv
Name Address
some addr

#!Map tsv
Name Address
some addr


새로운 InterpreterFetcher? InterpreterDataSource

#!DataSource { id: "dataSource1", "url": "https://spreadsheet" option:  }
Column1 Column2
Data1 Data2 


#!Table { "DataSource": "dataSource1" }

#!Map { "DataSource": "dataSource1" }

#!Map { "DataSource": "dataSource1", "option": { "hideMap": false, "hideTable": false, showList: true } }

인자의 경우 default를 false로 고정하는게 나을지, show/hide중 하나만 쓰는게 나을지 모르겠다.
JSON을 직접 쓰게 되면 "key": "value"와 같이 따옴표가 귀찮다..

--2019-12-23

세 가지 책임 중 데이터를 GoogleSpreadsheet 에서 읽어오는 부분은 이미 분리됐다. InterpreterMap 에는 spreadsheet 참조가 남아 있지 않고, 그 일은 WikiSpreadsheet.syncGoogleSpreadsheet 가 한다. 남은 것은 보관과 표시 둘이다.

Interpreter 인자로 JSON 을 받는 안은 아무것도 하지 않았다. 위에서 스스로 짚은 따옴표 문제도 그대로다. --2026-08-31

1.4. Table과 Map에 filter 적용

https://mottie.github.io/tablesorter/docs/example-widget-filter.html --2019-12-27

Map 에는 적용돼 있다. Wiki/map.scala.html 이 위 링크의 그 filter widget 을 widgets: ["filter"] 로 켜고, Tag · Category · Score 에 필터를 건다.

표에는 없다. InterpreterTable 이 모든 #!Table 에 tablesorter 클래스를 붙이므로 정렬은 전부 되지만, Wiki/view.scala.html 이 옵션 없이 .tablesorter() 로 초기화해 filter widget 이 꺼진 채로 남는다. widget 스크립트는 _baseSkeleton.scala.html 에서 이미 전역으로 불러오고 있으니, 표에 켜는 일 자체는 작다. --2026-08-31

표에도 켰다. Wiki/view.scala.html 의 초기화에 widgets: ["filter"] 를 줬다 — 예상대로 그 한 줄이다. Map 표는 제 초기화가 열마다 다른 필터를 거니 .not('[id^="mapTable"]') 로 뺐다. 무엇을 어떻게 하는지는 InterpreterTable 정렬과 필터. --2026-09-15

1.5. Comparison of wiki software

위키엔진 비교표

https://en.wikipedia.org/wiki/Comparison_of_wiki_software

항목들은 다음과 같다.

  • File uploading, attachments 지원함
  • Spam prevention 지원함
  • Page access control 지원함
  • Inline HTML 지원함
  • User-customizable interface 지원함
  • Document renaming 지원함
  • WYSIWYG editing
  • Web feeds 지원함
  • Export, import — 이 페이지 맨 위 칸반의 Admin - Export / Import 카드가 정본이다. 같은 할 일을 한 페이지에 두 번 적어 두지 않는다.
  • Extensibility
  • Selectable wiki syntax 지원함
  • Wiki farms 지원함
  • Outliner mechanism 지원함
  • Automatic TOC 지원함
  • Other features

이정도가 기본 기능이라는 거겠지.. --2019-12-27

https://www.wikimatrix.org/ 발견. 여기 기준에 좀 맞춰볼까.. --2020-11-26

취소선이 없는 항목 중 WYSIWYG editing 은 여전히 없다 — 편집기는 CodeMirror 기반 소스 편집기이고 미리보기가 따로 있다(Dev Editor). --2026-08-31

설계안을 정리했다(2026-09-16). Dev Editor WYSIWYG 절에 — 핵심 어려움은 «편집한 리치 문서 → AhaMark» 무손실 왕복(블록·매크로·커스텀 링크 보존)이다. 권장은 하이브리드(프로즈만 리치, 블록/매크로는 아톰 노드로 기존 표·간트 편집기 재사용), 단계별. 저비용 대안은 소스 편집기 위 서식 툴바. 착수 전 소유자가 정할 열린 결정 넷(접근·범위·에디터 라이브러리·정본)을 남겼다. 크고 데이터 손상 위험이 있어 blind 구현은 안 하고 결정 대기.

서식 툴바(접근 3의 첫걸음)를 구현·배포했다(2026-09-16). 소유자가 «서식 툴바» 를 택했다. 소스 편집기 위 버튼 — 굵게·기울임·밑줄·취소선·인라인 코드·제목·불릿·링크 — 이 선택을 감싸거나 줄 앞에 붙인다. 텍스트 연산은 순수 함수 AhaWiki.Format(단위테스트 ahawiki.format.test.mjs), CodeMirror replaceRange 로 적용해 undo 보존. Chrome 실편집기에서 8개 버튼 삽입 결과 확인(저장 아님, reCAPTCHA 무관). Scala 259·node 206 통과. 양쪽 배포, 태그 v20260916T112205-aha00a. 자세한 건 Dev Editor 서식 툴바. 진짜 WYSIWYG(접근 2)은 여전히 열린 결정.

1.6. InterpreterSlide

슬라이드로 보여준다.

--2020-01-20

구현했다(2026-09-16, v1). 소유자와 토의해 «접근 B» 로 지었다 — InterpreterPaper 가 이미 슬라이드 모델의 대부분(‹----› 분할·#!var·조각별 렌더·쪽번호)을 하므로, 그 분할·렌더·인쇄 코어를 PaperSlideCore 로 추출해 Paper·Slide 가 공유하고(중복 제거), #!Slide 는 «발표 정체성»만 얹는다 — 화면은 16:9 덱을 한 장씩(키보드·클릭·전체화면, #/n 딥링크), 인쇄는 한 장에 한 슬라이드씩 정적 유인물(InterpreterPaper 의 @media print 와 같은 성격). 이유: ① 발표 자료도 인쇄하면 결국 Paper 유인물이다(현장에서 PDF 유인물로만 발표하기도 함) → 인쇄 경로를 새로 만들 이유가 없다. ② Word(Paper)/PPT(Slide) 의 가장 깊은 차이인 자유 배치(캔버스)는 AhaWiki 에 애초에 없다(전부 흐름 마크업) → 코어 공유가 성립한다. 기각: 순수 최대공유(새 디렉티브 없이 Paper 에 «발표» 버튼만 — 발표 정체성이 약해 «전체화면 Word» 느낌), 완전 분리(인쇄 경로가 Paper 와 갈라져 handout 논리에 어긋남). 화면은 순수 CSS(_slide.less, 컨테이너 쿼리 cqw 로 글자 크기 자동)+작은 JS(AhaWiki.Slide.js, 순수 인덱스 함수는 ahawiki.slide.test.mjs), 외부 라이브러리 없음. SlideUnit 포함 Scala 260·node 211 통과, 정적 하네스로 넘김·경계·유인물 fallback 을 눈으로 확인. 문서 InterpreterSlide. 전환·fragment·슬라이드별 배경·발표자 노트는 fast-follow 로 남겼다.

1.7. 인스턴스 사이 실시간 이벤트

  • PageCursorHub 의 발행을 인스턴스 사이로 넓힌다(예: Redis pub/sub). 운영은 인스턴스 둘이 요청을 나눠 받는데, 커서·page.updated 는 저장한 요청과 같은 인스턴스에 붙은 사람에게만 간다 — 무엇이 빠지는지는 Dev WebSocket. 권한 캐시의 같은 문제는 만료 시간으로 묶었지만, 이벤트는 기다려서 오는 것이 아니라 설계를 정해야 한다. --2026-09-10
  • 설계안을 정리했다(2026-09-15). Dev WebSocket 인스턴스 사이 실시간 이벤트 절에 — 우선 page.updated 만 넓히고(커서는 고빈도라 인스턴스 안에 둔다), 브로커는 이미 공유 중인 Redis 의 pub/sub 를 권장(대안은 동일호스트 loopback HTTP relay), 전달은 at-most-once 로 충분. 착수 전 소유자가 정할 열린 결정 네 가지(범위·브로커·Redis 공유 확인·새 의존성 허용)를 남겼다. 결정 대기 중이라 구현은 안 했다.
  • Redis pub/sub 로 page.updated 만 구현했다(2026-09-15). 배포 전. 소유자가 «Redis pub/sub·page.updated만» 으로 정했다. CrossInstanceBus(신규) 가 저장 시 로컬 broadcast 후 캐시 Redis 채널 ahawiki:<db>:page.updated 로 PUBLISH 하고, 각 인스턴스가 SUBSCRIBE 해 남의 메시지만(자기 origin 은 건너뜀) 로컬로 재broadcast 한다. saveSenderId 를 메시지에 실어 상대 인스턴스도 저장자 탭을 제외한다. play-redis 는 pub/sub 를 안 열어 줘서 Jedis 를 따로 썼고, Redis 는 두 인스턴스가 이미 공유(로컬 redis, db0). 실패 시 로그만 남기고 로컬 동작 유지. 테스트 모드에선 끔. 단위테스트 CrossInstanceBusUnit(전송 형식), Scala 258 통과. 배포는 canary(10001) 로 부팅·저장·publish 확인 → 양쪽 올리고 두 인스턴스 간 왕복 확인 순서. 자세한 건 Dev WebSocket 의 그 절.
  • 배포했다(2026-09-15). canary(10001) 로 부팅·구독 확인 후 양쪽 올렸다. 확인: 두 인스턴스가 채널을 구독(PUBSUB NUMSUB=2), 채널에 넣은 page.updated 하나가 10001·10000 양쪽 소켓에 도달, 전송 형식 단위테스트, Scala 258 통과. 태그 v20260915T234918-aha00a. 남은 것 하나 — API·Spreadsheet·Diary 저장 경로는 예나 지금이나 page.updated 를 아예 발행하지 않는다(웹 저장 Wiki.save 만 발행). 그쪽도 알림이 필요하면 별도 작업.
  • 그 남은 것을 했다(2026-09-25). API(ApiV1.savePage)·Diary(Diary.write)·Spreadsheet(WikiSpreadsheet.syncGoogleSpreadsheet) 도 발행한다. 문서 sync 가 API 로 도는 지금은 API 저장이 가장 흔한 저장인데, 그것만 아무에게도 안 알리고 있었다. 호출부가 넷이 되므로 payload 조립을 CrossInstanceBus 안으로 옮겨 한 곳에서 만든다. rename 은 일부러 뺐다 — 옛 이름에 남는 건 #!redirect 리비전 1 이라 「새로고침할까요」 가 리다이렉트로 떨어진다. 단위테스트 CrossInstanceBusUnit 에 payload 필드·타입 추가, Scala 261·node 223 통과. 자세한 건 Dev WebSocket 발행하는 경로.
  • 배포했다(2026-09-25). 릴리스 20260925-143710. 배포 뒤 API 저장(문서 sync)이 그 페이지를 열어 둔 브라우저에 새로고침 토스트를 띄우는 것을 실제로 확인했다.

1.8. Kanban 저장

  • 409 재시도가 병합 없이 같은 보드 텍스트를 다시 보낸다. 그 사이 다른 세션의 보드 변경이 사라지고, 줄 수가 바뀌었으면 블록 밖 줄을 덮는다. 재시도 전에 최신 원문을 읽어 원격 업데이트처럼 병합하게 한다. --2026-09-12 고쳤다: 재시도 전에 최신 원문을 읽고, 이 보드가 아직 저장하지 못한 변경을 그 위에 다시 얹는다. 원격 업데이트의 병합은 카드 이동과 삭제를 모르므로(서버 자리가 이긴다) 그대로 쓰지 않고 따로 만들었다 — rebaseKanbanColumns. 지금 동작은 InterpreterKanban 규칙. --2026-09-15
  • 맨 위에 #! 줄이 있는 페이지의 보드 저장이, 원격 업데이트를 받기 전까지 그 줄 수만큼 위에 들어간다. 저장 경로도 편집 화면처럼 디렉티브 줄 수를 보정하거나, 화면이 원문 줄 번호를 내려 주게 한다. --2026-09-12 고쳤다: 둘 다 아니고, 페이지를 연 뒤 첫 저장이 먼저 원문을 읽어 블록의 자리를 원문에서 잡는다. 409 가 쓰는 길과 같다. --2026-09-15

1.9. 구간 안의 백틱

  • 백틱을 문서 전체에서 구간보다 먼저 뽑아서, 구간 안의 백틱도 렌더링된다. Raw 칸·Text·Vim 구간이 쓴 그대로를 못 보여 준다(Interpreter 구간 안의 백틱). 백틱이 든 Vim 구간은 자리표시자가 매번 새로 만들어져 캐시 키가 맞지 않으므로, 볼 때마다 vim 을 다시 돌린다. 순서를 되돌리면 백틱 안의 [[[ 와 {{변수}} 보호가 깨지므로, 구간 안을 백틱 처리에서 빼는 쪽으로 고친다. --2026-09-12
  • 구현했다(2026-09-15). 배포는 아직. ExtractConvertInjectBackQuote 만 구간을 알게 했다 — extract 가 텍스트를 왼쪽부터 한 번 훑어, 리터럴 구간(#!Text·#!Vim·#!WikiSyntaxPreview, 그리고 Text 로 읽히는 이름 없는 [[[...]]])만 통째로 비켜 두고 그 안 백틱을 안 건드린다. 나머지는 종전대로 백틱을 먼저 뽑는다. 백틱 span 은 건너뛰므로 백틱 안의 [[[ 는 여전히 구간을 안 연다. 구간 추출·단계 순서는 그대로고, Vim 구간 본문에 자리표시자가 안 들어가니 캐시 미스도 사라진다.
  • 처음엔 모든 구간을 비켜 뒀다가 canary 렌더 비교에서 좁혔다(2026-09-15). #!Quote·#!Table 같은 위키형 구간은 본문을 다시 위키로 렌더해 백틱이 어차피 코드가 되는데, 바깥에서 뽑던 걸 안쪽에서 뽑게 되며 코드만 있는 줄의 <p> 감쌈이 사라져 인용문 두 줄이 한 줄로 붙었다(표 칸은 td p 여백이 0 이라 그대로였다). 그래서 위키형은 손대지 않고 리터럴 구간으로만 좁혔다. 단위테스트 BackQuoteBlockUnit(리터럴은 원문 유지, 위키형은 종전대로), Scala 257·node 200 통과. 배포 순서: canary(한 인스턴스) → 백틱 든 전 페이지 전·후 렌더 비교 → 이상 없으면 나머지 인스턴스.
  • 본문을 그대로 넘기는 구간도 같은 문제였다(2026-09-25). 9-15 에는 «보여 주는» 구간(Text·Vim·WikiSyntaxPreview)만 비켜 뒀는데, 본문을 브라우저에 그대로 넘기는 구간도 백틱을 그대로 둬야 했다 — #!Kanban · #!Gantt · #!Mermaid · #!Math · #!Html. Kanban 이 실제로 글자를 잃고 있었다: 보드는 그려진 본문을 태그를 떼고 읽으므로 백틱이 사라진 채로 다음 저장에 문서를 다시 썼다. 카드 글은 보드가 AhaMark 로 다시 그리니, 백틱은 거기서 코드가 되는 게 맞다. #!Markdown 은 그대로 뒀다(txtmark 이 같은 결과를 만든다), #!Graph · #!Map 도 본문이 데이터라 손대지 않았다. 단위테스트 BackQuoteBlockUnit 에 다섯 구간과 Kanban 왕복을 추가, Scala 261 통과. ahawiki.net 사본 113쪽에는 이 다섯 구간에 백틱이 든 곳이 없다 — 다른 사이트는 canary 렌더 비교로 확인할 것.
  • 배포했다(2026-09-25). 릴리스 20260925-143710, 태그 v20260925T144110-aha00a. canary(10001 만 새 코드)로 ahawiki.net 사본 113쪽을 전·후 렌더 비교했다 — 107쪽 동일, 6쪽은 옛 코드에서도 매번 다른 페이지(Kanban·Graph·Map·Include·Uptime 이 든 것들), 달라진 쪽 0. 그 뒤 나머지 인스턴스를 올리고 세 URL 을 밖에서 확인했다.
  • 배포했다(2026-09-15). canary(10001 만 새 코드)로 백틱 든 페이지 전부를 전·후 렌더 비교했다: 리터럴 구간 8개만 의도대로 바뀌고(literal 백틱), 위키형 구간과 나머지는 동일, Vim 백틱 구간 23개는 새 코드에서 결정적(캐시 키 안정)이 됐다 — 옛 코드는 같은 페이지를 두 번 렌더해도 달랐다. 나머지 diff 는 Kanban·Graph 등 원래 비결정적인 페이지뿐. 두 인스턴스 모두 올리고 태그 v20260915T193157-aha00a.

1.10. 빈 Map 블록과 없는 날짜도 500 이었다

  • 고쳤다(2026-09-25). 위 매크로 점검을 «이상한 인자» 로 한 번 더 돌려 둘을 더 찾았다.
    • [[WeekdayName(2026-13-45)]] — 모양만 날짜면 통과시키고 실제 파싱에서 예외가 났다. 이제 Argument Error 상자다. MacroMonthName 이 9-12 에 같은 이유로 고쳐졌는데 이쪽은 남아 있었다.
    • 장소가 없는 #!Map 블록 — 빈 블록은 첫 줄을 읽다가, 장소 0개인 블록은 «주소 0개» 를 IN () 로 물어서 깨졌다. 이제 열 이름이 없으면 오류 상자를 보여 준다. 그리고 진짜 원인인 빈 IN () 은 쿼리 함수 셋 안에서 막았다(GeocodeCache.select, CalculatedLink.selectDstMaxMinCountWhereSrcIsDatePage, Page.selectLastRevision(Seq)) — 나머지 다섯 곳은 이미 막고 있었다. 규칙은 Dev Database 빈 목록을 IN 에 넣지 말 것, 검사는 EmptyInClauseSpec.
    • Scala 263 통과. 셋 다 가드를 빼면 실패하는 것을 확인했다(H2 가 운영과 같은 문법 오류를 낸다).
    • 배포했다(2026-09-25). 배포 전 인스턴스에서는 500, 새 인스턴스에서는 오류 상자인 것을 같은 요청으로 대조했다.

1.11. 로그를 읽고 나온 것들

  • 고쳤다(2026-09-25). 운영 로그 53일치를 종류별로 세어 «지금도 나는 500» 둘을 찾았다. 세는 방법과 나온 표는 Dev Logs.
    • 폼이 아닌 본문에 500. POST /w/FrontPage 에 아무 필드나 던지는 봇 때문에 bindFromRequest().get 이 None.get 으로 죽었다 — 7주에 727건, 그중 726건이 그 한 경로다. 지금은 400 이다. .get 을 쓰던 일곱 엔드포인트(save·delete·deleteLastRevision·rename·preview·Diary·Spreadsheet)가 모두 같은 FormResults.withForm 을 쓴다. 검사는 WikiSaveReCaptchaNotConfiguredSpec 의 두 항목.
    • 읽을 게 없을 때 Random 이 500. Home.random 이 빈 목록에서 고르려다 bound must be positive 를 냈다. 읽을 수 있는 페이지가 하나도 없는 방문자(전부 비공개인 사이트)가 Random 을 누르면 매일 한두 번씩 나고 있었다. 이제 FrontPage 로 보낸다.
    • 배포했다(2026-09-25). 릴리스 20260925-210723, 태그 v20260925T211116-aha00a. canary 로 옛 인스턴스와 대조했다 — 폼 아닌 POST 가 500 대 400, Random 은 양쪽 303, preview 는 UUID 말고 같은 HTML(옛 인스턴스끼리도 그 UUID 만 다르다). 그 뒤 나머지를 올리고 밖에서 세 URL 확인.
  • 로그 파일이 안 잘린다 — 한 파일에 53일치 491MB, 디스크 74%. conf/logback.xml 에서 인스턴스별 파일로 나눠 날마다 말고 30일·1GB 로 잡았다(2026-09-26). 배포함 — 릴리스 20260926-015144, 태그 v20260926T015509-aha00a. 재시작 직후 application-10001.log·application-10000.log 가 생겨 쓰이는 것을 봤다. 옛 application.log 는 손으로 지운다(Dev Logs).
  • 거절된 WebSocket 을 클라이언트가 계속 다시 연다 — 8월에 한 주소가 44,249번. 고쳤다(2026-09-26). 배포함 — 같은 릴리스. 한 번도 안 열린 소켓은 두 배씩 늦추며 다섯 번에서 그만두고, 열렸다 끊긴 소켓은 전처럼 바로 다시 연다(Dev WebSocket).
  • PageMeta.upsert 의 외래키 경합이 아직 난다(마지막 9-22). 계산 중에 그 리비전이 사라지면 나는 것이고 한 시간 안에 저절로 낫지만, ERROR 로 남아 진짜 오류를 가린다(Dev Logs 액터 쪽 경합).
    • 고쳤다(2026-10-03). 리비전이 사라졌으면 INFO 한 줄을 남기고 그만둔다. PageMetaSpec 이 늦게 도착한 쓰기를 재현하고, 고치기 전 코드로는 운영과 같은 외래키 예외로 실패한다. 배포했다(2026-10-03, 태그 v20261003T053233-aha00a) — 대조에서 바뀐 페이지 0.
  • 문서 sync 가 올리는 편집은 이제 minor 다 — 소유자가 2026-09-25 에 그렇게 정했다. 목록에서 접어도 되는 편집이라는 뜻이고, 「누가 올렸나」는 viaApi 가 이미 답한다. 아닌 경우에 쓰는 --major 가 생겼다(전에는 반대로 --minor 를 줘야 했다). 플래그는 저장소의 scripts/README.md.
  • 8-23~8-27 에 cookie has invalid signature 가 하루 1,400건씩 났다. 서명 키를 그때 바꿨는지 확인이 필요하다 — 맞다면 그 자체는 정상이고, 기록만 남기면 된다.
    • 확인했다(2026-10-03). 키는 8-13 에 바꿨고 경고도 그날 시작했다 — 정상이다. 날짜와 건수의 까닭은 Dev Logs WARN 쪽에서 눈에 띈 것.
  • 9-26 부터 일주일치 로그를 다시 셌다(2026-10-03, Dev Logs 2026-10-03 에 나온 것). 커넥션 풀 고갈로 500 이 세 번에 걸쳐 63건 — 크롤러가 여러 페이지를 한꺼번에 열 때마다 났다. 렌더링 중인 매크로·캐시·템플릿이 커넥션을 하나 더 얻던 것이 원인이고, 요청이 쥔 커넥션을 컨텍스트가 넘겨받게 고쳤다(Dev Database 렌더링 중에는 커넥션을 새로 얻지 않는다). SingleConnectionRenderSpec 이 풀 1개로 페이지를 그려 지킨다.
    • 그것만으로는 안 고쳐졌다. 한 대에 올려 30개를 동시에 요청하니 500 이 9건 — 캐시(Redis) 읽기가 요청과 같은 스레드 풀에서 마무리되느라 5초씩 멈췄다. play-redis 에 제 dispatcher 를 줬다(conf/base.conf). 그 시험으로 실제 방문자 요청 하나가 500 을 받았다.
    • 배포했다(2026-10-03, 태그 v20261003T061551-aha00a). 같은 30개 동시 요청이 500 9건·37초에서 전부 정상·3.1초가 됐다. 대조에서 바뀐 페이지 0. 교착 재시도(Dev Page)도 같은 배포다.
  • 같은 고침을 안 받은 진입점이 남아 있다 — 쓰는 쪽 다섯(저장, Diary, 삭제, 마지막 리비전 삭제, rename). 요청이 적어 풀을 말릴 일은 없다고 봤고, 그중 둘은 트랜잭션 안이라 따로 봐야 한다. 그 경로에서 Connection is not available 이 보이면 한다. 읽는 쪽(미리보기, 검색, pagePreview·statistics·change)은 같은 날 마저 했고 배포했다(태그 v20261003T065503-aha00a) — 스펙이 못 돌리는 셋을 포함해, 두 인스턴스의 응답이 같은 것을 운영 DB 에 대고 확인했다. --2026-10-03
  • 로그 테이블의 만료 삭제가 매번 테이블 전체를 훑었다 — 2026-10-04 RDS 재시작(Dev Database)을 보다가 DB 의 slow log 하루치에서 찾았다. 위키의 느린 문장 149개 중 132개가 이것이었고(한 번에 약 5초), 운영 EXPLAIN 이 AccessLog 55만 행 type ALL 이었다. 고쳤다(2026-10-04), 배포 전 — 기준 seq 를 먼저 읽고 값으로 지운다(ExpiredRows). 지우는 행은 그대로라는 것을 ExpiredRowsSpec 이 옛 코드와 새 코드 양쪽에서 확인했다. 배포했다(2026-10-04 08:03, 태그 v20261004T080337-aha00a). 정리 작업 한 번(세 테이블)이 전날 135번 중간값 4,484ms·최대 17초에서 174·213ms 가 됐고, 08:00~08:33 의 slow log 에 이 문장은 없다. 첫 실행이 264행을 지워 가장 오래된 행이 정확히 한 달 전이 됐다. --2026-10-04
  • DB 가 재시작할 때 실행 중이던 쿼리가 두 시간 매달렸다 — 2026-10-04 RDS 재시작 뒤 8개가 TCP keepalive(약 2시간)를 기다리며 풀 커넥션을 쥐고 있었고, 하나는 독자의 요청이었다. MySQL 드라이버에 socketTimeout 10분을 줬다(Dev Database). 배포했다(2026-10-04 08:37, 태그 v20261004T083747-aha00a) — 새 설정으로 두 대가 DB 에 붙고, canary 대조는 228경로 모두 같았다. DB 가 멈춘 상황에서 정말 10분 안에 끊는지는 다음에 그런 일이 나야 본다. --2026-10-04

1.12. 괄호 없는 매크로가 문서를 500 으로 만든다

  • 고쳤다(2026-09-25). [[Image]] 처럼 괄호 없이 쓰면 매크로에 인자로 null 이 가서, 인자를 바로 읽는 다섯(Image · Attachment · Copyable · Embed · Kbd)이 NullPointerException 을 냈고 문서 전체가 500 이었다. [[Image()]] 는 멀쩡했다. 운영에서 매크로 42개 · 인터프리터 19개를 /api/renderAhaMark 로 하나씩 그려 보다 찾았다. 고친 자리는 매크로 하나하나가 아니라 인자를 넘기는 곳(ExtractConvertInjectMacro) 하나 — 괄호 없음은 빈 인자와 같게 읽는다. 단위테스트 WikiMacrosUnit 이 다섯의 «괄호 없음 == 빈 괄호» 를 확인하고, 고치기 전 코드로 돌리면 운영과 같은 NPE 로 실패한다. Scala 261 통과. 문서는 Macro 괄호를 빼고 써도 됩니다.
  • 배포했다(2026-09-25). [[Image]] · [[Kbd]] 가 옛 인스턴스에서 500, 새 인스턴스에서 200 인 것을 대조했다.

1.13. Vim 블록이 새로 쓰이면 문서가 500 이다

  • 원인을 찾았다(2026-09-25) — JDK 가 돌고 있는 JVM 아래에서 갈렸다. 로그의 예외는 IOException: Cannot run program "sh": error=0, Failed to exec spawn helper 였다. 인스턴스는 9-20 19:06 부터 돌고 있었는데 /usr/lib/jvm/java-17-openjdk-amd64 는 9-23 06:29 에 패키지로 갈렸고, 돌고 있는 프로세스의 /proc/PID/exe 는 (deleted) 였다. 그래서 JVM 이 자식 프로세스를 띄우지 못했다 — vi 를 못 부르니 새 캐시 항목이 안 만들어진다. 캐시 디렉터리 권한도 디스크도 멀쩡했다(.txt·.sh 는 쓰였고 .html 만 없었다). 고치는 방법은 재시작 이고, 9-25 배포가 그 재시작이었다 — 배포 뒤 새 Vim 블록이 강조까지 정상으로 나온다. 패키지가 JDK 를 갈면 그 뒤로 재시작 전까지 프로세스 실행이 전부 막힌다는 뜻이므로, 그 사실은 Dev Deploying 에도 적었다.
  • 운영 확인이 필요하다(2026-09-25). → 위 항목에서 확인했다. 운영에서 캐시에 없는 #!Vim 블록을 그리면 500 이다 — /api/renderAhaMark 로 확인했다. 이미 캐시에 있는 블록(예: Dev Deploying 의 배포 명령)은 200 이고, 같은 새 블록을 네 번 보내도 매번 500 이라 캐시 항목이 아예 안 만들어진다. 즉 지금 어느 문서든 Vim 블록을 새로 넣으면 그 문서가 안 열린다. /hc 는 OK 라 디스크는 5% 넘게 남았다. 남은 후보는 cache/Vim 에 쓸 수 없음(권한·작업 디렉터리·릴리스의 cache 링크)이나 sh 부재인데, 어느 쪽인지는 서버 로그와 그 디렉터리를 봐야 안다.
  • 코드 쪽은 고쳤다(2026-09-25). InterpreterVim 이 캐시 항목을 만들다 실패하면 강조 없이 본문을 보여 주고 페이지는 산다. 전에는 vi 실행 실패(RuntimeException)만 잡아서 파일 쓰기의 IOException 이 그대로 올라갔다. 로그에 Vim highlighting unavailable 과 실제 예외를 남기므로, 배포하면 원인이 로그에 이름을 대고 나온다. 단위테스트는 쓸 수 없는 캐시 디렉터리를 넘겨 확인한다(InterpreterVimUnit), Scala 261 통과. 자세한 건 InterpreterVim 강조를 못 하면 본문만 보여준다.
  • 배포했다(2026-09-25). 재시작으로 원인 자체가 사라져서, 새 Vim 블록은 이제 강조까지 제대로 나온다. 폴백은 다음에 같은 일이 났을 때를 위한 안전망으로 남는다.
  • 구간 하나가 죽으면 문서 전체가 500 인 구조 — 소유자가 «실패하지 않기로» 정했다(2026-09-26). 배포함 — 릴리스 20260926-025601, 태그 v20260926T025916-aha00a. canary 대조에서 달라진 쪽은 S3 서명 URL 의 시각뿐이었다(옛 인스턴스끼리도 1.5초 차이로 다르고, 서명을 가리면 옛·새가 같다). Interpreters.render 와 ExtractConvertInjectMacro.render 가 예외를 잡아 그 자리에 오류 상자를 놓고 로그에 예외 전체를 남긴다. 단위테스트는 일부러 던지는 인터프리터·매크로로 확인(InterpreterBlockUnit·WikiMacrosUnit). 문서는 Interpreter 그리다 실패하면, Macro.

1.14. 옛 리비전이 최신처럼 보였다

  • 히스토리에서 번호를 눌러도 현재 버전으로 돌아간다는 보고(2026-09-26). 본문은 그 리비전이 맞았지만 둘레가 전부 최신을 말했고, Kanban 보드는 누가 저장하면 최신으로 다시 그려졌다. 고쳤다(2026-09-26). 배포함 — 릴리스 20260926-195055, 태그 v20260926T200032-aha00a. 옛 리비전이면 본문 위에 안내 상자를 두고, 읽기 전용으로 그리고, 소켓을 열지 않는다. 검사는 WikiOldRevisionViewSpec. 자세한 건 Dev Page 옛 리비전 화면.
    • canary(10001 만 새 코드)로 사본 114쪽의 보기·히스토리 화면 231개를 옛 인스턴스와 대조했다. 소켓 가드 네 줄과 공백을 빼면 228개 동일, 3개는 옛 코드끼리도 매번 다른 페이지(Map 의 무작위 ID, 계산 중에 바뀐 단어 목록), 새 코드로 달라진 것 0. 옛 리비전 화면은 운영 HTML 을 브라우저에서 돌려 WebSocket 생성 0회(최신 화면은 1회)를 확인했다.

1.15. 배포한 CSS·JS 가 늦게 닿는다

  • /public/ 파일은 max-age=3600 이고 주소에 버전이 없어서, 배포 전 한 시간 안에 온 브라우저는 새 HTML 을 옛 CSS·JS 로 그린다. 주소에 내용 해시나 릴리스를 붙일지 정해야 한다. --2026-09-26
    • 내용 해시로 정하고 고쳤다(2026-09-27). 배포했다(2026-10-01). 첫 canary 는 모든 페이지에 500 이라 되돌렸고, 원인(Using 의 리플렉션 close())을 고쳐 다시 올렸다 — Dev Resources «운영에서는 jar 안에서 읽는다». 대조에서 217곳이 바뀌었고 전부 CSS·JS 주소의 ?v= 줄뿐이다. 태그 v20261001T051852-aha00a. 템플릿이 자기 CSS·JS 를 전부 PublicAsset.url 로 불러 주소 뒤에 ?v=<내용 해시> 가 붙는다. 플러그인·빌드 변경 없음. 검사는 PublicAssetVersionSpec(렌더된 페이지의 해시 == 그 주소에서 받는 내용의 해시)과 test/templates.public-assets.test.mjs(맨 주소·없는 파일 이름). 둘 다 고치기 전 템플릿으로는 실패한다. 고르지 않은 것과 남은 틈은 Dev Deploying CSS·JS 주소에는 내용 해시가 붙는다.
  • 두 인스턴스의 릴리스가 다른 동안(한 대만 먼저 올려 대조하는 동안이 가장 길다) 새 HTML 이 부른 ?v=<새 해시> 가 옛 인스턴스로 가면, 옛 파일이 새 주소로 최대 한 시간 캐시된다. /public/ 을 Assets 를 감싸는 컨트롤러로 받아 v 가 자기 해시와 다를 때 no-cache 를 붙이면 막힌다 — 그 코드가 옛 인스턴스에 있어야 하므로 들어간 다음 배포부터 듣는다(Dev Deploying 남은 틈). --2026-09-27
    • PublicAssets 로 그렇게 했다(2026-10-02). PublicAssetCacheSpec 은 옛 라우트(Assets 직접)로는 no-cache 검사에서 실패한다. 배포했다(2026-10-02, 태그 v20261002T054014-aha00a) — canary 에서 맞는 v 는 max-age=3600, 틀린 v 는 no-cache 를 확인했다. 다음 배포부터 듣는다.

1.16. 배포가 canary 의 500 을 못 본다

  • 2026-10-01 첫 canary 는 모든 페이지에 500 이었는데 deploy.sh 는 성공을 보고했다. /hc 는 페이지를 그리지 않고, 외부 확인은 프록시를 거쳐 옛 대가 답한다. --2026-10-02
    • 5단계가 이제 재시작한 포트에 루프백으로 확인 URL 을 그려 보고, 하나라도 200 이 아니면 그 대를 이전 릴리스로 되돌리고 멈춘다 — Dev Deploying 그려지는지는 띄운 그 대에서 본다(그때 이름은 «재시작한 그 대»). 운영 인스턴스에 대고 그 함수만 돌려 정상 URL 은 통과, 403·404 는 잡히는 것을 봤다. 되돌리는 경로는 실제 배포로는 아직 안 돌았다. 2026-10-02 배포에서 처음 썼다: renders 3 verify URL(s).
  • 같은 날 10000 을 재시작하고 프록시 기다림이 200 으로 끝난 직후, 외부 요청 4개가 전부 502 였다. 몇 초 뒤부터는 계속 200. --2026-10-02
    • 배포 탓이 아니었다. 차단된 봇의 tarpit(1~6분)이 서버의 75초 유휴 제한에 걸려 리셋으로 끝나고, nginx 가 그것을 인스턴스 장애로 세어 두 대를 차례로 뺐다 — 마침 10000 이 재시작 중이라 남은 10001 까지 빠졌다. 평소에도 하루 수백 번 나던 502 다. tarpit 을 20~55초로 줄였다 — Dev BotDetection tarpit. 배포했다(2026-10-02 05:4x). 배포 뒤 2시간 동안 위키의 502 는 0건(전날 같은 시간대 75초짜리 502 는 10건), tarpit 은 37건 모두 403 으로 끝났다. 그 사이 nginx 의 502 5건은 같은 서버의 다른 서비스 것이다.

1.17. 브라우저로 열어 본 것

scripts/browser-sweep.mjs 로 ahawiki.net 전체와 whohow.net 전체(2026-10-03), aha00a.com 전체 3,823페이지(2026-10-03~04)를 헤드리스 Chrome 으로 열었다. 스크립트 예외와 console.error 는 한 건도 없었다. 서버는 aha00a.com 스윕 동안 받은 요청 33,384개(웹소켓 제외)에 모두 5초 안에 답했고 5xx 는 없었다 — «로드 이벤트 없음»·«응답 없음» 으로 나온 페이지는 Chrome 넷을 한꺼번에 돌린 탓이었고, 하나씩 다시 열어도 남는 것은 광고 iframe 이 로드 이벤트를 잡고 있는 경우였다. 엔진 쪽 문제는 고쳐서 배포했다 — 사이트 목록의 /favicon.ico, aha00a.com PageMap 의 스크립트 오류(줄을 넘는 링크와 이스케이프 안 된 이름), #! 없는 블록에서 생기던 링크, 그래프·검색 스크립트가 인코딩하지 않던 페이지명(둘 다 Dev Page, 태그 v20261004T002023-aha00a). 이미 저장된 링크는 그 페이지가 다시 계산될 때 바뀐다 — 저장할 때, 열람 열 번에 한 번꼴, 또는 관리 화면의 Calculate. 배포 뒤 다시 열어 보니 2017-03-07 의 ??? 는 200 이고, Robocopy 는 이미 다시 계산돼 깨끗했으며, DataUri 는 아직 옛 링크로 400 이었다. 배포하고 열일곱 시간 뒤에도 블록에만 있던 링크를 34페이지가 149개 쥐고 있어서(블록에서 나온 링크 가운데 본문 다른 곳에는 없는 것만 센 수다 — 위의 248개와 단위가 다르다), 2026-10-04 저녁 관리 화면의 Calculate 로 한 페이지씩 다시 계산했다(그사이 하나는 열람으로 저절로 바뀌어 33페이지). 이제 그런 링크는 40페이지 모두 0개다. 블록 밖 본문의 링크가 함께 지워지지 않았는지도 페이지마다 본문에서 뽑은 것과 저장된 것을 맞춰 봤다 — 어긋난 것은 자기 자신을 가리키는 링크와 백틱 안의 글자뿐이고, 둘 다 원래 링크가 아니다. 남은 것:

  • 인접 페이지 그래프가 실패한 썸네일을 다시 받을 때 주소 뒤에 _imgRetry=… 를 붙인다(adjacentPagesD3Canvas.scala.html). S3 서명 URL 에 쿼리를 더하면 서명이 맞지 않아 403 이므로, S3 이미지의 재시도는 언제나 실패한다 — 서명 URL 은 그대로 200, 붙이면 403 인 것을 확인했다. 첫 로드가 실패할 때만 일어나고, 그런 실패는 대개 이미지가 정말 없는 경우라 영향은 작다. 고친다면 서명 URL(X-Amz-Signature)에는 붙이지 않는다. --2026-10-03
    • 고쳤다(2026-10-04) — 서명 URL 은 붙이지 않고 그대로 다시 받는다. 실패한 응답은 캐시되지 않으므로 같은 주소로도 새로 요청한다. 배포는 미뤘다: 같은 날 06:02 에 데이터베이스가 메모리에 눌려 재시작했고(Dev Database), 재시작 뒤에도 여유 메모리가 금방 80MB 대로 돌아왔다. 급하지 않은 배포의 재시작·대조 읽기를 거기 얹지 않는다. 기다리는 동안 잃는 것도 거의 없다 — ahawiki.net·whohow.net 의 그래프 140개에는 서명 URL 썸네일이 아예 없었고(/api/links 로 다 훑었다), aha00a.com 은 전체 스윕에서 S3 썸네일이 실패한 페이지가 하나도 없었다(이 버그는 첫 로드가 실패해야 일어난다). 배포했다(2026-10-04 08:03, 태그 v20261004T080337-aha00a). /api/links 응답의 썸네일 둘을 서명 모양 주소와 보통 주소로 바꾸고 각각의 첫 요청을 실패시켜 CDP 로 봤다 — 옛 코드는 서명 주소에도 &_imgRetry= 를 붙였고, 새 코드는 서명 주소를 그대로 다시 받고 보통 주소에만 붙인다.
  • nginx 가 경로를 풀어서 넘겼다. proxy_pass http://upstream_…/; 처럼 끝에 / 가 붙으면 nginx 는 %22·%5B·%3C 를 "·[·< 로 되돌려 넘기고, Play 는 그런 경로를 읽지 못해 400 이다("Cannot parse path from URI"). 페이지명에 그런 글자가 든 링크 — 예: Tip "T.I." Harris — 는 누르면 400 이었다. --2026-10-04
    • 소유자 결정으로 위키로 가는 proxy_pass 19줄에서 끝 / 를 뺐다(2026-10-04). 따옴표 이름은 이제 없는 페이지 화면(404), /api/links/%3CMIME-type%3E 는 200 이고, 페이지·웹소켓·정적 파일은 그대로다. 어디를 어떻게 바꿨는지와 되돌릴 백업은 운영 저장소(AhariseDevOps) ahawiki/README.md '앞단 nginx'.
    • 그 대가로 nginx 가 더는 // 를 합쳐 주지 않는다 — //public/less.css 가 바꾸기 전에는 200, 지금은 404 다. 위키가 그런 주소를 만들던 곳이 하나 있었다: 인접 페이지 그래프의 썸네일 주소(/api/links)가 / 로 시작하는 대표 이미지를 https://<host>//… 로 만들었다. 같은 일을 제대로 하던 미리보기(/api/pagePreview)의 함수가 따로 있었으니 두 벌이던 것을 Api.pageImageUrl 하나로 합쳤다. 검사는 PageImageUrlSpec — 옛 코드로는 //public/…·///cdn… 이 나와 실패한다. 지금 세 사이트 그래프(aha00a.com 3,823 · ahawiki.net 114 · whohow.net 26개, 썸네일 339개)에 그런 주소는 하나도 없어서 깨진 것은 없었다 — 일주일치 nginx 로그의 // 요청도 스캐너와, 10-03 에 고친 사이트 목록 아이콘(//public/favicon.png)뿐이었다. 배포했다(같은 태그). 배포 전후 세 사이트의 /api/links·/api/pagePreview 답이 두 인스턴스에서 같았다. nginx 를 바꾼 뒤 ahawiki.net 114·whohow.net 26페이지를 헤드리스 Chrome 으로 다시 열어 모두 깨끗했다. --2026-10-04
  • 지어내는 페이지(날짜·schema:)는 내용을 다 그리면서 상태는 404 다(Wiki.renderNotFoundPage). 리다이렉트 Movie → schema:Movie 도 404 로 끝난다. 브라우저에는 그대로 보이니 깨진 것은 아니지만 검색 엔진에는 없는 페이지다 — 2021 년부터 그랬다. 소유자가 고민 중(2026-10-04). --2026-10-04
  • aha00a.com 콘텐츠 — 엔진이 아니라 본문이 가리키는 것이 사라졌다. 대표 이미지가 죽으면 그 페이지와 이어진 페이지들의 그래프 썸네일이 모두 실패한다 — 전체 스윕에서 무언가 실패한 507페이지는 대부분 이것이다(Perl 143페이지, ClamAv 69, Perfect Blue 66, Dexter 65, The Blacklist 58, Passport.js 50, Boston Legal 46, Westworld·Waveform 41). --2026-10-03
    • 아래 것들을 고친 뒤 그 507페이지를 다시 열었다(2026-10-04 08:4x~09:0x, 한 번에 하나씩). 399페이지가 깨끗해졌고 108페이지가 남았다. 남은 실패는 전부 아래 «소유자 판단» 목록의 것이고(많이 보이는 순: The Blacklist·Passport.js·Westworld·Waveform·블랙머니·Prison Break·자신·Perfect Blue·ProjectEulerProblem15), 엔진 쪽은 위에 적은 둘(schema:Movie 404, Windows7 리다이렉트)과 소녀시대의 http 플래시뿐이다.
    • 위키미디어 이미지는 전부 봤다(upload.wikimedia.org 를 쓰는 281페이지, 주소 291개 중 13개가 죽어 있었다). 원본 직접 주소는 파일 이름이 바뀌면 따라가지 않아서, 같은 그림인데 404 였던 것이 5개 — Commons/위키백과 API 로 새 이름을 확인해 새 주소로 바꿨다(부산광역시·CAP Theorem·J·Haskell·C, 사소한 편집). 부산광역시의 것은 2023 년에 바뀌기 전 상징이라는 이름이 붙었다 — 같은 그림이지만 지금 상징으로 바꿀지는 소유자 판단. whohow.net 의 이재명 사진도 같은 경우라 고쳤다 — 그 사이트 26페이지가 이제 다 깨끗하다.
    • 남은 8개는 위키백과에서 지워진 파일이라 새 주소가 없었다. 영어 위키백과의 타이틀 카드·포스터·로고는 비자유 파일이라 새것으로 바뀌면 지워진다 — 이 8개가 그렇게 죽었다. Perl·ClamAv·Dexter·Boston Legal 은 소유자 결정으로 Commons 의 같은 대상 로고(자유 파일, 위키백과 문서가 지금 쓰는 그림)로 바꿨다(2026-10-04, 사소한 편집) — 그 넷과, 썸네일이 퍼져 있던 이웃 페이지들이 이제 깨끗하다. 남은 넷은 다른 그림을 고르거나 지운다: 카지노(영어 위키백과에 새 타이틀 카드가 있지만 비자유), The Blacklist·Prison Break·Westworld(위키백과 문서에 대표 그림이 없다).
    • 위키미디어 밖은 전체 스윕(2026-10-04)으로 봤다. 바꿀 주소가 분명한 것은 고쳤다 — 사소한 편집이고, 고친 그림이 브라우저에서 뜨는 것을 확인했다. ZombieCafeLayout(서버의 파일은 .png 인데 .PNG 로 불렀다), WebSequenceDiagramsDotCom(임베드 스크립트를 https 로 — http 스크립트는 https 페이지에서 막혀 다이어그램 셋이 그려지지 않았다), Prisma·Normalize.css(GitHub camo 주소가 403 이라 camo 가 감싸던 원래 주소로), TracTicketSystem(htdocs: 경로를 Trac 공식 사이트의 같은 그림으로), Burnout(시사저널이 사진을 cdn.sisajournal.com 으로 옮겼다 — 기사가 같은 파일을 그 주소로 쓴다).
    • 남은 것은 소유자 판단이다. 그림이 사라짐: Passport.js(camo 가 감싸던 auth0 배너도 403), Perfect Blue·블랙머니(w.namu.la 도메인이 없어졌다), 자신(페이스북 CDN 서명 만료), 티구시포(imweb 403), Waveform(bbc.co.uk 서버가 503, 인증서도 안 맞는다), GoogleChartTools(Google Image Charts API 가 닫혔다). 막힘: ProjectEulerProblem15(projecteuler.net 이 403 — 봇 차단), Hadouken(트위터 영상이 다른 사이트에서 부르면 403, Referer 없이는 200 이고 <video> 에는 referrerpolicy 가 없다). 그 밖: 소녀시대(없어진 플래시 임베드, http), Windows7(없는 MicrosoftWindows7 로 가는 리다이렉트), PlantUML(예시 다이어그램이 지금 표준 라이브러리에 없는 awslib/Compute/EC2ContainerRegistry.puml 을 include 해서 오류 그림이 나온다).
    • 내용 스캔(API 로 본문의 그림 주소를 모아 직접 받아 본 것)에는 걸렸지만 브라우저에서는 멀쩡한 것: YouTube(문서의 예시 주소), 분개(구글 문서 iframe), ColorTemperature(출처 링크), 흡연관련법령(http 주소를 브라우저가 https 로 올려 받는다), TracSettingWikiCustomize(코드 예시). 페이스북 글 임베드 9개는 iframe 안이라 스윕이 보지 못한다.
    • 링크(불러오지 않고 가리키기만 하는 것)는 다 보지 않았다. 내용 스캔에 걸린 죽은 링크: 지워주세요(fs.textcube.com), 조형기·내가만약여자라면(clien.career.co.kr), 내가원하는글꼴(lowing.org), WhatTheCustomerWants(effectiveeng.com·blog.enzoy.kr).

1.18. 쓰지 않는 의존성

  • 릴리스가 모든 AWS 서비스의 SDK 를 실었다 — 코드가 쓰는 것은 S3 뿐이다. S3 모듈만 남기고, 코드가 부르는 곳이 하나도 없던 넷(google-oauth-client·scala-async·parboiled·jackson-module-scala)을 build.sbt 에서 지웠다. 그중 parboiled·jackson-module-scala 는 Play 쪽이 더 새 버전으로 끌어와 원래 그 버전이 실리고 있었고, 지금도 그대로 실린다. 지운 것 하나가 S3 밑의 HTTP 클라이언트 버전을 올려 주고 있어서, 그 버전은 그대로 두었다(Dev Attachment S3 클라이언트). 고쳤다(2026-10-04), 배포 전. 스펙은 S3 를 부르지 않으므로 canary 에서 첨부 업로드·썸네일·Admin 의 S3 Browser 를 눌러 본 뒤 닫는다. --2026-10-04
    • 배포했다(2026-10-04 23:15 canary · 23:18 나머지, 태그 v20261004T231606-aha00a). ahawiki.net 228경로는 두 대가 모두 같았다. S3 는 확인한 길이 처음 계획과 다르다 — 읽기는 루프백으로 그 대를 골라 볼 수 있었고, 쓰기는 고를 수 없어서 두 대를 다 올린 뒤 봤다(방법은 Dev Attachment S3 클라이언트). 읽기: aha00a.com 의 첨부 목록이 두 대에서 같았다(목록 조회 성공, 서명 URL 하나, S3 의 답은 둘 다 NoSuchKey 라 서명은 맞다). 쓰기: 같은 페이지에 8×8 PNG 를 올려 서명 URL 로 열리는 것을 보고 지웠고(10-05 03:33, 각각 200), 목록에서도 S3 에서도 사라졌다. 기동 뒤 두 대의 로그에 ERROR·WARN·클래스 누락은 없었다.
    • 그 HTTP 클라이언트 버전 고정은 같은 날 S3 를 SDK 2.x 로 옮기며 뺐다 — 2.x 는 Apache HttpClient 5 로 돌고, httpclient 4 를 찾는 것이 남지 않았다(아래 S3 를 AWS SDK 2.x 로). --2026-10-04
  • aha00a.com 신과함께-죄와 벌 의 첨부 하나는 DB 에는 있는데 S3 에는 객체가 없다(서명 URL 에 NoSuchKey). 첨부 목록은 그것을 integrityStatus OK 로 보여 준다 — 서명 URL 을 만들 수 있으면 OK 이고, 객체가 있는지는 보지 않는다. --2026-10-05
    • 전부 맞춰 봤다(2026-10-05). 살아 있는 첨부 행 36개(aha00a.com 7 · 사이트 15 29개) 가운데 객체가 없는 것은 이 하나(seq 1, 맨 첫 첨부)뿐이고, 나머지 35개는 키와 크기가 S3 와 같다. 행 없이 S3 에만 있는 객체도 없다.
    • 쓰인 적이 없는 첨부다. 2026-05-03 03:01:46 에 편집 중 붙여 넣기로 올라갔고, 36초 뒤 저장된 리비전 4 에는 그 그림이 없다. 다른 리비전에도 없다. 보기 화면은 첨부 목록을 그리지 않으므로(MacroAttachmentList 는 05-05 에 지웠다) 독자에게는 보이지 않는다. 깨진 항목이 보이는 곳은 그 페이지 편집 화면의 첨부 칸뿐이다.
    • 언제 어떻게 사라졌는지는 알 수 없다 — 버킷에 버전 관리가 없고, 앱 로그와 AccessLog 는 한 달, nginx 로그는 2주만 남는다. 페이지 삭제 길은 아니다: 그 길은 지운 객체의 행을 markDeleted 로 함께 표시하는데 이 행은 Uploaded 그대로다. 행을 남기고 객체만 지우는 길은 관리 화면의 S3 Browser 삭제(ApiAdminS3.adminDeleteS3Objects, 05-05 에 생겼다)다.
    • 정리했다(2026-10-05 04:54). 아래 두 고침을 배포한 뒤 그 행이 DB_ONLY 로 나오는 것을 보고, S3 Browser 삭제로 그 키를 지웠다 — S3 는 없는 키도 지웠다고 답하므로 행이 Deleted 로 표시됐고(markedAttachmentRows 1), 그 페이지의 첨부 목록은 비었다. 텔레그램 알림은 가지 않는 길이다.
  • S3 Browser 의 삭제는 객체만 지우고 Attachment 행은 그대로 둔다 — 위와 같은 행이 다시 생길 수 있다. 지운 키의 행을 markDeleted 로 함께 표시하면 된다. --2026-10-05
    • 고쳤다(2026-10-05), 배포 전. S3 가 지웠다고 응답에 적은 키만 표시하고, 일부 키가 실패해도 지워진 쪽은 표시한다. 그러려고 S3Logic.deleteObjects 가 실패한 키만이 아니라 지운 키도 돌려준다(S3LogicSpec). 이 길이 실제 S3 로 도는지는 운영에서 본다.
    • 배포했다(2026-10-05 04:49 canary · 04:51 나머지, 태그 v20261005T044925-aha00a). 실제 객체 하나(시험으로 올린 클립보드 그림)를 S3 Browser 로 지우니 markedAttachmentRows 1 이었고, 그 행은 Deleted, 목록에 S3_ONLY 로 남지도 않았다. 위 seq 1 처럼 객체가 없는 키도 같은 결과였다.
  • 첨부 목록의 integrityStatus 가 객체가 있는지 보지 않는다. 같은 응답이 이미 그 페이지의 S3 목록을 읽고 있으니, 거기 없는 행을 따로 표시하면 S3 요청을 더하지 않고 알 수 있다. --2026-10-05
    • 고쳤다(2026-10-05), 배포 전. 새 상태를 만들지 않고 DB_ONLY 를 쓴다 — 편집 화면이 이미 «S3 확인 필요» 로 빨갛게 보여 주고 삭제 버튼도 남겨 두는 값이고, 뜻(DB 에만 있다)도 그대로 맞는다. 판정 규칙은 AttachmentLogic.integrityStatus 와 그 스펙. 위 36행에 같은 규칙을 적용해 보면 seq 1 하나만 DB_ONLY 다. 배포 뒤 seq 1 이 실제로 DB_ONLY 로 나오는지 보고, 그다음 그 행을 정리한다.
    • 배포했다(같은 태그). canary 만 새 코드였을 때 루프백으로 두 대에 물으니 seq 1 이 옛 대에서는 OK, 새 대에서는 DB_ONLY 였다. 새로 올린 첨부 둘은 OK 였다.

1.19. S3 를 AWS SDK 2.x 로

  • S3 코드를 AWS SDK for Java 1.x(2025-12-31 지원 종료)에서 2.x 로 옮겼다(2026-10-04). 무엇이 바뀌었고 무엇을 골랐는지는 Dev Attachment S3 클라이언트. 스펙은 빈 AWS 설정으로 돌아 S3 에 닿지 않으므로, 실제 S3 로는 canary 에서 확인해야 한다 — 첨부 올리기(파일·클립보드 이미지)와 그 행의 etag(따옴표 없이 들어가야 한다), 첨부 목록과 지우기, 첨부가 있는 페이지 지우기(시험용 페이지로), 썸네일(인접 페이지 그래프·미리보기), favicon 올리기·지우기, 관리 화면 S3 Browser(목록·지우기·다운로드 URL). canary 대조에서는 서명 URL 이 든 페이지가 모두 «바뀜» 으로 나온다 — 쿼리 순서 때문이고, 버킷 이름에 점이 있으면 호스트도 바뀐다(Dev Attachment presigned URL). 그 밖의 차이가 나오면 그것이 문제다. --2026-10-04
    • 배포했다(2026-10-05 04:49 canary · 04:51 나머지, 태그 v20261005T044925-aha00a, 위 첨부 고침과 함께). ahawiki.net 228경로는 두 대가 모두 같았다 — 이 사이트에는 첨부가 없어 서명 URL 차이도 나오지 않았다. 기동 뒤 두 대의 로그에 ERROR·클래스 누락은 없었다. 실제 S3 로 확인한 것:
      • 읽기(canary 루프백): aha00a.com 첨부 목록이 S3 목록 조회와 함께 200, 2.x 로 서명한 URL 에 S3 가 NoSuchKey(서명이 틀리면 403).
      • 올리기(Chrome, aha00a.com 신과함께-죄와 벌): 파일 첨부와 클립보드 그림 각각 200, 서명 URL 로 열렸다. 행의 etag 는 따옴표 없이 S3 의 ETag 와 같았고, S3 객체의 Content-Type 은 image/png 였다.
      • 지우기: 편집 화면의 단일 삭제(deleteObject)와 S3 Browser 의 일괄 삭제(deleteObjects) 모두 200, 둘 다 행이 Deleted 가 됐다.
      • 실제 객체의 서명 URL: S3 Browser 다운로드 URL, 미리보기 그림(/api/pagePreview), 인접 페이지 그래프 썸네일이 모두 열렸다(aha00a.com HJH1 의 1080px 그림). S3 Browser 목록은 aha00a.com 객체 6개를 잘림 없이 돌려줬다.
      • favicon: ahawiki.net(원래 S3 favicon 없음)에 올려 서명 URL 로 열리는 것을 보고 지웠다 — 설정은 기본값으로 돌아갔고 S3 객체도 사라졌다(head-object 404).
      • 시험으로 올린 것은 모두 지웠다. 첨부 올리기 둘과 단일 삭제 하나는 텔레그램 알림을 하나씩 보냈다.
  • 첨부가 있는 페이지 지우기(AttachmentLogic.deletePageAttachments)는 SDK 2.x 로 실제 S3 에서 아직 안 해 봤다 — 시험용 페이지를 만들고 지워야 해서 남겼다. 쓰는 S3 호출(목록, S3Logic.deleteObject)은 위에서 각각 확인했다. --2026-10-05
    • 해 봤다(2026-10-05 06:00). aha00a.com 에 시험용 페이지를 API v1 로 만들고, 첨부 둘(파일·클립보드)을 올리고, 그 접두사에 DB 행 없는 객체 하나를 S3 에 직접 넣었다 — 목록에는 OK 둘과 S3_ONLY 하나. API v1 로 페이지를 지우니 200, 페이지는 404, 접두사 아래 객체는 셋 다 사라졌고(행 없는 것은 S3 목록으로 찾아 지운 것이다) 두 행은 Deleted 가 됐다. 다른 페이지의 첨부 객체 수는 그대로였고 로그에 오류는 없었다.

1.20. JVM 의 메모리와 CPU

위키 JVM 둘이 hatchery 에서 메모리를 가장 많이 쓰고, 상자 swap 의 대부분이 위키 몫이다 — 운영 저장소의 server/README.md. 2026-10-07 06:05 에 잰 것(각 인스턴스, 2일 가동): RSS 약 700MB 에 swap 430~520MB, 힙은 314MB 커밋, Metaspace 140MB, glibc malloc 아레나 12개가 381MB(그중 324MB 가 swap). 시작 옵션은 -Xms128m -Xmx512m -XX:+ExitOnOutOfMemoryError 뿐이고 GC 는 G1 이다.

  • blame 이 모든 리비전 본문을 한 번에 읽었다 — 20개씩 읽는다. 검색이 걸린 페이지를 개수 제한 없이 다 읽고 줄마다 정규식을 컴파일했다 — 100페이지·페이지당 20줄로 묶고 robots 로 막았다. 다른 사이트 페이지를 볼 때마다 모든 사이트의 페이지 목록을 Redis 에서 받아 해독했다 — 버전 키로 해독한 값을 다시 쓴다. 무엇을 왜는 Dev Page «본문을 많이 읽는 두 경로», Dev Cache «해독한 값을 프로세스에 둔다». --2026-10-07
    • 배포했다(2026-10-07 07:01 canary 10001 · 07:04 10000, 태그 v20261007T070128-aha00a, 아래 렌더 CPU 고침과 함께). ahawiki.net 228경로는 두 대가 모두 같았고, 가장 큰 사이트에서 무작위로 고른 40쪽도 같았다. canary 와 옛 대를 루프백으로 같은 시각에 대어 보니 /search?q=a 가 14.6MB·2.39초 → 0.57MB·0.17초(92건, 100건 넘침 안내가 나온다), q=의 가 4.7MB·0.46초 → 0.47MB·0.13초. blame 과 페이지 보기는 바이트가 같았다.
    • 7시간 22분 가동한 14:26 에 본 것(판정은 아래 재측정으로): RSS 630·488MB, swap 0·8MB, 아레나 2·1개, 상자 swap 382MB. 재시작 뒤 ERROR·5xx·캐시 오류 0건. 접근 로그의 /w/ 200 응답 시간(루프백 제외, 표본 약 230건씩)은 00:00~07:0x → 07:0x~14:26 에 p50 97·99ms → 62·59ms, p90 555·706ms → 163·199ms — 시간대가 달라 대략으로만 읽는다.
    • 며칠 가동한 뒤 두 대의 RSS·swap 과 아레나 수를 고치기 전(위 절 머리의 06:05 값)과 같은 방법으로 다시 잰다. 재는 법은 운영 저장소 ahawiki/README.md «MALLOC_ARENA_MAX=2». --2026-10-10
      • 3일 4시간 가동한 2026-10-10 11:48(재시작 없음). 10000·10001 이 RSS 595·548MB + swap 637·511MB = 1,232·1,058MB — 배포 전 1,103·1,156MB 와 비슷하다. 줄지 않았다. 상자 swap 1,712MB(배포 직전 1,580MB).
      • 60~66MB 익명 매핑은 8·7개, 501·379MB(그중 swap 398·291MB) — 배포 전 12개 381MB 와 크기가 같다. 아레나는 설정대로 2개를 넘을 수 없으니 이 매핑들은 아레나 하나가 64MB 씩 이어 붙인 덩어리이고, 이 방법은 아레나가 아니라 그 덩어리를 센다. 대부분 swap 에 있어 다시 쓰이지 않는 메모리로 보인다.
      • 힙 커밋 430·387MB(사용 308·208MB, swap 119·95MB) — 배포 전 314MB 보다 크다. Metaspace 135·124MB 는 그대로.
      • 판정: MALLOC_ARENA_MAX=2 는 아레나 수를 묶었지만 malloc 이 쥔 양은 줄이지 못했고, 힙 커밋은 오히려 늘었다. 인스턴스당 약 1.1GB 그대로.
  • malloc 이 쥔 채 swap 으로 간 400~500MB — 이 JDK(17.0.20)에 -XX:TrimNativeHeapInterval 과 jcmd <pid> System.trim_native_heap 이 있다. 한 대에서 먼저 시험해 볼지 소유자 판단. 무엇이 malloc 하는지는 -XX:NativeMemoryTracking=summary 로 재시작해야 보인다. --2026-10-10
    • NMT 로 보이는 것은 일부다(2026-10-11, 10000 의 jcmd 로 확인한 것과 JDK 문서). 시작할 때만 켤 수 있어 재시작이 들고(지금 VM.native_memory 는 «not enabled»), JVM 자신이 잡은 메모리(컴파일러·GC·스레드·심볼 등, DirectByteBuffer 는 «Other»)만 센다. zlib 처럼 JDK 의 네이티브 라이브러리가 직접 부른 malloc 은 세지 않는다. 또 지금 잡혀 있는 양만 세므로, trim 이 돌려준 «free 된 채 malloc 이 쥔» 빈자리는 NMT 에 나타나지 않는다 — 시점을 두고 summary.diff 로 어느 분류가 크게 오르내리는지 보는 데까지다. glibc 쪽 사정(아레나별 빈자리)을 보여 주는 System.native_heap_info 는 이 빌드(17.0.20.1)의 jcmd 에 없다. --2026-10-11
    • 소유자 승인으로 10001 한 대에 jcmd <pid> System.trim_native_heap 을 한 번 했다(2026-10-10 18:21, 재시작 없음 · 10000 은 비교용으로 그대로). RSS+swap 1,059 → 718MB, 그 덩어리가 약 320 → 55MB 로 줄었다 — free 된 채 malloc 이 쥐고 있던 메모리였다. 걸린 시간 약 1초. 영역별 분해·재는 법·숫자의 정본은 운영 저장소 ahawiki/README.md «AhaWiki 의 swap — 무엇이 쥐고 있나». --2026-10-10
    • 10000 에도 같은 것을 했다(2026-10-10 20:15, 소유자 지시). RSS+swap 1,232 → 795MB, 그 덩어리 502 → 74MB, 0.3초. 이제 비교용으로 남긴 인스턴스는 없다. --2026-10-10
    • 며칠 가동한 뒤 두 대(10001 은 18:21, 10000 은 20:15 부터)의 그 덩어리가 다시 자라는 속도를 같은 방법으로 잰다. 자라면 -XX:TrimNativeHeapInterval 을 운영 유닛 드롭인으로 넣는다(재시작이 들어 소유자 판단). --2026-10-10
      • 재지 않고 넣었다 — 소유자 지시(2026-10-10 23:06). 10001 이 trim 뒤 2시간에 55 → 101MB 로 다시 자란 것까지 봤다. 운영 유닛 드롭인의 JAVA_OPTS=-XX:TrimNativeHeapInterval=60000, 효력은 다음 재시작(배포)부터이고 지금 도는 두 대는 그대로다. 60초를 고른 이유·확인한 것·되돌리기는 운영 저장소 ahawiki/README.md «JAVA_OPTS — 유닛 드롭인»(그때 이름은 «TrimNativeHeapInterval»).
    • 다음 배포 재시작 뒤 10000 의 java 명령줄에 플래그가 있는지 보고(10001 은 아래 «평소 한 대» 뒤로는 배포 동안만 뜬다), 며칠 뒤 그 덩어리가 trim 직후 수준(55~74MB)에 머무는지와 표본기의 fault 수가 늘지 않았는지를 같은 방법으로 잰다. --2026-10-10
      • 배포보다 먼저 상자 재부팅(2026-10-11 04:17:52)으로 두 대에 들어갔다. 21:10 에 두 대 모두 TrimNativeHeapInterval = 60000 {command line} 을 확인했다. 며칠 뒤 재는 일은 남았다.
      • 주기 trim 전의 재성장(1회 trim 뒤 재부팅까지 8~10시간, cgroup 합이라 위쪽 한계): 10001 약 190MB/일, 10000 약 235MB/일. 재부팅 뒤 18.5시간(주기 trim 60초)의 비주 아레나 힙은 68·55MB 로 trim 직후 수준이고, major fault 는 시간당 약 250 이라 60초를 유지한다. 숫자는 운영 저장소 ahawiki/README.md «1회 trim» 의 «1일 뒤 재측정». --2026-10-11
  • MALLOC_ARENA_MAX=2 — 아레나 381MB 가 힙 다음으로 크다. Tikize 유닛들이 같은 설정을 이미 쓴다(운영 저장소 server/README.md «glibc 셋»). 유닛 파일(ahawiki@.service)은 운영 쪽 변경이고 재시작이 들어 소유자 판단. --2026-10-07
    • 소유자가 승인했다. 유닛 드롭인으로 넣고 위 배포의 재시작으로 두 대에 들어갔다. 재시작 직후 두 대 모두 아레나 1개(약 60MB). 경위·되돌리기는 운영 저장소 ahawiki/README.md. --2026-10-07
  • G1 이 한 번 커진 힙을 OS 에 잘 돌려주지 않는다. -XX:G1PeriodicGCInterval 이나 힙 512MB 규모에 맞는 -XX:+UseSerialGC 는 실측 비교 뒤에 정한다. --2026-10-07
    • 2026-10-10 재측정에서 힙 커밋이 314MB → 430·387MB 로 늘었다(위 «며칠 가동한 뒤»). G1PeriodicGCInterval 은 manageable 이라 재시작 없이도 켤 수 있다.
    • 왜 돌려주지 않나(2026-10-10 21:00, 3.6일 가동한 두 대의 jstat). 힙을 줄이는 일은 동시 표시 주기의 Remark(와 Full GC)에서만 일어나는데, 그 주기가 3.6일에 5번(약 17시간에 한 번)뿐이다. 그때도 MaxHeapFreeRatio 기본값 70 이 빈 칸을 70% 까지 허용해 줄일 것이 없다. 10000 은 커밋 430MB 중 eden 이 254MB, young GC 는 약 5분에 한 번 13ms. 그러니 G1PeriodicGCInterval 은 주기를 더 자주 돌릴 뿐 혼자서는 줄이지 못한다. 줄이는 손잡이는 MinHeapFreeRatio·MaxHeapFreeRatio 이고 둘 다 manageable 이다. --2026-10-10
    • 소유자 지시로 10001 에서 시험했다(2026-10-10 21:01, 재시작 없음 · 10000 은 대조로 그대로). jcmd <pid> VM.set_flag 로 MinHeapFreeRatio 10, MaxHeapFreeRatio 30, 주기를 바로 일으키려고 G1PeriodicGCInterval 60000 — 주기가 돈 뒤 21:02:27 에 0 으로 되돌렸다. 30초 안에 힙 커밋 396 → 143MB, RSS+swap 786 → 535MB. 뒤따른 mixed GC 로 old 사용 92 → 64MB. Full GC 는 없었다. --2026-10-10
      • 대가: 그 주기의 Remark·Cleanup 멈춤이 합쳐 1.95초였다(그 전 주기들은 한 번에 약 4ms). 같은 5분에 10001 의 major fault 가 8,249건(평소 수십 건), 상자 pswpin 약 97MB — swap 에 나가 있던 힙·메타데이터를 표시하며 다시 읽은 것으로 본다. 21:01:40~21:03:30 의 요청 9건 중 300ms 를 넘은 것은 없었다.
      • jcmd 로 바꾼 값은 재시작(다음 배포)에서 사라진다.
    • 하루 이상 지난 뒤 10001 과 10000 을 비교한다: 힙 커밋, RSS+swap(표본기), young GC·동시 표시 주기의 횟수와 멈춤 시간. jstat 값은 누계라 2026-10-10 21:05 기준값을 뺀다 — 10001 YGC 1102 · YGCT 13.165s · CGC 10 · CGCT 1.986s, 10000 YGC 889 · YGCT 11.632s · CGC 10 · CGCT 0.053s. Remark 가 계속 길면 그 대가를 같이 본다. 괜찮으면 운영 유닛 드롭인의 JAVA_OPTS 에 공백으로 이어 배포 재시작에 얹는다. 다음 배포의 재시작이 이 시험을 지우므로, 비교는 그 배포 전에 한다. --2026-10-10
      • 하루를 못 채웠다. 상자가 2026-10-11 04:14 에 재부팅돼(커널 갱신) 두 대가 04:17:52 에 새로 떴고, 시험은 약 7시간 13분(21:01~04:14)으로 끝났다. jstat 누계도 함께 사라져 그 구간의 young GC·동시 표시 멈춤 횟수와 시간은 잴 수 없다. GC 로그는 켜져 있지 않다. --2026-10-11
      • 표본기(cgroup 기준 mem+swap, 21:05 → 04:10): 10001 759 → 797MB, 10000 932 → 988MB. 시험 전(21:00)에는 10001 이 960MB 로 더 컸으니, 시험 뒤 약 190MB 작게 유지됐다. 두 대 모두 7시간에 38·56MB 자랐다 — 이때는 아직 trim 이 없었다. major fault 증가는 10001 16,643건, 10000 21,206건으로 비슷하다(10001 은 01:25~01:55 에 약 12,000건이 몰렸다).
      • 접근 로그(21:05:33~04:14:30, 403 제외): 200 응답 10001 2,286건, 10000 2,272건. 1초 넘은 것은 둘 다 0건, 가장 긴 것 990·974ms. 느려진 흔적은 없다.
      • 판단: 힙 비율을 낮춘 쪽이 메모리를 덜 쓴 채 유지됐고 응답은 같았다. 대가로 본 것은 처음 줄일 때의 Remark 1.95초 한 번뿐인데, 그 뒤 GC 시간이 늘었는지는 재부팅으로 확인하지 못했다. 재부팅 뒤 기본값(40/70)으로 17시간 돈 두 대의 힙 커밋은 282·289MB. 평소 한 대가 요청을 다 받으면 할당이 늘어 young GC 가 잦아지므로, 넣는다면 GC 시간을 함께 재야 한다. --2026-10-11
    • 운영 유닛 드롭인의 JAVA_OPTS 에 -XX:MinHeapFreeRatio=10 -XX:MaxHeapFreeRatio=30 을 넣을지 소유자 판단. 권고는 넣는 것, 그리고 같이 -Xlog:gc 를 파일로 켜서 다음 비교가 jstat 누계에 기대지 않게 하는 것. 평소 한 대가 된 뒤에는 AHAWIKI_RESTART_ONLY=1 로 적용한다 — 대기 인스턴스가 받는 동안이라 거의 끊기지 않지만, 첫 인계에서 주 인스턴스가 내려가는 7초에 503 한 건이 샜다(위 «인계 중 … 503»). --2026-10-11
      • 넣었다(2026-10-12 05:15, 소유자 «진행해»), -Xlog:gc 와 함께 — 수집마다 한 줄, 로그 디렉터리에 포트마다 하나(10MB × 5). 같은 옵션으로 java -version 을 서비스 계정으로 먼저 띄워 봤고, AHAWIKI_RESTART_ONLY=1 인계로 10000 에 들어갔다(05:16:14 기동, 명령줄에 넷 다 있다). 경위·되돌리기는 운영 저장소 ahawiki/README.md «JAVA_OPTS — 유닛 드롭인». --2026-10-12
    • 며칠 뒤 GC 로그로 10000 의 young GC 간격·멈춤, 동시 표시 주기의 Remark 멈춤, 힙 커밋을 본다 — 한 대가 요청을 다 받게 된 뒤 GC 가 얼마나 잦아졌는지, Remark 가 길어지는지. 힙 커밋은 재부팅 뒤 기본값(40/70)으로 282·289MB 였다. --2026-10-12
  • 평소 한 대로 운영하고, 배포 때만 대기 인스턴스를 띄운다 — 소유자 결정(2026-10-11). 인스턴스 하나가 상자 메모리의 4분의 1 안팎이다. 무엇을·왜·대가·되돌리기는 Dev Deploying «평소에는 한 대, 배포 때만 두 대». --2026-10-11
    • deploy.sh 가 대기 인스턴스를 먼저 띄우고, 프록시를 reload 해 실패 기억을 지운 뒤 10000 을 재시작하고 대기 인스턴스를 내린다. canary(AHAWIKI_CANARY)와 빌드 없는 인계(AHAWIKI_RESTART_ONLY)를 더했다. 서버에 닿지 않게 가짜 ssh·curl 로 경우마다 돌려 보낸 명령의 순서를 봤다 — 정상, 대기 인스턴스가 안 뜸·안 그림, 10000 이 새 릴리스에서만 안 그림·늘 안 그림, 빌드 없는 인계에서 10000 이 안 그림, 대기 인스턴스만 서비스 중인 상태, canary, reload 실패. 그러다 10000 이 실패하면 «대기 인스턴스가 대신 받는다» 고 알리고 끝내던 첫 모양이 틀렸음을 찾았다 — 프록시는 10000 이 무엇이든 답하면 그쪽으로 돌아간다. --2026-10-11
    • 운영 쪽: 프록시에서 10001 을 backup 으로 두고, 10001 을 disable 한다. 10001 의 힙 비율 시험을 비교한 뒤(위 «하루 이상 지난 뒤»), 소유자 확인을 받아서. 할 일과 명령은 운영 저장소 ahawiki/README.md. --2026-10-11
      • 프록시는 했다(2026-10-11 22:49, 10001 을 backup 으로, nginx -t 통과·reload). 10001 의 disable 과 첫 인계는 예약 작업의 권한 검사에서 거부돼 하지 않았다 — 10001 은 아직 enabled·active 로 backup 자리에 떠 있다. 백업 파일과 남은 명령은 운영 저장소 ahawiki/README.md. --2026-10-11
      • 10001 disable 과 첫 인계(AHAWIKI_RESTART_ONLY=1)를 했다(소유자 «진행해»). 이제 10000 만 enabled·active 다. --2026-10-12
    • 새 deploy.sh 의 첫 실제 인계(배포든 AHAWIKI_RESTART_ONLY=1 이든)를 본다 — 프록시를 거친 요청이 그동안 끊기지 않는지, 끝나고 10001 이 내려가 있는지. --2026-10-11
      • 2026-10-12 04:39~04:40 AHAWIKI_RESTART_ONLY=1. 순서대로 됐다: 10001 기동 → 10000 재시작 → 10001 내림. 외부 확인 URL 셋 다 200, 끝나고 10001 은 inactive 다. --2026-10-12
      • 끊김이 한 건 있었다. 10000 이 내려가던 7초(04:39:55~04:40:02) 사이에 들어온 GET 하나가 503 을 받았다(같은 몇 분 동안 요청 ~940건 중). 내려가는 서버가 503 으로 답했고, 프록시는 기본 설정상 503 을 backup 으로 넘기지 않는다. --2026-10-12
    • 인계 중 주 인스턴스가 내려가는 동안의 503 을 막을지 정한다. 막는 길은 둘이다: 프록시가 503 도 backup 으로 넘기게 하는 것(운영 저장소 ahawiki/README.md «평소 한 대» ⚠), 또는 deploy.sh 가 10000 을 멈추기 전에 프록시에서 먼저 빼는 것. 한 건이라 급하지 않다. --2026-10-12
      • 프록시 쪽으로 막았다(2026-10-12 05:15, 소유자 «진행해»). 위키로 가는 모든 location 이 프록시 설정 조각 하나를 include 하고, 거기서 503 도 backup 으로 넘긴다. 앱에는 503 을 스스로 내는 곳이 없어(app/ 에서 확인) 다시 보내는 것은 내려가는 인스턴스의 503 뿐이고, POST 는 프록시 기본대로 다시 보내지 않는다. 바로 뒤의 인계(05:15:44~05:16:36)에서 1초마다 프록시로 부른 /hc 72건이 모두 200 이었다. 무엇을 어디에 바꿨는지는 운영 저장소 ahawiki/README.md «앞단 nginx». --2026-10-12
  • 인계 끝에 대기 인스턴스를 내릴 때 그것이 붙잡고 있던 요청이 503 을 받는다. 2026-10-12 05:16:36 에 두 건(그 창 요청 620건 중) — 차단된 주소의 요청을 10001 이 tarpit 으로 붙잡고 있던 중(05:16:14 부터, Dev BotDetection)에 내려갔고, Play 의 종료는 진행 중 요청을 5초 기다린 뒤 503 으로 끊는다. backup 으로 간 요청을 프록시가 주 인스턴스로 되돌리지는 않는 것으로 보인다. 정상 요청은 1초 안에 끝나니 지금 걸리는 것은 차단된 봇뿐이다. 막으려면 deploy.sh 가 대기 인스턴스를 내리기 전에 그 연결이 빠지기를 기다리면 된다(tarpit 이 최대 55초라 배포가 그만큼 길어진다). 막을지 소유자 판단 — 권고는 두는 것. --2026-10-12
    • 두기로 했다(2026-10-12, 소유자가 판단을 맡겼다). 걸리는 것은 차단된 주소의 tarpit 요청뿐이고, 그것도 끊기지 않았다면 어차피 403 을 받았다. 막는 값은 배포마다 대기 인스턴스의 연결이 빠지기를 최대 55초 더 기다리는 것인데, 웹소켓은 스스로 끊기지 않으니 독자가 붙어 있으면 늘 끝까지 기다린다. 정상 요청이 5초 넘게 걸리는 경로가 생기면 다시 본다 — 인계 직후 접근 로그에서 «대기 인스턴스를 내린 시각의 503» 을 세면 알 수 있다. --2026-10-12
  • 렌더의 CPU — 짐작으로 고르지 않고 재서 골랐다. 이 저장소의 docs/ahawiki.net/ 114쪽을 H2 에 넣고 /w/… 로 다섯 번씩 그리며 JFR 로 표본을 떴다(스펙 하나를 임시로 써서 돌렸고 커밋하지 않았다). 고치기 전 한 쪽에 17.33ms, 고친 뒤 9.80ms. 고친 뒤 114쪽의 HTML 은 고치기 전과 글자 하나 다르지 않았다(푸터의 Uptime 만 가리고 비교). 무엇이 컸고 무엇을 바꿨는지(괄호는 고치기 전 렌더 시간 중 몫): --2026-10-07
    • ExtractConvertInject.inject 가 키마다 String.replace 로 페이지 전체를 복사했다(11%). 키 자리를 먼저 다 찾고 한 번에 짓는다 — 같은 결과인 이유는 replaceKeys 의 주석과 ExtractConvertInjectSpec.
    • 링크마다 AhaMarkLink 이 국가 코드 집합(약 250개)을 새로 만들었다(6%) — 동반 객체로. 날짜 모양 검사의 String.matches 가 링크마다 패턴을 다시 컴파일했다 — 컴파일된 Regex.matches 로.
    • 없는 페이지로 가는 링크마다 DefaultPageLogic 이 클래스패스(jar)를 뒤졌다(4%) — 답을 기억한다.
    • [ 도 :// 도 없는 줄에서도 링크 정규식을 돌렸다 — 건너뛴다. inlineToHtmlString 의 다섯 정규식과 백틱 정규식 둘을 호출마다 컴파일했다 — 한 번만.
    • PartialEdit.revision 이 리비전 번호 하나를 얻으려고 본문 전체와 User 조인을 읽었다(3%) — 번호만 읽는다(Page.selectLastRevisionNumber). 같은 요청 안에서 저장 뒤 다시 그리는 길이 있으면 낡을 수 있어서 컨텍스트에 기억시키지는 않았다. 블록이 없는 페이지는 아예 묻지 않는다.
    • Wiki.view 가 최신 리비전을 두 번 읽었다(Page.select(name, 0) 이 다시 읽는다) — 한 번.
    • 배포했다(위와 같은 태그). 대조 결과도 위와 같다 — 두 대가 그린 HTML 이 같았다.
  • ApplicationConf 가 설정값을 읽을 때마다 스레드 스택 트레이스를 뜬다(fqn). 고치지 않기로 했다 — 위 측정에서 렌더 시간 중 0.0% 였다. 그 측정은 필터를 끈 채라 FilterAccessLog 의 한 번은 빠졌지만, 요청마다 한 번이면 렌더 한 번에 비해 작다. 메서드 이름이 곧 설정 키라는 2015년부터의 설계를 바꿀 값이 없다. --2026-10-07
  • 남은 렌더 비용 중 큰 것은 TraitInterpreter.toText(jsoup, 고친 뒤 11%) — 페이지 설명이 PageMeta 에 없을 때 PageLogic.extractDescription 이 본문을 다시 그려 글자만 뽑는다. 측정용 H2 에는 설명이 없어 매번 돌았고, 운영에서는 설명이 있는 페이지가 많아 덜하다. 운영에서 얼마나 도는지 보고 정한다. --2026-10-07
    • 도는 경우는 둘이다(Wiki.scala 의 보기, 2026-10-12 코드로 확인). ① 최신 리비전이 아직 계산되지 않았을 때 — 편집 직후 잠깐이다. ② 최신 리비전으로 계산됐는데 설명이 없을 때 — 뽑을 문장이 없는 페이지(표·매크로만 있는 페이지 등)다. ② 는 다시 뽑아도 같은 코드가 같은 글에서 또 «없음» 을 내니 순전히 헛일이고, 그 페이지를 볼 때마다 돈다. 막는 법은 작다: 페이지 목록의 그 행이 지금 보는 리비전이면 그 설명(없음 포함)을 그대로 쓴다. 얼마나 되는지는 관리 화면의 PageMeta 목록(/api/Admin/Site/:seq/PageMetaList)에서 설명이 빈 행을 사이트마다 세면 안다 — 공개 API 로는 셀 수 없고 관리자 세션이 든다. --2026-10-12
  • 페이지 조회의 10% 가 재계산(Calculate)을 보낸다 — 렌더 두 번과 유사도 셀프 조인이고, 봇의 조회도 센다. 줄일지는 재계산이 무엇을 늦게 하게 되는지 보고 정한다. --2026-10-07
    • 무엇이 늦어지나 — 비슷한 페이지 목록뿐이다. 계산이 만드는 나머지는 그 페이지 자신의 글에서 나오고 편집마다 계산이 돈다. 유사도 행은 계산한 페이지 쪽에서만 쓰므로 다른 페이지의 편집이 이 페이지의 목록에 닿는 길이 보기의 재계산이다. 그래서 보기에서 온 계산은 그 페이지가 최신 리비전으로 하루 안에 계산돼 있으면 건너뛰게 했다(소유자가 판단을 맡겼다, 2026-10-12). 저장·이름 바꾸기·API·관리 화면·기동 때의 계산과 개발 모드의 localhost 보기는 그대로다. 무엇을 왜는 Dev Page «Calculation», 하루로 고른 이유와 잰 값은 PageLogic.ViewRecalculationInterval 의 주석. --2026-10-12
    • 잰 값(한 대 운영 첫 80분, 2026-10-12 05:17~06:37, 운영 앱 로그의 Calculate 줄): 보기에서 온 계산 16번, 합 8.7초, 하나에 최대 3.8초. 그중 셋은 30분 안에 같은 페이지를 다시 계산한 것이었다. 같은 시간에 위키 동기화의 저장 넷이 10.6초를 더 썼다 — 저장은 이번 변경과 상관없이 계산한다. --2026-10-12
    • 배포 뒤 같은 방법으로 보기에서 온 계산의 횟수·합을 잰다 — 배포 전 80분에 16번·8.7초. 2026-10-12 아침에 커밋했고 배포는 하지 않았다 — 이 세션에서는 상자를 바꾸는 명령이 권한 검사에 막힌다. 화면에 닿지 않는 배경 계산만 바뀌므로 대조(canary) 없는 보통 배포로 충분하다. --2026-10-12

1.21. 기타

  • 마크업 규칙 추가
    • from
      	a
      		a1
      		a2
      	b
      	c
      		c1
      		c2
    • to
       * a
        * a1
       * b
       * c
        * c1
        * c2
  • 구현했다(2026-09-16). 배포 전. 소유자가 «전역 규칙» 으로 정했다 — 마커 없이 맨앞이 탭이면 탭 수만큼 중첩된 불릿으로 렌더한다(InterpreterWiki 의 regexTabList, regexList 다음에 시도). 탭만 대상이고 공백 들여쓰기는 예전처럼 문단으로 둔다. blast radius 점검 결과 현재 맨앞 탭 프로즈 줄이 있는 페이지는 hands-off 3곳뿐(wiki.aharise.com 2, sst Gantt 1) — 그 3곳의 렌더만 바뀐다(내용은 안 건드림, 타인 위키). 단위테스트 TabListUnit, Scala 259 통과. 배포는 canary → 그 3곳만 바뀌는지 렌더 비교 → 양쪽.
  • 배포했다(2026-09-16). canary(10001)로 렌더 비교: change 3곳만 DIFF(탭 → <li> 불릿), 표본 104곳 전부 SAME(회귀 없음). aharise 주주명부 페이지에서 <p> …</p> → <ul><li>…</li></ul> 확인. 양쪽 올림, 태그 v20260916T102816-aha00a. 문서는 InterpreterWiki 탭 들여쓰기. hands-off 3곳 렌더가 바뀐 건 예상대로이고 내용은 안 건드림.

2. See Also

2.2. Twin Pages

2.3. Similar Pages

Similar pages by cosine similarity. Words after page name are term frequency.

  • 50.96% Dev Deploying ahawiki(33:47), 같은(62:9), 다시(38:16), 배포(32:22), 10000(28:26), 10001(35:15), dev(38:11), page(37:4), canary(32:8), 없는(31:7)
  • 47.20% Dev Page page(37:42), 같은(62:10), 페이지(31:20), wiki(30:19), 다시(38:9), dev(38:8), url(29:13), aha00a(40:1), api(32:9), 없는(31:7)
  • 43.51% Dev Attachment s3(52:60), 같은(62:8), page(37:25), url(29:25), attachment(11:42), api(32:11), 페이지(31:12), aha00a(40:1), dev(38:3), 다시(38:2)
  • 41.71% Dev Database 같은(62:15), page(37:13), 다시(38:9), dev(38:6), aha00a(40:1), api(32:8), wiki(30:7), 페이지(31:4), ahawiki(33:1), 없는(31:3)
  • 37.00% Dev Logs 같은(62:7), dev(38:17), page(37:9), 다시(38:2), 10001(35:3), 없는(31:4), 배포(32:1), 페이지(31:2), wiki(30:1), url(29:1)
  • 33.53% Dev WebSocket page(37:68), 같은(62:14), wiki(30:29), 페이지(31:20), dev(38:11), updated(6:43), 다시(38:7), cursor(1:41), id(4:34), 10001(35:1)
  • 32.55% Dev Editor 같은(62:5), s3(52:1), page(37:13), api(32:15), wiki(30:14), dev(38:4), 다시(38:3), interpreter(25:15), 없는(31:5), ahawiki(33:2)

2.4. Adjacent Pages

Control
≤ 32
all
1.0x
1.0x
80
-120
ON
Metrics
Nodes(visible/total)0/0
Links(visible/total)0/0
Avg degree0.00
Depth coverage0
Queue(fetch/graph)0 / 0
Zoom(scale)1.00x
Ctrl/⌘ + Scroll: Zoom
Root 1-hop 2-hop+