열거형 (enum)
들어가는 말
enum은 enumeration의 약자로, ’열거’라는 뜻이다.
프로그래밍에서는 서로 관련 있는 상수들을 하나의 타입으로 묶어 표현할 때 enum을 사용한다.
프로그램을 만들다 보면 값의 종류가 미리 정해져 있는 경우가 자주 있다.
- 요일:
MONDAY,TUESDAY,WEDNESDAY, … - 계절:
SPRING,SUMMER,AUTUMN,WINTER - 주문 상태:
PENDING,PAID,SHIPPED,CANCELED
이런 값들은 아무 값이나 들어와도 되는 값이 아니다.
정해진 후보 값만 사용할 수 있어야 한다.
enum은 이렇게 가능한 값을 미리 나열해 둔 자료형이다.
값의 집합을 코드로 명확히 표현하고, 그 외의 값이 들어오는 실수를 컴파일 단계에서 막기 위해 사용한다.
다만 enum은 언어마다 구현 방식에 차이가 있다.
C에서는 단순한 정수 값처럼 느슨하게 다뤄지고, Java에서는 하나의 타입처럼 더 엄격하게 다뤄진다.
이 글에서는 Java의 enum을 알아본다.
enum이 필요한 이유
enum이 왜 필요한지 알아보기 위해, enum이 없을 때 어떤 문제가 생기는지 살펴보자.
회원 등급을 int로 표현했을 때의 문제
회원 등급과 상품 금액을 입력받아 할인 금액을 반환하는 함수가 있다고 하자.
int calculateDiscountAmount(int grade, int price);
각 정수 값이 어떤 등급을 의미하는지 다음과 같이 정했다고 하자.
BRONZE: 0SILVER: 1GOLD: 2DIAMOND: 3
그럼 호출자 코드를 보자.
int discountAmount = calculateDiscountAmount(1, 10_000);
이 코드는 한눈에 의미가 드러나지 않는다.
회원 등급이 정수형이면, 1이 ’1등급(최상위 등급)’을 의미하는 것인지, 아니면 ’0부터 시작하는 값에서 두 번째로 낮은 등급’인지, ’1부터 시작하는 값에서 가장 낮은 등급’인지 헷갈리기 쉽다.
결국 1이 무엇인지 알기 위해서는 별도의 규칙을 확인해야 한다.
예를 들어 다음과 같은 호출도 문법적으로 가능하다.
calculateDiscountAmount(10, 10_000); // 존재하지 않는 등급
10은 회원 등급으로 정의되지 않은 값이다.
하지만 매개변수 타입이 int이기 때문에 컴파일러는 이 호출을 막지 못한다.
즉, int는 “정해진 회원 등급 중 하나”라는 의미를 표현하기에 부족하다.
회원 등급을 String으로 표현했을 때의 문제
정수 값이 헷갈리니 문자열을 사용해 볼 수도 있다.
int calculateDiscountAmount(String grade, int price);
이제 호출 코드는 조금 더 읽기 쉬워진다.
int discountAmount = calculateDiscountAmount("DIAMOND", 10_000);
하지만 문자열 역시 안전하지 않다.
calculateDiscountAmount("DIAMON", 10_000); // DIAMOND에서 D가 빠진 오타
calculateDiscountAmount("Gold", 10_000); // 대소문자 실수
calculateDiscountAmount("gold", 10_000); // 대소문자 실수
문자열은 오타에 취약하다.
회원 등급으로는 잘못된 값이어도 문법적으로는 올바른 문자열이기 때문에 컴파일러가 막지 못한다.
오타를 막기 위해 문자열을 직접 쓰지 않고 상수로 관리할 수 있다.
public final class Grade {
public static final String BRONZE = "BRONZE";
public static final String SILVER = "SILVER";
public static final String GOLD = "GOLD";
public static final String DIAMOND = "DIAMOND";
private Grade() {}
}
final class로 선언하면 다른 클래스가 Grade를 상속할 수 없다.
또한 생성자를 private으로 막아 두면 new Grade()처럼 불필요한 객체를 만들 수 없다.
public static final로 선언한 값들은 클래스 이름으로 바로 접근할 수 있는 상수가 된다.
이제 호출 코드는 다음처럼 바꿀 수 있다.
int discountAmount = calculateDiscountAmount(Grade.DIAMOND, 10_000);
문자열을 직접 입력하지 않으므로 오타 가능성이 줄어든다.
calculateDiscountAmount(Grade.DIAMOND, 10_000);
// calculateDiscountAmount(Grade.DIAMON, 10_000); // 컴파일 에러: DIAMOND에서 D가 빠져 존재하지 않는 상수
Grade.DIAMON이라는 상수는 존재하지 않기 때문에 IDE에서 먼저 경고가 표시되고, 컴파일 단계에서도 오류로 잡힌다.
하지만 이 방식도 완전하지는 않다.
calculateDiscountAmount("VIP", 10_000); // 존재하지 않는 등급
함수의 매개변수 타입은 여전히 String이다.
따라서 호출자가 상수를 사용하지 않고 임의의 문자열을 매개변수로 넘겨도 컴파일 단계에서 막을 수 없다.
결국 String도 “정해진 회원 등급 중 하나여야 한다”는 규칙을 타입으로 표현하지 못한다.
enum으로 회원 등급 표현하기
앞에서 본 문자열 상수 방식보다 더 강하게 타입으로 값을 제한하려면 Type-Safe Enum Pattern 같은 방식이 필요했다.
하지만 Java 5(J2SE 5.0, 2004년)부터는 이 패턴을 언어 차원에서 지원하는 enum 문법을 사용할 수 있다.
enum Grade {
BRONZE,
SILVER,
GOLD,
DIAMOND
}
Grade라는 새로운 타입을 만들고, 그 타입이 가질 수 있는 값을 미리 나열했다.
이제 함수의 매개변수 타입을 Grade로 바꿀 수 있다.
int calculateDiscountAmount(Grade grade, int price);
호출 코드도 의미가 명확해진다.
int discountAmount = calculateDiscountAmount(Grade.DIAMOND, 10_000);
이제 함수 호출자는 Grade에 정의된 값만 전달할 수 있다.
calculateDiscountAmount(Grade.GOLD, 10_000);
// calculateDiscountAmount(1, 10_000); // 컴파일 에러
// calculateDiscountAmount("DIAMOND", 10_000); // 컴파일 에러
매개변수 타입이 Grade이기 때문에, Grade에 정의되지 않은 값은 컴파일 단계에서 막을 수 있다.
덕분에 타입 안전성이 높아지고, 값의 의미가 코드에 드러나 가독성과 유지보수성도 좋아진다.
Java의 enum은 특별한 클래스다
이전 섹션에서 살펴본 enum Grade는 enum을 가장 단순하게 사용한 예다.
enum 상수를 미리 나열해 두고, 그 목록에 있는 상수만 Grade 타입의 값으로 사용하게 만든 것이다.
여기까지만 보면 enum은 단순한 상수 목록처럼 보인다.
하지만 Java의 enum 상수는 단순한 정수나 문자열이 아니라, 해당 enum 클래스의 인스턴스다.
그래서 각 상수마다 필요한 데이터를 생성자로 초기화하고, 그 데이터를 사용하는 메서드까지 함께 정의할 수 있다.
예를 들어 게임 난이도를 enum으로 표현해 보자.
enum Difficulty {
EASY("쉬움", 0.8),
NORMAL("보통", 1.0),
HARD("어려움", 1.5),
NIGHTMARE("악몽", 2.0);
private final String label;
private final double enemyHpMultiplier;
Difficulty(String label, double enemyHpMultiplier) {
this.label = label;
this.enemyHpMultiplier = enemyHpMultiplier;
}
public String getLabel() {
return label;
}
public int calculateEnemyHp(int baseHp) {
return (int) (baseHp * enemyHpMultiplier);
}
}
public class Main {
public static void main(String[] args) {
Difficulty difficulty = Difficulty.HARD;
System.out.println(difficulty.getLabel()); // 어려움
System.out.println(difficulty.calculateEnemyHp(100)); // 150
System.out.println(difficulty.ordinal()); // 2
}
}
EASY, NORMAL, HARD, NIGHTMARE는 각각 Difficulty 객체다.
예를 들어 HARD("어려움", 1.5)는 Difficulty의 생성자를 사용해 미리 정해진 인스턴스를 만드는 선언이다.
다만 일반 클래스처럼 외부에서 new Difficulty("어려움", 1.5)로 직접 생성할 수는 없다.
이런 식으로 사용하면 Java에서 enum은 HARD라는 이름만 표현하는 데서 끝나지 않고, 난이도에 필요한 데이터와 계산 로직을 Difficulty라는 타입 안에 함께 모을 수 있다.
또한 모든 Java enum은 java.lang.Enum을 상속하기 때문에 몇 가지 기본 메서드도 함께 가진다. 그중 하나가 ordinal()이다.
ordinal()은 enum 상수가 선언된 순서를 0부터 계산해 돌려주는 메서드다.
HARD는 EASY, NORMAL 다음 세 번째로 선언되었으므로 2가 출력된다.
하지만 ordinal()은 선언된 순서에 따라 값이 정해지기 때문에, 중간에 새로운 enum 상수가 삽입되면 기존 상수의 값도 달라질 수 있다.
따라서 ordinal() 값을 이용해 계산 로직을 만드는 것은 위험하다.
Java의 enum이 실제로 클래스처럼 다뤄지는지는 컴파일된 결과를 보면 더 분명하다.
아래 명령어를 사용해 .class 파일을 만들고, 그 내부 구조를 확인할 수 있다.
javac Main.java
javap Difficulty
출력은 다음과 같다.
final class Difficulty extends java.lang.Enum<Difficulty> {
public static final Difficulty EASY;
public static final Difficulty NORMAL;
public static final Difficulty HARD;
public static final Difficulty NIGHTMARE;
public static Difficulty[] values();
public static Difficulty valueOf(java.lang.String);
public java.lang.String getLabel();
public int calculateEnemyHp(int);
static {};
}
여기서 EASY, NORMAL, HARD, NIGHTMARE는 public static final 변수로 선언되어 있다.
즉 enum 상수는 사용할 때마다 새로 만들어지는 값이 아니라, 미리 만들어진 하나의 인스턴스를 여러 코드에서 참조하는 방식이다.
또한 java.lang.Enum을 자동으로 상속받으며, values()와 valueOf() 같은 메서드도 자동으로 제공된다.