JAVA/Effective Java

[아이템 34] int 상수 대신 열거 타입을 사용하라

꾸준함. 2024. 2. 25. 02:12

자바에서는 열거 패턴의 한계로 인해 5 버전에서 열거 타입을 도입했습니다.

C, C++, C#과 비슷해 보이지만 자바의 열거 타입은 완전한 형태의 클래스이기 때문에 다른 언어의 enum 타입보다 훨씬 강력합니다.

 

1. 열거 패턴의 한계

 

1.1 정수 열거 패턴의 단점

 

1.1.1 타입 안전을 보장할 방법이 없음

  • 문자열로 인식되기 때문에 서로 다른 타입을 ==로 비교하더라도 컴파일 에러 발생 X

 

 

 

컴파일 에러 X

 

1.1.2 표현력이 좋지 않음

  • 자바가 정수 열거 패턴을 위한 별도 namespace를 지원하지 않기 때문에 접두어를 써서 이름 충돌 방지
    • ex) 수은과 수성을 비교하려면 ELEMENT_MERCURY, PLANET_MERCURY와 같이 접두어를 붙여야 함

 

1.1.3 프로그램이 깨지기 쉬움

  • 평범한 상수를 나열한 것 뿐이라 컴파일하면 그 값이 클라이언트 파일에 그대로 새겨지기 때문에 상수의 값이 바뀔 경우 클라이언트도 반드시 다시 컴파일 필요
    • 다시 컴파일하지 않을 경우 클라이언트가 실행이 되더라도 엉뚱한 동작할 수 있음

 

1.1.4 디버깅 및 순회 어려움

  • 단순 숫자로만 출력되기 때문에 디버깅하기 어려움
  • 같은 정수 열거 그룹에 속한 모든 상수를 순회하면서 출력하는 방법도 마땅치 않음
  • 그룹 내 속한 상수의 갯수를 파악하기도 어려움

 

1.2 문자열 열거 패턴의 단점

 

  • 상수의 의미를 출력하는 것은 좋지만 문자열 상수의 이름 대신 문자열 값을 그대로 하드코딩
    • 하드코딩한 문자열에 오타가 있더라도 컴파일 타임에 확인 불가하므로 휴먼 에러로 인해 런타임 버그가 발생할 확률이 높아짐

 

  • 문자열 비교에 따른 성능 저하 유발

 

2. 자바의 열거 타입

자바의 enum 타입은 완전한 형태의 클래스이며 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 공개합니다.

열거 타입은 밖에서 접근할 수 있는 생성자를 제공하지 않으므로 사실상 불변이며 클라이언트가 인스턴스를 직접 생성하거나 확장할 수 없으니 열거 타입 선언으로 만들어진 인스턴스들은 싱글턴을 일반화한 형태라고 볼 수 있습니다.

자바의 열거 타입은 다음의 특징을 갖습니다.

  • type-safe
  • 각자의 namespace를 가짐

 

2.1 type-safe

 

  • 문자열이 아닌 클래스이기 때문에 컴파일 시점에 다른 타입임을 인식할 수 있음
    • ex) Apple의 enum 타입을 매개변수로 받는 메서드를 선언했을 경우 건네받은 참조는 Apple의 세 가지 값 중 하나 혹은 null 뿐임을 보장

 

  • 다른 타입의 값끼리 == 연산자로 비교하려고 하면 컴파일 에러 발생

 

 

 

 

2.2 각자의 namespace를 가짐

 

  • 열거 타입은 문자열이 아닌 클래스이기 때문에 각자의 namespace가 있어 이름이 같은 상수를 각 열거 타입마다 선언 가능
  • 열거 타입에 공개되는 것이 필드의 이름 뿐이기 때문에 정수 열거 패턴과 달리 상수 값이 클라이언트로 컴파일되어 각인되지 않기 때문에 새로운 상수를 추가하거나 순서를 바꿔도 열거 타입을 사용하는 클라이언트 코드는 다시 컴파일하지 않아도 됨
    • 코드의 확장성이 향상되고 유지보수가 용이해짐

 

2.3 문자열 출력에 용이

 

  • 열거 타입은 클래스이며 클래스는 묵시적으로 최상위 계층인 Object를 상속하기 때문에 toString 메서드를 재정의하여 적합한 문자열 출력 가능

 

이처럼 열거 타입은 열거 패턴의 단점들을 해소해 줍니다.

뿐만 아니라 열거 타입은 임의의 메서드나 필드를 추가할 수 있으며 임의의 인터페이스까지 구현할 수 있습니다.

 

3. 메서드와 필드를 갖는 열거 타입

메서드와 필드를 갖는 열거 타입의 대표적인 예시로 태양계의 여덟 행성이 있습니다.

 

 

 

클래스 부연 설명

  • 모든 행성은 구의 형태이기 때문에 각 행성은 질량과 반지름이 존재하며 두 속성을 이용해 표면중력을 계산할 수 있음
    • 열거 타입 상수 각각을 특정 데이터와 연결 지으려면 생성자에서 데이터를 받아 인스턴스 필드에 저장하면 됨 (mass, radius)
    • 열거 타입은 근본적으로 불변이라 public 한 생성자를 제공하지 않으며 모든 필드는 final
    • 필드를 public으로 선언해도 되지만 private으로 두고 별도의 public 메서드인 getter를 제공하는 것을 권장

 

  • 대상 객체의 질량을 입력받아 해당 객체가 행성 표면에 있을 때의 무게를 반환하는 surfaceWeight 메서드 제공

 

열거 타입은 자신 안에 정의된 상수들의 값을 배열에 담아 반환하는 정적 메서드인 values를 제공하기 때문에 앞서 언급한 정수형 열거 패턴의 제한사항 중 하나인 "같은 정수 열거 그룹에 속한 모든 상수를 순회하면서 출력하는 방법도 마땅치 않음"을 간편하게 해결할 수 있습니다.

또한, 각 열거 타입 값의 toString 메서드는 상수 이름을 문자열로 반환하므로 정수형 열거 타입의 또 다른 단점으로 언급되었던 문자열 출력에 안성맞춤입니다.

 

 

 

3.1 열거 타입을 선언한 클래스 혹은 그 패키지에서만 유용한 기능은 private이나 package-private 메서드로 구현

 

  • 이렇게 구현된 EnumType 상수는 자신을 선언한 클래스 혹은 패키지에서만 사용할 수 있는 기능을 담음
    • 해당 기능이 노출해야 할 할 합당한 이유가 없다면 pirvate으로 혹은 package-pirvate으로 선언 (아이템 15 참고)
    • 널리 쓰인다면 톱레벨 클래스로 만들고, 특정 톱레벨 클래스에서만 쓰인다면 해당 클래스의 멤버 클래스로 (아이템 24 참고)

 

4. 상수별 메서드 구현

사칙 연산 열거 타입 Operation이 다음과 같이 구현되었을 때 각 사칙연산에 대한 로직을 enum 타입 내 작성하고 싶을 때가 있습니다.

 

 

4.1 switch문을 활용한 구현

 

 

위와 같이 switch문을 활용하여 각 사칙연산에 대한 로직을 작성하면 동작은 하지만 장점보다는 단점이 더 많습니다.

  • 마지막의 throw문은 실제로는 도달할 일이 없지만 생략하면 컴파일이 안되기 때문에 추가해야 함
  • 새로운 상수를 추가하면 해당 switch문에도 추가해야 하는데 혹여라도 깜빡할 경우 AssertionError가 발생하기 때문에 휴먼 에러로 인해 깨지기 쉬운 코드 (컴파일 시점에 찾기 어려움)

 

4.2 추상 메서드를 이용한 구현

 

 

추상 메서드는 상수별로 다르게 동작하는 코드를 구현할 때 switch문보다 나은 수단입니다.

  • apply 메서드가 상수 선언 바로 옆에 붙어 있으므로 새로운 상수를 추가할 때 apply 재정의해야 한다는 사실을 깜빡하기 어렵기 때문에 휴먼 에러 확률을 줄일 수 있음
  • apply가 추상 메서드이므로 재정의하지 않을 경우 컴파일 에러가 발생하므로 컴파일 시점에 에러를 찾을 수 있음

 

열거 타입에는 상수 이름을 입력받아 그 이름에 해당하는 상수를 반환해 주는 valueOf(String) 메서드가 자동 생성되고 enum 타입의 toString 메서드를 재정의할 때 toString이 반환하는 문자열을 해당 enum 타입 상수로 변환해 주는 fromString 메서드도 함께 제공합니다.  

위 특성을 이용하고 각 상수 별 기호를 나타내는 symbol 필드까지 추가하면 계산식 출력도 편하게 할 수 있습니다.


 

코드 부연 설명

  • Operation 상수가 stringToEnum Map에 추가되는 시점은 Enum 타입 상수 생성 후 정적 필드가 초기화되는 시점
    • Enum 타입 상수는 생성자에서 자신의 인스턴스를 Map에 추가할 수 없으며 만약 이를 허용할 경우 런타임에 NullPointerException이 발생했을 것
    • Enum 타입의 정적 필드 중 열거 타입의 생성자에서 접근할 수 있는 것은 상수 변수뿐
    • 열거 타입 생성자가 실행되는 시점에는 정적 필드들이 아직 초기화되기 전이라 자기 자신을 추가하지 못하게 하는 제약

 

4.3 Functional Interface를 이용한 구현


 

위 코드는 아이템 24에서 다룬 계산기 클래스이며 자바 8 버전부터 지원하는 BiFunction을 이용해 람다식을 이용하여 로직을 구현할 수 있습니다.

 

5. 전략적 열거 타입 패턴

상수별 메서드 구현 시 열거 타입 상수끼리 코드를 공유하기 어렵다는 단점이 존재합니다.

급여명세서에서 사용할 요일을 표현하는 enum 타입이 있고 아래 내용을 충족해야 하는 PayrollDay 열거 타입이 있다고 가정해 보겠습니다.

  • enum 타입은 직원의 시급과 분 단위 일한 시간을 매개변수로 전달하면 일당을 계산하는 메서드 제공
    • 주중에 오버타임이 발생하면 잔업수당 제공
    • 주말에는 무조건 잔업수당 제공

 

5.1 switch문을 이용하여 구현


 

위와 같이 구현하면 간결하지만 관리 관점에서는 위험한 코드입니다.

  • 상수별 메서드 구현과 같이 열거 타입에 새로운 값을 추가하려면 해당 값을 처리하는 case문을 빠뜨리지 않고 추가해야 하므로, 휴먼 에러의 확률이 상승
  • 깜빡할 경우 휴가 기간에 일하더라도 평일과 똑같은 임금을 받을 수 있음

 

5.2 잔업수당을 계산하는 코드를 모든 상수에 중복해서 넣음


 

위와 같이 코드를 작성하면 switch문을 썼을 때와 똑같은 단점이 발생합니다.

  • 새로운 상수를 추가하면서 overtimePay 메서드를 재정의하지 않을 가능성 존재
  • 컴파일 시점에 휴먼 에러를 찾기 어려움

 

5.3 잔업수당 전략 선택하여 구현


 

가장 깔끔한 방법은 새로운 상수를 추가할 때 잔업수당 '전략'을 선택하도록 하는 것입니다.

  • 잔업수당 계산을 private nested Enum type인 PayType으로 이관하고 PayrollDay 열거 타입 생성자에서 이 중 적당한 것을 선택
  • PayrollDay 열거 타입은 잔업수당 계산을 그 전략 열거 타입에 위임하여 switch문이나 상수별 메서드 구현이 필요 없게 됨

 

비고

지금까지 switch 문이 enum 타입의 상수별 동작을 구현하는 데 적합하지 않다고 설명하였고, 이와 관련된 예시 코드를 살펴보았습니다.

하지만 기존 열거 타입에 상수별 동작을 혼합해 넣을 때는 최소한으로 코드를 변경하기 때문에 switch문이 좋은 선택이 될 수 있습니다.

다음은 이러한 효과를 내주는 정적 메서드이며 switch 문을 이용해 원래 열거 타입에 없는 기능을 수행합니다.


 

정리

컴파일 시점에 필요한 원소를 모두 알 수 있는 상수 집합의 경우, 항상 열거 타입을 사용하는 것을 권장합니다.

enum 타입은 나중에 상수가 추가돼도 바이너리 수준에서 호환되도록 설계되었기 때문에 열거 타입에 정의된 상수 개수가 영원히 고정 불변일 필요는 없습니다.

 

참고

이펙티브 자바

반응형