thenullpage.com
주문 상태를 문자열로 들고 다니면 어딘가에서 "SHIPPING"이 "shiping"으로 적히고 다른 파일에서는 "배송중"으로 적힌다. 숫자 상수로 둬도 0부터 2까지만 의미가 있는데 7이 들어오는 걸 막지 못한다. 값이 몇 개로 정해져 있다면 그 몇 개만 담는 타입을 만드는 게 낫다. 자바는 그걸 enum으로 만든다.
enum은 값이 정해진 타입이다. class 대신 enum이라고 적고 허용할 값을 쉼표로 나열한다.
public enum OrderStatus {
READY, SHIPPING, DONE
}
OrderStatus s = OrderStatus.READY;
System.out.println(s); // READY
System.out.println(s == OrderStatus.READY); // true
// OrderStatus.SHIPING;
// 컴파일 에러: cannot find symbol
여기 적힌 셋 말고는 이 타입에 들어갈 값이 없다. 오타는 실행 전에 잡힌다. 상수 하나당 객체도 프로그램 전체에 하나뿐이라 문자열과 달리 ==로 비교해도 안전하다.
enum도 클래스라 필드와 메서드를 가진다. 화면에 뿌릴 한글 이름을 상수마다 같이 들려 보내는 식이다.
public enum OrderStatus {
READY("결제 완료"),
SHIPPING("배송 중"),
DONE("배송 완료");
private final String label;
OrderStatus(String label) {
this.label = label;
}
public String getLabel() {
return label;
}
}
System.out.println(OrderStatus.SHIPPING.getLabel());
// 출력: 배송 중
상수 목록이 맨 위에 오고 세미콜론으로 끊은 뒤에 나머지를 적는다. 상수 옆 괄호에 넣은 값이 생성자로 넘어간다. 생성자는 적지 않아도 private이라 밖에서 new로 만들 수 없고, 그래서 개수가 늘어날 걱정이 없다.
values와 valueOf는 공짜로 딸려 온다. 전부 훑거나 문자열에서 되살릴 때 쓴다.
for (OrderStatus st : OrderStatus.values()) {
System.out.println(st.ordinal() + " " + st.name() + " " + st.getLabel());
}
// 출력:
// 0 READY 결제 완료
// 1 SHIPPING 배송 중
// 2 DONE 배송 완료
OrderStatus a = OrderStatus.valueOf("DONE");
OrderStatus b = OrderStatus.valueOf("done");
// Exception in thread "main" java.lang.IllegalArgumentException:
// No enum constant OrderStatus.done
valueOf는 대소문자까지 맞아야 하고 틀리면 예외를 던진다. ordinal은 선언 순서 번호인데 이 숫자를 DB에 저장하면 나중에 상수 순서를 한 번만 바꿔도 저장된 데이터의 의미가 어긋난다. 저장은 name이 주는 문자열로 한다.
switch에서는 상수 이름만 적는다. 타입이 이미 정해져 있어 앞에 enum 이름을 붙이면 오히려 에러다.
static String guide(OrderStatus s) {
return switch (s) {
case READY -> "상품을 준비하고 있다";
case SHIPPING -> "택배가 이동 중이다";
case DONE -> "수령 완료";
};
}
System.out.println(guide(OrderStatus.SHIPPING));
// 출력: 택배가 이동 중이다
화살표 switch는 값을 돌려주는 표현식이라 return 뒤에 바로 붙는다. 상수를 빠짐없이 덮으면 default 없이도 컴파일되고, CANCELED를 하나 추가하는 순간 이 switch가 에러로 바뀌어 고칠 자리를 알려준다. default를 습관적으로 적어 두면 그 알림을 못 받고 새 상태만 조용히 엉뚱한 문구로 나간다.
sealed는 상속할 자식을 명단으로 못 박는다. enum은 값 자체가 고정이지만, 경우마다 들고 있는 데이터가 다르면 값 목록으로는 표현이 안 된다. 그럴 때 permits로 구현할 수 있는 클래스를 열거한다.
public sealed interface Payment permits Card, Cash {}
public final class Card implements Payment {
final String number;
final int amount;
Card(String number, int amount) {
this.number = number;
this.amount = amount;
}
}
public final class Cash implements Payment {
final int amount;
Cash(int amount) { this.amount = amount; }
}
public class Voucher implements Payment {}
// 컴파일 에러: class is not allowed to extend sealed interface
명단에 없는 Voucher는 아예 낄 수 없다. 명단에 오른 자식은 final, sealed, non-sealed 중 하나를 반드시 붙여야 한다. final은 상속이 여기서 끝난다는 뜻이고 non-sealed는 다시 아무나 물려받게 문을 여는 것이다. 보통 인터페이스는 누가 구현하는지 알 수 없지만 sealed는 컴파일러가 전부를 안다.
그 명단 덕분에 switch가 빠진 곳을 잡아 준다. 값 대신 타입으로 갈라도 완전성 검사가 붙는다.
static int fee(Payment p) {
return switch (p) {
case Card c -> c.amount / 100;
case Cash c -> 0;
};
}
System.out.println(fee(new Card("1234", 50000))); // 500
System.out.println(fee(new Cash(50000))); // 0
case Card c는 p가 Card면 c라는 이름으로 꺼내 쓰겠다는 뜻이다. 형변환을 따로 적지 않아도 c.amount가 바로 읽힌다. 둘이 전부라 default도 필요 없다. Payment에 세 번째 수단을 추가하면 이 메서드가 컴파일되지 않아, 처리를 빠뜨린 채 넘어가는 일이 없다.
갈림길은 데이터 모양이다. 상태 이름, 요일, 등급처럼 값이 몇 개로 끝나고 상수마다 딸린 데이터 모양이 같으면 enum이다. 카드에는 카드번호가 있고 현금에는 없듯 경우마다 들고 있는 것이 다르면 sealed다. 종류가 고정이라 switch 빠뜨림을 컴파일러가 잡아 준다는 이득은 둘이 같다.
정리하면 셋이다. enum은 정해진 값만 담는 타입이라 오타가 실행 전에 걸린다는 것, 저장에는 ordinal 대신 name을 쓴다는 것, sealed는 자식 명단을 못 박아 switch가 놓친 경우를 알려준다는 것이다. OrderStatus를 직접 쳐 놓고 상수를 하나 더 넣어 보면 어느 switch가 멈추는지 그 자리에서 보인다.