본문 바로가기

강의/TDD, Clean Code with Java 12기

2단계 문자열 계산기 피드백 반영 (4일차)

enum에 대해 자세히 알아보자

TDD 강의를 듣기전 enum에 대한 개념을 알아놓으면 좋을 것 같다고 포비님께서 메일을 보내주셔서 enum에 대해 한번 살펴보았다.

https://daram.tistory.com/22?category=953984 

이때 살펴본 바로는 enum이 그냥 final 상수를 대체할 수 있는 역할이라는 느낌밖에 받지 못하였다.

 

TDD 다음 스텝으로 넘어가기 전 enum에 대해 한번 정리를 하고 가면 좋을 것 같다고 하여 다시 한번 더 살펴볼 예정이다.

 

멘토님께서 enum을 참고하라고 보내주신 링크다.

https://techblog.woowahan.com/2527/

https://github.com/bingbingpa/java-racingcar/tree/step2/src/main/java/calculator/calculator

우아한 블로그와 같은 기수에 계신 다른 분의 코드를 공유해주셨는데, 무엇을 의미하는 지 이해할 수가 없었다.

 

enum에 대해 좀 더 자세히 알기 위해서 블로그를 검색하였고, effective java에서 해당 내용이 존재한 다는 것을 파악 한 후 책을 바로 구입해서 읽어보았다. 

 

Effective Java 6장 - 아이템 34

 

데이터와 메서드를 갖는 enum 타입

 

예시를 통해 살펴보자.

package study;

public enum Planet {
    MERCURY (3.3, 2.4),
    VENUS (4.9, 6.0),
    EARTH (5.9, 6.3),
    MARS (6.4, 3.3),
    JUPITER (1.8, 7.1),
    SATURN (5.6, 6.0),
    URANUS (8.6, 2.5),
    NEPTUNE (1.0, 2.4);

    private final double mass;
    private final double radius;
    private final double surfaceGravity;

    private static final double G = 6.6;

    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
        surfaceGravity = G * mass / ( radius * radius );
    }

    public double mass() { return mass; }
    public double radius() { return radius; }
    public double getSurfaceGravity() { return surfaceGravity; }
    public double surfaceWeight(double mass) {
        return mass * surfaceGravity;
    }
}

enum type은 근본적으로 불편이라 모든 필드는 final이어야 한다.

열거 타입 상수 각각을 특정 데이터와 연결지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 된다.

   - 생성자에서 mass와 radius를 받고있는데, 이것이 열거형 데이터 0번째와 1번째의 이름을 결정짓는 요소가 된다.

   - MERCURY.mass = 3.3, MERCURY.radius = 2.4

 

enum type을 실행하는 부분을 살펴보자.

 

package study;

public class WeightTable {
    public static void main(String[] args) {
        double earthWeight = Double.parseDouble(args[0]);
        double mass = earthWeight / Planet.EARTH.getSurfaceGravity();
        for (Planet p : Planet.values()) {
            System.out.printf("%s에서의 무게는 %f이다.%n",
                    p, p.surfaceWeight(mass));
        }
    }
}

// argument : 40
// MERCURY에서의 무게는 3083.262712이다.
// VENUS에서의 무게는 732.508475이다.
// EARTH에서의 무게는 800.000000이다.
// MARS에서의 무게는 3162.795910이다.
// JUPITER에서의 무게는 192.165262이다.
// SATURN에서의 무게는 837.152542이다.
// URANUS에서의 무게는 7405.212203이다.
// NEPTUNE에서의 무게는 934.322034이다.

argument를 받고 있다.

argument에 사용자가 지정한 무게를 넣게 되면, 행성에 갔을때의 무게로 변환한 결과값을 얻을 수 있다.

위 예시는 인자로 40이라는 값을 넣었을 때 결과값이다.

 

약간 감이 잡히기 시작하는가?

다음 예시를 한번 더 살펴보자.

 

이번에 살펴볼 예시는 고유한 기능을 가진 상수를 만들어 볼 것이다.

예를들어 상수마다 동작이 달라져야 하는 경우가 존재할 것이다.

무슨 말인지는 아래 예시를 보면서 더욱 자세히 살펴보자

 

계산기에 대한 예시다.

사칙연산 계산기의 연산 종류를 열거 타입으로 선언하고, 실제 연산까지 열거타입 상수가 직접 수행하는 것을 살펴보자.

 

public enum Operation {
    PLUS, MINUS, TIMES, DIVIDE;

    // 상수가 뜻하는 연산을 수행한다.
    public double apply (double x, double y) {
        switch(this) {
            case PLUS: return x+y;
            case MINUS: return x-y;
            case TIMES: return x*y;
            case DIVIDE: return x/y;
        }
        throw new AssertionError("알 수 없는 연산: " + this);
    }
}

문제없이 동작을 한다.

기능상에 문제는 없지만 문제가 발생하기 쉬운 코드이다.

열거형 타입이 새로 추가되었을 때 case문도 새로 추가를 해줘야 하지만, 혹시라도 깜빡하게 되면 "알 수 없는 연산" 이라는 에러를 내면서 죽게된다.

 

이를 보완하는 더 나은 수단이 존재한다.

이것이 바로 상수별 메서드 구현이라고 한다.

 

상수별 메서드 구현

예제를 통해 알아보자

public enum Operation {
    PLUS {public double apply(double x, double y) {return x + y;}},
    MINUS {public double apply(double x, double y) {return x - y;}},
    TIMES {public double apply(double x, double y) {return x * y;}},
    DIVIDE {public double apply(double x, double y) {return x * y;}};
    
    public abstract double apply(double x, double y);
    }
}

apply 메서드가 상수 선언 바로 옆에 붙어 있으니 새로운 상수를 추가할 때 apply도 재정의해야 한다는 사실을 깜빡하기 어려울 것이다.

그뿐만 아니라 apply가 추상 메서드이므로 재정의하지 않았다면 컴파일 오류로 알려준다.

 

package study;

public enum Operation {
    PLUS("+") { public double apply(double x, double y) { return x + y; }},
    MINUS("-") { public double apply(double x, double y) { return x - y; }},
    TIMES("*") { public double apply(double x, double y) { return x * y; }},
    DIVIDE("/") { public double apply(double x, double y) { return x * y; }};

    private final String symbol;

    Operation(String symbol) { this.symbol = symbol;}

    @Override
    public String toString() { return symbol;}

    public abstract double apply(double x, double y);
}

열거형 타입에 symbol을 달아주고 toString을 재정의 해 주었다.

이제 실행부분을 살펴보자

 

public class Test {
    public static void main(String[] args) {
        double x = Double.parseDouble(args[0]);
        double y = Double.parseDouble(args[1]);
        for (Operation op : Operation.values())
            System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
    }
}
// 2.000000 + 4.000000 = 6.000000
// 2.000000 - 4.000000 = -2.000000
// 2.000000 * 4.000000 = 8.000000
// 2.000000 / 4.000000 = 8.000000

 

이번에는 다른 예제를 살펴보도록 하자.

일당을 계산해주는 예제이다.

주중에 overtime이 발생하면 잔업수당이 주어지고

주말에는 무조건 잔업수당이 주어지는 예시다.

 

public enum payrollDay {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY,
    SATURDAY, SUNDAY;

    private static final int MINS_PER_SHIFT = 8 * 60;

    int pay(int minutesWorked, int payRate) {
        int basePay = minutesWorked * payRate;

        int overtimePay;
        switch (this) {
            case SATURDAY: case SUNDAY:
                overtimePay = basePay / 2;
                break;
            default:
                overtimePay = minutesWorked <= MINS_PER_SHIFT ?
                        0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
        }
        return basePay + overtimePay;
    }
}

이 예시는 관리 관점에서 위험한 코드이다.

휴가와 같은 새로운 값이 생성되었다고 상상해보자.

그런데 갑작스러운 일로 인하여 휴가때 일을 하게 되었는데, 새로운 case문을 추가해주지 않았다면 평일과 똑같은 임금을 받게 된다.

 

이보다 더 깔끔한 방법은 상수를 추가할 때 잔업수당 '전략'을 선택하도록 하는 것이다.

import static Paytype.*;

public enum payrollDay2 {
    MONDAY(WEEKDAY), TUESDAY(WEEKDAY), WEDNESDAY(WEEKDAY), THURSDAY(WEEKDAY), FRIDAY(WEEKDAY),
    SATURDAY(WEEKEND), SUNDAY(WEEKEND);

    private final Paytype paytype;

    payrollDay2(Paytype paytype) {
        this.paytype = paytype;
    }

    int pay(int minutesWorked, int payRate) {
        return paytype.pay(minutesWorked, payRate);
    }
}

enum Paytype{
    WEEKDAY {
        int overtimePay(int minsWorked, int payRate) {
            return minsWorked <= MINS_PER_SHIFT ? 0 :
                    (minsWorked - MINS_PER_SHIFT) * payRate / 2;
        }
    },
    WEEKEND {
        int overtimePay(int minsWorked, int payRate) {
            return minsWorked * payRate / 2;
        }
    };

    abstract int overtimePay(int mins, int payRate);
    private static final int MINS_PER_SHIFT = 8 * 60;

    int pay(int minsWorked, int payRate) {
        int basePay = minsWorked * payRate;
        return basePay + overtimePay(minsWorked, payRate);
    }
}

 

main 함수를 만들어서 실행해보자

package study;

import java.util.Scanner;

public class payrollDayMain {
    public static void main(String[] args) {
        Scanner sc = new Scanner(System.in);
        String day = sc.next();
        
        payrollDay2[] values = payrollDay2.values();
        for (payrollDay2 pd :payrollDay2.values()) {
            int payment = pd.pay(420, 1000);
            System.out.printf("%s 임금은 %s%n",pd, payment);
        }
    }

}

// minuetesWorked == 420
// MONDAY 임금은 420000
// TUESDAY 임금은 420000
// WEDNESDAY 임금은 420000
// THURSDAY 임금은 420000
// FRIDAY 임금은 420000
// SATURDAY 임금은 630000
// SUNDAY 임금은 630000

// minutesWorked == 540
// MONDAY 임금은 570000
// TUESDAY 임금은 570000
// WEDNESDAY 임금은 570000
// THURSDAY 임금은 570000
// FRIDAY 임금은 570000
// SATURDAY 임금은 810000
// SUNDAY 임금은 810000

payRate 은 분당 임금을 말하는 것 같다.

임금의 반을 야근수당으로 주고 있습니다.

위의 예제가 어떻게 돌아가는지 이해가 가는가요?

 

직접 코드를 작성해보면 이해가 더 빠를 것 같습니다.