열거형 (enum)
들어가는 말
enum은 enumeration의 약자로, ’열거’라는 뜻이다.
프로그래밍에서는 서로 관련 있는 상수들을 하나의 타입으로 묶어 표현할 때 enum을 사용한다.
프로그램을 만들다 보면 값의 종류가 미리 정해져 있는 경우가 있다.
- 요일:
MONDAY,TUESDAY,WEDNESDAY, … - 계절:
SPRING,SUMMER,AUTUMN,WINTER - 주문 상태:
PENDING,PAID,SHIPPED,CANCELED
이러한 범주에는 미리 정해진 후보 값만 들어올 수 있어야 한다.
enum은 허용되는 값의 목록을 하나의 타입으로 정의하는 문법이다.
값의 집합을 코드로 명확히 표현하고, 그 외의 값이 들어오는 실수를 컴파일 단계에서 막기 위해 사용한다.
다만 enum은 언어마다 구현 방식에 차이가 있다.
C 언어에서는 단순한 정수 값처럼 느슨하게 다뤄지고, Java에서는 하나의 타입처럼 더 엄격하게 다뤄진다.
enum이 필요한 이유
enum이 왜 필요한지 알아보기 위해, enum이 없을 때 어떤 문제가 생기는지 살펴보자.
방향을 int로 표현할 때의 문제
방향과 이동 거리를 입력받아 이동하는 함수가 있다고 하자.
void move(int direction, int distance);
방향을 다음과 같이 정수 값으로 표현한다고 하자.
EAST: 0WEST: 1SOUTH: 2NORTH: 3
그럼 호출자 코드를 보자.
move(1, 10);
함수 시그니처를 알고 호출 코드를 보더라도, direction에 어떤 값이 대응하는지 알 수 없어 이 호출의 의미를 한눈에 파악하기 어렵다.
방향을 동서남북으로 구분한다는 사실을 알아도, 값이 0부터 시작하는지 1부터 시작하는지 알 수 없다.
동쪽을 1로 표현한다면, 서쪽을 -1로 표현하는지 2로 표현하는지도 이 코드만으로는 알 수 없다.
결국 1이 무엇인지 알기 위해서는 별도의 규칙을 확인해야 한다.
심지어 다음과 같은 호출도 문법적으로 가능하다.
move(10, 10);
10은 우리가 방향으로 정의하지 않은 값이다.
하지만 매개변수 타입이 int이기 때문에 컴파일러는 이 호출을 막지 못한다.
즉, int는 “정해진 방향 중 하나”라는 의미를 표현하기에 부족하다.
방향을 String으로 표현할 때의 문제
정수 값이 헷갈리니 문자열을 사용해 볼 수도 있다.
void move(String direction, int distance);
이제 호출 코드는 조금 더 읽기 쉬워진다.
move("EAST", 10);
하지만 문자열 역시 안전하지 않다.
move("EAS", 10); // EAST에서 T가 빠진 오타
move("East", 10); // 대소문자 실수
move("east", 10); // 대소문자 실수
문자열은 오타에 취약하다.
방향으로는 잘못된 값이어도 문법적으로는 올바른 문자열이기 때문에 컴파일러가 막지 못한다.
오타를 막기 위해 문자열을 직접 쓰지 않고 상수로 관리할 수 있다.
public final class Direction {
public static final String NORTH = "NORTH";
public static final String EAST = "EAST";
public static final String SOUTH = "SOUTH";
public static final String WEST = "WEST";
private Direction() {}
}
final class로 선언하면 다른 클래스가 Direction을 상속할 수 없다.
또한 생성자를 private으로 막아 두면 new Direction()처럼 불필요한 개체를 만들 수 없다.
public static final로 선언한 값들은 클래스 이름으로 바로 접근할 수 있는 상수가 된다.
이제 호출 코드는 다음처럼 바꿀 수 있다.
move(Direction.EAST, 10);
문자열을 직접 입력하지 않으므로 오타 가능성이 줄어든다.
move(Direction.EAST, 10);
// move(Direction.EAS, 10); // 컴파일 에러: EAST에서 T가 빠져 존재하지 않는 상수
Direction.EAS라는 상수는 존재하지 않기 때문에 IDE에서 먼저 경고가 표시되고, 컴파일 단계에서도 오류로 잡힌다.
하지만 이 방식도 완전하지는 않다.
move("UP", 10); // 정의되지 않은 방향
함수의 매개변수 타입은 여전히 String이다.
따라서 호출자가 상수를 사용하지 않고 임의의 문자열을 매개변수로 넘겨도 컴파일 단계에서 막을 수 없다.
결국 String도 “정해진 방향 중 하나여야 한다”는 규칙을 타입으로 표현하지 못한다.
C의 enum
방향과 계절을 각각 다음과 같이 정의할 수 있다.
enum Direction {
EAST,
WEST,
SOUTH,
NORTH
};
enum Season {
SPRING,
SUMMER,
AUTUMN,
WINTER
};
값을 따로 지정하지 않으면 각 enum의 첫 번째 상수는 0이 되고, 이후 상수는 앞선 값보다 1씩 커진다.
각 상수의 값을 출력해 보면 다음과 같다.
printf("%d %d\n", EAST, SPRING); // 0 0
printf("%d %d\n", WEST, SUMMER); // 1 1
printf("%d %d\n", SOUTH, AUTUMN); // 2 2
printf("%d %d\n", NORTH, WINTER); // 3 3
각 enum 타입의 변수를 선언하고, 정의한 상수를 대입할 수 있다.
enum Direction direction = EAST;
enum Season season = SPRING;
이름 있는 상수를 사용하니 숫자 리터럴을 직접 쓰는 것보다 코드의 의미를 알기 쉽다.
하지만 C의 enum은 열거형 타입이지만, 실제로는 정수와 가깝게 다뤄진다.
따라서 다음과 같이 서로 다른 enum 값이나 정수를 대입해도 컴파일 오류는 발생하지 않는다.
enum Direction direction = SPRING; // enum Season의 값을 enum Direction 변수에 대입
enum Season season = NORTH; // enum Direction의 값을 enum Season 변수에 대입
direction = 10; // 단순 정수 값을 enum Direction 변수에 대입
심지어 서로 다른 enum에 속한 EAST와 SPRING도 둘 다 정수 값 0이므로 비교 결과가 참이다.
if (EAST == SPRING) {
printf("EAST and SPRING are equal.\n");
}
enum은 자료형이지만 타입 안정성이 약해, 컴파일러가 잘못된 사용을 막아 주지는 못한다.
그래서 상수가 어느 enum에 속하는지 드러내기 위해 접두사를 붙이기도 한다.
enum Direction {
DIRECTION_EAST,
DIRECTION_WEST,
DIRECTION_SOUTH,
DIRECTION_NORTH
};
enum Season {
SEASON_SPRING,
SEASON_SUMMER,
SEASON_AUTUMN,
SEASON_WINTER
};
enum Direction direction = SEASON_SPRING;
enum Season season = DIRECTION_EAST;
if (DIRECTION_EAST == SEASON_SPRING) { ... }
접두사를 붙인다고 해서 컴파일러가 기존 문제를 해결해 주는 것은 아니다.
다만 상수의 소속이 이름에 드러나므로, 코드만 보아도 잘못된 대입을 알아차리기 쉬워진다.
Java의 enum
Type-Safe Enum Pattern
Java 5(J2SE 5.0, 2004년)에 enum이 도입되기 전에는, Type-Safe Enum Pattern을 사용했다.
public final class Direction {
public static final Direction NORTH = new Direction();
public static final Direction EAST = new Direction();
public static final Direction SOUTH = new Direction();
public static final Direction WEST = new Direction();
private Direction() {}
}
Direction 클래스의 생성자는 private이므로 호출자는 new Direction()으로 새 개체를 만들 수 없다.
각 상수는 Direction 클래스가 초기화될 때 한 번만 생성되는 Direction 개체다.
Direction direction = Direction.EAST;
if (direction == Direction.EAST) {
System.out.println("East");
}
==는 두 참조값이 같은지 비교한다.
이 패턴에서는 각 방향을 상수 개체로 표현하므로, ==로 두 방향이 같은지 비교할 수 있다.
enum을 단순 상수로 사용하기
Java의 enum은 C 언어의 enum보다 더 많은 기능을 제공하지만, 먼저 C 언어처럼 단순한 상수의 집합으로 사용할 수 있다.
방향처럼 후보 값이 정해진 경우, 상수를 하나의 타입으로 묶어 표현한다.
enum Direction {
NORTH,
EAST,
SOUTH,
WEST
}
Direction이라는 새로운 타입을 만들고, 그 타입이 가질 수 있는 값을 나열했다.
이제 함수의 매개변수 타입을 int, String이 아닌 Direction으로 바꿀 수 있다.
void move(Direction direction, int distance);
호출할 때는 Direction에 정의한 상수를 전달한다.
move(Direction.NORTH, 10);
// move(1, 10); // 컴파일 에러
// move("EAST", 10); // 컴파일 에러
매개변수 타입이 Direction이기 때문에, Direction에 정의되지 않은 값은 컴파일 단계에서 막을 수 있다.
덕분에 타입 안전성이 높아지고, 값의 의미가 코드에 드러나 가독성과 유지보수성도 좋아진다.
enum을 필드와 메서드를 갖는 타입으로 사용하기
Java의 enum은 단순한 상수 집합을 정의하는 데 그치지 않는다.
각 enum 상수는 하나의 개체이므로, 필드와 메서드를 정의할 수 있다.
게임 난이도마다 표시 이름과 적 체력 배율이 다르다고 하자.
이 정보와 체력 계산식을 enum Difficulty 안에 함께 둘 수 있다.
enum Difficulty {
EASY("쉬움", 0.75),
NORMAL("보통", 1.0),
HARD("어려움", 1.25),
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) Math.round(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)); // 125
}
}
HARD("어려움", 1.25)처럼 상수 뒤에 적은 인수는 Difficulty 생성자로 전달된다.
생성자는 전달받은 값을 각 상수의 label, enemyHpMultiplier 필드에 저장한다.
final 필드로 선언했으므로 난이도를 선택한 뒤에는 그 난이도의 이름과 배율이 바뀌지 않는다.
calculateEnemyHp()처럼 난이도에 따라 달라지는 계산도 enum 안에 두면, 호출하는 쪽은 난이도의 세부 규칙을 알 필요가 없다.
이처럼 Java의 enum은 상수에 관련 데이터와 동작을 함께 담아 하나의 타입으로 표현할 수 있다.