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최소 한 번은 본문을 실행해야 할 때. 메뉴·입력 검증처럼 “먼저 보여 주고, 조건을 검사”하는 UI에 적합.

패턴 요약

  • 고정 횟수: 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; for (auto it = begin(__range); it != end(__range); ++it) { int x = *it; ... } 형태로 풀어 쓴 것과 같습니다. 두 가지가 여기서 나옵니다. 첫째, 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까지 한 번에 나가지 않습니다.
  • continue: 현재 반복의 남은 본문을 건너뛰고 같은 루프의 다음 반복으로 갑니다.
  • switch 안의 break: 해당 switch만 빠져나가고, 바깥 while은 계속될 수 있습니다.

무한 루프와 실전 패턴

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

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

주의: 무한 루프는 반드시 어떤 경로에서든 break·return·예외로 빠져나올 수 있게 설계하세요. continue만으로는 조건 변수가 갱신되지 않으면 같은 상태에서 반복만 될 수 있습니다.

위 코드에서 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;
}
/*
*
**
***
****
*****
*/

실전 예제: 구구단·소수·패턴을 한 번에 정리

  • 구구단: 바깥 i는 단(29), 안쪽 j는 곱하는 수(19). 출력 형식만 바꾸면 표·한 줄 정렬 등으로 확장합니다.
  • 소수 찾기: 안쪽에서 2부터 √n까지 나누어 떨어지면 합성수. 바깥 루프는 2부터 N까지 검사(아래 “예시 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';
}

무한 루프 (문법 요약)

// 방법 1
while (true) {
    // ...
    if (조건) break;
}

// 방법 2
for (;;) {
    // ...
    if (조건) break;
}

// 실전 예시
while (true) {
    int choice;
    cout << "메뉴 선택 (0=종료): ";
    cin >> choice;
    
    if (choice == 0) break;
    
    // 메뉴 처리
}

위 무한 루프와 실전 패턴 절과 함께 보면, 입력 검증·메뉴 루프를 같은 방식으로 이어갈 수 있습니다.

중첩 루프 탈출하기

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;
}

작은 함수로 분리

중첩을 void 함수로 옮기고, 목표를 찾으면 return으로 한 번에 빠져나옵니다. 가독성이 가장 좋은 경우가 많습니다.

// 목표 좌표를 찾으면 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 (제한적 사용)

에러 처리나 한곳으로만 점프하는 레이블 패턴에서는 팀 컨벤션에 따라 허용되기도 합니다. 남발하지 말고, 위 방법으로 구조가 너무 지저분할 때만 검토합니다.

아래 “자주 발생하는 문제”의 문제 2와 같은 내용을 더 넓게 적어 둔 절입니다.

자주 하는 실수

실수 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;
}

설명: for 루프를 사용하여 범위 내의 소수를 찾고 출력합니다. 중첩 루프를 활용한 효율적인 소수 판별 알고리즘입니다.

예시 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;
}

설명: while 루프를 사용한 입력 검증 패턴입니다. 올바른 입력이 들어올 때까지 반복하며, 잘못된 입력 타입도 처리합니다.

예시 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;
}

설명: for 루프를 사용하여 피보나치 수열을 생성하고 저장합니다. vector를 활용하여 동적으로 크기를 조절합니다.

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

자주 발생하는 문제

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

증상: 반복 중 벡터 크기가 변경되어 예상과 다른 동작

원인: 루프 조건에서 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는 “vector iterators incompatible”/“can’t increment invalidated vector iterator” 같은 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처럼 매번 계산하는 방식이 유일하게 확실한 방법입니다. 누적 대신 곱셈으로 계산하면 오차가 반복마다 쌓이지 않는다는 장점도 있습니다.

성능 최적화

루프 성능: 실무에서 통하는 팁

  1. 측정 후 최적화
    추측으로 미세한 루프를 바꾸기보다, 프로파일러로 핫스팟을 먼저 확인하세요.

  2. 불변식 끌어올리기 (hoisting)
    루프 안에서 매번 같은 값을 계산한다면, 바깥에서 한 번만 계산해 두세요.

   // 나쁜 예: 매번 size() 호출(작은 오버헤드지만 습관이 중요)
   for (int i = 0; i < v.size(); ++i) { }

   // 좋은 예: 크기 캐시 또는 범위 기반 for
   const auto n = v.size();
   for (size_t i = 0; i < n; ++i) { }
  1. 참조로 순회
    큰 객체를 for (const T& x : vec)처럼 복사 없이 읽으세요.

  2. 캐시 친화적 접근
    2차원 배열을 다룰 때 행·열 순회 순서가 메모리 레이아웃과 맞으면 캐시 효율이 좋습니다(구현·컴파일러에 따라 차이).

  3. 컴파일러 최적화
    릴리즈 빌드에서 -O2 / -O3는 루프 융합·벡터화 등을 시도합니다. 디버그 빌드와 속도를 비교하지 마세요.

  4. 알고리즘 복잡도가 우선
    이중 루프를 미세하게 줄이는 것보다, O(n²)을 O(n log n)으로 바꾸는 자료구조·알고리즘 선택이 훨씬 큰 효과가 날 때가 많습니다.

같이 보면 좋은 글