선언된 규약과 집행되는 코드 사이 — 한 프로젝트를 통째로 훑고 나온 실패 하나
핵심 결론: 한 프로젝트를 컴퓨터과학 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중이다.
processResources.doFirst에서 원본 디렉터리 존재를check()로 강제bootJar.doLast에서ZipFile로 실제 jar 를 열어 매니페스트와 픽스처 개수를 확인- 빌드 컨텍스트가 왜 리포 루트여야 하는지 주석으로 고정
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 에 없다 |
| 기능이 꺼져 있다 | 운영에서는 켜져 있다 |
공통점이 세 가지 있다.
- 어느 것도 에러를 내지 않았다. 전부 정상 종료했고 전부 초록불이었다.
- 간극은 항상 안전한 쪽처럼 보이는 방향으로 벌어졌다. 0건 복사는 성공이고, 0개 수집은 정상이고, 공집합 순회는 통과다.
- 증상이 나타나는 지점이 원인에서 멀다. 매퍼 배선이 채점 실패로, 빌드 컨텍스트가 지표 부재로 나타난다.
대응은 두 가지로 수렴한다.
첫째, 최종 산출물을 열어서 확인하라.
- 복사했는지 묻지 말고 jar 를 열어라
- 테스트가 통과했는지 묻지 말고 프로덕션과 같은 객체를 통과했는지 봐라
- 수집기가 있는지 묻지 말고 대상 개수를 봐라
- 게이트가 돌았는지 묻지 말고 몇 건을 순회했는지 봐라
- git 이 관리하는지 묻지 말고 git 에서 재구성되는지 봐라
“돌았다” 는 항상 “됐다” 보다 검증하기 쉽다. 그래서 사람은 전자를 확인하고 후자를 확인했다고 믿는다.
둘째, 초록불을 믿기 전에 빨간불을 낼 수 있는지 확인하라. §9의 돌연변이 주입이 그것이다. 규칙을 일부러 깨뜨렸을 때 의도한 검사만 실패하지 않는다면, 그 검사는 지금 무엇을 보고 있는지 아무도 모르는 상태다.
이 두 번째가 첫 번째보다 강하다. 산출물 확인은 아는 실패를 막고, 돌연변이 주입은 모르는 실패를 드러낸다. 이 글의 일곱 개는 전부 후자였다.
11. 이 리뷰가 확인하지 못한 것
리뷰의 결론만큼 리뷰의 경계도 적어야 한다.
- 정적 검토다. 애플리케이션을 실행해 재현한 것은 채점 잡 하나뿐이다. 나머지는 코드와 실측 설정 근거다.
- XR 클라이언트는 보지 않았다. 리포에 포함된 Unity 측은 범위 밖이다.
- 콘텐츠 코퍼스를 전수로 읽지 않았다. 서사 파일 다수는 개별 내용을 확인하지 못했다.
- 복원 리허설 기록을 찾지 못했다. 백업은 이중으로 실제 동작하는 것을 확인했지만, 복원에 걸리는 시간은 근거가 없어 추정하지 않았다.
- 보안 발견은 이 글에서 제외했다. 수정 전 공개는 하지 않는다.
그리고 이 글의 논지 자체에 적용되는 한계가 하나 더 있다. 위 일곱 개는 찾았기 때문에 목록에 있다. 같은 형태이면서 아직 못 찾은 것들은 정의상 이 표에 없다. 패턴을 알았다고 해서 남은 개수를 아는 건 아니다.
결론: 초록불은 “문제가 없다” 가 아니라 “확인한 범위에서 문제를 못 찾았다” 는 뜻이다. 그 범위가 공집합이면 초록불은 언제나 켜진다.
References
이 글은 특정 도구나 아키텍처의 우열을 주장하지 않는다. 인용한 수치는 단일 프로젝트의 실측이며 일반화된 벤치마크가 아니다.
-
Spring Boot Reference Documentation — Spring Boot 4 의 기본 JSON 매퍼 구성. 본문의 Jackson 2/3 병존 상황은 대상 리포의
build.gradle.kts와 실제 빈 정의로 확인했다. ↩ -
FasterXML/jackson-module-kotlin — Kotlin 주 생성자 역직렬화를 위해 모듈 등록이 필요하다는 1차 근거. ↩
-
Gradle User Manual — Incremental build — 태스크 입력 선언과 up-to-date 판정. 선언되지 않은 입력이 태스크 스킵으로 이어지는 근거. ↩
-
Kubernetes Documentation — Configure Liveness, Readiness and Startup Probes — startupProbe 가 기동이 느린 컨테이너를 위해 존재한다는 공식 설명. ↩
-
Argo CD Image Updater — Update methods —
argocdwrite-back 이 Application 리소스에 직접 쓰고 git 에는 커밋하지 않는다는 공식 문서. ↩ -
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의 “게이트에 돌연변이 주입” 은 이 아이디어를 테스트가 아니라 게이트에 적용한 것이다. ↩