조건문 간결화

Updated:

조건문 간결화

1. 조건문 쪼개기

  • 복잡한 조건문이 있을 땐 if, then, else 부분을 각각 메서드로 빼내자.

1.1 동기

  • 큰 덩어리의 코드를 잘게 쪼개고 각 코드 조각을 용도에 맞는 이름의 메서드 호출로 바꾸면 코드의 용도가 분명히 드러난다.

1.2 방법

  • if절을 별도의 메서드로 빼내자
  • then절과 else절을 각각의 메서드로 빼내자.

  • 조건문이 여러 겹일 땐 먼저 여러 겹의 조건문을 감시 절로 전환기법부터 실시하자.
  • 그 결과가 만족스럽지 않으면 조건문을 쪼개자.

1.3 예제

  • 겨울인지 여름인지에 따라 달라지는 난방비를 계산하는 다음과 같은 코드가 있다.
double chargeHeatingCost(Date date) {
    if(date.before(SUMMER_START) || date.after(SUMMER_END)) {
        charge = quantity * winterRate + winterServiceCharge;
    } else {
        charge = quantity * summerRate;
    }
    
    return charge;
}
  • 조건식과 각 분기 절을 다음과 같이 각각의 메서드로 빼낸다.
double chargeHeatingCost(Date date) {
    if(notSummer(date)) {
        charge = winterCharge(quantity);
    } else {
        charge = summerCharge(quantity);
    }
    
    return charge;
}

private boolean notSummer(Date date) {
    return date.before(SUMMER_START || date.after(SUMMER_END));
}

private double summerCharge(int quantity) {
    return quantity * summerRate;
}

private double winterCharge(int quantity) {
    return quantity * winterRate + winterServiceCharge;
}

2. 중복 조건식 통합

  • 여러 조건 검사식의 결과가 같을 땐 하나의 조건문으로 합친 후 메서드로 빼내자.

2.1 동기

  • 조건식을 합치면 여러 검사를 OR 연산자로 연결해서 실제로 하나의 검사수행을 표현해서 무엇을 검사하는지 더 확실히 이해할 수 있다.
  • 통합하면 메서드 추출을 적용할 기반을 마련한다.

2.2 방법

  • 모든 조건문에 부작용이 없는지 검사하자
  • 여러 개의 조건문을 논리 연산자를 사용해 하나의 조건문으로 바꾸자.
  • 합친 조건문에 메서드 추출 적용을 고려하자.

2.3 예제

double disabilityAmount() {
    if(seniority < 2) {
        return 0;
    }

    if(monthDisabled > 12) {
        return 0;
    }

    if(isPartTime) {
        return 0;
    }

    // 장애인 공제액 산출
}
  • 다음과 같이 하나로 통합
double disabilityAmount() {
    if(seniority < 2 || monthDisabled > 12 || isPartTime) {
        return 0;
    }

    // 장애인 공제액 산출
}
  • 메서드 추출
double disabilityAmount() {
    if(isNotEligibleForDisability()) {
        return 0;
    }

    // 장애인 공제액 산출
}

private boolean isNotEligibleForDisability(){
    return seniority < 2 || monthDisabled > 12 || isPartTime;
}

3. 조건문의 공통 실행 코드 빼내기

  • 조건문의 모든 절에 같은 실행 코드가 있을 땐 같은 부분을 조건문 밖으로 빼자.

3.1 방법

  • 조건에 상관없이 공통적으로 실행되는 코드를 찾자.
  • 공통 코드가 조건문의 앞 절에 있을 땐 조건문 앞으로 빼자.
  • 공통 코드가 조건문의 끝 절에 있을 땐 조건문 뒤로 빼자.
  • 공통 코드가 조건문의 중간 절에 있을 땐 앞뒤의 코드와 위치를 바꿔도 되는지 판단하자. 그래서 바꿔도 된다면 조건문의 앞이나 끝 절로 뺀 후 앞의 단계처럼 조건문 앞이나 뒤로 빼자.
  • 공통 코드 명령이 둘 이상일 땐 메서드로 만들자.

3.2 예제

if(isSpecialDeal()) {
    total = price * 0.95;
    send();
} else {
    total = price * 0.98;
    send();
}
  • send 메서드 밖으로 뺀다.
if(isSpecialDeal()) {
    total = price * 0.95;
} else {
    total = price * 0.98;
}

send();
  • try구간과 catch 구간 안의 예외 발생 명령 뒤에 공통적으로 들어 있으면, 그 코드를 final 구간으로 옮긴다.

4. 제어 플래그 제거

  • 논리 연산식의 제어 플래그 역할을 하는 변수가 있을 땐 그 변수를 break 문이나 return 문의로 바꾸자.

4.1 동기

  • 제어 플래그를 없애면 의외로 많은 것을 할 수 있고 조건문의 진정한 의도를 쉽게 파악할 수 있다.

4.2 방법

  • 논리문을 빠져나오게 하는 제어 플래그 값을 찾자.
  • 그 제어 플래그 값을 대입하는 코드를 break나 continue로 바꾸자.

4.3 예제

4.3.1 간단한 제어 플래그를 break문으로 교체
void checkSecurity(String[] people) {
    boolean found = false;

    for(int i = 0; i < people.length; i++) {
        if(!found) {
            if(people[i].equals("Don")) {
                sendAlert();
                found = true;
            }

            if(people[i].equals("John")) {
                sendAlert();
                found = true;
            }
        }
    }
}
  • 하나씩 break로 바꾼다. 그리고 플래그 참조 삭제. 아래는 최종본
void checkSecurity(String[] people) {
    for(int i = 0; i < people.length; i++) {
        if(people[i].equals("Don")) {
            sendAlert();
            break;
        }

        if(people[i].equals("John")) {
            sendAlert();
            break;
        }
    }
}
4.3.2 제어 플래그를 return 문으로 교체
  • 리팩토링 기법의 다른 방식은 return 문을 사용하는 것이다.
void checkSecurity(String[] people) {
    String found = "";

    for(int i = 0; i < people.length; i++) {
        if(found.equals("")) {
            if(people[i].equals("Don")) {
                sendAlert();
                found = "Don";
            }

            if(people[i].equals("John")) {
                sendAlert();
                found = "John";
            }
        }
    }

    someLaterCode(found);
}
  • 여기서 found 변수를 두 가지 역할을 한다. 결과를 나타내기도 하고 제어 플래그 역할도 한다.
  • 이럴 땐 found 변수를 알아내는 코드를 메서드로 빼내자.
  • 그리고 제어 플래그를 return문으로 고치자.
void checkSecurity(String[] people) {
    String found = foundMiscreant(people);
    someLaterCode(found);
}

String foundMiscreant(String[] people) {
    String found = "";

    for(int i = 0; i < people.length; i++) {
        if(found.equals("")) {
            if(people[i].equals("Don")) {
                sendAlert();
                return "Don";
            }

            if(people[i].equals("John")) {
                sendAlert();
                return "John";
            }
        }
    }

    return "";
}
  • 값을 반환하지 않을 때도 return 문 방식을 사용할 수 있다.
  • 이 메서드는 아직 부작용이 있다.
  • 상태 변경 메서드와 값 반환 메서드 분리 기법을 실시해야 한다. 차후에 나온다.

5. 여러 겹의 조건문을 감시 절로 전환

  • 메서드에 조건문이 있어서 정상적인 실행 경로를 파악하기 힘들 땐 모든 특수한 경우에 감시 절을 사용하자.

5.1 동기

  • 조건식은 주로 두 가지 형태를 띤다.

  • 어느 한 경로가 정상적인 동작의 일부인지 검사하는 형태
  • 조건식 판별의 한 결과만 정상적인 동작을 나타내고 나머지는 비정상적인 동작을 나타내는 형태

  • 조건문의 의도는 드러나야 한다.
  • 만약 둘다 정상 동작의 일부분이라면 if절과 else절로 구성된 조건문을 사용하고, 조건문이 특이한 조건이라면 그 조건을 검사해서 조건이 true일 경우 반환하자, 이런 식의 검사를 감시 절이라고 한다.
  • 여러 겹의 조건문을 감시 절로 전환 기법의 핵심은 강조 부분이다.
  • if-then-else문을 사용하면 if 절과 else절의 비중이 동등하다.
  • 따라서 코드를 보는 사람은 if절과 else 절의 비중과 같다고 판단하게 된다.
  • 그와 달리, 감시 절은 “이것은 드문 경우이니 이 경우가 발생하면 작업을 수행한 후 빠져나와라”하고 명령한다.
  • 메서드에 진입점과 이탈점이 하나씩만 있다고 배운 프로그래머와 공동으로 작업할 대는 주로 여러 겹의 조건문을 감시 절로 전환 기법을 사용한다.

5.2 방법

  • 조건 절마다 감시 절을 넣자.
    • 그 감시 절은 값을 반환하거나 예외를 통지한다.
    • 모든 감시 절의 결과가 같다면 중복 조건식 통합 기법을 실시하자.

5.3 예제

double getPayAmout() {
    double result;

    if(isDead) {
        result = deadAmount();
    } else {
        if(isSeparated) {
            result = separatedAmount();
        } else {
            if(isRetired) {
                result = retiredAmount();
            } else {
                result = normalPayAmount();
            }
        }
    }

    return result;
}
  • 하나씩 감시절을 사용한다. 아래는 최종본
double getPayAmout() {
    if(isDead) {
        return deadAmount();
    }

    if(isSeparated) {
        return separatedAmount();
    }

    if(isRetired) {
        return retiredAmount();
    }

    return normalPayAmount();
}
5.3.1 조건문을 역순으로 만들기
public double getAdjustedCapital() {
    double result = 0.0;

    if(capital > 0.0) {
        if(intRate > 0.0 && duration > 0.0) {
            result = (income / duration) * ADJ_FACTOR;
        }
    }

    return result;
}
  • 조건문 역순으로 한다.
  • 감시 절의 return문에 명시적 값을 붙인다.
  • 아래는 최종
public double getAdjustedCapital() {		
    if(capital <= 0.0) {
        return 0.0;
    }

    if(intRate <= 0.0 || duration <= 0.0) {
        return 0.0;
    }

    return (income / duration) * ADJ_FACTOR;
}

6. 조건문을 재정의로 전환

  • 객체 타입에 따라 다른 기능을 실행하는 조건문이 있을 땐 조건문의 각 절을 하위클래스의 재정의 메서드 안으로 옮기고 원본 메서드는 abstract 타입으로 수정하자.

6.1 동기

  • 재정의의 본질은 타입에 따라 기능이 달라지는 여러 객체가 있을 때 일일이 조건문을 작성하지 않아도 다형적으로 호출되게 할 수 있다는 것이다.
  • 그래서 if문 switch문은 객체지향 프로그램에 별로 사용하지 않는다.
  • 다형성 개념을 적용한 재정의 방식을 사용하면 많은 장점이 있다.

6.2 방법

  • 조건문을 재정의로 전환기법을 적용하기 전에 필요한 상속 구조부터 만들어야 한다.
  • 앞에서 배운 리팩토링 기법의 적용으로 어쩌면 이미 적절한 상속 구조가 형성되어 있을지도 모른다.
  • 하지만 그렇지 않다면 적절한 상속 구조로 만들어야 한다.
  • 상속 구조를 만드는 방법은 두 가지다.
    1. 분류 부호를 하위클래스로 전환
    2. 분류 부호를 상태/전략 패턴으로 전환
  • 하위 클래스로 만드는 것이 가장 간단하다.
  • 그러나 객체가 생성된 후 분류 부호를 수정하면 분류 부호를 하위클래스나 상태/전략 패턴으로 바꿀 수 없다.
  • 다른 이유로 이 클래스에 이미 하위클래스를 작성했다면, 상태/전략 패턴으로 전환하는 기법을 사용해야 한다.
  • 여러 개의 case 문이 하나의 분류 부호 값에 따라 다른 코드를 실행할 땐 그 분류 부호를 대체할 하나의 상속 구조를 만들어야 한다.

6.3 예제

public class Employee {
	private EmployeeType type;
	
	int payAmount() {
		switch(getType()) {
			case EmployeeType.ENGINEER:
				return monthlySalary;
			case EmployeeType.SALESMAN:
				return monthlySalary + commission;
			case EmployeeType.MANAGER:
				return monthlySalary + bonus;
			default:
				throw new RuntimeException("없는 사원입니다");
		}
	}
	
	int getType() {
		return type.getTypeCode();
	} 
}
  • 하위클래스로 만들 EmployeeType 클래스로 옮겨야 한다.
public class EmployeeType {
	static final int ENGINEER = 0;
	static final int SALESMAN = 1;
	static final int MANAGER = 2;
	
	int payAmount(Employee emp) {
		switch (getTypeCode()) {
			case EmployeeType.ENGINEER:
				return emp.getMonthlySalary();
			case EmployeeType.SALESMAN:
				return emp.getMonthlySalary() + emp.getCommission();
			case EmployeeType.MANAGER:
				return emp.getMonthlySalary() + emp.getBonus();
			default:
				throw new RuntimeException("없는 사원입니다");
		}
	}
}
  • Employee 클래스의 데이터가 필요하므로 Employee 클래스를 인자로 전달해야 한다.
  • 이 데이터 중 일부는 Employee 타입의 객체로 옮기게 될 수도 있지만 그건 다른 리팩토링 기법으로 처리할 문제다.
  • 이것을 컴파일할 때 Employee 클래스 안의 payAmount 메서드를 수정해서 다음과 같이 새 클래스로 위임하게 만들자.
public class Employee {
	private EmployeeType type;
	
	int payAmount() {
		return type.payAmount(this);
	}
}
  • case 문을 정리한다.
  • case 문의 ENGINEER 절 코드를 Engineer 클래스로 복사한다.
public class Engineer extends EmployeeType{
	int payAmount(Employee emp) {
		return emp.getMonthlySalaray();
	}
}
  • 새로 만든 Engineer 메서드는 ENGINEER에 해당하는 case 문 전체를 재정의한다.
  • 나머지도 다 똑같이 한다.
public abstract class EmployeeType {
	static final int ENGINEER = 0;
	static final int SALESMAN = 1;
	static final int MANAGER = 2;
	
	abstract int payAmount(Employee emp);
}

7. Null 검사를 널 객체에 위임

  • null 값을 검사하는 코드가 계속 나올 땐 null 값을 널 객체로 만들자.

7.1 동기

  • 재정의의 본질은 어떤 종류인지를 객체에 일일이 물어서 그 응답에 따라 실행할 기능을 호출하는 것이 아니라, 묻지도 따지지도 않고 기능을 곧바로 호출하는 것이다.
  • 객체는 타입에 따라 그에 맞는 기능을 수행한다.
  • null 값이 저장된 필드가 있을 땐 재정의하면 비교적 이해하기 힘들다.

7.2 방법

  • 원본 클래스 안에 널 객체 역할을 할 하위클래스를 작성하자. 원본 클래스와 널 클래스 안에 isNull 메서드를 작성하자. 원본 클래스의 isNull 메서드는 false를 반환해야 하고, 널 클래스의 isNull 메서드는 true를 반환해야 한다.
    • isNull 메서드를 넣을 Nullable 인터페이스를 작성하면 좋을 때도 있다.
    • 아니면, 검사 인터페이스로 null 여부를 검사하는 방법도 있다.
  • 원본 객체에 요청하면 null을 반환할 수 있는 부분을 전부 찾자. 그래서 그 부분을 널 객체로 바꾸자.
  • 원본 타입의 변수를 null과 비교하는 코드를 전부 찾아서 isNull 호출로 바꾸자.
    • 원본 클래스와 클라이언트를 한 번에 하나씩 수정하고 그때마다 컴파일과 테스트를 실시하면 된다.
    • null이 나오지 말아야 할 곳에 null을 검사하는 어설셜을 몇 개 넣으면 좋다.
  • 클라이언트가 null이 아니면 한 메서드를 호출하고 null이면 다른 메서드를 호출하는 case문을 전부 찾자.
  • 각 case 문마다 널 클래스 안의 해당 메서드를 다른 기능의 메서드로 재정의하자.
  • 재정의 메서드를 사용하는 부분에서 조건문을 삭제하고 컴파일과 테스트를 실시하자.

7.3 예제

  • 공공설비 업체는 공공 설비 서비스를 이용하는 주택가와 아파트 단지 등의 지역을 파악하고 있다.
  • 한 지역에 있는 고객은 반드시 하나다.
public class Site {
	Customer customer;
	
	Customer getCustomer() {
		return customer;
	}
}

public class Customer {
	String name;
	
	public String getName() {
		return name;
	}
	
	public BillingPlan getPlan() {
	}
	
	public PaymentHistory getHistory() {
		
	}
}

public class PaymentHistory {
	int getWeeksDelinquentInLastYear() {
	}
}
  • 클라이언트는 다음과 같은 데이터에 접근할 수 있다.
  • 그러나 고객에 없는 지역도 간혹 있다.
  • 어떤 사람이 다른 지역으로 이사한 후 거기로 누가 이사해 왔는지 아직 파악하지 않았을 수도 있다.
  • 이런 상황이 발생할 수 있기에 Customer 클래스를 사용하는 코드에 다음과 같이 null처리를 넣어야 한다.
Site site = new Site();
Customer customer = site.getCustomer();
BillingPlan plan;

if(customer == null) {
    plan = BillingPlan.basic();
} else {
    plan = customer.getPlan();
}

String customerName;

if(customer == null) {
    customerName = "occupant";
} else {
    customerName = customer.getName();
}

int weeksDelinquent;

if(customer == null) {
    weeksDelinquent = 0;
} else {
    weeksDelinquent = customer.getHistory().getWeeksDelinquentInLastYear();
}
  • 이때 Site 클래스와 Customer 클래스가 많은 부분에 사용되고, 그 모든 부분에서 null을 검사해서 null을 발견할 때마다 같은 작업을 수행해야 할 수도 있다.
  • 따라서 널 객체가 필요하다.

  • 우선 다음과 같이 NullCustomer 클래스를 작성하고 널 검사 메서드를 호출하게 Customer 클래스를 수정하자.
public class NullCustomer extends Customer{
	public boolean isNull() {
		return true;
	}
}

public class Customer {
	String name;
	protected Customer() {
	}
	
	public boolean isNull() {
		return false;
	}
}
  • Customer 클래스를 수정할 수 없으면 null 검사 인터페이스를 사용하면 된다.
  • 괜찮다면 널 객체를 사용한다는 사실을 다음과 같이 인터페이스를 통해 나타내자.
interface Nullable {
	boolean isNull();
}

class Customer implements Nullable
  • NullCustomer 인스턴스를 생성하는 팩토리 메서드를 추가하자.
  • 이렇게 하면 클라이언트가 널 클래스 정보를 알 필요가 없다.
public class Customer {
	static Customer newNull() {
		return new NullCustomer();
	}
}
  • null이 예상될 때마다 새 널 객체를 반환하고 foo==null 형태의 null 검사 코드를 foo.isNull() 형태의 코드로 수정해야 한다.
  • Customer 클래스가 사용되는 부분을 모두 찾아서 null 대신 NullCustomer 클래스를 반환하게 수정하자.
public class Site {
	Customer customer;
	
	Customer getCustomer() {
		return (customer == null) ? Customer.newNull() : customer;
	}
}
  • 이 값을 사용하는 부분도 null 검사 코드 대신 isNull()로 수정하자.
Site site = new Site();
Customer customer = site.getCustomer();
BillingPlan plan;

if(customer.isNull()) {
    plan = BillingPlan.basic();
} else {
    plan = customer.getPlan();
}

String customerName;

if(customer.isNull()) {
    customerName = "occupant";
} else {
    customerName = customer.getName();
}

int weeksDelinquent;

if(customer.isNull()) {
    weeksDelinquent = 0;
} else {
    weeksDelinquent = customer.getHistory().getWeeksDelinquentInLastYear();
}
  • 수정하는 null의 각 원본 객체마다 null 여부를 검사하는 부분을 전부 찾아서 교체해야 한다.
  • Customer 타입의 모든 변수를 찾고 그 변수가 사용되는 곳을 전부 찾아야 한다.
  • 널 검사 형태의 == null 코드를 isNull 호출로 수정했지만 아직은 장점이 와닿지 않는다.
  • 그 장점은 NullCustomer로 기능을 옮기고 조건문을 삭제해야 느낄 수 있다.
  • NullCustomer 클래스에 다음과 같이 적합한 name 읽기 메서드를 추가하자.
String customerName;

if(customer.isNull()) {
    customerName = "occupant";
} else {
    customerName = customer.getName();
}
String customerName = customer.getName
    
if(customer.isNull()) {
    customer.setPlan(BillingPlan.special());
}

customer.setPlan(BillingPlan.special());

public class NullCustomer extends Customer{
	public boolean isNull() {
		return true;
	}
	
	public String getName() {
		return "occupant";
	}
	
	public void setPlan(BillingPlan arg) {
	}
}

8 어설션 넣기

  • 일부 코드가 프로그램의 어떤 상태를 전제할 땐 어설션을 넣어서 그 전체를 확실하게 코드로 작성하자.

8.1 동기

  • 어설션이란 항상 참으로 참제되는 조건문을 뜻한다.
  • 어설션이 실패하면 그건 프로그래머가 오류를 범한 것이다.
  • 그래서 어설션이 실패할 경우 반드시 에외를 통지하게 해야 한다.
  • 시스템의 다른 부분에는 절대로 어설션을 사용하면 안 된다.
  • 어설션은 대개 제품화 단게에서 삭제한다.
  • 따라서 코드에서 어설션을 넣은 부분을 꼭 표시해둬야 한다.

8.2 방법

  • (이번 절은 일단 딱히 중하지 않아 보여서 패스)