enum 이란?
enum은 JDK 1.5 부터 도입된 연관된 상수(Constant)들의 집합을 정의하는 특수한 클래스다. enum 에 미리 정의해둔 정해진 값만 사용하도록 강제함으로써 데이터 일관성이 확보되고 가독성,유지보수가 편리해진다. enum 은 싱글톤(Singleton)과 유사하게JVM에서 클래스 로딩 시 단 한번만 인스턴스되고 이후 동일한 객체를 재사용하므로 불필요한 인스턴스 생성이 일어나지 않는다.
enum 을 사용하지 않는 방식의 문제점
[문자열을 이용한 방식의 문제점]
enum 을 사용하지 않고, 단순 문자열 비교를 이용해 회원등급에 따라 할인을 적용하는 메서드를 정의했다.
public class MemberService {
public int discount(String grade, int price) {
if (grade.equals("BASIC")) {
return price - 1000;
} else if (grade.equals("GOLD")) {
return price - 3000;
} else if (grade.equals("DIAMOND")) {
return price - 5000;
}
return price;
}
}
해당 방식의 문제점은 "BASC", "GOD"처럼 오타가 있어도 컴파일 시점에서 검출할 수 없으며, 파라미터에 미리 정해둔 값이 아닌 임의의 문자열이 들어올 수 있다는 것이다.
[상수를 이용한 방식의 문제점]
다음으로 class 내부에 상수를 정의하고 이용하는 방식이다.
public class MemberGrade {
public static final String BASIC = "BASIC";
public static final String GOLD = "GOLD";
public static final String DIAMOND = "DIAMOND";
}
public class MemberService {
public int discount(String grade, int price) {
if (grade.equals(MemberGrade.BASIC)) {
return price - 1000;
} else if (grade.equals(MemberGrade.GOLD)) {
return price - 3000;
} else if (grade.equals(MemberGrade.DIAMOND)) {
return price - 5000;
}
return price;
}
}
단순 문자열을 사용하는 문자열 오타를 방지 (MemberGrade.BASIC 사용) 할 수 있다는 점에서 개선되었다. 하지만 문자열 상수를 사용해도 String 타입이기 때문에 파라미터로 어떤 문자열이든 입력할 수 있으며, 개발자가 실수로 문자열 상수를 사용하지 않고 직접 문자열을 사용해도 막을 수 있는 방법은 없다.
[Type-Safe Enum Pattern 을 이용하는 방식의 문제점]
일반적으로 잘 이용되지는 않지만, Enum을 사용하지 않고 Java 클래스를 통해 열거형을 구현하는 방식으로 구현할 수도 있다.
public class MemberGrade {
public static final MemberGrade BASIC = new MemberGrade("BASIC");
public static final MemberGrade GOLD = new MemberGrade("GOLD");
public static final MemberGrade DIAMOND = new MemberGrade("DIAMOND");
private final String grade;
private MemberGrade(String grade) {
this.grade = grade;
}
public String getGrade() {
return grade;
}
}
먼저 회원 등급을 다루는 클래스를 만들고, 각각의 회원 등급별로 상수를 선언한다. 이때 각각의 상수마다 별도의 인스턴스를 생성하고, 생성한 인스턴스를 대입한다. 이때 각각을 상수로 선언하기 위해 static, final을 사용한다.
- static을 사용해서 상수를 메서드 영역에 선언한다.
- final을 사용해서 인스턴스(참조값)를 변경할 수 없게 한다.
public class MemberService {
public int discount(MemberGrade grade, int price) {
if (grade == MemberGrade.BASIC) {
return price - 1000;
} else if (grade == MemberGrade.GOLD) {
return price - 3000;
} else if (grade == MemberGrade.DIAMOND) {
return price - 5000;
}
return price;
}
}
MemberGrade.BASIC, MemberGrade.GOLD, MemberGrade.DIAMOND는 클래스 로딩 시점에 한번만 생성되어 JVM의 메서드 영역에 저장된다. 따라서 해당 클래스를 사용하면 동일한 메모리주소를 참조하므로 비교할 때는 grade == MemberGrade.BASIC와 같이 == 참조값(Ref) 비교를 사용한다.
외부에서 MemberGrade 객체를 생성할 수 없으므로 BASIC, GOLD, DIAMOND 외에는 사용할 수 없도록 강제할 수 있게 되었다. 파라미터로 들어오는 타입도 MemberGrade 로 강제되었다.
하지만 이 방식도 아래와 같은 단점이 있다.
- switch 문을 사용할 수 없다. (자바의 switch는 enum을 지원하지만 일반 클래스 타입은 지원하지 않음)
- toString(), values() 같은 기본적인 유틸리티 메서드를 제공하지 않아, 필요시 직접 구현해야 하며, 코드가 길어진다.
- Java 직렬화시 역직렬화로 인한 중복 인스턴스 생성문제가 있다.
- 리플렉션을 막지 못한다. enum은 Reflection을 통한 객체 생성을 방지하지만, Type-Safe Enum 방식은 private 생성자를 setAccessible(true)로 변경하여 새로운 인스턴스를 만들 수 있다.
직렬화와 리플렉션 예시를 만들어 하나씩 살펴보면
public class MemberGrade implements Serializable {
public static final MemberGrade BASIC = new MemberGrade("BASIC");
...
}
직렬화를 위해 Serializable 을 구현하고
public class JavaSerializationTest {
public static void main(String[] args) throws Exception {
ObjectOutputStream out = new ObjectOutputStream(new FileOutputStream("membergrade.ser"));
out.writeObject(MemberGrade.BASIC);
out.close();
ObjectInputStream in = new ObjectInputStream(new FileInputStream("membergrade.ser"));
MemberGrade restored = (MemberGrade) in.readObject();
in.close();
System.out.println(MemberGrade.BASIC == restored);
System.out.println(MemberGrade.BASIC);
System.out.println(restored);
}
}
역직렬화해서 비교하면 서로다른 인스턴스다. 결론적으로, Java 직렬화시 Serializable 만으로는 인스턴스가 하나만 생성되는것을 보장할 수 없다.
Constructor<MemberGrade> constructor = MemberGrade.class.getDeclaredConstructor(String.class);
constructor.setAccessible(true);
MemberGrade newInstance = constructor.newInstance("VIP");
System.out.println(MemberGrade.BASIC == newInstance);
리플렉션으로 새로운 인스턴스가 생성되는 것 또한 막을 수 없다.
이처럼 enum 을 사용하지 않았을 때 나타나는 문제점들을 enum 을 사용하면 해결할 수 있다.
enum 주요 메서드
아래와 같은 메서드를 기본 제공하며 활용할 수 있다.
values() | 모든 enum 상수를 배열로 반환 |
valueOf(String name) | 문자열을 enum으로 변환 |
ordinal() | enum 선언 순서를 반환 |
name() | enum 이름을 반환 |
toString() | 기본적으로 name()을 반환하지만, 오버라이드 가능 |
ordinal()은 사용하지 않는 것이 좋다. ordinal() 을 활용해 순서로 코드를 작성하게되면, 중간에 상수를 선언하는 위치가 변경되거나, 중간에 새로운 상수를 추가하게 될 경우 경우 버그가 발생할 가능성이 있다.
enum 사용법과 활용
[switch 문에서 enum 사용 방법]
public int discount(MemberGrade grade, int price) {
return switch (grade) {
case BASIC -> price - 1000;
case GOLD -> price - 3000;
case DIAMOND -> price - 5000;
};
}
일반적인 switch 문 뿐 아니라 자바 12부터 지원되는 switch expression 을 이용해 간단히 값을 반환하도록 만들 수 있다. enum 이 가지고있는 필드를 switch 문에서 모두 정의했다면 default 가 필요없다.
public int discount(MemberGrade grade, int price) {
return switch (grade) {
case BASIC -> price - 1000;
case GOLD -> price - 3000;
default -> throw new IllegalStateException("Unexpected value: " + grade);
};
}
특정 필드를 제외하고, 누락시 처리를 추가하려면 default 를 이용할 수 있다.
[enum과 인터페이스 활용 방법]
인터페이스를 활용해 아래처럼 공통으로 정의될 메서드를 넣어줄 수 있다.
public interface DiscountPolicy {
int discount(int price);
}
public enum Grade implements DiscountPolicy {
BASIC {
@Override
public int discount(int price) {
return price - 1000;
}
},
GOLD {
@Override
public int discount(int price) {
return price - 3000;
}
},
DIAMOND {
@Override
public int discount(int price) {
return price - 5000;
}
};
}
public class MemberService {
public int discount(Grade grade, int price) {
return grade.discount(price);
}
}
추가적인 if-else나 switch 없이 각 열거형에서 직접 로직을 구현해 역할을 명확하게 정의할 수 있다. enum 값에 따라 각기 다른 동작을 할 수 있게 만들어 상태와 행위를 같이 관리할 수 있다.
[values() 와 valueOf() 사용법]
for (MemberGrade grade : MemberGrade.values()) {
System.out.println(grade.name()); // BASIC, GOLD, DIAMOND
}
MemberGrade grade = MemberGrade.valueOf("GOLD");
System.out.println(grade); // GOLD
values() 를 이용해 모든 enum 값을 순회하거나, valueOf 로 문자열을 enum 으로 변환할 수 있다.
[EnumMap과 EnumSet 활용법]
enum을 활용하게 되면 Set, Map 과 같은 자료구조에 넣어서 사용해야 할 수도 있다.
java는 일반적인 HashSet 과 HashMap 보다 빠르게 동작할 수 있도록 Enum 에 최적화된 EnumSet, EnumMap 을 제공한다.
둘다 Thread-safe 하지 않게 구현되어 있다.
Map<MemberGrade, Integer> discountMap = new EnumMap<>(MemberGrade.class);
discountMap.put(MemberGrade.BASIC, 1000);
discountMap.put(MemberGrade.GOLD, 3000);
discountMap.put(MemberGrade.DIAMOND, 5000);
enum 을 키로 사용해 EnumMap 을 사용할 수 있다. enumMap 은 내부적으로 배열기반으로 동작하며, null 키를 허용하지 않으며, null 값을 저장할 수는 있다.
EnumSet<MemberGrade> highGrades = EnumSet.of(MemberGrade.GOLD, MemberGrade.DIAMOND);
System.out.println(highGrades.contains(MemberGrade.BASIC)); // false
EnumSet 은 내부적으로 비트 벡터(bit-vector)를 사용해 빠르고 메모리 효율적으로 동작한다. null 값을 허용하지 않으며 enum 만 저장할 수 있다.
[Function 을 이용해 비즈니스로직을 간결히 처리하는 방법]
import java.util.function.Function;
public enum DiscountPolicy {
NONE(price -> price), // 할인 없음
BLACK_FRIDAY(price -> price * 0.7), // 30% 할인
NEW_USER(price -> price - 3000), // 신규 가입자 3,000원 할인
VIP(price -> price * 0.8); // VIP 고객 20% 할인
private final Function<Double, Double> discountFunction;
DiscountPolicy(Function<Double, Double> discountFunction) {
this.discountFunction = discountFunction;
}
public double applyDiscount(double price) {
return discountFunction.apply(price);
}
}
각각 할인 정책에 맞는 가격을 반환하는 enum 을 만들어 활용할 수 있다.
public class DiscountService {
public static void main(String[] args) {
double originalPrice = 10000;
System.out.println("일반 가격: " + DiscountPolicy.NONE.applyDiscount(originalPrice)); // 10000
System.out.println("블랙 프라이데이 가격: " + DiscountPolicy.BLACK_FRIDAY.applyDiscount(originalPrice)); // 7000
System.out.println("신규 유저 할인 가격: " + DiscountPolicy.NEW_USER.applyDiscount(originalPrice)); // 7000
System.out.println("VIP 고객 가격: " + DiscountPolicy.VIP.applyDiscount(originalPrice)); // 8000
}
}
enum 의 특징
[enum 은 컴파일 시 class로 변환된다]
enum 은 단순한 상수 그룹이 아니라, 컴파일되면 내부적으로 java.lang.Enum<T> 를 상속하는 final class 로 변환된다.
public enum MemberGrade {
BASIC(1000),
GOLD(3000),
DIAMOND(5000);
private final int discountAmount;
MemberGrade(int discountAmount) {
this.discountAmount = discountAmount;
}
public int getDiscountAmount() {
return discountAmount;
}
}
javac 명령어를통해 컴파일 후 얻은 바이트 코드를 읽기 좋게 javap -p 명령어로 디어셈블해보면
java.lang.Enum 클래스를 확장하는 클래스인 것을 확인할 수 있다.
[enum 인스턴스는 단 한번만 생성을 보장한다]
enum의 생성자는 반드시 private 이다. (protected 또는 public 으로 지정할 수 없다)
따라서 일반적으로 접근제어자(Access Modifier)를 생략하고 위처럼 생성자를 만든다.
컴파일한 enum 생성자 부분을 보면 자동으로 private 생성자로 매핑된다. 따라서 new 키워드를 사용하여 인스턴스를 만들 수 없다.
[enum 인스턴스는 클래스 로딩시점에 생성된다.]
그럼 enum 인스턴스는 언제 생성되는걸까?
javap -v 로 바이트코드를 더 자세히 살펴보면 위와 같은 결과를 확인할 수 있다.
static {}; 은 static initializer block(정적 초기화 블록)으로 클래스 로딩 시 단 한 번만 실행되며, 해당 클래스의 모든 정적 필드를 초기화하는 역할을 담당한다. Java에서 enum 타입을 컴파일하게 되면, 컴파일러가 자동으로 static initializer block을 생성해 클래스 내부의 정적 상수 필드를 초기화하는 코드를 추가하게 된다. 이후 static {} 블록을 실행해 enum의 모든 인스턴스를 초기화한다.
따라서 JVM이 enum 클래스를 처음 로드할 때, enum 인스턴스가 자동으로 생성된다.
[enum 은 불변객체다]
컴파일 결과를 확인했을 때 내부필드가 private static final 이므로 필드변경이 아예 불가능하다.
[상속을 지원하지 않는다]
Enum 은 내부적으로 java.lang.enum 클래스에 의해 상속된다. 자바는 다중상속을 지원하지 않기 때문에 enum 역시 다른 클래스를 사용받을 수 없다. (인터페이스는 구현 가능하다)
[비교시 equals 또는 == 모두 가능]
enum 의 equals 는 == 비교로 구현되어 있다.
[enum 직렬화 및 역직렬화]
Enum 상수는 일반적인 Serializable 또는 Externalizable 객체와는 다른 방식으로 직렬화된다.
- 직렬화(Serialization)
ObjectOutputStream이 enum 상수의 이름(name())을 직렬화한다. 즉, enum 상수 자체가 아닌 name() 값(String)만 저장된다. - 역직렬화(Deserialization)
ObjectInputStream이 직렬화된 이름(String) 을 읽은 후, java.lang.Enum.valueOf(enumType, name) 메서드를 호출하여
해당 enum 상수를 찾아 반환한다.
따라서 새로운 인스턴스가 생성되지 않고, 기존 인스턴스가 재활용된다.
'Java' 카테고리의 다른 글
[Java] 자바 객체의 Lock 과 Monitor 이해하기 (0) | 2025.03.09 |
---|---|
[Java] Blocking Queue 이해하고 사용해보기 (1) | 2025.03.07 |
[Java] Callable, Feature 이해 및 사용예시 (0) | 2025.02.26 |
[Java] Thread, Runnable 이해하고 사용하기 (1) | 2025.02.21 |
자바의 정석 OOP(1) (0) | 2024.09.23 |