개념
두 분석은 서로의 사각지대다
레슨 1~3은 "테스트가 코드를 실행했는가, 실행한 코드를 검증했는가"를 다뤘다. 그런데 커버리지 95%·뮤테이션 점수 80%인 프로젝트에서도 이런 일이 남는다. 테스트는 통과하는데 메모리가 하루에 400MB씩 증가한다. 통합 테스트가 200회 중 3회 크래시하고 재현이 안 된다. 부하를 걸면 30분 뒤 Too many open files 로 죽는다.
이유는 단순하다. 테스트는 "출력이 기대와 같은가"만 본다. 누수·릭·해제된 메모리 접근·데이터 레이스는 출력을 즉시 바꾸지 않는다.
| 정적 분석 | 동적 분석 | |
|---|---|---|
| 대상 | 실행하지 않은 코드 | 실행 중인 프로세스 |
| 커버리지 | 모든 경로를 원리상 훑는다 | 테스트가 지나간 경로만 |
| 대표 오류 | 오탐(false positive)이 많다 | 미탐(false negative)이 많다 |
| 확실성 | "여기 문제일 수 있다" | "여기서 실제로 일어났다" |
| 비용 | 빌드 시간의 일부 | 실행 시간 2~50배 |
정적 분석은 넓지만 흐릿하고, 동적 분석은 좁지만 선명하다. 대체재가 아니라 짝이다 — 정적으로 의심 지점을 좁히고, 동적으로 실제 결함인지 확정한다.
정적 분석 도구의 네 갈래, 그리고 한계
"정적 분석을 붙였다"는 말은 너무 넓어 정보가 없다. 최소한 넷을 구분해야 대화가 된다.
| 갈래 | 보는 것 | 대표 도구 | 오탐 성향 |
|---|---|---|---|
| 컴파일러 경고 | 언어 규칙 위반, 명백한 실수 | clang/gcc -Wall -Wextra -Werror | 매우 낮다. 가장 먼저 켠다 |
| 린터 | 버그 관용구·패턴·스타일 | ESLint, Ruff, clang-tidy, SpotBugs | 룰마다 다르다 |
| 타입 체커 | 타입 불일치·널 가능성 | tsc, mypy, pyright | 낮다. 도입 비용이 있다 |
| SAST | 보안 취약점. 테인트(taint) 흐름 추적 | Semgrep, CodeQL, Coverity | 높다. 흐름 분석의 근사 때문 |
기능안전 영역에는 추상 해석 기반 검증기와 코딩 표준(MISRA C:2012·CERT C) 준수 검사가 더 붙는다(모듈 35·40). 이 레슨의 관심은 보안이 아니라 코드 품질 결함 이다 — 보안 관점의 SAST 운용은 모듈 50, 임베디드 계측 장비는 모듈 46이다.
잘 잡는 것 — 널 역참조 가능 경로, 초기화 전 사용(레슨 2의 ur 이상), 자원 획득 후 미해제, dd·dk 데이터 흐름 이상, API 오용(반환값 무시·예외 삼키기·equals/hashCode 규약 위반), 포맷 문자열 불일치, 도달 불가 코드.
원리상 못 잡는 것 — 요구사항 오해(문법적으로 완벽하게 틀린 일을 한다), 상태·타이밍 의존 결함 대부분(특히 데이터 레이스), 설정·인프라 문제, 성능·사용성.
한계에는 이론적 뿌리가 있다. 프로그램의 비자명한 의미적 성질은 일반적으로 결정 불가능하다(Rice의 정리). 도구는 반드시 근사하고, 오탐 쪽으로 기울일지 미탐 쪽으로 기울일지 를 고른다. 기능안전용 검증기는 미탐을 줄이려 하므로 오탐이 쏟아지고, 일반 린터는 개발자가 쓸 수 있게 미탐을 감수한다. "오탐이 많다"는 도구의 결함이 아니라 설계 선택의 결과 다.
오탐을 다루는 운영 방식
정적 분석이 폐기되는 경로는 늘 같다. 경고가 1,400건 나오고, 아무도 다 볼 수 없어 방치되고, 전부 노이즈로 취급되고, CI에서 빠진다. 막는 규칙은 넷이다.
① 트리아지는 세 갈래다. 진탐은 수정하고, 오탐은 억제하고, 진탐이지만 지금 고치지 않는 것은 "수용" 으로 따로 기록한다(담당자·만료일 포함). 세 번째를 만들지 않으면 팀은 수용해야 할 것을 오탐이라 부르기 시작한다. 그 순간 억제 목록이 거짓말을 담고, 다시 신뢰를 잃는다.
② 억제는 최소 범위로, 사유와 함께.
// clang-tidy: 드라이버 ABI가 요구하는 캐스트. ABI v3 전환 시 제거(ISSUE-4712)
// NOLINTNEXTLINE(cppcoreguidelines-pro-type-cstyle-cast)
uint32_t raw = (uint32_t)desc->payload;// eslint-disable-next-line @typescript-eslint/no-explicit-any -- 외부 SDK 타입 미제공(ISSUE-4821)
function decode(payload: any) { /* ... */ }억제의 3원칙이 여기 다 있다. 범위는 한 줄, 규칙 id는 명시, 사유는 텍스트로. 파일 전체 억제(/* eslint-disable */)와 규칙 id 없는 억제는 리뷰에서 막거나, PR diff를 grep 하는 CI 검사로 차단한다.
③ 억제는 자산이 아니라 부채다. 개수의 추세를 지표로 두고 사유 없는 억제 와 만료일이 지난 수용 은 0으로 유지한다. 어떤 룰의 억제가 40건 쌓였다면 "코드가 나쁘다"보다 "그 룰이 이 코드베이스에 맞지 않는다" 는 증거다. 오탐률은 룰 단위로 잰다.
룰 id 트리아지 진탐 오탐 오탐률
python.lang.correctness.nan-eq 23 21 2 9%
generic.secrets.hardcoded-token 87 4 83 95% ← 룰을 끈다
java.lang.resource-leak 41 33 8 20%95% 룰을 켜 둔 채 "억제하면 됩니다"라고 하면 팀 시간을 태운다. 끌 룰을 고르는 것도 운영이다.
④ 게이트는 전체가 아니라 신규 코드에 건다. 레슨 1에서 커버리지 게이트를 변경분 기준으로 바꾼 것과 같은 논리다. 기존 위반 1,400건은 베이스라인으로 얼려 부채 백로그로 옮기고, "이 PR이 새로 만든 위반 0" 만 게이트로 남긴다.
semgrep scan --config=p/default \
--baseline-commit=$(git merge-base HEAD origin/main) --error
git diff -U0 origin/main | clang-tidy-diff.py -p1 -path build -quiet리뷰 부담이 PR 크기에 비례하게 되고, 베이스라인은 시간이 지나며 줄어든다.
동적 분석 — 새니타이저 계열
새니타이저는 컴파일 시 계측 코드를 삽입해 실행 중 위반을 즉시 잡는다.
| 도구 | 플래그 | 검출 대상 | 대가(대략) |
|---|---|---|---|
| ASan | -fsanitize=address | 힙·스택·전역 버퍼 오버플로, use-after-free, use-after-return/scope, 이중 해제·잘못된 해제 | 실행 2배, 메모리 2~3배 |
| LSan | -fsanitize=leak 또는 ASan 내장 | 메모리 누수(종료 시점 도달 불가 블록) | 단독 사용 시 거의 없음 |
| UBSan | -fsanitize=undefined | 부호 있는 정수 오버플로, 0 나눗셈, 널·비정렬 포인터 역참조, 범위 밖 시프트, 잘못된 enum·bool 값 | 낮다 |
| MSan | -fsanitize=memory | 초기화되지 않은 메모리 읽기 | 3배. 의존 라이브러리까지 계측 빌드 필요 |
| TSan | -fsanitize=thread | 데이터 레이스, 락 순서 역전(잠재적 교착) | 실행 5 |
조합 규칙이 중요하다. ASan과 TSan은 함께 켤 수 없다. MSan도 ASan과 못 쓴다. 그래서 CI는 최소 두 빌드를 만든다 — ASan+UBSan 하나, TSan 하나. 정확한 제약은 컴파일러 버전마다 달라지므로 Clang/GCC 문서에서 원문 확인한다.
clang -g -O1 -fno-omit-frame-pointer \
-fsanitize=address,undefined -fno-sanitize-recover=all -o app src/*.c
ASAN_OPTIONS=detect_leaks=1:detect_stack_use_after_return=1:abort_on_error=1 \
UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1 ./app-g 와 프레임 포인터가 없으면 스택 트레이스가 읽히지 않는다. 그리고 -fno-sanitize-recover=all 과 halt_on_error=1 이 없으면 UBSan은 위반을 출력만 하고 계속 실행한다 — 그러면 CI가 초록색이 된다. 새니타이저를 붙였는데 아무것도 실패하지 않는 팀의 첫째 원인이 이것이다. macOS에서는 누수 검출 기본값이 다를 수 있어 detect_leaks=1 을 명시한다.
Valgrind 계열 — 도구를 골라 쓴다
Valgrind는 재컴파일 없이 바이너리를 가상 CPU에서 돌려 계측한다. 서드파티 바이너리에도 쓸 수 있는 것이 강점, 느린 것이 약점이다.
| 도구 | 검출 대상 |
|---|---|
| Memcheck(기본) | 잘못된 읽기·쓰기, 초기화되지 않은 값 사용, 잘못된 free, free/delete 불일치, memcpy 영역 겹침, 누수. 실행 20~50배 |
| Helgrind | POSIX 스레드 오류 — 데이터 레이스, 락 순서 역전, pthread API 오용 |
| DRD | 데이터 레이스. Helgrind의 대안으로 메모리 사용이 적은 편 |
| Massif | 힙 사용량 프로파일 — 시간에 따른 증가 곡선 |
| DHAT | 할당 블록의 수명·접근 패턴. "쓰지 않는데 들고 있는" 메모리 찾기 |
| Cachegrind / Callgrind | 캐시·분기 예측 / 호출 그래프 프로파일. 결함 검출용이 아니다 |
스택·전역 배열 오버런용 실험적 도구도 있었으나 버전에 따라 제공 여부가 다르다 — 원문 확인한다. 새니타이저 대신 쓰는 경우 는 셋이다. 재빌드가 불가능할 때, 초기화되지 않은 값 사용을 MSan 없이 볼 때, 누수 분류가 더 세밀해야 할 때.
==8142== LEAK SUMMARY:
==8142== definitely lost: 4,096 bytes in 4 blocks ← 실제 누수. 여기부터 고친다
==8142== indirectly lost: 1,280 bytes in 20 blocks ← 부모를 고치면 함께 사라진다
==8142== possibly lost: 0 bytes in 0 blocks ← 내부 포인터만 남음. 오탐 잦다
==8142== still reachable: 72,704 bytes in 3 blocks ← 전역 캐시·싱글턴이면 정상
==8142== suppressed: 0 bytes in 0 blocksvalgrind --leak-check=full --show-leak-kinds=definite,indirect \
--track-origins=yes --error-exitcode=1 \
--suppressions=ci/valgrind.supp --gen-suppressions=all ./app--gen-suppressions=all 출력을 억제 파일에 붙인다. 여기에도 같은 규칙이 적용된다 — 사유 주석 없는 억제는 리뷰에서 막는다.
네 가지 결함을 증상과 출력으로 구분한다
실무에서 가장 자주 섞이는 넷이다. 증상만 보고 어느 도구를 켤지 판단 할 수 있어야 한다.
| 결함 | 전형적 증상 | 켜는 도구 |
|---|---|---|
| 메모리 누수 | RSS·힙이 시간에 비례해 단조 증가. 결국 OOM | LSan / Memcheck / Massif |
| 자원 릭 | 메모리는 정상인데 일정 요청 수 후 실패(EMFILE, 커넥션 타임아웃) | OS 카운터·풀 지표 |
| 와일드 포인터·use-after-free | 간헐적 크래시, 값이 이유 없이 손상, 재현율 1% | ASan / Memcheck |
| 데이터 레이스 | 부하가 걸릴 때만 값이 틀어진다. 로그를 넣으면 사라진다 | TSan / Helgrind·DRD |
==4271==ERROR: LeakSanitizer: detected memory leaks
Direct leak of 262144 byte(s) in 64 object(s) allocated from:
#1 0x4f8c10 in session_create src/session.c:88
#2 0x4f9204 in handle_login src/api.c:214
Indirect leak of 8192 byte(s) in 64 object(s) allocated from:
#1 0x4f8c44 in session_token src/session.c:95
SUMMARY: AddressSanitizer: 270336 byte(s) leaked in 128 allocation(s).읽는 순서는 아래에서 위로 다. handle_login 이 session_create 를 부르고 거기서 할당한 블록이 해제되지 않았다. Indirect 는 그 세션이 들고 있던 토큰이다. 부모 한 곳을 고치면 128건이 사라진다.
==5310==ERROR: AddressSanitizer: heap-use-after-free on address 0x60300000eff4
READ of size 4 at 0x60300000eff4 thread T0
#0 0x4f1a20 in cache_get src/cache.c:132
freed by thread T0 here:
#1 0x4f1b88 in cache_evict src/cache.c:167
previously allocated by thread T0 here:
#1 0x4f1988 in cache_put src/cache.c:104이 출력의 가치는 세 개의 스택 트레이스 다. 어디서 접근했고(cache_get), 어디서 해제했고(cache_evict), 어디서 할당했는지(cache_put)를 한 번에 준다. ASan은 해제된 블록을 격리(quarantine)해 바로 재사용하지 않으므로 1% 확률로만 터지던 결함이 거의 항상 잡힌다.
WARNING: ThreadSanitizer: data race (pid=6120)
Write of size 4 at 0x55e0f1a2c0 by thread T3:
#0 counter_inc src/metrics.c:41
Previous read of size 4 at 0x55e0f1a2c0 by thread T1:
#0 counter_report src/metrics.c:57
Location is global 'request_count' of size 4
src/fee.c:23:18: runtime error: signed integer overflow: 2147483647 + 1 cannot be represented in type 'int'TSan은 "언젠가 터질 수 있다"가 아니라 "이번 실행에서 동기화 없이 같은 주소에 접근한 두 스레드가 실제로 있었다" 를 보고한다. 값이 우연히 맞게 나와도 보고하므로, 부하 테스트로 재현되지 않는 레이스를 평범한 단위 테스트에서 잡아낸다. 마지막 한 줄은 UBSan이다 — 정의되지 않은 동작은 최적화 수준에 따라 결과가 바뀌므로 디버그 빌드에서 잘 돌던 코드가 릴리스에서 깨지는 대표 원인이다.
자원 릭은 새니타이저가 못 잡는다. 파일 핸들·소켓·DB 커넥션·스레드는 OS나 런타임이 관리하므로 계측 대상 밖이다. 카운터를 본다.
ls /proc/$PID/fd | wc -l # 부하 중 10초 간격으로 추세를 본다
ulimit -n
ss -tanp | awk '{print $1}' | sort | uniq -c # CLOSE_WAIT 누적 확인CLOSE_WAIT 가 계속 쌓이면 상대가 닫았는데 우리 쪽이 닫지 않았다 는 뜻이고, 대개 예외 경로에서 응답 스트림을 닫지 않는 코드다. TIME_WAIT 는 정상 상태이므로 혼동하지 않는다. 커넥션 풀은 활성·유휴 수와 획득 타임아웃 예외를 본다. 이 영역에서는 정적 분석이 의외로 강하다 — try-with-resources 없는 코드, finally 누락, with 문 없는 open() 은 린터의 자원 누수 룰이 잘 잡는다. 정적 분석으로 예방하고 부하 중 카운터로 검증하는 것 이 실무 조합이다.
관리 언어에서는 누수의 형태가 다르다
GC가 있으면 누수가 없다는 것은 오해다. GC는 도달 가능한 객체를 절대 수거하지 않는다. 그래서 누수는 "해제를 잊음"이 아니라 "참조를 놓지 않음" 으로 나타난다.
| 형태 | 예 | 증상 |
|---|---|---|
| 참조 유지 | static 컬렉션에 계속 담기, 스레드 풀에서 ThreadLocal 미정리 | 힙이 단조 증가, Full GC 후에도 안 줄어든다 |
| 리스너·타이머 미해제 | 이벤트 리스너·구독·setInterval 해제 누락 | 화면을 반복 진입할수록 증가 |
| 캐시 무한 증가 | 상한 없는 HashMap 캐시, lru_cache(maxsize=None) | 트래픽에 비례해 증가, 재시작하면 초기화 |
# JVM — 클래스별 인스턴스 수와 힙 덤프. 덤프는 dominator tree 로 참조 경로를 본다
jcmd $PID GC.class_histogram | head -20
jcmd $PID GC.heap_dump /tmp/heap.hprof
# 사고 대비: -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/var/log/
# Node.js — 스냅샷 3회 비교가 정석이다. ①기준 → 동작 N회 → ② → 동작 N회 → ③
node --inspect --heapsnapshot-signal=SIGUSR2 server.js # kill -USR2 로 스냅샷
# ①→② 와 ②→③ 양쪽에서 함께 남은 객체가 범인이다# Python — tracemalloc 스냅샷 차분
import tracemalloc
tracemalloc.start(25)
base = tracemalloc.take_snapshot()
run_workload()
for stat in tracemalloc.take_snapshot().compare_to(base, 'lineno')[:10]:
print(stat) # app/cache.py:64: size=+18.2 MiB, count=+48120세 도구의 공통 사용법이 하나 있다. 절대값이 아니라 차분을 본다. "힙이 800MB다"는 정보가 없고, "같은 동작 1,000회에 세션 객체가 1,000개 늘고 GC 후에도 안 줄었다"가 결함 보고다. 브라우저에서는 DOM에서 제거됐는데 JS가 참조를 들고 있는 분리된(detached) 노드 가 프런트엔드 누수의 큰 몫을 차지한다.
CI에 붙이는 현실 — 느린 검사를 어디에 두는가
기준은 하나다. PR 파이프라인의 총 시간 예산을 먼저 정하고, 그 안에 들어가는 것만 PR에 둔다.
| 검사 | 대가 | 배치 | 실패 처리 |
|---|---|---|---|
컴파일러 경고 -Werror | 없음 | 매 PR | 차단 |
| 린터·타입 체커(변경분) | 수십 초 | 매 PR | 차단 |
SAST 증분(--baseline-commit) | 수 분 | 매 PR | 신규 위반만 차단 |
| ASan + UBSan 테스트 | 2~3배 | 매 PR(가능하면) | 차단 |
| TSan 동시성 테스트 | 5~15배 | 야간 또는 동시성 모듈 한정 | 이슈 자동 생성 |
| Valgrind Memcheck | 20~50배 | 야간·릴리스 후보 | 릴리스 차단 |
| MSan(의존성 재빌드) | 3배 + 빌드 비용 | 주간 전용 잡 | 이슈 생성 |
| 장시간 누수 감시(soak) | 수 시간 | 야간·주말 | 추세 경보 |
주의점 넷. 새니타이저 빌드는 별도 빌드 트리로 분리한다(일반 캐시와 섞이면 계측 여부가 뒤섞인다). 테스트 타임아웃을 함께 늘린다(ASan 2배·TSan 10배면 기존 타임아웃이 먼저 터져 원인을 오해한다). 성능 테스트와 같은 잡에 두지 않는다(계측된 바이너리의 응답 시간은 의미가 없다). 야간 실패는 담당자에게 도달하게 만든다 — 아무도 안 보는 야간 잡은 검사가 아니라 전기 요금이다.
도입 순서는 뮤테이션 테스트와 같다. 싼 것부터 — -Werror, 린터 변경분, UBSan, ASan, 그다음 TSan·Valgrind. 처음부터 전부 켜면 첫 주에 꺼진다.
실무에서 왜 필요한가
간헐적 결함은 QA의 신뢰를 가장 빨리 깎는 종류다. "200회 중 3회 실패했습니다"라고 보고하면 개발자는 대개 "환경 문제 아닐까요"라고 답하고, 재현 절차가 없으니 논쟁이 끝나지 않는다. ASan 리포트 한 장이 이 대화를 끝낸다 — 해제 위치와 접근 위치의 파일·줄 번호가 함께 있어 반박할 여지가 없다.
누수도 같다. "메모리가 새는 것 같습니다"로는 아무 일도 일어나지 않지만, "동일 시나리오 1,000회 반복 후 힙 덤프에서 SessionContext 인스턴스가 1,000개 유지되고 GC 후에도 감소하지 않습니다. 참조 경로는 static SESSION_REGISTRY 입니다"는 그날 수정된다. 도구는 재현 가능한 증거 를 만드는 장치다. 조직 차원에서는 게이트 설계가 남는다 — 경고 1,400건을 그대로 두고 "품질 게이트를 도입했다"는 조직과, 베이스라인·신규 위반 0으로 리뷰 부담을 상수로 만든 조직의 차이는 1년 뒤 부채 곡선에서 드러난다.