16장: 반복문 제어

16장: 반복문 제어

  • “루프”는 반복 제어 구조 전반을 가리키는 비공식적 용어 — 코드 블록을 반복 실행하게 하는 모든 구조

16.1 반복문의 종류 선택

루프의 네 종류

  1. 계수 루프(counted loop) — 정해진 횟수만큼 실행 (예: 직원 한 명당 한 번)
  2. 연속 평가 루프(continuously evaluated loop) — 실행 횟수를 미리 모르고, 매 반복마다 끝났는지 검사 (예: 돈이 남아 있는 동안, 사용자가 quit을 누를 때까지)
  3. 무한 루프(endless loop) — 시작하면 영원히 실행 (심박조율기, 전자레인지, 크루즈 컨트롤 같은 임베디드 시스템)
  4. 반복자 루프(iterator loop) — 컨테이너의 각 요소마다 한 번씩 실행

루프를 구분하는 두 기준

  • 유연성: 정해진 횟수를 도는가 vs 매 반복 완료를 검사하는가
  • 종료 검사 위치: 시작 / 중간 / 끝 — 최소 실행 횟수를 결정함
    • 시작에서 검사 → 본문이 한 번도 실행 안 될 수 있음
    • 끝에서 검사 → 본문이 최소 1회 실행됨
    • 중간에서 검사 → 검사 앞부분은 최소 1회, 뒷부분은 0회일 수 있음
  • 표 16-1: 언어별 루프 종류와 유연성·검사 위치 (C 계열: for/while 유연-시작, do-while 유연-끝, foreach 고정-시작)

while 루프를 쓸 때

  • 초보자의 오해: while 조건이 거짓이 되는 순간 어디서든 즉시 종료된다고 생각함 (Curtis et al. 1986). 실제로는 반복마다 평가
  • 반복 횟수를 미리 모른다면 while — 핵심 쟁점은 검사를 시작에 둘지 끝에 둘지
    • 시작 검사: while
    • 끝 검사(최소 1회 실행이 필요할 때): do-while

loop-with-exit 루프를 쓸 때

  • 종료 조건이 시작도 끝도 아닌 중간에 있는 루프
  • VB는 Exit Do로 명시 지원, C++/C/Java는 while (true) + break로 흉내 냄
  • 전형적 용도: “한 번 반 루프(loop-and-a-half)”가 되는 경우
    • 중간에 탈출 가능성 있으면

아래는 나쁜 예: 루프 진입 전 2줄이 루프 바닥 2줄과 중복됨 → 수정할 때 두 곳을 나란히 고쳐야 한다는 것을 잊기 쉬움

// 나쁜 버전: 중복 코드
score = 0;
GetNextRating( &ratingIncrement );      // 이 두 줄이
rating = rating + ratingIncrement;
while ( ( score < targetScore ) && ( ratingIncrement != 0 ) ) {
    GetNextScore( &scoreIncrement );
    score = score + scoreIncrement;
    GetNextRating( &ratingIncrement );  // 여기 반복됨
    rating = rating + ratingIncrement;
}
// loop-with-exit 버전: 유지보수하기 쉬움
score = 0;
while ( true ) {
    GetNextRating( &ratingIncrement );
    rating = rating + ratingIncrement;
    if ( !( ( score < targetScore ) && ( ratingIncrement != 0 ) ) ) {
        break;
    }
    GetNextScore( &scoreIncrement );
    score = score + scoreIncrement;
}
  • 사용 시 세부 사항
    • 종료 조건을 한곳에 모을 것 — 흩어 놓으면 디버깅·수정·테스트에서 하나쯤 놓치는 것이 거의 보장됨
    • 언어가 직접 지원하지 않으면 주석으로 의도를 분명히 할 것

Students scored 25 percent higher on a test of comprehension when loop-with-exit loops were used, and the authors of the study concluded that the loop-with-exit structure more closely models the way people think about iterative control than other loop structures do.

“Software Productivity Cosortium 1989” 연구 결과에서 loop-with-exit 반복문이 다른 반복문에 비해서 이해하기 쉽다고 알려짐

비정상 loop-with-exit (Coding Horror)

goto로 루프 중간에 진입하는 코드 — 첫 반복에서 앞부분을 건너뛰고 싶을 때 쓰는 트릭

문제점 2가지:

  1. goto를 씀
  2. 특이해서 혼란스러움

해법: while (true) + break로 블록 앞뒤 순서를 바꾸면 goto 없이 같은 효과

for 루프를 쓸 때

  • 정해진 횟수 실행에 좋은 선택
  • 내부 루프 제어가 필요 없는 단순한 활동에 사용 — 단순 증감으로 컨테이너를 순회하는 정도
  • for의 요점: 루프 위에서 설정하고 잊어버리는 것. 루프 안에서 제어를 위해 무언가 해야 한다면 while을 쓸 것
  • for 인덱스 값을 강제로 바꿔 종료시키지 말 것 — 복잡한 루프 작업 대부분은 while이 더 잘 처리함

foreach 루프를 쓸 때

  • 배열이나 컨테이너의 각 멤버에 연산을 수행할 때 유용 (C#의 foreach, VB의 For-Each, Python의 for-in)
  • 루프 관리 산술(housekeeping arithmetic)을 제거함 → 그 산술에서 생길 오류 가능성 자체를 제거
  • 당시 Java에는 계획만 있었음 — 지금은 Java의 for-each, C++의 range-based for 등 주류 언어 대부분이 지원

16.2 반복문 제어

루프에서 잘못될 수 있는 것들: 초기화 누락/오류, 누산기 초기화 누락, 부적절한 중첩, 잘못된 종료, 증가 누락/오류, 루프 인덱스로 배열을 잘못 인덱싱

First, minimize the number of factors that affect the loop. Simplify! Simplify! Simplify! Second, treat the inside of the loop as if it were a routine—keep as much of the control as possible outside the loop.

두 가지 실천으로 예방

  1. 루프에 영향을 주는 요인의 수를 최소화 — 단순화!
  2. 루프 내부를 루틴처럼 취급 — 제어를 최대한 루프 밖에(루틴의 입력 처럼)둘 것
  • 루프를 블랙박스로 생각: 주변 프로그램은 제어 조건만 알고, 내용은 모름
    • while ( !inputFile.EndOfFile() && moreDataAvailable ) — 본문을 안 봐도 종료 조건을 앎
    • 단, while(true)+break 기법을 쓰면 종료 조건이 블랙박스 안으로 들어가 이 이점을 잃음

루프 진입

루프에는 한 위치에서만 진입하라

Enter the loop from one location only

  • 시작/중간/끝 검사 구조가 충분히 풍부하므로 진입은 항상 위에서 — 여러 위치로 진입할 필요가 없음
    • 위에서 본 goto로 루프에 진입하지 마라
초기화 코드는 루프 바로 앞에 둬라

Put initialization code directly before the loop

  • 근접성의 원리(14.2)의 적용 — 관련 문장은 함께
  • 초기화를 멀리(선언부, 루틴 상단 관리 구역) 두면: 루프를 더 큰 루프로 일반화할 때, 루프를 다른 루틴으로 옮기거나 복사할 때 초기화 코드 수정을 놓칠 수 있음
무한 루프에는 while( true )를 써라

Use while( true ) for infinite loops

  • for i = 1 to 99999 같은 가짜 무한 루프는 나쁜 선택 — 99999가 정당한 한계처럼 보여 의도를 흐리고, 유지보수에서 무너짐
  • while ( true )가 표준 관용구. for ( ;; )도 수용되는 대안
적절하다면 for 루프를 선호하라

Prefer for loops when they’re appropriate

  • for는 루프 제어 코드를 한곳에 모아 줌
    • 상단에 모임
  • 흔한 수정 실수: while 루프 위의 초기화만 고치고 바닥의 관련 코드를 잊는 것 — for는 관련 코드가 전부 상단에 있어 올바른 수정이 쉬움
while이 더 적절할 때 for를 쓰지 마라

Don’t use a for loop when a while loop is more appropriate

  • C 계열 for의 흔한 남용: while 루프의 내용물을 for 헤더에 아무렇게나 쑤셔 넣기 (Coding Horror)
// 나쁜 예: while의 내용이 for 헤더에 밀려 들어감
for ( inputFile.MoveToStart(), recordCount = 0; !inputFile.EndOfFile();
        recordCount++ ) {
    inputFile.GetRecord();
}
  • for 헤더는 루프 제어 문장(초기화, 종료 검사, 종료를 향한 전진)에만 쓸 것
    • 위 예에서 루프를 전진시키는 것은 GetRecord()이지 recordCount++가 아님 — recordCount는 관리(housekeeping)일 뿐인데 헤더에 있으면 루프를 제어한다는 거짓 인상(false impression)을 줌
  • 이 작업에는 결국 while이 더 적절함:
inputFile.MoveToStart();
recordCount = 0;
while ( !inputFile.EndOfFile() ) {
    inputFile.GetRecord();
    recordCount++;
}

루프 중간 처리

{ }로 루프 문장들을 감싸라

Use { and } to enclose the statements in a loop

  • 매번 쓸 것 — 런타임 비용이 전혀 없고, 가독성을 높이고, 수정 시 오류를 예방하는 방어적 프로그래밍 실천법
빈 루프를 피하라

Avoid empty loops

// 빈 루프: 일과 종료 검사가 while 식 안에 뭉쳐 있음
while ( ( inputChar = dataFile.GetChar() ) != CharType_Eof ) {
    ;
}

// 일이 보이는 루프로 변환
do {
    inputChar = dataFile.GetChar();
} while ( inputChar != CharType_Eof );
루프 관리 잡무는 시작이나 끝에 모아라

Keep loop-housekeeping chores at either the beginning or the end of the loop

  • housekeeping = i = i + 1, j++처럼 루프의 일이 아니라 루프 제어가 목적인 식

As a general rule, the variables you initialize before the loop are the variables you’ll manipulate in the housekeeping part of the loop.

  • 일반적으로, 루프 시작 전에 초기화하는 변수들이 바로 housekeeping 부분에서 값을 갱신하게 되는 변수들임
각 루프는 한 가지 기능만 수행하게 하라

Make each loop perform only one function

  • 루프 하나로 두 가지를 할 수 있다는 사실이 두 가지를 같이 할 충분한 이유는 아님 — 루프도 루틴처럼 한 가지 일만 잘해야 함
  • 효율이 걱정되면: 일단 두 루프로 쓰고, 합칠 수 있다고 주석을 달고, 벤치마크가 실제 성능 문제를 보여줄 때까지 기다릴 것

루프 종료

루프가 끝난다는 것을 스스로 확신하라

Assure yourself that the loop ends

  • 기본 중의 기본 — 정상 케이스, 끝점, 예외 케이스 각각에 대해 머릿속으로 실행을 시뮬레이션
종료 조건을 명백하게 만들어라

Make loop-termination conditions obvious

  • 핵심은 제어를 한곳에 두는 것
    • for 인덱스로 장난 안치기
    • for문에서 goto/break로 안 빠져나가기
    • while 절에 제어를 다 모으기
for 루프의 인덱스를 조작해 종료시키지 마라

Don’t monkey with the loop index of a for loop to make the loop terminate

// Coding Horror: 조기 종료를 위해 i에 100을 대입
for ( int i = 0; i < 100; i++ ) {
    ...
    if ( ... ) {
        i = 100;
    }
    ...
}
  • “사실상 모든 좋은 프로그래머가 피하는 관행 — 아마추어의 표시
  • for를 설정했으면 카운터는 출입 금지. 출구 조건 제어가 더 필요하면 while을 쓸 것
인덱스의 최종값에 의존하는 코드를 피하라

Avoid code that depends on the loop index’s final value

  • 루프가 끝난 뒤 인덱스 값을 쓰는 것은 나쁜 형식 — 최종값은 언어·구현마다 다르고, 정상 종료와 비정상 종료 때도 다름
  • 나쁜 예: break로 빠져나온 뒤 if ( recordCount < MAX_RECORDS )로 발견 여부 판단 — 인덱스가 끝을 지나 증가했는지 기억하기 어려워 off-by-one을 만들기 쉬움
    • off-by-one: 하나 차이 나는 오류

아래와 같이 재작성: 적절한 지점에서 최종값을 변수(found)에 대입

found = false;
for ( recordCount = 0; recordCount < MAX_RECORDS; recordCount++ ) {
    if ( entry[ recordCount ] == testValue ) {
        found = true;
        break;
    }
}
...
return( found );
  • 불린 변수 하나를 더 쓰는 대신 코드가 명확해짐
안전 카운터 사용을 고려하라

Consider using safety counters

  • 매 반복 증가시키는 안전 카운터로 루프가 너무 많이 돌지 않았는지 확인 (safetyCounter >= SAFETY_LIMIT이면 Assert)
  • 만능약은 아님 — 반복문 하나씩 도입하면 복잡성만 늘고 추가 오류를 부름.
    • 도입할거면 프로젝트 전체 표준으로 중요 루프에 도입해야 기대 가능한 코드가 됨

루프 조기 탈출

while 루프에서 불린 플래그 대신 break를 고려하라

Consider using break statements rather than boolean flags in a while loop

  • 탈출을 흉내 내려고 불린 플래그를 덧대면 오히려 읽기 어려워지는 경우가 있음
  • break를 쓰면 여러 단계의 들여쓰기와 if 검사 연쇄를 제거할 수 있음.
  • 여러 break 조건을 분리해 원인 코드 가까이에 두면 중첩이 줄어 읽기 쉬워짐
break가 잔뜩 흩어진 루프를 경계하라

Be wary of a loop with a lot of breaks scattered through it

  • break가 많다는 것은 루프 구조에 대한 불명확한 사고의 표시일 수 있음 — 출구가 많은 루프 하나보다 루프 여러 개가 더 명확할 가능성

the software error that brought down the New York City phone systems for 9 hours on January 15, 1990, was due to an extra break statement

  • 실화: 1990년 1월 15일 뉴욕시 전화 시스템을 9시간 다운시킨 오류가 break 문 하나 때문 (SEN 1990)
    • do-switch-if 블록에서 if를 빠져나가려던 break가 switch를 빠져나감
  • break 여러 개가 반드시 오류는 아니지만, 루프 안의 break 무리는 경고 신호 — “탄광의 카나리아”
루프 상단의 검사에는 continue를 써라

Use continue for tests at the top of a loop

  • 좋은 용도: 상단에서 조건을 검사하고 본문을 건너뛰기 (레코드를 읽고, 대상이 아닌 종류는 버리고, 대상만 처리하는 패턴)
  • 본문 전체를 들여쓰게 만들 if 문을 피할 수 있음

책의 의사코드 예제 — 상단 continue (원서 380쪽)

while ( not eof( file ) ) do
    read( record, file )
    if ( record.Type <> targetType ) then
        continue
    -- process record of targetType
    ...
end while
  • continue 없이 쓰면 본문 전체가 if 안으로 들어가 한 단계 들여써짐:
while ( not eof( file ) ) do
    read( record, file )
    if ( record.Type = targetType ) then
        -- process record of targetType
        ...
    end if
end while
  • 반대로 continue가 루프 중간이나 끝에 온다면 if를 쓸 것 (아래 대비 예시는 원문에 없는 보충)
-- 나쁜 예: 중간의 continue — 어디서부터 건너뛰는지 위를 다 읽어야 앎
while ( not eof( file ) ) do
    read( record, file )
    ValidateRecord( record )
    ComputeSubtotal( record )
    if ( record.IsDuplicate ) then
        continue
    UpdateStatistics( record )
    WriteRecord( record, outputFile )
end while

-- if로 재작성: 건너뛰는 범위가 블록으로 눈에 보임
while ( not eof( file ) ) do
    read( record, file )
    ValidateRecord( record )
    ComputeSubtotal( record )
    if ( not record.IsDuplicate ) then
        UpdateStatistics( record )
        WriteRecord( record, outputFile )
    end if
end while
  • 중간의 continue는 “여기서부터 끝까지 건너뜀”인데 그 영향권이 눈에 안 보임. if 블록은 영향권이 블록 경계로 보임
언어가 지원하면 labeled break를 써라

Use the labeled break structure if your language supports it

  • Java의 labeled break는 NYC 전화 사고 같은 문제를 예방 — break CALL_CENTER_DOWN;처럼 어느 블록을 빠져나가는지가 모호하지 않음
break와 continue는 조심해서만 써라

Use break and continue only with caution

  • break는 루프를 블랙박스로 취급할 가능성을 없앰
    • break 남발하지 말기
    • 반복문 종료 제어 문장을 하나로 제한하는 것이 루프를 단순화하는 강력한 방법
  • break/continue가 옳은지 그른지는 학자들도 결론 못 냄 — 쓰되, 틀렸을지 모른다는 두려움과 함께 쓸 것

It really is a simple proposition: if you can’t defend a break or a continue, don’t use it.

  • 단순한 명제: break나 continue를 방어할 수 없다면 쓰지 마라

종결점 검사

When you create a loop, mentally run through the first, middle, and last cases to make sure that the loop doesn’t have any off-by-one errors.

  • 루프 하나의 관심 케이스는 셋: 첫 케이스, 임의의 중간 케이스, 마지막 케이스 — 머릿속으로 돌려 off-by-one이 없는지 확인. 특수 케이스가 있으면 그것도
  • 효율적인 프로그래머 vs 비효율적인 프로그래머
    • 효율적: 정신적 시뮬레이션과 손 계산을 함 — 그것이 오류를 찾아 준다는 것을 알기 때문
    • 비효율적: 되는 조합을 찾을 때까지 무작위 실험 — <<=로 바꿔 보고, 안 되면 인덱스에 1을 더하거나 빼 봄. 우연히 돌아가게 돼도 왜 옳은지 모름, 혹은 원래 오류를 더 미묘한 오류로 교체했을 뿐

루프 변수 사용

배열과 루프의 한계에는 서수형이나 열거형을 써라

Use ordinal or enumerated types for limits on both arrays and loops

  • 루프 카운터는 일반적으로 정수 — 부동소수점은 증가가 제대로 안 됨
    • 26,742,897.0 + 1.0 = 26,742,897.0 (그대로..)

ordinal type(서수형)은 Pascal/Ada 계열에서 나온 분류: 이산적인 순서열을 가져야함. 정수, 문자, 불린, 열거형

중첩 루프에는 의미 있는 변수 이름을 써라

Use meaningful variable names to make nested loops readable

// Coding Horror: transaction의 인덱스들이 무엇을 뜻하는지 알 수 없음
sum = sum + transaction[ j ][ i ][ k ];

// 인덱스 순서가 맞는지도 확인 가능
sum = sum + transaction[ month ][ payCodeIdx ][ divisionIdx ];
  • 1차원 배열이면 i, j, k로 버틸 수 있지만, 2차원 이상이면 의미 있는 인덱스 이름을 쓸 것
  • 컴퓨터는 두 버전을 똑같이 읽지만 사람은 두 번째가 쉬움 — 주 독자는 컴퓨터가 아니라 인간
의미 있는 이름으로 인덱스 혼선(cross-talk)을 피하라

Use meaningful names to avoid loop-index cross-talk

  • i, j, k의 습관적 사용이 낳는 문제: 같은 중첩 구조 안에서 i를 두 가지 용도로 사용 (안쪽 루프의 for ( i = ... 가 바깥 i와 충돌)
  • 루프 본문이 몇 줄을 넘거나, 커질 것 같거나, 중첩 루프 안이라면 i, j, k를 피할 것
루프 인덱스 변수의 스코프를 루프 자체로 제한하라

Limit the scope of loop-index variables to the loop itself

  • Ada는 for 인덱스를 루프 밖에서 쓰면 컴파일 오류 — 언어가 강제
  • C++/Java는 루프 안 선언을 허용(강제는 아님): for ( int recordCount = 0; ... )
  • 단, 컴파일러의 스코프 강제를 믿지 말 것 — 저자가 C++ 컴파일러 3종으로 시험했더니 세 가지 다른 결과가 나옴 (오류 / 루프 밖 사용 허용 / 정확한 스코프 강제)

루프의 길이

  • 한눈에 다 보이게 짧게 — 모니터가 50줄을 보여 주면 50줄 제한. 전문가들은 1페이지 제한을 제안해 왔지만, 단순한 코드의 원리를 체득하면 15~20줄을 넘는 루프를 쓰는 일이 드묾
  • 중첩은 3단계까지 — 연구에 따르면 3단계를 넘으면 프로그래머의 루프 이해력이 크게 저하됨 (Yourdon 1986a). 넘어간다면 일부를 루틴으로 빼거나 제어 구조를 단순화할 것
  • 긴 루프의 내부는 루틴으로 옮겨라 — 잘 설계된 루프라면 내부 코드를 루틴 한두 개로 옮길 수 있음
  • 긴 루프는 특별히 명확하게 — 짧은 루프에서는 break/continue, 다중 출구, 복잡한 종료 조건 같은 위험한 구조를 쓸 수 있지만, 길게 쓴다면 단일 출구에 명백한 출구 조건을 갖출 것

16.3 반복문을 쉽게 작성하는 방법 — 안에서 밖으로

일반 과정: 한 케이스로 시작 → 리터럴로 코딩 → 들여쓰고 루프로 감싸기 → 리터럴을 인덱스/계산식으로 교체 → 필요한 만큼 반복 → 초기화 추가

  • 단순한 케이스에서 시작해 바깥으로 일반화하므로 “안에서 밖으로(inside out)” 코딩

책 예제: 보험사에서 나이·성별에 따라 달라지는 생명보험 요율로 그룹 전체 보험료 합계 계산

  1. 루프 본문이 할 일을 주석으로 씀 (문법, 인덱스 걱정 없이)
    -- get rate from table
    -- add rate to total
    
  2. 주석을 코드로 변환 — 구체적이고 특정한 데이터로, 한 사람 분만
    rate = table[ ]
    totalRate = totalRate + rate
    
  3. 배열 인덱스를 채움
    rate = table[ census.Age ][ census.Gender ]
    totalRate = totalRate + rate
    
  4. 기존 문장을 루프로 감쌈 (사람 단위로 반복하므로 person으로 인덱싱)
    For person = firstPerson to lastPerson
     rate = table[ census.Age, census.Gender ]
     totalRate = totalRate + rate
    End For
    
  5. 루프 인덱스에 의존하는 변수를 일반화 — census는 person에 따라 변함
    For person = firstPerson to lastPerson
     rate = table[ census[ person ].Age, census[ person ].Gender ]
     totalRate = totalRate + rate
    End For
    
  6. 필요한 초기화를 씀 — totalRate = 0
  • 단계를 엄격히 따를 필요는 없음.
  • 핵심은 구체적인 것에서 시작해, 한 번에 한 가지만 걱정하며, 단순한 구성 요소로부터 쌓아 올리는 것
  • 한 번에 집중해야 하는 코드 양을 최소화 = 오류 가능성 최소화 (9장 의사코드 프로그래밍 프로세스와 유사)

16.4 반복문과 배열의 관계

  • 루프와 배열은 자주 엮임 — 배열 조작을 위해 루프를 만들고, 루프 카운터와 배열 인덱스가 1:1로 대응하는 경우가 많음
// Java: 이 배열 연산에는 루프가 필요함
for ( int row = 0; row < maxRows; row++ ) {
    for ( int column = 0; column < maxCols; column++ ) {
        product[ row ][ column ] = a[ row ][ column ] * b[ row ][ column ];
    }
}
  • 하지만 루프 구조와 배열이 본질적으로 연결된 것은 아님 — APL, Fortran 90+ 같은 언어는 루프가 필요 없는 강력한 배열 연산을 제공

아래는 배열의 요소를 곱하는 같은 예제의 두 버전 — 반복문 수도 코드 vs APL

-- 반복문 버전: 같은 위치의 요소끼리 곱해 product에 저장
For row = 1 to maxRows
    For column = 1 to maxCols
        product[ row ][ column ] = a[ row ][ column ] * b[ row ][ column ]
    End For
End For
product <- a x b
  • APL 쪽이 더 단순하고 오류 가능성이 적음: 피연산자 3개 vs Java 17개. 잘못 코딩할 루프 변수도, 배열 인덱스도, 제어 구조도 없음

여기서 말하는 요점이 프로그래밍에서 “문제를 풀기 위한” 부분과 “특정 언어로 풀기 위한” 부분이 따로 있다는 것 — 사용하는 언어가 해법에 상당한 영향을 줌

루프가 필요 없는 연산은 요즘으로 치면 다음과 같음

  • NumPy의 벡터화 연산
  • map/filter
  • SQL의 집합 연산

results matching ""

    No results matching ""