1. 도입
솔직히 말해봅시다: 요소를 '도중에' 삭제하고 싶은 유혹은 항상 있습니다. 작업 목록, 정수 목록, 문자열 목록이 있고 불필요한 항목(빈 문자열, 완료된 작업, 음수 값 등)을 제거하고 싶습니다. 직관적으로는 딱 한 줄이면 될 것 같습니다: "마음에 안 들면 erase". 그리고 우리가 이미 for와 if를 알고 있으니, 뭐가 잘못될 수 있을까요?
문제는 루프와 erase가 동일한 과정을 제어하려고 하는데—컨테이너를 이동하는 작업을—서로 다른 규칙으로 처리한다는 점입니다. 루프는 요소들의 안정적인 '열'을 따라간다고 가정합니다. 반면 erase는 순회 도중에 한 타일을 뽑아내고 뒤에 있는 모든 요소를 왼쪽으로 밀어 구멍을 메웁니다. 결과적으로 루프 내부에서 생각하는 '다음 요소'와 실제 컨테이너에서의 '다음 요소'가 갑자기 다른 것이 될 수 있습니다.
이해를 돕기 위해 간단한 콘솔 애플리케이션 TaskBook을 만든다고 상상해봅시다: 우리는 작업들의 std::vector를 가지고 있고, 주기적으로 완료된 작업이나 제목이 빈 작업을 정리해야 합니다.
2. vector::erase가 어떻게 동작하는가
안티패턴을 비난하기 전에, erase가 실제로 무엇을 하는지 차분히 살펴봅시다. 구현 내부가 아니라 논리적으로 보면: 하나의 요소가 사라지고, 오른쪽에 있던 모든 요소가 왼쪽으로 한 칸씩 이동하고, 크기가 1만큼 줄어들며, 영향을 받은 요소들에 대한 반복자/참조는 문제가 됩니다.
예제로 살펴봅시다. 숫자를 사용하면 주의가 분산되지 않습니다:
| 인덱스 | 0 | 1 | 2 | 3 | 4 |
|---|---|---|---|---|---|
| 이전 | 10 | 20 | 30 | 40 | 50 |
인덱스 1(즉 20)를 삭제합니다. erase 후:
| 인덱스 | 0 | 1 | 2 | 3 |
|---|---|---|---|---|
| 이후 | 10 | 30 | 40 | 50 |
즉, "원래 30이 있던 것"이 이제는 20이 있던 자리에 위치합니다. 따라서 루프가 삭제 후에 ++it 또는 i++를 하려고 했다면, 거의 확실히 30을 건너뛰게 됩니다. 왜냐하면 30이 이미 삭제된 위치로 옮겨졌기 때문입니다.
의미적으로 단순화한 도식은 다음과 같습니다:
flowchart LR
A["요소를 순회한다"] --> B{"삭제 조건인가?"}
B -->|아니오| C["다음으로 이동"]
B -->|예| D["erase: 요소 제거"]
D --> E["꼬리 부분을 왼쪽으로 이동"]
E --> F["크기가 줄어듦"]
F --> A
핵심 포인트: erase 후에는 순회를 계속하기 위한 이전의 '기준점'이 잘못될 수 있습니다. 그래서 erase는 중요한 계약을 가지고 있습니다: 삭제된 요소 다음의 요소를 가리키는 반복자를 반환합니다. 이 반환값을 무시하면 거의 항상 지뢰밭을 걷는 셈이 됩니다.
3. 예제용 간단한 TaskBook 모델
예제가 단순한 vector<int>들의 나열이 되지 않도록, 최소한의 작업 모델을 정의합니다. 작은 '애플리케이션' 조각이지만, 이후 예제에서 추상적인 숫자 대신 실제 프로그램과 유사한 것을 삭제하는 모습을 보여주는 것이 중요합니다.
#include <string>
struct Task {
int id{};
std::string title;
bool done{};
};
결과를 빠르게 확인하기 위한 유틸리티 하나(실제 프로젝트에서는 별도 함수로 뺄 것이지만 여기서는 간단하게 유지):
#include <iostream>
#include <vector>
void print_ids(const std::vector<Task>& tasks) {
for (const auto& t : tasks) std::cout << t.id << ' ';
std::cout << '\n';
}
이제 우리는 '완료된 작업을 삭제하는 다양한 방법'을 살펴보고, 사람의 직관과 vector의 동작이 어디에서 어긋나는지 확인할 것입니다.
4. 안티패턴: 순회 중의 erase
erase를 range‑for 내부에서 사용하는 경우
Range‑for는 코드가 최소화되고 가독성이 최대화되는 선물처럼 보입니다. 그래서 가장 많이 "여기서 바로 삭제하자"하는 대상이 됩니다. 초보자의 논리는 간단합니다: Task& t를 받았으니 t.done을 확인하고 삭제하면 된다. 그러나 range‑for는 내부적으로 반복자를 사용하고, 컨테이너를 변경한 후 그 반복자가 어떻게 되는지 제어할 수 없습니다.
나쁜 예(주의: 위험한 줄은 실제로 실행하지 않습니다. 실제로 예측 불가능한 결과를 초래할 수 있습니다):
#include <vector>
void remove_done_bad(std::vector<Task>& tasks) {
for (Task& t : tasks) {
if (t.done) {
// 나쁨: range-for는 반복자를 숨기고,
// erase는 반복자를 무효화합니다.
// tasks.erase(...);
}
}
}
개념적으로 왜 이것이 나쁜가요? range‑for는 "지금부터 begin에서 end까지 순차적으로 이동할 것"이라고 생각합니다. 그런데 중간에 길 조각을 떼어내고 포장재를 옮기면, 루프가 사용하는 반복자는 무효가 될 수 있습니다. "어쩐지 작동하는 것처럼 보인다" 하더라도, 이는 "첫 번째 릴리스까지는 동작한다" 유형의 버그입니다.
올바른 결론은 range‑for가 나쁘다는 것이 아닙니다. 컨테이너를 변경하지 않고 요소를 읽거나 제자리에서 수정(예: done 필드 수정, 문자열 교체 등)할 때는 훌륭합니다. 하지만 요소 삭제는 컨테이너 구조를 변경하는 작업이므로 range‑for는 오히려 방해가 될 때가 많습니다.
for (it ... ++it) + erase(it) 를 재대입 없이 사용하는 경우
이 방법은 명시적으로 반복자를 사용하므로 더 '영리해 보입니다'. 초보자는 이렇게 씁니다: 반복자로 이동하면서, 요소가 삭제 대상이면 지운다. erase가 있다는 것도 알고 있습니다. 하지만 두 가지 효과를 동시에 고려하지 않습니다: erase는 현재 반복자를 무효화하고, for 헤더는 여전히 ++it를 수행합니다.
나쁜 예(위험한 줄은 주석 처리되어 있음):
#include <vector>
void remove_done_bad2(std::vector<Task>& tasks) {
for (auto it = tasks.begin(); it != tasks.end(); ++it) {
if (it->done) {
// 나쁨: erase가 it를 무효화하고,
// 루프는 무효화된 it에 ++을 적용합니다.
// tasks.erase(it);
}
}
}
비록 무효 반복자로 인해 즉시 크래시가 나지 않더라도, 요소를 자주 건너뛰게 되는 경우가 많습니다. 예를 들어 연속으로 두 개의 완료된 작업이 있다면 첫 번째를 삭제한 후 두 번째가 그 자리로 이동하고, 루프가 ++it를 적용하면 두 번째 작업을 전혀 검사하지 못합니다. 결과적으로 "조건에 맞는 항목마다 하나씩 삭제"가 아니라 "매 두 번째 항목만 삭제"되는 상황이 발생합니다.
여기서의 정상적인 전략은 하나입니다: 삭제할 때는 erase가 반환한 새 반복자를 받아야 하며 추가적인 ++it를 하지 않아야 합니다. 보통 이는 헤더에 ++it가 없는 루프 형태로 나타납니다. 이 패턴을 '미적으로' 다듬는 방법은 이후 강의에서 다루겠습니다; 오늘은 단지 왜 '순진한' 방식이 자주 깨지는지 이해하는 것이 중요합니다.
인덱스 기반 루프와 erase(begin() + i)
반복자가 복잡하다면, 다음 아이디어는 매우 인간적입니다: "나는 인덱스를 알고 있고, vector[i]를 사용할 수 있으니 평범한 for로 i를 돌면서 tasks.begin() + i를 지우자." 그리고 이것은 첫 번째 삭제 지점까진 논리적으로 타당해 보입니다. 하지만 삭제 후에는 요소들의 인덱스가 변경됩니다.
전형적인 나쁜 예:
#include <vector>
void remove_done_bad3(std::vector<Task>& tasks) {
for (size_t i = 0; i < tasks.size(); ++i) {
if (tasks[i].done) {
tasks.erase(tasks.begin() + static_cast<long long>(i));
}
}
}
이 코드의 증상은 대개 다음과 같습니다: "왜 모든 done이 삭제되지 않았지?", "가끔 하나만 남는다", "두 개가 연속이면 하나만 남는다" 등. 이유는 앞서 설명한 것과 동일합니다: 삭제 후 다음 요소가 현재 인덱스 i로 이동하고, 당신은 i를 증가시켜 그것을 건너뜁니다.
또 하나의 덜 명백한 '선물'은 다음과 같습니다: vector 중간에서의 각 erase는 꼬리 부분을 이동시킵니다. 따라서 삭제가 많아질수록 이동 비용이 증가합니다. 직관적으로 보면, 단순한 필터링 작업이 대용량 데이터에서 눈에 띄게 느려질 수 있습니다.
사람들은 때때로 오른쪽에서 왼쪽으로 처리하는 방식으로 "치료"하려고 합니다: 오른쪽에서부터 삭제하면 이미 처리한 인덱스는 영향을 받지 않습니다. 하지만 여기에는 size_t와 관련된 또 다른 함정이 있습니다: 부호 없는 타입이므로 'minus one'이 큰 수가 되어 무한 루프를 만들 수 있습니다. 이 부분은 별도 강의가 필요하지만 알아두면 좋습니다: "size_t로 역방향 루프"는 우연히 영구 루프를 만드는 가장 흔한 방법 중 하나입니다.
end()를 '고정'하려 하거나 참조/포인터를 보관하려는 시도
무효화를 처음 접하면 이를 '우회'하려는 자연스러운 유혹이 생깁니다: "좋아, 반복자가 무효화된다면 미리 end를 저장해두자", 또는 "현재 요소에 대한 참조를 저장하고 삭제하자." 이것은 합리적으로 들리지만, vector에서는 종종 저장해둔 것이 더 이상 현재 컨테이너의 상태와 관련이 없게 됩니다.
예: end()를 저장하는 경우:
#include <vector>
void remove_done_bad4(std::vector<Task>& tasks) {
auto last = tasks.end(); // 나쁜 생각: end()는 무효화될 수 있습니다
for (auto it = tasks.begin(); it != last; ++it) {
if (it->done) {
tasks.erase(it); // 여기서 last도 "문제가 될 수 있음"
break;
}
}
}
end()는 단지 '경계'처럼 보이지만, vector를 수정하면 경계가 변경되고 저장해둔 last는 더 이상 올바른 경계가 아닐 수 있습니다. 이것도 "가끔 작동"하는 코드의 전형적인 원인입니다.
참조를 저장하는 경우도 비슷하게 위험합니다:
#include <vector>
void bad_ref(std::vector<Task>& tasks) {
Task& ref = tasks[0];
tasks.erase(tasks.begin()); // ref는 이제 "다른 곳"을 참조할 수 있습니다
// ref.id; // 사용하면 위험함
}
벡터 요소에 대한 참조와 포인터는 배열 내 특정 위치에 묶여 있습니다. erase는 그 위치들을 변경합니다: 요소들을 이동시키고, 삭제된 것을 파괴하며, 경우에 따라 객체를 이동시킵니다. 따라서 '요소에 대한 참조'와 '요소 삭제'는 거의 항상 이해상 충돌을 일으킵니다.
그리고 맞습니다. 이 부분에서 많은 사람이 C++의 불편한 철학을 처음 접합니다: "네가 위험한 짓을 했다면 컴파일러는 널 구해줄 의무가 없다." 표준 위원회도 erase-유사 연산의 무효화 규칙과 계약을 계속 다루고 있는데, 이는 단순히 재미로 하는 일이 아니라 실제로 중요한 문제이기 때문입니다.
많은 단건 erase 호출과 갑작스러운 O(N²)
특별히 고통스러운 경우가 있습니다: 코드가 논리적으로 '거의 맞는'데도 갑자기 느려지는 경우입니다. 전형적인 시나리오: 100000개의 작업이 있고, 완료된 모든 작업을 삭제하려고 합니다. 당신은 루프를 작성하고 조건에 맞을 때마다 erase를 호출합니다. 작은 데이터에서는 모든 것이 빠릅니다. 큰 데이터에서는 갑자기 "왜 이리 오래 걸리지?"가 됩니다.
직관적인 이유는 간단합니다: vector 중간에서의 erase는 꼬리 부분을 이동시킵니다. 많은 요소를 삭제하면 많은 이동이 발생합니다. 결과적으로 작업은 줄을 사이사이로 이동시키며 사람들을 밖으로 밀어내는 것과 비슷해집니다.
비용이 높은 '나쁜 아이디어'의 전형:
#include <vector>
void remove_done_slow(std::vector<Task>& tasks) {
for (auto it = tasks.begin(); it != tasks.end(); ++it) {
if (it->done) {
tasks.erase(it); // 반복자를 "고치더라도", 이동 비용은 여전히 큽니다
break;
}
}
}
여기서는 '어떻게 올바르게 다시 작성하는가'를 일부러 보여주지 않았습니다. 대량 삭제는 별도의 관용구로 다루어야 하고(후속 강의에서 다룰 예정), 오늘 강의의 핵심은 다음 느낌을 갖게 하는 것입니다: 많은 단건 erase 호출은 긴 순회 내에서 거의 항상 의심스러운 신호라는 것—심지어 반복자를 조심스럽게 처리하더라도 그렇습니다.
5. 삭제 전에 고민할 체크리스트
다음 번에 std::vector에서 요소를 제거하려 할 때, 스스로에게 2초만 멈추고 한 가지 질문을 해보세요: "하나를 삭제하나, 아니면 많은 것을 삭제하나?" 조건에 따라 많은 것을 삭제해야 한다면, 거의 항상 '루프에서 하나씩 뽑아내는' 방식보다는 이동을 최소화하고 순회를 망가뜨리지 않는 방식을 선택하는 것이 낫습니다. 만약 순회 도중에 특정하게 삭제해야 한다면, 반복자 단계를 수동으로 제어하고, 삭제 후에는 erase가 반환한 반복자에서 계속해야 합니다.
시간을 절약해주는 또 하나의 규칙: 같은 vector에 대해 range‑for 안에서 요소를 삭제하는 코드는 거의 확실히 잘못된 코드입니다. 때로는 "작동하도록" 만들 수 있지만, 그것은 이미 초보 수준이 아닌, "이 트릭을 다른 사람에게 당당히 설명할 수 있다"는 수준의 기술입니다.
오늘 강의의 목적은 '정답 한 줄'을 외우게 하는 것이 아니라 안티패턴을 얼굴로 알아보게 하는 것입니다. 안티패턴을 알면 우연에 의존하는 취약한 코드를 덜 쓰게 되고, 컨테이너의 계약에 맞는 코드를 작성하게 됩니다.
6. 전형적인 실수들
실수 №1: 동일한 vector에 대해 range‑for 내부에서 삭제하기.
Range‑for는 안전해 보이지만 반복자를 숨깁니다. erase를 호출하면 컨테이너가 변경되고 range‑for가 내부적으로 사용하는 반복자가 무효화될 수 있습니다. 좋은 경우엔 요소를 건너뛰고, 나쁜 경우엔 정의되지 않은 동작이 발생합니다.
실수 №2: erase(it)를 for (...; ++it) 내부에서 반복자 재대입 없이 호출하기.
여기서는 두 가지 문제가 연속으로 발생합니다: erase가 it를 무효화하고, 루프 헤더는 여전히 ++it를 시도합니다. 프로그램이 즉시 크래시되지 않더라도, 특히 연속된 삭제 대상이 있을 때 요소를 건너뛰는 일이 자주 발생합니다.
실수 №3: 인덱스 기반 루프 for (i...) + erase(begin()+i) 와 단순한 i++.
삭제 후 오른쪽 요소가 현재 인덱스 i로 이동하고, 당신은 i를 증가시켜 그것을 건너뜁니다. 결과적으로 삭제되어야 할 것들이 모두 삭제되지 않습니다. 추가로 꼬리 이동이 많아져 큰 데이터에서 성능이 떨어집니다.
실수 №4: 순회 경계(end())를 고정하려 하거나 요소에 대한 참조/포인터를 보관한 뒤 컨테이너를 변경하는 것.
vector에서 요소 이동과 관련된 어떤 변경도 저장된 바인딩을 신뢰할 수 없게 만듭니다. 삭제 후의 end()는 더 이상 이전의 end()가 아니며, 삭제 이후의 참조는 다른 객체를 가리키거나 전혀 안전하지 않을 수 있습니다.
실수 №5: 대량 필터링을 단건 erase 반복으로 처리하기.
반복자를 조심스럽게 다루어도, vector 중간에서 자주 erase를 호출하면 많은 요소 이동이 발생합니다. 사용자가 느끼기에 '너무 느려짐'으로 귀결됩니다. 이런 경우에는 대량 삭제에 적합한 방법을 선택해야 합니다.
GO TO FULL VERSION