Devin.KR

조건문 정리 - 보호 구문·다형성·표

개발자KR 조회 5

이 장에서 배우는 것

앞 장에서 수강료 계산 함수를 몇 가지 작은 함수로 나눴다. 함수 이름은 읽기 좋아졌지만, 함수 안에는 여전히 조건문이 겹쳐 있다. 활성 상태를 확인하고, 수강료를 확인하고, 수강생 유형과 강좌 유형을 차례로 확인해야 할인액에 도달한다. 함수가 짧아져도 조건을 따라가는 길이 복잡하면 읽는 부담은 남는다.

이 장에서는 조건의 개수보다 조건이 놓인 자리에 주목한다. 계산을 진행할 수 없는 경우는 앞에서 끝내고, 같은 형태로 반복되는 할인율 선택은 표에 모은다. 그 과정에서 기존 계산의 순서와 정수 나눗셈의 결과를 유지한다. 완성 코드에는 이전 구현도 남겨 두어 같은 입력의 결과를 직접 대조한다.

  • 보호 구문(guard clause)으로 예외적인 경로를 먼저 끝내고 중첩을 줄인다.
  • 분기가 선택하는 것이 동작인지 값인지 구별하여 다형성과 맵 기반 표를 선택한다.
  • 계산의 뜻을 숨기는 숫자를 상수로 바꾸고, switch 식이 어울리는 범위를 판단한다.
  • 할인 규칙의 모든 조합을 열거하고 이전 구현과 이후 구현의 결과를 비교한다.

문제 상황

학원 수강 관리 프로그램에는 할인액을 반환하는 함수가 있다. 수강생 유형은 일반, 재수강, 지원 대상의 세 가지다. 강좌 유형은 단체와 개인의 두 가지다. 이 장의 할인율은 설명을 위한 가상 업무 규칙이며, 실제 기관의 요금이나 제도를 나타내지 않는다.

일반 수강생은 할인받지 않는다. 재수강생은 단체 강좌에서 10%, 개인 강좌에서 5%를 할인받는다. 지원 대상 수강생은 단체 강좌에서 20%, 개인 강좌에서 10%를 할인받는다. 비활성 수강생이거나 수강료가 0 이하이면 할인액은 0원이다. 할인액의 원 미만 부분은 정수 나눗셈으로 버린다.

이 장에서 유지할 수강생 유형별 할인율
수강생 유형단체 강좌개인 강좌
일반0%0%
재수강10%5%
지원 대상20%10%

기존 함수는 할인액을 0으로 시작한 뒤 조건에 맞을 때만 값을 덮어쓴다. 담당자는 개인 강좌의 할인율 하나를 확인하려고 여러 중괄호를 오간다. 규칙을 추가할 때는 비슷한 분기를 복사하기 쉽다. 복사한 분기에서 숫자 하나를 놓쳐도 컴파일은 성공한다.

long discount = 0L;
if (active) {
    if (fee > 0) {
        if (studentType == StudentType.RETURNING) {
            if (courseType == CourseType.GROUP) {
                discount = fee * 10L / 100;
            } else {
                discount = fee * 5L / 100;
            }
        }
    }
}
return discount;

이 조각은 문제의 모양을 보여 주기 위해 일부 유형만 남긴 것이다. 완성 코드의 이전 구현에는 세 유형의 규칙이 모두 들어간다. 여기서 바꿀 대상은 할인 정책이 아니라 정책을 표현하는 방식이다. 음수 수강료를 예외로 바꾸거나 할인액을 반올림하는 변경은 이번 작업에 섞지 않는다.

입력의 범위도 정해 둔다. 수강생 유형과 강좌 유형은 null이 아닌 열거형 값이며, 수강료는 원 단위 int 값이다. 활성 상태는 boolean 값이다. 비교에서 말하는 모든 조합은 세 수강생 유형, 두 강좌 유형, 두 활성 상태의 조합을 뜻한다. int 전체 범위의 모든 수강료를 실행한다는 뜻은 아니다. 수강료는 경계를 드러내는 대표값으로 확인한다.

중첩을 줄이는 보호 구문

보호 구문은 나머지 계산을 할 필요가 없는 조건을 앞에 두고 함수를 끝내는 방식이다. 이 함수에서는 비활성 수강생과 0 이하의 수강료가 해당한다. 두 경우 모두 기존 결과가 0원이므로, 그 결과를 곧바로 반환할 수 있다.

if (!active) {
    return 0L;
}
if (fee <= 0) {
    return 0L;
}

// 여기부터는 활성 수강생이며 수강료가 양수다.

이렇게 바꾸면 뒤쪽 코드는 두 조건이 통과되었다는 전제에서 읽을 수 있다. 할인율을 찾는 부분을 읽으면서 활성 여부를 다시 생각할 필요가 줄어든다. 들여쓰기를 두 단계 없앤다는 외형적인 효과보다, 계산이 시작되는 지점의 전제가 선명해진다는 점이 더 중요하다.

계산하지 않는 경우를 먼저 반환하면 할인 계산 경로가 한 줄로 이어진다

조건을 뒤집을 때는 경계도 함께 뒤집어야 한다. 기존에 fee > 0일 때 계산했다면 계산하지 않는 조건은 fee <= 0이다. fee < 0으로만 바꾸면 0원의 경로가 달라진다. 이번 계산에서는 우연히 결과가 같더라도, 그 경로에서 다른 작업이 수행되는 함수라면 차이가 드러날 수 있다.

반환 위치가 늘었다는 이유만으로 보호 구문을 피할 필요는 없다. 이 함수의 조기 반환은 계산에 들어갈 수 없는 이유를 보여 준다. 반대로 정상 처리의 중간마다 반환을 흩어 놓으면 결과를 찾는 일이 다시 어려워진다. 입구에서 끝낼 조건과 정상 계산을 구분하여 배치한다.

조건의 순서도 검토한다. 이 예제의 조건은 전달받은 값을 읽기만 하므로 앞뒤를 바꾸어도 결과가 같다. 그러나 조건 검사에서 로그를 남기거나 상태를 바꾸는 기존 코드라면 순서를 바꾸는 일이 관찰 가능한 결과에 영향을 줄 수 있다. 중첩을 펴기 전에 조건이 무엇을 수행하는지 확인해야 한다.

보호 구문을 넣은 직후에는 할인율 분기를 그대로 두고 비교할 수 있다. 활성 여부와 수강료 검사만 옮긴 상태에서 결과가 같음을 확인하면, 이후 표로 옮기다가 차이가 생겼을 때 살펴볼 범위가 줄어든다. 한 번에 모든 모양을 바꾸는 것보다 변경의 원인을 찾기 쉽다.

분기가 고르는 것은 값인가 동작인가

중첩을 걷어 낸 뒤에는 남은 분기의 역할을 본다. 각 분기가 같은 계산식을 사용하고 할인율만 다르게 고른다면, 분기가 표현하는 내용은 값의 대응 관계다. 수강생 유형과 강좌 유형을 알면 할인율 하나가 정해진다. 이런 관계는 맵 기반 표로 옮기기 좋다.

반면 어떤 유형은 정액을 차감하고, 다른 유형은 비율을 곱하며, 또 다른 유형은 상한액을 적용한다면 분기마다 계산 동작이 다르다. 그때는 같은 인터페이스를 구현한 객체에 계산을 맡기는 다형성(polymorphism)을 검토할 수 있다. 중요한 기준은 분기의 길이만이 아니라 분기 사이에 무엇이 달라지는가다.

interface DiscountRule {
    long discount(int fee);
}

final class FixedDiscount implements DiscountRule {
    private final long amount;

    FixedDiscount(long amount) {
        this.amount = amount;
    }

    @Override
    public long discount(int fee) {
        return Math.min((long) fee, amount);
    }
}

이 코드는 정액 할인이라는 별도의 동작을 객체로 표현한 예다. 양수 수강료와 음수가 아닌 정액을 전제로 한다. 현재 학원 규칙에 정액 할인을 추가한다는 뜻은 아니다. 이번 완성 코드에서는 여섯 칸 모두 비율 계산이 같으므로, 계산 객체 여섯 개 대신 할인율 여섯 개를 표에 넣는다.

반복되는 분기를 옮길 위치를 고르는 기준
분기의 차이고려할 표현확인할 점
반환하는 값만 다르다맵 기반 표필요한 키 조합이 모두 있는가
몇 개의 고정된 선택에서 값을 만든다switch 식열거형의 경우를 빠짐없이 다루는가
계산 절차 자체가 다르다인터페이스와 구현 객체각 구현에 독립된 동작이 있는가

switch 식은 값을 선택하는 흐름을 드러낸다

JDK 17의 switch 식은 선택 결과를 값으로 돌려줄 수 있다. 아래처럼 강좌 유형에 따라 두 할인율 중 하나를 선택할 때 사용할 수 있다. 화살표 오른쪽 결과가 해당 경우의 값이 되므로, 이 형태에서는 다음 분기로 흘러가는 것을 막기 위한 break가 필요하지 않다.

int rate = switch (courseType) {
    case GROUP -> 10;
    case PERSONAL -> 5;
};

이 조각은 재수강생 한 유형의 할인율을 선택하는 예다. 열거형의 모든 상수를 직접 적었으므로 default 없이도 경우를 채울 수 있다. 나중에 강좌 유형을 추가하고 다시 컴파일하면 빠진 선택을 확인할 수 있다. 의미 없는 default로 0을 반환하면 새 유형을 검토해야 한다는 신호를 가릴 수 있다.

switch 식이 있다고 모든 표를 switch로 바꿀 필요는 없다. 두 축의 규칙을 중첩된 switch로 작성하면 여섯 칸의 대응 관계가 다시 제어 흐름 속으로 흩어진다. 이번 완성 코드에서는 행 하나가 수강생 유형 하나를 나타내도록 표를 구성한다. switch 식은 작고 닫힌 값 선택에 쓸 수 있는 다른 표현으로 구분해 둔다.

표의 빈칸과 0은 다르다

일반 수강생의 할인율 0은 정해진 업무 규칙이다. 반면 표에 특정 조합이 없는 것은 규칙을 옮기다 빠뜨렸을 가능성을 뜻한다. 둘을 모두 0으로 처리하면 누락이 정상 결과처럼 보인다. 완성 코드에서는 표를 만드는 시점에 모든 열거형 조합을 순회하여 빈칸을 확인한다.

할인율 표는 여섯 조합을 모두 채우며 누락된 칸을 할인율 0과 구별한다

표를 조회할 때마다 누락에 대비한 기본값을 붙이기보다, 프로그램 시작 시점에 표 전체가 갖추어졌는지 확인한다. 현재 표는 코드에 고정되어 있으므로 이 시점의 검사로 충분하다. 실행 중에 규칙을 교체하는 구조라면 새 표를 받아들이는 지점에도 검사가 필요하다.

숫자에도 역할을 붙인다. 100은 백분율을 나누는 기준이므로 PERCENT_BASE라는 상수로 만든다. 10이나 20은 할인율 표의 데이터이므로 표에 그대로 둔다. 모든 숫자를 상수로 바꾸면 이름을 찾아다니느라 표의 대응 관계를 읽기 어려워질 수 있다. 계산의 기준과 업무 규칙의 값을 구별하는 편이 낫다.

금액 계산에서는 곱셈의 자료형도 유지해야 한다. int끼리 곱한 뒤 long 변수에 넣으면, long으로 저장되기 전에 int 범위에서 넘칠 수 있다. 완성 코드에서는 곱셈 전에 fee를 long으로 바꾼다. 이전 구현도 long 곱셈을 사용하여 비교 대상의 계산 범위를 맞춘다.

완성 코드

아래 파일 하나에 이전 구현, 표를 쓰는 이후 구현, 실행 검사를 함께 넣는다. 외부 입력이나 현재 시각을 사용하지 않으므로 매번 같은 결과를 출력한다. 할인액을 반환하는 함수이며, 실제 납부액을 반환하는 함수는 아니라는 점에 주의한다.

Main.java

import java.util.EnumMap;
import java.util.Map;

public class Main {
    private static final int PERCENT_BASE = 100;

    enum StudentType {
        GENERAL, RETURNING, SUPPORTED
    }

    enum CourseType {
        GROUP, PERSONAL
    }

    private static final Map<StudentType, Map<CourseType, Integer>>
            DISCOUNT_RATES = createDiscountRates();

    private static Map<StudentType, Map<CourseType, Integer>>
            createDiscountRates() {
        Map<StudentType, Map<CourseType, Integer>> rates =
                new EnumMap<>(StudentType.class);

        rates.put(StudentType.GENERAL, Map.of(
                CourseType.GROUP, 0,
                CourseType.PERSONAL, 0));
        rates.put(StudentType.RETURNING, Map.of(
                CourseType.GROUP, 10,
                CourseType.PERSONAL, 5));
        rates.put(StudentType.SUPPORTED, Map.of(
                CourseType.GROUP, 20,
                CourseType.PERSONAL, 10));

        for (StudentType studentType : StudentType.values()) {
            Map<CourseType, Integer> row = rates.get(studentType);
            if (row == null) {
                throw new IllegalStateException(
                        "할인율 행 누락: " + studentType);
            }
            for (CourseType courseType : CourseType.values()) {
                Integer rate = row.get(courseType);
                if (rate == null) {
                    throw new IllegalStateException(
                            "할인율 칸 누락: "
                                    + studentType + "/" + courseType);
                }
                if (rate < 0 || rate > PERCENT_BASE) {
                    throw new IllegalStateException(
                            "할인율 범위 오류: " + rate);
                }
            }
        }
        return Map.copyOf(rates);
    }

    static long oldDiscount(
            boolean active,
            int fee,
            StudentType studentType,
            CourseType courseType) {
        long discount = 0L;
        if (active) {
            if (fee > 0) {
                if (studentType == StudentType.GENERAL) {
                    discount = 0L;
                } else if (studentType == StudentType.RETURNING) {
                    if (courseType == CourseType.GROUP) {
                        discount = fee * 10L / 100;
                    } else {
                        discount = fee * 5L / 100;
                    }
                } else {
                    if (courseType == CourseType.GROUP) {
                        discount = fee * 20L / 100;
                    } else {
                        discount = fee * 10L / 100;
                    }
                }
            }
        }
        return discount;
    }

    static long newDiscount(
            boolean active,
            int fee,
            StudentType studentType,
            CourseType courseType) {
        if (!active) {
            return 0L;
        }
        if (fee <= 0) {
            return 0L;
        }

        int rate = DISCOUNT_RATES.get(studentType).get(courseType);
        return (long) fee * rate / PERCENT_BASE;
    }

    private static void check(boolean condition, String name) {
        System.out.println((condition ? "PASS " : "FAIL ") + name);
        if (!condition) {
            throw new AssertionError(name);
        }
    }

    public static void main(String[] args) {
        int[] fees = {-1, 0, 99, 10_000, Integer.MAX_VALUE};
        boolean[] activeStates = {false, true};
        boolean allEqual = true;
        int comparisons = 0;

        for (StudentType studentType : StudentType.values()) {
            for (CourseType courseType : CourseType.values()) {
                for (boolean active : activeStates) {
                    for (int fee : fees) {
                        long before = oldDiscount(
                                active, fee, studentType, courseType);
                        long after = newDiscount(
                                active, fee, studentType, courseType);
                        if (before != after) {
                            allEqual = false;
                            System.out.println(
                                    "不一致: " + studentType + "/"
                                            + courseType + "/"
                                            + active + "/" + fee);
                        }
                        comparisons++;
                    }
                }
            }
        }

        System.out.println("전후 비교: " + comparisons + "개");
        check(allEqual, "모든 조합의 전후 결과");

        StudentType[] students = {
                StudentType.GENERAL,
                StudentType.GENERAL,
                StudentType.RETURNING,
                StudentType.RETURNING,
                StudentType.SUPPORTED,
                StudentType.SUPPORTED
        };
        CourseType[] courses = {
                CourseType.GROUP,
                CourseType.PERSONAL,
                CourseType.GROUP,
                CourseType.PERSONAL,
                CourseType.GROUP,
                CourseType.PERSONAL
        };
        long[] expected = {0L, 0L, 1_000L, 500L, 2_000L, 1_000L};

        boolean expectedMatched = true;
        for (int i = 0; i < expected.length; i++) {
            long actual = newDiscount(
                    true, 10_000, students[i], courses[i]);
            if (actual != expected[i]) {
                expectedMatched = false;
            }
        }
        check(expectedMatched, "기준 금액 6개 할인액");
        check(newDiscount(
                false, 10_000,
                StudentType.SUPPORTED, CourseType.GROUP) == 0L,
                "비활성 수강생");
        check(newDiscount(
                true, -1,
                StudentType.SUPPORTED, CourseType.GROUP) == 0L,
                "음수 수강료");
        check(newDiscount(
                true, 99,
                StudentType.RETURNING, CourseType.GROUP) == 9L,
                "원 미만 버림");

        long before = oldDiscount(
                true, 10_000,
                StudentType.RETURNING, CourseType.GROUP);
        long after = newDiscount(
                true, 10_000,
                StudentType.RETURNING, CourseType.GROUP);
        System.out.println(
                "예시 할인액: 이전=" + before + ", 이후=" + after);
    }
}

줄별 해설

import 두 줄은 열거형을 키로 사용하는 EnumMap과 표의 공통 자료형인 Map을 가져온다. 표는 수강생 유형으로 행을 찾고, 강좌 유형으로 할인율을 찾는 두 단계 구조다. Map<StudentType, Map<CourseType, Integer>>에서 바깥 키와 안쪽 키의 역할을 먼저 읽으면 긴 선언도 따라갈 수 있다.

PERCENT_BASE는 할인율을 금액으로 환산할 때 나누는 기준이다. 이전 구현의 100은 비교할 원형을 보여 주기 위해 남겨 두었다. 이후 구현에서는 이 상수를 사용하여 계산의 뜻을 드러낸다. StudentType과 CourseType은 표의 두 축을 정의하며, 문자열 철자를 맞추는 대신 정해진 값으로 조회하게 한다.

DISCOUNT_RATES의 초기화는 createDiscountRates 호출로 이루어진다. 이 함수는 먼저 수정 가능한 EnumMap에 세 행을 넣는다. 각 행은 Map.of로 만들어 변경할 수 없게 한다. 마지막에 바깥 맵도 Map.copyOf로 복사한다. 바깥 맵을 복사하는 것만으로 내부 객체까지 변경 불가능해지는 것은 아니므로, 안쪽 행을 만드는 방식도 함께 봐야 한다.

세 번의 rates.put은 앞의 업무 규칙 표와 같은 순서다. 일반 행에는 두 개의 0이 명시되어 있다. 할인하지 않는다는 규칙도 표의 한 칸으로 기록한다. 이 덕분에 0이라는 정상 값과 키 누락으로 얻는 null을 구별할 수 있다.

이어지는 두 반복문은 표의 구조를 확인한다. 바깥 반복문은 모든 수강생 유형의 행이 있는지 확인한다. 안쪽 반복문은 각 행에 모든 강좌 유형의 값이 있는지 확인한다. 할인율은 0 이상 100 이하인지도 검사한다. 이러한 검사는 표의 누락과 범위를 찾지만, 10을 15로 잘못 적은 오류까지 찾아내지는 못한다.

oldDiscount는 중첩된 원래 계산이다. 양수 수강료의 정상 경로에 들어가려면 활성 상태 검사와 수강료 검사를 통과해야 한다. 마지막 else는 현재 입력 계약에서 지원 대상을 뜻한다. 표로 바꾼 뒤에는 지원 대상이라는 의미가 명시적인 키로 나타난다.

newDiscount의 처음 두 if는 보호 구문이다. 두 반환을 지나면 할인율 조회와 계산만 남는다. 표에 모든 조합이 있다는 사실은 생성 시 검사로 보장하며, 조회에는 null이 아닌 열거형 값이 들어온다는 입력 계약을 사용한다. null을 허용할 필요가 생긴다면 처리 규칙을 먼저 정하고 양쪽 구현과 검사에 함께 반영해야 한다.

반환문의 (long) fee는 곱셈 전에 금액의 계산 범위를 넓힌다. 곱셈 다음에 백분율 기준으로 나누므로, 99원의 10%는 9원이 된다. fee / PERCENT_BASE * rate처럼 나눗셈을 앞으로 옮기면 99 / 100이 먼저 0이 되어 결과가 달라진다. 같은 수식을 닮았어도 정수 연산에서는 순서가 의미를 바꾼다.

check는 조건에 맞으면 PASS를 출력하고, 맞지 않으면 FAIL을 출력한 뒤 프로그램을 실패로 끝낸다. 모든 검사가 성공한 것처럼 다음 작업으로 넘어가는 일을 막기 위한 작은 장치다. 별도의 검사 도구 없이 main에서 실행할 수 있다.

main의 fees에는 음수, 0, 절삭이 발생하는 값, 계산을 읽기 쉬운 기준 금액, int 최댓값을 넣었다. 네 겹의 반복문은 3 × 2 × 2 × 5로 60번 비교한다. 결과가 다르면 해당 입력을 먼저 출력한다. 표의 순회 순서에 의존하지 않고 열거형 배열과 정해진 배열을 순회하므로 출력 순서도 일정하다.

expected 배열의 여섯 값은 10,000원에 업무 규칙을 직접 적용한 기대 할인액이다. 이전 구현과 이후 구현이 같다는 검사에 더해, 이후 구현이 명세의 대표 결과와 맞는지도 확인한다. 두 구현이 같은 오타를 공유할 가능성을 줄이는 역할이다. 마지막 세 check는 보호 구문과 절삭 규칙을 이름으로 드러낸다.

실행 결과

Main.java가 있는 디렉터리에서 다음 명령을 실행한다. JDK 17의 javac와 java만 사용한다. 추가 경고 검사 옵션을 켠 명령에서도 이 코드는 컴파일 경고를 만들지 않는다.

javac -Xlint:all -d out *.java
java -cp out Main

기본 컴파일 명령인 javac -d out *.java로도 같은 프로그램을 실행할 수 있다. 정상 실행의 예상 출력은 다음과 같다.

전후 비교: 60개
PASS 모든 조합의 전후 결과
PASS 기준 금액 6개 할인액
PASS 비활성 수강생
PASS 음수 수강료
PASS 원 미만 버림
예시 할인액: 이전=1000, 이후=1000

첫 번째 PASS는 지정한 다섯 수강료 각각에 대해 모든 유형과 활성 상태 조합의 결과가 같다는 뜻이다. 두 번째 PASS는 여섯 할인 규칙이 기준 금액에서 예상한 값을 낸다는 뜻이다. 마지막 출력에서는 동일한 재수강생의 단체 강좌에 두 함수를 적용한 결과를 나란히 읽을 수 있다.

이 검사가 int 수강료 전체의 동등성을 증명하지는 않는다. 실행으로 확인한 범위는 60개 입력이다. 그 밖의 범위를 판단할 때는 양쪽이 같은 할인율을 사용하고, 같은 보호 조건을 가지며, long 곱셈 뒤에 100으로 나눈다는 코드의 대응 관계도 함께 살펴본다. 실행 결과와 코드 검토가 맡는 역할을 구분해야 한다.

실무에서 자주 틀리는 것

보호 구문을 넣으며 기존 결과까지 바꾼다

음수 수강료가 이상해 보인다는 이유로 예외를 던지면, 기존에 0을 받던 호출자의 동작이 바뀐다. 정책 변경이 필요할 수는 있지만, 조건문 표현을 바꾸는 작업과 구분하여 다뤄야 한다.

틀린 코드는 다음과 같다.

if (fee < 0) {
    throw new IllegalArgumentException("음수 수강료");
}

이번 작업에서 기존 결과를 유지하는 코드는 다음과 같다.

if (fee <= 0) {
    return 0L;
}

보호 구문은 반환할 근거를 앞으로 옮긴다. 근거 자체를 새로 만드는 작업은 아니다. 반환값뿐 아니라 예외 발생 여부도 호출자가 관찰하는 결과에 포함된다.

누락된 규칙을 할인율 0으로 숨긴다

표를 쓰면서 기본값을 붙이면 조회가 간단해 보인다. 그러나 지원 대상의 개인 강좌 칸을 빠뜨려도 할인율 0으로 처리되어, 정해진 할인이 조용히 사라질 수 있다.

틀린 코드는 다음과 같다.

int rate = row.getOrDefault(courseType, 0);

누락을 드러내는 검사 코드는 다음과 같다.

Integer rate = row.get(courseType);
if (rate == null) {
    throw new IllegalStateException("할인율 칸 누락");
}

완성 코드처럼 생성 시 모든 칸을 검사하면 정상 계산 경로에서 같은 검사를 반복할 필요가 줄어든다. 기본값이 실제 업무 규칙인 경우에만 기본값을 사용한다.

식을 정리하면서 정수 계산의 순서를 바꾼다

백분율 계산을 먼저 한다는 생각으로 나눗셈을 앞으로 옮기면 작은 금액의 결과가 달라진다. int 곱셈을 끝낸 뒤 long으로 형 변환하는 것도 늦다.

틀린 코드는 다음과 같다.

return fee / PERCENT_BASE * rate;

// 곱셈이 먼저 int 범위에서 수행된다.
return (long) (fee * rate) / PERCENT_BASE;

고친 코드는 다음과 같다.

return (long) fee * rate / PERCENT_BASE;

99원 검사는 나눗셈 순서의 차이를, Integer.MAX_VALUE 검사는 곱셈 자료형의 차이를 드러내는 데 도움이 된다. 읽기 좋은 수식도 실행 결과가 유지될 때 채택할 수 있다.

검사할 기대값을 검사 대상의 표에서 가져온다

기대값을 실제 계산과 같은 표에서 만들면 표의 오타를 발견하기 어렵다. 재수강 단체 할인율을 실수로 12로 적었을 때 기대값도 함께 12%가 되기 때문이다.

틀린 검사는 다음과 같다.

int rate = DISCOUNT_RATES
        .get(StudentType.RETURNING)
        .get(CourseType.GROUP);
long expected = 10_000L * rate / PERCENT_BASE;

규칙에서 직접 정한 기대값을 사용한다.

long expected = 1_000L;
long actual = newDiscount(
        true, 10_000,
        StudentType.RETURNING, CourseType.GROUP);
check(actual == expected, "재수강 단체 할인액");

이전 구현과 비교하는 검사와 명시적인 기대값 검사는 서로 보완한다. 하나는 옮기는 과정의 차이를 찾고, 다른 하나는 기준으로 삼은 업무 결과를 확인한다.

한눈에 보기

조건문을 정리할 때 바꿀 표현과 유지할 동작
대상정리 방법유지 여부를 확인할 점
정상 경로를 감싼 중첩계산하지 않는 경우를 먼저 반환한다0과 음수의 경계, 반환값
값만 다른 반복 분기키와 값의 표로 모은다모든 키 조합과 할인율
동작이 다른 반복 분기공통 인터페이스의 구현으로 나눈다각 동작의 입력과 결과
작고 고정된 값 선택switch 식으로 표현한다열거형 경우의 누락
뜻이 숨겨진 계산 숫자역할을 나타내는 상수를 둔다곱셈 자료형과 나눗셈 순서
분기를 표로 옮긴 결과조합 비교와 기대값 검사를 실행한다비교 범위와 대표 경계값

조건문이 줄었다는 사실만으로 코드를 평가하지 않는다. 읽는 사람이 계산에 들어갈 조건, 규칙을 찾을 위치, 규칙이 빠졌을 때의 반응을 쉽게 확인할 수 있어야 한다. 이번 코드에서는 입구의 두 보호 구문과 여섯 칸의 표가 그 역할을 나누어 맡는다.

분기가 가진 의미를 먼저 드러내면 필요한 설명도 짧아진다. 다음 장에서는 이렇게 읽히는 코드에 어떤 주석을 남길지 살펴본다.

연습 문제

  1. 완성 코드의 수강료 배열에 101을 추가하라. 전후 비교 횟수를 구하고, 재수강생의 개인 강좌 할인액을 계산하라. 기존 다섯 수강료는 그대로 둔다.
  2. 표에서 지원 대상의 개인 강좌 항목을 지웠다고 가정하라. 어느 단계에서 문제가 드러나는지 설명하고, 해당 칸을 0으로 남겼을 때와 차이를 설명하라.
  3. 재수강생의 강좌별 할인율만 반환하는 returningRate 함수를 switch 식으로 작성하라. default를 넣지 않고 현재 두 강좌 유형을 모두 처리하라.
  4. 새 업무 규칙으로 할인액을 일정 금액으로 정하는 방식과 수강료의 비율로 정하는 방식이 함께 필요해졌다고 가정하라. 할인율 숫자만 저장한 현재 표로 충분한지 판단하고, 동작을 표현할 인터페이스의 메서드 하나를 작성하라. 완성 코드의 정책 자체는 변경하지 않는다.

정답과 해설

  1. 수강료가 여섯 개가 되므로 비교 횟수는 3 × 2 × 2 × 6인 72개다. 101원의 5%는 505를 100으로 나눈 5원이다. long 곱셈을 해도 정수 나눗셈에서 원 미만 부분을 버린다는 규칙은 유지된다.

    int[] fees = {-1, 0, 99, 101, 10_000, Integer.MAX_VALUE};
    
    check(newDiscount(
            true, 101,
            StudentType.RETURNING, CourseType.PERSONAL) == 5L,
            "101원 개인 강좌 할인액");
  2. DISCOUNT_RATES를 초기화하는 동안 createDiscountRates의 안쪽 반복문이 누락을 찾는다. main이 실행되기 전에 IllegalStateException이 발생하며, 클래스 초기화 과정에서 발생한 예외이므로 실행 화면에는 이를 원인으로 가진 ExceptionInInitializerError가 나타난다. 반면 칸에 0을 넣으면 구조 검사와 범위 검사를 통과한다. 그 오류는 기준 금액 기대값 검사와 이전 구현 비교에서 드러난다. 누락 검사와 값의 정확성 검사가 별도로 필요한 이유다.

  3. 두 열거형 상수를 직접 나열하고 선택 결과를 반환한다.

    static int returningRate(CourseType courseType) {
        return switch (courseType) {
            case GROUP -> 10;
            case PERSONAL -> 5;
        };
    }

    이 함수도 null이 아닌 강좌 유형을 전제로 한다. 강좌 유형을 추가한 뒤 다시 컴파일하면 switch 식에 빠진 경우를 확인할 수 있다. 값 선택이 작은 범위에 머물 때 읽기 쉬운 표현이다.

  4. 현재 표는 모든 할인이 같은 비율 계산을 한다는 전제다. 정액과 비율이 함께 있으면 숫자 하나만으로 그 숫자가 원인지 백분율인지 구별할 수 없다. 규칙 객체가 각 계산을 수행하도록 다음 인터페이스를 둘 수 있다.

    interface DiscountRule {
        long discount(int fee);
    }

    정액 규칙과 비율 규칙은 같은 메서드를 구현하고, 필요한 금액이나 할인율은 각 객체가 보관한다. 표가 여전히 필요하다면 값으로 Integer 대신 DiscountRule을 저장할 수 있다. 표는 어떤 규칙을 고를지 표현하고, 다형성은 선택한 규칙이 어떻게 계산할지 표현한다. 두 방식은 함께 사용할 수도 있다.

댓글 0

아직 댓글이 없습니다. 첫 댓글을 남겨 보세요.

댓글을 남기려면 로그인이 필요합니다.