CodeGym /행동 /C++ SELF /std::vector 순회: 인덱스 vs range-for

std::vector 순회: 인덱스 vs range-for

C++ SELF
레벨 12, 레슨 4
사용 가능

1. 세 가지 순회 방식

벡터를 "돌아보는" 서로 다른 세 가지 방법을 처음 보면 자연스러운 질문이 생깁니다: “왜 하나의 괜찮은 루프만 두지 않았나?” 좋은 질문입니다. 현실은 다음과 같습니다: 서로 다른 문제가 컬렉션을 바라보는 서로 다른 관점을 필요로 합니다 — 때로는 요소의 번호가 필요하고, 때로는 요소 자체만 필요하며, 때로는 이후 다른 연산에 사용할 수 있는 컨테이너 내의 “위치”(이터레이터)가 필요합니다.

지금부터 우리가 비교할 것은 다음과 같습니다:

  • 인덱스 루프: i를 = 0..size()-1로 이동하며 v[i]에 접근합니다.
  • range-for: for (auto x : v)처럼 작성하여 수동 인덱스 없이 요소를 얻습니다.
  • 이터레이터: for (auto it = v.begin(); it != v.end(); ++it)처럼 작성하고 *it로 작업합니다.

이 세 가지 방법은 누가 더 낫다를 가리는 경쟁자가 아니라 도구 상자의 서로 다른 도구들입니다. 드라이버, 렌치, 망치 같은 관계: 기술적으로 망치로 나사를 박을 수는 있지만 보통 결과가 좋지 않습니다.

인덱스 루프: 요소의 번호가 필요할 때

인덱스 루프는 가장 직접적인 접근 방식으로 보통 처음 떠오르는 방법입니다. i 변수를 명시적으로 만들고 경계를 정해 각 반복에서 v[i]를 가져옵니다. 이는 페이지 번호가 있는 책을 넘기는 것과 비슷합니다 — 때로는 텍스트 자체가 아니라 페이지 번호가 필요합니다.

여기서 가장 중요한 생각: 루프는 size()를 기준으로 돌아야 하며, capacity()로 돌면 안 됩니다. 조건은 거의 항상 i < v.size() 형태입니다. 왜 <=가 아닌가요? 마지막 허용 가능한 인덱스는 size() - 1이고, size()는 이미 "마지막 뒤"의 위치이기 때문입니다.

기본 인덱스 순회

#include <cstddef>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{10, 20, 30};

    for (std::size_t i = 0; i < scores.size(); ++i) {
        std::cout << "scores[" << i << "]=" << scores[i] << '\n';
    }
}
// scores[0]=10
// scores[1]=20
// scores[2]=30

여기서는 인덱스와 요소가 명확히 있습니다. 번호 → 값 형태로 출력하거나, 위치에 따라 요소를 변경하고자 할 때 편리합니다.

인덱스 루프가 특히 유용한 경우

인덱스 루프는 프로그램이 "어디에" 요소가 있는지를 기억해야 할 때 유리합니다. 예를 들어, 처음으로 60보다 작은 값을 찾아 그위치를 출력하고 싶다면 인덱스 루프가 자연스럽습니다. range-for로도 할 수 있지만 그 경우 인덱스를 별도로 관리해야 합니다.

range-for: 가독성과 복사에 대한 주의점

range-for(range-based for)는 보통 루프를 시각적으로 짧게 만들어 주기 때문에 선호됩니다: "벡터의 각 요소에 대해 …"라고 말하면 됩니다. 사람의 언어에 가깝고, 솔직히 말해 "컴퓨터, 멋지게 처리해줘"라는 우리의 작은 바람과 닮아 있습니다 — i < size()를 쓰기 싫을 때 특히요.

하지만 range-for에는 초보자들이 종종 이상한 동작을 겪게 만드는 주의점이 있습니다: 요소의 복사본으로 작업할 수 있다는 점입니다. 즉, 여러분은 x를 바꾼 것처럼 보여도 실제 벡터는 그대로일 수 있습니다 — 왜냐하면 임시 복사본을 바꿨기 때문입니다.

작은 역사적 주석: range-for의 세부 의미도 표준 문서에서 한때 명확히 정리되고 수정된 바 있습니다.

range-for는 읽기 전용에 적합

#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{10, 20, 30};

    for (int x : scores) {              // x — 각 요소의 복사본
        std::cout << x << ' ';
    }
    std::cout << '\n';
}
// 10 20 30

여기서는 복사본으로 작업해도 문제가 없습니다. 우리는 단지 읽기만 합니다.

왜 "변경"이 동작하지 않을 수 있는가

#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{10, 20, 30};

    for (int x : scores) {
        x += 5;                         // 복사본을 변경, 벡터는 변경되지 않음
    }

    for (int x : scores) {
        std::cout << x << ' ';
    }
    std::cout << '\n';
}
// 10 20 30

우리가 5를 더한 것처럼 보이지만 실제로는 복사본들에만 더한 것입니다. 벡터 본체는 변하지 않습니다.

range-for로 요소를 변경하려면 & 필요

#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{10, 20, 30};

    for (int& x : scores) {             // x — 요소에 대한 참조
        x += 5;                         // 벡터 자체를 변경
    }

    for (int x : scores) {
        std::cout << x << ' ';
    }
    std::cout << '\n';
}
// 15 25 35

여기서는 실제 컨테이너가 변경됩니다. x가 "원본에 대한 접근"이기 때문입니다. 이 강좌에서는 참조의 이론을 깊게 다루지 않으니 단순히 기억하세요: &는 range-for에서 “복사가 아니라 실제 요소를 작업”한다는 뜻입니다.

이터레이터 begin()/end(): 컨테이너 내의 “위치”

이터레이터라는 이름은 처음에는 겁을 줄 수 있습니다. 마치 "컨테이너의 양자역학" 강의가 시작되는 것처럼 느껴지기도 합니다. 하지만 기본적인 관점에서 이터레이터는 단순히 컨테이너 내의 위치이며, "스마트 포인터"와 비슷한 개념이라고 볼 수 있습니다(포인터 심화 없이도 이해할 수 있습니다).

핵심은 다음과 같습니다:

  • begin() — 첫 요소의 위치(요소가 있다면),
  • end() — 마지막 뒤의 위치입니다.

이 "마지막 뒤"가 핵심입니다. end()는 실제 요소를 가리키지 않으므로 *v.end()를 역참조하면 안 됩니다. 이는 복도 끝의 출구 문과 같아서, 출구 문 자체는 복도의 일부가 아니므로 그 안에 살 수는 없습니다.

C++ 표준에서는 이터레이터와 그 요구사항이 매우 중요하게 다뤄지므로, 이터레이터 섹션에 대한 표준 문서의 수정 보고서들이 종종 존재합니다.

기본 이터레이터 루프

#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{10, 20, 30};

    for (auto it = scores.begin(); it != scores.end(); ++it) {
        std::cout << *it << ' ';        // *it — 현재 요소
    }
    std::cout << '\n';
}
// 10 20 30

여기서 auto는 "마법"이 아니라 긴 이터레이터 타입을 쓰지 않기 위한 편리한 방법입니다. 학습 초기에는 "컴파일러, 타입을 추론해줘"라고 보는 것이 편합니다.

이터레이터를 통한 요소 변경

#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{10, 20, 30};

    for (auto it = scores.begin(); it != scores.end(); ++it) {
        *it += 1;                       // 요소를 "제자리에서" 변경
    }

    for (auto it = scores.begin(); it != scores.end(); ++it) {
        std::cout << *it << ' ';
    }
    std::cout << '\n';
}
// 11 21 31

벡터의 구조(요소 삽입/삭제)를 변경하지 않는 한 이 방식은 예측 가능하게 동작합니다. 삭제 시의 동작과 그로 인한 무효화 문제는 다음 강의 주제입니다(erase와 함께 다룹니다).

3. 어떤 접근을 선택할까 — 코드가 수수께끼가 되지 않게

세 가지 순회 방식을 살펴본 뒤에는 간단한 답을 원하게 됩니다: "어떤 게 정답인가 말해줘". 하지만 정답은 과제에 따라 달라집니다. 아래의 간단한 표는 점술이나 마법 없이 결정을 돕는 지침입니다(그리고 ++it 주위의 신비주의 의식도 제외합니다).

기준 인덱스 (i) range-for 이터레이터 (begin()/end())
단순히 순회하며 출력하기 보통 우수 보통
인덱스(요소 위치)가 필요할 때 최적 불편(별도 카운터 필요) 불편(카운터 필요)
요소를 변경하고 싶을 때 가능 가능하나 & 중요 가능
경계 오류의 위험 존재(off-by-one) 최소 중간(end()는 역참조 불가)
삭제/삽입 같은 연산에 대비 약함 약함 강함(보통 erase가 이터레이터를 받음)

이 내용을 머릿속에 더 잘 각인하려면 간단한 "선택 라우터"를 떠올리면 됩니다:

flowchart TD
    A[벡터를 순회해야 함] --> B{인덱스가 필요한가?}
    B -->|예| C["인덱스 루프 for (size_t i...)"]
    B -->|아니오| D{요소를 변경해야 하나?}
    D -->|아니오| E["range-for: for (auto x : v)"]
    D -->|예| F{나중에 위치가 필요한가?}
    F -->|아니오| G["range-for: for (auto& x : v)"]
    F -->|예| H["이터레이터: for (auto it = begin; it != end; ++it)"]

완벽한 도식은 아니지만 실무에서는 잘 맞습니다: "읽기"의 경우 거의 항상 range-for가 우세하고, "읽기 + 인덱스"는 인덱스 루프, "컨테이너 내 위치"가 필요하면 이터레이터를 선택합니다.

4. 실용 예제: 성적 목록과 세 가지 순회

이제 작지만 실제 애플리케이션처럼 느껴지는 예제를 만들어 봅시다. 사용자로부터 여러 성적을 입력받아 std::vector<int>에 저장한 뒤 출력하고, 보너스 점수를 더하고, 첫 번째 낙제성적을 찾는 프로그램입니다.

중요: 함수는 따로 만들지 않습니다 — 함수는 나중에 다룹니다. 따라서 전체 로직은 main() 안에 있지만 논리는 이해하기 쉬운 블록으로 나뉘어 있습니다.

벡터에 성적 입력받기

순회 예제를 다루려면 먼저 요소가 있어야 합니다. 실제로는 벡터가 입력, 파일, 계산 결과 등으로 채워집니다. 여기서는 순회할 데이터가 있도록 간단한 입력용 뼈대를 준비합니다.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores;
    int x = 0;

    while (std::cin >> x) {
        scores.push_back(x);
    }

    std::cout << "count=" << scores.size() << '\n';
}
// 입력: 10 20 30
// 출력: count=3

입력이 끝날 때까지 숫자를 읽습니다. Web‑IDE에서는 입력을 멈추고 프로그램을 실행하거나, 문제에서 정해진 고정 입력을 사용하는 방식이 편합니다.

인덱스로 출력하기

사용자에게 값 목록을 보여줄 때는 값뿐 아니라 번호도 필요할 때가 많습니다. 예를 들어 "0 — 우유, 1 — 빵"처럼요. 이럴 때 인덱스 루프가 자연스럽습니다: 번호와 요소를 동시에 제공합니다.

#include <cstddef>
#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{50, 75, 90};

    for (std::size_t i = 0; i < scores.size(); ++i) {
        std::cout << i << ": " << scores[i] << '\n';
    }
}
// 0: 50
// 1: 75
// 2: 90

합계 계산

합계를 구하는 것은 인덱스가 전혀 필요 없는 전형적인 경우입니다. 각 요소를 더하기만 하면 되므로 range-for가 가독성 면에서 최적입니다: 세부 사항이 적을수록 실수할 가능성이 줄어듭니다.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{50, 75, 90};
    int sum = 0;

    for (int x : scores) {
        sum += x;
    }

    std::cout << "sum=" << sum << '\n';
}
// sum=215

모든 요소에 보너스 추가

다음 시나리오는 "데이터를 한꺼번에 조정"하는 경우입니다. 예를 들어 시험이 어려워서 모든 학생에게 +5점을 주는 경우입니다. 이때도 range-for가 좋지만 &를 빼먹으면 복사본만 조정하게 되어 의도한 결과가 나오지 않습니다.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{50, 75, 90};

    for (int& x : scores) {
        x += 5;
    }

    for (int x : scores) {
        std::cout << x << ' ';
    }
    std::cout << '\n';
}
// 55 80 95

임계값보다 낮은 첫 성적 찾기

첫 번째 "낙제" 성적을 찾는 것은 이터레이터가 자연스럽게 보이는 예입니다. begin()에서 end()까지 이동하면서 *it를 확인하고, 찾으면 멈춥니다. 이는 목록을 넘기다가 원하는 항목을 만나는 행위와 비슷하며, 위치 기반 연산에 대비한 스타일이기도 합니다.

#include <iostream>
#include <vector>

int main() {
    std::vector<int> scores{80, 55, 90, 40};
    const int limit = 60;

    for (auto it = scores.begin(); it != scores.end(); ++it) {
        if (*it < limit) {
            std::cout << "first failing score=" << *it << '\n';
            break;
        }
    }
}
// first failing score=55

필요하다면 위치(pos)를 별도로 유지해서 출력할 수도 있습니다. 원칙은 동일합니다.

5. std::vector 순회 시 흔한 실수들

실수 #1: i <= v.size()를 쓰고 i <를 써야 하는 경우.
이것은 전형적인 off-by-one 문제입니다: 불필요한 반복이 발생하여 i가 v.size()와 같아지는 경우가 생깁니다. 이는 이미 마지막 뒤 인덱스이므로 때로는 즉시 충돌하고, 때로는 이상한 동작을 보입니다. 경계를 엄격히 관리하지 않으면 찾기 어려운 버그가 됩니다.

실수 #2: 인덱스에 int를 쓰고 size()와 비교하기.
size()는 std::size_t(부호 없는 타입)를 반환합니다. 인덱스를 int로 선언하고 size()와 비교하면 음수나 다른 산술 연산에서 원치 않는 형 변환이 발생할 수 있습니다. 이 단계에서는 인덱스와 크기는 std::size_t를 사용하는 것이 규칙이라고 생각하세요.

실수 #3: end()를 역참조하기(*v.end() 시도).
end()는 마지막 요소가 아닌 "마지막 뒤" 위치입니다. 역참조하면 안 됩니다. 영화 속 해킹 장면처럼 "거의 동작한다"는 식의 오해는 위험합니다 — 실제로는 정의되지 않은 동작입니다.

실수 #4: 값으로 순회하는 range-for를 사용해도 변경되지 않는다는 의문.
for (int x : v)에서 x는 복사본입니다. 복사본을 변경하면 벡터 본체는 바뀌지 않습니다. 요소를 변경하려면 for (int& x : v)를 사용해야 합니다. 초보자들이 자주 겪는 기만적인 실수입니다.

실수 #5: 순회 중에 벡터 구조를 변경하고도 아무 일도 없을 거라고 생각하기.
루프 내부에서 push_back, erase 등을 실행하면 이터레이터나 인덱스가 무효화되어 접근 오류가 발생할 수 있습니다. 오늘은 경고 정도로만 언급합니다: 안전하게 삭제하는 방법과 어떤 상황에서 무엇이 무효화되는지는 별도의 주제입니다.

1
과제
C++ SELF, 레벨 12, 레슨 4
잠금
카탈로그 출력
카탈로그 출력
1
과제
C++ SELF, 레벨 12, 레슨 4
잠금
대량 보너스
대량 보너스
1
과제
C++ SELF, 레벨 12, 레슨 4
잠금
첫 번째 경보
첫 번째 경보
1
과제
C++ SELF, 레벨 12, 레슨 4
잠금
영으로 이동
영으로 이동
코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION