1. 리포트 빠르게 훑기
AddressSanitizer나 UndefinedBehaviorSanitizer 출력을 처음 보면 터미널을 닫고 밭에 가고 싶은 충동이 듭니다. 거기서는 적어도 'out of range' 같은 문구가 사람이 이해하기 쉬운 형태로 나오니까요. 하지만 sanitizer 리포트가 길게 나오는 것은 일부러 더 많은 문맥을 제공해서 여러분이 근본 원인을 찾게끔 하기 위함입니다. 단순히 «폭발한 곳»만 보는 것이 아니라요.
이 강의의 핵심은 매우 실용적입니다: sanitizer 리포트를 위에서 아래로 읽는 것이 아니라 지도처럼 읽어야 합니다. 이 지도에는 몇 가지 기준점이 있습니다: 문제 유형, 좌표(파일/라인), 호출 스택, 그리고 때로는 추가 진단 정보(예: 얼마나 많은 바이트를 넘겼는지). 이러한 기준점을 빠르게 찾아내면 가장 긴 리포트도 조사 시나리오로 단축됩니다: «무슨 일이 일어났나 → 어디서 감지되었나 → 누가 잘못된 데이터를 전달했나 → 왜 전달했나».
처음 10초 동안 눈으로 찾아야 할 것
좋은 sanitizer 리포트는, GCC/Clang이나 OS가 달라도, 거의 항상 동일한 의미 블록을 포함합니다. 여러분의 임무는 이 블록들을 도로 표지판처럼 인식하는 법을 배우는 것입니다: «주의, 회전», «막다른길», «여기서 되돌아가도 됨».
아래는 초심자에게 보통 중요한 항목들을 보기 좋게 정리한 '지도'(표)입니다. 모든 필드를 설명하는 매뉴얼은 아니고, 어디를 봐야 하는지 빠르게 파악하게 해주는 '표지'들입니다.
| 리포트 블록 | 보통 어떻게 보이는가 | 이것이 의미하는 바 |
|---|---|---|
| 문제 유형 | |
이는 '질병의 이름'입니다. 여기서부터 시작하세요: 무엇이 위반되었나. |
| 발견 지점 | |
sanitizer가 문제를 감지한 위치입니다. 항상 잘못된 인덱스/제수를 만든 위치와 동일하지는 않습니다. |
| Stack trace | |
여기는 '어떻게 여기까지 왔는가'입니다. 근본 원인을 찾는 데 거의 항상 도움이 됩니다. |
| 추가 세부사항 (ASan) | |
메모리 접근의 규모와 유형에 대한 힌트입니다. 유용하지만 보조적인 정보입니다. |
| 런타임/시스템 프레임 | libc, startup, sanitizer runtime에 대한 참조 | 초반에는 무시해도 되는 경우가 많습니다. 근본 원인을 잡을 때까지는요. |
이 강의의 가장 중요한 규칙은 지루하게 들리지만 많은 시간을 절약해 줍니다: 소스 파일을 가리키는 스택의 첫 번째 줄을 찾고, 그 줄에서부터 의미를 따라가며 위로 풀어나가세요. 리포트를 '모두 이해'할 필요는 없습니다. 코드 줄 하나와 그 줄이 깨지는 조건을 이해할 만큼만 파악하면 됩니다.
리포트 읽기 알고리즘
거의 모든 진단은(그리고 sanitizer는 매우 정직합니다) 반복 가능한 알고리즘을 적용하면 이득을 봅니다. 매번 탐정처럼 즉흥적으로 할 필요 없습니다. 간단한 절차를 따르면 마음도 편합니다.
일종의 '정신적 컨베이어'를 정리해봅시다. 짧고 거의 항상 작동합니다:
flowchart TD
A[문제 유형 찾기] --> B[첫 번째 .cpp/.hpp 참조 찾기]
B --> C[코드 라인 보기: 어떤 위험한 작업을 하는가]
C --> D[스택을 타고 올라가기: 누가 인자를 전달했나]
D --> E[불변식 정립: 무엇이 참이어야 하는가]
E --> F[근본 원인 찾기: 어디서 불변식이 깨졌나]
'불변식 정립' 단계에 유의하세요. 이것은 생각보다 중요합니다. sanitizer는 '여기서 깨졌다'고 알려주지만, 여러분이 보장해야 할 조건이 무엇인지는 항상 알려주지 않습니다. 그 조건은 보통 사람 말로는 간단합니다: '인덱스는 size보다 작아야 한다', '제수는 0이 아니어야 한다', '시프트는 허용 범위 내여야 한다' 등등입니다. 여러분의 일은 이 조건을 검사나 함수 설계로 바꾸는 것입니다.
3. 예제용 미니 프로젝트
리포트가 추상적이지 않고 '여러분 코드 관점'이 되게 하려면 확장하기 쉬운 작은 프로젝트가 필요합니다. 콘솔 프로그램으로 정수 집합을 저장하고 명령을 수행하는 간단한 프로그램으로 합시다. 아키텍처를 크게 설계할 필요는 없습니다 — 호출 체인이 중요합니다. 오류는 한 곳에서 '태어나' 다른 곳에서 '터지는' 경우가 많으니까요.
아이디어는 이렇습니다: 사용자가 한 줄에 숫자를 입력하고, 그다음 명령을 입력합니다. 명령은 간단합니다: last2avg (마지막 두 개의 평균), norm (정규화: x / sum), 그리고 exit.
먼저 골격부터 시작합니다(위험한 부분 제외):
#include <iostream>
#include <string>
#include <vector>
int main() {
std::vector<int> data{10, 20, 30};
std::cout << "data size = " << data.size() << '\n'; // data size = 3
std::cout << "Type command: last2avg / norm / exit\n";
std::string cmd;
while (std::cin >> cmd && cmd != "exit") {
std::cout << "Unknown command\n";
}
}
심심하긴 하지만 이게 '조립 지점'입니다: 이후로는 함수를 추가해서 sanitizer 리포트에 호출 체인이 나오게 할 겁니다. 스택이 항상 한 함수만 나오면 근본 원인을 찾는 법을 배우기 어렵습니다 — 단순히 실패한 줄만 찍어보게 되니까요.
4. 전형적인 시나리오 분석
ASan 예: std::vector 경계 벗어남
실제 상황: '마지막 원소'를 얻으려다 실수로 v[v.size()] 라고 쓰는 경우가 있습니다. 자연어로는 '크기가 있으니 마지막'이라고 생각할 수 있지만, C++에서 size는 '개수'이고 마지막 인덱스는 size() - 1 입니다. 이런 실수는 매우 흔하고 sanitizer는 읽기 시점에 최대한 가깝게 잡아줍니다.
명령 → 계산 → 원소 읽기 같은 함수 체인을 추가해봅시다. 위험한 줄은 기본적으로 비활성화해서 예제가 바로 터지지 않게 해두겠습니다.
#include <cstddef>
#include <vector>
int ReadAt(const std::vector<int>& v, std::size_t i) {
#if 0
return v[i]; // i가 범위를 벗어나면 잠재적인 UB
#else
return 0;
#endif
}
int LastTwoAverage(const std::vector<int>& v) {
std::size_t last = v.size(); // 실수: last는 size()-1이어야 함
int a = ReadAt(v, last); // 코드 활성화 시 여기서 '폭발'이 발생
int b = ReadAt(v, last - 1);
return (a + b) / 2;
}
이제 위험한 코드를 활성화하고 ASan으로 실행하면, 리포트의 '골격'은 대략 다음과 같이 보일 수 있습니다(단순화함):
==12345==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x...
READ of size 4 at 0x... thread T0
#0 ReadAt(std::vector<int> const&, unsigned long) main.cpp:8
#1 LastTwoAverage(std::vector<int> const&) main.cpp:15
#2 main main.cpp:42
...
여기서 강의의 핵심 연습이 시작됩니다. 모두 읽을 필요는 없습니다. 시선으로 네 가지를 확인합니다.
먼저 유형을 고정합니다: heap-buffer-overflow. 이는 '힙에서 할당된 버퍼의 경계를 넘어 읽거나 썼다'는 뜻입니다. std::vector의 메모리는 보통 힙에 있으니 인덱스 문제일 가능성이 높습니다.
다음으로 자신의 파일에 대한 첫 참조를 찾습니다. 예제에서는 ReadAt … main.cpp:8 입니다. 바로 '오! ReadAt에서 버그다!'라고 결론내리기 쉽습니다. 하지만 ReadAt는 단순히 요청받은 대로 인덱스 i를 읽었을 뿐입니다. 진짜 질문은: 왜 i가 잘못되었는가? 입니다.
여기서 스택이 중요해집니다. 다음 줄인 #1 LastTwoAverage … main.cpp:15는 누가 ReadAt을 호출했고 어떤 의미로 호출했는지를 보여줍니다. LastTwoAverage로 돌아가 보면 std::size_t last = v.size(). 이것이 근본 원인입니다: '크기'와 '마지막 인덱스'를 혼동한 것입니다. Sanitizer는 ReadAt → LastTwoAverage → main 순으로 경로를 보여줘서 이 과정을 도와줍니다.
스택을 '호출 사다리'처럼 시각화하는 것이 유용합니다:
flowchart BT
A[main] --> B[LastTwoAverage]
B --> C[ReadAt]
C --> D["operator[]가 메모리를 읽음"]
이 시점에서 불변식을 정리할 수 있습니다: «v[i]를 읽으려면 i < v.size()여야 한다». 이 불변식은 ReadAt뿐 아니라 ReadAt을 호출하는 모든 곳에 적용됩니다.
수정 방안(아이디어 수준)은 간단합니다: 마지막 인덱스는 v.size() - 1이며, 벡터가 비어있지 않아야 한다는 조건을 확인해야 합니다. 여기서는 assert를 쓰지 않지만(다음 강의에서 다룹니다) 핵심 생각은: '빈 벡터 여부 검사와 인덱스 산술을 올바르게 하라' 입니다.
UBSan 예: 0으로 나눔
ASan 이후 UBSan 리포트는 보통 더 친절하게 보입니다: 'runtime error: ...' 같은 문구로 좌표를 보여줍니다. 하지만 함정은 때때로 0으로 나누는 상황이 나누는 지점과 멀리 떨어진 곳에서 '생겨난다'는 점입니다. 스택 없이 고치려 하면 증상만 고치게 됩니다.
정규화 예를 봅시다: 합계를 구한 뒤 각 원소를 합으로 나눕니다. 합이 0이면(예: 모든 값이 0) 나누기는 문제입니다.
#include <vector>
int Sum(const std::vector<int>& v) {
int s = 0;
for (int x : v) s += x;
return s;
}
int NormalizeFirst(const std::vector<int>& v) {
int total = Sum(v);
#if 0
return v[0] / total; // total == 0일 때 UB (보통 UBSan으로 진단됨)
#else
return 0;
#endif
}
UBSan의 전형적인 '골격' 리포트는 다음과 같습니다:
main.cpp:16:17: runtime error: division by zero
#0 NormalizeFirst(std::vector<int> const&) main.cpp:16
#1 main main.cpp:45
여기는 소음이 적지만 읽는 방식은 동일합니다. 먼저 유형을 고정하세요: division by zero. 그럼 불변식은 단순합니다: '제수는 0이 아니어야 한다'.
다음으로 위치를 봅니다: NormalizeFirst. 이 함수에서 제수를 만든 곳을 찾으면: int total = Sum(v) 입니다. 이것은 '근본 원인은 데이터이거나 Sum에 있을 수 있다'는 힌트입니다.
Sum이 올바르다면(대개는 올바릅니다) 근본 원인은 '합이 0인 경우'를 고려하지 않았다는 점입니다. 이것이 많은 버그와의 중요한 차이점입니다: 근본 원인은 '잘못된 한 줄'이 아니라 '처리하지 않은 케이스'일 수 있습니다.
그래서 불변식을 말로 정리하는 것이 유용합니다: '정규화는 total != 0일 때만 가능하다'. 이렇게 말로 정리하면 어디에 검사문을 넣을지 명확해집니다: 나누기 전에 검사하세요.
«발견 지점»과 «발생 지점»
조금 더 까다로운 예를 통해 강의의 핵심을 확실히 하겠습니다. 오류는 ReadAt에서 발견되지만, 실제로는 명령 파싱에서 생겨납니다. 초심자들이 '잘못된 곳을 고치는' 전형적인 상황입니다.
예를 들어 사용자 명령 get i가 있고, 인덱스 i는 사용자로부터 옵니다. 우리는(일단) 입력 검증과 경계 검사를 일부러 생략할 것입니다 — 스택을 만들기 위한 '이야기'가 필요하니까요.
#include <cstddef>
#include <iostream>
#include <string>
#include <vector>
int ReadAt(const std::vector<int>& v, std::size_t i) {
#if 0
return v[i]; // i >= v.size()일 때 UB
#else
return 0;
#endif
}
std::size_t ParseIndex(const std::string& s) {
return static_cast<std::size_t>(std::stoi(s));
}
int ProcessGet(const std::vector<int>& v, const std::string& arg) {
std::size_t i = ParseIndex(arg);
return ReadAt(v, i);
}
ASan의 '골격' 리포트는 거의 확실히 ReadAt를 가리킬 것입니다. 예:
ERROR: AddressSanitizer: heap-buffer-overflow
#0 ReadAt(...) main.cpp:8
#1 ProcessGet(...) main.cpp:21
#2 main main.cpp:50
만약 ReadAt에서 멈춰서 ReadAt만 '개선'하려 들면, 무한 리팩터링에 빠질 수 있습니다: []를 at()로 바꾸거나 로그를 추가하는 등의 작업을 반복할 수 있죠. 때로 그런 개선이 유용하지만 근본 원인은 남아 있습니다: ParseIndex가 무엇이든 반환할 수 있고, 범위 검사를 하지 않았다는 점입니다.
그래서 스택은 '추가 라인'이 아니라 '누가 폭탄을 가져왔는가'에 대한 직접적인 답입니다.
실제 코드에서는 계약을 명시할 것입니다: ProcessGet은 i < v.size()인지 검사하거나 오류/메시지를 반환해야 합니다. 하지만 모든 에러 처리 전략을 아직 배우지 않았다 해도, '스택을 타고 올라가서 데이터가 언제 나빠졌는지 찾는 기술'만으로도 성숙한 개발의 절반 이상을 배운 것입니다.
5. 유용한 팁과 세부사항
스택 트레이스에서 '내 프레임'과 '타사 프레임' 구별하는 법
sanitizer를 처음 사용할 때 호출 스택에는 여러분의 코드뿐 아니라 표준 라이브러리, 런타임, 프로세스 진입점, 심지어 정체불명의 주소들이 섞여 나오는 경우가 많습니다. 초심자는 '아무것도 이해할 수 없으니 내 잘못이 아니다'라고 생각하기 쉽습니다. 스포일러: 거의 항상 여러분 탓입니다.
간단한 전략은 스택을 두 부분으로 나눠 생각하는 것입니다: '상위 프레임'은 보통 사고 지점에 가깝고, '하위 프레임'은 main과 프로세스 시작에 가깝습니다. 목표는 자신의 파일을 가리키는 첫 번째 줄(main.cpp, src/..., include/...)을 찾는 것입니다. 그 주변의 것들은 일단 부차적입니다.
리포트에 30줄이 있고 여러분의 코드는 #0, #1, #2 라인에서 보인다면 상황은 좋습니다: 호출 체인이 짧고 근본 원인이 가까이 있습니다.
여러분의 코드가 #7에서만 보이고 위에는 std::..., libc++, libstdc++ 같은 것들이 있다면, 보통 잘못된 조건으로 표준 라이브러리를 호출한 것입니다. 그럴 때는 여러분의 파일이 '닻(anchor)'이며 거기서부터 스택을 타고 올라가 어떤 인자를 넘겼고 어떤 조건을 보장했어야 하는지 질문하세요.
ASan 세부사항 빠른 메모
ASan은 때때로 많은 '마이크로 디테일'을 보여줍니다: 읽기 크기, 주소, '0 bytes to the right', shadow bytes 등. 초심자는 고대 유물을 발굴하듯 바이트 단위까지 파고들기 쉽습니다.
절제해서 보세요: 디테일은 필요하지만 적절히만 사용하세요.
'READ of size 4'는 보통 int 읽기(종종 4바이트)를 의미합니다. 'READ of size 1'이면 char나 바이트 단위 접근일 수 있습니다. 이것은 어떤 연산이 있었는지 추정하는 데 도움을 주지만, 근본 원인은 보통 스택이 알려줍니다.
'0 bytes to the right of 12-byte region' 같은 문구는 '배열 바로 다음 첫 바이트'에 접근했다는 의미인 경우가 많습니다. 예를 들어 세 원소짜리 vector<int>는 3 * 4 = 12바이트이고, 인덱스 3은 바로 경계를 벗어나는 전형적인 케이스입니다.
이런 정보는 '아, 정말로 size와 마지막 인덱스를 혼동했구나'라는 가설을 확증하는 데 유용합니다.
6. sanitizer 리포트 읽기에서 흔한 실수
실수 #1: 리포트를 소설처럼 위에서 아래로만 읽는 것.
이렇게 하면 시스템/런타임 로그에 시간을 낭비하고 '닻(anchor)'을 놓칩니다. 리포트는 지도로 읽으세요: 먼저 문제 유형, 그다음 자신의 코드에 대한 첫 참조, 그리고 스택. 필요하면 바이트/주소 같은 세부 정보를 봅니다.
실수 #2: sanitizer가 가리킨 줄만 고치고 스택을 타고 올라가지 않는 것.
sanitizer는 대개 '발견 지점'을 보여줍니다. 하지만 근본 원인은 위쪽에 있는 경우가 많습니다: 인덱스가 계산된 곳, 제수가 만들어진 곳, 크기가 검증되지 않은 곳 등. #0 → #1 → #2 체인을 따라가지 않으면 증상만 고치게 됩니다.
실수 #3: 'runtime error: …' 같은 문구를 그냥 경고로 무시하는 것.
UBSan은 부드럽게 말하는 편이지만 의미는 강력합니다: 언어가 정상 동작을 보장하지 않는 행동이 일어났습니다. 이는 '가끔 발생하는 일'이 아니라 '여기선 정상적으로 계속할 수 없다'는 신호입니다.
실수 #4: 표준 라이브러리 프레임을 보고 'STL이 망가졌다'고 겁먹는 것.
STL 자체가 망가지는 경우는 드뭅니다. 대신 잘못된 조건으로 STL을 호출하는 경우가 흔합니다. 스택에 여러분의 코드가 적어도 한 번 보인다면 그 지점부터 시작해 '내가 어떤 계약을 어겼나?'를 물어보세요.
실수 #5: '벡터가 비어있다'와 '벡터가 작다'를 혼동하는 것.
if (!v.empty()) 검사는 v[0]을 보호하지만 '마지막 두 원소'를 원할 때는 충분하지 않습니다. '마지막 두 개'를 위해서는 v.size() >= 2라는 조건이 필요합니다. Sanitizer는 보통 경계 초과를 보고하지만 근본 원인은 잘못된 올바름 조건입니다.
GO TO FULL VERSION