C++ 반복자 무효화 에러 | "vector iterators incompatible" 크래시 완벽 해결
이 글의 핵심
STL 컨테이너를 순회·삭제하는 도중 반복자가 무효화되면 미정의 동작과 크래시로 이어집니다. 이 글에서는 vector·list·map 등 컨테이너별 무효화 규칙, 안전한 erase 패턴, 범위 기반 for 주의점과 디버깅 방법을 정리합니다.
들어가며: “Debug Assertion Failed: vector iterators incompatible"
"루프 중에 삭제했더니 크래시가 나요”
C++ STL을 사용하다 보면 “vector iterators incompatible”, “list iterator not dereferencable”, “map/set iterator not incrementable” 같은 에러 메시지를 만나게 됩니다. 이는 모두 반복자 무효화(Iterator Invalidation)로 인한 문제입니다.
반복자 무효화란 컨테이너를 수정(삽입, 삭제, 재할당)할 때 기존 반복자가 더 이상 유효하지 않게 되는 현상입니다. 무효화된 반복자를 사용하면 미정의 동작(Undefined Behavior)이 발생하며, 대부분 크래시로 이어집니다.
이 글에서 다루는 것:
- 반복자 무효화가 일어나는 10가지 패턴
- 컨테이너별 무효화 규칙 (vector, list, map, set, unordered_map)
- 실전 에러 사례: 실제 크래시 코드와 해결법
- 안전한 코드 작성법: erase 루프, 조건부 삭제, 범위 기반 for
- 디버깅 방법: AddressSanitizer, iterator debugging
- 프로덕션 패턴: 지연 삭제, 인덱스 기반 순회, erase-remove
실무에서 겪은 문제
실제 프로젝트에서 이 에러를 겪으며 배운 교훈을 공유합니다.
문제 상황과 해결
대규모 C++ 프로젝트를 진행하며 반복자 무효화로 인한 크래시를 여러 번 겪었습니다. 특히 멀티스레드 환경에서는 재현이 어려워 디버깅에 수일이 걸렸습니다.
실전 경험:
- 문제: 게임 엔티티 삭제 루프에서 간헐적 크래시가 발생했습니다
- 원인:
for (auto& e : entities) { if (e.dead) entities.erase(...); }패턴 - 해결: 지연 삭제 패턴으로 변경 (삭제 대상 마킹 → 일괄 삭제)
- 교훈: 순회 중 컨테이너를 수정하지 말고, 수정이 필요하면 별도 단계로 분리
이 글이 여러분의 디버깅 시간을 절약해주길 바랍니다.
목차
1. 반복자 무효화란?
개념
반복자(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”
2. 컨테이너별 무효화 규칙
vector 무효화 규칙
| 연산 | 반복자 무효화 | 참조/포인터 무효화 |
|---|---|---|
push_back | 재할당 시 모든 반복자 무효화 | 재할당 시 모든 참조/포인터 무효화 |
insert | 삽입 위치 이후 모든 반복자 무효화 | 재할당 시 모든 참조/포인터 무효화 |
erase | 삭제 위치 이후 모든 반복자 무효화 | 삭제 위치 이후 모든 참조/포인터 무효화 |
clear | 모든 반복자 무효화 | 모든 참조/포인터 무효화 |
reserve | 재할당 시 모든 반복자 무효화 | 재할당 시 모든 참조/포인터 무효화 |
resize | 재할당 시 모든 반복자 무효화 | 재할당 시 모든 참조/포인터 무효화 |
operator[] | 무효화 안 됨 | 무효화 안 됨 |
핵심: 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 | 모든 반복자 무효화 | 중간 삭제 시 모든 참조/포인터 무효화 |
3. 자주 발생하는 10가지 에러 패턴
패턴 1: 범위 기반 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(),
{ return x % 2 == 0; }),
vec.end()
);
// 결과: {1, 3, 5}
패턴 2: push_back 중 범위 기반 for
// ❌ 크래시 코드
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}
패턴 3: 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
패턴 4: 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
패턴 5: 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;
}
}
패턴 6: 중첩 루프에서 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;
}
}
}
패턴 7: 반복자를 변수에 저장 후 수정
// ❌ 크래시 코드
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
패턴 8: 멀티스레드에서 반복자 사용
// ❌ 크래시 코드
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();
패턴 9: 함수에 반복자 전달 후 수정
// ❌ 크래시 코드
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);
}
패턴 10: 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';
}
4. 안전한 코드 작성법
패턴 1: 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(),
{ return x % 2 == 0; }),
vec.end()
);
// 결과: {1, 3, 5}
작동 원리:
remove_if가 조건을 만족하지 않는 원소들을 앞으로 이동 (복사/이동)- 제거할 원소들은 끝으로 밀림
erase로 끝 부분을 한 번에 삭제
장점:
- 반복자 무효화 걱정 없음
- O(n) 시간 복잡도 (erase 루프는 O(n²)가 될 수 있음)
- STL 알고리즘 조합으로 간결함
패턴 2: 지연 삭제 (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(),
{ return e.dead; }),
entities.end()
);
장점:
- 순회 중 컨테이너를 수정하지 않음
- 복잡한 로직에서도 안전
- 멀티스레드 환경에서도 적용 가능 (마킹은 원자적 플래그로)
패턴 3: 인덱스 기반 순회
// ✅ 안전한 코드
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가 더 효율적입니다.
패턴 4: 역방향 순회 (삭제 시)
// ✅ 안전한 코드 (뒤에서부터 삭제)
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()를 사용합니다.
패턴 5: 복사본으로 순회
// ✅ 안전한 코드 (원본 수정 가능)
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());
}
}
단점: 메모리 복사 비용이 있으므로 대량 데이터에는 비효율적입니다.
패턴 6: 조건부 복사 (새 컨테이너에 담기)
// ✅ 안전하고 효율적
std::vector<int> vec = {1, 2, 3, 4, 5};
std::vector<int> result;
std::copy_if(vec.begin(), vec.end(), std::back_inserter(result),
{ return x % 2 != 0; });
vec = std::move(result); // 이동으로 효율적
// 결과: {1, 3, 5}
패턴 7: 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는 노드 기반이므로 삭제가 다른 노드에 영향을 주지 않습니다.
패턴 8: 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"}
패턴 9: unordered_map rehash 주의
// ❌ 위험한 코드
std::unordered_map<int, std::string> m = {{1, "a"}, {2, "b"}};
auto it = m.begin();
m.insert({3, "c"}); // rehash 발생 가능 → it 무효화!
std::cout << it->second << '\n'; // ❌ 무효화될 수 있음
해결법: reserve로 rehash 방지
// ✅ 안전한 코드
std::unordered_map<int, std::string> m;
m.reserve(100); // 미리 공간 확보
auto it = m.begin();
m.insert({1, "a"}); // rehash 없음 → it 유효
패턴 10: 참조 저장 후 재할당
// ❌ 크래시 코드
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
5. 디버깅 방법
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: vector iterator + offset out of range
설정 확인 (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
Clang-Tidy 정적 분석
# .clang-tidy 설정
Checks: '-*,bugprone-use-after-move,bugprone-dangling-handle'
감지 가능한 패턴:
- 범위 기반 for 루프 내 컨테이너 수정
- erase 후 반복자 재사용
- 일부 댕글링 반복자
GDB/LLDB 디버깅
# GDB에서 반복자 내용 확인
(gdb) p it
$1 = {_M_current = 0x12345678}
# 메모리 주소 확인
(gdb) p &vec[0]
$2 = (int *) 0x87654321 # 주소가 다르면 무효화됨
컴파일 타임 경고 활성화
# GCC/Clang
g++ -std=c++17 -Wall -Wextra -Werror main.cpp
# 일부 패턴은 경고로 잡힘
# warning: loop variable 'x' may be invalidated
6. 프로덕션 패턴
패턴 1: 게임 엔티티 관리 (지연 삭제)
#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(),
{ return e.pending_destroy; }),
entities_.end()
);
}
};
패턴 2: 이벤트 리스너 제거
#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(),
{ return w.expired(); }),
listeners_.end()
);
}
};
패턴 3: 조건부 삭제 (필터링)
#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, { return x % 2 != 0; });
// vec는 그대로, odds = {1, 3, 5}
패턴 4: 안전한 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;
}
}
}
패턴 5: 스레드 안전 컨테이너 수정
#include <vector>
#include <mutex>
#include <shared_mutex>
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);
}
}
};
패턴 6: 안전한 순회 + 조건부 삽입
// ✅ 크기를 미리 저장
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까지만 순회)
}
}
패턴 7: 컨테이너 교체 (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);
7. 정리
컨테이너별 무효화 요약
| 컨테이너 | insert | erase | 재할당 |
|---|---|---|---|
| vector | 삽입점 이후 무효화 | 삭제점 이후 무효화 | 모든 반복자 무효화 |
| list | 무효화 안 됨 | 삭제된 원소만 | N/A |
| map/set | 무효화 안 됨 | 삭제된 원소만 | N/A |
| unordered_map/set | rehash 시 모든 반복자 | 삭제된 원소만 | rehash 시 모든 반복자 |
| deque | 모든 반복자 무효화 | 모든 반복자 무효화 | N/A |
안전한 패턴 요약
| 상황 | 권장 패턴 |
|---|---|
| 조건부 삭제 | erase-remove 관용구 |
| 순회 중 삭제 필요 | 지연 삭제 (마킹 → 일괄 삭제) |
| erase 루프 | erase 반환값 사용 it = vec.erase(it) |
| 순회 중 삽입 | 크기 미리 저장, 인덱스 기반 순회 |
| 복잡한 로직 | 복사본으로 순회 또는 새 컨테이너에 담기 |
| 멀티스레드 | 뮤텍스로 보호 또는 지연 삭제 |
핵심 규칙
- 범위 기반 for 루프 안에서 컨테이너를 수정하지 마세요
- erase는 반환값을 받아 사용하세요 (
it = vec.erase(it)) - push_back/insert 전에 reserve로 재할당을 방지하세요
- 반복자를 장기간 저장하지 마세요 (인덱스 사용)
- 순회 중 수정이 필요하면 지연 삭제 패턴을 사용하세요
구현 체크리스트
반복자 무효화를 방지하기 위한 체크리스트:
- 범위 기반 for 루프 안에서 컨테이너를 수정하지 않는가?
- erase 루프에서 반환값을 받아 사용하는가?
- push_back 전에 reserve를 호출했는가? (또는 크기를 미리 저장)
- 반복자를 멤버 변수로 저장하지 않는가? (인덱스 사용)
- 멀티스레드 환경에서 뮤텍스로 보호하는가?
- 디버그 빌드에서 iterator debugging이 활성화되어 있는가?
- AddressSanitizer로 테스트했는가?
같이 보면 좋은 글 (내부 링크)
이 주제와 연결되는 다른 글입니다.
- C++ vector 완벽 가이드 | 인덱스 오류·반복자 무효화·재할당 병목 해결
- C++ STL 반복자 완벽 가이드 | begin/end·erase·reverse_iterator
- C++ Use After Free | “해제된 메모리 접근” 가이드
- C++ 범위 기반 for문 | “Range-based for loop” 가이드
자주 묻는 질문 (FAQ)
Q. const_iterator를 쓰면 무효화가 안 일어나나요?
A. 아닙니다. const_iterator는 반복자가 가리키는 값을 수정하지 못하게 할 뿐, 컨테이너가 수정되면 똑같이 무효화됩니다.
std::vector<int> vec = {1, 2, 3};
auto it = vec.cbegin(); // const_iterator
vec.push_back(4); // 재할당 → it 무효화!
// *it = 10; // 컴파일 에러 (const_iterator는 수정 불가)
std::cout << *it << '\n'; // ❌ 무효화됨
Q. list는 항상 안전한가요?
A. 삽입은 안전하지만, 삭제된 원소의 반복자는 무효화됩니다. erase 반환값을 사용해야 합니다.
std::list<int> lst = {1, 2, 3};
auto it = lst.begin();
lst.erase(it); // it 무효화!
++it; // ❌ 크래시
Q. 반복자 대신 인덱스를 쓰면 항상 안전한가요?
A. vector에서는 대부분 안전하지만, erase로 원소가 이동하면 인덱스가 가리키는 원소가 바뀔 수 있습니다.
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이었음)
해결: 삭제 후 인덱스를 재계산하거나, 삭제 대상을 따로 수집합니다.
Q. 디버그 빌드에서는 괜찮은데 릴리스에서 크래시가 나요
A. 디버그 빌드는 iterator debugging으로 즉시 에러를 감지하지만, 릴리스 빌드는 최적화로 인해 간헐적으로 크래시가 발생합니다. AddressSanitizer로 릴리스 빌드도 테스트하세요.
Q. 멀티스레드에서 반복자를 안전하게 사용하려면?
A. 읽기/쓰기 모두 뮤텍스로 보호해야 합니다. 한 스레드가 순회하는 동안 다른 스레드가 삽입/삭제하면 무효화됩니다.
std::vector<int> vec;
std::mutex mtx;
// 읽기
{
std::lock_guard lock(mtx);
for (auto& x : vec) {
std::cout << x << '\n';
}
}
// 쓰기
{
std::lock_guard lock(mtx);
vec.push_back(42);
}
실전 사례 분석
사례 1: 프로덕션 크래시 (게임 서버)
증상: 플레이어가 많을 때 간헐적으로 서버가 크래시합니다.
// ❌ 버그 코드
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()
);
}
사례 2: 캐시 만료 처리
증상: LRU 캐시에서 만료된 항목을 삭제할 때 크래시가 발생합니다.
// ❌ 버그 코드
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;
}
}
}
사례 3: 이벤트 핸들러 제거
증상: 이벤트 핸들러 안에서 자기 자신을 제거할 때 크래시가 발생합니다.
// ❌ 버그 코드
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<>());
for (size_t index : pending_removal_) {
if (index < handlers_.size()) {
handlers_.erase(handlers_.begin() + index);
}
}
pending_removal_.clear();
}
};
컴파일러별 에러 메시지
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++)
/usr/include/c++/11/bits/stl_vector.h:1043:
error: attempt to increment a singular iterator.
Objects involved in the operation:
iterator "this" @ 0x0x7ffc12345678 {
type = __gnu_cxx::__normal_iterator<int*, std::vector<int>>;
state = singular;
}
Clang (libc++)
Assertion failed: (__get_const_db()->__find_c_from_i(&__i) == this),
function __dereferenceable, file __debug, line 315.
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-tidy
Checks: >
bugprone-use-after-move,
bugprone-dangling-handle,
bugprone-inaccurate-erase,
cppcoreguidelines-pro-bounds-pointer-arithmetic
감지 가능한 패턴:
vec.erase(std::remove(...))에서erase누락- 범위 기반 for 루프 내 컨테이너 수정 (일부)
- 이동 후 사용 (use-after-move)
Cppcheck
cppcheck --enable=all --inconclusive main.cpp
감지 가능한 패턴:
- 무효화된 반복자 사용 (일부 명확한 케이스)
- 범위 밖 접근
마치며
반복자 무효화는 C++ STL을 사용할 때 가장 자주 만나는 버그입니다. 특히 범위 기반 for 루프의 편리함 때문에 무심코 컨테이너를 수정하다가 크래시를 겪게 됩니다.
핵심 원칙:
- 순회 중에는 컨테이너를 수정하지 마세요
- 수정이 필요하면 지연 삭제 패턴을 사용하세요
- erase는 반환값을 받아 사용하세요
- 디버그 빌드와 AddressSanitizer로 검증하세요
이 규칙들을 지키면 반복자 무효화로 인한 크래시를 99% 방지할 수 있습니다. 코드 리뷰 시 이 패턴들을 체크리스트로 활용하면, 팀 전체의 코드 품질을 높일 수 있습니다.
다음 단계: 이제 안전한 STL 사용법을 익혔다면, STL 알고리즘 완벽 가이드에서 더 효율적인 코드 작성법을 배워보세요.
관련 글
- C++ 템플릿 에러 메시지 해석 |
- C++ 초보자가 자주 하는 실수 Top 15 | 컴파일 에러부터 런타임 크래시까지
- C++ Segmentation fault 원인 5가지와 디버깅 방법 | GDB로 추적하기
- C++ 미정의 동작 (UB) 완벽 가이드 |
- C++ 스택 오버플로우 에러 |