C++ 반복자 무효화: vector iterators incompatible 크래시 원인과 해결
이 글의 핵심
루프를 돌며 요소를 지우거나 push_back으로 추가하는 코드는 디버그 빌드에서는 단언 실패로, 릴리스 빌드에서는 불규칙한 크래시로 나타납니다. erase의 반환값을 이용하는 방법, erase-remove 관용구, 지연 삭제 패턴, 멀티스레드에서 반복자를 공유할 때의 위험을 짚어 컨테이너를 수정하면서도 안전하게 순회하는 방법을 제공합니다.
“Debug Assertion Failed: vector iterators incompatible"
"루프 중에 삭제했더니 크래시가 나요”
C++ STL을 사용하다 보면 “vector iterators incompatible”, “list iterator not dereferencable”, “map/set iterator not incrementable” 같은 에러 메시지를 만나게 됩니다. 이는 모두 반복자 무효화(Iterator Invalidation)로 인한 문제입니다.
반복자 무효화란 컨테이너를 수정(삽입, 삭제, 재할당)할 때 기존 반복자가 더 이상 유효하지 않게 되는 현상입니다. 무효화된 반복자를 사용하면 미정의 동작(Undefined Behavior)이 발생하며, 대부분 크래시로 이어집니다.
이 글에서는 vector·list·map·set·unordered_map별 무효화 규칙과 크래시로 이어지는 10가지 패턴을 실제 코드로 살펴보고, erase 루프와 erase-remove, 지연 삭제, 인덱스 기반 순회 같은 안전한 작성법과 AddressSanitizer·iterator debugging을 이용한 디버깅 방법을 다룹니다.
반복자가 무효화된다는 것의 의미
무효화된 반복자란
반복자(Iterator)는 컨테이너의 원소를 가리키는 객체입니다. 컨테이너를 수정하면 내부 메모리 레이아웃이 변경되어, 기존 반복자가 더 이상 유효한 위치를 가리키지 않게 됩니다.
std::vector<int> vec = {1, 2, 3, 4, 5};
auto it = vec.begin(); // 첫 번째 원소를 가리킴
vec.push_back(6); // 재할당 발생 가능 → it 무효화!
std::cout << *it << '\n'; // ❌ 미정의 동작 (크래시 가능)
주의사항: 릴리스 빌드에서는 “우연히” 동작하는 것처럼 보일 수 있어, 디버그·ASan으로 조기에 잡는 것이 안전합니다.
재할당과 노드 삭제가 무효화를 부르는 이유
flowchart TB
subgraph Before[수정 전]
M1[메모리 주소 0x1000]
I1[iterator → 0x1000]
end
subgraph After["수정 후 (재할당)"]
M2[메모리 주소 0x2000]
I2[iterator → 0x1000 ❌]
end
Before -->|push_back으로 재할당| After
I2 -.->|댕글링 포인터| Crash[크래시]
vector는 용량이 부족하면 새 메모리를 할당하고 기존 원소를 복사/이동합니다. 이때 기존 반복자는 옛날 메모리 주소를 가리키므로 무효화됩니다.
무효화된 반복자를 쓰면 생기는 일
- 크래시 (가장 흔함): Segmentation Fault, Access Violation
- 쓰레기 값 읽기: 옛날 메모리에 다른 데이터가 있을 수 있음
- 간헐적 버그: 메모리가 아직 해제되지 않아 “운 좋게” 동작하다가 나중에 크래시
- 디버그 빌드 에러: “vector iterators incompatible”, “iterator not dereferencable”
컨테이너별 무효화 규칙
vector 무효화 규칙
| 연산 | 반복자 무효화 | 참조/포인터 무효화 |
|---|---|---|
push_back/emplace_back | 재할당 시 모든 반복자, 아니면 end()만 무효화 | 재할당 시 모두 무효화, 아니면 유효 |
insert | 재할당 시 모두, 아니면 삽입 위치 이후(end() 포함) 무효화 | 재할당 시 모두, 아니면 삽입 위치 이후 무효화 |
erase | 삭제 위치 이후(end() 포함) 무효화 | 삭제 위치 이후 무효화 |
clear | 모든 반복자 무효화 | 모든 참조/포인터 무효화 |
reserve | 실제로 재할당되면 모두 무효화 | 실제로 재할당되면 모두 무효화 |
resize | 늘릴 때 재할당되면 모두, 줄일 때는 지워진 원소와 end() | 늘릴 때 재할당되면 모두, 줄일 때는 지워진 원소 |
operator[] | 무효화 안 됨 | 무효화 안 됨 |
capacity()보다 크기가 커지는 순간에만 재할당이 일어나므로, 미리 reserve해 둔 범위 안에서의 push_back은 end() 외의 반복자를 무효화하지 않습니다.
vector 재할당 메커니즘 (push_back 내부 동작):
vector 재할당 메커니즘 (push_back 내부 동작):
std::vector<int> vec = {1, 2, 3}; // capacity = 3, size = 3
auto it = vec.begin(); // it → 0x1000 (첫 번째 원소)
vec.push_back(4); // 재할당 발생!
내부 동작:
1. capacity 확인:
size == capacity?
→ 3 == 3 → true
→ 재할당 필요!
2. 새 capacity 계산:
대부분 구현: new_capacity = capacity * 1.5 ~ 2
예시 (GCC libstdc++):
new_capacity = capacity * 2
→ 3 * 2 = 6
3. 새 메모리 할당:
int* new_data = allocator.allocate(6)
→ new_data = 0x2000 (새 주소!)
4. 기존 원소 이동/복사:
for (size_t i = 0; i < size; ++i):
new_data[i] 위치에 원소를 이동 생성
(이동 생성자가 noexcept가 아니고 복사가 가능하면 복사)
메모리 상태:
기존 (0x1000): [1, 2, 3]
새로운 (0x2000): [1, 2, 3, _, _, _]
5. 새 원소 추가:
new_data[3] = 4
→ [1, 2, 3, 4, _, _]
6. 기존 메모리 해제:
allocator.deallocate(data, 3)
→ 0x1000 주소 해제!
7. 포인터 업데이트:
data = new_data (0x2000)
size = 4
capacity = 6
결과:
vec.begin() → 0x2000 (새 주소)
it → 0x1000 (옛날 주소, 이미 해제됨!)
→ it는 댕글링 포인터!
*it 접근 시:
→ 해제된 메모리 접근
→ Segmentation Fault / Access Violation
시각화:
push_back 전:
┌─────────────────┐
│ 0x1000 │
│ [1] [2] [3] │ ← it가 가리킴
│ capacity: 3 │
└─────────────────┘
push_back 후:
┌─────────────────┐
│ 0x1000 (해제됨) │ ← it는 여전히 여기를 가리킴!
│ ??? │
└─────────────────┘
┌─────────────────────────┐
│ 0x2000 │
│ [1] [2] [3] [4] _ _ │ ← vec.begin()이 가리킴
│ capacity: 6 │
└─────────────────────────┘
재할당 회피:
vec.reserve(100); // 미리 capacity 확보
vec.push_back(4); // 재할당 없음!
재할당 감지:
void* old_ptr = vec.data();
vec.push_back(4);
if (vec.data() != old_ptr) {
// 재할당 발생!
}
성능 영향:
재할당 있을 때:
- 새 메모리 할당 (원소 생성은 하지 않음)
- 원소 이동/복사: O(size)
- 기존 메모리 해제
→ push_back: O(n) (최악)
재할당 없을 때:
- 원소 추가: O(1)
→ push_back: O(1) (최선)
Amortized 분석:
n번 push_back:
재할당: log n번 (1→2→4→8→...)
총 복사: 2 + 4 + 8 + ... + n = 2n
→ Amortized O(1)
핵심: vector는 재할당이 일어나면 모든 것이 무효화됩니다.
list 무효화 규칙
| 연산 | 반복자 무효화 | 참조/포인터 무효화 |
|---|---|---|
push_back/front | 무효화 안 됨 | 무효화 안 됨 |
insert | 무효화 안 됨 | 무효화 안 됨 |
erase | 삭제된 원소만 무효화 | 삭제된 원소만 무효화 |
clear | 모든 반복자 무효화 | 모든 참조/포인터 무효화 |
핵심: list는 노드 기반이므로 삽입은 무효화를 일으키지 않습니다. 삭제된 노드만 무효화됩니다.
map/set 무효화 규칙
| 연산 | 반복자 무효화 | 참조/포인터 무효화 |
|---|---|---|
insert | 무효화 안 됨 | 무효화 안 됨 |
erase | 삭제된 원소만 무효화 | 삭제된 원소만 무효화 |
clear | 모든 반복자 무효화 | 모든 참조/포인터 무효화 |
핵심: 트리 기반이므로 삽입/삭제가 다른 노드에 영향을 주지 않습니다.
unordered_map/set 무효화 규칙
| 연산 | 반복자 무효화 | 참조/포인터 무효화 |
|---|---|---|
insert | rehash 시 모든 반복자 무효화 | 무효화 안 됨 (참조는 유효) |
erase | 삭제된 원소만 무효화 | 삭제된 원소만 무효화 |
rehash/reserve | 모든 반복자 무효화 | 참조/포인터는 유효 |
핵심: 해시 테이블 재구성 시 반복자는 무효화되지만, 참조/포인터는 유효합니다.
deque 무효화 규칙
| 연산 | 반복자 무효화 | 참조/포인터 무효화 |
|---|---|---|
push_back/front | 모든 반복자 무효화 | 참조/포인터는 유효 |
중간 insert | 모든 반복자 무효화 | 모든 참조/포인터 무효화 |
양 끝 erase(pop_front/pop_back 포함) | 지워진 원소의 반복자 (뒤쪽 삭제는 end()도) | 지워진 원소만 |
중간 erase | 모든 반복자 무효화 | 모든 참조/포인터 무효화 |
크래시를 부르는 열 가지 코드 패턴
범위 기반 for 루프 안에서 erase
가장 흔한 실수: 범위 기반 for 루프 안에서 컨테이너를 수정합니다.
// ❌ 크래시 코드
std::vector<int> vec = {1, 2, 3, 4, 5};
for (auto& x : vec) {
if (x % 2 == 0) {
vec.erase(std::remove(vec.begin(), vec.end(), x), vec.end());
// 범위 기반 for의 내부 반복자가 무효화됨!
}
}
에러 메시지 (Visual Studio Debug):
Debug Assertion Failed!
Expression: vector iterators incompatible
해결법 1: 일반 for문 + erase 반환값 사용
// ✅ 안전한 코드
std::vector<int> vec = {1, 2, 3, 4, 5};
for (auto it = vec.begin(); it != vec.end(); ) {
if (*it % 2 == 0) {
it = vec.erase(it); // erase는 다음 유효한 반복자를 반환
} else {
++it;
}
}
// 결과: {1, 3, 5}
해결법 2: erase-remove 관용구
// ✅ 더 효율적인 방법
std::vector<int> vec = {1, 2, 3, 4, 5};
vec.erase(
std::remove_if(vec.begin(), vec.end(),
[](int x) { return x % 2 == 0; }),
vec.end()
);
// 결과: {1, 3, 5}
범위 기반 for 루프 안에서 push_back
// ❌ 크래시 코드
std::vector<int> vec = {1, 2, 3};
for (auto x : vec) {
vec.push_back(x * 2); // 재할당 발생 → 반복자 무효화!
}
문제: 범위 기반 for는 begin()과 end()를 루프 시작 시 한 번만 평가합니다. push_back으로 재할당이 일어나면 이 반복자들이 무효화됩니다.
해결법: 크기를 미리 저장
// ✅ 안전한 코드
std::vector<int> vec = {1, 2, 3};
size_t original_size = vec.size();
for (size_t i = 0; i < original_size; ++i) {
vec.push_back(vec[i] * 2);
}
// 결과: {1, 2, 3, 2, 4, 6}
insert 뒤 기존 반복자 사용
// ❌ 크래시 코드
std::vector<int> vec = {1, 2, 3, 4, 5};
auto it = vec.begin() + 2; // 3을 가리킴
vec.insert(vec.begin(), 0); // 맨 앞에 삽입 → it 무효화!
std::cout << *it << '\n'; // ❌ 미정의 동작
해결법: insert 후 반복자 재설정
// ✅ 안전한 코드
std::vector<int> vec = {1, 2, 3, 4, 5};
auto it = vec.begin() + 2;
vec.insert(vec.begin(), 0);
it = vec.begin() + 3; // 재계산 (0이 추가되어 인덱스 +1)
std::cout << *it << '\n'; // 3
reserve를 믿고 반복자를 재사용
// ❌ 크래시 코드
std::vector<int> vec = {1, 2, 3};
auto it = vec.begin();
vec.reserve(100); // 재할당 발생 → it 무효화!
std::cout << *it << '\n'; // ❌ 미정의 동작
해결법: reserve를 먼저 호출
// ✅ 안전한 코드
std::vector<int> vec = {1, 2, 3};
vec.reserve(100); // 미리 할당
auto it = vec.begin(); // 이제 반복자 생성
std::cout << *it << '\n'; // 1
map/set을 순회하며 erase
// ❌ 크래시 코드
std::map<int, std::string> m = {{1, "a"}, {2, "b"}, {3, "c"}};
for (auto it = m.begin(); it != m.end(); ++it) {
if (it->second == "b") {
m.erase(it); // it 무효화!
// ++it가 무효화된 반복자에 접근 → 크래시
}
}
해결법 1: erase 반환값 사용
// ✅ 안전한 코드 (C++11 이상)
std::map<int, std::string> m = {{1, "a"}, {2, "b"}, {3, "c"}};
for (auto it = m.begin(); it != m.end(); ) {
if (it->second == "b") {
it = m.erase(it); // C++11: erase가 다음 반복자 반환
} else {
++it;
}
}
해결법 2: 후위 증가 (C++03 스타일)
// ✅ C++03 호환 코드
for (auto it = m.begin(); it != m.end(); ) {
if (it->second == "b") {
m.erase(it++); // 후위 증가: 삭제 전에 다음으로 이동
} else {
++it;
}
}
중첩 루프 안에서 erase
// ❌ 크래시 코드
std::vector<std::vector<int>> matrix = {{1, 2}, {3, 4}, {5, 6}};
for (auto& row : matrix) {
for (auto it = row.begin(); it != row.end(); ++it) {
if (*it % 2 == 0) {
row.erase(it); // it 무효화, ++it가 크래시 유발
}
}
}
해결법: 내부 루프도 erase 반환값 사용
// ✅ 안전한 코드
std::vector<std::vector<int>> matrix = {{1, 2}, {3, 4}, {5, 6}};
for (auto& row : matrix) {
for (auto it = row.begin(); it != row.end(); ) {
if (*it % 2 == 0) {
it = row.erase(it);
} else {
++it;
}
}
}
저장해 둔 반복자로 수정 후 접근
// ❌ 크래시 코드
std::vector<int> vec = {1, 2, 3, 4, 5};
auto saved_it = vec.begin() + 2; // 3을 가리킴
// 나중에 다른 코드에서...
vec.push_back(6); // 재할당 → saved_it 무효화
std::cout << *saved_it << '\n'; // ❌ 크래시
해결법: 인덱스로 저장
// ✅ 안전한 코드
std::vector<int> vec = {1, 2, 3, 4, 5};
size_t saved_index = 2;
vec.push_back(6);
std::cout << vec[saved_index] << '\n'; // 3
여러 스레드가 같은 컨테이너를 순회·수정
// ❌ 크래시 코드
std::vector<int> vec = {1, 2, 3, 4, 5};
std::thread t1([&]() {
for (auto& x : vec) {
std::cout << x << '\n';
}
});
std::thread t2([&]() {
vec.push_back(6); // t1의 반복자 무효화!
});
t1.join();
t2.join();
해결법: 뮤텍스로 보호
// ✅ 안전한 코드
std::vector<int> vec = {1, 2, 3, 4, 5};
std::mutex mtx;
std::thread t1([&]() {
std::lock_guard<std::mutex> lock(mtx);
for (auto& x : vec) {
std::cout << x << '\n';
}
});
std::thread t2([&]() {
std::lock_guard<std::mutex> lock(mtx);
vec.push_back(6);
});
t1.join();
t2.join();
반복자를 넘긴 함수 안에서 컨테이너 수정
// ❌ 크래시 코드
void processElement(std::vector<int>& vec, std::vector<int>::iterator it) {
vec.push_back(99); // 재할당 → it 무효화!
std::cout << *it << '\n'; // ❌ 크래시
}
int main() {
std::vector<int> vec = {1, 2, 3};
auto it = vec.begin();
processElement(vec, it);
}
해결법: 인덱스 전달
// ✅ 안전한 코드
void processElement(std::vector<int>& vec, size_t index) {
vec.push_back(99);
std::cout << vec[index] << '\n'; // 안전
}
int main() {
std::vector<int> vec = {1, 2, 3};
processElement(vec, 0);
}
end() 반복자를 저장해 두고 재사용
// ❌ 위험한 코드
std::vector<int> vec = {1, 2, 3};
auto end_it = vec.end(); // end 반복자 저장
vec.push_back(4); // end_it 무효화!
for (auto it = vec.begin(); it != end_it; ++it) { // ❌ 무효화된 end_it 사용
std::cout << *it << '\n';
}
해결법: end()를 매번 호출
// ✅ 안전한 코드
std::vector<int> vec = {1, 2, 3};
vec.push_back(4);
for (auto it = vec.begin(); it != vec.end(); ++it) { // 매번 end() 호출
std::cout << *it << '\n';
}
무효화를 피하는 안전한 순회·삭제 방법
erase-remove 관용구 (권장)
가장 효율적이고 안전한 방법입니다.
#include <algorithm>
#include <vector>
std::vector<int> vec = {1, 2, 3, 4, 5, 6};
// 짝수 제거
vec.erase(
std::remove_if(vec.begin(), vec.end(),
[](int x) { return x % 2 == 0; }),
vec.end()
);
// 결과: {1, 3, 5}
작동 원리는 다음과 같습니다. remove_if는 남길 원소들을 앞쪽으로 이동시켜 채우고, 남길 원소들의 새 끝을 반환합니다. 제거 대상이 뒤로 “밀려나는” 것은 아니며, 반환된 위치 이후의 원소들은 이동된 뒤의 유효하지만 지정되지 않은(valid but unspecified) 상태입니다. 그래서 반드시 erase로 그 구간을 잘라 내야 합니다. 원소를 한 번씩만 옮기므로 O(n)이고, 원소마다 erase를 호출하는 루프는 뒤쪽 원소를 매번 당겨야 해서 최악 O(n²)이 됩니다. C++20부터는 같은 일을 std::erase_if(vec, pred) 한 줄로 할 수 있습니다.
지연 삭제 (Deferred Deletion)
게임 엔진, 이벤트 시스템에서 자주 사용합니다.
#include <vector>
#include <algorithm>
struct Entity {
int id;
bool dead = false;
};
std::vector<Entity> entities = {{1}, {2}, {3}, {4}, {5}};
// 1단계: 삭제 대상 마킹
for (auto& e : entities) {
if (e.id % 2 == 0) {
e.dead = true;
}
}
// 2단계: 일괄 삭제 (순회 종료 후)
entities.erase(
std::remove_if(entities.begin(), entities.end(),
[](const Entity& e) { return e.dead; }),
entities.end()
);
순회하는 동안에는 플래그만 바꾸고 컨테이너 구조는 건드리지 않으므로, 업데이트 로직이 아무리 복잡해도 반복자가 무효화될 일이 없습니다. 삭제는 순회가 끝난 뒤 한 번에 처리합니다.
인덱스 기반 순회
// ✅ 안전한 코드
std::vector<int> vec = {1, 2, 3, 4, 5};
for (size_t i = 0; i < vec.size(); ) {
if (vec[i] % 2 == 0) {
vec.erase(vec.begin() + i); // i는 무효화되지 않음
// size()가 줄어들므로 i는 증가하지 않음
} else {
++i;
}
}
주의: erase는 O(n)이므로 이 패턴은 O(n²)입니다. 대량 삭제는 erase-remove가 더 효율적입니다.
인덱스도 항상 안전한 것은 아닙니다. 인덱스는 재할당에는 영향을 받지 않지만, 앞쪽 원소를 지우면 뒤 원소들이 당겨지면서 같은 인덱스가 다른 원소를 가리키게 됩니다.
std::vector<int> vec = {1, 2, 3, 4, 5};
size_t index = 2; // 3을 가리킴
vec.erase(vec.begin()); // 1 삭제 → 뒤 원소가 앞으로 이동
std::cout << vec[index] << '\n'; // 4 (원래는 3이었음)
역방향 순회 (삭제 시)
// ✅ 안전한 코드 (뒤에서부터 삭제)
std::vector<int> vec = {1, 2, 3, 4, 5};
for (auto it = vec.rbegin(); it != vec.rend(); ) {
if (*it % 2 == 0) {
// reverse_iterator를 base()로 변환 후 erase
it = std::reverse_iterator(vec.erase((it + 1).base()));
} else {
++it;
}
}
주의: reverse_iterator의 base()는 다음 원소를 가리키므로 (it + 1).base()를 사용합니다. std::reverse_iterator(...)처럼 템플릿 인자를 생략하는 표기는 C++17의 클래스 템플릿 인자 추론(CTAD)이 필요합니다.
복사본으로 순회
// ✅ 안전한 코드 (원본 수정 가능)
std::vector<int> vec = {1, 2, 3, 4, 5};
std::vector<int> copy = vec;
for (auto x : copy) {
if (x % 2 == 0) {
vec.erase(std::remove(vec.begin(), vec.end(), x), vec.end());
}
}
단점: 메모리 복사 비용이 있으므로 대량 데이터에는 비효율적입니다.
조건부 복사 (새 컨테이너에 담기)
// ✅ 안전하고 효율적
std::vector<int> vec = {1, 2, 3, 4, 5};
std::vector<int> result;
std::copy_if(vec.begin(), vec.end(), std::back_inserter(result),
[](int x) { return x % 2 != 0; });
vec = std::move(result); // 이동으로 효율적
// 결과: {1, 3, 5}
list에서 안전한 erase
// ✅ list는 삭제된 원소만 무효화
std::list<int> lst = {1, 2, 3, 4, 5};
for (auto it = lst.begin(); it != lst.end(); ) {
if (*it % 2 == 0) {
it = lst.erase(it); // 다음 반복자 반환
} else {
++it;
}
}
핵심: list는 노드 기반이므로 삭제가 다른 노드에 영향을 주지 않습니다.
map에서 조건부 삭제
// ✅ 안전한 map erase
std::map<int, std::string> m = {{1, "a"}, {2, "b"}, {3, "c"}, {4, "d"}};
for (auto it = m.begin(); it != m.end(); ) {
if (it->first % 2 == 0) {
it = m.erase(it);
} else {
++it;
}
}
// 결과: {1: "a", 3: "c"}
unordered_map rehash 주의
// ❌ 위험한 코드
std::unordered_map<int, std::string> m = {{1, "a"}, {2, "b"}};
auto it = m.find(1);
m.insert({3, "c"}); // rehash 발생 가능 → it 무효화!
std::cout << it->second << '\n'; // ❌ 무효화될 수 있음
해결법: reserve로 rehash 방지
// ✅ 안전한 코드
std::unordered_map<int, std::string> m;
m.reserve(100); // 원소 100개까지는 rehash가 일어나지 않음
m.insert({1, "a"});
auto it = m.find(1);
m.insert({2, "b"}); // rehash 없음 → it 유효
reserve(n)은 max_load_factor()를 고려해 버킷을 확보하므로, 크기가 n을 넘기 전까지는 삽입이 rehash를 일으키지 않습니다. 참고로 rehash가 일어나도 원소에 대한 참조와 포인터는 유효하고, 무효화되는 것은 반복자뿐입니다.
참조 저장 후 재할당
// ❌ 크래시 코드
std::vector<int> vec = {1, 2, 3};
int& ref = vec[0]; // 첫 번째 원소 참조
vec.push_back(4); // 재할당 → ref 무효화!
std::cout << ref << '\n'; // ❌ 댕글링 참조
해결법: reserve 또는 인덱스 사용
// ✅ 해결법 1: reserve
std::vector<int> vec = {1, 2, 3};
vec.reserve(10);
int& ref = vec[0];
vec.push_back(4); // 재할당 없음 → ref 유효
std::cout << ref << '\n'; // 1
// ✅ 해결법 2: 인덱스 사용
size_t index = 0;
vec.push_back(5);
std::cout << vec[index] << '\n'; // 1
Iterator Debugging·디버그 모드·ASan으로 잡아내기
Visual Studio Iterator Debugging
Debug 빌드에서는 자동으로 iterator debugging이 활성화됩니다.
// Debug 빌드에서 실행 시
std::vector<int> vec = {1, 2, 3};
auto it = vec.begin();
vec.push_back(4); // 재할당
std::cout << *it << '\n';
// Debug Assertion Failed!
// Expression: can't dereference invalidated vector iterator
설정 확인 (Visual Studio):
프로젝트 속성 → C/C++ → 전처리기 → 전처리기 정의
_ITERATOR_DEBUG_LEVEL=2 (Debug 빌드 기본값)
AddressSanitizer (ASan)
런타임에 메모리 오류를 감지합니다.
# Visual Studio 2019 이상
# 프로젝트 속성 → C/C++ → 일반 → Address Sanitizer 사용 → 예
// ASan이 감지하는 예제
std::vector<int> vec = {1, 2, 3};
auto it = vec.begin();
vec.push_back(4); // 재할당
std::cout << *it << '\n';
// ==12345==ERROR: AddressSanitizer: heap-use-after-free
libstdc++ 디버그 모드 (GCC)
GCC에서는 -D_GLIBCXX_DEBUG로 빌드하면 표준 컨테이너가 반복자 상태를 추적하는 디버그 버전으로 바뀌어, 무효화된(singular) 반복자 사용을 즉시 잡아 줍니다. 컨테이너의 메모리 레이아웃이 바뀌므로 컨테이너를 주고받는 코드(링크하는 라이브러리 포함)를 모두 같은 설정으로 빌드해야 합니다.
g++ -std=c++17 -g -D_GLIBCXX_DEBUG main.cpp
GDB/LLDB 디버깅
# GDB에서 반복자 내용 확인
(gdb) p it
$1 = {_M_current = 0x12345678}
# 메모리 주소 확인
(gdb) p &vec[0]
$2 = (int *) 0x87654321 # 주소가 다르면 무효화됨
컴파일러 경고(-Wall -Wextra)는 반복자 무효화를 거의 잡지 못합니다. 무효화 여부는 실행 경로에 따라 달라지므로, 런타임 검사(MSVC iterator debugging, _GLIBCXX_DEBUG, ASan)가 주된 수단입니다.
MSVC·GCC·Clang이 내는 에러 메시지
Visual Studio (MSVC)
Debug Assertion Failed!
Program: C:\path\to\program.exe
File: C:\Program Files\Microsoft Visual Studio\...\vector
Line: 1234
Expression: vector iterators incompatible
For information on how your program can cause an assertion
failure, see the Visual C++ documentation on asserts.
GCC (libstdc++, -D_GLIBCXX_DEBUG)
.../debug/safe_iterator.h:...
Error: attempt to dereference a singular iterator.
Objects involved in the operation:
iterator "this" @ 0x7ffc12345678 {
type = __gnu_debug::_Safe_iterator<...std::vector<int>...>;
state = singular;
}
_GLIBCXX_DEBUG 없이 빌드하면 이런 메시지는 나오지 않고, 해제된 메모리를 그냥 읽거나 크래시합니다.
Clang (libc++)
libc++는 예전에 _LIBCPP_DEBUG로 반복자 추적 기능을 제공했지만 LLVM 15 무렵 제거되었습니다. 지금의 하드닝 모드(_LIBCPP_HARDENING_MODE)는 범위 검사 같은 일부 단언을 제공할 뿐 반복자 무효화를 추적하지는 않으므로, Clang에서는 ASan을 쓰는 편이 확실합니다.
AddressSanitizer 출력
==12345==ERROR: AddressSanitizer: heap-use-after-free on address 0x60300000eff0
READ of size 4 at 0x60300000eff0 thread T0
#0 0x4a2b3c in main example.cpp:15
#1 0x7f8b2c3d1b96 in __libc_start_main
0x60300000eff0 is located 0 bytes inside of 12-byte region [0x60300000eff0,0x60300000effc)
freed by thread T0 here:
#0 0x4a1234 in operator delete(void*)
#1 0x4a2abc in std::vector<int>::push_back example.cpp:12
반복자 카테고리와 반복자 안정성
반복자 카테고리별 특성
// Input Iterator: 한 번만 순회 가능
std::istream_iterator<int> in_it(std::cin);
// 컨테이너 수정과 무관 (스트림 기반)
// Forward Iterator: 여러 번 순회 가능
std::forward_list<int> flist = {1, 2, 3};
auto it = flist.begin();
flist.push_front(0); // it는 여전히 1을 가리킴 (유효)
// Bidirectional Iterator: 양방향 이동
std::list<int> lst = {1, 2, 3};
auto it2 = lst.begin();
lst.push_back(4); // it2는 여전히 1을 가리킴 (유효)
// Random Access Iterator: 임의 접근
std::vector<int> vec = {1, 2, 3};
auto it3 = vec.begin();
vec.push_back(4); // 재할당 시 it3 무효화!
반복자 안정성 (Iterator Stability)
안정적인 컨테이너 (삽입/삭제가 다른 원소에 영향 없음):
std::liststd::forward_liststd::mapstd::setstd::multimapstd::multiset
불안정한 컨테이너 (재할당 또는 원소 이동 발생):
std::vectorstd::dequestd::string
부분 안정 (rehash 시만 무효화):
std::unordered_mapstd::unordered_set
정적 분석으로 미리 막기
Clang-Tidy와 Clang Static Analyzer
clang-tidy의 bugprone-inaccurate-erase는 vec.erase(std::remove(...))처럼 erase에 끝 반복자를 빠뜨려 원소 하나만 지우는 실수를 잡아 줍니다. 그러나 “범위 기반 for 안에서 컨테이너 수정”이나 “erase 후 반복자 재사용”을 직접 잡는 clang-tidy 체크는 없습니다. 이런 흐름 분석은 Clang Static Analyzer의 실험적(alpha) 체커인 alpha.cplusplus.InvalidatedIterator, alpha.cplusplus.MismatchedIterator가 담당하며, alpha 단계라 오탐·미탐이 있을 수 있습니다.
# .clang-tidy
Checks: >
bugprone-inaccurate-erase,
bugprone-use-after-move
Cppcheck
cppcheck --enable=all --inconclusive main.cpp
Cppcheck에는 invalidContainer, invalidContainerLoop 같은 반복자·참조 무효화 검사가 있어 루프 안에서 컨테이너를 수정하는 명확한 경우를 경고합니다.
감지 가능한 패턴:
- 무효화된 반복자 사용 (일부 명확한 케이스)
- 범위 밖 접근
게임 엔티티·이벤트 리스너·스레드 안전 컨테이너 패턴
게임 엔티티 관리 (지연 삭제)
#include <vector>
#include <algorithm>
struct Entity {
int id;
bool pending_destroy = false;
void destroy() { pending_destroy = true; }
};
class EntityManager {
std::vector<Entity> entities_;
public:
void update() {
// 1. 업데이트 (순회 중 destroy() 호출 가능)
for (auto& e : entities_) {
if (e.id % 10 == 0) {
e.destroy(); // 마킹만
}
}
// 2. 실제 삭제 (순회 종료 후)
entities_.erase(
std::remove_if(entities_.begin(), entities_.end(),
[](const Entity& e) { return e.pending_destroy; }),
entities_.end()
);
}
};
이벤트 리스너 제거
#include <vector>
#include <algorithm>
#include <memory>
class EventSystem {
std::vector<std::weak_ptr<Listener>> listeners_;
public:
void notify() {
// 1. 복사본으로 순회 (원본 수정 가능)
auto snapshot = listeners_;
for (auto& weak : snapshot) {
if (auto listener = weak.lock()) {
listener->onEvent();
// onEvent 안에서 unsubscribe 호출해도 안전
}
}
// 2. 만료된 weak_ptr 정리
listeners_.erase(
std::remove_if(listeners_.begin(), listeners_.end(),
[](const std::weak_ptr<Listener>& w) { return w.expired(); }),
listeners_.end()
);
}
};
조건부 삭제 (필터링)
#include <vector>
#include <algorithm>
// 원본 유지하고 필터링된 결과 생성
template <typename T, typename Pred>
std::vector<T> filter(const std::vector<T>& vec, Pred pred) {
std::vector<T> result;
result.reserve(vec.size()); // 재할당 방지
std::copy_if(vec.begin(), vec.end(), std::back_inserter(result), pred);
return result;
}
// 사용 예
std::vector<int> vec = {1, 2, 3, 4, 5};
auto odds = filter(vec, [](int x) { return x % 2 != 0; });
// vec는 그대로, odds = {1, 3, 5}
안전한 map 순회 중 삭제
#include <map>
#include <vector>
std::map<int, std::string> cache;
// 방법 1: 삭제할 키 수집 후 일괄 삭제
void cleanupCache() {
std::vector<int> to_remove;
for (const auto& [key, value] : cache) {
if (isExpired(value)) {
to_remove.push_back(key);
}
}
for (int key : to_remove) {
cache.erase(key);
}
}
// 방법 2: erase 반환값 사용
void cleanupCache2() {
for (auto it = cache.begin(); it != cache.end(); ) {
if (isExpired(it->second)) {
it = cache.erase(it);
} else {
++it;
}
}
}
스레드 안전 컨테이너 수정
#include <algorithm>
#include <functional>
#include <mutex>
#include <shared_mutex>
#include <vector>
class ThreadSafeVector {
std::vector<int> data_;
mutable std::shared_mutex mtx_;
public:
void remove_if(std::function<bool(int)> pred) {
std::unique_lock lock(mtx_); // 쓰기 락
data_.erase(
std::remove_if(data_.begin(), data_.end(), pred),
data_.end()
);
}
void forEach(std::function<void(int)> func) {
std::shared_lock lock(mtx_); // 읽기 락
for (int x : data_) {
func(x);
}
}
};
안전한 순회 + 조건부 삽입
// ✅ 크기를 미리 저장
std::vector<int> vec = {1, 2, 3};
size_t original_size = vec.size();
for (size_t i = 0; i < original_size; ++i) {
if (vec[i] > 0) {
vec.push_back(vec[i] * 2); // 안전 (original_size까지만 순회)
}
}
컨테이너 교체 (Swap Trick)
// ✅ 삭제 대신 새 컨테이너로 교체
std::vector<int> vec = {1, 2, 3, 4, 5};
std::vector<int> filtered;
for (int x : vec) {
if (x % 2 != 0) {
filtered.push_back(x);
}
}
vec.swap(filtered); // 또는 vec = std::move(filtered);
게임 서버·캐시 만료·이벤트 핸들러에서 자주 보는 버그
연결 끊긴 플레이어 정리
플레이어 목록을 순회하면서 연결이 끊긴 플레이어를 바로 지우는 구조는, 끊긴 플레이어가 생기는 타이밍에 따라 간헐적으로 크래시합니다.
// ❌ 버그 코드
std::vector<Player> players;
void updatePlayers() {
for (auto& player : players) {
if (player.disconnected) {
// 다른 플레이어에게 알림
for (auto& other : players) { // ❌ 중첩 순회
other.notify(player.id);
}
// 삭제
players.erase(
std::remove_if(players.begin(), players.end(),
[&](const Player& p) { return p.id == player.id; }),
players.end()
); // ❌ 외부 루프의 반복자 무효화!
}
}
}
해결:
// ✅ 수정된 코드
void updatePlayers() {
std::vector<int> disconnected_ids;
// 1. 연결 끊긴 플레이어 수집
for (const auto& player : players) {
if (player.disconnected) {
disconnected_ids.push_back(player.id);
}
}
// 2. 알림 (순회 중 삭제 없음)
for (int id : disconnected_ids) {
for (auto& player : players) {
player.notify(id);
}
}
// 3. 일괄 삭제
players.erase(
std::remove_if(players.begin(), players.end(),
[&](const Player& p) {
return std::find(disconnected_ids.begin(),
disconnected_ids.end(),
p.id) != disconnected_ids.end();
}),
players.end()
);
}
캐시 만료 처리 중 크래시
만료된 항목을 범위 기반 for로 순회하면서 키로 지우면, 지운 노드를 가리키던 루프의 내부 반복자를 다음 단계에서 증가시키므로 크래시가 납니다.
// ❌ 버그 코드
std::map<std::string, CacheEntry> cache;
void evictExpired() {
auto now = std::chrono::steady_clock::now();
for (auto& [key, entry] : cache) {
if (entry.expiry < now) {
cache.erase(key); // ❌ 순회 중 삭제
}
}
}
해결:
// ✅ 수정된 코드
void evictExpired() {
auto now = std::chrono::steady_clock::now();
for (auto it = cache.begin(); it != cache.end(); ) {
if (it->second.expiry < now) {
it = cache.erase(it); // 반환값 사용
} else {
++it;
}
}
}
이벤트 핸들러 제거 중 크래시
이벤트 핸들러 안에서 자기 자신을 제거하면, 순회 중인 벡터가 바뀌어 크래시가 발생합니다.
// ❌ 버그 코드
class EventManager {
std::vector<std::function<void()>> handlers_;
public:
void trigger() {
for (auto& handler : handlers_) {
handler(); // 핸들러 안에서 removeHandler 호출 가능
}
}
void removeHandler(size_t index) {
handlers_.erase(handlers_.begin() + index); // ❌ 순회 중 삭제
}
};
해결:
// ✅ 수정된 코드
class EventManager {
std::vector<std::function<void()>> handlers_;
std::vector<size_t> pending_removal_;
public:
void trigger() {
// 1. 복사본으로 순회
auto snapshot = handlers_;
for (auto& handler : snapshot) {
handler();
}
// 2. 지연 삭제 처리
processPendingRemovals();
}
void removeHandler(size_t index) {
pending_removal_.push_back(index); // 마킹만
}
private:
void processPendingRemovals() {
if (pending_removal_.empty()) return;
// 내림차순 정렬 + 중복 제거 (같은 인덱스를 두 번 지우면 엉뚱한 원소가 삭제됨)
std::sort(pending_removal_.begin(), pending_removal_.end(),
std::greater<>());
pending_removal_.erase(
std::unique(pending_removal_.begin(), pending_removal_.end()),
pending_removal_.end());
for (size_t index : pending_removal_) {
if (index < handlers_.size()) {
handlers_.erase(handlers_.begin() + index);
}
}
pending_removal_.clear();
}
};
인덱스로 핸들러를 지우는 방식은 지연 삭제 사이에 핸들러가 추가·삭제되면 인덱스가 어긋날 수 있습니다. 실제 코드에서는 구독할 때 고유 ID를 발급하고 그 ID로 지우는 편이 안전합니다.
같이 보면 좋은 글
- std::vector 제대로 쓰기
- C++ Iterator 카테고리 이해하기
- C++ use-after-free: 해제 후 사용이 생기는 패턴과 ASan·gdb로 추적하기
- C++ 범위 기반 for문과 구조화된 바인딩 | 모던 C++ 반복문
- C++ 템플릿 에러 메시지 해석
- C++ 초보자가 자주 하는 실수 Top 15 | 컴파일 에러부터 런타임 크래시까지
- C++ Segmentation fault 원인 5가지와 디버깅 방법 | GDB로 추적하기
- 디버그에선 되고 릴리스에서 죽는 코드
- C++ 스택 오버플로우 에러