[Java] Enum을 활용적으로 쓰는 법
Java에서 Enum을 활용적으로 !
Enum을 통해 얻는 기본적인 장점들은 아래와 같다.
1. 문자열과 비교해, IDE의 적극적인 지원을 받을 수 있다.
- 자동완성, 오타검증, 텍스트 리팩토링
2. 허용 가능한 값들을 제한할 수 있다.
3. 리팩토링 시, 변경 범위가 최소화된다.
- 내용의 추가가 필요하더라도, Enum 코드 외에 수정할 필요가 없다.
이 장점들은 모든 언어들의 Enum에서 얻을 수 있는 공통적인 장점이다.
하지만, Java의 Enum은 이보다 더 많은 장점을 가지고 있다.
Java의 Enum은 완전한 기능을 가진 클래스이기 때문이다. (C/C++의 경우 Enum이 결국 int 값이다.)
1. 데이터들간의 연관관계 표현
origin 테이블에 있는 내용을 2개의 테이블 (테이블명: table1, table2)에 등록하는 기능이 있다고 하자.
origin 테이블의 값은 "Y", "N"으로 저장되는데 table1, table2는 각각 "1", "0" / true, false 형태로 저장된다고 한다.
그럼 이를 분류하는 메서드를 다음과 같이 만들 것이다.
public class LegacyCase {
public String toTable1Value(String originValue) {
if ("Y".equals(originValue)) {
return "1";
} else {
return "0";
}
}
public boolean toTable2Value(String originValue) {
if ("Y".equals(originValue)) {
return true;
} else {
return false;
}
}
}
기능상의 문제는 없지만, 문제가 있다.
1. "Y", "1", true가 모두 같은 의미라는 것을 알 수 없다.
"Y"란 값이 "1"이 될 수도 있고 true가 될 수도 있다는 것을 확인하려면 항상 위에서 선언된 클래스와 메서드를 찾아야 한다.
2. 불필요한 코드량이 많다.
- "Y", "N"이외에 "R", "S" 등의 추가 값이 필요한 경우, if문을 포함하여 메서드 단위로 코드가 증가하게 된다.
- 동일한 타입의 값이 추가되는 것에 비해 너무 많은 반복성 코드가 발생하게 된다.
그래서 이 부분을 Enum으로 추출해보자.
public enum TableStatus {
Y("1", true);
N("0", false);
private String table1Value;
private boolean table2Value;
TableStatus(String table1Value, boolean table2Value) {
this.table1Value = table1Value;
this.table2Value = table2Value;
}
public String getTable1Value() {
return table1Value;
}
public boolean getTable2Value() {
return table2Value;
}
}
"Y", "1", true 가 한 묶음으로, "N", "0", false가 한 묶음이 된 것을 코드로 확인할 수 있다.
또한 추가 타입이 필요한 경우에도, Enum 상수와 get 메서만 추가하면 된다.
(lombok의 @Getter 를 사용하여 Enum의 get 메서드를 대신할 수 있다.)
@Test
public void test() throws Exception {
// given
TableStatus origin = selectFromOriginTable();
String table1Value = origin.getTable1Value();
String table2Value = origin.getTable2Value();
assertThat(origin, is(TableStatus.Y));
assertThat(table1Value, is("1"));
assertThat(table2Value, is(true));
}
2. 상태와 행위를 한 곳에서 관리
서로 다른 계산식을 적용해야할 때가 있다.
DB에 저장된 code의 값이 "CALC_A"일 경우엔 값 그대로, "CALC_B"일 경우에 10을 곱해서, "CALC_C"인 경우에는 3을 곱해서 전달해야 한다.
가장 쉬운 해결방법은 아래와 같이 static 메서드를 작성해서 필요한 곳에 호출하는 방식이다.
public class LegacyCalculator {
public static long calculate(String code, long originValue) {
if ("CALC_A".equals(code)) {
return originValue;
} else if ("CALC_B".equals(code)) {
return originValue * 10;
} else if ("CALC_B".equals(code)) {
return originValue * 3;
} else {
return 0;
}
}
}
이렇게 메서드를 분리하고 사용하다 보면, 코드는 코드대로 조회하고, 계산은 별도의 클래스와 메서드에서 진행하게 된다.
@Test
public void 코드에_따른_서로다른_계산_legacy() throws Exception {
String code = selectCode();
long originValue = 10000L;
long result = LegacyCalculator.calculator(code, originValue);
assertThat(result, is(10000L));
}
이 상황에 문제가 있다.
LegacyCalculator의 메서드와 code가 서로 관계가 있음을 표현할 수 없기 때문이다.
이런 경우,
1. 똑같은 기능을 하는 메서드를 중복 생성할 수 있다.
- 히스토리 관리가 되지 않은 상태에서 계산 메서드를 중복 생성할 수 있다.
- 관리 포인트가 증가할 확률이 매우 높다.
2. 계산 메서드를 누락할 수 있다.
- 결국 문자열과 메서드로 분리되어 있어, 이 계산 메서드를 써야함을 알 수 없어 새로운 기능 생성 시 계산 메서드 호출이 누락될 수 있다.
"DB에서 뽑은 특정 값은 지정된 메서드와 관계가 있다."
역할과 책임이라는 관점에서 봤을 때, 위 메시지는 Code에 책임이 있다.
public class CalculatorType {
CALC_A(value -> value),
CALC_B(value -> value * 10),
CALC_C(value -> value * 3),
CALC_ETC(value -> 0L);
private Function<Long, Long> expression;
CalculatorType(Function<Long, Long> expression) {
this.expression = expression;
}
public long calculate(long value) {
return expression.apply(value);
}
}
Code가 본인 만의 계산식을 가지도록 지정하였다. (Java8이 업데이트되면서, 인자값으로 함수를 사용할 수 있게 되었다.)
Entity 클래스에서 선언할 경우에 String이 아니라 enum을 선언하면 된다.
@Column
@Enumerated(EnumType.STRING)
private CalculatorType calculatorType;
실제로 사용하는 곳에서도 직접 Code에게 계산을 요청하자.
@Test
public void 코드에_따라_서로다른_계산_enum() throws Exception {
CalculatorType code = selectCode();
long originValue = 10000L;
long result = code.calculate(originValue);
assertThat(result, is(10000L));
}
3. 데이터 그룹관리
결제라는 데이터는 '결제 종류'와 '결제 수단'이라는 2가지 형태로 표현된다.
예를 들어 신용카드 결제는 '신용카드'라는 결제 수단과 '카드'라는 결제 종류가 있다.
결제된 건이 어떤 결제 수단으로 진행되며, 결제 방식이 어떤 결제 종류에 속하는지 확인해야한다고 하자.
이를 해결하는 가장 쉬운 방법은 if문이다.
public class LegacyPayGroup {
public static String getPayGroup(String payCode) {
if ("ACCOUNT_TRANSFER".equals(payCode) || "PERMITTANCE".equals(payCode) || "ON_SITE_PAYMENT".equals(payCode) || "TOSS".equals(payCode)) {
return "CASH";
} else if ("PAYCO".equals(payCode) || "CARD".equals(payCode) || "KAKAO_PAY".equals(payCode) || "BAEMIN_PAY".equals(payCode)) {
return "CARD";
} else if ("POINT".equals(payCode) || "COUPON".equals(payCode)) {
return "ETC";
} else {
return "EMPTY";
}
}
}
여기서도 여러 문제가 있다.
1. 둘의 관계를 파악하기 힘들다.
- 위 메서드는 포함관계를 나타내는 것일까. 아니면 단순한 대체값을 리턴한 것일까?
- 현재는 결제 종류가 결제 수단을 포함하고 있는 관계인데, 메서드만으로 표현이 불가능하다.
2. 입력값과 결과값이 예측 불가능하다.
- 결제 수단의 범위를 지정할 수 있어서 문자열이면 전부 파라미터로 전달될 수 있다.
- 마찬가지로 결과를 받는 쪽에서도 문자열을 받기 때문에 결제 종류로 지정된 값만 받을 수 있도록 검증코드가 필요하게 된다.
3. 그룹별 기능을 추가하기가 어렵다.
- 또 다시 결제 종류에 따른 if문으로 메서드를 만들어야 하나?
각각의 메서드는 원하는 때에 사용하기 위해 독립적으로 구성할 수 밖에 없는데
그럴 때마다 결제 종류를 분기하는 코드가 필수적으로 필요하다.
각 타입은 본인이 수행해야할 기능과 책임과 가질 수 있게 하기 위해 enum 타입으로 변경하자.
@Getter
public enum PayGroup {
CASH("현금", Arrays.asList("ACCOUNT_TRANSFER", "REMITTANCE", "ON_SITE_PAYMENT", "TOSS")),
CARD("카드", Arrays.asList("PAYCO", "CARD", "KAKAO_PAY", "BAEMIN_PAY")),
ETC("기타", Arrays.asList("POINT", "COUPON")),
EMPTY("없음", Collections.EMPTY_LIST);
private String title;
private List<String> payList;
PayGroup(String title, List<String> payList) {
this.title = title;
this.payList = payList;
}
public static PayGroup findByPayCode(String code) {
return Arrays.stream(PayGroup.values())
.filter(payGroup -> payGroup.hasPayCode(code))
.findAny()
.orElse(EMPTY);
}
public boolean hasPayCode(String code) {
return payList.stream()
.anyMatch(pay -> pay.equals(code));
}
}
Java의 Enum은 결국 클래스이기 때문에 Enum 상수에 결제 종류 문자열 리스트를 가지도록 했다.
@Test
public void PayGroup에게_직접_결제종류_물어보기_문자열() throws Exception {
String payCode = selectPayCode();
PayGroup payGroup = PayGroup.findByPayCode(payCode);
assertThat(payGroup.name(), is("BAEMIN_PAY"));
assertThat(payGroup.getTitle(), is("배민페이"));
}
하지만, 해결되지 않은 것이 있다.
결제수단은 문자열로 되어있다.
DB 테이블의 결제수단 컬럼에 잘못된 값을 등록하거나, 파라미터로 전달된 값이 잘못되었을 경우가 있을 때 관리가 되지않을 위험이 있다.
public enum PayType {
ACCOUNT_TRANSFER("계좌이체"),
REMITTANCE("무통장입금"),
ON_SITE_PAYMENT("현장결제"),
TOSS("토스"),
PAYCO("페이코"),
CARD("신용카드"),
KAKAO_PAY("카카오페이"),
BAEMIN_PAY("배민페이"),
POINT("포인트"),
COUPON("쿠폰"),
EMPTY("없음");
private String title;
PayType(String title) {
this.title = title;
}
public String getTitle() {
return title;
}
}
이렇게 Enum으로 결제종류를 만들고 PayGroup에서 사용해보자.
@Getter
public enum PayGroupAdvanced {
CASH("현금", Arrays.asList(PayType.ACCOUNT_TRANSFER, PayType.REMITTANCE, PayType.ON_SITE_PAYMENT, PayType.TOSS)),
CARD("카드", Arrays.asList(PayType.PAYCO, PayType.CARD, PayType.KAKAO_PAY, PayType.BAEMIN_PAY)),
ETC("기타", Arrays.asList(PayType.POINT, PayType.COUPON)),
EMPTY("없음", Collections.EMPTY_LIST);
private String title;
private List<String> payList;
PayGroup(String title, List<String> payList) {
this.title = title;
this.payList = payList;
}
public static PayGroup findByPayType(PayType payType) {
return Arrays.stream(PayGroupAdvanced.values())
.filter(payGroup -> payGroup.hasPayCode(payType))
.findAny()
.orElse(EMPTY);
}
public boolean hasPayCode(PayType payType) {
return payList.stream()
.anyMatch(pay -> pay == payType));
}
}
@Test
public void PayGroup에게_직접_결제종류_물어보기_enum() throws Exception {
PayType payType = selectPayType();
PayGroupAdvanced payGroup = PayGroupAdvanced.findByPayType(payType);
assertThat(payGroup.name(), is("BAEMIN_PAY"));
assertThat(payGroup.getTitle(), is("배민페이"));
}
PayType으로 데이터를 받아 타입 안전성까지 확보하여 관련 처리를 진행할 수 있다.
4. 관리 주체를 DB에서 객체로
DB에서 코드를 관리할 경우 문제가 발생할 수 있다.
1. 코드명만 봐서는 무엇을 나타내는지 알 수 없다.
- 문서화가 되어있다 하더라도, 문서 업데이트가 잘 되어있는지 확신할 수 없어 DB를 다시 찾아봐야 하는 번거로움이 있다.
2. 항상 코드 테이블 조회 쿼리가 실행되어야 했다.
- 화면에 표시하기 위해 코드 테이블을 조회해야 한다.
Enum을 바로 JSON으로 리턴하게 되면, 상수 name만 출력된다.
Enum의 name과 title 모두 필요한 경우가 많다.
클래스의 생성자로 일관된 타입을 받기 위해 인터페이스를 하나 생성하자.
public interface EnumMapperType {
String getCode();
String getTitle();
}
@Getter
@ToString
public class EnumMapperValue {
private String code;
private String title;
public EnumMapperValue(EnumMapperTupe enumMapperType) {
code = enumMapperType.getCode();
title = enumMapperType.getTitle();
}
}
Enum은 미리 선언한 인터페이스를 구현하면 된다.
public enum FeeType implements EnumMapperType {
PERCENT("정율"),
MONEY("정액");
private String title;
FeeType(String title) {
this.title = title;
}
@Override
public String getCode() {
return name();
}
@Override
public String getTitle() {
return title;
}
}
이제 Enum을 Value 클래스로 변경한 후, 전달하자.
@GetMapping("/no-bean-categories")
public List<EnumMapperValue> getNoBeanCategory() {
return Arrays.stream(FeeType.values())
.map(EnumMapperValue::new)
.collect(Collectors.toList());
}
/*
[
{
"code": "PERCENT",
"title": "정율"
},
{
"code": "MONEY",
"title": "정액"
}
]
*/
출처
https://techblog.woowahan.com/2527/
'programming language > Java' 카테고리의 다른 글
[Java] Optional를 잘 사용하는 법 2 (어떻게 사용할까) (2) | 2023.02.03 |
---|---|
[Java] Optional를 잘 사용하는 법 1 (NPE, if문으로 null 체크를 하지 말자) (2) | 2023.01.27 |
[Java] setter를 지양하라 > HOW? (0) | 2022.10.28 |
[Java] Meta Annotation @Target, @Retention (0) | 2022.07.13 |
[Java] ModelMapper 라이브러리 (0) | 2022.06.02 |
댓글 개