티스토리 뷰
Enum 열거 타입은 일정 개수의 상수 값을 정의한 다음, 그 외의 값은 허용하지 않는 타입이다. 사계절, 태양계의 항성 등이 좋은 예이다.
자세한 문법에 대한 사용 등은 설명을 생략할 것이다. 궁금하신 분들은 다음 블로그를 참고하시기 바란다.
https://blog.naver.com/ilgolc/222474673407
자 그러면 열거 타입을 올바르게 사용할 수 있도록 다 같이 공부해보자
int 상수 대신 열거 타입을 사용하라
먼저 정수 열거 타입 패턴에 대해 알아볼 필요가 있다. 코드를 보자
public class IntegerEnumPattern {
public static final int APPLE_FUJI = 0;
public static final int APPLE_PIPPIN = 1;
public static final int APPLE_GRANNY_SMITH = 2;
public static final int ORANGE_NAVEL = 0;
public static final int ORANGE_TEMPLE = 1;
public static final int ORANGE_BLOOD = 2;
}
이 구조에는 단점이 여러 존재한다. 일단 타입 안전을 보장할 방법도 없을뿐더러 오렌지를 건네어야 할 상황에서 APPLE을 건네더라도 == 1인 경우에는 컴파일러는 아무런 경고를 보내지 않을 것이다.
// 향긋한 오렌지 향의 사과 소스!
int i = (APPLE_FUJI - ORANGE_TEMPLE) / APPLE_PIPPIN;
보다시피 논리적으로 이상한 코드임에도 전혀 문제가 되지 않는다. 결국 이건 평범한 상수 그 이상 이하도 아니기 때문이다.
그래서 자바에서는 열거 타입으로 이러한 점을 해결하였다.
public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }
자바 열거 타입은 타 언어보다 더 강력하다. 열거 타입 자체는 클래스이며, 상수 하나당 자신의 인스턴스를 만들어 public static final 필드로 공개한다. 또한 열거 타입은 밖에서 접근할 수 있는 생성자를 제공하지 않기 때문에 사실 상 final이다. 따라서 Enum 타입은 인스턴스가 통제되어 싱글톤으로 존재한다.
(개인적으로 가장 완벽한 싱글톤이라 생각한다. 생성자를 제공하지 않기에 reflection으로도 생성자에 접근이 불가능 하다.)
또 한 열거 타입은 컴파일 타입 타입 안정성을 제공한다. 각자의 이름 공간이 있어 이름이 같은 상수도 평화롭게 공존할 수 있다. 또한 상수를 새로 추가하거나 변경해도 공개되는 것이 오직 필드 이름이기 때문에 다시 컴파일할 필요가 없다. 또한 열거 타입은 임의의 메서드나 필드를 추가할 수도 있고, 임의의 인터페이스를 구현하게 할 수 도 있다. 그리고 마찬가지로 Serializable을 구현하여 직렬화 형태도 문제없이 동작하게 끔 구현되어있다.
그럼 이제 구조를 살펴보자. 예제 코드는 프로젝트에서 ErrorCode를 정의한 코드를 갖고 왔다.
@JsonFormat(shape = JsonFormat.Shape.OBJECT)
public enum ErrorCode {
// Member
EMAIL_DUPLICATION(400, "M001", "중복된 이메일 입니다."),
USER_NOT_FOUND(400, "M002", "다시 로그인 시도해 주세요"),
PASSWORD_NULL_ERROR(400, "M003", "올바른 비밀번호 입력이 아닙니다."),
PASSWORD_MISS_MATCH(400, "M004", "비밀번호가 일치하지 않습니다."),
MEMBER_NOT_FOUND(400, "M005", "해당 회원을 찾을 수 없습니다."),
// Token, Auth
Token_NOT_FOUND(401, "T001", "유효하지 않은 토큰입니다."),
UN_AUTHORIZATION_ERROR(401, "T002", "이미 탈퇴한 회원입니다."),
// Favorite
OVER_PERIOD_ERROR(400, "F001", "날짜 입력이 잘못 되었습니다."),
NOT_FOUND_FAVORITE(400, "F002", "해당 개체를 찾을 수 없습니다.");
private final String code;
private final String message;
private final int status;
ErrorCode(final int status, final String code, final String message) {
this.status = status;
this.code = code;
this.message = message;
}
public int status() {
return status;
}
public String message() {
return message;
}
public String code() {
return code;
}
}
열거 타입으로 값을 정의하는 것은 그렇게 어렵지 않다. 열거 타입 상수 각각을 특정 데이터와 연결 지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 된다.
이렇게 값을 정의하게 된다면 간단하게 외부로 필요한 데이터만 노출할 수 있게 된다. 이 열거 타입을 이용한 GlobalException 처리부를 살펴보자
@ExceptionHandler(Exception.class)
protected ResponseEntity<ErrorResponse> handleException(Exception e) {
log.error("handleEntityNotFoundException", e);
final ErrorResponse response = ErrorResponse.of(ErrorCode.INTERNAL_SERVER_ERROR);
return new ResponseEntity<>(response, HttpStatus.INTERNAL_SERVER_ERROR);
}
단순히 ErrorCode에 정의된 열거형 상수만 인자로 넘겨주면 내부에 데이터들이 자동으로 Response객체에 담겨 외부로 전송된다. 그렇기 때문에 매우 짧은 코드로 값과 내부의 데이터들을 다룰 수 있게 된다.
만약 여기서 상수 하나를 제거하더라도 클라이언트 입장에서는 단지 오류에 대해 스프링에서 제공하는 기본 페이지를 보는 불편함 외에는 아무런 영향이 없다. 제거된 상수를 참조하더라도 컴파일한다면 상수를 참조하는 줄에서 디버깅에 유용한 메시지를 담아 오류를 발생시킬 것이다. 이 역시 열거 타입에서만 볼 수 있는 바람직한 대응이라 생각한다.
상수들이 데이터와 연결되는 것뿐만 아니라 상수마다 동작이 달라져야 할 상황도 존재하는데 먼저 switch문을 이용한 방법을 살펴보자
package enumType;
public enum OperationWithSwitch {
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);
}
}
정상적으로 동작하지만 보기 좋지는 않다. throw문 까지 실제로 도달할 일은 없겠지만 기술적으로는 도달할 수 있기 때문에 생략 시 컴파일조차 할 수 없고 새로운 상수를 추가할 때마다 case문은 늘어날 것이다. 혹시라도 넣지 않는다면 알 수 없는 연산이라는 런타임 오류가 나며 프로그램이 종료된다.
하지만 열거 타입에는 상수 별로 다르게 동작하는 코드를 구현하는 더 좋은 수단이 있다. 상수별 메서드 구현이라 하는데 코드를 보자
public enum OperationWithMethod {
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 method도 정의를 해줘야 한다는 사실을 보여주고 있다. 그리고 추상 메서드이므로 정의하지 않았다면 컴파일 오류로 알려준다.
상수별 메서드 구현을 상수별 데이터와 결합할 수도 있다. 이게 무엇일까 코드로 알아보자
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);
}
자 이제 데이터와 결합하여 연산을 하기 위한 Enum 클래스를 완성하였다. 계산식이 얼마나 간단한지 실행시켜보자
public class OperationMain {
public static void main(String[] args) {
double x = 2.00;
double y = 4.00;
for (Operation op : Operation.values()) {
System.out.printf("%f %s %f = %f%n", x, op, y, op.apply(x, y));
}
}
}
정말 간단하게 모든 연산을 마칠 수 있다. 상수 이름을 입력받아 상수에 맞는 메서드가 자동 생성되고 실행된다.
한편, 상수별 메서드 구현에는 열거 타입 상수끼리 코드를 공유하기는 어렵다는 단점이 있다.
다음 예제 코드는 직원의(시간당) 기본임금과 그날 일한 시간(분 단위)이 주어지면 일당을 계산해주는 프로그램이다.
주중에 오버타임이 발생하면 잔업 수당이 주었고 주말에는 무조건 잔업수당이 주어진다.
public enum PayrollDay {
MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY;
private static final int MINUS_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 <= MINUS_PER_SHIFT ?
0 : (minutesWorked - MINUS_PER_SHIFT) * payRate / 2;
}
return basePay * overtimePay;
}
}
분명 간결하지만 관리 관점에서 위험한 코드이다. 코드에는 외의 잔업에 대해서는 전혀 고려하지 않고 있다. 공휴일이나 휴가기간에 발생할 수 있는 잔업에 대한 조건에 대해 추가해야 한다. 그리고 그때마다 매번 case를 추가하거나 조건을 고쳐야 한다.
이를 어떻게 해결할까?
전략 패턴을 사용하자
public enum PayrollDayWithStrategy {
MONDAY (WEEKDAY), TUESDAY (WEEKDAY),
WEDNESDAY (WEEKDAY), THURSDAY (WEEKDAY), FRIDAY (WEEKDAY), SATURDAY(WEEKEND), SUNDAY (WEEKEND);
private final PayType payType;
PayrollDayWithStrategy(PayType payType) {
this.payType = payType;
}
int pay (int minutesWorked, int payRate) {
return payType.pay(minutesWorked, payRate);
}
// 전략 열거 타입
enum PayType {
WEEKDAY {
@Override
int overtimePay(int minsWorked, int payRate) {
return minsWorked <= MINUS_PER_SHIFT ?
0 : (minsWorked - MINUS_PER_SHIFT) * payRate / 2;
}
},
WEEKEND {
@Override
int overtimePay(int minsWorked, int payRate) {
return minsWorked * payRate / 2;
}
};
abstract int overtimePay(int mins, int payRate);
private static final int MINUS_PER_SHIFT = 8 * 60;
int pay(int minutesWorked, int payRate) {
int basePay = minutesWorked * payRate;
return basePay * overtimePay(minutesWorked, payRate);
}
}
}
가장 깔끔한 방법이다. 새로운 상수를 추가할 때 잔업수당 '전략'을 선택하도록 하는 것이다. 잔업 수당 계산을 private 중첩 열거 타입으로 옮기고 PayrollDay 열거 타입의 생성자에서 이 중 적당한 것을 선택한다. 그러면 PayrollDay열거 타입은 잔업 수당 계산을 전략 열거 타입에 위임하여 상수 별 메서드 구현이 필요 없어진다.
그렇다면 Switch 문은 무조건 없는 게 좋을까라고 하면 그건 아니다. 기존 열거 타입에 상수 별 동작을 혼합해 넣을 경우 switch문이 좋을 수 있다. Operation이 있고 각 타입의 반대 연산을 반환하는 inverse라는 메서드를 구현해야 한다면 switch문이 적절한 선택이 될 수 있다.
정리
필요한 원소를 컴파일 타입에 다 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하자.
열거 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없다.
Reference.
EffectiveJava 3.Edition
'Java' 카테고리의 다른 글
<자바 고급스터디 5주차 - 1부> 자바는 어떻게 Lock을 걸까? (0) | 2022.03.08 |
---|---|
<자바 고급 스터디 3주차 - 4부> Enum 타입 2번째 (0) | 2022.02.26 |
<자바 고급 스터디 3주차 - 2부> Generic과 API 유연성 (0) | 2022.02.24 |
<자바 고급 스터디 3주차 - 1부> Generic을 사용하는 이유는 뭘까? (0) | 2022.02.21 |
Abstract와 Interface사이에서 발생할 수 있는 오해(?) (0) | 2022.02.16 |
- Total
- Today
- Yesterday
- 취업
- 백엔드
- 면접준비
- 개발
- 프로젝트
- swarm
- 인터뷰
- 동시성
- 취업준비
- java
- DB
- 게시판
- Spring
- thread
- CS
- Kotlin
- 면접 준비
- DevOps
- JPA
- Redis
- docker
- 코드
- 취준
- 자바
- 코딩
- IT
- 프로그래밍
- 면접
- MySQL
- 개발자
일 | 월 | 화 | 수 | 목 | 금 | 토 |
---|---|---|---|---|---|---|
1 | 2 | 3 | 4 | 5 | 6 | 7 |
8 | 9 | 10 | 11 | 12 | 13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 |
22 | 23 | 24 | 25 | 26 | 27 | 28 |
29 | 30 | 31 |