동일한 탐지 결과를 반복해서 표시하는 문제 해결하기 — 침묵하지 않고도 가능합니다
요약
CommitBrief가 동일한 탐지 결과를 반복해서 표시하는 문제를 해결하기 위해 사용하는 두 가지 방식인 개발자별 베이스라인과 인라인 억제 마커를 소개합니다. 특히 코드 이동(Code drift) 시에도 탐지 결과의 정체성을 유지하기 위해 라인 번호를 제외한 지문(fingerprint) 생성 방식을 설명합니다.
핵심 포인트
- 개발자별 베이스라인을 통해 이미 수락된 탐지 결과를 제외할 수 있음
- 인라인 억제 마커를 사용하여 소스 코드 내에 명시적으로 무시 사유를 남길 수 있음
- 라인 번호를 지문 생성에서 제외하여 코드 이동 시에도 탐지 결과가 유지됨
- LLM의 재구성 가능성을 고려하여 설명과 스니펫을 지문에서 제외함
매 실행마다 이미 알고 있는 동일한 문제를 표시하는 리뷰어는 당신이 그 문제를 무시하도록 훈련시킵니다. 해결책이 "탐지 결과 숨기기"가 되어서는 안 됩니다. 왜냐하면 조용히 결과를 누락시키는 도구는 잔소리를 하는 도구보다 더 나쁘기 때문입니다. CommitBrief에는 탐지 결과를 수락하고 넘어가는 두 가지 방법 — 개발자별 베이스라인 (per-developer baseline)과 소스 내 억제 마커 (in-source suppression marker) — 이 있으며, 두 방법 모두 제거되는 항목이 항상 카운트되도록 설계되어 있어 결코 조용히 삼켜지지 않습니다. 흥미로운 부분은 주변 코드가 이동할 때 탐지 결과가 어떻게 자신의 정체성을 유지하느냐 하는 점입니다.
요약 (TL;DR)
- 베이스라인 (Baseline) (
.commitbrief/baseline.json, gitignored): 현재의 탐지 결과들을 한 번 수락합니다. 이후 실행 시 해당 파일에 이미 지문 (fingerprint)이 있는 항목은 제외됩니다. - 인라인 억제 (Inline suppression): 라인 위나 해당 라인에
commitbrief-ignore: <reason>주석을 달면 해당 탐지 결과가 제거됩니다. 이는 커밋된 소스에 남기 때문에 리뷰어도 이를 볼 수 있습니다. - 탐지 결과의 지문 (fingerprint)은 의도적으로 라인 번호를 제외하므로, 코드가 파일 내에서 위아래로 밀려나더라도 수락된 상태가 유지됩니다.
- 두 방법 모두 '진정한' 제거입니다. 단순히 표시에서만 제외되는 것이 아니라
--fail-on옵션과 JSONfindings[]에 영향을 미치며, 두 방법 모두 무엇을 제거했는지 출력합니다. - 한계점. 베이스라인은 팀 전체의 정책이 아닌 개발자별 설정입니다. 이는 개인의 실행 결과는 조용하게 만들지만, CI (지속적 통합) 결과까지 조용하게 만들지는 않습니다.
코드 이동(Code drift)에도 살아남는 지문 (Fingerprint)
전체 설계는 하나의 질문에 기반합니다: 언제 탐지 결과가 당신이 이미 수락한 "동일한 탐지 결과"가 되는가? 만약 그 답에 라인 번호가 포함되어 있다면, 문제 위에 import 문을 추가하는 순간 베이스라인은 증발해 버릴 것입니다. 그래서 포함하지 않습니다. 탐지 결과의 정체성은 해싱된 세 가지 필드로 구성됩니다:
func normalizeTitle(title string) string {
return strings.ToLower(strings.Join(strings.Fields(title), " "))
}
...
파일(File), 심각도(severity), 그리고 정규화된 제목(normalized title) — 그 외에는 아무것도 포함하지 않습니다. Line은 제외되었습니다. 그래야 주변 코드가 변경되더라도 동일한 이슈가 동일한 지문(fingerprint)을 유지할 수 있기 때문입니다. Description과 Snippet 또한 제외되었습니다. LLM은 실행할 때마다 이를 재구성(rephrase)하기 때문입니다. 이들을 포함하면 모델이 매번 다른 문장을 선택할 때마다 지문이 새로 생성되어, 기준점(baseline)이 실제로 아무것도 잡아내지 못하게 될 것입니다. 필드 사이의 NUL 바이트는 각 필드를 모호하지 않게 유지합니다. 즉, "ab" + "c"와 "a" + "bc"가 동일한 해시로 충돌할 수 없습니다.
이 단 하나의 제외 사항 — 줄 번호(line number) — 이 기준점을 취약한 것이 아닌 내구성 있는 것으로 만듭니다.
기준점(Baseline): 한 번 수락하고, 넘어가기
기준점(baseline)은 수락된 지문(fingerprints)들의 집합입니다. 필터링은 집합 멤버십(set membership) 문제입니다:
func Filter(findings []render.Finding, set Set) (kept []render.Finding, baselined int) {
if len(set) == 0 {
return findings, 0
...
--update-baseline을 통해 선택적으로 참여(opt in)할 수 있으며, 이는 현재의 탐지 결과(findings)를 baseline.json에 흡수하고 해당 실행에 대해서는 필터링되지 않은 결과를 반환합니다. 즉, 전체 세트를 한 번 확인한 후, 향후 실행에서는 정확히 그 항목들에 대해 침묵하게 됩니다. --no-baseline은 모든 결과를 다시 보고 싶을 때 해당 실행에서 파일을 무시합니다. 이 두 플래그는 플래그 계층에서 상호 배타적입니다.
실패 모드(failure modes)는 의도적으로 설계되었습니다. baseline.json이 없는 것은 투명한 무작위 동작(no-op)입니다. 즉, 한 번도 참여하지 않은 개발자는 기준점(baselined)이 설정된 것이 아무것도 없습니다. 존재하지만 손상된 파일은 침묵하는 빈 집합이 아니라 명시적인 에러를 발생시킵니다. 왜냐하면 침묵하며 기준점을 해제해 버리면 이미 해결되었다고 생각한 탐지 결과가 다시 나타날 수 있기 때문이며, 신뢰가 중요한 도구는 이 지점에서 실패(fail closed)해야 합니다. 파일은 gitignored 처리된 .commitbrief/ 아래에 저장되므로, 오직 사용자만의 것입니다.
인라인 억제(Inline suppression): 소스 코드 내의 논리적인 마커
기준점(baseline)은 보이지 않습니다. 때로는 그 반대, 즉 리뷰어가 보고 이의를 제기할 수 있는 억제(suppression)를 원할 때가 있습니다. 그것이 바로 마커(marker)입니다:
var markerRe = regexp.MustCompile(`(?i)commitbrief-ignore\s*(?:\[\s*([a-z]+)\s*\])?\s*:\s*(.*)`)
commitbrief-ignore: <reason>은 해당 라인의 모든 탐지 결과(finding)를 묵인(silence)합니다. commitbrief-ignore[high]: <reason>은 해당 심각도(severity)만 묵인합니다. 여기에는 두 가지 설계 선택 사항이 중요하게 작용합니다. 주석 구문은 파싱(parsing)되지 않습니다. 정규 표현식(regex)이 라인 어디에서든 commitbrief-ignore 토큰을 매칭하므로, 언어별 테이블 없이도 //, #, --, /* */가 모두 작동합니다. 또한, 마커는 diff의 추가된(added) 라인에서만 읽힙니다. 즉, 억제(suppression)는 검토 중인 변경 사항의 일부여야 하며, 수정되지 않은 코드에서 몰래 가져올 수 없습니다.
마커는 해당 라인 자체 또는 바로 위 라인에 있는 탐지 결과를 묵인합니다. 문장이 길 경우 관용적으로 사용하는 위치는 다음과 같습니다:
func isSuppressed(f render.Finding, sup Suppressions) bool {
if f.Line <= 0 {
return false
...
파서가 인식하지 못하는 대괄호 안의 심각도(예: [bogus]와 같은 오타)는 범위가 지정되지 않은(unscoped) 상태로 폴백(fallback)됩니다. 이는 아무 작업도 하지 않고 조용히 넘어가는 대신 억제를 수행합니다. 왜냐하면 어떤 경우든 마커가 diff에 표시되므로 검토자가 오타를 잡아낼 수 있기 때문입니다.
항상 카운트되는 진정한 제거 (True removals)
두 계층은 --fail-on 게이트(gate)와 렌더러(renderer)가 작동하기
_전(before)_에 베이스라인(baseline) 적용 후 억제(suppression)를 수행하는 단일 단계에서 실행됩니다:
// SC1 — 베이스라인 필터
if app.Config.Review.Baseline && !global.noBaseline {
set, lerr := baseline.Load(app.RepoRoot)
...
이것이 이들을
진정한(true) 제거로 만드는 요소입니다. 이는 단순히 표시만 하지 않는 --min-severity(사람에게는 탐지 결과를 숨기지만 JSON과 게이트에는 남겨둠)와는 의도적으로 다릅니다. 베이스라인이 적용된 탐지 결과는 CI에서 --fail-on=high를 트리거하지 않습니다. 즉, 조치 가능한(actionable) 세트에서 사라집니다. 하지만 "사라졌다"고 해서 결코 "조용해진" 것은 아닙니다. 해당 카운트는 선택적 meta.baselined / meta.suppressed JSON 필드(스키마는 v1 유지)와 한 줄짜리 stderr 푸터(footer)를 통해 계속 표시됩니다.
func signalControlFooter(cmd *cobra.Command, app *appContext, baselined, suppressed int) {
if baselined == 0 && suppressed == 0 {
return
...
이는 stderr(표준 에러)로 출력되므로, 파이프(piped)를 통해 전달되는 --json stdout(표준 출력)을 절대 오염시키지 않습니다. 무엇을 누락했는지 신뢰할 수 없는 리뷰 도구라면, pre-commit hook(사전 커밋 훅)에 남겨둘 수 없을 것입니다.
이것이 아닌 것
베이스라인(baseline)은 개발자별로 적용되며 의도적으로 gitignored(git 무시) 처리됩니다. 즉, 팀 전체의 공유 정책이 아닙니다. 이는 당신의 실행 결과만 조용하게 만들 뿐이며, 팀 동료나 CI, 또는 마지막 단계의 시니어 리뷰어는 여전히 모든 탐지 결과(finding)를 볼 수 있습니다. 이것이 핵심입니다(당신이 수용한 불필요한 항목(cruft) 목록이 다음 사람에게 실제 버그를 숨겨서는 안 됩니다). 그리고 이것이 비용입니다(팀 전체를 위해 무언가를 베이스라인화할 수는 없습니다). 인라인 억제(Inline suppression)는 이와 반대되는 트레이드오프(trade-off)입니다. 이는 코드와 함께 이동하며, 바로 그렇기 때문에 리뷰가 가능합니다. 즉, 누군가 이의를 제기할 수 있도록 diff(차이점)에 그 이유가 남게 됩니다. 두 방식 모두 소스 코드를 수정하지 않습니다. 억제(suppression)는 오직 당신이 직접 마커(marker)를 작성했을 때만 효과를 발휘합니다.
Repo: github.com/CommitBrief/commitbrief.
Building CommitBrief의 8부. 다음 편: remote PR review — 당신의 gh 인증을 사용하여 실행되고 인라인 댓글을 게시하는 셀프 호스팅(self-hosted) 리뷰어.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기