[C언어 50강] 13강. 반복문 2: do-while, break/continue, goto(개념)

[C언어 50강] 13강. 반복문 2: do-while, break/continue, goto(개념)

반복문을 어느 정도 쓰기 시작하면 곧바로 만나게 되는 질문이 있습니다. “반복을 멈추고 싶을 때는 어떻게 하지?”, “이번 회차만 건너뛰고 다음으로 가려면?”, “조건을 먼저 검사할지, 한 번 실행하고 검사할지?” 같은 질문입니다. 이번 강의는 do-while, break, continue, 그리고 goto를 문법 소개 수준에서 끝내지 않고, 흐름 제어를 설계하는 관점에서 설명합니다. 특히 goto는 무조건 나쁜 문법으로 외우기보다, 왜 위험하고 어떤 제한된 상황에서만 고려되는지까지 함께 보겠습니다.


핵심 개념

  • do-while본문을 최소 1회 실행한 뒤 조건을 검사하는 후검사(post-test) 반복문이다.
  • break는 가장 가까운 반복문/switch를 즉시 종료하고, continue는 현재 회차를 중단하고 다음 회차로 이동한다.
  • goto는 흐름을 라벨로 점프시키는 저수준 도구로, 남용하면 가독성과 안정성을 무너뜨리지만 예외적인 정리(cleanup) 경로에서는 제한적으로 쓰이기도 한다.

개념 먼저 이해하기

많은 입문서가 흐름 제어를 “상황별 문법 암기”로 가르칩니다. 예를 들어 “break는 탈출, continue는 건너뛰기, do-while은 한 번은 실행”처럼요. 이 설명 자체는 맞지만, 실제 코드를 작성할 때는 훨씬 더 중요한 기준이 필요합니다. 바로 흐름의 의도를 읽는 사람에게 얼마나 명확하게 전달하느냐입니다. 프로그램이 돌기만 하면 되는 단계에서 유지보수 가능한 단계로 넘어가려면, 제어문의 선택은 성능보다 의도 표현에 더 큰 영향을 줍니다.

먼저 do-while을 보겠습니다. while은 “조건이 참이면 실행”이라는 전검사(pre-test) 구조이고, do-while은 “일단 실행하고 조건을 본다”는 후검사 구조입니다. 이 차이는 작아 보이지만 설계에서 매우 큽니다. 예를 들어 메뉴를 한 번 보여주고 입력을 받은 뒤, 종료 조건을 검사하는 콘솔 프로그램은 do-while이 모델에 더 잘 맞습니다. 반대로 본문 실행 자체가 위험하거나 비용이 큰 작업(파일 쓰기, 네트워크 전송)은 조건을 먼저 검증하는 while이 안전합니다. 즉 do-while은 “최소 1회 실행이 요구사항인 경우”에 쓰는 문법이지, 단순 취향 선택이 아닙니다.

breakcontinue는 반복문의 제어권을 강제로 바꾸는 장치입니다. 강력한 만큼 남용하면 코드의 선형적인 읽기 흐름을 깨뜨립니다. break는 즉시 탈출이므로, 성능 최적화(더 찾을 필요가 없을 때)와 오류 회피(위험 조건 감지 즉시 종료)에서 유용합니다. 문제는 루프 안에 break가 많아질수록 “어떤 조건에서 끝나는지”를 한눈에 파악하기 어려워진다는 점입니다. 그래서 실무에서는 탈출 조건을 루프 상단 또는 명확한 블록으로 모으는 습관이 중요합니다.

continue도 비슷합니다. “이번 회차만 스킵”은 매우 흔한 요구지만, continue가 여러 곳에 흩어지면 상태 갱신 코드가 건너뛰어지는 버그가 생깁니다. 특히 while에서 반복 조건에 쓰이는 변수 갱신이 continue 아래에 있으면 무한 루프를 만들기 쉽습니다. 이건 문법 문제라기보다 제어흐름 설계 실패입니다. 안전한 패턴은 두 가지입니다. (1) 갱신을 continue 이전에 끝내거나, (2) for 헤더 증감식처럼 갱신이 구조적으로 보장되는 형태를 쓰는 것입니다.

그리고 goto입니다. C를 배우다 보면 “절대 쓰지 마라”는 말과 “에러 정리에선 쓸 수 있다”는 말을 동시에 듣습니다. 둘 다 맥락이 있습니다. goto의 본질은 임의 점프라서, 코드가 크고 분기가 많아질수록 제어흐름 그래프가 복잡해집니다. 테스트가 어렵고, 코드 리뷰에서 의도를 읽기 어려우며, 자원 해제 누락 같은 문제가 숨어들기 쉽습니다. 그래서 애플리케이션 로직에서는 거의 항상 구조화된 제어문(if, for, while, 함수 분리)으로 대체하는 것이 맞습니다.

다만 C는 예외(exception)나 defer가 언어 차원에서 제공되지 않기 때문에, 함수 중간에서 여러 단계 자원 할당이 실패했을 때 공통 정리 코드로 점프하는 용도는 현실적으로 쓰입니다. 예를 들어 파일 열기, 버퍼 할당, 핸들 초기화가 순차적으로 진행되고 2단계에서 실패하면 1단계를 정리해야 하는 패턴이 있습니다. 이때 goto cleanup;은 중복 해제 코드를 줄여 실수를 줄일 수 있습니다. 핵심은 “편해서 점프”가 아니라 “정리 경로 단일화”라는 제한된 목적이어야 한다는 점입니다.

정리하면, 흐름 제어문의 선택 기준은 화려한 문법이 아니라 명확성·안전성·검증 가능성입니다. do-while은 최소 1회 실행 요구를 드러내고, break/continue는 제어를 단순화할 때만 최소한으로 쓰며, goto는 일반 로직에서 피하고 정리 경로 같은 예외적 상황에서만 엄격하게 제한해야 합니다. 이 관점이 잡히면 “돌아가는 코드”가 아니라 “팀이 믿고 고칠 수 있는 코드”에 가까워집니다.

기본 사용

예제 1) do-while: 메뉴를 최소 1회 표시하기

#include <stdio.h>

int main(void) {
    int menu = 0;

    do {
        printf("\n=== 메뉴 ===\n");
        printf("1. 상태 보기\n");
        printf("2. 설정 변경\n");
        printf("0. 종료\n");
        printf("선택: ");

        if (scanf("%d", &menu) != 1) {
            int ch;
            while ((ch = getchar()) != '\n' && ch != EOF) {
                ;
            }
            menu = -1;
            printf("숫자를 입력하세요.\n");
            continue;
        }

        if (menu == 1) {
            printf("현재 상태: 정상\n");
        } else if (menu == 2) {
            printf("설정 변경 로직(예시)\n");
        } else if (menu != 0) {
            printf("지원하지 않는 메뉴입니다.\n");
        }
    } while (menu != 0);

    printf("프로그램을 종료합니다.\n");
    return 0;
}

설명:

  • 메뉴는 최소 한 번 보여줘야 하므로 후검사 구조인 do-while이 요구사항과 일치합니다.
  • 입력 실패 시 버퍼를 비우지 않으면 같은 잘못된 입력이 반복 처리될 수 있습니다.
  • continue를 쓰더라도 종료 조건(menu)이 어떻게 유지/변경되는지 추적 가능해야 합니다.

예제 2) break: 탐색 성공 시 즉시 종료하기

#include <stdio.h>

int main(void) {
    int arr[] = {4, 8, 15, 16, 23, 42};
    int n = (int)(sizeof(arr) / sizeof(arr[0]));
    int target = 23;
    int found_index = -1;

    for (int i = 0; i < n; i++) {
        if (arr[i] == target) {
            found_index = i;
            break;
        }
    }

    if (found_index >= 0) {
        printf("%d를 인덱스 %d에서 찾았습니다.\n", target, found_index);
    } else {
        printf("%d를 찾지 못했습니다.\n", target);
    }

    return 0;
}

설명:

  • 더 이상 순회할 필요가 없을 때 break는 성능과 가독성 모두에 이점이 있습니다.
  • found_index 같은 결과 상태를 별도로 두면 루프 밖에서 의도를 명확히 표현할 수 있습니다.
  • 탈출 조건이 하나로 분명할수록 코드 리뷰와 테스트가 쉬워집니다.

예제 3) continue와 goto(정리 경로) 비교

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int data[] = {10, -3, 25, 0, -1, 8};
    int n = (int)(sizeof(data) / sizeof(data[0]));
    int sum_positive = 0;

    for (int i = 0; i < n; i++) {
        if (data[i] <= 0) {
            continue;
        }
        sum_positive += data[i];
    }
    printf("양수 합계: %d\n", sum_positive);

    FILE *fp = fopen("sample_output.txt", "w");
    char *buffer = NULL;

    if (fp == NULL) {
        printf("파일 열기 실패\n");
        return 1;
    }

    buffer = (char *)malloc(128);
    if (buffer == NULL) {
        printf("메모리 할당 실패\n");
        goto cleanup;
    }

    fprintf(fp, "결과=%d\n", sum_positive);
    printf("파일 기록 완료\n");

cleanup:
    free(buffer);
    fclose(fp);
    return 0;
}

설명:

  • 첫 번째 루프는 continue로 “처리 대상 필터링”을 간결하게 구현합니다.
  • 두 번째 파트는 goto를 일반 로직 점프가 아닌 정리 코드 단일화 용도로 제한했습니다.
  • 자원 해제를 여러 if 분기마다 중복 작성하는 것보다 누락 위험이 줄어드는 패턴입니다.

자주 하는 실수

실수 1) do-while을 while처럼 사용해 의도와 코드가 어긋남

  • 원인: “반복문이면 다 비슷하다”는 생각으로 최소 1회 실행 조건을 고려하지 않음.
  • 해결: 본문이 0회 실행 가능해야 하는지 먼저 판단하고, 요구사항이 1회 보장이라면 do-while을 선택합니다.

실수 2) continue 아래에 중요한 갱신 코드를 둬서 무한 루프 발생

  • 원인: continue가 어디까지 건너뛰는지 흐름 추적을 생략함.
  • 해결: 반복 조건 관련 갱신은 continue 이전에 수행하거나, for 증감식으로 이동해 구조적으로 보장합니다.

실수 3) break/continue를 과도하게 사용해 종료 조건이 분산됨

  • 원인: 예외 상황이 생길 때마다 즉흥적으로 제어문을 추가함.
  • 해결: 루프 진입 전 필터링, 조기 반환, 함수 분리를 활용해 루프 내부 분기를 줄입니다.

실수 4) goto를 업무 로직 분기용으로 남용

  • 원인: 빠르게 동작만 맞추려다 구조화된 설계를 포기함.
  • 해결: 일반 로직에는 사용하지 말고, 에러 정리/자원 해제 경로처럼 제한된 목적에서만 검토합니다.

실무 패턴

  • 탈출 조건 우선 배치: 루프 상단에서 실패/종료 조건을 먼저 처리하면 본문이 핵심 로직만 남아 읽기 쉬워집니다.
  • 가드 스타일 continue: 복잡한 중첩 if를 줄이기 위해 “조건 불충족이면 continue” 패턴을 쓰되, 갱신 누락 위험을 함께 점검합니다.
  • 자원 정리 단일 라벨: C에서 다단계 자원 획득 함수는 cleanup 라벨 하나로 해제 순서를 일원화하면 누락을 줄일 수 있습니다.
  • 루프 상태 변수 명시화: found, done, valid 같은 상태 변수를 명확히 두면 break 이유가 코드에 드러납니다.
  • 리뷰 체크리스트 운영: “탈출 경로 수”, “갱신 누락 가능성”, “정리 코드 중복 여부”를 코드 리뷰 항목으로 고정하면 반복문 관련 버그가 크게 줄어듭니다.

오늘의 결론

do-while, break, continue, goto는 모두 흐름을 바꾸는 강력한 도구입니다. 중요한 건 문법을 많이 아는 것이 아니라, 의도와 안전성을 해치지 않게 제한적으로 사용하는 것입니다. 최소 1회 실행은 do-while, 조기 종료는 명확한 break, 회차 스킵은 신중한 continue, 정리 경로 단일화 같은 예외적 경우에만 goto—이 기준을 지키면 제어흐름이 훨씬 단단해집니다.

연습문제

  1. 숫자를 입력받아 1~9 범위가 아닐 경우 재입력받는 프로그램을 do-while로 작성하세요. 왜 while보다 do-while이 자연스러운지 설명하세요.
  2. 정수 배열에서 첫 번째 음수를 찾으면 즉시 반복을 멈추고 인덱스를 출력하는 코드를 작성하세요. 찾지 못한 경우도 처리하세요.
  3. 파일 열기와 메모리 할당이 연속으로 필요한 함수를 설계하고, 중간 실패 시 정리 경로를 한 곳으로 모으는 방식(goto cleanup)과 중복 해제 방식의 장단점을 비교하세요.

이전 강의 정답

지난 12강(반복문 1: for/while 기본 패턴) 연습문제 예시 정답입니다.

  1. 1~100 합 + 짝수 합
  • for (int i = 1; i <= 100; i++)로 전체 합을 누적하고, if (i % 2 == 0) 조건으로 짝수 합을 별도 누적하면 됩니다.
  1. 0 입력 시 종료, 양수 개수/합계 출력
  • while에서 입력값을 받고 0이면 break로 종료합니다.
  • 종료 전까지 value > 0인 경우에만 countsum을 갱신합니다.
  1. 최댓값/최솟값 탐색에서 i=1부터 시작하는 이유
  • 초기값을 max = arr[0], min = arr[0]으로 잡았기 때문에 첫 원소는 이미 비교에 반영됐습니다.
  • 따라서 두 번째 원소부터(i = 1) 비교하면 중복 비교를 줄이고 의도가 명확합니다.

참고 코드:

#include <stdio.h>

int main(void) {
    int sum_all = 0;
    int sum_even = 0;

    for (int i = 1; i <= 100; i++) {
        sum_all += i;
        if (i % 2 == 0) {
            sum_even += i;
        }
    }

    printf("1~100 합계: %d\n", sum_all);
    printf("짝수 합계: %d\n", sum_even);
    return 0;
}

실습 환경/재현 정보

  • 컴파일러: Apple clang 17.x 또는 gcc 13+
  • 컴파일 옵션: -Wall -Wextra -Werror -std=c11
  • 실행 환경: macOS(arm64), Linux x86_64 터미널
  • 재현 체크:
    • do-whilewhile로 바꿨을 때 첫 실행 보장 여부 차이 확인
    • continue 위치를 바꿔 갱신 누락 시 무한 루프가 생기는지 실험
    • 자원 해제 코드 중복 버전과 goto cleanup 버전을 비교해 누락 가능성 점검