침묵이 먼저 도착했다
민준은 회의실 문을 열자마자 이상한 조용함을 느꼈다. 평소 같으면 키보드 두드리는 소리, 누군가의 질문, IDE가 새로 빌드되는 짧은 알림음이 뒤섞여 있었을 시간이었다. 그런데 그날은 모두가 모니터를 보고 있었고, 아무도 먼저 입을 열지 않았다.
어제 저녁, 학생 목록 관리 프로그램에서 문제가 터졌다. 점수가 낮은 학생을 목록에서 제외하는 기능을 추가했을 뿐이었다. 코드는 짧았다. 빌드도 통과했다. 첫 실행 결과도 그럴듯했다. 그러나 데이터가 조금 늘어나자 이상한 일이 벌어졌다. 삭제되어야 할 학생이 남아 있었고, 남아 있어야 할 학생이 사라졌으며, 어떤 경우에는 프로그램이 아무 말 없이 멈췄다.
처음에는 단순한 실수라고 생각했다. 조건식을 잘못 썼거나, 인덱스를 하나 밀렸거나, 입력 파일이 깨졌을 수도 있었다. 하지만 로그를 다시 펼쳐 볼수록 문제는 더 깊은 곳에서 시작된 것처럼 보였다.
반복자였다.
그리고 반복자는 단지 코드 안의 문제가 아니었다. 그 반복자를 다루는 사람들 사이의 신뢰도 함께 흔들리고 있었다.
민준은 책상 위에 놓인 노트를 펼쳤다. 전날 적어 둔 문장이 눈에 들어왔다.
반복자는 컨테이너 안의 위치를 가리키는 표식이다.
하지만 컨테이너가 변하면, 그 표식이 더 이상 같은 곳을 가리킨다고 믿어서는 안 된다.
그 문장은 갑자기 사람에 대한 말처럼 읽혔다. 한때 믿을 수 있다고 생각했던 약속도, 상황이 바뀌면 다시 확인해야 했다. 중요한 것은 누가 잘못했느냐를 서둘러 가르는 일이 아니었다. 지금 무엇이 바뀌었고, 무엇이 더 이상 유효하지 않은지를 알아내는 일이었다.

사라진 학생보다 먼저 사라진 것
문제의 코드는 이렇게 시작되었다.
for (auto it = students.begin(); it != students.end(); ++it) {
if (it->score < 60) {
students.erase(it);
}
}처음 보는 사람에게는 자연스러워 보인다. students를 처음부터 끝까지 훑으면서, 점수가 60점 미만인 학생을 지운다. 문장도 읽기 쉽고, 의도도 분명하다.
하지만 이 코드는 위험하다.
std::vector에서 원소 하나를 지우면, 그 뒤에 있던 원소들이 앞으로 한 칸씩 이동한다. 빈자리를 메우기 위해서다. 이때 지워진 위치를 가리키던 반복자는 더 이상 유효하지 않다. 그 뒤쪽 원소를 가리키던 반복자들도 흔들릴 수 있다. 그런데 위 코드는 erase로 반복자를 무효화한 뒤에도, 반복문의 끝에서 ++it를 실행한다.
이미 길을 잃은 표식에게 한 걸음 더 가라고 명령하는 셈이다.
반복자 무효화란 말이 어렵게 들릴 수 있다. 하지만 비유하면 간단하다. 책갈피를 책의 120쪽에 꽂아 두었다고 하자. 그런데 누군가 앞쪽의 여러 장을 뜯어내거나 새로 끼워 넣었다면, 그 책갈피가 여전히 같은 내용을 가리킨다고 확신할 수 없다. 책갈피 자체는 그대로 있어 보여도, 책의 구조가 바뀌었기 때문이다.
std::vector는 연속된 메모리 공간에 원소를 나란히 저장한다. 그래서 중간 원소를 지우면 뒤쪽 원소들이 이동한다. 이동이 일어나면 이전 반복자, 포인터, 참조는 더 이상 예전 의미를 보장하지 못할 수 있다.
민준은 코드를 천천히 고쳤다.
for (auto it = students.begin(); it != students.end();) {
if (it->score < 60) {
it = students.erase(it);
} else {
++it;
}
}핵심은 erase의 반환값이다. vector::erase는 삭제된 원소 다음 위치를 가리키는 반복자를 돌려준다. 그러므로 원소를 삭제한 경우에는 직접 ++it를 하지 않는다. 이미 다음 위치를 받았기 때문이다. 삭제하지 않은 경우에만 ++it로 다음 원소로 이동한다.
이 차이는 작아 보이지만, 프로그램의 운명을 가른다. 흔들린 반복자를 계속 믿을 것인가, 아니면 컨테이너가 새로 알려 준 안전한 위치에서 다시 시작할 것인가.
민준은 의자에 등을 기대었다. 문제가 된 기능은 삭제 코드 하나가 아니었다. 그 코드를 리뷰하지 않고 넘어간 과정, 이상한 로그를 보고도 "나중에 보자"고 미룬 태도, 그리고 누군가가 실패한 테스트 결과를 공유하지 않은 침묵이 같은 방향을 가리키고 있었다.
학생 목록에서 사라진 것은 몇 개의 데이터만이 아니었다. 팀이 서로를 믿는 방식도 함께 사라지고 있었다.
단서들은 처음부터 흩어져 있었다
민준은 전날의 커밋 기록을 다시 열었다. 코드만 보면 모든 것이 우연처럼 보였다. 하지만 순서를 따라가자 조금씩 다른 그림이 드러났다.
첫 번째 단서는 vector의 재할당이었다.
std::vector<Student> students;
students.push_back(Student{1, "Jin", 82});
Student& first = students[0];
students.push_back(Student{2, "Mina", 91});
students.push_back(Student{3, "Haru", 77});
first.score = 100; // 항상 안전하다고 말할 수 없다std::vector는 내부 공간이 부족해지면 더 큰 공간을 새로 마련하고, 기존 원소들을 그곳으로 옮긴다. 이 과정을 재할당이라고 한다. 재할당이 일어나면 이전 원소를 가리키던 참조나 포인터, 반복자는 더 이상 안전하지 않을 수 있다.
위 코드에서 first는 처음에는 students[0]을 잘 가리킨다. 그러나 뒤이어 push_back이 여러 번 호출되면서 vector 내부 공간이 옮겨졌다면, first는 예전 장소를 바라보는 낡은 약속이 된다.
민준은 로그에서 이상한 점을 발견했다. 학생 추가 기능을 넣은 뒤부터 특정 테스트가 가끔 실패했다. 항상 실패한 것은 아니었다. 어떤 컴퓨터에서는 통과했고, 어떤 입력에서는 실패했다. 그래서 더 쉽게 무시되었다.
가끔 실패하는 버그는 가장 성가시다. 하지만 바로 그 점 때문에 더 중요한 단서가 된다. 프로그램이 매번 같은 방식으로 실패하지 않는다면, 객체의 생명주기, 메모리 위치, 반복자 무효화처럼 보이지 않는 조건이 숨어 있을 가능성이 크다.
두 번째 단서는 unordered_map이었다.
std::unordered_map<int, Student> table;
table.emplace(1, Student{1, "Jin", 82});
auto it = table.find(1);
table.emplace(2, Student{2, "Mina", 91});
table.emplace(3, Student{3, "Haru", 77});
if (it != table.end()) {
std::cout << it->second.name << 'n'; // 재해시가 있었다면 위험할 수 있다
}unordered_map은 해시 테이블을 사용한다. 해시 테이블은 키를 계산해서 원소를 여러 바구니에 나누어 담는 구조라고 생각하면 된다. 원소가 많아지면 바구니 수를 늘리고, 기존 원소를 다시 배치할 수 있다. 이것을 재해시, 영어로는 rehash라고 한다.
재해시가 일어나면 기존 반복자는 무효화된다. 즉, find로 얻어 둔 반복자를 나중에도 그대로 믿으면 안 된다. 삽입이 단순히 원소 하나를 더하는 일이 아니라, 내부 구조 전체를 다시 짜는 일이 될 수 있기 때문이다.
세 번째 단서는 더 조용했다. map과 set은 vector와 다르게 동작한다. 이 컨테이너들은 원소를 트리 구조로 관리하기 때문에 중간에 원소를 삽입한다고 해서 모든 원소가 한 칸씩 밀려나지는 않는다. 그래서 반복자 안정성이 비교적 좋다. 하지만 삭제된 원소를 가리키던 반복자는 당연히 더 이상 쓸 수 없다.
for (auto it = scores.begin(); it != scores.end();) {
if (it->second < 60) {
it = scores.erase(it);
} else {
++it;
}
}컨테이너마다 규칙이 다르다. vector의 반복자와 map의 반복자, unordered_map의 반복자는 겉으로는 비슷하게 생겼지만 같은 약속 위에 서 있지 않다. 어떤 컨테이너는 삽입과 삭제에 민감하고, 어떤 컨테이너는 재정렬이나 재해시에 민감하다.
민준은 노트 한쪽에 크게 적었다.
반복자는 값이 아니라 관계다.
컨테이너가 바뀌면 관계도 바뀔 수 있다.
그 문장을 쓰고 나서야, 그는 어제 도윤이 왜 말을 아꼈는지 조금은 이해할 수 있었다.
도윤은 코드 작성 속도가 빨랐다. 기능을 붙이는 데 능했고, 회의 때마다 "일단 돌아가게 만들자"고 말했다. 그 말은 틀리지 않았다. 프로그램은 언젠가 실제로 돌아가야 한다. 문제는 "일단"이라는 말이 너무 오래 지속될 때 생긴다. 검증하지 않은 편의는 곧 빚이 되고, 빚은 언젠가 누군가의 시간과 신뢰로 갚아야 한다.
도윤이 일부러 문제를 숨겼다고 단정할 수는 없었다. 하지만 실패한 테스트 로그가 공유 폴더가 아니라 개인 작업 폴더에 남아 있었다는 사실은 무겁게 다가왔다. 실수보다 위험한 것은 실수를 혼자만 알고 있는 상태였다.
말하지 않는 쪽의 움직임
그날 오후, 프로젝트 채널에는 짧은 공지가 올라왔다.
오늘부터 기능 추가 속도를 우선합니다.
리팩터링과 테스트 보강은 안정화 기간에 처리합니다.
누가 쓴 공지인지는 명확했다. 하지만 그 공지가 무엇을 뜻하는지는 누구도 크게 말하지 않았다. 마감이 가까웠고, 외부 시연 일정도 잡혀 있었다. 학생 목록 프로그램은 이제 단순한 연습 문제가 아니라, 동아리 전체가 보여 줄 결과물이 되어 있었다.
민준은 공지를 읽고도 바로 답하지 않았다. 반대편 세력이라는 말은 거창했다. 이곳에는 악당도 음모도 없었다. 다만 다른 우선순위를 가진 사람들이 있었다. 어떤 사람은 속도를 지키려 했고, 어떤 사람은 품질을 지키려 했으며, 어떤 사람은 갈등 자체를 피하려 했다.
하지만 방향이 다르면, 같은 침묵도 다른 의미를 가진다.
속도를 원하는 사람에게 침묵은 동의처럼 보인다. 불안을 느끼는 사람에게 침묵은 압박처럼 느껴진다. 책임을 져야 하는 사람에게 침묵은 나중에 돌아올 질문이 된다.
주변 변화는 작지만 분명했다. 코드 리뷰 요청은 줄어들었다. 테스트 실패 메시지에는 이모티콘 하나 달리지 않았다. 회의는 짧아졌고, 질문은 더 조심스러워졌다. 누군가가 직접 "검증은 미루자"고 말하지 않아도, 모두가 그쪽으로 조금씩 밀려가고 있었다.
민준은 다시 코드를 보았다. 반복자 문제는 해결할 수 있다. 규칙을 배우고, 올바른 패턴을 쓰고, 테스트를 추가하면 된다. 하지만 사람들 사이의 반복자, 그러니까 서로를 가리키는 믿음의 표식은 어떻게 다시 유효하게 만들 수 있을까.
그 질문에 답하기 위해 민준은 서연을 찾아갔다.
책임은 누가 틀렸는지를 묻는 말이 아니다
서연은 창가 자리에서 오래된 노트북을 열어 두고 있었다. 화면에는 컴파일 로그가 줄지어 올라와 있었다. 민준이 다가가자 서연은 고개만 살짝 들었다.
"찾았어?"
민준은 잠시 망설이다가 대답했다.
"코드의 원인은 찾았어요. vector에서 erase한 뒤 반복자를 계속 썼고, unordered_map에서도 삽입 뒤에 예전 반복자를 믿은 흔적이 있어요. 그런데 그게 전부는 아닌 것 같아요."
서연은 노트북을 덮지 않은 채 민준을 바라보았다.
"전부가 아니라는 건?"
"실패한 테스트가 있었어요. 공유되지 않았고요. 공지는 테스트보다 기능 추가를 먼저 하자는 방향으로 올라왔어요. 다들 말은 안 하는데, 이미 한쪽으로 기울고 있는 느낌이에요."
서연은 바로 대답하지 않았다. 잠깐의 침묵 뒤에 천천히 말했다.
"기울어진다는 걸 알아차리는 사람은 보통 불편해져. 모르는 척하면 편하니까."
"그럼 제가 말해야 할까요?"
"그 질문 전에 하나 더 물어봐야 해."
"뭔데요?"
"네가 지키고 싶은 게 뭐야?"
민준은 예상하지 못한 질문에 입을 다물었다. 버그를 고치고 싶었다. 시연을 망치고 싶지 않았다. 도윤을 몰아세우고 싶지도 않았다. 팀이 갈라지는 것도 원하지 않았다. 하지만 그 모든 답은 조금씩 부족했다.
서연은 민준의 표정을 읽은 듯 말을 이었다.
"책임은 누가 틀렸는지 찾아내는 일이 아니야. 물론 원인은 찾아야 해. 하지만 책임의 핵심은 그다음이야. 같은 일이 다시 일어나지 않게 구조를 바꾸는 것. 그리고 그 구조를 모두가 볼 수 있게 만드는 것."
민준은 노트 모서리를 만지작거렸다.
"그럼 신뢰는요?"
"신뢰는 아무도 실수하지 않을 거라고 믿는 게 아니야. 실수했을 때 드러날 수 있고, 드러난 뒤 고칠 수 있다고 믿는 거지."
그 말은 민준의 안쪽 어딘가에 오래 남았다. 신뢰는 완벽함이 아니었다. 숨기지 않아도 무너지지 않는 관계였다.
서연은 다시 노트북을 열고 간단한 코드를 보여 주었다.
std::vector<int> removeTargets;
for (const auto& student : students) {
if (student.score < 60) {
removeTargets.push_back(student.id);
}
}
for (int id : removeTargets) {
eraseById(students, id);
}"이런 식으로 삭제할 대상을 먼저 모으는 방법도 있어. 순회하면서 바로 지우는 게 항상 나쁜 건 아니지만, 복잡한 조건이 섞이면 단계를 나누는 편이 더 안전할 때가 많아."
"느려지지 않나요?"
"조금 느려질 수는 있어. 하지만 항상 느린 건 아니고, 무엇보다 명확해져. 선택에는 대가가 있어. 속도를 택하면 복잡성을 떠안을 수 있고, 명확성을 택하면 약간의 비용을 지불할 수 있어. 중요한 건 대가를 모른 척하지 않는 거야."
민준은 그 말을 듣고 나서야 자신이 두려워한 것이 무엇인지 알았다. 그는 버그보다도, 버그를 고치자고 말했을 때 돌아올 시선을 두려워했다. "지금 그럴 시간이 있어?"라는 말. "일단 돌아가잖아"라는 말. "너 때문에 늦어지면 어떡할래?"라는 말.
선택에는 대가가 있다. 말하지 않는 선택에도 대가가 있다.
컨테이너가 다르면 지켜야 할 약속도 다르다
민준은 자리로 돌아와 정리 문서를 만들기 시작했다. 누군가를 비난하기 위한 문서가 아니라, 더 이상 같은 실수를 반복하지 않기 위한 문서였다. 첫 줄에는 이렇게 적었다.
데이터를 담는 그릇이 다르면, 안전하게 다루는 방식도 달라진다.
그는 팀원들이 헷갈렸던 지점을 차분히 풀어 썼다.
std::vector는 빠른 임의 접근이 장점이다. students[3]처럼 인덱스로 바로 접근할 수 있고, 원소들이 메모리에 연속으로 놓이기 때문에 순회 성능도 좋다. 하지만 중간 삽입과 삭제에는 비용이 든다. 뒤쪽 원소들이 이동해야 하기 때문이다. 또한 공간이 부족해져 재할당이 일어나면 기존 반복자, 포인터, 참조가 무효화될 수 있다.
std::map과 std::set은 정렬된 상태를 유지한다. 내부적으로 트리 구조를 사용하므로 원소를 삽입해도 기존 원소들이 vector처럼 줄줄이 이동하지 않는다. 그래서 삽입 때문에 기존 반복자가 쉽게 무효화되지는 않는다. 다만 삭제한 원소를 가리키던 반복자는 사용할 수 없다. 사라진 대상을 계속 가리킬 수는 없기 때문이다.
std::unordered_map과 std::unordered_set은 정렬 순서를 보장하지 않는 대신 평균적으로 빠른 검색을 제공한다. 그러나 원소가 늘어나면서 재해시가 일어나면 반복자가 무효화될 수 있다. 특히 "방금 찾은 반복자를 저장해 두었다가 여러 삽입 후 다시 쓰는" 코드는 조심해야 한다.
민준은 이어서 팀 규칙 초안을 적었다.
순회 중 삭제가 필요하면
erase가 반환하는 반복자를 사용한다.vector원소에 대한 참조나 포인터를 오래 보관하지 않는다.unordered_map에 원소를 많이 추가하는 동안 기존 반복자를 믿지 않는다.복잡한 조건의 삭제는 "대상 수집"과 "실제 삭제"를 분리하는 방식을 우선 검토한다.
컨테이너를 바꾸면 반복자 안정성 규칙도 함께 다시 확인한다.
규칙을 쓰는 일은 이상하게 마음을 가라앉혔다. 모호한 불안이 문장으로 바뀌면, 적어도 무엇을 붙잡아야 하는지 보이기 시작한다.
하지만 기술 규칙만으로는 충분하지 않았다. 민준은 문서 아래쪽에 또 다른 항목을 추가했다.
실패한 테스트는 개인 폴더에 묻어 두지 않고 공유 채널에 올린다.
원인을 모를 때는 추측 대신 재현 방법을 먼저 기록한다.
리뷰에서 사람의 능력이 아니라 코드의 가정을 질문한다.
마감 때문에 생긴 예외는 반드시 이름을 붙이고 나중에 갚을 빚으로 기록한다.
마지막 문장을 쓰는 데 시간이 걸렸다.
침묵은 중립이 아니다. 위험을 알고도 말하지 않으면, 그 위험은 팀 전체의 것이 된다.
민준은 저장 버튼을 누르기 전에 한참 동안 화면을 바라보았다. 너무 강한 말일까. 누군가를 겨냥하는 말처럼 보이지 않을까. 하지만 지우지는 않았다. 그 문장이 지금 가장 필요한 말이라는 것을 알고 있었기 때문이다.
도윤의 자리
저녁이 가까워질 무렵, 도윤이 민준의 자리 옆에 섰다. 손에는 식지 않은 캔커피가 들려 있었다. 그는 평소처럼 농담을 던지지 않았다.
"문서 봤어."
민준은 고개를 들었다.
"아직 초안이야. 내일 회의 때 같이 보려고."
도윤은 캔커피의 탭을 만지작거리며 말했다.
"테스트 로그 말인데."
그 말이 나오자 주변 소리가 조금 멀어지는 것 같았다. 민준은 도윤을 재촉하지 않았다.
"처음엔 별거 아니라고 생각했어. 입력 데이터가 이상한 줄 알았고. 그다음엔 내가 바로 고칠 수 있을 줄 알았어. 공유하면 일이 커질 것 같았고."
민준은 천천히 숨을 들이마셨다.
"왜 말을 안 했는지 이해하려고 노력 중이야. 하지만 다음에도 그러면 안 돼."
도윤은 시선을 피하지 않았다.
"알아."
"우리가 완벽해서 같이 하는 게 아니잖아. 혼자 고칠 수 있으면 좋지만, 혼자 숨기면 같이 망가져."
도윤은 작게 웃었다. 웃음이라기보다는, 무언가를 인정하고 난 뒤 새어 나오는 숨에 가까웠다.
"내일 네가 말할 거야?"
"응."
"다들 좋아하진 않을 텐데."
"그럴 것 같아."
"그래도?"
민준은 문서의 마지막 문장을 다시 보았다. 침묵은 중립이 아니다. 위험을 알고도 말하지 않으면, 그 위험은 팀 전체의 것이 된다.
"그래도."
도윤은 잠시 고개를 끄덕이다가 자기 자리로 돌아갔다. 사과는 짧았고, 해결은 아직 멀었다. 하지만 적어도 하나는 달라졌다. 숨겨져 있던 것이 말의 형태를 얻었다.
균열은 드러났다고 해서 곧바로 메워지지 않는다. 그러나 드러나지 않은 균열은 더 깊어진다.
중간점에서 돌아서지 않기
밤이 되자 동아리방에는 몇 사람만 남았다. 창밖의 불빛이 모니터 가장자리에 희미하게 비쳤다. 민준은 마지막으로 프로그램을 실행했다. 수정한 삭제 코드는 더 이상 엉뚱한 학생을 지우지 않았다. 반복자는 삭제된 자리에서 길을 잃지 않았고, erase가 돌려준 다음 위치에서 차분히 순회를 이어 갔다.
하지만 진짜 실행은 내일이었다.
내일 회의에서 민준은 세 가지를 말해야 했다. 버그의 원인, 다시 막을 방법, 그리고 지금 팀이 선택해야 할 방향. 기능을 계속 쌓을 것인지, 아니면 흔들리는 기반을 먼저 바로잡을 것인지.
둘 중 하나만 완전히 옳은 선택은 아니었다. 마감도 현실이고, 품질도 현실이다. 속도를 포기할 수 없듯이 안정성도 포기할 수 없다. 중요한 것은 무엇을 미루고 있는지 알고 미루는 것, 그리고 미룬 대가를 누가 치르게 되는지 외면하지 않는 것이었다.
민준은 문서 맨 위에 제목을 붙였다.
반복자 안정성과 팀 규칙 제안
딱딱한 제목이었다. 하지만 그 안에는 더 많은 뜻이 들어 있었다. 더 이상 낡은 반복자를 붙잡지 않겠다는 뜻. 컨테이너가 바뀌었는데도 예전 약속을 그대로 믿지 않겠다는 뜻. 침묵이 편하다는 이유로 위험을 지나치지 않겠다는 뜻.
그는 공유 버튼을 눌렀다.
잠시 뒤, 채널에 알림이 떴다. 누군가 문서를 열었다. 또 다른 누군가가 댓글을 달기 시작했다. 반응이 어떻게 흘러갈지는 알 수 없었다. 불편한 질문이 이어질 수도 있고, 방어적인 말들이 오갈 수도 있다. 기능 추가를 우선하자는 쪽에서는 민준의 제안을 발목 잡기로 볼지도 모른다.
그래도 그는 더 이상 관망하지 않기로 했다.
균열의 중간점은 애매한 장소가 아니다. 한쪽으로 무너질 수도, 반대쪽으로 다리를 놓을 수도 있는 자리다. 그곳에 서게 되면 결국 선택해야 한다. 못 본 척 물러설 것인지, 아니면 흔들리는 틈을 들여다보고 손을 댈 것인지.
민준은 노트북을 닫고 자리에서 일어났다. 내일은 코드만 고치는 날이 아닐 것이다. 누군가는 속도를 말할 것이고, 누군가는 책임을 말할 것이다. 그리고 그 사이에서 민준은 자신이 지키기로 한 것을 분명히 말해야 한다.
문을 나서기 전, 그는 불 꺼진 회의실을 한 번 돌아보았다.
조용했다.
하지만 이제 그 침묵은 끝나 가고 있었다.
이 노트는 보도·비평·교육·연구 목적으로 작성되었습니다. 저작권 문의가 있으시면 에서 알려주세요.
