낭만인프라에서 사용하던 Docmost를 Outline으로 전환하게 됐다. 문서 수가 적지 않아서 수동으로 옮기는 건 처음부터 배제했고, Python으로 자동화 스크립트를 짜서 한 번에 이전하기로 했다. 결론부터 말하면 스크립트 자체는 단순했는데, 인프라 구조 때문에 예상보다 시간이 많이 걸렸다.
환경 소개
우리 팀 인프라는 WireGuard VPN으로 구성된 172.16.0.x 폐쇄망 위에 서비스들이 올라가 있다. 서버 접근은 Teleport(tsh)를 통해서만 가능하고, Outline은 AWS EC2 위에 Docker로 올라가 있으며 Teleport 앱으로 등록되어 있다. 이 구조가 나중에 핵심 원인이 된다.
스크립트 구조
스크립트의 동작 방식은 단순하다.
- 로컬 디렉토리를 재귀적으로 순회
- 폴더는 Outline 문서로 생성 (계층 구조 유지)
- 마크다운 파일은 내용을 읽어
documents.createAPI로 업로드 - 마크다운 내 이미지 경로를 찾아
attachments.createAPI로 업로드 후 URL 치환 - 이미지가 있으면
documents.update로 내용 갱신
import os
import requests
import time
import re
API_KEY = "ol_api_..."
BASE_URL = "api url"
COLLECTION_ID = "Collection UUID"
PARENT_DOC_ID = "Parent UUID"
ROOT_DIR = r"현재 내 컴퓨터에 위치한 파일의 경로"
def migrate(local_path, outline_parent_id):
for item in os.listdir(local_path):
if item == 'files': continue
full_path = os.path.join(local_path, item)
if os.path.isdir(full_path):
new_id = create_outline_doc(item, f"# {item}", outline_parent_id)
if new_id:
migrate(full_path, new_id)
elif item.endswith(".md"):
title = os.path.splitext(item)[0]
with open(full_path, 'r', encoding='utf-8') as f:
content = f.read()
doc_id = create_outline_doc(title, content, outline_parent_id)
if doc_id:
refined = process_markdown_content(content, os.path.dirname(full_path), doc_id)
if refined != content:
update_outline_doc(doc_id, title, refined)
time.sleep(1.5)
트러블슈팅
1. ModuleNotFoundError: No module named 'requests'
가장 단순한 오류. Python 환경에 requests가 설치되어 있지 않았다.
pip install requests
2. FileNotFoundError: 경로를 찾을 수 없음
WSL에서 스크립트를 실행했는데 ROOT_DIR이 Windows 경로(C:\Users\...)로 설정되어 있었다. WSL에서는 Windows 드라이브가 /mnt/c/... 형식으로 마운트되기 때문에 경로를 바꿔줘야 한다.
# 변경 전
ROOT_DIR = r"C:\Users\user\Desktop\xxxx"
# 변경 후
ROOT_DIR = "/mnt/c/Users/user/Desktop/xxxx"
반대로 Windows PowerShell에서 실행하면 WSL 경로(/mnt/c/...)를 못 찾는다. 실행 환경에 맞게 경로를 설정해야 한다.
3. HTTP 405 Method Not Allowed (1차)
http://도메인ip주소:포트번호/api/documents.create로 POST 요청을 보냈더니 405가 반환됐다. 원인은 Outline 컨테이너 환경변수에 설정된 FORCE_HTTPS=true였다.
HTTP로 요청하면 Outline이 HTTPS로 리다이렉트하는데, 문제는 POST 요청이 리다이렉트될 때 GET으로 변환된다는 점이다. GET으로 변환된 요청이 documents.create 엔드포인트에 도달하면 해당 엔드포인트는 POST만 허용하므로 405가 반환된다.
# 변경 전
BASE_URL = "http://도메인ip주소:포트번호/api"
# 변경 후
BASE_URL = "https://도메인ip주소/api"
4. SSLCertVerificationError: self-signed certificate
HTTPS로 변경하니 이번엔 SSL 인증서 오류가 발생했다. 내부망에서 자체 서명 인증서(self-signed certificate)를 사용하고 있기 때문이다. verify=False로 SSL 검증을 비활성화하고, urllib3 경고도 함께 꺼줬다.
import urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# 모든 requests 호출에 verify=False 추가
response = requests.post(url, headers=headers, json=payload, verify=False)
5. HTTP 404 - Outline이 아닌 Phase 페이지가 반환됨
SSL 문제를 해결하고 나니 이번엔 404가 반환됐는데, 응답 본문을 보니 Outline이 아니라 Phase(시크릿 관리 서비스)의 HTML 페이지였다.
원인을 파악하기 위해 서버의 Nginx 설정을 확인했다.
docker exec phase-nginx cat /etc/nginx/conf.d/default.conf
확인 결과, 172.XX.XX.XX:443에 올라간 Nginx는 Phase 전용이었다. 모든 요청을 Phase frontend(http://frontend:3000)로 라우팅하고 있었고, Outline 관련 설정은 전혀 없었다. Outline은 이 Nginx와 무관하게 별도로 0.0.0.0:3000에 떠 있었다.
docker ps
# outline-outline-1 0.0.0.0:3000->3000/tcp
# phase-nginx 0.0.0.0:80->80/tcp, 0.0.0.0:443->443/tcp
6. HTTP 405 Method Not Allowed (2차) - 근본 원인 분석
그렇다면 172.XX.XX.XX:3000으로 직접 요청하면 되지 않을까 생각했다. 그런데 서버 내부에서 curl로 직접 때려봐도 똑같이 405가 반환됐다.
curl -v -X POST http://172.XX.XX.XX:3000/api/documents.create \
-H "Authorization: Bearer ol_api_..." \
-H "Content-Type: application/json" \
-d '{"title":"test","collectionId":"..."}'
# 결과: HTTP/1.1 405 Method Not Allowed
# Allow: GET, HEAD
라우트 자체가 문제인지 확인하기 위해 Outline 컨테이너 안에서 소스코드를 직접 분석했다.
# 라우트 등록 확인
grep -n "documents.create" /opt/outline/build/server/routes/api/documents/documents.js
# → router.post("documents.create", ...) 정상 등록 확인
# API 마운트 확인
grep -n "api" /opt/outline/build/server/services/web.js
# → app.use(koaMount("/api", api)) 정상 마운트 확인
# 실제 등록된 라우트 목록 확인 (Node.js로 직접 조회)
docker exec outline-outline-1 node -e "
const api = require('/opt/outline/build/server/routes/api/index.js');
const dispatch = api.default.middleware[10];
const routes = dispatch.router.stack.filter(r => r.path && r.path.includes('create'));
routes.forEach(r => console.log(r.methods, r.path));
"
# → [ 'POST' ] /documents.create 정상 등록 확인
코드상으로는 완벽했다. 그런데 브라우저 Network 탭에서 웹 UI가 보내는 요청 헤더를 분석하다가 결정적인 단서를 발견했다.
# 웹 UI 요청 헤더
Host: docs.~~~.xxxx.!!!
# curl 요청 헤더
Host: 172.XX.XX.XX:3000
그리고 docs.console.nangman.cloud에 직접 HTTPS 요청을 보내봤더니 302 리다이렉트가 반환됐다.
curl -sk -v https://docs.console.nangman.cloud/api/documents.create 2>&1 | grep Location
# 결과
Location: https://console.nangman.cloud:443/web/launch/docs.console.nangman.cloud
Outline이 Teleport 프록시 뒤에 있었던 것이다. 브라우저에서는 Teleport 세션 쿠키가 있어서 정상적으로 접근되지만, 스크립트에서 직접 요청하면 Teleport 인증을 통과하지 못하고 로그인 페이지로 리다이렉트된다. 172.XX.XX.XX:3000으로 직접 때려도 405가 나오는 이유는 아직 명확히 파악하지 못했지만, 어쨌든 올바른 접근 방법은 Teleport를 통하는 것이었다.
7. tsh proxy app으로 터널링
Teleport CLI를 사용하면 로컬 포트를 Teleport 앱으로 터널링할 수 있다. 먼저 앱에 로그인하고, 로컬 프록시를 실행한다.
# 앱 목록 확인
tsh app ls
# docs HTTP docs.~~~.xxxx.!!!
# 앱 로그인
tsh app login docs
# 로컬 프록시 실행 (터미널 1에서 유지)
tsh proxy app docs -p 8080
# Proxying connections to docs on 127.0.0.1:8080
이렇게 하면 http://127.0.0.1:8080으로 Outline API에 직접 접근할 수 있다. Teleport가 인증을 대신 처리해주기 때문에 API 키만 있으면 된다.
BASE_URL = "http://127.0.0.1:8080/api"
8. collectionId / parentDocumentId Invalid UUID
연결은 됐는데 이번엔 validation_error: collectionId: Invalid UUID 오류가 발생했다.
Outline URL에 표시되는 ID와 API에서 사용하는 ID가 다르다. URL에 보이는 inVlbSKlUN은 urlId이고, API에서 요구하는 건 실제 UUID 형식(xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx)이다.
# 컬렉션 UUID 조회
curl http://127.0.0.1:8080/api/collections.list -X POST \
-H "Authorization: Bearer ol_api_..." \
-H "Content-Type: application/json" \
-d "{}"
# 특정 문서 UUID 조회 (urlId로 조회 가능)
curl http://127.0.0.1:8080/api/documents.info -X POST \
-H "Authorization: Bearer ol_api_..." \
-H "Content-Type: application/json" \
-d "{\"id\":\"FQk08rqtub\"}"
조회 결과에서 id 필드 값을 사용하면 된다. urlId는 URL 표시용이고, id가 실제 UUID다.
9. 이미지 업로드 400 Bad Request
문서 업로드는 성공했는데 이미지 업로드에서 400이 반환됐다. attachments.create API에 documentId를 query parameter로 전달했는데, 이 API는 multipart form data로 받아야 한다.
# 잘못된 방법 - query parameter로 전달
response = requests.post(url, headers=headers, files=files, params={"documentId": doc_id})
# 올바른 방법 - form data로 전달
response = requests.post(url, headers=headers, files=files, data={"documentId": doc_id})
params는 URL 쿼리스트링(?documentId=...)으로 붙고, data는 multipart form body에 포함된다. Outline의 attachments.create는 후자를 요구한다.
10. 429 Too Many Requests
이미지가 많은 문서에서 연속으로 업로드 요청을 보내면 429 rate limit 오류가 발생했다. Outline 설정에서 RATE_LIMITER_ENABLED=true, RATE_LIMITER_REQUESTS=1000으로 설정되어 있었는데, 짧은 시간에 요청이 몰리면 걸리는 것 같았다.
# 이미지 업로드 간 딜레이
time.sleep(3)
# 문서 생성 간 딜레이
time.sleep(1.5)
최종 동작 흐름 정리
모든 문제를 해결한 후 최종적으로 동작하는 흐름은 다음과 같다.
- 터미널 1:
tsh proxy app docs -p 8080실행 후 유지 - 터미널 2:
python auto-migration.py실행 - 스크립트가
http://127.0.0.1:8080/api로 요청 → Teleport가 Outline으로 프록시 - 문서 생성 → 이미지 업로드 → 문서 내용 업데이트 순으로 처리
# 최종 설정값
API_KEY = "ol_api_..."
BASE_URL = "http://127.0.0.1:8080/api"
COLLECTION_ID = "COLLECTION_ID"
PARENT_DOC_ID = "PARENT_DOC_ID" #
ROOT_DIR = r"C:\Users\user\Desktop\XXXXXX"
마무리
단순해 보이는 마이그레이션 스크립트 하나 돌리는 데 이렇게 많은 시간이 소모될 줄 몰랐다. Outline 소스코드까지 직접 분석하면서 라우트 등록, 미들웨어 순서, koa-router 동작 방식까지 뒤졌는데, 결국 문제는 코드가 아니라 인프라 구조였다.
인프라 구조를 먼저 파악하고 시작했으면 훨씬 빨랐을 것 같다. 앞으로 비슷한 작업을 할 때는 서비스 앞단에 어떤 프록시나 게이트웨이가 있는지 먼저 확인하는 습관을 들여야겠다.
'Troubleshooting' 카테고리의 다른 글
| [Troubleshooting Report] Harbor 서비스 관리 체계 개선 (0) | 2026.03.11 |
|---|