C++ 반복문: for/while/do-while과 무한 루프·off-by-one 실수 피하기

이 글의 핵심

반복문 버그는 문법보다 경계 조건과 루프 안에서의 컨테이너 수정에서 주로 나옵니다. 순회 중 vector에 원소를 추가해 반복자가 무효화되는 문제, 부동소수점 카운터가 목표값에 정확히 닿지 않는 문제, break·continue가 가장 안쪽 루프에만 적용된다는 점을 예제로 확인합니다.

for 문 기본

// 1부터 10까지 출력
for (int i = 1; i <= 10; i++) {
    cout << i << " ";
}
// 출력: 1 2 3 4 5 6 7 8 9 10

for 문 구조

for (초기화; 조건; 증감) {
    // 반복할 코드
}

// 예시
for (int i = 0; i < 5; i++) {
    cout << i << endl;
}
// 0, 1, 2, 3, 4

세 부분의 실행 순서를 정확히 알아 두면 경계 버그를 읽어 내기 쉽습니다. 초기화는 처음 한 번만 실행되고, 이후 “조건 검사 → 본문 → 증감”이 반복됩니다. 조건이 처음부터 거짓이면 본문은 한 번도 실행되지 않습니다. 초기화에서 선언한 i는 루프 안에서만 유효하므로 루프가 끝난 뒤 i를 쓰면 컴파일 에러가 나는데, 이는 실수로 루프 밖에서 카운터를 재사용하는 버그를 막아 주는 장점이기도 합니다. i++와 ++i는 정수에서는 결과가 같고 컴파일러가 같은 코드를 만들지만, 반복자처럼 복사 비용이 있는 타입에서는 ++it가 임시 객체를 만들지 않으므로 C++에서는 전위 증가를 습관으로 두는 경우가 많습니다.

while 문

int i = 1;
while (i <= 10) {
    cout << i << " ";
    i++;
}

do-while 문

int i = 1;
do {
    cout << i << " ";
    i++;
} while (i <= 10);

// 차이점: 최소 1번은 실행
int x = 100;
do {
    cout << "실행됨" << endl;  // 조건이 거짓이어도 1번 실행
} while (x < 10);

for vs while vs do-while: 선택 기준

형태언제 쓰기 좋은가
for반복 횟수를 알거나 초기값·조건·증감을 한 줄에 모아 두고 싶을 때. 0부터 n-1까지의 인덱스 순회가 대표적입니다.
while횟수보다 상태(입력, 파일 끝, 플래그)가 반복을 결정할 때.
do-while본문을 최소 한 번은 실행해야 할 때. 메뉴를 먼저 보여 주고 입력을 검사하는 경우가 대표적입니다.

고정 횟수라면 for (int i = 0; i < n; ++i), 올바른 입력이 들어올 때까지라면 while (true) { ... if (ok) break; }나 do { ... } while (!ok);, 컨테이너 전체 순회라면 아래의 범위 기반 for를 먼저 떠올리면 됩니다. 같은 문제를 여러 문법으로 쓸 수 있으니, 읽는 사람이 조건과 횟수를 가장 빨리 파악할 수 있는 형태를 고르는 것이 기준입니다.

범위 기반 for (C++11)

vector<int> v = {1, 2, 3, 4, 5};

// 읽기 전용
for (int x : v) {
    cout << x << " ";
}

// 수정 가능
for (int& x : v) {
    x *= 2;
}

// 배열도 가능
int arr[] = {1, 2, 3, 4, 5};
for (int x : arr) {
    cout << x << " ";
}

범위 기반 for 심화

vector<int> v = {1, 2, 3};

// 요소 복사 (작은 타입·값 의미론에 적합)
for (int x : v) { /* ... */ }

// 참조: 원본 수정
for (int& x : v) { x *= 2; }

// const 참조: 큰 객체 순회 시 복사 비용 절약
for (const string& s : names) { cout << s << '\n'; }

// C++11 auto (타입이 길 때)
for (const auto& p : pairs) { /* ... */ }

인덱스가 필요 없다면 범위 기반 for가 인덱스 루프보다 읽기 쉽고, 경계를 직접 쓰지 않으니 off-by-one 실수도 생기지 않습니다. 인덱스와 값이 함께 필요하면 전통적인 for (size_t i = 0; i < v.size(); ++i)를 쓰거나, 컴파일러가 지원한다면 C++23의 std::views::enumerate를 쓸 수 있습니다.

범위 기반 for는 컴파일러가 auto&& __range = v; auto __begin = begin(__range); auto __end = end(__range); for (; __begin != __end; ++__begin) { int x = *__begin; ... } 형태로 풀어 쓴 것과 같습니다. end()는 루프 시작 전에 한 번만 계산된다는 점에 주의하세요. 여기서 몇 가지가 나옵니다. 첫째, for (int x : v)처럼 값으로 받으면 원소가 매번 복사되므로 std::string이나 큰 구조체라면 const auto&가 기본값이어야 합니다. 둘째, 순회 대상은 원본 컨테이너의 반복자이지 복사본이 아니므로, 루프 안에서 같은 컨테이너에 push_back하거나 erase하면 반복자가 무효화되어 미정의 동작이 됩니다(아래 “문제 1” 참고). 또 for (auto x : getVector())처럼 임시 객체를 직접 순회하는 것은 수명이 연장되어 안전하지만, for (auto x : getObject().items())처럼 임시 객체의 멤버를 참조로 반환받아 순회하면 C++20까지는 임시 객체가 먼저 파괴되어 댕글링 참조가 됩니다(C++23에서 이 경우의 수명 연장이 추가됨).

break와 continue

break (반복문 종료)

for (int i = 1; i <= 10; i++) {
    if (i == 5) {
        break;  // 5에서 종료
    }
    cout << i << " ";
}
// 출력: 1 2 3 4

continue (다음 반복으로)

for (int i = 1; i <= 10; i++) {
    if (i % 2 == 0) {
        continue;  // 짝수는 건너뛰기
    }
    cout << i << " ";
}
// 출력: 1 3 5 7 9

break·continue가 적용되는 범위

break는 자신을 감싼 가장 안쪽의 반복문이나 switch 하나만 종료하고, 바깥 for까지 한 번에 나가지는 않습니다. 그래서 while 안의 switch에서 쓴 break는 switch만 빠져나가고 while은 계속 돕니다. continue는 현재 반복의 남은 본문을 건너뛰고 같은 루프의 다음 반복으로 갑니다. for에서는 증감식이 실행된 뒤 조건을 검사하지만, while에서는 곧바로 조건으로 가므로 카운터 증가가 continue 뒤에 있으면 무한 루프가 됩니다.

무한 루프와 실전 패턴

while (true)나 for (;;)로 루프를 열고 종료 조건을 본문 안에서 검사하는 패턴은 메뉴, 이벤트 루프, 입력 검증에 자주 씁니다.

while (true) {
    int cmd;
    if (!(cin >> cmd)) {
        cin.clear();
        cin.ignore(10000, '\n');
        continue;  // 잘못된 입력 → 다시
    }
    if (cmd == 0) break;  // 정상 종료
    // ...
}

이런 루프는 어떤 입력이 들어와도 break, return, 예외 중 하나로 빠져나올 경로가 있어야 합니다.

위 코드에서 cin.clear()와 cin.ignore()가 둘 다 필요한 이유가 입력 루프에서 가장 많이 겪는 함정입니다. 숫자 자리에 문자를 입력하면 cin은 실패 상태(failbit)가 되고, 이후의 모든 >>는 입력을 읽지도 않고 즉시 실패합니다. clear()로 상태만 되돌리면 잘못된 문자가 버퍼에 그대로 남아 다음 >>가 또 같은 문자에서 실패하므로 화면에 에러 메시지가 끝없이 찍힙니다. 그래서 상태를 지운 뒤 ignore로 줄 끝까지 버려야 합니다. 10000 대신 std::numeric_limits<std::streamsize>::max()를 쓰면 “줄 길이와 상관없이 끝까지”라는 의도가 정확해집니다. 또 EOF(Ctrl+D, Ctrl+Z)가 들어오면 clear() 후에도 계속 실패하므로, cin.eof()를 확인해 루프를 끝내는 경로도 두는 것이 안전합니다.

중첩 반복문

구구단

for (int i = 2; i <= 9; i++) {
    for (int j = 1; j <= 9; j++) {
        cout << i << " x " << j << " = " << i*j << endl;
    }
}

별 찍기

// 직각삼각형
for (int i = 1; i <= 5; i++) {
    for (int j = 1; j <= i; j++) {
        cout << "*";
    }
    cout << endl;
}
/*
*
**
***
****
*****
*/

안쪽 루프를 나누어 쓰기

중첩 루프에서 바깥 변수는 “몇 번째 줄(또는 몇 단)인가”를, 안쪽 변수는 “그 줄에서 무엇을 반복하는가”를 맡습니다. 줄마다 공백과 별처럼 서로 다른 것을 찍어야 한다면 안쪽 루프를 두 개로 나누고, 각각의 반복 횟수를 바깥 변수 row에 대한 식으로 쓰면 됩니다. 가운데 정렬 삼각형은 5 - row개의 공백과 2 * row - 1개의 별로 이루어집니다.

// 예: 가운데 정렬 삼각형 (공백 + 별)
for (int row = 1; row <= 5; ++row) {
    for (int s = 0; s < 5 - row; ++s) cout << ' ';
    for (int st = 0; st < 2 * row - 1; ++st) cout << '*';
    cout << '\n';
}

중첩 루프 탈출하기

break는 한 단계의 루프만 빠져나옵니다. 바깥 루프까지 끝내는 방법은 다음과 같습니다.

플래그 변수

bool done = false;
for (int i = 0; i < n && !done; ++i) {
    for (int j = 0; j < m; ++j) {
        if (조건) {
            done = true;
            break;
        }
    }
}

바깥 조건에 논리 포함

for (int i = 0; i < n; ++i) {
    bool found = false;
    for (int j = 0; j < m; ++j) {
        if (원하는_값) {
            found = true;
            break;
        }
    }
    if (found) break;
}

작은 함수로 분리

중첩 루프를 함수로 옮기고, 목표를 찾으면 return으로 한 번에 빠져나옵니다. 결과가 필요하면 아래처럼 std::optional로 돌려줄 수 있습니다(<optional>, <vector>, <utility> 필요).

// 목표 좌표를 찾으면 return으로 두 루프를 한 번에 빠져나옴
std::optional<std::pair<int,int>> findTarget(const std::vector<std::vector<int>>& grid, int target) {
    for (int i = 0; i < (int)grid.size(); ++i)
        for (int j = 0; j < (int)grid[i].size(); ++j)
            if (grid[i][j] == target) return std::pair{i, j};
    return std::nullopt;
}

플래그 방식은 조건이 두 곳(바깥 루프 조건, 안쪽 break)에 흩어지고, 탈출 이후에 실행되면 안 되는 코드가 안쪽 루프 뒤에 있으면 그것까지 플래그로 막아야 해서 금방 복잡해집니다. 함수 분리는 탐색 로직에 이름이 생기고 결과를 반환값으로 돌려줄 수 있어서, 코드 리뷰에서 가장 흔히 권하는 방법입니다.

goto (제한적 사용)

C++에는 Java처럼 레이블을 붙인 break가 없어서, 루프 바로 뒤의 레이블로 goto하는 것도 방법입니다(아래 “문제 2” 예제). 앞으로만, 한 곳으로만 점프하는 이 용도는 팀 컨벤션에 따라 허용되기도 하지만, goto는 초기화가 있는 변수 선언을 건너뛸 수 없다는 제약도 있으므로 함수 분리가 어색할 때만 검토합니다.

자주 하는 실수

실수 1: 무한 루프

// 무한 루프
int i = 0;
while (i < 10) {
    cout << i << endl;
    // i++; 없음!
}

// 올바른 코드
int i = 0;
while (i < 10) {
    cout << i << endl;
    i++;
}

실수 2: off-by-one 에러

// 9번만 실행
for (int i = 1; i < 10; i++) {
    // ...
}

// 10번 실행
for (int i = 1; i <= 10; i++) {
    // ...
}

off-by-one은 문법 실수가 아니라 구간 표기의 혼동에서 나옵니다. C++ 표준 라이브러리 전체가 반개구간 [begin, end)를 쓰기 때문에, 인덱스 루프도 for (i = 0; i < n; ++i)로 통일하면 “반복 횟수 = n - 0 = n”이 바로 보이고, 빈 범위(n == 0)도 자연스럽게 처리됩니다. 1부터 세는 루프(i = 1; i <= n)를 섞어 쓰는 순간 배열 인덱스로 arr[i]를 쓸 때 마지막에 범위를 한 칸 넘어서는 버그가 생기기 쉽습니다. 역방향 루프에서 for (size_t i = n - 1; i >= 0; --i)는 size_t가 음수가 될 수 없어 조건이 항상 참인 무한 루프가 되므로, for (size_t i = n; i-- > 0; ) 관용구나 std::views::reverse를 씁니다.

실수 3: 세미콜론

// 세미콜론 있음
for (int i = 0; i < 10; i++);  // 빈 문장!
{
    cout << "한 번만 실행됨" << endl;
}

// 세미콜론 제거
for (int i = 0; i < 10; i++) {
    cout << "10번 실행됨" << endl;
}

실전 예시

예시 1: 소수 판별 및 출력

#include <iostream>
using namespace std;

bool isPrime(int n) {
    if (n < 2) return false;
    for (int i = 2; i * i <= n; i++) {
        if (n % i == 0) return false;
    }
    return true;
}

int main() {
    cout << "100 이하의 소수:" << endl;
    int count = 0;
    
    for (int i = 2; i <= 100; i++) {
        if (isPrime(i)) {
            cout << i << " ";
            count++;
            if (count % 10 == 0) cout << endl;
        }
    }
    
    cout << "\n총 " << count << "개" << endl;
    return 0;
}

isPrime은 2부터 √n까지만 나누어 봅니다. n이 합성수라면 두 약수 중 하나는 반드시 √n 이하이므로 그 이상은 확인할 필요가 없고, 조건을 i <= sqrt(n) 대신 i * i <= n으로 쓰면 부동소수점 계산 없이 정수만으로 비교할 수 있습니다. 바깥 루프가 2부터 100까지 돌고 그 안에서 isPrime의 루프가 도는, 함수로 분리된 중첩 루프 구조입니다. 범위가 크다면 에라토스테네스의 체가 더 빠릅니다.

예시 2: 입력 검증 루프

#include <iostream>
using namespace std;

int main() {
    int age;
    
    while (true) {
        cout << "나이를 입력하세요 (1-150): ";
        cin >> age;
        
        if (cin.fail()) {
            cin.clear();
            cin.ignore(10000, '\n');
            cout << "숫자를 입력하세요!" << endl;
            continue;
        }
        
        if (age >= 1 && age <= 150) {
            break;
        }
        
        cout << "1-150 사이의 값을 입력하세요!" << endl;
    }
    
    cout << "입력된 나이: " << age << endl;
    
    if (age < 20) {
        cout << "미성년자입니다" << endl;
    } else {
        cout << "성인입니다" << endl;
    }
    
    return 0;
}

숫자가 아닌 입력은 cin.fail()로, 범위를 벗어난 숫자는 별도 조건으로 걸러서 올바른 값이 들어올 때까지 반복합니다. 다만 입력 스트림이 EOF에 도달하면 clear() 후에도 계속 실패해 같은 메시지가 끝없이 출력되므로, 실제 프로그램에서는 if (cin.eof()) return 1; 같은 탈출 경로를 추가해야 합니다.

예시 3: 피보나치 수열 생성

#include <iostream>
#include <vector>
using namespace std;

int main() {
    int n;
    cout << "몇 개의 피보나치 수를 생성할까요? ";
    cin >> n;
    
    if (n <= 0) {
        cout << "양수를 입력하세요" << endl;
        return 1;
    }
    
    vector<long long> fib;
    fib.push_back(0);
    if (n > 1) fib.push_back(1);
    
    for (int i = 2; i < n; i++) {
        long long next = fib[i-1] + fib[i-2];
        fib.push_back(next);
    }
    
    cout << "피보나치 수열:" << endl;
    for (int i = 0; i < fib.size(); i++) {
        cout << "F(" << i << ") = " << fib[i] << endl;
    }
    
    return 0;
}

반복 횟수를 사용자가 정하는 루프에서는 값의 범위도 같이 확인해야 합니다. long long(64비트)으로 표현할 수 있는 피보나치 수는 F(92) = 7,540,113,804,746,346,429까지이고, F(93)부터는 부호 있는 정수 오버플로(미정의 동작)가 되어 음수 같은 엉뚱한 값이 출력됩니다. 입력 n에 상한(93)을 두거나, 필요하면 unsigned long long(F(93)까지)이나 큰 수 라이브러리를 쓰는 식으로 루프 횟수와 자료형의 한계를 함께 설계해야 합니다.

자주 발생하는 문제

문제 1: 벡터 크기 변경 중 반복

인덱스 루프의 조건 i < v.size()는 매 반복마다 다시 평가되므로, 루프 안에서 원소를 추가하면 끝이 계속 멀어집니다.

// 위험한 코드
vector<int> v = {1, 2, 3, 4, 5};
for (int i = 0; i < v.size(); i++) {
    v.push_back(i);  // 크기가 계속 증가해 메모리가 바닥날 때까지 끝나지 않음
}

// 올바른 코드 (크기 미리 저장)
vector<int> v = {1, 2, 3, 4, 5};
int size = v.size();
for (int i = 0; i < size; i++) {
    v.push_back(i);  // OK
}

// 읽기만 할 때는 범위 기반 for (원본을 순회하므로 루프 안에서 v를 수정하면 안 됨)
vector<int> v = {1, 2, 3, 4, 5};
for (int x : v) {  // 원소 값은 복사, 순회는 원본 반복자
    cout << x << " ";
}

인덱스 루프에서 push_back하는 것은 “끝나지 않는다”는 논리 버그지만, 범위 기반 for나 반복자 루프 안에서 push_back하는 것은 미정의 동작입니다. vector는 용량이 차면 더 큰 메모리로 원소를 옮기므로 기존 반복자와 참조가 전부 무효화되고, 이후 ++it는 해제된 메모리를 읽습니다. MSVC 디버그 빌드나 libstdc++의 _GLIBCXX_DEBUG 모드는 무효화된 반복자 사용을 assert로 잡아 주지만, 릴리즈 빌드에서는 조용히 이상한 값이 출력되거나 가끔만 크래시가 나서 원인을 찾기 어렵습니다. 순회 중 원소를 지워야 한다면 v.erase(std::remove_if(...), v.end())(C++20은 std::erase_if(v, pred))처럼 순회와 수정을 분리하는 것이 정석입니다. 참고로 int i < v.size()는 부호 있는 정수와 size_t 비교라 -Wall에서 -Wsign-compare 경고가 나므로, 인덱스 타입은 size_t로 맞추는 편이 깔끔합니다.

문제 2: 중첩 루프에서 break/continue

안쪽 루프에서 break해도 바깥 루프는 계속 돕니다. break는 가장 가까운 루프 하나만 종료하기 때문입니다.

// 의도와 다른 코드
for (int i = 0; i < 10; i++) {
    for (int j = 0; j < 10; j++) {
        if (i * j > 50) {
            break;  // 내부 루프만 종료
        }
        cout << i * j << " ";
    }
    cout << endl;  // 여전히 실행됨
}

// 올바른 코드 (플래그 사용)
bool found = false;
for (int i = 0; i < 10 && !found; i++) {
    for (int j = 0; j < 10; j++) {
        if (i * j > 50) {
            found = true;
            break;
        }
        cout << i * j << " ";
    }
}

// 올바른 코드 (goto 사용)
for (int i = 0; i < 10; i++) {
    for (int j = 0; j < 10; j++) {
        if (i * j > 50) {
            goto end_loop;
        }
        cout << i * j << " ";
    }
}
end_loop:
cout << "종료" << endl;

문제 3: 부동소수점 루프 카운터

실수를 루프 카운터로 쓰면 오차가 누적되어 예상한 횟수만큼 반복되지 않습니다.

// 위험한 코드
for (double x = 0.0; x != 1.0; x += 0.1) {
    cout << x << endl;
    // 0.1을 10번 더해도 정확히 1.0이 안될 수 있음!
    // 무한 루프 가능
}

// 올바른 코드 (정수 카운터 사용)
for (int i = 0; i < 10; i++) {
    double x = i * 0.1;
    cout << x << endl;
}

// 부등호로 바꾸면 무한 루프는 피하지만 횟수가 틀릴 수 있음
for (double x = 0.0; x < 1.0; x += 0.1) {
    cout << x << endl;  // 0.1을 10번 더한 값은 0.9999999999999999라 11번 실행됨
}

0.1은 이진 부동소수점으로 정확히 표현되지 않아 약간 작은 값으로 저장되고, 이 오차가 덧셈마다 누적됩니다. IEEE 754 double에서 0.1을 열 번 더하면 1.0이 아니라 0.9999999999999999가 되므로 x != 1.0은 영원히 참(무한 루프)이고, x < 1.0은 한 번 더 실행됩니다. 그래서 반복 횟수가 중요할 때는 정수 카운터로 돌리고 실수값은 i * 0.1처럼 매번 계산하는 방식이 가장 확실합니다. 누적 대신 곱셈으로 계산하면 오차가 반복마다 쌓이지 않습니다.

루프 성능

루프를 빠르게 만드는 데 가장 큰 차이를 내는 것은 알고리즘입니다. 이중 루프 본문을 미세하게 다듬는 것보다 O(n²) 탐색을 정렬 후 이분 탐색(O(n log n))이나 해시 조회(평균 O(n))로 바꾸는 쪽이 대부분 훨씬 효과가 큽니다. 그 다음으로는 메모리 접근 순서가 중요합니다. C++의 2차원 배열 a[i][j]는 행 우선으로 저장되므로 바깥 루프를 행, 안쪽 루프를 열로 두면 메모리를 순서대로 읽어 캐시를 잘 활용하고, 반대로 돌면 매 접근마다 멀리 떨어진 위치를 읽게 됩니다.

i < v.size()를 const auto n = v.size();로 바꾸는 식의 손질은 대개 효과가 없습니다. vector::size()는 인라인되는 간단한 함수라 최적화 빌드에서 컴파일러가 알아서 처리하는 경우가 많고, 루프 안에서 벡터를 수정한다면 오히려 캐시한 값이 틀린 값이 됩니다. 큰 객체를 for (const auto& x : vec)처럼 참조로 순회하는 것은 복사를 실제로 없애므로 의미가 있습니다. 속도 비교는 반드시 -O2 이상으로 빌드한 결과로 하고, 추측보다 프로파일러로 실제로 시간이 쓰이는 곳을 먼저 확인하세요.

같이 보면 좋은 글