정해진 몇 가지 중 하나를 표현할 일이 잦다. 요일은 일곱 중 하나, 주문 상태는 접수, 배송, 완료, 취소 중 하나다. 이런 값을 문자열 "배송중"으로 다루면 누군가 오타를 내도 컴파일러가 못 잡고, 숫자 0, 1, 2로 다루면 그 숫자가 뭘 뜻하는지 알 수 없다. 정해진 값들의 집합을 안전하게 표현하는 도구가 열거형, 곧 enum이다.
enum은 이름 붙은 상수들의 타입이다. 가능한 값들을 미리 나열해 하나의 타입으로 만든다. 아래처럼 정의하면 Status 타입의 값은 나열된 넷뿐이라, 오타나 엉뚱한 값이 컴파일 단계에서 막힌다. 각 값은 Status.SHIPPING처럼 타입 이름과 함께 부른다.
public enum Status {
RECEIVED, SHIPPING, DELIVERED, CANCELED
}
Status s = Status.SHIPPING;
enum은 기본 메서드를 갖추고 있다. values()는 모든 값을 배열로 돌려주어 전부 훑을 때 쓰고, valueOf("SHIPPING")은 문자열을 그에 맞는 enum 값으로 바꾼다. name()은 이름을, ordinal()은 나열된 순서 번호를 준다. 다만 ordinal()의 숫자에 의존한 로직은 피해야 한다. 나중에 순서를 바꾸면 그 숫자가 달라져 조용히 깨지기 때문이다.
for (Status st : Status.values()) System.out.println(st.name());
Status parsed = Status.valueOf("DELIVERED");
enum은 switch와 잘 어울린다. 특히 표현식형 switch와 쓰면 모든 값을 다뤘는지 컴파일러가 검사한다. 새 상태를 enum에 추가했는데 switch에서 그 경우를 빠뜨리면 컴파일러가 알려 준다. 놓친 경우를 사람이 아니라 컴파일러가 잡는 것이 이 조합의 큰 장점이다.
String label = switch (s) {
case RECEIVED -> "접수됨";
case SHIPPING -> "배송중";
case DELIVERED -> "배송완료";
case CANCELED -> "취소됨";
};
enum은 데이터를 품을 수 있다. 자바의 enum은 각 값에 필드와 생성자를 붙여 데이터를 담을 수 있어 다른 언어의 열거형보다 강력하다. 아래에서 각 요일 값이 한글 이름과 주말 여부를 함께 지닌다. 생성자는 enum 안에서만 불리며, 각 상수를 선언할 때 괄호로 값을 넘긴다.
public enum Day {
MON("월", false), SAT("토", true), SUN("일", true);
private final String korean;
private final boolean weekend;
Day(String korean, boolean weekend) {
this.korean = korean; this.weekend = weekend;
}
public boolean isWeekend() { return weekend; }
}
상수마다 다른 동작을 정의할 수도 있다. 각 enum 값이 저마다 다른 메서드 구현을 가질 수 있다. PLUS는 더하기, MINUS는 빼기를 하도록 값마다 계산법을 다르게 두는 식이다. switch로 종류를 나누지 않고도 각 상수가 알아서 제 동작을 한다. 다형성이 enum 안에서 작동하는 셈이라, 조건 분기가 흩어지는 걸 막는다.
public enum Op {
PLUS { public int apply(int a, int b) { return a + b; } },
MINUS { public int apply(int a, int b) { return a - b; } };
public abstract int apply(int a, int b);
}
enum은 타입 안전한 싱글턴이기도 하다. 각 enum 값은 프로그램 전체에 딱 하나만 존재한다. Status.SHIPPING은 어디서 부르든 같은 객체라, 값끼리는 equals 대신 ==로 비교해도 안전하다. 이 성질 덕분에 인스턴스가 하나뿐이어야 하는 싱글턴을 만드는 안전한 방법으로도 쓰인다.
enum 전용 컬렉션도 있다. enum을 키로 쓰는 Map은 EnumMap, enum들의 집합은 EnumSet을 쓴다. 이들은 순서 번호를 활용해 일반 HashMap이나 HashSet보다 빠르고 메모리도 알뜰하다. enum을 다루는 자리라면 떠올려 볼 만하다.
sealed는 물려받을 수 있는 자식을 제한한다. JDK 17부터 정식이 된 sealed는 클래스나 인터페이스를 상속하거나 구현할 수 있는 타입을 못 박는다. permits 뒤에 허용된 자식만 나열하고, 그 밖의 타입은 이 계층에 낄 수 없다. 누구에게나 열려 통제가 어렵던 상속의 문을 정해진 몇에게만 여는 것이다.
public sealed interface Shape
permits Circle, Rectangle {}
public record Circle(double r) implements Shape {}
public record Rectangle(double w, double h) implements Shape {}
sealed는 record, switch와 만나 완전성을 이룬다. Shape의 자식이 Circle과 Rectangle 둘뿐임이 확정돼 있으니, switch로 다루면 컴파일러가 모든 경우를 다뤘다고 확신해 default 없이도 컴파일된다. 나중에 Triangle을 permits에 추가하면 그 경우를 안 다룬 모든 switch가 에러를 낸다. 놓친 곳을 컴파일러가 전부 짚어 주는 것이 이 조합의 힘이다.
double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.r() * c.r();
case Rectangle rec -> rec.w() * rec.h();
};
}
enum과 sealed는 쓰임이 갈린다. enum은 미리 정해진 고정된 값들의 집합이라, 요일이나 신호처럼 값 자체가 유한하고 각각이 하나뿐일 때 쓴다. sealed는 정해진 타입들의 집합이라, 각 타입이 저마다 다른 데이터를 담는다. 원과 사각형은 종류는 정해졌지만 원마다 반지름이 다르고 얼마든지 만들 수 있다. 값이 유한하면 enum, 종류는 유한하되 각 종류의 값이 무한하면 sealed가 맞다.
sealed의 자식은 자신의 개방 정도를 정해야 한다. 자식은 더는 못 물려받게 final로 닫거나, 지정된 자식에게만 열도록 다시 sealed로 두거나, 아무나 물려받게 non-sealed로 열거나 셋 중 하나를 골라야 한다. 자식으로 쓴 record는 자동으로 final이라 이 고민이 없다. 자식들은 같은 모듈 안에 있어야 컴파일러가 계층이 닫혔음을 확신할 수 있다.
다음 편에서는 제네릭을 다룬다. 지금까지 List<String>이나 Map<String, Integer>의 꺾쇠를 별 설명 없이 써 왔는데, 다음 편에서 그 정체를 파고든다. 타입을 값처럼 넘기는 제네릭, 상한과 하한을 정하는 와일드카드, 자바 특유의 타입 소거까지 코드로 살핀다.