두 번째 마이그레이션
오래 전 도쿠위키를 메인으로 쓰다가 규모가 커져 컨플루언스로 옮긴 적이 있는데 그때 옮긴 결과가 간신히 텍스트만 남는 모양이라 그대로 검색 인덱스로만 쓰던 채 수 년이 흘렀습니다. 이번에는 과거 기록을 현대적인 환경에 제대로 다시 옮기고 미래에 또 다른 마이그레이션을 만났을 때를 대비한 경험을 같이 쌓을 겸 시간을 들여 한 번 더 옮겨 보았습니다.
오랫동안 도쿠위키를 메인 위키로 써 왔습니다. 장거리 자전거 라이딩 기록과 사적인 메모, 일하면서 기록한 잡다한 노트, 진행 중인 프로젝트의 임시 설계 메모가 모두 한 자리에 누적되었고 도쿠위키 특유의 단순한 텍스트 파일 한 장이 곧 한 페이지가 되는 구조를 처음에는 가볍지만 단단히 기록할 수단으로 느꼈습니다[^DokuWiki: An elegant and lightweight wiki engine]. 시간이 흐르면서 페이지가 천 단위로 늘어났고 그 사이에 도쿠위키 코어와 플러그인의 호환성 문제, 검색의 한계, 모바일에서의 사용성 같은 종류의 부담도 같이 늘어났습니다. 어느 시점부터는 위키 자체가 무거워서 새로운 기록을 더하기 망설여지게 되었고 그래서 좀 더 본격적인 도구인 컨플루언스로 위키 자체를 옮기기로 결정했습니다.
옮기는 작업은 당시 구할 수 있었던 마이그레이션 스크립트로 진행되었습니다. 그 스크립트는 도쿠위키의 텍스트 마크업을 컨플루언스의 storage XML 비슷한 모양으로 옮겨 주는 일까지는 해냈지만 그 이상은 못 했습니다. 결과적으로 페이지 안에 있던 표는 평문이 되어 흩어졌고 코드 블록은 들여쓰기만 남고 본문에 풀려 있었으며 내부 링크는 거의 다 깨졌고 한국어 파일명을 가진 이미지는 절반 가까이가 글로브 아이콘으로 표시되는 외부 링크로 격하되었습니다. 도쿠위키의 매크로와 플러그인이 본문에 raw 텍스트 그대로 남아 있는 페이지도 많았고 같은 페이지를 도쿠위키 측과 컨플루언스 측에서 나란히 열어 보면 같은 글이라고 받아들이기 어려운 모양이었습니다. 도구로 더 할 수 있는 일이 보이지 않았고 일정도 더 끌 수 없는 상황이라 옮긴 결과를 그대로 두는 결정을 내렸습니다.
그 다음부터 옛 위키는 검색 결과로만 남은 이사를 마쳤지만 아직 상자를 열지 않은 채 그냥 쌓아둔 짐 같은 느낌이 되었습니다. 컨플루언스의 검색 인덱스에 옛 기록의 텍스트가 어쨌든 남아 있었기 때문에 어떤 페이지가 어디쯤에 있었다는 사실은 알 수 있었지만 그 페이지를 열어 본문을 다시 읽고 싶을 때는 거의 매번 불편함이 따라왔습니다. 표는 평문이 되어 흩어져 있어 의미를 복원하려면 한참 따라가야 했고 사진과 첨부는 깨진 글로브 아이콘이 되어 있어 어떤 사진이었는지조차 짐작하기 어려웠습니다. 과거 기록이 이렇게 불완전하게 남아 있다는 점이 늘 마음 한구석에 부담으로 자리하고 있었고 언젠가 시간을 두고 제대로 다시 옮겨야겠다는 생각을 하며 수 년이 그대로 흘러갔습니다.
그렇게 시간이 흐른 다음 이번에 다시 한번 옮겨 보기로 결정했습니다. 두 가지 목적이 있었는데 첫 번째는 과거의 기록을 현대적인 환경에 최대한 잘 옮겨 다시 일상적으로 읽을 수 있게 회복시키는 일이었고 두 번째는 미래에 만약 컨플루언스에서 다른 위키로 다시 이전해야 할 때를 대비한 경험을 미리 쌓아 두는 일이었습니다. 두 번째 목적이 마이그레이션의 도구와 절차에 대한 요구 수준을 한 단계 끌어올린 측면이 있는데 다음에 또 옮길 일이 있을 때 다시 쓰일 것은 마이그레이션 스크립트가 아니라 마이그레이션을 거치며 얻은 경험이라는 판단이 그 배경에 있었습니다. 도구를 일부러 무겁게 만들 생각은 없었고 이번 스크립트가 결과적으로 복잡해진 이유도 도구의 복잡도 자체가 목표여서가 아니라 도쿠위키를 거의 그대로 컨플루언스로 옮기려는 1차 목표 때문이었습니다. 지난 '현대 문서로 전환'에서 100~200명 규모 조직이 워드 파일 무더기를 노션이나 컨플루언스 같은 현대 위키로 옮기려 할 때 무엇을 준비해야 할지 거창하게 정리한 적이 있는데 정작 글을 쓰던 시점에 직접 진행한 도쿠위키에서 컨플루언스로의 마이그레이션은 그 가이드가 권한 모든 단계의 절반도 따르지 않은 상태로 완료되어 있었다는 사실이 이번 작업의 시작점이었습니다. 시간을 들인 것은 맞지만 이번에도 일정에 쫓겼습니다. 다만 모바일 환경에서 마이그레이션 스크립트를 다듬고 원격 홈랩 서버에서 테스트하고 갤러리 페이지로 결과를 확인하는 식으로 기간에 쫓기면서도 이전에 비해 요구 수준을 훨씬 높일 수 있었습니다.
당시 마이그레이션이 마음에 들지 않았던 부분을 몇 가지 살펴보면 먼저 어떤 데이터를 1차 원본으로 삼아 옮길지를 결정하지 않은 채 시작했습니다. 도쿠위키 텍스트 마크업을 그대로 옮길지, 도쿠위키가 렌더링한 HTML을 옮길지, 아니면 그 사이의 어떤 중간 표현을 옮길지 정하지 않은 채로 옮기다 보니 어떤 페이지는 마크업 기준, 어떤 페이지는 렌더링 결과 기준으로 옮겨져 결과가 일관되지 않았습니다. 다음으로 결과를 검증할 단계를 두지 않았다는 점이 있었습니다. 페이지를 컨플루언스에 PUT하면 그것으로 끝이었고 그 결과가 도쿠위키 측 원본과 같은 모양인지 확인하는 자동·수동 단계가 없었습니다. 또 도쿠위키 측의 사용 환경을 정확히 재현하지 않은 점이 있었습니다. 도쿠위키는 conf/local.php, ACL, .htaccess, 플러그인 설정에 따라 같은 데이터에서도 전혀 다른 모양으로 렌더링 결과가 나오는데 이 환경 재현 없이 데이터만 가져다 옮기다 보니 어떤 페이지의 매크로는 처리되고 어떤 페이지의 매크로는 본문에 raw 텍스트로 옮겨졌습니다. 마지막으로 손실이 일어날 수 있는 영역에 대한 결정을 미루어 둔 점이 있었습니다. 컨플루언스 본문 한도를 초과하는 큰 페이지를 어떻게 처리할지, 컨플루언스가 과거 시점의 작성 시간으로 리비전을 만드는 것을 막아 둔 상황에서 과거 리비전을 어떻게 보존할지, 도쿠위키의 struct 같은 데이터 모델을 컨플루언스의 어떤 형태로 옮길지에 대한 결정을 작업 도중에 임시변통으로 내리다 보니 결과적으로 결정 자체가 일관되지 않았습니다.
이 네 가지 결정을 미리 기록해 두지 않으면 마이그레이션은 결국 결정을 옮기는 작업이 아니라 결정을 미루는 작업이 된다는 사실은 자주 인용되는 넷플릭스의 컨플루언스 클라우드 이전 사례[^The Space Between: Netflix's Cloud Migration Story]를 보면 더 잘 알 수 있습니다. 정확한 출처와 세부 사정은 사람마다 조금씩 다르게 말하지만 약 1년이라는 숫자만큼은 거의 모든 자료에서 같은 숫자로 등장하고 그 1년이 무엇으로 채워졌는지를 거칠게 정리하면 다음과 같은 흐름이 됩니다. 처음 몇 개월은 옮길 데이터의 양과 모양을 측정하는 일에 할애됩니다. 페이지 수와 첨부 크기와 사용자 수와 권한 모델의 복잡도와 사용 중인 서드파티 앱 목록을 측정하고 그 측정 결과를 가지고 클라우드 측이 제공하는 도구가 무엇을 할 수 있고 무엇을 할 수 없는지를 영역별로 정리합니다. 클라우드에 동등한 앱이 존재하지 않는 서드파티 마켓플레이스 앱을 식별해 대체 앱으로 교체할지, 자체 구현으로 옮길지, 아니면 그 데이터 모델 자체를 포기할지를 사전에 결정하는 일도 이때 같이 진행됩니다.
다음 몇 개월은 옮기지 않아도 될 데이터를 찾아 정리하는 일에 할애됩니다. 사용자가 이미 잊은 페이지, 부서가 사라져 더이상 의미가 없는 공간, 임시로 만들어 두었다가 그대로 누적된 초안들, 첨부로만 남고 본문은 비어 있는 페이지들이 그 대상입니다. 이 정리 없이 옮기면 새 환경에 같은 양의 부담을 그대로 옮기는 결과가 되고 사용자가 새 환경에서 처음 검색했을 때 옛 환경의 잡음을 그대로 다시 만나게 됩니다. 미리 줄여 두는 일은 마이그레이션의 무게 자체를 결정합니다. 이어지는 몇 개월은 시험 이전 단계인데 가장 적은 위험을 가진 한두 공간을 골라 실제 이전을 수행한 다음 사용자에게 정상 사용을 시키고 그 사이에 어떤 결함이 드러나는지 관찰합니다. 시험 이전이 중요한 이유는 옮기는 도구와 사용자의 사용 방식 사이의 미세한 미스매치가 보통 이 단계에서 처음 드러나기 때문입니다. 권한 매핑이 SSO나 LDAP 그룹과 어떻게 만나는지, 본문 안에 들어 있는 매크로가 클라우드 측 표현 능력과 어떤 부분에서 어긋나는지, 페이지 히스토리의 사용자 attribution이 어떤 모양으로 깨지는지, 검색 인덱스가 옛 환경의 검색 습관과 어떻게 다른지 비슷한 문제들이 이 시험 이전에서 다 한 번씩 드러납니다.
본격 이전은 그 다음 단계이고 이 단계에서는 사용자의 동시 작업을 일정 기간 일시 중단한 다음 짧은 시간에 데이터를 옮기는 식으로 진행됩니다. 1년 가운데 이 본격 이전 자체가 차지하는 시간은 짧으면 며칠 길어도 한두 주 단위로 끝나는데 1년이라는 숫자가 이상해 보이는 이유는 바로 이 점에 있습니다. 본격 이전 자체보다 본격 이전을 안전하게 하기 위한 측정·정리·시험 이전·사용자 교육·앱 교체 결정이 비교할 수 없이 큰 무게를 가진다는 점이 그 1년의 정수입니다. 본격 이전 직후에는 사후 검증과 옛 환경의 동시 종료 일정이 들어가고 사용자가 새 환경에 정착할 때까지 옛 환경을 일정 기간 읽기 전용으로 유지하는 식의 안전판이 같이 작동합니다. 이 1년이라는 사례를 자기 마이그레이션의 호흡 단위로 가져가면 한 가지 단순한 사실이 분명히 보입니다. 마이그레이션의 어려움은 옮기는 순간이 아니라 옮기기 전과 옮긴 다음에 있다는 사실입니다.
비슷한 호흡은 컨플루언스 클라우드 이전을 진행한 다른 엔터프라이즈 사례에서도 자주 보입니다. 자동차 제조사·항공기 정비사·생활용품 제조사·미디어 그룹 같은 대규모 조직이 아틀라시안 서버나 데이터센터 인스턴스를 클라우드로 옮기는 작업을 1년 단위의 프로그램으로 짜고 본격 이전 자체는 한두 주 단위로 끝낸다는 사례가 아틀라시안 자체 케이스 스터디[^Mercedes-Benz on Atlassian cloud: less upkeep, more value]에서도 반복적으로 보고됩니다. 도구 자체를 바꾸는 다른 종류의 마이그레이션도 비슷합니다. 큰 SaaS 회사가 컨플루언스에서 노션으로 옮겨 가는 사례[^クラスメソッドにおけるConfluenceからNotionへの全社移行事例]나 폐쇄형 위키에서 GitLab 핸드북 같은 공개 문서[^The GitLab Handbook]로 옮겨 가는 사례를 보면 옮기는 도구의 데이터 모델 차이가 클수록 측정·정리·시험 이전·앱 교체 결정·사용자 교육의 단계가 더 길어지고 본격 이전 자체는 오히려 짧아진다는 경향이 같이 나타납니다. 1년이라는 숫자의 모양은 조직마다 조금씩 달라지지만 본격 이전 자체보다 그 앞뒤에 무게가 실린다는 점은 모든 사례에서 거의 비슷합니다.
같은 1년을 그대로 옮겨 따라 할 만큼 큰 작업은 아니지만 이번 마이그레이션을 다시 시도하기로 결정한 다음에는 그 1년이 의미하는 작업의 무게를 적어도 호흡 단위로는 같이 가져가기로 했습니다. 다만 넷플릭스 같은 엔터프라이즈 사례와 이번 마이그레이션이 갈리는 부분이 한 가지 있었습니다. 넷플릭스가 옮기지 않아도 될 데이터를 찾아 정리하고 옮길 자료와 옮기지 않을 자료를 구분하는 단계에 몇 개월을 들였다면 이번 마이그레이션은 반대 방향이었습니다. 기존 모든 문서를 최대한 동일하게 누락 없이 옮기는 것이 목표였습니다. 1,675 페이지가 한 페이지도 빠짐 없이 컨플루언스 측에 같은 모양으로 도착하고 본문·내부 링크·첨부·매크로·히스토리 같은 부속 데이터도 가능한 한 같이 따라오는 것이 1차 목표였고 정리·취사선택은 옮긴 다음에 컨플루언스 측에서 점진적으로 진행할 수 있는 일로 미루어 두었습니다. 그래서 처음 몇 시간은 코드를 한 줄도 작성하지 않고 어떤 데이터를 1차 원본으로 삼을지를 결정하는 일에 썼습니다. 이 결정이 마이그레이션 스크립트의 책임 범위와 환경 재현의 책임 범위를 가르는 분기점이었습니다.
도쿠위키 텍스트 마크업을 직접 파싱하는 길과 도쿠위키가 렌더링한 결과를 옮기는 길 두 가지를 두고 후자를 골랐는데 그 결정의 이유는 도쿠위키 마크업의 표현 범위 때문이었습니다. 도쿠위키는 코어 마크업 외에도 wrap, struct, todo, discussion, blog, include, pagelist, tag, tagging, sqlite, htmlok, encryptedpasswords, monthcal, iframe 같은 수십 종의 플러그인이 매크로 형태로 본문에 영향을 끼치는 구조이고 어떤 페이지에는 ~~MACRO:foo:bar~~ 같은 매크로가 단독 줄로 등장하고 어떤 페이지에는 <wrap notice> 같은 HTML 태그가 본문에 그대로 섞여 있으며 어떤 페이지에는 플러그인이 활성일 때만 의미를 가지는 블록이 있고 또 어떤 페이지에는 도쿠위키 코어 정책이 바뀌면서 더이상 렌더되지 않는 옛 블록이 그대로 남아 있습니다. 이 모든 케이스를 마이그레이션 스크립트 안에 따로따로 구현해 넣으면 마이그레이션 스크립트의 표현 능력이 도쿠위키 코어와 모든 플러그인의 표현 능력을 따라잡아야 한다는 무거운 약속을 떠안게 되는데 도쿠위키의 다음 버전이 새 매크로를 발표할 때마다 마이그레이션 스크립트를 다시 짜야 한다는 부채까지 같이 얹어집니다. 도쿠위키가 자기 마크업을 누구보다 잘 해석한다는 사실을 한 번 인정하고 나면 가장 정직한 원본은 도쿠위키 텍스트 마크업이 아니라 도쿠위키 자신이 렌더링한 결과 그러니까 ?do=export_xhtmlbody라는 export endpoint의 응답이 됩니다. 이 결정으로 마이그레이션 스크립트의 책임은 줄었습니다.
대신 마이그레이션 스크립트의 책임이 줄어든 만큼 도쿠위키 환경의 정확한 재현이라는 다른 종류의 책임이 들어왔습니다. 호스트의 도쿠위키 데이터를 그대로 두고 옮기는 길은 그 환경을 안전하게 재현하기 어렵기 때문에 도쿠위키를 도커 컨테이너로 따로 띄워 호스트 데이터의 클론만 그 안에 주입하는 길을 골랐습니다. php:8.2-apache 기반의 도쿠위키 컨테이너에 호스트 데이터의 클론을 APFS clonefile로 묶어 넣은 다음 컨테이너 측에서 export endpoint를 호출해 렌더링 결과를 모은다는 흐름이 그 결정의 모양입니다. 호스트 데이터에는 손을 대지 않고 클론 측에만 모든 패치가 들어간다는 안전판이 이 흐름의 핵심이었습니다. 같은 데이터를 가지고 같은 컨테이너를 다시 띄우면 같은 결과가 나오도록 만들어 두면 시행착오의 비용이 거의 0에 가까워집니다.
도커 컨테이너 안의 도쿠위키 환경을 호스트 측과 같은 모양으로 만들기 위해 클론 직후에 다섯 단계의 자동 패치가 들어갑니다. 첫 번째는 ACL 우회입니다. 호스트의 도쿠위키는 ACL이 켜져 있어 익명 사용자가 페이지를 읽지 못하게 막혀 있는데 이 상태에서 export endpoint를 호출하면 본문이 빈 응답을 받습니다. 클론 측의 conf/local.php에 $conf['useacl'] = 0 한 줄을 주입하면 익명 export가 다시 살아납니다. 두 번째는 .htaccess 파일을 자동으로 만들어 두는 단계입니다. 도쿠위키의 userewrite=1 설정은 깔끔한 URL을 위해 mod_rewrite 룰에 의존하는데 그 룰을 정의하는 .htaccess가 클론 측에 부재할 경우 /_media/...로 시작하는 모든 미디어 요청이 404를 받습니다. .htaccess가 있으면 그대로 두고 없으면 .htaccess.dist에서 복원하며 둘 다 없으면 도쿠위키 공식 매뉴얼에 적힌 룰셋을 직접 작성해 넣는 헬퍼가 이 단계에 들어갑니다. 세 번째는 플러그인 자동 감지·설치입니다. 클론 측의 conf/plugins.local.php와 meta/struct.sqlite3 그리고 페이지 본문에 들어 있는 ~~MACRO~~ 패턴을 모두 스캔해서 어떤 플러그인이 필요한지 추정한 다음 그 결과를 PLUGIN_DOWNLOADS라는 매핑 테이블에 따라 release tarball로 자동 설치합니다. 매핑에 들어 있지 않은 플러그인은 unknown으로 표시해 두고 사용자가 컨테이너 기동 후 admin → extension UI에서 수동 설치할 여지를 비워 둡니다. 자동 설치 단계에서 한 번 문제가 있었는데 어떤 패키지가 사실은 RPM spec 래퍼 파일만 들어 있고 실제 도쿠위키 플러그인 구조(plugin.info.txt, syntax.php 등의 marker 파일)는 없는 경우에도 정상 플러그인으로 잘못 인식해 설치하려 했던 점이었습니다. marker 파일 존재를 sanity check로 넣어 둔 다음에는 같은 문제가 다시 일어나지 않았습니다.
네 번째는 한국어 파일명에 대한 NFC 정규화입니다. macOS의 APFS 파일시스템은 한국어 파일명을 NFD라는 정규화 형식으로 디스크에 저장하고 도쿠위키는 페이지 본문에서 그 파일을 가리킬 때 NFC 형식의 URL을 생성하는데 컨테이너 안의 PHP는 file_exists()를 호출할 때 두 바이트열을 byte-exact로 비교하기 때문에 NFD로 저장된 파일을 NFC URL로 찾으면 단 한 글자도 같지 않다는 결론이 나서 404를 돌려줍니다. 한국어 파일명을 가진 미디어와 페이지가 약 1,900개였으므로 손으로 고칠 수 있는 양이 아니었고 도쿠위키 코어의 fetch.php나 mediaFN 함수를 패치하는 길도 있었지만 vendor 코드에는 손대고 싶지 않았습니다. 대신 클론 측의 모든 비-ASCII 파일을 walk해서 NFD 이름의 파일을 NFC 이름으로 추가 복사하는 헬퍼가 자동 패치에 들어갔습니다. APFS는 NFC와 NFD를 동등하게 보기 때문에 같은 파일을 두 번 복사한다 해도 디스크 안에 두 개의 inode가 생기는 것이 아니라 같은 entry에 NFC 이름이 추가로 매겨지는 효과만 일어나는데 이 한 번의 추가 entry만으로 컨테이너 PHP의 file_exists()가 NFC 바이트열도 같은 파일을 가리키는 것으로 인식하기 시작합니다. 다섯 번째는 discussion 플러그인의 PHP 8 호환성 패치입니다. php:8.2-apache 컨테이너에서 토론 페이지의 export 응답에 빨간색 PHP 8 TypeError 박스가 통째로 표시되는 문제가 있었는데 action.php의 어느 줄에서 io_readFile이 false를 반환하고 그것을 unserialize한 결과가 다시 false가 된 다음 array_key_exists($key, false) 호출이 일어나는데 PHP 7까지는 그저 경고만 띄우던 호출이 PHP 8부터는 두 번째 인자에 배열이 아닌 값이 들어오면 곧장 TypeError를 던지도록 바뀌었기 때문이었습니다. 클론 측의 action.php에 if (!is_array($data)) { $data = []; } 한 줄을 끼워 넣는 헬퍼가 같은 자동 패치 흐름에 들어갔고 같은 줄에 마커 주석을 같이 남겨 두는 방식으로 멱등성을 유지했습니다. 이 다섯 단계의 자동 패치를 거치고 나서 도쿠위키 컨테이너의 export endpoint는 호스트 도쿠위키와 거의 같은 모양으로 응답하기 시작했고 마이그레이션 스크립트 측은 그 응답을 1차 원본으로 받아들이는 자기 책임에 집중할 수 있게 되었습니다.
다섯 단계 자동 패치 외에도 첫 풀 트리 변환을 실행해 본 다음 컨테이너 측과 마이그레이션 스크립트 측 양쪽에서 정리해야 했던 결함이 몇 가지 더 드러났습니다. 컨테이너 기반으로 처음 시도한 이미지는 bitnami/dokuwiki였습니다. 도쿠위키를 라이브로 운영하던 시절 AWS에 도쿠위키 인스턴스를 띄울 때 사용했던 컨테이너 이미지가 이쪽이라서 같은 이미지를 그대로 가져오면 로컬에서도 도쿠위키 컨테이너를 손쉽게 띄울 수 있으리라 예상했습니다. 하지만 Bitnami가 공개 카탈로그를 정리하면서 이 이미지는 더이상 배포되지 않는 상태가 되어 있었고 대신 php:8.2-apache 기본 이미지에 호스트 도쿠위키 트리를 그대로 마운트하는 방식을 골랐습니다. 그 컨테이너가 띄워질 때 PHP 8.2의 deprecation warning 본문이 ?do=export_xhtmlbody 응답 본문 앞에 섞여 나오는 문제가 있어서 마이그레이션 스크립트가 본문을 bs4로 잘못 파싱하는 결과가 따라왔고 컨테이너 시작 시 display_errors=Off와 error_reporting=E_ERROR를 /usr/local/etc/php/conf.d에 주입하는 방식으로 막았습니다. 도쿠위키의 userewrite=1 인스턴스가 내부 링크를 <a href="/wiki/syntax"> 같은 path 형 URL로 출력해 마이그레이션 스크립트가 external로 잘못 분류한 문제가 있어서 <a>의 data-wiki-id 속성을 doku id의 1순위 신호로 삼도록 고쳤고 같은 종류의 분류 문제로 도쿠위키의 외부 이미지 proxy(fetch.php?media=https%3A%2F%2F... 형태)가 첨부로 잘못 분류되어 attachments 테이블에 가짜 row를 만들던 문제가 있어 media= 값이 http(s)://로 시작하면 external 이미지로 재분류하고 <img src>를 디코딩한 실제 URL로 옮기는 식으로 정리했습니다. 한국어 파일명을 가진 미디어 경로는 NFD/NFC 정합성과 별개로 한 가지 문제가 더 있었는데 도쿠위키가 본문에 사용하는 미디어 링크 path가 URL-인코딩된 상태(ride:files:%EB%8F%84%EC%84%A0%EC%82%AC.gpx)였고 마이그레이션 스크립트가 parsed.path를 raw 그대로 매칭에 사용해 attachments 테이블의 디코딩된 키와 만나지 못한 문제라서 urllib.parse.unquote를 거쳐 매칭하도록 옮겼습니다. ACL로 anonymous deny된 페이지가 ?do=export_xhtmlbody 응답에 풀 HTML 문서(<head>·<script>·<link>·<form>·<meta>까지 같이)를 토하던 문제는 corpus 1,208 파일에 위험 태그가 그대로 남게 만들었는데 마이그레이션 스크립트가 <main id="dokuwiki__content"> 안의 <div class="page">만 추출하고 위험 태그를 일괄 decompose하도록 정리했습니다.
마이그레이션 스크립트와 컨플루언스 측 업로드와 내부 링크 해소까지의 전체 흐름은 총 14단계로 묶인 마법사가 차례로 처리하도록 정리했고 모든 단계의 진행 상태는 SQLite 단일 파일에 누적되도록 했습니다. 어떤 단계가 도중에 멈춰도 다시 호출하면 같은 단계에서 이어지고 마이그레이션 스크립트에 새 룰을 넣은 다음 다시 실행하면 이미 같은 해시로 도착해 있는 페이지는 push가 자동으로 건너뜁니다. 이 반응성이 5일 동안 거의 매일 새 문제가 터지는 동안 그때마다 문제를 수정하고 전체 파이프라인을 다시 실행하는 흐름을 만들었습니다.
이 마이그레이션을 진행할 무렵 별도로 시간을 내 컴퓨터 앞에 앉아 작업할 여유가 거의 없었습니다. 일하는 중간중간 짬을 내고 출퇴근 지하철 안에서 아이폰을 한 손에 들고 코드를 다듬고 마이그레이션 이터레이션을 반복하는 식으로 진행했고 그 환경에서는 옮긴 페이지를 컨플루언스 모바일 화면으로 하나씩 직접 확인하기가 어려웠습니다. 같은 페이지를 도쿠위키 측과 컨플루언스 측에서 나란히 열고 같은 모양인지 비교하는 일이 시각 검증의 핵심인데 모바일에서 두 측을 동시에 띄우거나 컨플루언스 측의 매크로가 모바일 렌더링에서 어떻게 풀리는지 살펴보는 일은 사실상 불가능했습니다. 그래서 검증 자체를 도구 안에 포함하고 한 페이지로 묶어 두면 아이폰으로 그 한 페이지만 열어도 양쪽을 동시에 비교할 수 있게 만든 것이 바로 비교 갤러리였습니다.
그렇게 한 차례 메인 페이지 1,675개를 옮기고 비교 갤러리를 발행한 다음부터가 이번 마이그레이션의 진짜 본격적인 단계였습니다. 갤러리는 도쿠위키 측과 컨플루언스 측 양쪽의 풀-페이지 스크린샷을 헤드리스 Chromium으로 캡쳐해 한 페이지에 나란히 배치하는 페이지입니다. 모든 페이지를 다 담을 수는 없으므로 도구가 자동으로 10개 카테고리(메인 / 사용자 시작 / iframe / encrypt / 표 풍부 / 이미지·첨부 / info·note·warning / 매크로 다양 / 코드 / 대용량)에서 카테고리당 두세 페이지씩 골라 한 batch를 만들고 같은 batch가 반복되지 않도록 이전 발행 이력을 누적해 다음 batch에서 자동으로 제외합니다. start와 sidebar처럼 본문이 양쪽 다 빈 페이지는 비교 가치가 없으므로 영구 제외 목록에 등록해 두었습니다. 같은 갤러리에 batch를 여러 번 발행해 가며 시각적으로 양쪽을 비교하는 단계가 이번 마이그레이션의 주된 검증 단계였습니다.
이 갤러리에서 발견된 사건들이 마이그레이션 5일 동안 큰 fix 흐름을 만들었습니다. 첫 batch를 열어 봤을 때 가장 먼저 눈에 띈 것은 컨플루언스 측 캡쳐의 이미지가 거의 다 깨져 있다는 점이었습니다. 같은 페이지의 도쿠위키 측 캡쳐에는 이미지가 정상으로 보이는데 컨플루언스 측만 빈 액자 모양으로 나란히 표시되어 있어서 처음에는 첨부 업로드가 실패했나 의심했지만 실제로는 컨플루언스의 /wiki/download/attachments/... endpoint가 OAuth 토큰만 받고 Basic 인증을 거부한다는 점이 원인이었습니다. 캡쳐 컨텍스트의 인증 헤더를 v1 endpoint에 맞춰 추가하고 src 속성을 일괄 rewrite한 다음 첨부 이미지가 정상으로 같이 캡쳐되기 시작했습니다. 같은 갤러리에서 다음으로 보인 것은 컨플루언스 측 캡쳐가 통째로 빈 페이지인 경우들이 있다는 점이었습니다. Playwright의 wait_until="domcontentloaded"가 컨플루언스 측 동적 렌더링이 끝나기 전에 캡쳐를 떠 버린 결과였습니다. networkidle로 옮기고 1.5초의 추가 대기를 끼워 넣은 다음에는 빈 캡쳐가 사라졌습니다. 그 다음 batch에서는 어떤 페이지의 풀-페이지 캡쳐가 110MB짜리 PNG로 나오는 문제가 있었습니다. 이미지가 100장 이상 들어 있는 거대한 페이지를 풀-페이지로 캡쳐하다 보니 캡쳐 자체가 첨부 100MB 한도를 초과해 갤러리에 담지 못할 만큼 커졌습니다. 페이지의 scrollHeight를 측정한 다음 viewport를 12000px로 clip하고 PIL의 ImageChops.difference로 빈 영역을 trim하는 방식으로 캡쳐 크기를 한도 안에 맞췄습니다. iframe 매크로 위주 페이지(구글 캘린더 embed 등)는 view body API가 iframe 매크로를 빈 placeholder로 응답하기 때문에 양쪽 캡쳐에 노란색 안내 박스를 명시적으로 inject해 빈 박스만 나란히 보이는 상황을 막았습니다.
같은 갤러리에서 발견된 가장 흥미로운 사건은 dwc-link라는 placeholder가 컨플루언스에 그대로 raw로 옮겨진 사례였습니다. 마이그레이션 스크립트는 도쿠위키 페이지 안의 내부 링크를 1패스에서는 dwc-link:<target> 형태의 placeholder로 남겨 두고 모든 페이지가 컨플루언스에 올라간 다음 2패스에서 그 placeholder를 진짜 <ac:link><ri:page> 매크로로 치환하는 방식으로 동작합니다. 이 2패스의 dry-run 옵션이 처음에는 "컨플루언스 PUT만 안 한다"는 정의로 만들어져 있었는데 이 정의가 한 글자 차이로 큰 문제를 냈습니다. dry-run이 컨플루언스 측 PUT만 막을 뿐 로컬 storage XML과 페이지 컨텐츠 해시는 그대로 갱신한다는 뜻이었고 그 결과 dry-run을 한 번 실행한 다음 라이브로 다시 실행하면 컨플루언스 측 페이지가 어떤 상태인지와 상관없이 로컬 해시는 이미 "치환된 상태"가 되어 있어서 PUT이 "같은 상태이므로 건너뜀" 분기로 빠져 영구히 막혀 버렸습니다. 결과적으로 라이브 컨플루언스에는 dwc-link:... 문자열이 7,943개의 링크와 345개의 페이지에 그대로 raw 노출되었고 컨플루언스 측 렌더러는 unknown URL scheme을 본 김에 모든 placeholder 옆에 친절하게 globe 아이콘을 붙여 두었습니다. 이미지가 깨졌다고 인지하기 딱 좋은 결과였는데 정작 첨부는 멀쩡했고 깨진 것은 placeholder였습니다. 갤러리에서 같은 패턴이 여러 페이지에 걸쳐 보였기 때문에 원인이 특정 페이지의 문제가 아니라 마이그레이션 스크립트 측의 흐름 자체에 있다는 점이 분명히 드러났습니다. PUT 판정을 두 해시(로컬 변환 상태의 content_hash와 마지막 push 성공 시점의 uploaded_hash)의 비교로 바꾼 다음 갤러리에서 같은 부분이 정상으로 채워지기 시작했습니다.
갤러리 batch를 새로 발행할 때마다 다른 종류의 문제가 새로 나타났습니다. 어떤 토론 페이지에는 본문 한가운데에 빨간색 PHP TypeError 박스가 그대로 표시되어 있어서 discussion 플러그인의 PHP 8 호환성 패치가 자동 패치 단계에 같이 들어가야 한다는 점이 드러났습니다. 어떤 페이지에는 cipher가 escape 텍스트로 노출되어 있어서 encryptedpasswords 플러그인의 raw HTML pre-process 단계가 빠져 있다는 점이 드러났는데 이 cipher는 OpenSSL AES-256-CBC와 EVP_BytesToKey MD5 1회 반복으로 만들어진 형식이었고 round-trip 테스트가 10건 모두 통과하는 복호화 헬퍼를 같은 사이클에서 같이 만들어 두었습니다. 어떤 페이지에는 <html><iframe> 블록이 escape 텍스트로 노출되어 있어서 도쿠위키 Jack Jackrum 이후 core가 <html>...</html> 블록을 더이상 렌더하지 않게 바뀐 사실을 모른 채 옮긴 결과가 드러났는데 같은 문제에는 htmlok 플러그인의 설치와 플러그인 설정 활성이 필요했습니다. 어떤 글쓰기 메모 페이지에는 이미지 아래쪽부터 불릿 리스트가 평문처럼 보이는 문제가 있었는데 원인은 <li><div class="li">...</div></li> 패턴이었습니다. 도쿠위키가 li 안에 자체 CSS 타깃용 wrapper div를 출력하는데 본 마이그레이션 스크립트는 이걸 그대로 보존했고 컨플루언스 storage에서 li의 직접 자식이 block-level div인 경우 렌더러가 li의 bullet 위치 계산을 잘못해 시각적으로 list가 평문으로 보이는 문제가 일어났습니다. 1,675 페이지 중 748 페이지(약 45%)에 div.li가 그대로 남아 있었기 때문에 마이그레이션 스크립트의 serialize 직전에 div.li를 무손실 unwrap하는 한 줄을 끼워 넣고 전체를 다시 push했습니다. 그 다음 wrapper만 무손실로 제거된 모양이 갤러리에 정상으로 나타났습니다.
또 다른 사이클에서는 도쿠위키 todo 플러그인이 본문에 출력하는 <input type="checkbox"> 인터랙티브 체크박스가 컨플루언스 storage 에 그대로 들어가 거부되는 문제가 191 페이지에 걸쳐 드러났습니다. 도쿠위키 측에서는 클릭으로 체크·언체크가 되는 정상 동작이지만 컨플루언스 storage 는 인라인 인터랙티브 컨트롤을 받아들이지 않습니다. 두 모드 변환을 같이 만들었는데 한 <ul> 안의 모든 <li> 가 단일 pure todo 인 경우 <ul> 통째를 <ac:task-list> 매크로로 치환해 컨플루언스 측에서도 그대로 클릭 가능한 체크박스로 나타나도록 했고 일반 텍스트가 섞인 mixed 케이스나 nested 구조는 [x]/[ ] 텍스트 마커로 폴백하는 식이었습니다. 841 리스트 / 1,547 항목이 컨플루언스 측에서 클릭 가능한 체크박스로 같이 옮겨졌습니다.
같은 갤러리에서 도쿠위키 측 캡쳐가 어떤 페이지에서 이미지를 거의 다 잃은 모양으로 표시되는 문제가 있었습니다. 처음에는 컨테이너 측의 export 자체가 깨졌나 의심했지만 사실은 두 단계의 mismatch chain이 같이 일어난 결과였습니다. 첫 단계는 .htaccess 부재로 모든 /_media/... 요청이 404를 받는 상황이었고 두 번째 단계는 한국어 파일명의 NFD/NFC byte mismatch였습니다. 자동 패치 다섯 단계 가운데 두 단계가 한꺼번에 문제가 되었던 셈입니다. .htaccess 자동 생성과 NFC 정규화가 자동 패치 흐름에 같이 들어가고 나서 같은 페이지의 도쿠위키 측 캡쳐도 정상으로 보이기 시작했고 갤러리 양쪽이 비로소 같은 모양으로 나란히 보였습니다.
과거 리비전을 옮기는 흐름은 본 마이그레이션 스크립트 안에 history-discover·history-render·history-convert·history-upload 의 별도 단계로 묶여 있었습니다. 도쿠위키 측의 data/attic/<페이지>.<유닉스타임스탬프>.txt.gz 형식으로 압축된 과거 본문 37,287건과 data/meta/<페이지>.changes 의 변경 로그를 같이 인덱싱한 다음 도쿠위키의 ?rev=<유닉스타임스탬프> 엔드포인트로 그 시점 본문을 같은 자동 패치가 적용된 컨테이너에서 다시 렌더링하고 같은 변환 파이프라인을 거쳐 컨플루언스 측에 시간 순으로 PUT replay 하는 방식이었고 그 결과 컨플루언스 페이지의 버전 체인에 도쿠위키 측 리비전 하나하나가 같은 시간 순서로 쌓였습니다. 누적 라운드와 후속 재개 라운드가 끝난 시점에 32,453 리비전(전체의 약 86.8%)이 컨플루언스 버전 체인으로 옮겨졌고 컨플루언스 클라우드가 페이지 버전의 작성 시간을 과거 시점으로 적는 것을 막아 둔 상황이라 모든 리비전의 표시 시간이 마이그레이션 시점으로 맞춰지는 한계 외에는 도쿠위키 측의 변경 이력이 컨플루언스 측에서도 그대로 따라갑니다. 컨플루언스 본문 한도 등으로 PUT 이 거부된 남은 리비전은 도쿠위키 측 원본 트리가 제 Perforce 저장소에 그대로 보존된 상태라 누락 리비전이 두 건 이상인 22 페이지의 latest 본문 끝에 보존된 리비전 수·누락 리비전 수·도쿠위키 원본 P4 백업 경로를 같이 적는 안내 푸터 박스를 부착해 원본 파일에서 그대로 열어 볼 수 있도록 했습니다. 미디어 첨부의 과거 버전 193 파일은 컨플루언스 첨부의 버전 체인으로 같이 옮겼습니다.
갤러리는 또 다른 문제를 해결할 수 있게 해 주었는데 컨플루언스 측 본문이 어떤 페이지에서 도쿠위키 측보다 한참 짧다는 점이었습니다. 이 문제의 원인은 마이그레이션 도구의 history-upload 흐름에 있었습니다. 한 페이지의 과거 리비전들을 시간 순으로 PUT replay하는 도중에 어느 한 리비전이 fail하면 그 페이지의 나머지 리비전을 모두 건너뛰고 다음 페이지로 넘어가는 break 로직이 들어가 있었는데 이 break가 같은 페이지의 더 새 리비전(latest 본문)까지 같이 막아 결과적으로 컨플루언스 측 본문이 옛 리비전 시점의 짧은 모양으로 남아 있는 경우가 49 페이지에 걸쳐 발생했습니다. break를 continue로 바꾸고 rev replay 종료 후 latest storage 본문을 강제로 PUT하는 보조 단계를 추가한 다음 49 페이지가 정상 길이로 복원되었습니다. 갤러리에 강제 latest 복원 직전과 직후의 캡쳐를 같이 넣어 두면 본문이 길이를 회복한 모양이 한눈에 들어옵니다.
비교 갤러리는 처음 batch에서 다음 batch로 넘어갈 때마다 매번 새 문제를 한두 가지씩 들고 왔고 그 문제 하나하나가 마이그레이션 스크립트나 컨테이너 자동 패치나 마이그레이션 흐름 어느 영역의 fix로 연결되었습니다. 갤러리의 누적 batch는 처음 한 번에 8 페이지, 그 다음 20 페이지로 늘렸고 같은 페이지가 반복되지 않도록 발행 이력을 누적하면서 카테고리별 새 페이지가 항상 들어가도록 만들었습니다. 직접 양쪽을 비교해 OK/NG/DEFER로 분류한 결정도 같이 누적되었기 때문에 어떤 문제는 자동으로 알아내지 못한 상황에서 사람이 알아내고 어떤 문제는 자동 신호가 먼저 알아내고 사람이 확인하는 식의 두 방향의 검증 흐름이 같이 진행되었습니다. 자동 신호는 문장 정렬 ratio, artifact(숫자·일정·IP·버전·URL·이메일) 보존, 코드블록 해시 set 일치, 헤딩 시퀀스 LCS, 링크 해소율 같은 다섯 가지 정량 측정과 이후 추가된 pixel diff, 4×8 타일 PHash, bbox LCS, OCR 백업 텍스트 비교, 색상 histogram 유사도 같은 일곱 가지 시각 측정을 다 같이 실행해 자동 status 격상을 도와주었지만 어떤 종류의 문제는 자동 측정으로는 잘 알아내지 못하고 사람이 양쪽을 한 화면에서 보았을 때 비로소 알아내는 경우가 있었습니다. 비교 갤러리가 없었다면 이번 마이그레이션의 절반은 끝났다는 사실 자체를 받아들이지 못한 채 시간이 흘러갔을 거라고 봅니다.
비교 갤러리와 자동 측정 외에 마이그레이션이 어느 정도 정리된 시점에 source ↔ rendered ↔ confluence 세 측을 같이 비교하는 3-측 invariant audit 도 같이 진행했습니다. 기존 자동 감사는 도쿠위키가 렌더링한 결과와 컨플루언스 측 본문 두 측만 비교했기 때문에 도쿠위키 측 원본 텍스트 마크업에서 일어난 변형은 알아내지 못했습니다. 3-측 감사는 source 측 본문에서 직접 추출한 신호(IP·일정·URL·이메일·코드블록 해시 같은 artifact 보존)와 변환 단계에서 일어나는 의도된 변환의 화이트리스트(예를 들어 도쿠위키 [[wiki:syntax]] 가 컨플루언스 link 매크로로 옮겨지는 정상 변환)를 같이 비교해 책임 영역을 source 측 변형·마이그레이션 스크립트 측 변형·판정 불가의 세 카테고리로 자동 분류해 줍니다. 1,675 페이지 중 7건(0.42%)이 violation 으로 분류되었고 7건 모두 source 측 변형이었으며 마이그레이션 스크립트 측은 0이었습니다. 마이그레이션 스크립트 자체는 깨끗했다는 사후 신뢰도가 같이 확보되었습니다.
이 모든 흐름이 끝나고 남은 숫자를 정리하면 메인 페이지 1,675개가 100% 업로드 완료, 첨부 10,725개가 같이 올라갔고(100MB 한도를 넘는 10건은 본문 안내 박스로 처리), 내부 링크 5,180건이 컨플루언스 링크로 마이그레이션 되고 1,317건은 평문으로 격하되었으며(대부분 user 네임스페이스의 alias 등 부수적 케이스), 자동 감사 통과율은 명백한 OK 64%에 분류기 한계 36%로 실손실은 0이었고, 마이그레이션 스크립트 버그가 라이브와 후속 audit를 거치며 9종 fix되었으며, struct가 4개 schema에 1,213개의 row와 208개의 bound 페이지 임베드로 함께 들어갔고, 본문 storage XML 이 컨플루언스 본문 한도를 넘는 큰 페이지 2건(416KB·1.4MB)이 split-oversize 단계로 H 단위 자식 페이지 분할을 거쳐 각각 6·15 개의 자식 페이지로 회복되었으며, 과거 리비전 37,287건 가운데 32,453건(약 86.8%)이 컨플루언스 버전 체인으로 옮겨지고 남은 리비전은 도쿠위키 측 원본의 Perforce 백업과 22 페이지에 부착된 안내 푸터로 손실 없이 모두 보존되었으며, 비-마이그레이션 페이지 1,465개가 휴지통(30일 회복 가능)으로 들어갔고 결과 보고서가 컨플루언스 페이지 한 장으로 자동 발행되었습니다. 메인 페이지·struct 자식 페이지·인덱스 페이지·history 안내 푸터 노출용 페이지를 모두 합친 컨플루언스 측 페이지 수는 2,818 개이고 컨플루언스에 PUT 된 버전 누적은 60,772 건이며 본문 storage XML 합계는 24.23 MB 였습니다. 무엇을 본래 모양 그대로 옮기지 못했는지도 같이 기록해 두면 struct의 Database 객체는 컬럼/row API가 공개되지 않은 한계에 도달했기 때문에 빈 쉘로만 두고 데이터는 Page Properties와 Page Properties Report 매크로 조합으로 옮겼고, 컨플루언스 본문 한도 등으로 PUT 이 거부된 과거 리비전 약 5,500건(u:neoocean:2020 의 large_body_fallback 2,895 리비전 + 비-마이그레이션 페이지의 958 리비전 + 시간 역순 skip 103 리비전 + 본문 한도 도달 등 SKIPPED 1,536 리비전)은 컨플루언스 본문 안에 같은 모양으로 두지 못해 도쿠위키 측 원본 파일과 그 Perforce 백업과 22 페이지의 안내 푸터 형태로 같이 이전했습니다. 어떤 데이터를 컨플루언스 본문 안에 같은 모양으로 두지 못하는 영역이 나타나면 별도 경로나 다른 매크로 형태로 어떻게 같이 보존할지를 도구의 일부로 같이 두는 결정이 따라옵니다.
이 마이그레이션에서 가장 절충이 깊었던 한 영역이 도쿠위키의 struct 플러그인 데이터였습니다. struct 플러그인은 페이지 본문 외에 구조화된 메타 데이터를 정의·저장·조회할 수 있게 해 주는 도구로 schema 단위로 컬럼을 정의하고 각 페이지에 row를 부착하는 식으로 동작합니다. 본 인스턴스의 경우 장거리 자전거 라이딩 운영을 위한 4개 schema(brevet_course·brevet_event·brevet_place·brevet_uri_cppage)에 약 1,213개의 row가 누적되어 있었고 데이터 자체는 도쿠위키 측 meta/struct.sqlite3 한 파일에 모여 있었습니다. 사실상 위키 안에 작은 데이터베이스를 두고 운영해 온 셈입니다.
이 데이터를 옮겨 받을 첫 번째 후보로 기대한 것은 컨플루언스 클라우드에 비교적 최근 추가된 Database 익스텐션이었습니다. Database 익스텐션은 노션 데이터베이스와 유사한 새 객체 타입으로 컬럼 타입(텍스트·숫자·날짜·사람·링크 등)을 정의해 row를 입력하고 같은 데이터를 표·보드·카드·갤러리 같은 여러 view로 보여 줄 수 있고 다른 페이지에 임베드도 가능합니다. struct schema의 컬럼 타입(Wiki·Media·Lookup·Date·Decimal·Text 같은 도쿠위키 측 타입)을 컨플루언스 측 컬럼 타입에 1:1 가까이 매핑해 옮길 수 있고 이전 이후에도 새 데이터를 자연스럽게 추가할 수 있는 환경이 되어 줍니다. 마이그레이션 도구의 코드 경로도 이 결과를 가정하고 만들어 두었습니다.
하지만 실제 API 가용 영역을 측정해 보니 컨플루언스 클라우드 v2의 Database API는 빈 객체의 create/get/delete 세 가지만 공개되어 있고 컬럼 정의나 row 입력 endpoint는 모두 미공개 상태였습니다. 컬럼/row 관련 endpoint 7개를 차례로 측정했지만 모두 500 응답을 돌려 받았고 storage format의 <ri:database> 임베드도 거부되었습니다. 결과적으로 데이터는 컨플루언스의 오래된 방식인 Page Properties 매크로와 Page Properties Report 매크로 조합으로 옮겨야 했습니다.
컨플루언스 Page Properties는 각 페이지가 한 row 역할을 하고 페이지 본문에 들어 있는 Page Properties 매크로 안에 키-값 쌍 형태로 properties를 기록해 두는 방식입니다. 같은 라벨이 붙은 여러 페이지의 properties를 모아 Page Properties Report 매크로가 표 모양으로 집계해 보여 줍니다. 모든 컨플루언스 인스턴스에 기본 탑재되어 있어 안정적이고 마이그레이션 결과를 즉시 활용할 수 있다는 점이 장점이지만 진짜 데이터베이스라기보다는 페이지 본문에 들어 있는 매크로와 라벨 필터링으로 흉내 낸 평면 표에 가까워서 컬럼 타입 시스템·view 전환·관계 컬럼·검색·정렬·필터링의 표현력이 제한적입니다. 데이터는 페이지 본문 안에서만 살아 있어 한 row의 값을 바꾸려면 매크로가 들어 있는 페이지를 열어 수정해야 하고 새 row를 추가하려면 새 페이지를 만들어야 합니다.
이 한계 때문에 본 마이그레이션은 절충안으로 정착했습니다. 도쿠위키 측 4개 schema 각각에 대응되는 빈 Database 쉘을 컨플루언스 공간 루트에 만들어 두고 인덱스 페이지에는 schema 메타·컬럼 표·Page Properties Report 매크로를 넣어 두었으며 각 row는 자식 페이지 한 장으로 만들어 details 매크로 본문에 셀 값을 넣고 dokuwiki-struct-<schema> 라벨을 붙여 Page Properties Report에서 집계되도록 만들었습니다. 빈 Database 쉘의 confluence_db_id는 state.db에 기록해 두어 추후 Atlassian이 컬럼/row API를 공개하면 같은 코드 경로가 그대로 진짜 마이그레이션을 수행할 수 있게 했습니다. struct row의 Wiki·doku_id 컬럼이 가리키는 본문 페이지 208장에는 본문 끝에 '관련 struct 데이터' 패널을 자동 임베드해 같은 데이터를 양쪽에서 볼 수 있도록 보완했습니다.
만약 컨플루언스 Database API가 풀로 공개되어 있었다면 같은 1,213 row가 진짜 데이터베이스 객체 안의 row로 옮겨져 컬럼 타입이 보존되고 표·보드·카드·갤러리 같은 view로 자유롭게 전환 가능하고 검색·필터·정렬·관계 컬럼 같은 데이터베이스 본연의 표현력이 그대로 동작했을 겁니다. 또 이전 이후에 새 row를 추가할 때 페이지를 새로 만들 필요 없이 Database 안에서 한 row를 추가하는 것으로 끝났을 테고 도쿠위키 시절처럼 위키 안에 자연스러운 데이터베이스 환경이 다시 자리잡았을 겁니다. 본 마이그레이션이 도착한 절충안은 어디까지나 임시 정거장이고 Atlassian이 후속 API를 공개하는 시점에 같은 코드 경로가 다시 동작해 본격 native 마이그레이션이 완성되는 그림을 기대하고 있습니다.
5일을 보낸 뒤에 정리한 배운 점은 몇 가지로 압축됩니다. 먼저 1차 원본을 무엇으로 삼을지 처음에 결정하는 것이 가장 중요합니다. 텍스트 마크업을 1차 원본으로 삼았다면 같은 5일 동안 도쿠위키의 모든 매크로와 플러그인을 다시 구현하다가 시간이 끝났을 겁니다. 도쿠위키가 자기 마크업을 자기가 해석한 결과를 1차 원본으로 삼은 결정이 마이그레이션 스크립트의 책임을 줄였고 줄어든 만큼의 책임이 환경 재현 쪽으로 옮겨갔지만 환경 재현은 컨테이너와 자동 패치 다섯 단계로 묶을 수 있는 종류의 책임이었기 때문에 결국 작업이 마무리될 수 있었습니다. 다음으로 모든 단계를 멱등하게 만들고 진행 상태를 단일 SQLite 파일에 남겨 두는 일의 가치를 확인했습니다. 매일 새 문제가 터지는 동안 그때마다 문제를 수정한 다음 같은 명령을 다시 호출하는 식으로 작업이 이어졌고 이 흐름이 가능하려면 모든 명령이 어디서든 다시 시작할 수 있고 이미 끝난 단계는 자동으로 건너뛸 줄 알아야 합니다. 또 비교 갤러리 같은 시각 검수 단계를 만들어 사람과 도구가 같은 화면에서 양쪽을 동시에 보게 만드는 일의 가치도 컸습니다. 자동 신호는 문장 정렬과 코드블록 해시와 시각 비교 같은 영역은 잘 찾아내지만 어떤 종류의 문제는 사람이 양쪽을 한 화면에서 보아야 비로소 알아내는 경우가 있고 사람의 OK/NG/DEFER 분류가 다시 자동 신호 임계값의 보정 자료로 누적됩니다. 마지막으로 어떤 데이터는 컨플루언스 본문 안에 같은 모양으로 두지 못할 수 있다는 사실을 미리 인정하고 그 데이터를 별도 경로나 다른 형태로 어떻게 같이 보존할지를 도구의 일부로 같이 두는 결정이 마이그레이션 전체의 호흡을 만들어 준다는 점입니다.
서로 다른 도구 사이에 마이그레이션을 시도하시는 분들께 도움이 될 만한 경험 몇 가지를 같이 기록해 둡니다. 도구가 제공하지 않는 영역의 한계를 미리 측정해 두는 일이 그중 하나입니다. 컨플루언스의 Database 컬럼/row API가 공개되어 있지 않다는 사실을 마이그레이션이 한참 진행된 다음에 알게 되면 그 뒤의 결정은 모두 임시변통이 됩니다. 측정은 보통 그 영역의 endpoint 몇 개에 가짜 payload를 한 번씩 던져 보는 정도면 충분합니다. 비슷한 측정을 컨플루언스 본문 한도, 첨부 100MB 한도, 리비전을 과거 시점의 작성 시간으로 만들 수 있는지, descendants 응답의 완전성, rate limit 헤더 동작에 대해 각각 한 번씩 해 두면 마이그레이션 도중에 만나는 모든 거부는 이미 알고 있던 영역에서 다시 일어나는 사건이 되고 사건의 fix도 같이 미리 준비되어 있습니다. dry-run의 정의를 도구의 문서에 두 줄로 기록해 두는 일도 있습니다. 도쿠위키와 컨플루언스 사이를 오가는 도구라면 dry-run이 양쪽 가운데 어느 쪽의 변경을 막고 어느 쪽의 변경은 그대로 둔다는 약속인지 한 글자도 모호하지 않게 두 줄로 기록해 두어야 합니다. 비교 갤러리처럼 사람이 양쪽을 시각적으로 비교할 수 있는 단계를 도구의 핵심에 같이 두는 일도 있습니다. 자동 신호만으로는 알아내지 못하는 종류의 문제들이 거의 매번 시각 비교에서 처음 드러났고 그 발견 자체가 다음 자동 신호의 임계값과 다음 자동 패치의 룰을 같이 결정해 주었습니다. 보존 경로에 대한 결정을 미리 기록해 두는 일도 있습니다. 어떤 영역의 데이터를 컨플루언스 본문 안에 같은 모양으로 두지 못한다고 판단되면 어떤 별도 경로나 어떤 매크로 형태로 같이 보존할지를 작업 시작 전에 두세 줄로 기록해 두면 작업 도중에 같은 흐름에서 만나는 결정이 임시변통이 아니라 미리 기록해 둔 약속의 실행으로 바뀝니다. 이번 마이그레이션에서 사용한 마이그레이션 스크립트와 진행 중에 겪은 여러 상황을 정리한 문서는 dokuwiki-to-confluence-cloud에 같이 두었으니 비슷한 마이그레이션을 시도하시는 분들께 도움이 되면 좋겠습니다.
비교 갤러리를 한 번씩 다시 열어 보고 있습니다. 결과는 첫 번째 마이그레이션 때처럼 짐만 쌓아 놓은 모습이 아니라 도쿠위키 측과 컨플루언스 측을 양쪽에 나란히 두고 사람이 직접 시각적으로 같은 모양인지 확인한 끝에 정리한 결과에 더 가까워 보입니다. 다음에 또 다른 위키나 다른 시스템을 옮길 일이 생기면 이번처럼 1차 원본 결정과 환경 재현과 멱등 마법사와 비교 갤러리라는 네 단계를 다시 차례로 거치게 될 거라고 기대하고 그때는 이번 5일 동안 기록해 둔 자동 패치 다섯 단계와 dry-run의 두 줄짜리 약속 정의와 보존 경로에 대한 두세 줄짜리 사전 결정이 다시 한번 쓰일 수 있을 거라고 봅니다.