조건부 로직은 프로그램을 강력하게 만들지만 동시에 복잡성의 주범이다. 분기가 늘고 중첩이 깊어지면 코드는 금세 읽기 어려워진다. 이 장의 기법들은 세 가지 방향으로 조건문을 다룬다. 조건에 이름을 붙여 의도를 드러내거나, 조건 분기를 구조(다형성·특이 케이스)로 대체하거나, 반복되는 검사를 아예 제거한다. 순서대로 시도해 보면 대부분의 지저분한 조건문이 정리된다.

지저분한 조건문을 만났을 때 위에서부터 차례로 물어 내려가면 알맞은 기법이 나온다.

flowchart TD
    S["지저분한 조건문"] --> Q1{"이름을 붙일 수 있나?"}
    Q1 -->|예| A1["조건문 분해·조건식 통합"]
    Q1 -->|아니오| Q2{"비정상 특이 케이스인가?"}
    Q2 -->|예| A2["보호 구문으로 일찍 반환"]
    Q2 -->|아니오| Q3{"타입별 분기가 여러 함수에 반복되나?"}
    Q3 -->|예| A3["다형성으로 대체"]
    Q3 -->|아니오| Q4{"같은 값 검사가 곳곳에 산재하나?"}
    Q4 -->|예| A4["특이 케이스(Null 객체)"]
    Q4 -->|아니오| A5["어서션으로 가정을 명시"]

위쪽의 가벼운 기법으로 안 되면 아래쪽의 무거운 구조 대체로 내려간다.

조건문 분해하기

긴 조건식과 그 분기의 본문은 “무엇을 하는가”는 말해도 “왜 하는가”는 말하지 않는다. 조건식과 각 분기를 의도가 담긴 이름의 함수로 추출하면, 코드가 문장처럼 읽힌다.

// before
if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd)) {
  charge = quantity * plan.summerRate
} else {
  charge = quantity * plan.regularRate + plan.regularServiceCharge
}
// after
if (summer()) {
  charge = summerCharge()
} else {
  charge = regularCharge()
}
 
function summer() {
  return !aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd)
}
function summerCharge() {
  return quantity * plan.summerRate
}
function regularCharge() {
  return quantity * plan.regularRate + plan.regularServiceCharge
}

절차는 단순하다. 조건식을 함수로 추출하고, then 절과 else 절도 각각 추출한다. 함정은 “코드가 짧아지지 않는다”는 불평인데, 목적은 줄 수 감소가 아니라 이름을 통한 의도 노출이다. summer()라는 이름 하나가 주석 한 줄을 대체한다.

조건식 통합하기

결과가 같은 조건 검사가 여러 개 나열돼 있으면, 사실 그것들은 하나의 검사다. and/or로 묶어 그 사실을 드러내고, 묶인 조건식을 함수로 추출한다.

// before
function disabilityAmount(anEmployee) {
  if (anEmployee.seniority < 2) return 0
  if (anEmployee.monthsDisabled > 12) return 0
  if (anEmployee.isPartTime) return 0
  // 실제 계산
}
// after
function disabilityAmount(anEmployee) {
  if (isNotEligibleForDisability()) return 0
  // 실제 계산
 
  function isNotEligibleForDisability() {
    return anEmployee.seniority < 2 || anEmployee.monthsDisabled > 12 || anEmployee.isPartTime
  }
}

주의할 점: 검사들이 정말 독립적인 별개의 판단이라면 통합하면 안 된다. 통합의 전제는 “이건 사실 하나의 검사”라는 것이다. 함정은 각 조건에 부수효과가 있을 때인데, 그러면 || 단축 평가 때문에 뒤 조건이 실행되지 않아 동작이 바뀐다. 통합 전에 부수효과 없음을 확인한다.

중첩 조건문을 보호 구문으로 바꾸기

이 장에서 제일 많이 쓰게 되는 기법이다. 조건문에는 두 종류가 있다. 정상 흐름의 양 갈래를 나누는 것과, 비정상적인 특이 케이스를 걸러내는 것. 후자는 “이건 본론이 아니다”라는 신호이므로, 먼저 검사해 일찍 반환한다. 이것이 보호 구문(guard clause)이다.

// before — else if 사다리로 정상 로직이 깊이 묻힌다
function payAmount(employee) {
  let result
  if (employee.isSeparated) {
    result = { amount: 0, reason: "SEP" }
  } else {
    if (employee.isRetired) {
      result = { amount: 0, reason: "RET" }
    } else {
      // 진짜 로직
      lorem.ipsum(dolor.sitAmet)
      consectetur(adipiscing.elit)
      result = someFinalComputation()
    }
  }
  return result
}
// after — 특이 케이스를 위로 밀어내면 본문이 평평해진다
function payAmount(employee) {
  if (employee.isSeparated) return { amount: 0, reason: "SEP" }
  if (employee.isRetired) return { amount: 0, reason: "RET" }
 
  lorem.ipsum(dolor.sitAmet)
  consectetur(adipiscing.elit)
  return someFinalComputation()
}

핵심 통찰은 이거다. if-else는 두 갈래가 똑같이 중요하다고 말한다. 보호 구문은 “이 갈래는 드물고, 처리하고 나면 잊어도 된다”고 말한다. 의미가 다르다.

조건 반대로 만들기도 이 기법의 일부다. 원래 조건이 정상 케이스를 감싸고 있으면, 조건을 뒤집어 비정상 케이스를 보호 구문으로 만든다.

// before
function adjustedCapital(anInstrument) {
  let result = 0
  if (anInstrument.capital > 0) {
    if (anInstrument.interestRate > 0 && anInstrument.duration > 0) {
      result = (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor
    }
  }
  return result
}
// after — 조건을 반대로 뒤집어 보호 구문으로
function adjustedCapital(anInstrument) {
  if (anInstrument.capital <= 0) return 0
  if (anInstrument.interestRate <= 0 || anInstrument.duration <= 0) return 0
  return (anInstrument.income / anInstrument.duration) * anInstrument.adjustmentFactor
}

&&를 뒤집으면 ||가 되고 부등호도 뒤집힌다(드모르간). 함정은 “함수의 반환점은 하나여야 한다”는 오래된 규칙에 얽매이는 것이다. 파울러는 명료함이 규칙보다 우선이라고 못 박는다. 이건 Rust의 early return + Option/Result 습관과 정확히 같은 철학이라, TS/JS에서도 같은 리듬으로 쓰면 된다.

조건부 로직을 다형성으로 바꾸기

같은 타입 코드로 갈리는 switch가 여러 함수에 반복해서 나타나면, 그 분기 구조를 클래스 계층으로 옮긴다. 각 타입은 서브클래스가 되고, 분기의 각 갈래는 그 서브클래스의 메서드가 된다.

Refactoring Step 원본 — 두 함수에서 반복되는 switch
1/3
function plumage(bird) {  switch (bird.type) {    case "EuropeanSwallow":      return "average"    case "AfricanSwallow":      return bird.numberOfCoconuts > 2 ? "tired" : "average"    default:      return "unknown"  }}function airSpeedVelocity(bird) {  switch (bird.type) {    case "EuropeanSwallow":      return 35    case "AfricanSwallow":      return 40 - 2 * bird.numberOfCoconuts    default:      return null  }}

switch가 사라진 게 아니라 팩토리 한 곳으로 모였다. 새 타입을 추가할 때 여기저기 흩어진 switch를 다 찾을 필요 없이 클래스 하나만 추가하면 된다. 함정과 경고: 파울러는 다형성이 만병통치약이 아니라고 강조한다. 조건부 구조 대부분은 분해·보호 구문 같은 기본 기법으로 충분하다. 같은 타입 분기가 여러 함수에서 반복될 때만 이 큰 도구를 꺼낸다.

특이 케이스 추가하기 (Null 객체 패턴)

특정 값을 여기저기서 똑같이 검사하고 똑같이 처리한다면, 그 값에 “특이 케이스” 객체를 만들어 기본 동작을 담는다. null을 반복 검사하는 경우가 대표적이라 흔히 Null 객체 패턴이라 부른다.

// before — "unknown" 고객 검사가 코드 곳곳에 흩어진다
const customer = site.customer
const name = customer === "unknown" ? "occupant" : customer.name
const plan = customer === "unknown" ? registry.billingPlans.basic : customer.billingPlan
const weeksDelinquent =
  customer === "unknown" ? 0 : customer.paymentHistory.weeksDelinquentInLastYear
// after — 특이 케이스 객체가 기본값을 스스로 안다
class UnknownCustomer {
  get isUnknown() {
    return true
  }
  get name() {
    return "occupant"
  }
  get billingPlan() {
    return registry.billingPlans.basic
  }
  get paymentHistory() {
    return {
      get weeksDelinquentInLastYear() {
        return 0
      },
    }
  }
}
function isUnknown(arg) {
  return arg === "unknown" || arg instanceof UnknownCustomer
}
 
// 호출부는 분기 없이 흐른다
const customer = site.customer
const name = customer.name // "occupant"가 알아서 나온다
const plan = customer.billingPlan
const weeksDelinquent = customer.paymentHistory.weeksDelinquentInLastYear

호출부에 흩어졌던 === "unknown" 검사가 일제히 사라진다. 함정: 소수의 곳에서 특이 케이스를 여전히 특별히 다뤄야 한다면 isUnknown() 같은 판별 메서드를 남겨 둔다. 특이 케이스 객체는 보통 불변으로 공유해도 되므로 값 객체처럼 다룬다.

어서션 추가하기

어서션은 “이 지점에서 이 조건은 항상 참”이라는 프로그래머의 가정을 코드로 문서화한 것이다. 조건이 깨지면 즉시 실패하므로, 버그가 멀리 퍼지기 전에 발원지를 알려 준다.

// before — 할인율 계산은 "할인율이 양수"라는 숨은 가정 위에 있다
applyDiscount(number) {
  return number - this.discountRate * number;
}
// after — 가정을 명시한다
applyDiscount(number) {
  if (!(this.discountRate > 0)) throw new Error("assert: discountRate must be positive");
  return number - this.discountRate * number;
}

가장 중요한 규율: 어서션은 제어 흐름이 아니다. 프로그램은 모든 어서션을 제거해도 똑같이 동작해야 한다. 어서션이 하는 일을 실제 로직이 의존하기 시작하면 그건 이미 어서션이 아니라 검증 로직이다. 어서션은 “절대 일어나면 안 되는 일”에만 붙이고, 정상적으로 발생 가능한 잘못된 입력은 보통의 오류 처리로 다룬다. 함정은 어서션을 남발하는 것 — 자명하게 참인 곳에 붙이면 잡음만 늘 뿐이다.

정리

  • 조건문을 만나면 순서대로 묻는다. 이름을 붙일 수 있나(분해·통합) → 일찍 반환할 수 있나(보호 구문) → 같은 분기가 반복되나(다형성·특이 케이스).
  • 보호 구문은 “이건 본론이 아니다”라는 신호를 읽는 사람에게 주는 장치다. if-else의 대칭성과 의미가 다르다.
  • 다형성과 특이 케이스는 강력하지만 무겁다. 기본 기법으로 안 될 때만 꺼낸다.
  • 어서션은 버그를 찾는 도구지 제어 흐름이 아니다 — 지워도 동작이 같아야 한다.
  • 보호 구문의 철학은 Rust의 early return + Option/Result 습관과 같다. 언어가 달라도 조건문을 읽는 눈은 하나다.