요약

새로 추가하는 메서드 없이 타입 정의가 목적이라면 마커 인터페이스를 선택하고,

인터페이스/클래스 외의 프로그램 요소(모듈, 패키지, 필드, 지역 변수)에 마킹을 하거나 애너테이션을 많이 사용하는 프레임워크를 사용할 때는 마커 애너테이션을 사용하자.

 

마커 인터페이스란

마커 인터페이스는 아무 메서드도 담고 있지 않고, 단지 자신을 구현하는 클래스가 특정 속성을 가짐을 표시해 주는 인터페이스를 의미한다.

예시로 Serializable 인터페이스가 있다. Serializable은 자신을 구현한 클래스의 인스턴스는 ObjectOutputStream을 통해 직렬화 할 수 있다고 알려주는 인터페이스이다.

 

마커 인터페이스 vs. 마커 애너테이션

마커 인터페이스 > 마커 애너테이션

  1. 마커 인터페이스는 타입을 구분할 수 있다
    1. 컴파일 타임에 오류를 잡아낼 수 있다
    2. 반면 마커 애너테이션은 런타임에 오류를 잡아낼 수 있다
  2. 마커 인터페이스는 적용 대상을 더 정밀하게 지정할 수 있다
    1. 마커 애너테이션은 적용 대상을 @Target(Element.TYPE)으로 지정할 수 있지만, 세밀하게 하지는 못한다
    2. 반면 마커 인터페이스는 클래스가 해당 인터페이스를 구현하면 되기 때문에 원하는 곳에 적용할 수 있다

 

마커 인터페이스 < 마커 애너테이션

  1. 마커 애너테이션은 애너테이션의 시스템 지원을 받을 수 있다
    1. 애너테이션을 적극 활용하는 프레임워크에서는 마커 애너테이션을 쓰는 쪽이 일관성을 지키는 데 더 유리하다

 

정리

마커 애너테이션을 사용해야 할 때

  1. 인터페이스나 클라스가 아닌 프로그램 요소(모듈, 패키지, 필드, 지역 변수)에 마킹을 해야할 때
  2. 애너테이션을 많이 사용하는 프레임워크를 사용할 때

마커 인터페이스를 사용해야 할 때

  1. 마킹이 된 객체를 매개변수로 받는 메서드 작성이 필요할 때
    1. 매개변수 타입으로 마커 인터페이스를 설정하면 컴파일타임에 오류가 발생하기 때문

요약

애노테이션 사용하자! 그리고 자바가 제공하는 애노테이션은 잘 알아두자.

 

명명패턴

이름 짓기에 패턴을 만드는 방법을 의미한다. 예시로 Junit 3까지는 테스트 메서드 이름을 testA() 처럼 test로 시작했어야 했다.

 

단점

  1. 오타가 나면 안된다
  2. 실수로 메서드 이름을 tsetSafetyOverride로 지어도 Junit 3은 이 메서드를 테스트 메서드로 인식 못하고 넘어간다. 하지만 개발자는 테스트가 성공해서 넘어간 것으로 오해할 수 있어 문제 발생의 가능성이 존재한다.
  3. 올바른 프로그램 요소(메서드)에서만 사용되리라 보증할 방법이 없다
  4. 메서드가 아닌 클래스 이름을 TestSafety로 설정한다 가정하면, 클래스 이름이 test로 시작하지만 메서드가 아닌 클래스에 설정된 이름이기 때문에 이또한 Junit 3가 인지하지 못한다. 하지만 개발자는 테스트에 사용하는 클래스 목적으로 TestSafety라는 이름을 설정했기에, 문제가 발생할 가능성이 또한 존재한다.
  5. 프로그램 요소를 매개변수로 전달할 마땅한 방법이 없다
  6. 특정 예외를 던져야 성공하는 테스트인 경우, 예외를 넘겨줘야 하는데 명명패턴에서는 넘길 수 없다. 또한 명명패턴에서는 예외의 이름을 테스트 메서드 이름에 덧붙이는 방법도 있지만, 이는 보기도 나쁘고 깨지기도 쉽다.

→ 위 모든 문제를 애너테이션이 해결할 수 있고 Junit 4부터 애너테이션을 사용할 수 있다.

 

마커 애너테이션

마커 애너테이션은 아무 매개변수 없이 단순히 대상에 마킹하는 목적으로 사용하는 애너테이션이다.

 

@Test

/**
 * 테스트 메서드임을 선언하는 에너테이션이다.
 * 1) 매개변수 없는 2)정적 메서드 전용이다.
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface Test {
}
  • @Test를 설정하는 코드이고, @Test를 마커 애너테이션이라고 한다.
  • 메타 애너테이션 : 애너테이션 선언에 있는 애너테이션을 의미
    • @Retention(RetentionPolicy.RUNTIME) : @Test가 런타임에도 유지되어야 함
    • @Target(ElementType.METHOD) : @Test가 반드시 메서드 선언에서만 사용되어야 함
  • 매개변수 없는 정적 메서드 전용이다.
    • 인스턴스 메서드 or 매개변수가 있는 메서드에서는 사용 못한다
  • 장점
    • 규칙 어길 시 컴파일 오류가 발생해 안정성 보장

 

마커 애너테이션 예시

public class Sample {

    @Test // Test 도구가 체크하는 메서드 & 성공 케이스
    public static void m1() {
    }

    @Test // Test 도구가 체크하는 메서드 & 실패 케이스
    public static void m2() {
        throw new RuntimeException("실패");
    }

    @Test // Test 도구가 체크하는 메서드 & 잘못 사용한 케이스(정적 메서드가 아님)
    public void m3() {
    }

    // Test 도구가 체크하지 않는 메서드
    public static void m4() {
    }
}
  • @Test
    • m1, m2, m3 메서드는 @Test 애너테이션이 설정되어 있어 Test 도구가 체크할 수 있는 메서드이다
    • 이 중에서 성공 케이스와 실패 케이스로 나뉘는데, 실패 케이스는 예외를 throw 한다
    • m4()처럼 정적 메서드 임에도 @Test 애너테이션이 설정되어 있지 않다면, Test 도구가 체크하지 않는다
  • 정적 메서드
    • @Test 애너테이션을 설정하더라도, 설정된 메서드가 static(정적)이 아니면 잘못 사용한 예시다

 

@Test

이 애너테이션은 클래스에 직접적인 영향은 주지 않고, 플러스 알파로 해당 애너테이션에 관심 있는 도구에게 추가 정보를 제공한다.

  • 마커 애너테이션을 처리하는 프로그램
public class RunTests {

    public static void main(String[] args) throws ClassNotFoundException {
        int tests = 0; // 실행된 테스트 개수
        int passed = 0; // 실행된 것 중 통과한 테스트 개수

        Class<?> testClass = Class.forName(args[0]); // 명령줄로부터 완전 정규화된 클래스 불러옴
        for (Method method : testClass.getDeclaredMethods()) {
            if (method.isAnnotationPresent(Test.class)) { // @Test 선언한 메서드 차례로 호추
                tests++;
                try {
                    method.invoke(null);
                    passed++;
                } catch (InvocationTargetException wrappedException) {
                    Throwable cause = wrappedException.getCause();
                    System.out.println(method + " 실패 : " + cause);
                } catch (Exception e) {
                    System.out.println("잘못 사용한 @Test : " + method);
                }
            }
        }
        System.out.printf("성공 : %d, 실패 : %d%n", passed, tests - passed);
    }
}
  • InvocationTargetException
    • 테스트 예외를 InvocationTargetException으로 감쌈
    • 그래서 테스트 예외를 알아내기 위해 wrappedException.getCause() 호출
    • 이 예외가 발생하면 @Test 애너테이션을 잘못 사용한 것을 의미
      • 정적 메서드가 아닌 인스턴스 메서드, 매개변수가 있는 메서드, 호출할 수 없는 메서드 등

 

매개변수 하나를 받는 애너테이션

특정 예외를 던져야만 성공하는 테스트를 지원하기 위해 예외를 매개변수로 받는 애너테이션 @ExceptionTest를 만들어야 한다.

  •  @ExceptionTest
/**
 * 명시한 예외를 던져야만 성공하는 테스트 메서드용 애너테이션
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ExceptionTest {
    Class<? extends Throwable> value();
}
  • 애너테이션 매개변수 타입 : Class<? extends Throwable>(한정적 타입 토큰)
    • Throwable을 상속한 하위 타입 모두 해당됨을 의미한다
    • 모든 예외와 오류 타입을 수용한다

 

예시

public class Sample2 {
    
    // 성공 케이스
    @ExceptionTest(ArithmeticException.class)
    public static void m1() {
        int i = 1;
        i /= i;
    }
    
    // 실패 케이스 - @ExceptionTest 에 설정한 Arithmetic 예외가 아닌 다른 예외 발생
    @ExceptionTest(ArithmeticException.class)
    public static void m2() {
        int[] a = new int[0];
        int i = a[1]; // IndexOutOfBoundException
    }
    
    // 예외가 발생하지 않는 케이스
    @ExceptionTest(ArithmeticException.class)
    public static void m3() { }
}

활용

  • 코드
public class RunTests2 {

    public static void main(String[] args) throws ClassNotFoundException {
        int tests = 0; 
        int passed = 0; 
        Class<?> testClass = Class.forName(args[0]); 
        for (Method method : testClass.getDeclaredMethods()) {
            if (method.isAnnotationPresent(ExceptionTest.class)) { // @Test 선언한 메서드 차례로 호추
                tests++;
                try {
                    method.invoke(null);
                    System.out.printf("테스트 %s 실패 : 예외를 던지지 않음%n", method);
                } catch (InvocationTargetException wrappedException) {
                    Throwable exceptionCause = wrappedException.getCause();
                    Class<? extends Throwable> exceptionType = 
                            method.getAnnotation(ExceptionTest.class).value();
                    if (exceptionType.isInstance(exceptionCause)) { // 기대한 예외 == 발생한 예외
                        passed++;
                    } else { // 기대한 예외 != 발생한 예외
                        System.out.printf("테스트 %s 실패 : 기대한 예외 %s, 발생한 예외 %s%n", method, exceptionType.getName(), exceptionCause);
                    }
                    
                    System.out.println(method + " 실패 : " + exceptionCause);
                } catch (Exception e) {
                    System.out.println("잘못 사용한 @Exceptiontest : " + method);
                }
            }
        }
        System.out.printf("성공 : %d, 실패 : %d%n", passed, tests - passed);
    }
}

  • @Test 애너테이션 실행 코드와 다른 부분
    • 애너테이션의 매개변수 값을 추출해 테스트 메서드가 올바른 예외를 던지는지 확인하는데 애너테이션을 사용한다

 

여러 개의 예외를 받는 애너테이션

  • @MultipleExceptionTest
/**
 * 배열 매개변수를 받는 애너테이션 타입
 */
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface MultipleExceptionTest {
    Class<? extends Throwable>[] value();
}

 

활용

  • 코드
public class RunTests3 {

    // 매개변수로 예외 배열 넘김
    @MultipleExceptionTest({IndexOutOfBoundsException.class, 
                            NullPointerException.class})
    public static void doubleBad() {
        List<String> list = new ArrayList<>();
        list.addAll(5, null);
    }
    
    public static void main(String[] args) throws ClassNotFoundException {
        int tests = 0;
        int passed = 0;
        Class<?> testClass = Class.forName(args[0]);
        for (Method method : testClass.getDeclaredMethods()) {
            if (method.isAnnotationPresent(MultipleExceptionTest.class)) { // @Test 선언한 메서드 차례로 호추
                tests++;
                try {
                    method.invoke(null);
                    System.out.printf("테스트 %s 실패 : 예외를 던지지 않음%n", method);
                } catch (InvocationTargetException wrappedException) {
                    Throwable exceptionCause = wrappedException.getCause();
                    int oldPassed = passed;
                    Class<? extends Throwable>[] exceptionTypes =
                            method.getAnnotation(MultipleExceptionTest.class).value();
                    for (Class<? extends Throwable> exceptionType : exceptionTypes) {
                        if (exceptionType.isInstance(exceptionCause)) {
                            passed++;
                            break;
                        }
                    }
                    if (passed == oldPassed) {
                        System.out.printf("테스트 %s 실패 : %s %n", method, exceptionCause);
                    }
                    System.out.println(method + " 실패 : " + exceptionCause);
                } catch (Exception e) {
                    System.out.println("잘못 사용한 @Test : " + method);
                }
            }
        }
        System.out.printf("성공 : %d, 실패 : %d%n", passed, tests - passed);
    }
}

  • 여러 개의 값을 받는 애너테이션
    • 배열 매개변수
    • @Repeatable 메타 애너테이션
      • 장점 : 가독성 향상
      • 단점
        • 애너테이션 선언/처리 코드양 증가
        • 처리가 복잡해서 오류 발생 가능성 증가

요약

비트 필드 보다 EnumSet이 가독성, 성능 면에서 훨씬 낫기 때문에 EnumSet을 사용하자.

 

비트 필드(bit field)

비트 필드는 비트별 OR( | )를 사용해 여러 상수를 하나의 집합으로 만든 것을 의미한다.

public class Text {
    // 비트 필드 열거 상수 : 각 상수에 서로 다른 2의 거듭제곱을 할당한 정수 열거 패턴
    public static final int STYLE_BOLD = 1 << 0; // 1
    public static final int STYLE_ITALIC = 1 << 1; // 2
    public static final int STYLE_UNDERLINE = 1 << 2; // 4
    public static final int STYLE_STRIKETHROUGH = 1 << 3; // 8

    public void applyStyles(int styles) { ... }
}

// 클라이언트에서 applyStyles() 호출
text.applyStyles(STYLE_BOLD | STYLE_ITALIC);
  • 장점
    • 비트별 연산을 사용해 합집합과 교집합 같은 집합 연산을 효율적으로 수행 가능하다
  • 단점
    • 비트 필드는 정수 열거 상수의 단점을 그대로 안고 있다
    • 비트 필드 값이 그대로 출력되면 정수 열거 상수를 출력할 때보다 해석하기가 훨씬 어렵다
    • API 작성 시 고려사항이 하나 더 추가된다
      • 최대 몇 비트가 필요한지를 API 작성 시 미리 예측해 적절한 타입(int, long)을 선택해야하기 때문

결론 : 사용하지 말자!

 

EnumSet

EnumSet은 java.util 패키지의 클래스로, enum 타입 상수의 값으로 구성된 집합을 효과적으로 표현해준다.

public class Text {
    public enum Style { BOLD, ITALIC, UNDERLINE, STRIKETHROUGH }

    // 어떤 Set을 넘겨도 상관없으나, EnumSet이 가장 좋다.
    public void applyStyles(Set<Style> styles) { ... }
}

// 클라이언트
text.applyStyles(EnumSet.of(STYLE.BOLD, STYLE.ITALIC));
  • 장점
    1. Set 인터페이스를 완벽히 구현한다
      1. 다른 어떤 Set 구현체와도 함께 사용할 수 있다
    2. 타입 안전성
    3. 총 64개 이하의 원소에 대해서는 비트 필드와 비슷한 성능을 가진다
      1. 내부가 비트 벡터로 구현되어 있기 때문에 EnumSet 전체를 long 변수 하나로 표현할 수 있다
    4. 대량 작업(removeAll, retainAll)의 효율적인 처리 및 비트 관련 문제 해결
      1. 효율적인 처리: 내부에서 비트를 효율적으로 처리할 수 있는 산술 연산을 사용하기 때문
      2. 비트 관련 문제 : 난해한 작업을 EnumSet이 다 처리해주기 때문
  • 단점
    1. 자바 9까지는 불변 EnumSet을 만들 수 없다
      1. 이는 Collections.unmodifiableSet으로 EnumSet을 감싸면 해결 가능하다

 

참고

비트 벡터(bit Vector)

요약

정수 상수 대신 열거 타입을 사용하자. 열거 타입이 더 가독성 좋고, 안전하다.

필요한 원소를 컴파일 타임에 알 수 있는 상수 집합이라면 항상 열거 타입을 사용하자.

 

-> 열거 타입에서 메서드 구현 방식으로는 상수별 메서드 구현, 전략 열거 타입 패턴이 있다.

 

열거 타입

열거 타입은 일정 개수의 상수 값을 정의한 다음, 그 외의 값은 허용하지 않는 타입이다. 예시로 사계절, 태양계의 행성, 카드 게임의 카드 종류 등이 있다.

 

정수 열거 패턴은 되도록이면 사용하지 말자.

왜냐하면 타입 안전을 보장할 수 있는 방법이 없고, 표현력도 좋지 않고, 코드가 깨지기 쉽기 때문이다.

public static final int APPLE_FUJI = 0;
public static final int ORANGE_NAVEL = 0;
  • 타입 안전 보장 못함
    • APPLE_을 사용하고 싶은데 실수로 ORANGE_를 사용하더라도 잘못 입력했단 걸 미리 확인할 수 없음
  • 표현력 좋지 않음
    • 이름으로 상수를 구분하면 값 출력이나 디버깅 시 숫자만 표현되어 해당 숫자가 어떤 것을 의미하는지 파악하기 어렵다. 위 코드를 예시로 0으로 확인되는 것의 값이 APPLE인지 ORANGE인지 알 수 있는 길이 없다
  • 코드 깨지기 쉬움
    • 상수 값 변경 시 관련 클라이언트도 재컴파일 해야하는 번거로움이 생길 수 있다.

 

열거 타입 사용하자.

public enum Apple { FUJI, PIPPIN, GRANNY_SMITH }
public enum Orange { NAVEL, TEMPLE, BLOOD }
  • 열거 타입 특징
    • 자바 열거 타입 자체는 클래스
    • 상수 하나당 자신의 인스턴스를 하나씩 만들어 public static final 필드로 설정
    • 열거 타입은 public 생성자를 제공하지 않음
    • 열거 타입으로 만들어진 인스턴스는 하나만 존재 → 인스턴스가 통제됨
      • 열거타입은 싱글턴을 일반화한 형태
      • 싱글턴은 원소가 하나뿐인 열거 타입
  • 열거 타입 장점
    • 컴파일타임 타입 안정성 제공
      • ex) Apple 타입이면 3가지 중 하나임을 보장, 아니면 컴파일 오류 발생
    • 새로운 상수를 추가하거나 순서를 바꿔도 재컴파일 하지 않아도 됨
    • 열거 타입 각자 이름공간이 있어 이름이 같은 상수도 평화롭게 공존 가능
    → 정수 열거 패턴의 단점을 해소 해준다.

 

데이터와 메서드를 갖는 열거 타입

각 상수와 연관된 데이터를 해당 상수 자체에 내재시키고 싶을 때 인스턴스 필드와 메서드를 이용하면 된다. 필드와 메서드의 접근 제어자는 필요한 경우가 아니라면 웬만해선 private, package-private(default)로 선언해야 한다.

public enum Planet {
    EARTH(123, 456);

    private final double mass;
    private final double radius;
    private final double surfaceGravity;

    private static final double G = 1;

    Planet(double mass, double radius) {
        this.mass = mass;
        this.radius = radius;
        surfaceGravity = G * mass / (radius * radius);
    }
    
    public double mass() { return mass; }
    public double radius() { return radius; }
    public double surfaceGravity() { return surfaceGravity; }

    public double surfaceWeight(double mass) { return mass * surfaceGravity; }
}

EARTH와 관련된 mass, radius 값은 변하지 않으므로 열거 타입에 상수로 저장해 놓으면 물체 무게(mass)만 입력하면 지구에서의 물체 무게를 바로 구할 수 있다.

for (Planet p : Planet.values()) {
    System.out.printf("%s에서의 무게는 %f이다.", p, p.surfaceWeight(10));
}

열거 타입을 이용하면 행성 관련 정보를 입력하지 않고 짧은 코드로 원하는 값을 출력할 수 있다. 또한 열거 타입에서 상수가 새로 추가되거나 제거되는 변경이 발생하더라도 컴파일 오류가 발생하기 때문에 안전하게 대응할 수 있다.

 

상수별 메서드 구현

상수별 메서드 구현은 열거 타입에서 apply라는 추상 메서드를 선언하고, 각 상수에 맞게 재정의하는 방법을 의미한다.

  • 상수별 메서드 구현을 권장하는 경우
    • 상수를 특정 데이터와 연관지어야 하는 경우
    • 상수별로 메서드를 실행해야 하는 경우
public enum Operation {
    PLUS  {public double apply(double x, double y) {return x + y;},
    MINUS {public double apply(double x, double y) {return x - y;},
    
    public abstract double apply(double x, double y);
}

위 코드처럼 작성하면 새로운 상수를 추가할 때 재정의 메서드를 빼먹지 않고 추가할 수 있을 것이다. 그래도 만약 재정이 메서드를 작성하지 않았다면 컴파일 오류로 알려주기 때문에 안전성을 보장한다.

  • 장점
    • 재정의 메서드 빼먹지 않을 수 있음
      • 상수 옆에 바로 재정의 메서드가 있어서 바로 확인이 가능하다
    • 컴파일 오류
      • 만약 메서드를 작성하지 않는다면 컴파일 오류로 타입 안정성을 보장할 수 있다
  • 단점
    • 열거 타입 상수끼리 코드 공유가 어렵다

 

근무시간 계산 - 열거 타입 상수끼리 코드 공유 어려운 경우

enum PayrollDay {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY

    private static final int MINS_PER_SHIFT = 8 * 60;
    
    int pay(int minutesWorked, int payRate) {
        int basePay = minuteWorked * payRate;

        int overtimePay;
        switch (this) {
            case SATURDAY: case SUNDAY:
                overtimePay = basePay / 2;
                break;
            default :
                overtimePay = minutesWorked <= MINS_PER_SHIFT ? 0 : (minutesWorked - MINS_PER_SHIFT) * payRate / 2;
        }
        
        return basePay + overtiePay;
}

열거 타입으로 비교적 간단한 코드이지만 이 코드에는 관리 관점에서 문제가 있다.

HOLIDAY 같은 새로운 값을 열거 타입에 추가하려면 HOLIDAY를 추가하고, 관련 로직을 switch()에 따로 추가해줘야 한다. 그렇지 않으면 휴일 비율이 아닌 평일 비율로 계산될 수 있기 때문이다.

이 문제를 해결하기 위해서는 전략 열거 타입 패턴을 이용해 코드를 작성하면 된다.

 

전략 열거 타입 패턴

  • 전략 열거 타입 패턴을 권장하는 경우
    • 열거 타입 상수 일부가 같은 동작을 공유하는 경우
enum PayrollDay {
    MONDAY(WEEKDAY), SATURDAY(WEEKEND), THANKSGIVINGDAY(HOLIDAY);

    private final PayType payType;
    
    PayrollDay(PayType payType) { this.payType = payType; }

    int pay(int minutesWorked, int payRate) {
        return payType.pay(minutesWorked, payRate)
    }

   // 전략 열거 타입
    enum PayType {
        WEEKDAY {
            int overtimePay(int minsWorked, int payRate) {
                return minsWorked <= MINS_PER_SHIFT ? 0 : (minsWorked - MINS_PER_SHIFT) * payRate / 2;
            }
        },
        WEEKEND {
            int overtimePay(int minsWorked, int payRate) {
                return minsWorked * payRate / 2;
            }
        };

        abstract int overtimePay(int mins, int payRate);
        private static final int MINS_PER_SHIFT = 8 * 60;

        int pay(int minsWorked, int payRate) {
            int basePay = minsWorked * payRate;
            return basePay + overtimePay(minsWorked, payRate);
        }
    }
}
  1. 잔업 수당 계산은 private 중첩 열거 타입인 PayType을 이용한다.
  2. PayrollDay 생성자에서 PayType을 선택해 관련 로직을 진행한다.

→ 이렇게 할 경우 코드는 좀 복잡해질 수 있지만, 더 안전하고 유연하게 코드를 작성할 수 있다.

+ Recent posts