핵심 결론: 한 프로젝트를 컴퓨터과학 15개 렌즈로 훑었더니 서로 무관해 보이던 결함 일곱 개가 전부 같은 형태였다. 선언된 것과 집행되는 것이 다르다. 그리고 그 간극은 언제나 초록불 쪽으로 벌어진다.

프로토타입 하나를 종합 리뷰했다. 코드 리뷰가 아니라 시스템 리뷰다 — 아키텍처, 빌드, 테스트, 배포, 관측까지 한 번에 본다.

기대한 결과는 “여기가 약하다” 는 목록이었다. 실제로 나온 건 목록이 아니라 하나의 반복되는 문법이었다. 이 글은 그 문법에 대한 기록이다.

보안 관련 발견은 이 글에 싣지 않는다. 아직 서비스 중이고 수정 전이다.

1. 리뷰의 증거 경계

먼저 이 글이 무엇을 근거로 하는지 밝힌다. 이게 없으면 아래 주장들은 인상 비평이다.

항목 확인 방법
프로덕션 Kotlin 280 파일 / 11,935줄 find+wc 실측
테스트 Kotlin 98 파일 / 10,716줄 동일
문서 47 파일 / 17,839줄 동일
바운디드 컨텍스트 14개 디렉터리 실측
Spring Boot 4.0.4 build.gradle.kts:11
Kotlin 2.2.20 build.gradle.kts:8-10
커버리지 (CI 실측) LINE 98.27% · BRANCH 86.17% JaCoCo 리포트

테스트 코드가 프로덕션 코드의 90%에 육박하고 라인 커버리지가 98%다. 숫자만 보면 잘 관리된 프로젝트다.

그런데 아래 일곱 개는 그 98% 안에서 전부 초록불이었다. 이 글의 전제가 그것이다.

2. 사례 1 — 테스트가 검증한 객체와 배포된 객체가 달랐다

정기 채점 잡이 운영에서만 매번 실패했다. 지표를 보니 성공 횟수가 프로젝트 시작 이래 계속 0이었다.

원인은 데이터가 아니라 배선이었다. Spring Boot 4의 기본 ObjectMapper 는 Jackson 3(tools.jackson.databind)다1. Jackson 2를 쓰는 레거시 경로는 손으로 만든 빈을 받는데, 그 빈에 모듈이 하나도 등록돼 있지 않았다.

// 문제
@Bean
fun legacyJacksonObjectMapper(): ObjectMapper = ObjectMapper()

// 수정
@Bean
fun legacyJacksonObjectMapper(): ObjectMapper = jacksonObjectMapper()

KotlinModule 이 없으면 Kotlin 주 생성자를 못 읽는다2. 그래서 파라미터에 기본값이 없는 data class 는 역직렬화가 통째로 실패한다.

같은 지뢰를 세 클래스가 밟고 있었는데 하나만 터진 것이 진단을 늦췄다.

클래스 결과 살아남은 이유
픽스처 루트 통과 모든 파라미터에 기본값 → Kotlin 이 no-arg 생성자를 생성
매니페스트 통과 전 필드에 @param:JsonProperty
근거 본문 실패 둘 다 없음

핵심은 왜 CI 가 못 잡았느냐다. 기존 테스트는 전부 이렇게 생겼다.

val adapter = ClasspathGoldenSetAdapter(jacksonObjectMapper())   // 매퍼를 손수 만든다

프로덕션만 그 빈을 주입받았다. 즉 테스트가 검증한 매퍼와 배포된 매퍼는 서로 다른 물건이었다. 회귀 테스트는 그래서 매퍼를 만들지 않고 빈을 직접 호출한다.

private val mapper = JacksonCompatConfig().legacyJacksonObjectMapper()

이 파일에서 jacksonObjectMapper() 를 쓰는 순간 테스트의 의미가 사라진다.

배선 버그는 배선을 우회하는 테스트로는 절대 안 잡힌다.

3. 사례 2 — “복사 태스크가 돌았다” 와 “jar 안에 있다” 는 다른 명제다

같은 채점 잡이 그 전에도 한 번 죽었다. 이번엔 데이터가 이미지 안에 없었다.

Gradle 의 from(존재하지 않는 디렉터리)조용히 0건 복사하고 성공한다3. Docker 빌드 컨텍스트가 하위 디렉터리라서 리포 루트의 데이터가 애초에 보이지 않았다. 빌드는 초록불이었고, 데이터 없는 jar 가 배포됐다.

교정이 3중이다.

  1. processResources.doFirst 에서 원본 디렉터리 존재를 check() 로 강제
  2. bootJar.doLast 에서 ZipFile 로 실제 jar 를 열어 매니페스트와 픽스처 개수를 확인
  3. 빌드 컨텍스트가 왜 리포 루트여야 하는지 주석으로 고정

2번이 핵심이다. 1번만으로는 “입력이 있었다” 까지만 증명한다. 산출물을 열어보기 전까지 “넣었다” 는 “들어갔다” 가 아니다.

4. 사례 3 — 공집합에 대한 전칭명제는 항상 참이다

이 프로젝트에는 콘텐츠 게이트 러너가 있다. 1,594줄짜리 파이썬 파일에 게이트 27개, RED 픽스처 27종과 GREEN 픽스처 7종, 게이트 자체 테스트 51건이다.

여기서 실제로 난 사고가 이 글의 주제를 가장 정확히 보여준다. 게이트 설정에서 금지어 목록을 “전량” 이 아니라 “부분집합” 으로 적어 둔 곳이 있었다. 목록이 짧아진 만큼 검사는 조용히 줄었고, 결과는 여전히 초록이었다. 설정 키가 아예 정의되지 않은 항목도 다섯 개 있었다 — 미정의는 실패가 아니라 검사 없음으로 흘러갔다.

그래서 판정을 셋으로 나눴다.

판정 의미
PASS 검사했고 통과했다
FAIL 검사했고 걸렸다
BLOCKED 검사 자체를 못 했다

타겟이 없거나, 빈 배열을 순회해 0회 돌았거나, 설정이 미정의면 BLOCKED 다. PASS 가 아니다.

교정 후 두 인물의 판정은 이렇게 바뀌었다.

이전:  PASS 19 · FAIL 0 · BLOCKED 8
이후:  PASS 23 · FAIL 0 · BLOCKED 4

FAIL 은 이전에도 0이었다. 달라진 건 초록 8칸이 “검사 못 했음” 으로 정직하게 이동한 것뿐이다. 이전의 PASS 19 는 거짓말이 아니라 의미 없는 참이었다.

공집합에 대한 전칭명제는 항상 참이다. 그걸 초록으로 인쇄하는 것이 반복해서 난 사고다.

더 엄격하면 false red 가 나고, 더 느슨하면 vacuous green 이 난다. vacuous green 이 더 나쁘다. false red 는 사람이 와서 본다. vacuous green 은 아무도 안 온다.

가장 순수한 형태 — 검사가 틀린 값끼리 대조하고 있었다

같은 파이프라인에서 이보다 더 극단적인 사례가 나왔다. 인용문 원문이 표기 오류를 갖고 있었고 픽스처 9건이 그 문장을 쓰고 있었다. 그런데 그걸 검사하는 무결성 테스트의 검증 상수가 똑같이 틀린 문장을 정답으로 담고 있었다.

테스트는 통과했다. 틀린 값과 틀린 값을 비교해서 같다고 판정한 것이다. 커밋 메시지의 표현이 정확하다 — 게이트가 아니라 게이트 모양의 장식이었다.

이 실패에는 자동으로 잡을 방법이 없다. 검사와 피검사가 같은 출처에서 왔기 때문이다. 그래서 교정도 검사 강화가 아니라 규칙 변경이었다 — 인용 단위를 “구절” 에서 “절 전문 그대로” 로 바꿔, 인용 범위가 자유로워서 표류를 못 잡던 구조 자체를 없앴다.

검사를 강화해도 안 되는 구멍이 있다. 그때는 구멍을 못 만들게 설계를 바꿔야 한다.

5. 사례 4 — 수집기는 있었고, 75일간 아무것도 고르지 않았다

Prometheus ServiceMonitor 는 두 홉으로 동작한다.

ServiceMonitor.spec.selector
  → Service 의 metadata.labels      (홉 1)
Service.spec.selector
  → Pod 의 labels                   (홉 2)

홉 1이 보는 건 Service 의 metadata.labels 다. spec.selector 가 아니다. 이 둘은 이름이 비슷하고 파일에서 몇 줄 안 떨어져 있다.

Service 에 metadata.labels 가 비어 있으면 ServiceMonitor 는 아무것도 고르지 않는다. 그리고 에러를 내지 않는다. 리소스는 존재하고, kubectl get servicemonitor 는 정상이고, 대상이 0개일 뿐이다.

이 프로젝트에서 그 상태가 75일 갔다. 고친 커밋 메시지가 그대로 남아 있다 — “ServiceMonitor 가 75일간 0개를 고르고 있었다”.

교훈은 두 홉을 각각 검증하라는 것이다. 최종 확인은 리소스가 아니라 대상이다.

# 리소스 존재 확인으로 끝내지 말 것
kubectl get servicemonitor -n <ns>

# 실제로 스크레이프되는지까지 봐야 한다
kubectl get --raw '/api/v1/namespaces/monitoring/services/<prom>:9090/proxy/api/v1/targets?state=active'

6. 사례 5 — liveness 의 initialDelay 가 부팅을 가리고 있었다

이 애플리케이션의 실측 부팅 시간이다.

Started ...Application in 45.206 seconds (process running for 48.185)

프로브 설정은 이랬다. startupProbe 는 없고, livenessProbe.initialDelaySeconds 가 60이다.

지금은 통과한다. 부팅 48초, 첫 검사 60초 — 여유 12초. 그리고 이건 설계가 아니라 우연이다.

failureThreshold 는 명시돼 있지 않아 기본값 3이 적용된다. 즉 initialDelay 60 + period 30 × 3 이므로 강제 종료 임계는 150초다. 부팅이 그걸 넘기면 kubelet 이 죽이고, 다시 부팅하고, 다시 죽는다. 콜드 이미지 pull 이나 노드 CPU 압박이 겹치면 12초는 없는 마진이다.

startupProbe 는 정확히 이 문제를 위해 존재한다4. 부팅 구간에는 startup 만 돌고, 통과한 뒤에야 liveness 가 시작된다. 그러면 “부팅에 얼마를 허용할지” 와 “살아 있는지 얼마나 자주 볼지” 가 분리된다.

지금 구조는 그 둘을 하나의 숫자로 겸하고 있다. 선언된 의도는 “부팅을 기다린다” 인데, 집행되는 건 “죽음을 늦게 확인한다” 다.

7. 사례 6 — 배포된 태그가 git 어디에도 없다

GitOps 로 배포한다. 이미지 자동 갱신은 write-back-method: argocd 로 설정돼 있다.

이 모드에서 갱신된 태그는 클러스터의 Application 리소스에만 기록된다. git 에는 안 남는다5. 그리고 git 쪽 values 파일에 남아 있는 값은 이렇다.

tag: latest

그래서 두 문장이 동시에 참이다.

  • “이 배포는 git 으로 관리된다”
  • “지금 무엇이 떠 있는지는 git 으로 알 수 없다”

Application 리소스가 git 에서 재생성되는 순간 태그는 가변 태그 latest 로 붕괴한다. 롤백은 Argo 의 히스토리에만 의존하는데 그 보관 개수는 기본 10개다.

흥미로운 건 같은 차트 안에서 한 서비스만 예외로 git 에 SHA 가 박혀 있다는 점이다. 자동 갱신이 오판해 옛 이미지에 고착된 사고가 나서 수동 관리로 뺐기 때문이다. 즉 이 프로젝트는 이미 한 번 대가를 치르고 그 서비스만 고쳤다. 나머지는 같은 조건에 그대로 있다.

8. 사례 7 — 문서가 실측과 어긋난다

리뷰 중 문서의 주장과 실측이 갈린 지점만 모았다.

문서 주장 실측
PostgreSQL 16 + pgvector (설계 문서 2곳) 통합 테스트 컨테이너는 pgvector/pgvector:pg17
“Phase 0 — 시장 검증(계획)”, “설계 단계, 구현 X” 백엔드·프론트·CI·배포 모두 가동 중
인물 4명 (빌드 계획 3곳) 인물 설계 문서 11종 / 런타임 enum 7종
채점 기능 “아직 안 켰다” 운영 환경변수는 true

마지막 줄이 가장 위험하다. 기능이 꺼져 있다고 적힌 문서를 읽고 그 경로를 안 건드리면, 실제로는 켜져 있는 코드를 방치하게 된다.

여기서 문서를 탓하는 건 초점이 어긋난다. 문서는 원래 낡는다. 문제는 낡았다는 사실을 알려주는 장치가 없다는 것이다. 이 프로젝트에는 커버리지 래칫도 있고 데이터 표준을 소스 스캔으로 강제하는 테스트도 있다. 문서 수치만 그 밖에 있다.

9. 이 프로젝트가 이미 잘하고 있는 것

일곱 개를 늘어놓았으니 균형을 맞춘다. 위 사례들이 발견될 수 있었던 이유 자체가 이 프로젝트의 강점이다.

  • 지표를 정직하게 설계했다. 표본이 없을 때 0이 아니라 NaN 을 낸다. 0을 내면 “오탐률 0%” 로 읽혀 표본 없이 통과한 것처럼 보인다. 실패해도 지난 성공 시각을 덮지 않아 값이 늙는 것으로 장애가 드러난다.
  • 자기 지표의 무의미함을 스스로 기록한다. 표본 7개일 때와 13개일 때 정밀도가 1.00 → 0.857 로 흔들린 것을 표로 남기고 “이 표본 수에서 이건 아직 지표가 아니라 잡음이다” 라고 적었다.
  • 임계치를 데이터에 맞춰 재조정하지 않았다. 한 표본이 임계치에 0.006 모자라 틀렸는데, 그걸 통과시키는 격자 최적값은 홀드아웃의 다른 위험 표본까지 통과시킨다. 과적합을 이유로 거부한 판단이 문서에 남아 있다.
  • 뚫린 표본을 고쳐 없애지 않고 보존한다. 두 방어선을 모두 통과하는 유일한 사례를 “자동 방어선이 0개인 지점” 의 증거로 남겼다.
  • 게이트에 돌연변이를 주입해 게이트를 검사한다6. 이게 이 글의 문제에 대한 유일한 정면 대응이다. 스크래치 사본을 만들어 일부러 규칙을 깨뜨리고, 의도한 게이트만 FAIL 하고 부수 FAIL 이 없는지 확인한다.
토큰 1개 삭제           → G0d 만 FAIL
예문을 '…' 로 교체      → G0c 만 FAIL
목록 원소 1개 삭제      → G0c 만 FAIL (선언 45 ≠ 실제 44)
부수 FAIL 없음

마지막 줄이 특히 좋다. 목록의 선언된 개수와 실제 개수를 대조한다. 원소가 조용히 하나 빠지는 것이 이 파이프라인에서 실제로 난 사고였고, 그 사고를 다시 못 나게 만든 검사다. 초록불이 의미 있는지 확인하는 유일한 방법은 빨간불을 낼 수 있는지 확인하는 것이다.

  • 같은 함정을 이미 한 곳에서는 막아 뒀다. 시나리오 로더는 yml 파일이 없으면 경고만 남기고 조용히 건너뛴다 — 위 일곱 개와 정확히 같은 형태다. 그런데 그 자리엔 이런 주석과 테스트가 붙어 있다.
// 값을 추가하면 resources/scenarios/{dbValue}.yml 이 *반드시* 함께 있어야 한다.
// ScenarioYamlLoader 는 파일이 없으면 warn 로그만 남기고 조용히 건너뛴다 —
// ScenarioYamlLoaderTest 의 `모든 Character 에 시나리오 yml 존재` 가 그 구멍을 막는다.

즉 이 프로젝트는 조용한 스킵이 위험하다는 것을 이미 알고 있었다. 문제는 그 앎이 한 지점에만 적용됐다는 것이다. 패턴을 아는 것과 전수로 적용하는 것은 다른 일이다.

그리고 채점 결과 자체는 이렇게 정의된다. 문장 \(s\) 의 최대 유사도가 임계치 \(\tau\) 에 못 미치면 미근거로 세고, 그 비율이 허용치를 넘으면 거부한다.

\[\mathrm{unsupported}(S, P) = \frac{\left|\left\{\, s \in S \;:\; \max_{p \in P} \cos(e_s, e_p) < \tau \,\right\}\right|}{|S|}\]

수정 후 첫 성공 채점에서 표본 13건, \(F_1 = 0.857\), 정확 일치 0.846 이 나왔다. 낮은 값이다. 그리고 이 값이 처음으로 존재한다는 것이 이번 수정의 결과다.

10. 패턴의 일반형

일곱 사례를 같은 틀에 넣으면 이렇게 된다.

선언된 것 집행되는 것
테스트가 통과했다 테스트가 다른 객체를 통과시켰다
리소스를 jar 에 넣었다 복사 태스크가 0건 복사하고 성공했다
게이트가 통과했다 검사 항목이 미정의라 안 돌았다
무결성 테스트가 있다 검증본이 똑같이 틀린 값이었다
지표를 수집한다 수집 대상이 0개였다
부팅을 기다린다 죽음을 늦게 확인한다
git 이 배포를 관리한다 배포된 태그가 git 에 없다
기능이 꺼져 있다 운영에서는 켜져 있다

공통점이 세 가지 있다.

  1. 어느 것도 에러를 내지 않았다. 전부 정상 종료했고 전부 초록불이었다.
  2. 간극은 항상 안전한 쪽처럼 보이는 방향으로 벌어졌다. 0건 복사는 성공이고, 0개 수집은 정상이고, 공집합 순회는 통과다.
  3. 증상이 나타나는 지점이 원인에서 멀다. 매퍼 배선이 채점 실패로, 빌드 컨텍스트가 지표 부재로 나타난다.

대응은 두 가지로 수렴한다.

첫째, 최종 산출물을 열어서 확인하라.

  • 복사했는지 묻지 말고 jar 를 열어라
  • 테스트가 통과했는지 묻지 말고 프로덕션과 같은 객체를 통과했는지 봐라
  • 수집기가 있는지 묻지 말고 대상 개수를 봐라
  • 게이트가 돌았는지 묻지 말고 몇 건을 순회했는지 봐라
  • git 이 관리하는지 묻지 말고 git 에서 재구성되는지 봐라

“돌았다” 는 항상 “됐다” 보다 검증하기 쉽다. 그래서 사람은 전자를 확인하고 후자를 확인했다고 믿는다.

둘째, 초록불을 믿기 전에 빨간불을 낼 수 있는지 확인하라. §9의 돌연변이 주입이 그것이다. 규칙을 일부러 깨뜨렸을 때 의도한 검사만 실패하지 않는다면, 그 검사는 지금 무엇을 보고 있는지 아무도 모르는 상태다.

이 두 번째가 첫 번째보다 강하다. 산출물 확인은 아는 실패를 막고, 돌연변이 주입은 모르는 실패를 드러낸다. 이 글의 일곱 개는 전부 후자였다.

11. 이 리뷰가 확인하지 못한 것

리뷰의 결론만큼 리뷰의 경계도 적어야 한다.

  • 정적 검토다. 애플리케이션을 실행해 재현한 것은 채점 잡 하나뿐이다. 나머지는 코드와 실측 설정 근거다.
  • XR 클라이언트는 보지 않았다. 리포에 포함된 Unity 측은 범위 밖이다.
  • 콘텐츠 코퍼스를 전수로 읽지 않았다. 서사 파일 다수는 개별 내용을 확인하지 못했다.
  • 복원 리허설 기록을 찾지 못했다. 백업은 이중으로 실제 동작하는 것을 확인했지만, 복원에 걸리는 시간은 근거가 없어 추정하지 않았다.
  • 보안 발견은 이 글에서 제외했다. 수정 전 공개는 하지 않는다.

그리고 이 글의 논지 자체에 적용되는 한계가 하나 더 있다. 위 일곱 개는 찾았기 때문에 목록에 있다. 같은 형태이면서 아직 못 찾은 것들은 정의상 이 표에 없다. 패턴을 알았다고 해서 남은 개수를 아는 건 아니다.

결론: 초록불은 “문제가 없다” 가 아니라 “확인한 범위에서 문제를 못 찾았다” 는 뜻이다. 그 범위가 공집합이면 초록불은 언제나 켜진다.

References

이 글은 특정 도구나 아키텍처의 우열을 주장하지 않는다. 인용한 수치는 단일 프로젝트의 실측이며 일반화된 벤치마크가 아니다.

  1. Spring Boot Reference Documentation — Spring Boot 4 의 기본 JSON 매퍼 구성. 본문의 Jackson 2/3 병존 상황은 대상 리포의 build.gradle.kts 와 실제 빈 정의로 확인했다. 

  2. FasterXML/jackson-module-kotlin — Kotlin 주 생성자 역직렬화를 위해 모듈 등록이 필요하다는 1차 근거. 

  3. Gradle User Manual — Incremental build — 태스크 입력 선언과 up-to-date 판정. 선언되지 않은 입력이 태스크 스킵으로 이어지는 근거. 

  4. Kubernetes Documentation — Configure Liveness, Readiness and Startup Probes — startupProbe 가 기동이 느린 컨테이너를 위해 존재한다는 공식 설명. 

  5. Argo CD Image Updater — Update methodsargocd write-back 이 Application 리소스에 직접 쓰고 git 에는 커밋하지 않는다는 공식 문서. 

  6. Y. Jia and M. Harman, “An Analysis and Survey of the Development of Mutation Testing”, IEEE Transactions on Software Engineering, vol. 37, no. 5, 2011 — 결함을 일부러 주입해 테스트가 그것을 잡는지 확인하는 기법의 동료심사 개관. 본문 §9의 “게이트에 돌연변이 주입” 은 이 아이디어를 테스트가 아니라 게이트에 적용한 것이다.