19장: 제어와 관련된 일반적인 이슈

19장: 제어와 관련된 일반적인 이슈

19.1 불린식

  • 순차 실행을 제외한 모든 제어 구조는 불린식 평가에 의존

truefalse 사용

  • 불린 검사에는 0, 1 대신 true, false 또는 언어의 불린형을 사용
    • 숫자 1이 참인지 첫 번째 보고서 종류인지 코드만 보고 구분하기 어려움
  • 불린값은 직접 검사한다: 불린 변수나 불린 함수가 이미 true/false를 돌려주므로, 조건식에서 다시 == true 또는 == false를 붙이지 않는다.
// true인지 확인
// Bad: 불린값을 다시 true와 비교한다.
if (printerError == true) NotifyUserOfError();

// Good: printerError가 true일 때 실행한다.
if (printerError) NotifyUserOfError();

// false인지 확인
// Bad: 불린값을 다시 false와 비교한다.
if (isClosed == false) openStore();

// Good: !는 “false일 때”를 뜻한다.
if (!isClosed) openStore();

// false인지 확인하는 불린 함수도 같은 방식이다.
// Bad
while (queue.isEmpty() == false) {
    process(queue.remove());
}

// Good
while (!queue.isEmpty()) {
    process(queue.remove());
}

복잡한 식 단순화

  • 긴 검사는 의미 있는 중간 불린 변수로 나눔
  • 반복되거나 주 흐름을 가리는 검사는 이름 있는 불린 함수로 추출
    • 함수 이름이 검사 목적을 코드로 문서화하고, 주 흐름은 if (DocumentIsValid(...))처럼 읽힘
Function DocumentIsValid(documentToCheck As Document, _
    lineCount As Integer, inputError As Boolean) As Boolean
    Dim allDataRead As Boolean = documentToCheck.AtEndOfStream And Not inputError
    Dim legalLineCount As Boolean = (MIN_LINES <= lineCount) And (lineCount <= MAX_LINES)
    DocumentIsValid = allDataRead And legalLineCount And Not ErrorProcessing()
End Function
  • 여러 변수 조합의 복잡한 조건은 의사결정 테이블로 대체 가능
    • 코드와 까다로운 제어 구조를 줄이고, 규칙 변경을 데이터 수정으로 한정

중간 불린 변수와 이름 있는 함수는 단순한 분리가 아니라 추상화다. DocumentIsValid() 같은 이름은 주 흐름에서 “무엇을 확인하는지”를 드러내고, 세부 조건은 필요할 때만 읽게 한다. 한 번만 쓰는 복잡한 검사라도 이 효과가 있으면 추출할 가치가 있다.

긍정형 불린식 만들기

  • 부정이 겹친 식은 이해하기 어려움 — 가능하면 긍정형 검사로 바꾸고 if/else 본문을 교환
  • 변수 이름도 statusOK 대신 필요에 따라 errorDetected처럼 의도를 직접 드러내게 선택
  • DeMorgan 법칙으로 부정된 and/or 식을 더 읽기 쉬운 동치식으로 변환 가능

다만 “긍정형”과 “정상 경로를 먼저 배치”가 충돌할 수 있다. 이때는 기계적으로 한 규칙을 따르지 말고, 해당 루틴에서 흔한 경로와 오류 처리가 더 분명해지는 표현을 고른다.

// 전: 부정이 두 번 등장해 읽을 때 해석이 필요하다.
if (!user.isDisabled() && !user.isLocked()) {
    allowLogin();
}

// 후: 정책 자체를 이름 있는 긍정 조건으로 만든다.
if (user.canLogIn()) {
    allowLogin();
}

괄호와 평가 순서

  • 우선순위 암기에 의존하지 말고 논리식을 완전히 괄호로 감쌈
  • 괄호는 왼쪽부터 열 때 증가, 닫을 때 감소해 마지막에 0인지 세면 균형을 확인 가능
  • 단락 평가(short-circuit) 여부와 연산자별 평가 순서를 언어 문서로 확인
    • 나눗셈·배열 접근처럼 위험한 항은 안전 검사 뒤에 둠
  • 독자가 평가 순서를 알아야만 안전한 코드보다 중첩 검사로 의도를 명확히 하는 편이 나음

특히 Java의 &&/||는 단락 평가하지만 &/|는 모든 피연산자를 평가한다. 나눗셈, 널 역참조, 배열 접근처럼 안전성에 평가 순서가 걸린 식은 언어별 의미를 확인하고, 복잡하면 두 단계 검사로 풀어 쓴다.

// 0이 아닌지 확인한 뒤에만 나눗셈 수행
if ((denominator != 0) && ((item / denominator) > MIN_VALUE)) {
    ...
}
// 평가 순서가 안전성의 전제가 된다면 두 단계로 쓰면 더 명확하다.
if (denominator != 0) {
    double quotient = item / denominator;
    if (quotient > MIN_VALUE) {
        process(quotient);
    }
}

수치 비교 작성

  • 수치 검사는 수직선 순서로 작성
    • 범위 안: MIN <= i && i <= MAX
    • 범위 밖: i < MIN || MAX < i
  • 0은 용도에 따라 명시성을 다르게 함
    • 불린 값은 암묵 비교
    • 숫자, 문자 널 종료, 포인터, 열거형 값은 명시 비교

언어별 흔한 함정

  • Java에서 a == b는 두 참조가 같은 객체인지를, a.equals(b)논리적으로 같은 값인지를 검사한다. 값 비교가 목적이면 a.equals(b)를 쓸 것
// Java: 값이 같은지 보려면 == 대신 equals
if (name.equals("admin")) { ... }   // 참조 동일성(==)이 아니라 값 동일성

19.2 복합문(블록)

  • 조건문과 반복문의 본문은 문장이 하나여도 중괄호로 감쌈
  • 중괄호는 수정 시 문장이 의도치 않게 블록 밖으로 빠지는 오류를 막고 구조를 명확히 함
  • 여는 중괄호와 닫는 중괄호는 한 번에 짝으로 먼저 쓰고 안을 나중에 채운다 — 그러면 짝이 맞지 않는 문제가 아예 생기지 않음
// 1단계: 헤더 먼저
for (int i = 0; i < maxLines; i++)
// 2단계: 빈 블록으로 여닫는 짝을 맞춘다
for (int i = 0; i < maxLines; i++) { }
// 3단계: 마지막에 안을 채운다
for (int i = 0; i < maxLines; i++) {
    // 내용
}
if (isAdmin) {
    grantAccess();
}
auditAccess(); // 의도적으로 모든 요청을 기록

// 중괄호가 없으면 유지보수 중 다음 줄이 조건부라고 착각하기 쉽다.

19.3 널 문장

  • 널 문장은 실제 작업을 하지 않는 문장 — 의도하지 않은 세미콜론 하나로도 생김
  • 필요한 널 문장이라면 독자가 놓치지 않도록 명시적으로 표시하고 주석으로 이유를 남김
  • 널 문장이 필요한 코드는 대개 더 명확한 구조로 다시 쓸 수 있는지 검토

빈 루프는 조건식의 부수 효과에 의존하는 경우가 많다. 한 줄짜리 ;{}로 의도를 눈에 띄게 만들고, 가능하면 읽기·증가 같은 부수 효과를 본문으로 꺼내 평범한 루프로 바꾼다.

// 피하기: 세미콜론을 놓치기 쉽고 index 증가가 조건식에 숨는다.
while (records.read(index++) != Record::empty()) ;

// 권장: 의도와 부수 효과가 보인다.
Record record = records.read(index++);
while (record != Record::empty()) {
    record = records.read(index++);
}

19.4 위험할 정도로 깊은 중첩 길들이기

  • 깊은 중첩은 루틴의 실제 논리보다 복잡해 보이게 하고, 한 번에 기억할 조건을 늘림
  • 일반적으로 3~4단계를 넘는 중첩은 재구성을 검토할 경고 신호다. 절대 금지선은 아니지만, 그대로 두는 이유가 있어야 한다.
  • 다음 방법을 상황에 맞게 사용
  • 조건 일부를 다시 검사해 중첩을 평탄화
  • 중첩 ifif-then-else 연쇄로 변환
  • 정수·열거형 분기는 case로 변환
    • 깊은 내부 코드를 별도 루틴으로 추출
    • 객체와 다형성 디스패치로 타입별 분기 제거
    • 상태 변수, guard clause, 예외 처리 사용
  • 복잡한 부분 자체를 재설계
  • 조건 재검사는 중첩을 줄이는 대신 조건식을 길게 만든다. 중첩 단계가 크게 감소하고 검사에 부작용이 없을 때 유리하다.
  • do { ... } while (false) 같은 break 블록은 드물고 낯선 기법 — 팀이 합의한 경우에만 사용
  • 깊은 중첩은 코드나 설계를 더 잘게 나눌 신호. 그대로 둘 때도 그럴 만한 이유가 있어야 함
// 중첩된 범위 검사를 평평한 if-else 연쇄로 변환
if (1000 < quantity) {
    discount = 0.10;
} else if (100 < quantity) {
    discount = 0.05;
} else if (10 < quantity) {
    discount = 0.025;
} else {
    discount = 0.0;
}

거래 처리처럼 타입별 분기가 얽힌 코드는 세 단계로 점점 나아진다. 먼저 타입과 세부 타입이 중첩된 if-else는 화면을 넘길 만큼 길고 읽기 어렵다.

// 개선 전: 거래 타입 × 계좌 타입이 중첩돼 깊고 길다.
while (!transactionsComplete()) {
    Transaction transaction = readTransaction();
    if (transaction.type() == DEPOSIT) {
        if (transaction.accountType() == CHECKING) {
            if (transaction.accountSubType() == BUSINESS) {
                makeBusinessCheckDeposit(transaction);
            } else if (transaction.accountSubType() == PERSONAL) {
                makePersonalCheckDeposit(transaction);
            }
        } else if (transaction.accountType() == SAVINGS) {
            makeSavingsDeposit(transaction);
        }
    } else if (transaction.type() == WITHDRAWAL) {
        // ... 계좌 타입별 분기가 또 이어진다
    } else if (transaction.type() == TRANSFER) {
        // ...
    } else {
        logUnknownTransaction(transaction);
    }
}

세부 처리를 루틴으로 밀어내고 타입 분기를 case로 바꾸면 루프의 주 흐름이 짧아진다.

// 개선 1: 타입별 세부 처리는 루틴으로, 분기는 case로.
switch (transaction.type()) {
    case DEPOSIT -> processDeposit(transaction);
    case WITHDRAWAL -> processWithdrawal(transaction);
    case TRANSFER -> processTransfer(transaction);
    default -> logUnknownTransaction(transaction);
}

객체지향 환경이라면 Transaction을 상위 타입으로 두고 타입별 하위 클래스를 만든다. 타입별 처리 로직이 각 클래스로 흩어지므로, 처리 코드에서는 case 없이 complete() 호출 한 번으로 끝난다.

// 개선 2: 타입별 처리를 하위 클래스로 나누고 다형성으로 호출.
abstract class Transaction {
    abstract void complete();
}
class Deposit extends Transaction { void complete() { ... } }
class Withdrawal extends Transaction { void complete() { ... } }
class Transfer extends Transaction { void complete() { ... } }

// 객체를 만드는 case만 남고, 처리 분기는 다형성이 대신한다.
while (!transactionsComplete()) {
    TransactionData data = readTransaction();
    Transaction transaction = switch (data.type()) {
        case DEPOSIT    -> new Deposit(data);
        case WITHDRAWAL -> new Withdrawal(data);
        case TRANSFER   -> new Transfer(data);
        default         -> throw new IllegalArgumentException("Unknown transaction");
    };
    transaction.complete();   // 타입별 분기 없이 각 클래스가 알아서 처리
}
  • 처리 로직이 여러 곳에서 타입별로 분기된다면 다형성이 특히 유리하다. 반대로 분기가 이 한 곳뿐이면 굳이 클래스 계층을 만들 필요는 없다 — 목적에 맞게 선택한다.

19.5 프로그래밍의 토대: 구조적 프로그래밍

  • 구조화된 블록은 시작 지점 하나와 종료 지점 하나만 가짐
  • 구조적 프로그래밍은 상세 코딩 수준의 개념이며, 구조적 하향식 설계와 같은 말이 아님
  • 모든 제어 흐름은 세 구조의 조합으로 만들 수 있음
    • 순차(sequence): 문장을 정해진 순서로 실행
    • 선택(selection): ifcase로 한 경로 선택
    • 반복(iteration): 루프로 문장 그룹 반복
  • break, continue, return, 예외 등 표준 세 구조 밖의 제어는 편리하더라도 비판적으로 검토

19.6 제어 구조와 복잡도

  • 제어 흐름은 프로그램 복잡도의 가장 큰 원인 중 하나이며, 복잡도는 낮은 신뢰성과 오류 증가와 관련됨
  • 사람이 한 번에 다룰 수 있는 정신적 대상 수는 제한적이므로, 개인의 집중력보다 코드 복잡도를 줄이는 것이 효과적

결정점으로 복잡도 측정

  • 직선 경로에서 1로 시작하고, if·while·for·and·or마다 1, case의 각 경우마다 1을 더함
  • 해석 기준
    • 0~5: 대체로 문제 없음
    • 6~10: 단순화 방법 검토
    • 10 초과: 일부를 별도 루틴으로 분리하는 재설계 검토
  • 10은 절대 규칙이 아니라 경고 신호 — 목적이 분명한 큰 case는 분해하지 않는 편이 나을 수 있음
  • 데이터 양, 중첩 깊이, 코드 길이, 변수의 생존 시간 등도 복잡도에 영향을 줌

루틴을 추출하면 프로그램 전체의 결정점 수가 사라지는 것은 아니다. 대신 한 번에 이해·수정·테스트해야 하는 범위를 줄인다. 따라서 수치는 품질 판정이 아니라, 읽기 어려운 루틴을 찾아 재설계를 시작하는 신호로 쓴다.

// 1(직선 경로) + 1(if) + 1(&&) + 1(for) = 결정점 4
if (account.isActive() && account.hasBalance()) {
    for (Invoice invoice : invoices) {
        send(invoice);
    }
}

참고 자료

  • 14장: 순차적 코드 구성
  • 15장: 조건문 사용
  • 16장: 반복문 제어
  • 17장: 특이한 제어 구조

체크리스트: 제어 구조 이슈

  • 불린식에 1, 0 대신 true, false를 쓰는가?
  • 불린 값은 암묵 비교하고, 숫자 값은 명시 비교하는가?
  • 중간 불린 변수·불린 함수·의사결정 테이블로 식을 단순화했는가?
  • 불린식은 긍정형이고, 논리식은 완전히 괄호로 감쌌는가?
  • 중괄호가 균형을 이루고 필요한 곳에 모두 쓰였는가?
  • 수치 검사는 수직선 순서로 썼는가?
  • Java에서 필요할 때 a == b 대신 a.equals(b)를 쓰는가?
  • 널 문장이 분명하게 드러나는가?
  • 재검사, if-then-else/case, 루틴 추출, 다형성 등으로 깊은 중첩을 줄였는가?
  • 결정점이 10을 넘는 루틴을 재설계하지 않을 타당한 이유가 있는가?

요점 정리

  • 단순하고 읽기 쉬운 불린식은 코드 품질에 크게 기여한다.
  • 깊은 중첩은 이해하기 어렵지만 조건 재구성, 루틴 추출, 다형성 등으로 줄일 수 있다.
  • 순차·선택·반복만으로 모든 프로그램을 구성할 수 있다는 구조적 프로그래밍의 원리는 여전히 유효하다.
  • 복잡도를 최소화하는 일은 고품질 코드 작성의 핵심이다.

results matching ""

    No results matching ""