코드 주석은 다섯 종류만 남깁니다 — pstack Comment Sicko 를 모노레포에 적용하며

정기창·

pstack 의 주석 규칙인 Comment Sicko 는 코드 주석을 다섯 종류만 남기라고 합니다. 이 규칙을 모노레포 전체에 적용해 JS/TS 주석 4만 9천여 개 가운데 판정 후보 48,941개를 1차 판정해 보니, 남길 주석은 4.9% 였고 95.1% 가 지울 대상이었습니다.

그런데 남긴 쪽을 다시 살펴보다가, 같은 데이터베이스 드라이버의 동작을 두고 정반대로 말하는 두 갈래의 주석이 둘 다 «우리가 바꿀 수 없는 외부 사실»로 남아 있는 것을 보았습니다. 레포의 통합 테스트는 그 사실을 이미 고정하고 있었고, 주석들이 서로 다른 말을 하는 동안 아무것도 실패하지 않았습니다.

이 글은 그 규칙을 적용하며 무엇을 예측했고, 실제 결과가 어디서 어긋났는지에 대한 기록입니다. 어떤 코드 주석을 남기고, 어떤 주석을 코드로 옮기고, 지운 결과를 어떻게 증명하는지를 차례로 적었습니다.

먼저 밝혀 둘 것이 있습니다. 이 글은 2026년 10월 28일 기준, 작업 도중의 기록입니다. JS/TS 주석은 «지울 대상으로 판정한» 단계까지 왔고, 아직 지우지 않았습니다. 그래서 아래의 JS/TS 숫자는 모두 1차 판정 기준입니다. 표본 감사에서 드러난 세 유형은 다시 판정하는 중이어서 그 결과는 이 글에 없습니다.

코드로 옮기기로 한 주석(MUST KILL)도 아직 하나도 옮기지 않았고, 주석에 관한 정책 문서도 그대로입니다. 실제로 지운 것은 CSS·Dockerfile·셸 스크립트·YAML 같은 비JS 파일의 주석뿐입니다. 그것도 작업 브랜치의 커밋이고, 아직 병합 전입니다.

pstack 의 Comment Sicko — 남겨도 되는 주석은 다섯 종류뿐입니다

pstack 은 Cursor 의 플러그인 저장소에 들어 있는 스킬 묶음입니다. 만든 사람은 poteto(Lauren Tan)이고 라이선스는 MIT 입니다. README 의 자기소개에 따르면 Meta·Netflix·Cursor 에서 수백만 줄의 코드를 다뤘고, React 코어 팀에서 React Compiler 를 만들고 유지하는 일을 하고 있습니다. 작업에 쓴 판은 0.15.34 이고, 이 글이 인용한 문장은 2026년 10월 28일의 main(0.15.36)에서 글자 그대로 확인했습니다.

README 의 문제의식은 짧습니다. «there's a growing sense that ai writes too much slop code. i agree.» AI 가 조잡한 코드를 너무 많이 쓴다는 느낌이 커지고 있고, 자신도 동의한다는 말입니다. 그리고 pstack 이 «write less, but higher quality code», 즉 더 적게, 그러나 더 높은 품질의 코드를 쓰도록 돕는다고 적습니다.

주석을 다루는 것은 에이전트 comment-sicko 와 그것을 부르는 스킬 /no-comments 입니다. 이 레포는 전에도 pstack 의 원칙 몇 가지(최소 변경, 호출자 이관, 행동 검증)를 리뷰·검증 절차 문서에 들인 적이 있고, 이번에는 주석 규칙을 레포 전체에 적용했습니다.

Comment Sicko 의 정의는 이렇게 시작합니다. «A deranged comment-hater that savors deletion and condemns workaround code.» 삭제를 음미하고 우회 코드를 규탄하는, 제정신이 아닌 주석 혐오자라는 뜻입니다. 소환되면 첫 출력이 정확히 «Yes... Ha ha ha... Yes!» 여야 한다고 적혀 있을 만큼 말투는 과장되어 있지만, 살아남을 수 있는 예외(«Only these exceptions get to crawl away.»)는 다섯 줄로 짧습니다.

  1. 법적 고지·라이선스 머리말
  2. 우리가 바꿀 수 없는 외부 의존성·플랫폼·벤더·프로토콜이 강제한, 코드만 봐서는 알 수 없는 동작
  3. // prettier-ignore. 린트 억제는 그 규칙이 틀렸거나, 지나치게 깐깐하거나, 스타일만 다룰 때만
  4. 공개 API 계약을 정의하는 문서 주석
  5. 코드로 표현할 수 없는 제약을 설명하는 이슈·RFC 링크

무게는 두 번째 항목에 바로 이어지는 문장에 실려 있습니다.

Surprises in our own code are meat. Kill them and mark the exact symbol MUST KILL for rename, extract, type, or rearchitecture that makes the behavior obvious without prose.

우리 코드의 의외성은 먹잇감이니 지우고, 산문 없이도 동작이 드러나도록 이름을 바꾸거나 뽑아내거나 타입으로 만들거나 구조를 다시 짤 그 심볼에 MUST KILL 을 표시하라는 뜻입니다. 바깥이 강제한 의외성은 주석으로 남기되, 우리 코드의 의외성은 주석으로 설명하지 말고 코드를 고쳐서 없애라는 것입니다.

판정의 기본값도 분명합니다. 예외에 해당하는지 확신이 없으면 지웁니다(«When I am not sure a keep clause applies, the comment dies.»). IMPORTANT, do not remove, too risky, fine for now 같은 표지와 긴 정당화는 «scent, not conviction», 즉 단서일 뿐 근거가 아니라고 합니다. 그런 주석은 판정하기 전에 근처 코드를 읽고, 거기서도 주장이 분명하지 않으면 그 심볼을 따로 조사하라고 적어 두었습니다. 그리고 살아남는 조건을 이렇게 좁힙니다.

Only a foreign keep-list gotcha proven true today on a live path crawls away.

오늘, 실제로 도는 경로에서, 사실로 증명된 외부 함정만 살아남는다는 뜻입니다. 이 조건이 왜 필요한지는 글의 뒷부분에서 mysql2 사례로 다시 적겠습니다. 긴 변명에 대해서는 더 짧게 말합니다. «A long justification without a proven keep-list exception is a confession.» 증명된 예외 없이 늘어놓는 긴 정당화는 자백이라는 것이고, 줄여서 알리바이로 다듬지도 말라고 합니다.

이 에이전트를 부르는 /no-comments 스킬은 그 보고를 어떻게 검토할지 순서를 정해 둡니다. 범위를 벗어난 수정이나 예외에 해당하는 삭제는 거부하고, 죽은 경로 삭제 같은 사소한 지적은 바로 고칩니다. «지우지 마», «문구 바꾸지 마» 같은 제약 주석에는 가장 싼 인코딩(타입·런타임 검사·테스트·CI 린트)을 제안하고, 승인을 받으면 «encode then delete», 즉 인코딩한 뒤에 지웁니다.

코드 주석 판정 기준표 — 규칙을 옮기며 정한 것

이번에는 플러그인을 그대로 돌리지 않았습니다. Comment Sicko 는 «Feed me the parent scoped files or diff.»라고 말하는, 범위가 정해진 파일이나 diff 를 받아 보고하는 에이전트입니다.

레포 전체의 주석 4만 9천여 개를 다루기 위해 규칙을 판정 기준표(루브릭)로 옮기고, 목록 작성·판정·삭제·증명을 각각의 도구로 나눴습니다. pstack 의 원칙 Build the Lever 에 이 방식을 한 줄로 요약하는 문장이 있습니다. «A deterministic script turns "trust me" into "run this".» 결정적인 스크립트가 «나를 믿어»를 «이걸 돌려 봐»로 바꿔 준다는 뜻입니다. 판정 자체는 기준표를 준 모델에 묶음 단위로 맡겼고, 결과는 데이터 파일로 모았습니다.

남김은 네 가지 코드로 매겼습니다. 외부 제약(EXT), 공개 API(API), 링크(LINK), 라이선스(LEGAL)입니다. 린트 억제는 판정 대상에서 빼서 따로 감사하기로 했습니다.

외부 제약은 세 조건이 모두 참일 때만 남깁니다.

  • 원인이 우리 밖에 있어야 합니다.
  • 그 원인이 지금 코드의 모양을 강제해야 합니다. 그 원인이 없었다면 다르게 짰을 코드여야 합니다.
  • 코드만 읽는 사람은 그 이유를 짐작할 수 없어야 합니다.

외부 이름이 나온다고 외부 제약이 되지는 않습니다. «drizzle 로 조회한다»는 외부 라이브러리의 이름이 들어간 서술일 뿐입니다.

공개 API 는 «패키지 밖에서 쓰는 표면»으로 좁혔습니다. 공용 라이브러리 패키지의 export, 외부 호출자가 있는 HTTP 엔드포인트·웹훅·MCP 도구·CLI 플래그 같은 것들입니다. 그 문서 주석 중에서도 시그니처만으로는 알 수 없는 단위·범위·불변식·오류 동작·부작용·호출 순서를 적은 것만 계약으로 봤습니다. /** 게시 여부 */ isPublished 처럼 이름과 타입을 되풀이하는 문서 주석은 계약이 아닙니다.

무조건 지우는 쪽도 정했습니다. 코드를 말로 되풀이하는 서술, 구분선과 배너, 주석 처리된 코드, 날짜·PR 번호·«예전엔» 같은 이력과 경위, given/when/then 이나 «1) 로그인한다» 같은 테스트 단계 서술입니다. TODO·FIXME·«임시»는 지우고 열린 과제로 옮깁니다. «중요», «주의», «삭제 금지»는 원문 말대로 단서일 뿐이어서, 근처 코드를 확인하는 계기로만 썼습니다.

우리 코드의 의외성을 설명하던 주석은 MUST KILL 로 분류하고, 무엇으로 옮길지에 따라 rename·extract·type·test·delete-dead·handle-error·open-work 의 일곱 종류로 나눴습니다. 종류별 예는 뒤에서 표로 적었습니다.

원문과 다르게 정한 것도 다섯 가지 있습니다.

  • 주석처럼 생긴 도구 입력, 즉 지시문 목록을 따로 만들었습니다. 원문은 prettier-ignore 와 린트 억제만 말합니다.
  • MUST KILL 주석은 지금 지우지 않고, 코드로 옮기는 커밋에서 함께 지웁니다. 원문은 지우고 표시합니다.
  • 빈 블록·빈 함수의 유일한 내용인 주석은 남깁니다. 지우면 no-empty 린트 위반이어서, 대신 MUST KILL 후보로 넘겼습니다.
  • 여러 줄 // 블록은 한 줄이라도 남기면 블록 전체를 남깁니다.
  • 제약 주석을 코드로 옮기는 일은 가장 싼 인코딩(타입·테스트)만 바로 적용하고, 린트 규칙이나 CI 신설처럼 정책이 바뀌는 것은 열린 과제로 올립니다.

첫째, 둘째, 넷째를 그렇게 정한 이유는 뒤의 «예측과 어긋난 지점»에 적었습니다.

1차 판정 결과 — 판정 후보 주석의 95% 가 지울 대상이었습니다

대상은 git 이 추적하는 JS/TS 파일 3,383개였고, 그 안에서 코드 주석 49,660개가 나왔습니다. 1차 판정까지의 결과를 나무 모양으로 그리면 다음과 같습니다.

JS/TS 주석 49,660개
├─ 지시문 259개 — 절대 지우지 않음
├─ 빈 블록의 유일한 주석 124개 — 남김, 나중에 처리
├─ 제외 336개 — 다른 작업이 고치는 파일, 테스트 픽스처, 서드파티 사본
└─ 판정 후보 48,941개
   ├─ 남김 2,413개 (4.9%)
   │  └─ 외부 제약 1,506 · 공개 API 896 · 링크 8 · 라이선스 3
   └─ 지울 대상 46,528개 (95.1%)
      ├─ 첫 단계에서 지울 주석 45,601개
      └─ MUST KILL 927개 — 코드로 옮기는 커밋에서 지움

MUST KILL 은 모두 929개인데, 그중 2개는 여러 줄 블록을 통째로 남기면서 남김 쪽에 들어갔습니다.

지시문 259개 가운데 eslint 억제가 205개로 가장 많았고, 테스트 환경 지정 14개, 셔뱅 13개, JS 파일의 JSDoc 타입 13개가 뒤를 이었습니다.

원문의 기본값이 «확신이 없으면 지운다»이니, 지울 쪽이 압도적으로 많은 것은 규칙대로의 결과입니다. 그래도 95% 는 과격해 보일 수 있습니다. 표본 감사로 잰, 남겼어야 할 주석을 삭제로 판정한 비율은 3.3% 였습니다. 그 오류에는 방향이 있어서 해당 유형만 다시 판정하고 있는데, 이 이야기는 뒤에서 하겠습니다.

판정과는 별개로, 후보 48,941개의 생김새를 정규식으로 세어 보았습니다. 한 주석이 여러 항목에 걸릴 수 있습니다.

생김새개수
TODO·FIXME·XXX·HACK9개
계획 문서의 절 번호(§)를 단 주석3,266개 (6.7%)
★ 표지3,234개 (6.6%)
⚠ 표지1,259개 (2.6%)
10줄 이상인 주석2,761개 (5.6%)
날짜(YYYY-MM-DD) · PR·이슈 번호811개 · 539개

이 레포는 할 일을 주석에 거의 적지 않았습니다. TODO 류는 9개뿐이었습니다. 대신 «언제, 어떤 계획의 몇 절에 따라, 왜 이렇게 했는지»를 주석에 적었습니다. 그것은 이력이고, 이력의 정본은 git 과 PR 입니다. 다만 이 숫자가 뜻하지 않는 것도 있습니다. 별표나 절 번호가 붙었다고 다 지울 대상으로 판정한 것은 아닙니다. 그런 주석 안에서도 외부 제약은 남겼습니다.

주석이 이렇게 쌓인 원인 하나는 레포 안에 있었습니다. 코딩 에이전트에게 주는 지침 두 곳이 주석을 요구하고 있었습니다. 하나는 «테스트에 명확한 단계별 주석 포함 (리뷰 시 흐름을 이해할 수 있도록)», 다른 하나는 «any 사용 시 반드시 주석으로 사유 명시»입니다. 테스트 단계 서술로 보이는 주석이 정규식 근사치로 1,507개 있었습니다. Comment Sicko 식으로 보면 any 에 사유 주석을 다는 대신 any 를 없애야 하고, 외부 타입의 결함 때문에 정말 불가피할 때만 그 사유가 외부 제약으로 남습니다.

pstack 의 원칙 Encode Lessons in Structure 에는 이런 문장이 있습니다. «If the fix is structural, only use the structural fix. The instruction is the symptom.» 고칠 방법이 구조에 있다면 구조만 고치라는 것, 그리고 지시는 증상이라는 것입니다. 주석을 달라는 지시는 그 모양의 주석을 대량으로 만듭니다.

문서로 적은 규율이 네 번 실패해서 그 판단을 hook 으로 옮겼던 일을 «에이전트 126개가 떴습니다»에 쓴 적이 있는데, 같은 방향의 이야기입니다. 이 두 지침은 정책을 갱신하는 단계에서 바꿀 예정이고, 아직 바꾸지 않았습니다.

남길 주석, 지울 주석, 코드로 옮길 주석

지울 것 — 코드가 이미 말하는 것과 이력

외부 이름이 나와도 코드가 이미 그 사실을 말하고 있으면 지웁니다. /** ⚠️ Next 16 — 동적 라우트의 params 는 Promise 다. */ 바로 아래에는 params: Promise<{ slug: string }> 라는 타입이 있었습니다. 주석은 타입을 한 번 더 말하고 있을 뿐입니다.

// 3) pagePath → slug 매핑, // 4. truncate 용 풀 생성 같은 테스트 단계 서술, // ====== 으로 이어지는 구분선(정규식 근사치로 약 2,900개), «(제거됨) ○○ 블록 — 이미 옮겨졌다» 같은 경위도 모두 지울 대상입니다. 이력은 git 과 PR 이 말하고, 서술은 이름과 구조가 말합니다.

남길 것 — 우리가 바꿀 수 없는 바깥의 사정

외부 제약으로 남긴 주석에는 이런 것들이 있습니다.

  • Meta Graph API 는 레이트 리밋을 HTTP 429 가 아니라 본문의 error.code(4·17·32·613)로 알립니다. 403·400 으로 오기도 해서, 상태 코드가 아니라 이 값으로 판정합니다.
  • MongoDB Atlas 의 vectorSearchScore(cosine)는 원시 코사인이 아니라 (1 + cos) / 2 로 정규화한 값입니다.
  • jest 처럼 다른 realm 에서 돌면, Node 코어가 던진 RangeError 는 그 realm 의 RangeError 가 아닙니다. 그래서 instanceof 대신 코드 문자열로 판정합니다.
  • MySQL 의 TIMESTAMP(0) 칼럼은 밀리초를 반올림해 저장합니다. 그래서 비교하기 전에 초 단위로 버려 저장값과 비교값을 맞춥니다.
  • S3 호환 저장소의 멀티파트 업로드 ETag 는 내용 해시가 아닙니다.
  • Slack 이 이스케이프하는 문자는 &, <, > 셋뿐입니다.

법령과 표준도 바깥입니다. 정보통신망법의 2년 재확인 의무 때문에 재확인 주기를 700일로 둔 주석이 있습니다. 2년 정각으로 잡으면 크론이 하루 밀렸을 때 기한을 넘기기 때문입니다. WCAG 의 대비 기준도 같은 자리에 있습니다. 링크로는 Google ID 토큰 검증 공식 문서(email_verified 의 의미 제약)나 오픈소스 배포 도구의 이슈($ 보간이 비밀값을 깨뜨리는 문제 등)를 남겼고, 라이선스로는 폰트 라이선스(OFL)의 예약 이름 조항, 아이콘 라이브러리의 ISC 고지, 공공데이터 출처 고지를 남겼습니다.

여기 고른 예는 공식 문서로 알려진 동작들입니다. 남긴 외부 제약 1,506개가 하나하나 참인지는 확인하지 않았습니다. 그 한계가 실제로 어떤 모습으로 드러났는지는 뒤에서 적겠습니다.

코드로 옮길 것 — MUST KILL 929개

MUST KILL 은 지금 주석이 지키고 있는 것을 코드가 지키도록 옮길 대상입니다. 종류별 개수와 예는 다음과 같습니다.

종류 (개수)주석이 하던 말옮길 방향
test (675)자르고 나서 escape 한다 — 순서를 바꾸면 escape 시퀀스가 중간에서 잘릴 수 있다순서를 테스트로 고정
type (82)여기 목록은 DTO 필드와 1:1 이어야 한다 — 타입이 안 지켜 준다손으로 옮겨 적기 제거
extract (79)보존일수 0 은 무기한이다이름 있는 가드로 추출
open-work (35)activeUsers 는 집계되지 않으므로 0구현 또는 필드 제거
rename (34)이 조회 호출이 안 읽음 표시를 정리한다getThreadAndMarkRead()
delete-dead (15)실제 가드 경로는 이 메서드를 거치지 않는다테스트만 부르는 메서드 삭제
handle-error (9)기록 1건 — 절대 던지지 않는다명시적 catch 와 경고 로그

가장 싼 것은 rename 입니다. 조회처럼 보이는 getThread() 에는, 이 호출 자체가 «운영자가 읽었다»로 취급되어 서버에서 안 읽음 표시를 정리하므로 성공한 뒤 목록과 배지 캐시를 반드시 무효화해야 한다는 주석이 붙어 있었습니다. 조회처럼 보이는 함수가 상태를 바꾸는 것입니다. 이름이 getThreadAndMarkRead() 라면 그 주석은 필요 없습니다.

같은 방식으로, 실은 저장 키 정규화기여서 . 을 - 로 바꿔 확장자를 뭉개는 sanitizeFilename() 은 sanitizeStorageKey() 로, 옆의 coverageRatio(0~1)와 단위가 다른 percentile(0~100)은 percentilePct 로 옮길 방향을 잡았습니다.

extract 는 매직 값과 조건에 이름을 붙이는 일입니다. 보존일수 0 은 «무기한 보존»을 뜻하는데, 이 0 을 보통 값처럼 흘리면 기준 시각이 «지금»이 되어 전부 지웁니다. 이것은 주석이 아니라 이름 있는 가드가 막아야 할 일입니다.

type 에는 실제로 한 번 빠뜨린 적이 있는 자리도 있었습니다. 컨트롤러가 DTO 필드를 서비스 입력으로 손으로 옮겨 적는 곳에, «옵션이 전부 선택이라 한 칸을 빼먹어도 컴파일이 통과하고, 요청은 204 로 성공하면서 그 옵션만 조용히 무시된다»는 주석이 있었습니다. 주석은 다음 사람에게 조심하라고 말할 수는 있지만, 빠뜨린 코드를 멈춰 세우지는 못합니다. 옮길 방향은 손으로 옮겨 적기를 없애거나, 빠뜨리면 컴파일이 멈추게 만드는 것입니다. 아직 옮기지 않았고, 아래는 방향을 보여 주는 예시입니다.

type AllKeys<T> = { [K in keyof Required<T>]: T[K] };

interface UpdateSettingsDto {
  theme?: 'light' | 'dark';
  language?: string;
  weeklyDigest?: boolean;
}

function toServiceInput(dto: UpdateSettingsDto): AllKeys<UpdateSettingsDto> {
  return {
    theme: dto.theme,
    language: dto.language,
  };
}

옵션은 여전히 선택이라 값은 undefined 일 수 있지만, 키는 하나도 빠질 수 없습니다. 위 코드는 weeklyDigest 를 빠뜨렸기 때문에 TypeScript 5.9 의 strict 설정에서 TS2741(«Property 'weeklyDigest' is missing…») 오류로 컴파일되지 않습니다. 주석이 사람에게 부탁하던 확인을 컴파일러가 대신하게 됩니다.

가장 많은 것은 test 였습니다. 929개 중 675개, 73% 입니다. «자르고 나서 escape 한다», «marketing 은 의도적으로 빠져 있다 — 선택 동의라 미동의로도 가입이 되어야 한다», «한 항목당 열린 행(effective_to IS NULL)은 최대 1개 — 이 테이블의 핵심 불변식» 같은 문장은 모두 «이렇게 동작해야 한다»는 불변식이고, 불변식은 테스트로 고정할 수 있습니다.

나머지는 주석이 문제를 고백하는 경우입니다. «백엔드 409 본문에 기계가 읽는 code 가 없어 서버 문구의 고정 조각으로 가른다»는 서버가 code 를 실으면 사라질 주석이고(handle-error), «단순화를 위해 LFU 는 LRU 로 폴백»은 옵션이 조용히 다른 동작으로 떨어진다는 고백입니다(open-work).

주석 삭제 검증 — 지우는 도구와 증명하는 도구를 따로 두었습니다

목록은 TypeScript 파서로 만들었습니다. git 이 추적하는 JS/TS 파일에서 주석을 전부 뽑아 주석마다 id 를 붙였는데, id 는 «파일 + 주석 원문 + 같은 원문의 파일 안 순번»의 해시입니다. 줄 번호를 쓰지 않았기 때문에 줄이 밀려도, 리베이스를 해도 같은 판정을 다시 적용할 수 있습니다. 뽑은 주석은 판정에 앞서 지시문, 빈 블록의 유일한 주석, 제외, 판정 후보로 나눴습니다.

삭제 도구는 판정 데이터를 읽어 주석의 범위만 지웁니다. 줄 단독 주석이면 그 줄째, 줄 끝 주석이면 앞 공백까지, JSX 의 {/* */} 는 중괄호째 지웁니다. 지시문과 빈 블록 주석은 id 가 넘어와도 지우지 않습니다.

증명은 서로 독립인 세 겹으로 두었습니다. 삭제와 검증을 같은 방법으로 하면, 삭제가 놓친 것을 검증도 같이 놓치기 때문입니다.

  1. 검증기 — 변경 전후 파일을 각각 TypeScript 의 구문 트리(AST)로 파싱해, 주석·공백·따옴표 종류·괄호·속성 이름 따옴표·숫자 표기·JSX 의 의미 없는 공백을 정규화한 노드 열을 비교합니다. 같으면 PASS 이고, 지시문 주석이 사라지거나 생기면 FAIL 입니다.
  2. 컴파일 산출물 비교 — 백엔드는 tsc --removeComments 로 만든 JS 를 전후로 바이트 비교합니다. JS/TS 를 실제로 지우는 단계에서 할 예정이고, 아직 하지 않았습니다.
  3. 패키지별 검사 — 타입 검사·린트·빌드·테스트입니다.

검증기의 테스트는 15건입니다. 주석만 지운 쌍은 PASS, 토큰 하나를 지운 쌍은 FAIL 이어야 합니다. (a + b) * c 를 a + b * c 로 바꾸면 FAIL, (c) 를 c 로 바꾸면 PASS 입니다. let → const, !x → -x, extends → implements, import → import type 은 모두 FAIL 이고, // eslint-disable-next-line no-console 이나 /* #__PURE__ */ 를 지운 것도 FAIL 입니다. 동작이나 번들 결과가 바뀔 수 있는 지시문이기 때문입니다.

이 검증기로 왕복 시험도 했습니다. 23개 패키지에서 고른 실제 파일 60개(테스트 파일, tsx·jsx, js·mjs·cjs 를 섞었습니다)에서 후보 주석 2,475개를 전부 지우고 prettier 를 돌린 뒤 검증기에 넣었습니다. 원본 그대로와 비교하면 58개가 PASS 였습니다. 실패한 2개는 주석 때문이 아니라, prettier 의 Tailwind 클래스 정렬 플러그인이 className 순서를 바꾼 탓이었습니다. prettier 를 적용한 원본과 비교하자 60개 모두 PASS 였습니다.

pstack 의 원칙 Prove It Works 에는 «When verification fails, suspect the observation method before suspecting the system»이라는 문장이 있습니다. 검증이 실패하면 시스템보다 관찰 방법을 먼저 의심하라는 것인데, 이 두 건이 그런 경우였습니다. 틀린 것은 지운 쪽이 아니라 비교의 기준이었습니다.

CSS·Dockerfile·셸·YAML 등의 주석 — 실제로 지운 2,346줄

비JS 파일의 주석은 작업 브랜치에서 이미 지웠습니다. 대상 82개 중 57개 파일에서 줄 단독 주석 2,346줄을 지웠고(css 1,021 · 셸 458 · Dockerfile 424 · yaml 290 · env 116 · python 21 · html 13 · toml 3), diff 는 삭제뿐이고 추가는 0줄입니다. 남긴 것은 외부 제약 68개 범위와 라이선스 1개, 그리고 기능 주석 25개(셔뱅 24개, # -*- coding 1개)입니다. 언어마다 «같다»를 증명하는 방법은 달랐습니다.

언어전후가 같다는 증명
YAMLyaml.safe_load 결과 일치
Pythonast.dump 결과 일치
CSSpostcss 로 주석 노드를 뺀 직렬화 일치
Dockerfile주석·빈 줄을 뺀 명령 열 일치, docker build --check, 백엔드 이미지 1회 실제 빌드
셸shfmt -mn(최소화) 결과 일치, bash -n

함정도 있었습니다. YAML 블록 스칼라(run: |) 안의 # 줄은 주석이 아니라 값입니다. 그 안에 PowerShell 주석이 들어 있어도 YAML 입장에서는 문자열입니다. Dockerfile 의 RUN <<EOF 본문 안 # 도 Dockerfile 주석이 아닙니다. 줄 끝 # 은 따옴표 해석이 필요해서 건드리지 않았습니다.

Dockerfile 에서 지운 것의 실물은 이렇습니다.

-# ============================================
-# Backend Dockerfile (Multi-target)
-# ============================================
-# Targets:
-#   api    — API 서버 (0.5 CPU / 512MB)
-#   worker — Scheduled Jobs (0.25 CPU / 256MB)
 FROM public.ecr.aws/docker/library/node:24-alpine AS builder
-# pnpm 활성화
 RUN corepack enable && corepack prepare pnpm@10.30.3 --activate
-# 1. Workspace 설정 파일 복사
 COPY pnpm-lock.yaml pnpm-workspace.yaml package.json tsconfig.base.json ./

반대로 남긴 것은 NEXT_PUBLIC_* 값이 빌드 시점에 번들에 인라인된다는 것, Docker 명명 볼륨이 처음 붙을 때 이미지 안 디렉터리의 소유권을 물려받는다는 것, 네이티브 최적화(GGML_NATIVE)로 빌드한 바이너리가 다른 CPU 에서 SIGILL 로 죽는다는 것, ENTRYPOINT 와 CMD 의 덮어쓰기 규칙 같은 외부 제약입니다.

커밋 메시지에는 이런 판단을 남겼습니다. «이번 변경은 주석 줄만 삭제하므로 주석·빈 줄을 뺀 명령 열이 전후 동일함을 파일마다 증명했고(RUN <<EOF 본문은 제외), 이것이 빌드 성공보다 강한 근거다.» 빌드가 성공했다는 사실은 «같다»를 증명하지 않습니다. Prove It Works 도 대리 지표나 자기 보고, «it compiles» 로 추론하지 말라고 적습니다.

예측과 어긋난 지점 — 주석 판정과 삭제에서 달라진 네 가지

«주석을 지워도 동작은 바뀌지 않는다»

처음의 예측은 «주석 삭제는 런타임을 바꾸지 않는다. 바꿀 수 있는 것은 지시문 누락뿐이다»였습니다. 실제로는 지시문 말고도 예외가 둘 있었습니다. 하나는 ASI(자동 세미콜론 삽입)입니다.

function before(x) {
  return /*
  */ x;
}

function after(x) {
  return x;
}

console.log(before(1), after(1));

실행하면 undefined 1 이 찍힙니다. 줄바꿈이 든 블록 주석은 파싱할 때 줄바꿈처럼 취급되어, return 뒤에 세미콜론이 자동으로 들어갑니다. 주석을 지우면 반환값이 바뀝니다. 다른 하나는 JSX 텍스트 사이의 빈 표현식 주석입니다.

<p>foo{/* c */}
bar</p>          렌더 결과: foobar

<p>foo
bar</p>          렌더 결과: foo bar

JSX 는 줄바꿈이 섞인 텍스트의 줄들을 공백 하나로 잇습니다. 그런데 사이에 {/* */} 가 끼어 있으면 두 텍스트가 따로 놓여 공백 없이 붙습니다. 그래서 검증기가 이 두 경우를 FAIL 로 잡게 했습니다. 다만 60개 파일 왕복 시험에서 이런 경우는 0건이었습니다.

지시문도 생각보다 종류가 많았습니다. 원문은 prettier-ignore 와 린트 억제만 말하지만, 실제로 «주석처럼 생긴 도구 입력»은 이만큼 있었습니다.

JS/TS 파일
eslint-disable*, @ts-ignore, @ts-expect-error, prettier-ignore,
istanbul / c8 ignore, webpack 매직 주석, @vite-ignore,
/// <reference>, @jsx 프래그마, @jest-environment, #__PURE__,
sourceMappingURL, // falls through, @license, /*!, @deprecated,
JS 파일의 JSDoc 타입(@type, @typedef — checkJs), 셔뱅

그 밖의 파일
Dockerfile # syntax=, # shellcheck, # yaml-language-server:,
# type: ignore, # noqa, # -*- coding

조건이 붙는 것도 있습니다. // falls through 는 다음 토큰이 case 나 default 일 때만 지시문입니다. 이 레포의 «falls through» 2건과 «no default» 4건은 산문이었습니다. @internal 은 stripInternal 을 쓰는 tsconfig 가 있을 때만 의미가 있는데, 이 레포에는 없었습니다. JSDoc 타입은 JS 파일에서만 지시문이고, TS 파일에서는 산문입니다. 지시문의 개수 자체도 정의에 따라 558개(초기의 넓은 기준)에서 259개(최종 기준)까지 달라졌습니다.

더 현실적인 위험은 삭제 커밋에 딸려 오는 다른 변경이었습니다. 후보 파일 2,783개 중 107개는 한 번도 prettier 를 거치지 않은 파일이었습니다. 이런 파일은 주석을 지우는 커밋에서 prettier 가 함께 돌며 Tailwind 클래스 순서까지 바꾸고, 그러면 검증기가 FAIL 합니다. 그래서 판정 전에 포맷 정리만 담은 커밋을 따로 두었습니다.

커밋 훅(lint-staged)도 같은 종류의 문제를 만듭니다. 훅은 스테이징한 파일 전체를 린트하기 때문에, 그동안 린트를 거친 적 없는 파일의 기존 오류가 주석 삭제 커밋을 막습니다. 그런 오류는 따로 고친 커밋을 먼저 둡니다.

«한 줄씩 판정하면 된다»

여러 줄 // 블록은 줄마다 다른 판정을 받을 수 있고, 그러면 문장 중간만 남는 조각이 생깁니다. 블록 4,536개 중 140개에서 판정이 줄마다 갈렸습니다. 규칙을 «한 줄이라도 남기면 블록 전체를 남긴다»로 바꿔, 317줄을 «남김»으로 올렸습니다.

지우고 표시하는 대신, 옮기면서 지우기로 했습니다

원문의 Comment Sicko 는 주석을 지우고 심볼에 MUST KILL 을 표시합니다. 코드로 옮기는 일은 그다음입니다. 이번에는 MUST KILL 929개를 첫 단계에서 지우지 않았습니다. 그 주석이 설명하던 불변식은 코드로 옮겨지기 전까지 다른 어디에도 없습니다. 지우는 단계와 옮기는 단계 사이에 지식이 비는 구간이 생기는데, 레포 전체 규모에서는 그 구간이 길어질 수 있습니다. 그래서 «코드로 옮기는 커밋에서 그 주석을 함께 지운다»로 바꿨습니다. /no-comments 가 제약 주석에 두는 «인코딩한 뒤 지운다»는 순서를 MUST KILL 전체에 적용한 셈입니다.

test 종류 675개는 기존 테스트가 그 불변식을 이미 고정하고 있는지부터 찾습니다. 있으면 테스트를 새로 쓰지 않고 주석만 지운 뒤, 그 테스트의 이름을 근거로 남깁니다.

«기준표를 주면 판정은 일관될 것이다»

같은 기준표로 다른 판정자가 표본 500개를 블라인드로 다시 판정했습니다. 남김으로 판정된 200개 중 33개(16.5%)는 지웠어야 했고, 삭제로 판정된 300개 중 10개(3.3%)는 남겼어야 했습니다. 어긋남은 남긴 쪽에서 더 컸고, 방향이 있었습니다.

위치를 계약으로 착각했습니다. 공용 타입 패키지 안의 문서 주석은 이름을 되풀이할 뿐인데도(/** items 의 상태별 건수. */, /** GET … 응답. */) 공개 API 로 남겨졌습니다. 기준은 «패키지 밖에서 쓰이는가»와 «시그니처로 알 수 없는 계약을 정의하는가» 둘 다였는데, 판정은 앞의 것만 봤습니다. 우리 정책을 설명하는 주석(«월 한도의 80% 에 닿으면 원격 실행을 멈춘다»)도 외부 제약으로 남겨졌습니다.

긴 주석 속 외부 제약 한 문장까지 삭제로 판정했습니다. «긴 정당화는 자백이다»를 너무 넓게 적용한 결과입니다.

감사가 «남겼어야 한다»고 본 주석에는 이런 문장이 섞여 있었습니다. React 상태는 다음 렌더에야 반영되므로 같은 순간의 두 번째 클릭은 동기 잠금(ref)으로 막아야 한다는 것, TypeScript 의 interface 에는 암묵 인덱스 시그니처가 없어 Record<string, unknown> 타입 칸에 대입되지 않는다는 것(satisfies 로 우회), WCAG 1.4.11 의 비텍스트 대비 3:1 때문에 특정 색 토큰을 써야 한다는 것입니다. 올바른 처리는 외부 제약 문장만 남기고 나머지를 지우는 것입니다.

외부 호출 표면의 판정이 들쭉날쭉했습니다. 관리 화면이나 다른 서버가 부르는 엔드포인트의 문서 주석이 어떤 묶음에서는 API 로 남고, 어떤 묶음에서는 삭제로 판정됐습니다. 감사가 남겼어야 한다고 본 것은 외부에서 부르는 HTTP 경로의 응답 계약(토큰 원문을 싣지 않고 끝 4자리만 마스킹해 준다), 다른 서버만 부르는 인증 경로의 계약(헤더, 비밀이 없으면 503, 틀리면 401) 같은 것입니다.

잘못 삭제로 판정한 비율은 패키지 단위로 전면 재판정하기로 한 기준(10% 이상)에 미치지 않았습니다. 그래서 세 유형만 골라 다시 판정하고 있습니다. 대상은 공용 타입 문서 주석 778개, 긴 삭제 주석 2,155개, 외부 호출 표면 문서 주석 410개입니다. 이 보정은 아직 진행 중이어서 결과 숫자가 없습니다.

남긴 외부 제약이 서로 반대를 말하고 있었습니다 — mysql2 의 affectedRows

감사가 잰 것은 비율이었습니다. 남긴 쪽에는 비율로 드러나지 않는 문제도 있었습니다. 판정 데이터를 전부 검색해 보니, 이 레포에는 같은 드라이버 동작을 두고 서로 반대로 말하는 주석이 두 갈래로 있었습니다.

  • 적어도 9곳: «mysql2 기본(CLIENT_FOUND_ROWS 미설정)에서 UPDATE 의 affectedRows 는 매칭된 행이 아니라 값이 실제로 바뀐 행 수다.»
  • 4곳: «mysql2 는 FOUND_ROWS 로 연결하므로 affectedRows 는 WHERE 에 걸린 행 수다(값이 안 바뀌어도 1).»

1차 판정은 양쪽에서 4곳씩을 외부 제약으로 남겼습니다. 서로 반대인 두 주장을 둘 다 «우리가 바꿀 수 없는 외부 사실»로 남긴 것입니다. «값이 바뀐 행» 쪽의 나머지는 2곳이 MUST KILL(test), 3곳이 삭제 판정이었습니다. 판정은 주장의 모양, 즉 외부 드라이버의 동작을 설명한다는 점을 봤고, 그 주장이 참인지는 확인하지 않았습니다. 기준표의 세 조건에도 «그 말이 참인가»를 묻는 조건은 없었습니다.

이 글을 쓰며 확인한 것은 다음과 같습니다.

  • 설치된 mysql2(3.24.4)에서 ConnectionConfig.getDefaultFlags() 가 돌려주는 기본 목록에 FOUND_ROWS 가 들어 있습니다.
  • 레포의 커넥션 풀 생성 코드는 flags 를 넘기지 않습니다.
  • MySQL 문서에 따르면 CLIENT_FOUND_ROWS 가 켜진 연결에서 UPDATE 의 affected rows 는 WHERE 에 걸린(found) 행 수입니다.
  • 레포의 다른 모듈에는 실제 MySQL 에 붙는 통합 테스트가 이미 있었습니다. 전용 DB 주소가 주어질 때만 도는 테스트인데, «현재 값으로 다시 UPDATE 해도 한 행으로 센다(FOUND_ROWS)»를 단언으로 고정하고 있었습니다.

그러니 이 연결 설정에서는 «WHERE 에 걸린 행» 쪽이 맞고, «값이 바뀐 행» 쪽이 틀립니다.

같은 사실을 테스트는 고정하고 있었습니다. 주석들은 서로 다른 말을 하고 있었고, 아무것도 실패하지 않았습니다. 주석은 실행되지 않으니, 틀려도 실패할 길이 없습니다.

이것이 뜻하지 않는 것도 적어 둡니다. 틀린 주석이 붙은 코드가 모두 버그라는 뜻은 아닙니다. 로직에 따라서는 «0행»을 두 의미 중 어느 쪽으로 읽어도 같은 결론에 닿을 수 있습니다. 그리고 이 글을 쓰며 실제 DB 에 대고 다시 재지는 않았습니다. 확인한 범위는 라이브러리 소스와 연결 설정, 기존 통합 테스트까지입니다.

원문의 «proven true today on a live path»라는 조건이 왜 필요한지를 이 사례가 보여 줍니다. 외부 제약의 모양을 했다고 해서, 그 말이 오늘도 참이라는 보증이 되지는 않습니다.

예전에 이스케이프 함수의 주석과 테스트가 «콜론을 막아 준다»고 주장했는데 재 보니 아니었던 일을 «주석이 거짓말을 하고 있었습니다»에 쓴 적이 있습니다. 그 글의 결론은 «틀린 주석은 코드를 망가뜨리지 않는 대신 사람을 망가뜨립니다»였고, 죽은 설정과 틀린 주석을 «둘 다 읽는 사람에게만 작동하는 코드»라고 불렀습니다. 그 결론에 대한 pstack 식의 답은, 읽는 사람에게만 작동하는 것을 기계가 확인하는 것(타입·테스트)으로 옮기라는 것입니다.

그래서 외부 제약 주석을 남길 때는 소스·문서·실측 중 하나로 확인한 근거를 같이 남기거나, 테스트로 그 동작을 고정해야 한다고 봅니다. 검증 없이 남긴 외부 제약은 다음 사람에게 틀린 확신을 줍니다. 테스트로 고정한다면 예를 들어 이런 모양이 됩니다. 레포의 통합 테스트가 고정하던 단언을 설명하려고 쓴 예시이고, 이 글을 쓰며 DB 에 대고 돌린 코드는 아닙니다.

import mysql, { type ResultSetHeader } from 'mysql2/promise';

const url = process.env.TEST_MYSQL_URL;

(url ? describe : describe.skip)('UPDATE 의 affectedRows', () => {
  it('값이 그대로여도 WHERE 에 걸린 행을 센다 (FOUND_ROWS)', async () => {
    const conn = await mysql.createConnection(url!);
    try {
      await conn.query('CREATE TEMPORARY TABLE t (id INT PRIMARY KEY, v INT)');
      await conn.query('INSERT INTO t VALUES (1, 1)');
      const [res] = await conn.query<ResultSetHeader>(
        'UPDATE t SET v = 1 WHERE id = 1',
      );
      expect(res.affectedRows).toBe(1);
    } finally {
      await conn.end();
    }
  });
});

동작이 달라지는 날, 주석은 조용히 틀린 채 남지만 테스트는 실패합니다.

정리 — 다른 코드베이스에 코드 주석 규칙을 적용한다면

JS/TS 주석은 아직 지우지도 옮기지도 않은 단계의 정리지만, 다른 코드베이스에 같은 규칙을 적용한다면 이렇게 하겠습니다.

  • 주석처럼 생긴 코드부터 목록으로 만듭니다. 지시문을 지우면 동작·번들·타입 검사가 바뀝니다.
  • 남기는 기준은 «바깥인가, 강제하는가, 코드로 짐작할 수 없는가» 세 가지입니다. 외부 이름이 나오는지는 기준이 아닙니다.
  • 남긴 외부 제약도 참인지 확인합니다. 근거(소스·문서·실측)를 같이 남기거나 테스트로 고정합니다.
  • 우리 코드의 의외성은 지우되, 옮길 곳을 정하고 지웁니다. 이름·추출·타입·테스트 순으로 싼 것부터 하고, 이번 판정에서는 MUST KILL 의 73% 가 테스트로 옮길 것이었습니다.
  • 삭제와 검증은 다른 방법으로 합니다. «빌드가 됐다»는 «같다»의 증명이 아닙니다. 포맷터와 린트 훅이 딸려 오는 변경은 먼저 별도 커밋으로 빼서 삭제 커밋을 순수하게 둡니다.
  • 기준표를 써도 판정은 기웁니다. 표본 감사로 그 방향을 잽니다.
  • 주석을 요구하는 규칙부터 고칩니다. «단계별 주석을 달라», «any 에 사유 주석»은 그 모양의 주석을 대량으로 만듭니다.

코드 주석이 줄면 처음 온 사람은 무엇으로 코드를 이해하느냐는 물음이 남습니다. 남는 주석은 코드로는 알 수 없는 바깥의 사정이고, 우리 코드의 사정은 이름·타입·테스트가 말하게 하는 것이 이 규칙의 방향입니다.

pstack 의 원칙 Minimize Reader Load 는 «Maintainability is the work a reader must do to understand code.», 즉 유지보수성을 코드를 이해하려고 읽는 사람이 해야 하는 일로 정의하고, 새로 온 사람이 «X 는 어디서 오나», «무엇이 X 를 바꾸나»에 30초 안에 답할 수 있는지를 시험으로 둡니다. 지운 주석의 지식이 사라지지도 않습니다. 이력은 git 에 남고, MUST KILL 은 코드로 옮긴 뒤에 지웁니다.

남은 일은 세 유형의 보정 재판정, JS/TS 주석의 실제 삭제와 컴파일 산출물 비교, MUST KILL 929개를 코드로 옮기는 리팩터링, 그리고 주석을 요구하던 지침을 고치는 정책 갱신입니다.

표본 감사에서 어긋남은 삭제로 판정한 쪽(3.3%)보다 남김으로 판정한 쪽(16.5%)에서 컸고, mysql2 사례에서는 서로 반대인 두 주장이 함께 남았습니다. 주석을 다섯 종류로 줄여도, 남긴 외부 제약마다 «proven true today on a live path», 즉 오늘 실제로 도는 경로에서 참인지를 확인하는 일은 남습니다.

주석pstack모노레포TypeScriptmysql2테스트