@milcho0604/velog-mcp 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.ko.md +366 -0
  3. package/README.md +381 -0
  4. package/dist/auth.d.ts +57 -0
  5. package/dist/auth.js +124 -0
  6. package/dist/auth.js.map +1 -0
  7. package/dist/capabilities.d.ts +50 -0
  8. package/dist/capabilities.js +60 -0
  9. package/dist/capabilities.js.map +1 -0
  10. package/dist/client.d.ts +113 -0
  11. package/dist/client.js +322 -0
  12. package/dist/client.js.map +1 -0
  13. package/dist/format.d.ts +31 -0
  14. package/dist/format.js +66 -0
  15. package/dist/format.js.map +1 -0
  16. package/dist/graphql.d.ts +29 -0
  17. package/dist/graphql.js +82 -0
  18. package/dist/graphql.js.map +1 -0
  19. package/dist/index.d.ts +25 -0
  20. package/dist/index.js +149 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/me.d.ts +23 -0
  23. package/dist/me.js +35 -0
  24. package/dist/me.js.map +1 -0
  25. package/dist/ownership.d.ts +42 -0
  26. package/dist/ownership.js +62 -0
  27. package/dist/ownership.js.map +1 -0
  28. package/dist/plugin-env.d.ts +67 -0
  29. package/dist/plugin-env.js +102 -0
  30. package/dist/plugin-env.js.map +1 -0
  31. package/dist/ratelimit.d.ts +48 -0
  32. package/dist/ratelimit.js +79 -0
  33. package/dist/ratelimit.js.map +1 -0
  34. package/dist/render/chrome.d.ts +77 -0
  35. package/dist/render/chrome.js +287 -0
  36. package/dist/render/chrome.js.map +1 -0
  37. package/dist/render/cover.d.ts +29 -0
  38. package/dist/render/cover.js +195 -0
  39. package/dist/render/cover.js.map +1 -0
  40. package/dist/render/icons.d.ts +22 -0
  41. package/dist/render/icons.js +158 -0
  42. package/dist/render/icons.js.map +1 -0
  43. package/dist/render/index.d.ts +32 -0
  44. package/dist/render/index.js +137 -0
  45. package/dist/render/index.js.map +1 -0
  46. package/dist/render/page.d.ts +89 -0
  47. package/dist/render/page.js +761 -0
  48. package/dist/render/page.js.map +1 -0
  49. package/dist/render/tones.d.ts +30 -0
  50. package/dist/render/tones.js +46 -0
  51. package/dist/render/tones.js.map +1 -0
  52. package/dist/slug.d.ts +42 -0
  53. package/dist/slug.js +79 -0
  54. package/dist/slug.js.map +1 -0
  55. package/dist/tools/discover.d.ts +6 -0
  56. package/dist/tools/discover.js +106 -0
  57. package/dist/tools/discover.js.map +1 -0
  58. package/dist/tools/drafts.d.ts +23 -0
  59. package/dist/tools/drafts.js +227 -0
  60. package/dist/tools/drafts.js.map +1 -0
  61. package/dist/tools/export.d.ts +21 -0
  62. package/dist/tools/export.js +132 -0
  63. package/dist/tools/export.js.map +1 -0
  64. package/dist/tools/images.d.ts +34 -0
  65. package/dist/tools/images.js +556 -0
  66. package/dist/tools/images.js.map +1 -0
  67. package/dist/tools/posts.d.ts +14 -0
  68. package/dist/tools/posts.js +82 -0
  69. package/dist/tools/posts.js.map +1 -0
  70. package/dist/tools/profile-edit.d.ts +15 -0
  71. package/dist/tools/profile-edit.js +216 -0
  72. package/dist/tools/profile-edit.js.map +1 -0
  73. package/dist/tools/profile.d.ts +9 -0
  74. package/dist/tools/profile.js +133 -0
  75. package/dist/tools/profile.js.map +1 -0
  76. package/dist/tools/publish.d.ts +16 -0
  77. package/dist/tools/publish.js +424 -0
  78. package/dist/tools/publish.js.map +1 -0
  79. package/dist/tools/stats.d.ts +32 -0
  80. package/dist/tools/stats.js +154 -0
  81. package/dist/tools/stats.js.map +1 -0
  82. package/dist/types.d.ts +42 -0
  83. package/dist/types.js +3 -0
  84. package/dist/types.js.map +1 -0
  85. package/docs/PRD.md +146 -0
  86. package/docs/api-reference.md +329 -0
  87. package/docs/architecture.md +112 -0
  88. package/docs/decisions/0001-why-build-our-own.md +89 -0
  89. package/docs/decisions/0002-draft-only-write.md +84 -0
  90. package/docs/decisions/0003-token-env-only.md +109 -0
  91. package/docs/decisions/0004-capability-model.md +123 -0
  92. package/docs/decisions/0005-render-in-server.md +117 -0
  93. package/docs/decisions/0006-ship-as-plugin.md +532 -0
  94. package/docs/security.md +384 -0
  95. package/docs/tools.md +404 -0
  96. package/npm-shrinkwrap.json +2345 -0
  97. package/package.json +61 -0
@@ -0,0 +1,89 @@
1
+ # ADR 0001 — 기존 패키지를 포크하지 않고 직접 만든다
2
+
3
+ - 날짜: 2026-07-30
4
+ - 상태: 채택
5
+
6
+ ## 맥락
7
+
8
+ Velog MCP 구현이 이미 둘 있다. 처음 검토한 선택지는 세 가지였다.
9
+
10
+ 1. `velog-mcp` (stoneHee99) 를 npm 에서 그대로 설치
11
+ 2. `velog-mcp-claude` (seongwon030) 를 포크해서 위험한 도구를 제거
12
+ 3. 직접 만든다
13
+
14
+ ## 조사한 사실 (2026-07-30 실측)
15
+
16
+ ### 라이선스
17
+
18
+ 둘 다 MIT, LICENSE 파일 실물 확인. 포크·수정·재배포에 법적 장애 없음.
19
+ 의무는 저작권 고지 유지 하나뿐.
20
+
21
+ ### stoneHee99/velog-mcp
22
+
23
+ - 소스 3파일 26KB. 감사하기 쉬운 크기
24
+ - 의존성 2개 (MCP SDK, zod)
25
+ - 외부 접속 URL 을 전수 확인한 결과 벨로그 도메인 외에는 없음 — 토큰 유출 코드 없음
26
+ - **다만** `security find-generic-password -w -s "Chrome Safe Storage" -a "Chrome"`
27
+ 로 Chrome 마스터 암호키를 가져와 쿠키 DB 를 복호화한다.
28
+ SQL 은 `WHERE host_key='.velog.io' AND (name='access_token' OR name='refresh_token')`
29
+ 으로 정확히 한정돼 있어 **현재 코드는 결백**하다. 문제는 그 키를 다루는 코드가
30
+ 상주한다는 구조 자체다
31
+ - `write_post` 가 곧바로 발행. `delete_post` 존재
32
+
33
+ ### seongwon030/velog_mcp
34
+
35
+ - 모듈화 양호, 테스트 있음, `createDraft`/`publishPost` 분리 (설계 좋음)
36
+ - 기능 풍부 (시리즈·태그·이미지·github-import)
37
+ - 키체인 미접근 — 토큰 붙여넣기 방식
38
+ - **npm 배포본이 소스보다 낡음**: npm `velog-mcp-claude` 0.10.0 (2026-04-22) vs
39
+ GitHub `package.json` 0.20.0 (2026-04-28). 소스의 스코프명
40
+ `@seongwon030/velog-mcp-claude` 는 npm 에 **존재하지 않음(404)**
41
+
42
+ ## 결정
43
+
44
+ **직접 만든다.**
45
+
46
+ ## 근거
47
+
48
+ **포크로도 해결이 안 되는 문제가 있었다.** 위험한 도구를 지우는 건 포크로 되지만,
49
+ 그건 "지웠다"는 상태일 뿐 다음 upstream 머지 때 되살아날 수 있다. 우리가 원한 건
50
+ **애초에 그 코드가 없는 상태**다. 발행 mutation 을 호출하는 줄이 레포에 한 줄도
51
+ 없으면, 리뷰할 것도 없고 되살아날 것도 없다.
52
+
53
+ **직접 만들 재료가 이미 다 있었다.** 결정적이었던 건 이것이다.
54
+
55
+ ```
56
+ $ curl -s -X POST https://v3.velog.io/graphql \
57
+ -d '{"query":"{ __schema { queryType { name } } }"}'
58
+ {"data":{"__schema":{"queryType":{"name":"Query"}}}}
59
+ ```
60
+
61
+ **`v3.velog.io/graphql` 은 introspection 이 열려 있다.** (`v2` 는 막혀 있다.)
62
+ 즉 벨로그가 스키마 전체를 직접 알려준다. Mutation 23개, Query 24개, 각 입력
63
+ 타입의 필드까지 전부. 남의 코드를 참고할 이유가 사라졌다.
64
+
65
+ 이건 법적으로도 더 깨끗하다. 우리 구현의 출처는 **벨로그의 introspection 응답**
66
+ 이지 다른 MIT 패키지가 아니다. (API 인터페이스 재구현 자체는 Google v. Oracle
67
+ (2021) 로 침해가 아님이 확립돼 있지만, 애초에 참조하지 않는 편이 낫다.)
68
+
69
+ **포크 대상을 골라도 유지보수 부담은 같았다.** 비공식 API 라 벨로그가 바꾸면
70
+ 누가 만들었든 깨진다. 그 부담을 지는 게 전제라면, 남의 설계를 물려받는 것보다
71
+ 우리 요구(초안 전용)에 맞춰 처음부터 짜는 게 총비용이 낮다. 실제 코어는
72
+ GraphQL POST 한 함수 + 도구 정의들이라 규모가 크지 않다.
73
+
74
+ ## 결과
75
+
76
+ - 런타임 의존성 2개 유지 (MCP SDK, zod). fetch·테스트 러너·타입 스트리핑은 Node 24 내장
77
+ - introspection 실측을 `docs/api-reference.md` 에 날짜와 함께 고정
78
+ - 발행·삭제·소셜·계정 mutation 은 **미구현**. `docs/security.md` 에 목록과 이유 기록
79
+
80
+ ## 남는 부채
81
+
82
+ 벨로그가 스키마를 바꾸면 우리가 고쳐야 한다. 이걸 감당하려고
83
+ introspection diff 스크립트를 둔다 — 깨졌을 때 "어디가" 바뀌었는지 즉시 나온다.
84
+
85
+ ## 참고
86
+
87
+ - [stoneHee99/velog-mcp](https://github.com/stoneHee99/velog-mcp) (MIT)
88
+ - [seongwon030/velog_mcp](https://github.com/seongwon030/velog_mcp) (MIT)
89
+ - [Velog 이용약관](https://velog.io/policy/terms)
@@ -0,0 +1,84 @@
1
+ # ADR 0002 — 쓰기는 `is_temp: true` 로 고정한다
2
+
3
+ - 날짜: 2026-07-30
4
+ - 상태: **[ADR 0004](0004-capability-model.md) 로 대체됨** (2026-07-31)
5
+
6
+ > 이 문서의 결론("이 서버는 발행할 수 없다")은 더 이상 유효하지 않다.
7
+ > 위험의 경계가 '발행'이 아니라 '공개'에 있다는 걸 나중에 실측으로 알았고,
8
+ > 권한을 사용자에게 돌려주는 쪽으로 바꿨다. 다만 아래의 **분석 자체는 유효하다** —
9
+ > `is_temp` 가 불린 하나라는 점, 정책보다 구조가 낫다는 점은 그대로 ADR 0004 의
10
+ > 설계 근거로 이어진다. 기록으로 남긴다.
11
+
12
+ ## 맥락
13
+
14
+ MCP 서버를 붙이면 모델이 내 블로그에 쓰기 권한을 갖는다. 여기서 위험은
15
+ "모델이 악의적일까"가 아니라 **"판단을 한 번 잘못했을 때 되돌릴 수 있는가"** 다.
16
+
17
+ 벨로그 mutation 중 되돌리기 어려운 것들:
18
+
19
+ | mutation | 되돌릴 수 있나 |
20
+ | --- | --- |
21
+ | `writePost(is_temp: false)` | 발행됨. 지워도 RSS·검색엔진·구독 메일에 이미 나감 |
22
+ | `unregister` | **계정 탈퇴.** 불가 |
23
+ | `likePost` / `follow` | 남의 알림에 이미 뜸 |
24
+ | `writePost(is_temp: true)` | 임시저장. 남에게 안 보이고 지우면 끝 |
25
+
26
+ 마지막 줄이 답이었다.
27
+
28
+ ## 결정
29
+
30
+ **쓰기 도구는 `is_temp: true` 를 하드코딩한다.** 이 값은 도구 파라미터로 노출하지
31
+ 않는다. 모델도, 사용자도, 설정 파일도 이 값을 바꿀 수 없다.
32
+
33
+ 발행 경로는 구현하지 않는다. 사람이 벨로그에서 누른다.
34
+
35
+ ## 근거
36
+
37
+ **introspection 이 마침 이걸 가능하게 해줬다.** `WritePostInput` 실측:
38
+
39
+ ```
40
+ * title String * is_markdown Boolean
41
+ * body String * is_temp Boolean ← 이것
42
+ * tags LIST * is_private Boolean
43
+ * url_slug String * meta JSON
44
+ thumbnail String series_id ID
45
+ ```
46
+
47
+ 발행과 임시저장이 **별도 mutation 이 아니라 같은 mutation 의 불린 하나**로
48
+ 갈린다. 그래서 "발행 도구를 안 만든다"가 아니라 **"그 불린을 상수로 박는다"**
49
+ 가 된다. 도구를 안 만드는 건 목록에서 빼는 것뿐이지만, 값을 박으면
50
+ 호출 경로 자체가 발행에 도달할 수 없다.
51
+
52
+ **정책보다 구조가 낫다.** "발행하지 마세요"라는 프롬프트나 확인 절차는
53
+ 지켜질 수도, 안 지켜질 수도 있다. `is_temp: true` 가 상수면 지켜지고 말고가 없다.
54
+
55
+ **되돌릴 수 있는 실수만 허용한다.** 모델이 초안을 20개 만들어놓는 최악의 경우도,
56
+ 사용자가 벨로그에서 임시글 목록을 열어 지우면 끝난다. 남에게 보인 적이 없다.
57
+
58
+ ## 구현
59
+
60
+ ```ts
61
+ // src/tools/drafts.ts — 이 파일이 유일한 쓰기 경로
62
+ const DRAFT_ONLY = { is_temp: true } as const; // 파라미터 아님. 상수.
63
+
64
+ // 도구 입력 스키마에 is_temp 가 없다 — 받지 않으므로 덮어쓸 수 없다
65
+ ```
66
+
67
+ ## 검증
68
+
69
+ `A1` 성공 기준으로 테스트에 고정한다.
70
+
71
+ - 도구 입력 스키마에 `is_temp` 키가 없음을 단언
72
+ - 생성되는 GraphQL variables 에 `is_temp === true` 임을 단언
73
+ - 도구 목록 스냅샷에 발행/삭제 계열 이름이 없음을 단언
74
+
75
+ ## 대가
76
+
77
+ **초안에서 발행까지 한 번에 못 한다.** 의도한 대가다. 벨로그 에디터에서 한 번
78
+ 읽고 누르는 절차가 남는데, 이건 비용이 아니라 마지막 검토 기회다.
79
+
80
+ ## 재검토 조건
81
+
82
+ 발행이 정말 필요해지면, 그때는 별도 CLI (`npm run publish -- <id>`) 로 낸다.
83
+ MCP 도구 목록에는 끝까지 올리지 않는다 — 사람이 터미널에 직접 치는 것과
84
+ 모델이 호출할 수 있는 것은 다르다.
@@ -0,0 +1,109 @@
1
+ # ADR 0003 — 토큰은 환경변수로만 받는다
2
+
3
+ - 날짜: 2026-07-30
4
+ - 상태: 채택
5
+
6
+ ## 맥락
7
+
8
+ 벨로그는 공개 쓰기 API 가 없다. 인증 수단은 브라우저 세션 쿠키뿐이다.
9
+
10
+ ```
11
+ access_token 1시간
12
+ refresh_token 30일 ← 30일짜리 계정 전권 자격증명
13
+ ```
14
+
15
+ 이걸 서버에 어떻게 전달할지에 선택지가 셋 있었다.
16
+
17
+ | 방식 | 편의 | 위험 |
18
+ | --- | --- | --- |
19
+ | A. Chrome 쿠키 DB 자동 추출 | 최고 (로그인만 하면 끝) | macOS 키체인에서 Chrome 마스터 키를 뽑는 코드가 상주 |
20
+ | B. 파일 저장 (`~/.velog-mcp/tokens.json`) | 좋음 (한 번만 넣으면 됨) | 30일 전권 자격증명이 평문으로 디스크에 상주 |
21
+ | C. 환경변수 전용 | 보통 (만료되면 다시 넣어야) | 프로세스 수명만큼만 존재 |
22
+
23
+ 기존 두 구현은 각각 A+B, B 를 쓴다.
24
+
25
+ ## 결정
26
+
27
+ **C. 환경변수 전용.**
28
+
29
+ - `VELOG_ACCESS_TOKEN`, `VELOG_REFRESH_TOKEN` 만 읽는다
30
+ - 디스크에 쓰지 않는다
31
+ - 브라우저 쿠키 DB·키체인을 건드리지 않는다
32
+ - 토큰이 없으면 **읽기 전용**으로 정상 기동한다 (에러로 죽지 않는다)
33
+
34
+ ## 근거
35
+
36
+ **A 를 뺀 이유.** `security find-generic-password -w -s "Chrome Safe Storage"` 는
37
+ Chrome 의 **모든** 쿠키를 복호화할 수 있는 키를 가져온다. 기존 구현의 SQL 은
38
+ `.velog.io` 의 토큰 2개로 제대로 한정돼 있어 지금은 결백하다. 하지만 그 키를
39
+ 손에 쥔 코드가 항상 돌아야 할 이유가 없다. 편의 한 단계를 위해 능력의 상한을
40
+ 크게 올리는 교환이라 거절했다.
41
+
42
+ **B 를 뺀 이유.** 30일 자격증명이 평문으로 디스크에 남으면, 그 파일 하나가
43
+ 백업·동기화·다른 도구의 스캔 범위에 계속 노출된다. 반면 얻는 편의는
44
+ "1시간마다 다시 안 넣어도 됨" 정도다.
45
+
46
+ **C 의 대가를 받아들인 이유.** `access_token` 이 1시간이라 자주 갱신해야 한다.
47
+ 불편하다. 다만 이 서버의 주 용도가 **초안 작성**이라 상시 구동이 아니다.
48
+ 글 쓸 때 켜고, 그 세션 동안만 살아 있으면 된다.
49
+
50
+ 읽기 도구 대부분이 무인증으로 동작하는 것도 이 결정을 싸게 만든다. 조회·검색·
51
+ 트렌딩은 토큰 없이 되므로, 토큰을 넣는 건 초안을 만들 때뿐이다.
52
+
53
+ ## 구현 규칙
54
+
55
+ ```ts
56
+ // 1. 읽기는 한 곳에서만
57
+ const ACCESS = process.env.VELOG_ACCESS_TOKEN;
58
+ const REFRESH = process.env.VELOG_REFRESH_TOKEN;
59
+
60
+ // 2. 로그·에러에 실리지 않게 — 토큰 문자열은 절대 메시지에 넣지 않는다
61
+ // (GraphQL 에러 응답을 그대로 던지면 Cookie 헤더가 섞일 수 있어 마스킹한다)
62
+
63
+ // 3. 토큰 없음은 에러가 아니라 상태다
64
+ // 읽기 도구는 정상 동작, 쓰기 도구만 "인증 필요" 를 반환
65
+ ```
66
+
67
+ ## 검증
68
+
69
+ - `A3`: 파일 쓰기 API 를 쓰지 않음을 검사 + 에러 마스킹 단위 테스트
70
+ - `A4`: 토큰 없이 공개 글 조회가 되는 통합 테스트
71
+
72
+ ## 개정 (2026-07-30) — 갱신은 서버가 한다
73
+
74
+ 초안에서는 "`refresh_token` 자동 갱신을 넣지 않는다. 넣으면 30일 자격증명을 상시
75
+ 보관해야 해서 B 와 같아진다"고 적었다. **이건 사실 확인 없이 내린 결론이었고,
76
+ 벨로그 공식 소스를 읽고 뒤집었다.**
77
+
78
+ ```ts
79
+ // velog-io/velog · apps/server/src/common/plugins/global/authPlugin.mts
80
+ const diff = accessTokenData.exp * 1000 - new Date().getTime()
81
+ // refresh token when life < 30mins
82
+ if (diff < Time.ONE_MINUTE_IN_MS * 30 && refreshToken) {
83
+ await userService.restoreToken({ request, reply })
84
+ }
85
+ ...
86
+ if (!accessToken && refreshToken) { // access 가 아예 없어도
87
+ const tokens = await userService.restoreToken({ request, reply })
88
+ }
89
+ ```
90
+
91
+ **갱신 로직은 이미 서버에 있다.** 우리가 구현할 게 없다. 우리는 두 가지만 하면 된다.
92
+
93
+ 1. `refresh_token` 만 있어도 인증을 시도한다 — 서버가 `access_token` 을 만들어준다
94
+ 2. 응답의 `Set-Cookie` 로 오는 새 토큰을 **메모리에** 반영한다
95
+
96
+ 이래도 원칙은 그대로다: **디스크에 쓰지 않는다.** 갱신된 토큰은 프로세스가
97
+ 살아 있는 동안만 존재하고, 종료하면 사라진다. 파일도, 키체인도 없다.
98
+
99
+ 얻는 것은 크다. 종전에는 1시간마다 세션이 죽었는데, 이제 `refresh_token` 하나만
100
+ 넣으면 **30일간** 초안 작성이 된다. 그리고 사용자가 넣을 값이 둘에서 하나로 줄었다.
101
+
102
+ `TokenStore` 가 **갱신 전 토큰도 계속 마스킹 대상에 둔다** — 옛 토큰이 나중에
103
+ 로그로 새면 갱신한 의미가 없다.
104
+
105
+ ## 재검토 조건
106
+
107
+ 토큰을 디스크에 쓰자는 제안이 오면 이 문서를 먼저 볼 것. 편의 문제는 위
108
+ 서버 주도 갱신으로 대부분 해소됐으므로, 파일 저장을 정당화하려면 새로운
109
+ 근거가 필요하다.
@@ -0,0 +1,123 @@
1
+ # ADR 0004 — 권한을 사용자에게 돌려주되, 모델은 못 올리게 한다
2
+
3
+ - 날짜: 2026-07-31
4
+ - 상태: 채택
5
+ - [ADR 0002](0002-draft-only-write.md) 를 대체한다
6
+
7
+ ## 맥락
8
+
9
+ ADR 0002 는 "이 서버는 글을 발행할 수 없다"였다. `is_temp: true` 를 상수로 박아
10
+ 발행 경로를 아예 없앴다. 안전했지만 **과했다.**
11
+
12
+ 실제로 써보니 문제가 분명했다. 사용자 본인 블로그인데 발행 여부를 서버가 정하고
13
+ 있다. 초안까지 만들어놓고 매번 브라우저를 열어야 하면 도구의 값어치가 절반이다.
14
+ "안전하다"는 이유로 사용자의 결정권을 가져간 셈이었다.
15
+
16
+ ## 결정
17
+
18
+ **권한 스위치를 사용자에게 준다. 단 모델은 그 스위치를 못 건드린다.**
19
+
20
+ ```
21
+ 기본 (설정 없음) 읽기 + 초안 + 비공개 발행
22
+ VELOG_ALLOW_PUBLIC=1 공개 발행
23
+ ```
24
+
25
+ ## 근거
26
+
27
+ ### 1. 위험의 경계는 '발행'이 아니라 '공개'였다
28
+
29
+ ADR 0002 는 발행 전체를 위험으로 봤는데, 실측해보니 선이 다른 곳에 있었다.
30
+
31
+ ```ts
32
+ // apps/server/src/services/PostApiService/index.mts
33
+ count({ where: { fk_user_id, is_private: false, released_at: { gt: 5분전 } } })
34
+ if (count >= 10) {
35
+ updateMany({ where: { fk_user_id, released_at: { gt: 5분전 } },
36
+ data: { is_private: true } })
37
+ }
38
+ ```
39
+
40
+ 계수 대상이 `is_private: false` 뿐이다. **비공개 글은 이 카운터를 올리지 않는다.**
41
+ (다만 '유발하지 않는다'는 뜻은 아니다 — 아래 §2 정정 참고.)
42
+ 그리고 비공개 글은 RSS·검색·구독 메일 어디에도 안 나간다 — 지우면 정말 지워진다.
43
+
44
+ 공개만 다르다. 한번 나가면 회수가 안 되고, 위 계수에도 잡힌다.
45
+ 그래서 **비공개까지는 기본으로 열고, 공개만 opt-in** 으로 나눴다.
46
+
47
+ ### 2. 부수 효과가 컸다 — 초안을 `is_private: true` 로
48
+
49
+ 이 사실을 알고 나니 초안도 고칠 게 보였다. 종전 초안은
50
+ `is_temp: true, is_private: false` 였는데, `is_private: false` 라서 위 계수에
51
+ 들어갔다. 그래서 "초안을 5분에 10개 만들면 그 시간대에 발행한 글이 비공개가
52
+ 된다"는 [높음] 위험이 있었고, 재시도 금지 + 자체 rate limit 으로 막아뒀었다.
53
+
54
+ 초안을 `is_private: true` 로 바꾸자 **초안이 카운터를 올리지 않게 됐다.**
55
+ 초안은 어차피 본인만 보므로 공개 여부에 의미가 없어 잃는 것도 없다.
56
+
57
+ ⚠️ **정정** (2026-07-31, 2차 검토): 처음엔 이걸 "위험이 원천 소멸했다"고 적었다.
58
+ 틀렸다. 서버 코드를 다시 보면:
59
+
60
+ ```ts
61
+ const isPublish = !data.is_temp && !data.is_private
62
+ const isLimit = await this.isPostLimitReached(signedUserId) // ← 무조건 실행
63
+ ```
64
+
65
+ 공개 여부를 보기 **전에** 검사가 돈다. 이미 최근 5분에 공개 글이 10건 쌓여 있으면,
66
+ 다음 요청이 비공개 초안 생성이어도 그 시점에 최근 글 전체가 비공개로 바뀐다.
67
+ 그 10건은 사용자가 웹에서 직접 올린 것일 수 있어 우리 카운터로는 안 보인다.
68
+
69
+ 정확한 결론:
70
+
71
+ | 명제 | 참/거짓 |
72
+ | --- | --- |
73
+ | `is_private:true` 글은 카운터를 **올리지 않는다** | 참 |
74
+ | 그래서 위험이 **원천 소멸한다** | **거짓** |
75
+ | 비공개로 두면 위험이 **줄어든다** | 참 |
76
+
77
+ '카운터를 올리지 않는다'와 '파괴 동작을 유발하지 않는다'는 다르다. 그래서 쓰기
78
+ 무재시도와 자체 상한을 **없애지 않고 유지한다.** 방어를 걷어낼 뻔했다.
79
+
80
+ ### 3. 스위치는 환경변수여야 한다
81
+
82
+ 도구 파라미터로 두면 모델이 스스로 권한을 올릴 수 있다. 환경변수는 MCP 설정
83
+ 파일을 여는 사람만 바꾼다 — **토큰을 넣는 것과 같은 신뢰 경계**다.
84
+
85
+ 구현도 이 경계를 따른다. 설정이 꺼져 있으면 `is_private` 파라미터가 **스키마에
86
+ 아예 없다.** 모델이 요청할 방법 자체가 없고, 혹시 넘겨도 `resolvePrivacy` 가
87
+ `true` 로 확정한다. 두 겹이라 한쪽이 뚫려도 막힌다.
88
+
89
+ `1`·`true`·`yes`·`on` 만 '켬'으로 본다. 오타로 조용히 켜지면 안 된다.
90
+
91
+ ### 4. 초안과 발행은 별도 도구로
92
+
93
+ 같은 도구에 `is_temp` 파라미터를 두면 초안을 쓰려다 값 하나 잘못 넣어 발행되는
94
+ 경로가 생긴다. 이름이 다르면 그 사고가 안 난다.
95
+
96
+ ```
97
+ velog_create_draft 항상 초안. 어떤 설정에서도 발행 안 함
98
+ velog_publish_post 새 글 발행
99
+ velog_publish_draft 초안을 발행
100
+ velog_unpublish_post 발행글을 초안으로 되돌림
101
+ velog_update_post 발행글 수정 (발행 상태 유지)
102
+ ```
103
+
104
+ ## 바뀌지 않은 것
105
+
106
+ - 삭제는 여전히 없다. 애초에 v3 에 `deletePost` mutation 이 없다
107
+ - 소셜(좋아요·팔로우)·계정설정·탈퇴·로그아웃·메일발송은 미구현. 설정으로도 안 열린다
108
+ - 토큰은 환경변수로만, 디스크에 안 쓴다 ([ADR 0003](0003-token-env-only.md))
109
+
110
+ ## 대가
111
+
112
+ 기본 설정에서 **공개 글을 수정하면 비공개로 내려간다.** 공개 권한이 없는데 공개
113
+ 상태를 유지시키면 그게 곧 공개 발행 권한이 되기 때문이다. 도구 설명에 적어뒀고,
114
+ 공개 글을 다루려면 `VELOG_ALLOW_PUBLIC=1` 을 켜는 게 맞다.
115
+
116
+ ## 실수 기록
117
+
118
+ `is_private` 에 `.default(true)` 를 걸어두는 바람에, 공개 발행이 켜진 상태에서
119
+ `velog_update_post` 로 제목만 고쳐도 zod 가 `true` 를 채워 넣어 **공개글이 조용히
120
+ 비공개로 내려갔다.** 테스트를 쓰다 잡았다.
121
+
122
+ 교훈: '새로 만들 때'와 '기존 것을 고칠 때'의 안전한 기본값이 다르다.
123
+ 만들 때는 안전한 쪽(비공개)이 기본이고, 고칠 때는 **건드리지 않는 것**이 기본이다.
@@ -0,0 +1,117 @@
1
+ # ADR 0005 — 그림은 서버가 그린다 (모델은 데이터만 준다)
2
+
3
+ - 날짜: 2026-07-31
4
+ - 상태: 채택
5
+ - 관련: [0001](0001-why-build-our-own.md), [0003](0003-token-env-only.md), [0004](0004-capability-model.md)
6
+
7
+ ## 배경
8
+
9
+ 글에 넣을 구성도·흐름도를 MCP 로 만들고 싶다. 방법이 셋 있었다.
10
+
11
+ 1. **모델이 HTML/SVG 를 써서 넘긴다** — 서버는 렌더만
12
+ 2. **mermaid 같은 자동 레이아웃** 을 넣는다
13
+ 3. **모델은 데이터만 주고 서버가 그린다**
14
+
15
+ ## 결정
16
+
17
+ **3번.** 모델은 `nodes` / `edges` / `groups` 만 준다. 색·여백·글자 실측·선 라운딩·
18
+ 캔버스 크기·라벨 자리는 전부 렌더러가 정한다.
19
+
20
+ ## 왜 1번이 아닌가
21
+
22
+ 두 가지가 걸린다.
23
+
24
+ **보안.** 모델이 준 HTML/JS 를 브라우저에서 실행하면 그건 그냥 코드 실행 통로다.
25
+ 렌더 페이지는 로컬 파일을 `file://` 로 열고 도는데, 거기서 임의 스크립트가 돌면
26
+ 막을 방법이 마땅치 않다. 데이터만 받으면 그 통로 자체가 없다 —
27
+ DOM 은 `createElementNS` + `textContent` 로만 만들고 `innerHTML` 을 쓰지 않으므로
28
+ 라벨에 마크업이 들어와도 그냥 글자로 그려진다.
29
+
30
+ **일관성.** 매번 처음부터 그리게 하면 매번 다르게 생긴다. 같은 블로그의 그림 열 장이
31
+ 제각각인 것보다, 규칙을 코드에 박아 어느 글에 넣어도 같은 인상이 나오는 게 낫다.
32
+
33
+ ## 왜 2번이 아닌가
34
+
35
+ 자동 레이아웃은 선과 간격을 제어할 수 없다. 노드는 대충 놓이고, 선은 노드를 관통하고,
36
+ 라벨은 겹친다. "그럴듯한데 볼 때마다 거슬리는" 그림이 나온다.
37
+
38
+ 손 배치가 번거로운 건 맞다. 그래서 번거로운 부분만 자동화했다 —
39
+ **폭·높이·캔버스 크기·선 경로·라벨 자리는 계산하고, 노드를 어디 둘지만 사람이 정한다.**
40
+
41
+ ## 실측을 고집하는 이유
42
+
43
+ 글자 폭을 글자수 × 상수로 잡으면 한글·영문이 섞인 라벨에서 반드시 틀린다.
44
+ 그래서 전부 브라우저 `getBBox()` 로 잰다. 이게 비용을 만든다 —
45
+ **크기를 알려면 먼저 그려봐야 하므로 크롬을 두 번 부른다** (측정 → 촬영).
46
+ 그 대가로 "그림이 잘렸다"가 구조적으로 안 생긴다. 캔버스는 다 그린 뒤
47
+ 내용 bbox 로 정하기 때문이다.
48
+
49
+ ## 자가감사를 넣은 이유
50
+
51
+ 눈으로 보면 알지만 코드는 모르는 결함이 있다: 글자가 카드를 넘거나, 선이 노드를
52
+ 관통하거나(뒤에 숨는 것도 깨진 것이다), 라벨이 겹치거나, 자간이 눌리거나.
53
+ 페이지가 스스로 재서 여섯 종류를 보고한다.
54
+
55
+ **걸리면 올리지 않는다.** 벨로그는 이미지 삭제 API 가 없고 업로드 한도(1시간 100건)도
56
+ 있다. 어설픈 그림은 올리는 것보다 고쳐 그리는 게 싸다.
57
+
58
+ ## 크롬을 쓰는 방식
59
+
60
+ `puppeteer` 를 넣지 않는다. 런타임 의존성 2개 상한(A6)을 지키기 위해서이기도 하고,
61
+ 크로미움을 통째로 받아 수백 MB 를 쓰는 비용이 사용자 기기에 그대로 가기 때문이기도 하다.
62
+ 필요한 건 스크린샷 한 장뿐이라 이미 깔린 크롬을 CLI 로 부른다.
63
+
64
+ **끝나기를 기다리지 않는다.** Chrome 150 새 헤드리스는 `--dump-dom` 결과를 1.7초에
65
+ 내놓고도 프로세스를 유지한다(실측: 30초 더 기다려도 종료 안 함). 종료를 기다리던
66
+ 구현에서는 그림 한 장에 **90초**가 걸렸다 — 실제 작업 2초, 나머지는 전부 기다림.
67
+ 지금은 산출물이 완성됐는지 직접 보고(PNG 는 마지막 IEND 청크로 판정) 끝내면
68
+ 프로세스를 종료한다. **90초 → 3.9초.**
69
+
70
+ ## 자원 비용과 직렬화
71
+
72
+ 그림 한 장에 크롬 프로세스 9~11개, 최대 약 1GB 가 든다(실측). 이건 크롬 헤드리스의
73
+ 바닥이라 그림 내용과 거의 무관하고, 플래그로 줄일 방법도 찾지 못했다
74
+ (`--single-process` 는 675MB 로 내려가지만 스크린샷이 안 나온다).
75
+
76
+ 문제는 **동시 실행**이었다. MCP 클라이언트는 도구를 병렬로 부른다. 동시 2회를 재보니
77
+ 크롬 17개·1.9GB 로 선형으로 늘었다 — 그림 다섯 장 요청이면 45개·6GB 다.
78
+ 사용자 기기에서 도는 물건이 그러면 다른 작업까지 같이 죽는다.
79
+
80
+ 그래서 **렌더를 줄 세운다.** 한 장에 4초라 체감 손해는 거의 없고, 얻는 것은
81
+ "이 서버가 쓰는 메모리는 어떤 경우에도 1GB 를 넘지 않는다"는 상한이다.
82
+ 10회 연속 렌더에서 최대치가 평평하고 매회 후 잔존이 0 인 것도 확인했다.
83
+
84
+ ## 대가
85
+
86
+ - 크롬(또는 크로미움 계열)이 있어야 한다. 없으면 렌더 도구 2종만 안내와 함께
87
+ 실패하고 나머지 19개는 그대로 동작한다.
88
+ - 노드 배치는 여전히 사람(모델)이 한다. 대신 감사가 잘못된 배치를 잡아준다.
89
+ - 브랜드 로고를 못 쓴다. 내장 아이콘 28종은 전부 도형 조합이다 —
90
+ 네트워크를 막아둔 상태에서 외부 아이콘을 받을 수 없고, 공개 저장소에 남의 상표를
91
+ 싣는 것도 별개의 문제를 만든다.
92
+
93
+
94
+ ## 나중에 온 대가 — 그리고 배운 것
95
+
96
+ 이 결정 자체는 유지했지만, "브라우저를 띄운다"는 선택이 뒤따라 만든 일들이 있었다.
97
+ 전부 코덱스 교차검증 5라운드에서 나왔고, 기록해 둔다.
98
+
99
+ **자원.** 그림 한 장이 크롬 9~11개·약 1GB 다. 동시 호출을 막지 않으면 선형으로 늘고,
100
+ MCP 클라이언트는 도구를 병렬로 부른다. 렌더를 줄 세워 상한을 1GB 로 고정했다.
101
+ 그리고 서버가 죽어도 자식 크롬은 안 죽는다(POSIX) — 실제로 23분 살아 있는 걸 봤다.
102
+
103
+ **검사의 위치.** 캔버스 상한을 바깥(DOM 을 받은 뒤)에 뒀더니 늦었다. 브라우저는
104
+ `width`/`height` 를 받는 순간 표면을 준비한다. 페이지가 크기를 **설정하기 전에**
105
+ 스스로 거르게 옮겼다.
106
+
107
+ **검사가 둘이면 하나가 다른 하나를 가린다.** 파일 크기를 `stat` 과 `read` 두 곳에서
108
+ 봤더니, 한쪽을 지워도 다른 쪽이 잡아 **테스트가 변이를 못 잡았다.** 실제로 읽은
109
+ 바이트 하나로 통일했다. 방어를 늘리는 게 늘 좋은 게 아니다.
110
+
111
+ **측정 도구를 먼저 의심할 것.** `ps -axo rss= -p <pid>` 는 `-a`/`-x` 가 `-p` 를 무시해
112
+ 전 시스템을 합산한다. 이걸 모르고 "이 렌더가 43GB 를 쓴다"고 단정했다가, **대조군을
113
+ 재보고** 틀린 걸 알았다. 실제로는 918MB 였다.
114
+
115
+ **"방어를 지웠는데도 통과하는 테스트는 테스트가 아니다."** 변이 20종을 만들어 각각이
116
+ 정확히 1건씩 실패시키는지 확인한다. 처음엔 여러 개가 통과했고, 이유가 제각각이었다 —
117
+ fixture 가 없어서, 다른 방어가 가려서, 잘못돼도 오류가 안 나서.