조건부 로직 간소화

2025. 5. 19. 01:17·Programming/Refactoring

조건부 로직은 프로그램의 힘을 강화하는데 크게 기여하지만, 안타깝게도 프로그램을 복잡하게 만드는 원흉이기도 하다. 본 챕터에서는 조건부 로직을 쉽게 바꾸는 리팩터링을 알아보도록 한다.

Decompose Conditional (조건문 분해)

배경

복잡한 조건문은 코드의 가독성과 유지보수성을 심각하게 떨어뜨린다. 조건 자체가 복잡하거나, 조건에 따라 수행하는 로직이 길고 다양할수록 코드는 읽기 어려워진다. 이럴 때는 조건문을 여러 개의 의미 있는 함수로 분리하면 코드를 이해하기 쉬워지고, 변경이 필요할 때도 훨씬 유연하게 대응할 수 있다.

"조건문 분해"는 이러한 복잡한 조건문을 각각의 의미 단위로 나누어, 조건식과 각 분기 동작을 각각 별도의 함수로 추출하는 기법이다.

절차

  1. 조건식을 별도의 함수로 추출한다.
  2. 조건에 따라 수행되는 각 분기(then, else 등)도 각각 별도의 함수로 추출한다.
  3. 가능하다면 삼항 연산자 등의 간결한 구문으로 치환한다.
  4. 각 추출된 함수에는 명확한 의도를 드러내는 이름을 부여한다.

예시

리팩터링 전

if (!aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd)) {
  charge = quantity * plan.summerRate;
} else {
  charge = quantity * plan.regularRate + plan.regularServiceCharge;
}

위 코드는 summer 기간인지 여부에 따라 요금을 다르게 계산하고 있다. 하지만 조건식과 각 분기의 동작이 한 눈에 들어오지 않아 읽기 어렵다.

1단계: 조건식 추출

if (isSummer()) {
  charge = quantity * plan.summerRate;
} else {
  charge = quantity * plan.regularRate + plan.regularServiceCharge;
}

function isSummer() {
  return !aDate.isBefore(plan.summerStart) && !aDate.isAfter(plan.summerEnd);
}

2단계: 분기 로직 분리

if (isSummer()) {
  charge = summerCharge();
} else {
  charge = regularCharge();
}

function summerCharge() {
  return quantity * plan.summerRate;
}

function regularCharge() {
  return quantity * plan.regularRate + plan.regularServiceCharge;
}

3단계: 삼항 연산자 적용 (선택 사항)

charge = isSummer() ? summerCharge() : regularCharge();

조건식 통합하기

배경

조건문을 보다 보면, 조건은 서로 다른데 수행하는 동작은 완전히 똑같은 경우가 종종 있다. 이런 상황에서 조건들을 개별적으로 작성해두면 코드의 의도가 흐려진다."여러 개의 조건을 검사하지만, 결국 하고 싶은 일은 하나"라면, 굳이 조건도 나눠 둘 필요가 없다.

조건식을 통합하면 다음과 같은 이점이 있다.

의도를 명확하게 전달할 수 있다.→ ‘이 조건들이 전부, 하나의 목적을 위해 검사되는구나’라는 사실이 한눈에 드러난다.

함수 추출과 궁합이 좋다.→ 통합한 조건식은 보통 의미 있는 이름의 함수로 추출할 수 있어 코드 가독성이 크게 향상된다.

하지만 조건식을 통합하면 안 되는 경우도 있다.조건들이 의미상 완전히 독립적이고, 결과만 우연히 같을 뿐이라면 이 리팩터링은 피하는 게 낫다.예를 들어 "사용자 나이 초과"와 "결제 실패"는 전혀 다른 성격의 검사다. 단지 둘 다 오류 페이지를 띄운다고 해서 하나로 묶는 건 적절하지 않다.

절차

조건식들에 부수효과가 없는지 확인한다.→ 조건식 안에서 변수 값을 변경하거나 외부에 영향을 주지 않아야 안전하게 통합할 수 있다.

두 개의 조건문을 선택해 논리 연산자(&&, ||)로 결합한다.→ ||는 여러 조건 중 하나라도 true면 실행, &&는 모두 true여야 실행된다.

결합 후 테스트한다.

남은 조건문이 있다면 반복적으로 통합한다.

최종 조건식을 의미 있는 이름의 함수로 추출할지 고려한다.→ "isNotEligibleForDisability()"처럼 의도를 담은 이름이면 읽는 사람이 이해하기 훨씬 쉽다.

예시

아래는 직원의 장애 보상금을 계산하는 함수다. 세 가지 조건 중 하나라도 만족하면 보상을 받을 수 없고, 그렇지 않으면 보상금이 계산된다.

리팩터링 전

function disabilityAmount(employee) {
  if (employee.seniority < 2) return 0;
  if (employee.monthsDisabled > 12) return 0;
  if (employee.isPartTime) return 0;

  return computeDisabilityAmount(employee);
}

각 조건이 별도로 나열돼 있어, 결과는 같지만 조건들의 공통 목적이 드러나지 않는다.

1단계: 조건식 통합

function disabilityAmount(employee) {
  if ((employee.seniority < 2) ||
      (employee.monthsDisabled > 12) ||
      (employee.isPartTime)) {
    return 0;
  }

  return computeDisabilityAmount(employee);
}

이제는 "이 조건들이 모두, 보상 자격이 없는 이유"라는 점이 눈에 들어온다.

2단계: 함수로 추출

function disabilityAmount(employee) {
  if (isNotEligibleForDisability(employee)) return 0;

  return computeDisabilityAmount(employee);
}

function isNotEligibleForDisability(employee) {
  return (
    employee.seniority < 2 ||
    employee.monthsDisabled > 12 ||
    employee.isPartTime
  );
}

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

배경

조건문은 보통 두 가지 방식으로 쓰인다.
하나는 if-else로 양쪽 모두 정상 흐름인 경우,
다른 하나는 한쪽만 정상 흐름인 경우다.

두 경로 모두 주요 흐름이면 if-else가 적합하다. 하지만 한쪽은 예외거나, 조기 종료되는 흐름이라면 그 조건을 “함수에서 빠져나오는 보호 구문(Guard Clause)”으로 바꾸는 게 더 명확하다.

보호 구문은 다음과 같은 의미를 코드에 담는다: “이건 예외적인 상황이야. 처리를 끝내고 이 함수는 더 이상 진행하지 않아.”

반대로, if-else는 두 경로 모두 중요하고, 함수의 핵심 동작이라는 인상을 준다. 따라서 예외나 조기 종료를 명확하게 구분하려면 보호 구문을 사용하는 것이 좋다.

절차

  1. 교체할 조건문 중 가장 바깥쪽 조건을 선택하고, 해당 조건이 참일 때 조기 반환하는 형태로 수정한다.
  2. 테스트한다.
  3. 다른 중첩 조건문에 대해서도 위 과정을 반복한다.
  4. 모든 보호 구문이 동일한 값을 반환한다면, 조건식을 하나로 통합할 수 있는지 검토한다.

예시

아래는 직원의 급여를 계산하는 코드다. 퇴사자와 퇴직자는 모두 급여가 0원으로 처리되며, 그 외에만 본격적인 계산이 들어간다.

리팩터링 전

function payAmount(employee) {
  let result;

  if (employee.isSeparated) {
    result = { amount: 0, reasonCode: "SEP" };
  } else {
    if (employee.isRetired) {
      result = { amount: 0, reasonCode: "RET" };
    } else {
      // 복잡한 급여 계산
      lorem.ipsum(dolor.sitAmet);
      consectetur(adipiscing).elit();
      result = someFinalComputation();
    }
  }

  return result;
}

이 코드는 본래 목적(급여 계산)에 도달하기까지 불필요하게 중첩된 조건문을 거쳐야 한다. 이럴 경우 핵심이 아닌 조건부터 보호 구문으로 처리해나가는 방식이 낫다.

1단계: 최상단 조건을 보호 구문으로 변경

function payAmount(employee) {
  if (employee.isSeparated) {
    return { amount: 0, reasonCode: "SEP" };
  }

  let result;

  if (employee.isRetired) {
    result = { amount: 0, reasonCode: "RET" };
  } else {
    lorem.ipsum(dolor.sitAmet);
    consectetur(adipiscing).elit();
    result = someFinalComputation();
  }

  return result;
}

2단계: 두 번째 조건도 보호 구문으로 변경

function payAmount(employee) {
  if (employee.isSeparated) {
    return { amount: 0, reasonCode: "SEP" };
  }

  if (employee.isRetired) {
    return { amount: 0, reasonCode: "RET" };
  }

  // 본격적인 급여 계산
  lorem.ipsum(dolor.sitAmet);
  consectetur(adipiscing).elit();
  return someFinalComputation();
}

3단계: 불필요한 변수 제거

변수 result는 더 이상 의미가 없다. 바로 반환하면 된다. 이로써 함수는 간결하고 목적 중심적인 구조가 된다.


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

배경

복잡한 조건부 로직은 프로그램에서 가장 읽기 어렵고, 유지보수하기 힘든 부분 중 하나다.
특히 switch문이나 여러 if-else 블록이 다양한 곳에서 반복된다면, 코드는 점점 더 지저분해지고 변경에 취약해진다.

이럴 때 객체 지향의 핵심인 다형성(polymorphism)을 활용하면 조건에 따라 달라지는 동작을 각 타입이 스스로 처리하게 만들 수 있다.
즉, 조건문이 결정하던 "무엇을 할지"를, 객체 스스로 "나는 이렇게 동작해"라고 말하게 만드는 것이다.

예를 들어 타입에 따라 speed()나 description()이 달라지는 로직이 있다면, 이를 조건문이 아니라 각 타입의 메서드 오버라이딩으로 분리할 수 있다.
이렇게 하면 코드 중복도 줄고, 타입별 동작을 독립적으로 관리할 수 있어 확장에도 유리하다.

물론, 모든 조건부 로직을 다형성으로 바꾸는 것이 정답은 아니다.
간단한 조건은 if/else나 switch로도 충분하며, 다형성을 억지로 적용하면 코드가 과도하게 분산되어 오히려 불편해질 수 있다.

절차

  1. 다형적 동작을 표현할 클래스 구조를 만든다.
    → 각 조건 분기(case)에 대응하는 서브클래스를 정의한다.
    → 팩터리 함수로 적절한 인스턴스를 생성하게 만든다.
  2. 호출 코드에서 조건문 대신 팩터리 함수로 객체를 생성하도록 변경한다.
  3. 조건부 로직을 슈퍼클래스의 메서드로 이동한다.
  4. 서브클래스 중 하나에서 해당 조건에 해당하는 로직을 오버라이드한다.
    → switch/case 또는 if/else 중 일부 로직을 복사해 넣고 조정한다.
  5. 다른 조건절들도 각 서브클래스로 분리한다.
  6. 슈퍼클래스에는 기본 동작만 남기거나, 추상 메서드로 선언한다.

예시

새가 여러 종류 있고, 새의 종류에 따라 깃털 상태(plumage)나 비행 속도(speed)가 다르다고 하자.
아래 코드는 switch문으로 새의 타입별 동작을 처리하고 있다.

리팩터링 전

function plumage(bird) {
  switch (bird.type) {
    case 'EuropeanSwallow':
      return 'average';
    case 'AfricanSwallow':
      return bird.numberOfCoconuts > 2 ? 'tired' : 'average';
    case 'NorwegianBlueParrot':
      return bird.voltage > 100 ? 'scorched' : 'beautiful';
    default:
      return 'unknown';
  }
}

이런 코드가 곳곳에 퍼져 있다면 새 종류가 늘어날수록 switch 문은 계속 커질 수밖에 없다.

1단계: 공통 슈퍼클래스 정의

class Bird {
  constructor(bird) {
    Object.assign(this, bird);
  }

  get plumage() {
    return 'unknown';
  }
}

2단계: 서브클래스 정의

class EuropeanSwallow extends Bird {
  get plumage() {
    return 'average';
  }
}

class AfricanSwallow extends Bird {
  get plumage() {
    return this.numberOfCoconuts > 2 ? 'tired' : 'average';
  }
}

class NorwegianBlueParrot extends Bird {
  get plumage() {
    return this.voltage > 100 ? 'scorched' : 'beautiful';
  }
}

3단계: 팩터리 함수 정의

function createBird(bird) {
  switch (bird.type) {
    case 'EuropeanSwallow':
      return new EuropeanSwallow(bird);
    case 'AfricanSwallow':
      return new AfricanSwallow(bird);
    case 'NorwegianBlueParrot':
      return new NorwegianBlueParrot(bird);
    default:
      return new Bird(bird);
  }
}

4단계: 조건문 제거 및 호출부 수정

function plumage(bird) {
  return createBird(bird).plumage;
}

이제 새 객체는 스스로 자신의 깃털 상태를 알고 있는 존재가 되었다.


특이 케이스 추가하기

배경

어떤 데이터 값이 특정 조건을 만족할 때, 시스템 전반에 걸쳐 동일한 방식으로 처리되는 중복 코드가 있다면, 이를 하나로 모으는 것이 좋다.
예를 들어, 사용자가 없을 경우 if (user == null)을 곳곳에서 검사하고, 그 결과로 "Guest"를 출력하는 식의 로직 말이다.

이럴 때 유용한 리팩터링 기법이 특이 케이스 패턴(Special Case Pattern)이다.
특이 케이스(예외 상황)에 대해 별도의 객체를 만들어 해당 동작을 캡슐화하면, 코드 전반에 퍼져 있던 조건문은 간단한 메서드 호출로 대체된다.

이 패턴은 특히 null 값을 다룰 때 유용하기 때문에, Null Object 패턴이라고도 불린다.
의도는 단순하다: "예외인 값을 객체화해서, 평범한 값처럼 다뤄버리자."

절차

  1. 컨테이너 객체에 isSpecialCase 속성을 추가하고, 일반 객체에서는 기본값으로 false를 반환하도록 한다.
  2. 특이 케이스 객체를 만든다.
    → 해당 객체는 isSpecialCase 속성이 true를 반환한다.
  3. 클라이언트 코드에서 특이 케이스를 판별하는 조건문을 함수로 추출하고, 직접 비교 대신 이 함수를 사용하도록 변경한다.
  4. 특이 케이스를 반환하는 경로를 마련한다.
    → 함수의 반환값으로 제공하거나, 원본 데이터를 특이 케이스 객체로 변환하는 방식이다.
  5. 판별 함수의 구현을 isSpecialCase 속성을 참조하도록 변경한다.
  6. 테스트하여 리팩터링이 의도대로 적용됐는지 확인한다.

예시

사용자가 null일 때 ‘Guest’라는 이름으로 표시해야 하는 상황을 생각해보자.

리팩터링 전

function renderUsername(user) {
  const name = user ? user.name : 'Guest';
  return `<span>${name}</span>`;
}

user == null 조건문이 여기저기 등장하면 중복도 많고, 예외 상황 관리가 분산되어 버그에 취약해진다.

1단계: 일반 유저 객체에 기본 속성 추가

class User {
  constructor(name) {
    this.name = name;
  }

  get isUnknown() {
    return false;
  }
}

2단계: 특이 케이스 객체 정의

class UnknownUser {
  get name() {
    return 'Guest';
  }

  get isUnknown() {
    return true;
  }
}

3~4단계: 특이 케이스 반환 처리

function createUser(data) {
  if (!data) return new UnknownUser();
  return new User(data.name);
}

5단계: 클라이언트 코드 수정

function renderUsername(user) {
  return `<span>${user.name}</span>`;
}

// 호출부
const user = createUser(rawUserData);
renderUsername(user);

이제는 user가 null이든 아니든, 항상 .name을 호출할 수 있다.
예외 상황을 별도 분기하지 않아도 되고, 클라이언트 입장에서는 평범한 객체를 다루는 것처럼 보인다.


어서션 추가하기

배경

코드 안에는 "이 시점에서는 반드시 이 조건이 참이어야 한다"는 가정이 종종 존재한다.
예를 들어, 객체의 필드 중 하나는 꼭 값이 들어 있어야 하고, 배열이 비어 있으면 안 되고, 설정 값이 반드시 초기화되어 있어야 하는 상황들이다.

이러한 불변 조건을 코드에 명시하지 않으면, 훗날 그 조건을 깬 코드가 추가되었을 때도 누구도 그 사실을 눈치채지 못할 수 있다.
그리고 그렇게 지나간 코드가 언젠가 원인을 알 수 없는 버그로 되돌아온다.

이럴 때 유용한 것이 바로 어서션(assertion)이다.
어서션은 다음을 코드에 명시적으로 표현한다: “이건 항상 참이어야 해. 그렇지 않다면 그건 프로그래머의 실수야.”

중요한 건, 어서션은 프로그램의 정상 동작에는 영향을 주지 않아야 한다.
실행 흐름을 제어하거나 조건 분기를 대체해서는 안 된다.
디버깅을 도와주는 수단이자, 코드의 의도를 명확히 하는 커뮤니케이션 도구로 활용해야 한다.

절차

  1. 코드에서 반드시 참이어야 하는 조건을 발견하면, 해당 위치에 assert 문을 추가한다.
    → 단, 이 조건이 프로그램 로직에 따라 달라지는 것이라면 예외 처리를 써야 한다.
    → 어서션은 오직 "절대 깨지지 않아야 하는 가정"에만 사용한다.

예시

아래 함수는 주문 금액에 따라 배송비를 결정한다. 하지만 계산 도중 order.quantity가 반드시 0보다 크다는 묵시적 가정이 숨어 있다.

리팩터링 전

function deliveryFee(order) {
  const base = order.quantity * 100;
  if (base > 1000) {
    return 0;
  }
  return 500;
}

위 코드는 정상적으로 작동하지만, 만약 order.quantity가 undefined거나 음수인 경우에도 조용히 통과된다.
문제는 “이 값은 항상 정수여야 하고, 0보다 커야 한다”는 가정이 코드상 드러나지 않는다는 것이다.

1단계: 어서션 추가

import assert from "assert";

function deliveryFee(order) {
  assert(Number.isInteger(order.quantity) && order.quantity > 0, "수량은 양의 정수여야 합니다.");

  const base = order.quantity * 100;
  if (base > 1000) {
    return 0;
  }
  return 500;
}

이제 이 함수는 "수량은 반드시 1개 이상이어야 한다"는 가정을 명확히 드러낸다.
해당 조건이 위반되면, 실행 도중 예외를 던지며 프로그래머의 실수를 빠르게 드러내준다.


제어 플래그를 탈출문으로 바꾸기

배경

제어 플래그(control flag)란 코드의 흐름을 제어하기 위해 사용되는 불리언 변수다.
일반적으로 어떤 조건을 만족했는지 저장해두었다가, 이후의 코드 흐름을 조절하는 용도로 쓰인다.

하지만 제어 플래그는 코드에 불필요한 상태(state)를 추가하며, 흐름을 한눈에 파악하기 어렵게 만든다.
특히 반복문이나 복잡한 조건 안에서 갱신되고 해석되는 경우, 코드가 쉽게 ‘스파게티 코드’로 변질된다.

이런 경우, 플래그를 제거하고 조기 반환(return)이나 반복문 탈출(break, continue) 같은 명시적인 제어 흐름으로 바꾸는 것이 훨씬 낫다.
이렇게 하면 의도가 드러나고, 코드 흐름이 단순해진다.

절차

  1. 제어 플래그를 사용하는 코드가 길거나 복잡하다면, 먼저 함수로 추출할지 고려한다.
  2. 제어 플래그를 사용하는 부분을 하나씩 살펴보며, 조기 return, break, continue 등으로 바꾼다.
    → 하나씩 바꿀 때마다 테스트해서 영향 범위를 확인한다.
  3. 모든 제어 플래그의 용도를 대체했다면, 플래그 변수를 제거한다.

예시

아래는 checkSecurity 함수가 ‘보안 위반이 있는지’를 검사하는 코드다.
두 명 이상의 용의자가 등장하면 found 플래그를 true로 설정하고, 이를 바탕으로 후속 조치를 취한다.

리팩터링 전

function checkSecurity(people) {
  let found = false;

  for (const person of people) {
    if (!found) {
      if (person === "Don") {
        sendAlert();
        found = true;
      }
      if (person === "John") {
        sendAlert();
        found = true;
      }
    }
  }
}

조건 중첩이 깊고, found 플래그를 계속 신경 써야 해서 코드의 의도가 흐릿하다.

1단계: 제어 플래그를 return으로 변경

function checkSecurity(people) {
  for (const person of people) {
    if (person === "Don") {
      sendAlert();
      return;
    }
    if (person === "John") {
      sendAlert();
      return;
    }
  }
}

이제는 어떤 사람을 만나면 즉시 탈출한다는 의도가 명확하게 드러난다.
found 플래그가 사라지면서 코드도 간결해졌다.

'Programming > Refactoring' 카테고리의 다른 글

데이터 조직화  (0) 2025.05.15
기능 이동  (0) 2025.05.08
캡슐화  (0) 2025.04.24
기본적인 리팩터링 (3)  (0) 2025.04.16
기본적인 리팩터링 (2)  (0) 2025.04.10
'Programming/Refactoring' 카테고리의 다른 글
  • 데이터 조직화
  • 기능 이동
  • 캡슐화
  • 기본적인 리팩터링 (3)
Seung-o
Seung-o
공부한 것들을 정리하는 개발 블로그입니다.
  • Seung-o
    조그만 사람의 개발 주머니
    Seung-o
  • 전체
    오늘
    어제
    • 분류 전체보기 (52) N
      • Database (20)
        • Real MySQL (4)
        • High Performance MySQL (14)
      • Programming (27) N
        • Protocol (2)
        • Designing Data-Intensive Ap.. (5)
        • Unit Testing (4)
        • Refactoring (11) N
        • Langchain (4)
      • Etc (5)
        • Thought (2)
        • Git (1)
        • Jira (1)
        • Experience (1)
  • 블로그 메뉴

    • 홈
    • 태그
  • 링크

    • Github
  • 인기 글

  • 최근 글

  • hELLO· Designed By정상우.v4.10.3
Seung-o
조건부 로직 간소화
상단으로

티스토리툴바