[Java 21] (12) - modern Java features

Generics

 

지네릭스는 다양한 타입의 객체들을 다루는 메서드나 컬렉션 클래스에 컴파일 시의 타입 체크(compile-time type check)를 해주는 기능입니다. 

public int compare(Object o1, Object o2) {
    if(o1 instanceof Student && o2 instanceof Student) {
        Student s1 = (Student) o1;
        Student s2 = (Student) o2;
        return Integer.compare(s1.getTotal(), s2.getTotal());
    }
    return -1;
}

위 코드에서 compare()의 매개변수 타입이 Object인 이유는 모든 타입을 비교할 수 있게 하기 위해서입니다. 이렇게 하지 않으면 매개변수 타입이 다른 무수히 많은 버전의 compare()가 존재해야 합니다. 

 

이처럼 매개변수를 Object타입으로 하는 것은 코드의 중복을 제거할 수 있다는 장점이지만, 실제 타입 정보를 알 수 없기 때문에 컴파일러의 주요 기능인 타입 체크가 동작되지 않는다는 단점이 있습니다. 컴파일러가 타입을 체크할 수 없기 때문에 위 코드에서 처럼 'instanceof'로 실제 타입을 체크하고 형변환하는 일을 직접 해야합니다. 만일 Object타입 대신 실제 타입을 사용한다면 다음과 같이 훨씬 깔끔한 코드가 될 것입니다.

public int compare(Student s1, Student s2) {
    return Integer.compare(s1.getTotal(), s2.getTotal());
}

 

이처럼 지네릭스를 이용하면, 직접 코드를 추가하지 않아도 컴파일러가 미리 실행전에 체크해주므로 코드가 간단해지고, 실행시 발생할 수 있는 에러도 감소하여 안정성이 높아집니다.

지네릭은 컴파일 시점에만 존재하는 개념으로, 런타임에는 지네릭 정보가 제거된 원시 타입(raw type)만 남습니다.

 

지넥릭 클래스의 선언

 

지네릭 타입은 클래스와 메서드에 선언할 수 있는데, 먼저 클래스에 선언하는 지네릭 타입에 대해 알아보겠습니다. 예를 들어 Box클래스가 다음과 같이 정의되어 있다고 가정하겠습니다.

class Box {
    Object item;

    void setItem(Object item) { this.item = item; }
    Object getItem() { return item; }
}

이 클래스를 지네릭 클래스로 변경하면 다음과 같이 클래스 옆에 '<T>'를 붙이고 Object를 모두 T로 변경하면 됩니다.

class Box<T> {
    T item;

    void setItem(T item) { this.item = item; }
    T getItem() { return item; }
}

 

Box<T>에서 T를 '타입 변수(type variable)'라고 하며, Type의 첫 글자에서 따온 것입니다. 타입 변수는 T가 아닌 다른 것을 사용해도 됩니다. ArrayList<E>의 경우, 타입 변수 E는 Element(요소)에서 첫 글자를 따서 사용한 것입니다. 타입 변수가 여러 개인 경우에는 Map<K, V>와 같이 콤마(, )를 구분자로 나열하면 됩니다. 이 때 K는 Key, V는 Value를 의미합니다.

이들은 기호의 종류만 다를 뿐 '임의의 참조형 타입'을 의하한다는 것은 모두 같습니다.

 

이렇게 지네릭 클래스가 된 Box클래스의 객체를 생성할 때는 다음과 같이 참조변수와 생성자에 타입 T대신에 실제로 사용될 타입을 지정해주어야 합니다.

Box<String> b = new Box<String>();

 

아래와 같이 지네릭 타입을 지정하지 않고 객체를 생성하는 것도 허용되긴 하지만, 지네릭 타입을 지정하지 않아서 안전하지 않다는 경고가 발생하기 때문에 반드시 타입을 지정해서 사용하는 것이 좋습니다.

Box b = new Box();		// warn: Raw use of parameterized class

 

지네릭스의 용어

지네릭스에서 사용되는 용어들은 자칫 헷갈리기 쉽기 때문에 먼저 정리하고 넘어가겠습니다.

타입 문자 T는 지네릭 클래스 Box<T>의 타입 변수 또는 타입 매개변수라고 하는데, 이는 메서드의 매개변수와 유사한 면이 있기 때문입니다. 그리고 타입 매개변수 T에 지정된 실제 타입을 '매개변수화된 타입(parameterized type)'이라고 합니다.

 

지네릭스의 제약 사항

지네릭 클래스 Box의 객체를 생성할 때, 다음과 같이 객체별로 다른 타입을 지정하는 것은 적절합니다. 

Box<Apple> appleBox = new Box<Apple>();
Box<Grape> grapeBox = new Box<Grape>();

 

그러나 모든 객체에 대해 동일하게 동작해야하는 static멤버에 타입 변수 T를 사용할 수는 없습니다. T는 인스턴스변수로 간주되기 때문입니다.

class Box<T> {
    static T item;	  // Error

    static int compare(T t1, T t2) {..}		// Error
}

static멤버는 타입 변수에 지정된 타입, 즉 대입된 타입의 종류와 관계없이 동일한 것이어야 하기 때문입니다.

 

그리고 지네릭 타입의 배열을 생성하는 것도 허용되지 않습니다. 지네릭 배열 타입의 참조 변수를 선언하는 것은 가능하지만, 'new T[10]'과 같이 배열을 생성하는 것은 안됩니다.

class Box<T> {
    T[] itemArr;   // 가능

    T[] toArray() {
        T[] tmpArr = new T[itemArr.length];	// Error

        return tmpArr;
    }
}

지네릭 배열을 생성할 수 없는 것은 new연산자 때문인데, 이 연산자는 컴파일 시점에 타입 T가 뭔지 정확히 알아야 합니다. 그런데 위 코드에 정의된 Box<T>클래스는 컴파일 시점에 T가 어떤 타입이 될지 알 수가 없습니다. 같은 이유로 instanceof연산자도 T를 피연산자로 사용할 수 없습니다.

 

만약 꼭 지네릭 배열을 생성해야할 필요가 있을 때는, new연산자 대신 'Reflection API'의 newInstance()와 같이 동적으로 객체를 생성하는 메서드로 배열을 생성하거나, Object배열을 생성해서 복사한 다음에 T로 형변환하는 방법을 사용해야 합니다.

 

지네릭 클래스의 객체 생성과 사용

 

지네릭 클래스의 객체 생성 시 양변의 제네릭 타입을 일치시켜줘야 합니다.

Box<Apple> appleBox = new Box<Apple>();		// 가능
Box<Apple> appleBox = new Box<Grape>();		// Error

 

이는 두 타입이 상속관계에 있어도 마찬가지입니다.

Box<Fruit> appleBox = new Box<Apple>();		// Error

Apple이 Fruit의 하위 타입이라도, Box<Apple>은 Box<Fruit>의 하위 타입이 아니기 때문입니다.

 

JDK 7부터는 추정이 가능한 경우 타입을 생략할 수 있게 되었습니다. 좌변의 타입으로부터 타입 추론(type inference)이 가능하기 때문에 우변에 반복해서 타입을 지정해주지 않아도 되는 것입니다.

Box<Apple> appleBox = new Box<>();		// 가능

 

제한된 지네릭 클래스

 

타입 문자로 사용할 타입을 명시하면 한 종류의 타입만 저장할 수 있도록 제한할 수 있지만, 그래도 여전히 모든 종류의 타입을 지정할 수 있다는 것에는 변함이 없습니다. 다음과 같이 지네릭 타입에 'extends'를 사용하면, 특정 타입의 자손들만 대입할 수 있게 제한할 수 있습니다.

class FruitBox<T extends Fruit> {
    ...
}

여전히 한 종류의 타입만 담을 수 있지만, Fruit클래스의 자손들만 담을 수 있다는 제한이 더 추가된 것입니다.

FruitBox<Apple> appleBox = new FruitBox<>();	// 가능
FruitBox<Toy> toyBox = new FruitBox<>();	// Error

 

만일 클래스가 아니라 인터페이스를 구현해야 한다는 제약이 필요하다면, 이때도 'extends'를 사용합니다. 'implement'를 사용하지 않는 다는 점을 주의해야 합니다.

interface Eatable { }

class FruitBox<T extends Eatable> {...}

 

2개 이상의 상속(또는 구현) 조건이 필요하다면 '&' 기호로 연결합니다.

class FruitBox<T extends Fruit & Eatable> {...}

 

와일드 카드

 

앞에서 살펴본 바와 같이 객체를 생성할 때 지네릭 타입이 서로 상속 관계에 있더라도 반드시 좌변과 우변의 타입이 같아야 합니다.

FruitBox<Fruit> box = new FruitBox<Apple>();	// Error

 

다형성에 비해 이러한 제약이 너무 빡빡하다고 느껴질 수 있는데, 지네릭스에서도 '와일드 카드'를 통해서 다형성과 같은 유연성을 제공합니다.

  • <? extends T> : 와일드 카드의 상한 제한 (T와 그 자손들만 가능)
  • <? super T> : 와일드 카드의 하한 제한 (T와 그 조상들만 가능)
  • <?> : 제한 없음 (모든 타입 가능) == <? extends Object>

이러한 와일드 카드를 이용하면, 하나의 참조 변수 타입으로 여러 객체를 다룰 수 있습니다. 단, 와일드 카드는 참조 변수, 즉 좌변에만 사용할 수 있습니다. 우변에서 객체를 생성할 때는 단 하나의 타입이 명확히 지정되어야 하기 때문입니다.

FruitBox<? extends Fruit> box = new FruitBox<Fruit>();	// 가능
FruitBox<? extends Fruit> box = new FruitBox<Apple>();	// 가능
FruitBox<? extends Fruit> box = new FruitBox<Grape>();	// 가능

 

 

다음 두 개의 메서드를 오버로딩으로 정의하면, 컴파일 에러가 발생합니다.

static Juice makeJuice(FruitBox<Fruit> box) {..}	// Error

static Juice makeJuice(FruitBox<Apple> box) {..}	// Error

 

대입된 지네릭 타입이 다른 것 만으로는 오버로딩이 성립하지 않기 때문입니다. 지네릭 타입은 컴파일러가 컴파일할 때만 사용하고 제거해버립니다. 그래서 위의 두 메서드는 오버로딩이 아니라 '메서드 중복 정의'가 됩니다. 이럴 때 바로 와일드 카드를 사용하면 좋습니다.

static Juice makeJuice(FruitBox<? extends Fruit> box) {..}

 

이제 FruitBox<Fruit>뿐만 아니라, FruitBox<Apple>와 FruitBox<Grape>도 가능하게 됩니다.

 

지네릭 메서드

 

메서드의 선언부에 지네릭 타입이 선언된 메서드를 지네릭 메서드라 합니다. 지네틱 타입의 선언 위치는 반환 타입 바로 앞입니다.

다음은 지네릭 메서드의 예시인 Collections.sort() 입니다.

static <T> void sort(List<T> list, Comparator<? super T>)

지네릭 클래스에 정의된 타입 매개변수와 지네릭 메서드에 정의된 타입 매개변수는 전혀 별개의 것입니다. 같은 타입 문자 T를 사용해도 같은 것이 아니라는 것에 주의해야 합니다.

 

또한 위 메서드가 static메서드라는 것에 주목해야 합니다. 앞서 설명한 것처럼, static멤버에는 타입 매개변수를 사용할 수 없지만, 이처럼 메서드에 지네릭 타입을 선언하고 사용하는 것은 가능합니다.

 

메서드에 선언된 지네릭 타입은 지역 변수를 선언한 것과 같다고 생각하면 이해하기 쉽습니다. 이 타입 매개변수는 메서드 내에서만 지역적으로 사용될 것으므로 메서드가 static이건 아니건 상관이 없습니다.

 

위에서 정의했던 makeJuice 메서드를 지네릭 메서드로 변경해보겠습니다.

 

이제 이 메서드를 호출할 때는 아래와 같이 타입 변수에 타입을 대입해야 합니다.

FruitBox<Fruit> fruitBox = new FruitBox<>();

Juicer.<Fruit>makeJuice(fruitBox);

 

그러나 대부분의 경우 컴파일러가 타입을 추론할 수 있기 때문에 생략 가능합니다.

Juicer.makeJuice(fruitBox);		// 지네릭 타입 추론

 

 

지네릭 타입의 형변환

 

다음 코드는 지네릭 타입과 원시 타입(raw type)간의 형변환입니다.

Box box = null;
Box<Object> objBox = null;

box = (Box) objBox;			// 가능
objBox = (Box<Object>) box;		// 가능

위 코드에서처럼 지네릭 타입과 원시 타입간의 형변환은 항상 가능합니다. 다만 경고가 발생할 뿐입니다.

 

다음 코드는 서로 다른 지네릭 타입간의 형변환입니다.

Box<Object> objBox = null;
Box<String> strBox = null;

objBox = (Box<Object>) strBox;		// Error
strBox = (Box<String>) objBox;		// Error

위 코드는 불가능합니다. 대입된 타입이 모든 클래스의 조상인 Object라고 하더라도, 서로 다른 지네릭 타입간의 형변환은 불가능합니다.

 

하지만 와일드 카드가 포함된 지네릭을 사용하면 형변환이 가능합니다. 대신 이미 타입이 호환되기 때문에 불필요한(redundant) 캐스팅이라는 경고가 발생합니다.

Box<String> strBox = null;
Box<? extends Object> objBox = null;

objBox = (Box<? extends Object>) strBox;	// Casting is redundant

 

지네릭 타입의 제거

 

컴파일러는 지네릭 타입을 이용해서 소스파일을 체크하고, 필요한 곳에 형변환을 넣어줍니다. 그리고 지네릭 타입을 제거합니다. 즉, 컴파일된 파일(*.class)에는 지네릭 타입에 대한 정보가 없는 것입니다.

 

이렇게 하는 주된 이유는 지네릭이 도입되기 이전의 소스 코드와의 호환성을 유지하기 위해서입니다. JDK 8부터 지네릭스가 도입되었지만, 아직도 원시 타입을 사용해서 코드를 작성하는 것을 허용합니다. 그러나 앞으로 가능하면 원시 타입을 사용하지 않는 것이 좋습니다. 언젠가는 분명히 새로운 기능을 위해 하위 호환성을 포기하게 될 때가 올 것이기 때문입니다.

 

코드에서 지네릭 타입의 제거 과정은 복잡하지만, 기본적으로는 다음과 같습니다.

 

    1. 지네릭 타입의 경계(bound)를 제거합니다.

 

<T extends Fruit> 라면 T는 Fruit로 치환됩니다. <T>인 경우에는 <T extends Object>와 동일하므로 T는 Object로 치환됩니다.

 

    2. 지네릭 타입을 제거 후 타입이 일치하지 않으면 형변환을 추가합니다.

 

 

열거형 (enum)

 

열거형은 서로 관련된 상수를 편리하게 선언하기 위한 것으로 여러 상수를 정의할 때 사용하면 유용합니다. 원래 자바는 열거형이라는 것이 존재하지 않았으나 JDK 5부터 새로 추가되었습니다. 자바의 열거형은 C언어의 열거형보다 더 향상된 것으로 열거형이 갖는 값뿐만아니라 타입도 체크하기 때문에 논리적인 오류를 더 줄일 수 있습니다.

class Card {
    enum Kind { CLOVER, HEART, DIAMOND, SPADE }
    enum Value { TWO, THREE, FOUR }

    Kind kind;	
    Value value;
}
if(Card.Kind.CLOVER == Card.Value.TWO)	// Compile error

위 코드에서 CLOVER와 TWO는 둘 다 0이라는 값을 갖지만 열거형 타입이 다르기 때문에 컴파일 에러가 발생합니다.

 

열거형의 정의와 사용

 

열거형을 정의하는 방법은 간단합니다. 다음과 같이 괄호{ } 안에 상수의 이름을 나열하기만 하면 됩니다.

enum 열거형이름 ( 상수명1, 상수명2, 상수명3, ... }

 

예를 들어 열거형 Direction을 정의해보겠습니다.

enum Direction { EAST, SOUTH, WEST, NORTH }

이 열거형에 정의된 상수를 사용하는 방법은 클래스의 static변수를 참조하는 것과 동일하게 '열거형이름.상수명' 입니다.

 

열거형 상수간의 비교에는 '=='를 사용할 수 있습니다. equals()가 아닌 '=='로 비교가 가능하다는 것은 그만큼 빠른 성능을 제공한다는 얘기입니다. 그러나 '<', '>' 와 같은 비교연산자는 사용할 수 없고 compareTo()는 사용 가능합니다.

 

그리고 다음과 같이 switch문의 조건식에도 열거형을 사용할 수 있습니다.

void move() {
    switch(dir) {
        case EAST -> x++;	// Direction.EAST 불가능 -> JDK 21부터 허용
        case WEST -> x--;
        case SOUTH -> y++;
        case NORTH -> y--;
    }
}

 

 

이때 두 가지 주의할 점이 있습니다.

  1. case문에는 열거형 이름을 붙이지 않습니다. (JDK 21부터 허용)
  2. case문은 열거형의 모든 상수를 처리해야 합니다. 만약 누락된 경우 default문을 추가해야 합니다.

 

java.lang.Enum

 

자바의 모든 열거형은 java.lang.Enum클래스를 상속받습니다. 그래서 열거형은 다른 클래스를 상속받을 수 없습니다.

 

다음은 Enum클래스의 소스코드 일부입니다.

public abstract class Enum<E extends Enum<E>> implements Comparable<E>, ... {
    private final String name;
    private final int ordinal;
    
    protected Enum(String name, int ordinal) {
        this.name = name;
        this.ordinal = ordinal;
    }
    
    public final int compareTo(E o) {
        ...
        return self.ordinal - other.ordinal;
    }
    ...
}
메서드 설  명
Class<E> getDeclaringClass() 열거형의 Class객체를 반환합니다.
String name() 열거형 상수의 이름을 문자열로 반환합니다.
int ordinal() 열거형 상수가 정의된 순서를 반환합니다. (0부터 시작)
T valueOf(Class<T> enumType, String name) 지정된 열거형에서 name과 일치하는 열거형 상수를 반환합니다.
Optional<EnumDesc<E>> describeConstable() Enum의 설명자(EnumDesc)가 담긴 Optional을 반환합니다. (JDK 12부터)

 

 

위에서 정의한 열거형은 컴파일러가 컴파일 시점에 다음과 같이 Enum클래스를 상속하는 일반 클래스 형태로 변환합니다.

public final class Direction extends Enum<Direction> {
    public static final Fruit EAST  = new Fruit("EAST", 0);
    public static final Fruit SOUTH = new Fruit("SOUTH", 1);
    public static final Fruit WEST = new Fruit("WEST", 2);
    public static final Fruit NORTH = new Fruit("NORTH", 3);
    
    private Direction(String name, int ordinal) {
    	super(name, ordinal);
    }
    
    public static Direction values() {..}		// 컴파일러가 자동 추가
    public static Direction valueOf(String name) {..}	// 컴파일러가 자동 추가
}

이때 컴파일러가 자동으로 모든 열거형 상수가 담긴 배열을 반환하는 values(), 열거형 상수의 이름으로 객체를 반환하는 valueOf() 메서드를 추가합니다. 또한 클래스에 final 키워드를 붙여줍니다. 따라서 열거형(enum)은 묵시적으로 final 입니다.

열거형(enum)의 각 상수는 해당 enum 클래스 내부에 상수로 선언된, 포함 관계에 있는 인스턴스로 볼 수 있습니다.
- 열거형 상수가 static상수이기 때문에 '=='로 객체 주소 비교가 가능합니다.

 

열거형에 멤버 추가하기

 

Enum클래스에 정의된 ordinal()이 열거형 상수가 정의된 순서를 반환하지만, 이 값(ordinal)을 열거형 상수의 값으로 사용하지 않는 것이 좋습니다. 이 값은 내부적인 용도로만 사용되기 위한 것이기 때문입니다.

 

열거형 상수의 값은 다음과 같이 열거형 상수의 이름 옆에 원하는 값을 괄호( )와 함께 적어주면 됩니다.

enum Direction { EAST(1), SOUTH(3), WEST(4), NORTH(-1) }

그리고 지정된 값을 저장할 수 있는 인스턴스 변수와 생성자를 새로 추가해 주어야 합니다.

 

이때 주의할 점은 다음과 같습니다.

  1. 열거형 상수를 모두 정의한 다음에 다른 멤버들을 추가해야 합니다.
  2. 열거형 상수의 마지막에 세미콜론(;)을 반드시 붙여야 합니다.
enum Direction {
    EAST(1), SOUTH(3), WEST(4), NORTH(-1);	// 끝에 세미콜론(;) 필수

    private final int value;

    Direction(int value) { this.value = value; }

    public int getValue() { return value; }
}

위 코드에서 열거형 Direction에 생성자를 추가했지만, 이는 묵시적으로 private 생성자이기 때문에 외부에서 생성자를 통해 객체를 생성할 수는 없습니다.

 

열거형에 추상 메서드 추가하기

열거형에도 추상 메서드를 추가할 수 있으며, 이 경우 각 열거형 상수가 이 추상 메서드를 반드시 구현해야 합니다.

 

다음은 추상 메서드를 추가한 열거형 예시 코드입니다.

enum Transportation {
    BUS(100) { @Override int fare(int distance) { return distance * BASIC_FARE; } },
    TRAIN(150) { @Override int fare(int distance) { return distance * BASIC_FARE; } },
    SHIP(100) { @Override int fare(int distance) { return distance * BASIC_FARE; } },
    AIRPLANE(300) { @Override int fare(int distance) { return distance * BASIC_FARE; } };

    abstract int fare(int distance);

    private final int BASIC_FARE;

    Transportation(int basicFare) {
        BASIC_FARE = basicFare;
    }

    public int getBasicFare() { return BASIC_FARE; }
}
열거형의 각 상수는 포함관계에 있는 인스턴스이기 때문에 열거형 클래스의 private 멤버도 접근할 수 있습니다.

 

 

 

애너테이션 (annotaion)

 

자바를 개발한 사람들은 소스 코드에 대한 문서를 따로 만들기보다 소스 코드와 문서를 하나의 파일로 관리하는 것이 낫다고 생각했습니다. 그래서 소스코드의 주석 /** ~ */ 에 소스 코드에 대한 정보를 저장하고, 소스 코드의 주석으로부터 HTML문서를 생성해내는 프로그램(javadoc)을 만들어 사용했습니다. 다음은 모든 애너테이션의 조상인 Annotation인터페이스의 소스 코드의 일부입니다.

/**
 * The common interface extended by all annotation interfaces.  Note that an
 * interface that manually extends this one does <i>not</i> define
 * an annotation interface.  Also note that this interface does not itself
 * define an annotation interface.
 *
 * More information about annotation interfaces can be found in section
 * {@jls 9.6} of <cite>The Java Language Specification</cite>.
 *
 * The {@link java.lang.reflect.AnnotatedElement} interface discusses
 * compatibility concerns when evolving an annotation interface from being
 * non-repeatable to being repeatable.
 *
 * @author  Josh Bloch
 * @since   1.5
 */
public interface Annotation { ... }

/** 로 시작하는 주석 안에 소스 코드에 대한 설명들이 있고, 그 안에 @이 붙은 태그들이 보입니다. 미리 정의된 태그들을 이용해서 주석 안에 정보를 저장하고, javadoc이라는 프로그램이 이 정보를 읽어서 문서를 작성하는데 사용합니다.

 

이 기능을 응용하여, 프로그램의 소스 코드 안에 다른 프로그램을 위한 정보를 미리 약속된 형식으로 포함시킨 것이 바로 애너테이션입니다. 애너테이션은 주석(comment)처럼 프로그래밍 언어에 영향을 미치지 않으면서도 다른 프로그램에게 유용한 정보를 제공할 수 있다는 장점이 있습니다.

 

예를 들어 @Test 라는 애너테이션을 메서드 앞에 붙이면 해당 메서드를 테스트해야 한다는 것을 테스트 프로그램에게 알리는 것입니다. 이는 메서드가 포함된 프로그램 자체에는 아무런 영향을 미치지 않으며, 주석처럼 존재하지 않는 것이나 다름없습니다. @Test는 테스트 프로그램에서 미리 정의된 종류와 형식으로 작성된 것이기 때문에, 테스트 프로그램을 제외한 다른 프로그램에게는 아무런 의미가 없는 정보일 것입니다.

 

애너테이션은 Java에서 기본적으로 제공하는 것과 다른 프로그램에서 제공하는 것들이 있는데, 어느 것이든 그저 약속된 형식으로 정보를 제공하기만 하면 될 뿐입니다. Java에서 제공하는 표준 애너테이션은 주로 컴파일러를 위한 것으로 컴파일러에게 유용한 정보를 제공합니다. 그리고 새로운 애너테이션을 정의할 때 사용하는 메타 애너테이션도 제공합니다.

 

표준 애너테이션

 

자바에서 기본적으로 제공하는 애너테이션들은 몇 개 없습니다. 그나마 이들의 일부는 '메타 애너테이션(meta annotaion)'으로 애너테이션을 정의하는데 사용되는 애너테이션의 애너테이션입니다.

애너테이션 설  명
@Override 컴파일러에게 오버라이딩하는 메서드라는 것을 알립니다.
@Deprecated 앞으로 사용하지 않을 것을 권장하는 대상에게 붙입니다.
@SuppressWarnings 컴파일러의 특정 경고메시지가 나타나지 않게 해줍니다.
@SafeVarargs 지네릭스 타입의 가변인자에 사용합니다. (JDK 7)
@FunctionalInterface 함수형 인터페이스라는 것을 알립니다. (JDK 8)
@Native native메서드에서 참조되는 상수 앞에 붙입니다. (JDK 8)
@Target 애너테이션이 적용가능한 대상을 지정하는데 사용합니다.
@Documented 애너테이션 정보가 javadoc으로 작성된 문서에 포함되게 합니다.
@Inherited 애너테이션이 자손 클래스에서 상속되도록 합니다.
@Retention 애너테이션이 유지되는 범위를 지정하는데 사용합니다.
@Repeatable 애너테이션을 반복해서 적용할 수 있도록 합니다. (JDK 8)

 

@Override

메서드 앞에만 붙일 수 있는 애너테이션으로, 조상의 메서드를 오버라이딩하는 것이라는 걸 컴파일러에게 알려주는 역할을 합니다. 오버라이딩을 할 때 메서드의 이름을 잘못 적는 경우가 많은데, 이것을 컴파일러는 그저 새로운 이름의 메서드가 추가된 것으로 인식할 뿐입니다. 

그러나 @Override 애너테이션을 붙이면, 컴파일러가 같은 이름의 메서드가 조상에 있는지 확인하고 없으면, 에러메시지를 출력합니다.

 

오버라이딩할 때 메서드 앞에 @Override를 붙이는 것이 필수는 아니지만, 알아내기 어려운 실수를 미연에 방지해주므로 반드시 붙이는 것이 좋습니다.

 

@Deprecated

새로운 버전의 Java가 소개될 때, 새로운 기능이 추가될 뿐만 아니라 기존의 부족했던 기능들을 개선하기도 합니다. 이 과정에서 기존의 기능들을 대체할 것들이 추가되어도, 이미 여러 곳에서 사용되고 있을지 모르는 것들을 함부로 삭제할 수는 없습니다.

 

그래서 생각해낸 방법이 더 이상 사용되지 않는 필드나 메서드에 @Deprecated를 붙이는 것입니다. 이는 다른 것으로 대체되었으니 더 이상 사용하지 않는 것을 권장한다는 의미입니다.

 

만일 @Deprecated가 붙은 대상을 사용하는 코드를 작성하면, 컴파일 시에 아래와 같은 메시지가 나타납니다.

Note: xxx.java uses or overrides a deprecated API.
Note: Recompile with -Xlint:deprecation for defails.

이는 해당 소스파일이 'deprecated'된 대상을 사용하고 있으며, '-Xlint:deprecation' 옵션을 붙여서 다시 컴파일하면 자세한 내용을 알 수 있다는 뜻입니다.

 

@Deprecated가 붙은 대상을 사용하지 않도록 권장할 뿐 강제성은 없기 때문에, 메시지가 나오긴 하지만 컴파일도 실행도 문제 없습니다.

 

@FunctionalInterface

함수형 인터페이스를 선언할 때, 이 애너테이션을 붙이면 컴파일러가 함수형 인터페이스를 올바르게 선언했는지 확인하고, 잘못된 경우 에러를 발생시킵니다. 이 또한 필수는 아니지만, 실수를 방지할 수 있기 때문에 반드시 붙이는 것이 좋습니다.

 

@SuppressWarnings

컴파일러가 보여주는 경고메시지가 나타나지 않게 해줍니다. 이전 예제에서처럼 컴파일러의 경고메시지는 무시하고 넘어갈 수도 있지만, 모두 확인하고 해결해서 컴파일 후에 어떠한 메시지도 나타나지 않게 해야합니다.

 

그러나 경우에 따라서는 경고가 발생할 것을 알면서도 묵인해야 할 때가 있는데, 이 경고를 그대로 놔두면 컴파일할 때마다 메시지가 나타납니다. 이때 묵인해야하는 경고가 발생하는 대상에 @SuppressWarnings를 붙여서 컴파일 후에 어떤 경고 메시지도 나타나지 않게 해야합니다.

 

@SuppressWarnings로 억제할 수 있는 경고 메시지의 종류는 여러 가지가 있는데, Java의 버전이 올라가면서 앞으로도 계속 추가될 것입니다. 이 중에서 주로 사용되는 것은 'deprecation', 'unchecked', 'rawtypes', 'varargs' 정도입니다.

'deprecation'은 앞서 살펴본 것과 같이 @Deprecated가 붙은 대상을 사용해서 발생하는 경고를, 'unchecked'는 지네릭스로 타입을 지정하지 않았을 때 발생하는 경고를, 'rawtypes'는 지네릭스를 사용하지 않아서 발생하는 경고를, 그리고 'varargs'는 가변인자의 타입이 지네릭 타입일 때 발생하는 경고를 억제할 때 사용합니다.

@SuppressWarings("unchecked")
ArrayList list = new ArrayList();
list.add(obj);

 

만일 둘 이상의 경고를 동시에 억제하려면 다음과 같이 합니다.

@SuppressWarnings({"deprecation", "unchecked", "varargs"})

 

@SuppressWarnings로 억제할 수 있는 경고 메시지의 종류는 앞으로 계속 추가될 것이기 때문에, 이전 버전에서는 발생하지 않던 경고가 새로운 버전에서는 발생할 수 있습니다. 발생한 경고 메시지의 종류를 알기 위해서는 '-Xlint' 옵션으로 컴파일해서 나타나는 경고의 내용 중 대괄호[ ] 안에 있는 것이 바로 메시지의 종류입니다.

@SuppressedWarnings은 최대한 해당 대상에 직접 붙여서 경고의 억제범위를 최소화하는 것이 좋습니다.

 

@SafeVarargs

메서드에 선언된 가변인자의 타입이 non-reifiable타입일 경우, 해당 메서드를 선언하는 부분과 호출하는 부분에서 'unchecked' 경고가 발생합니다. 해당 코드에 문제가 없다면 이 경고를 억제하기 위해 @SafeVarargs를 사용해야 합니다.

 

이 애너테이션은 static이나 final이 붙은 메서드와 생성자에만 붙일 수 있습니다. 즉, 오버라이드될 수 있는 메서드에는 사용할 수 없다는 뜻입니다. 컴파일 후에도 제거되지 않는 타입을 reifiable타입이라고 하고, 제거되는 타입을 non-reifiable타입이라고 합니다. 지네릭 타입들은 대부분 컴파일 시에 제거되므로 non-reifiable타입입니다.

 

예를 들어, java.util.ArraysasList()는 다음과 같이 정의되어 있습니다.

@SafeVarargs
@SuppressWarnings("varargs")
public static <T> List<T> asList(T... a) {
    return new ArrayList<>(a);
}

 

이 메서드는 매개변수로 넘겨받은 값들로 새로운 ArrayList객체를 만들어서 반환하는데 이 과정에서 varargs 경고가 발생합니다.

 

가변인자(varargs)는 런타임에 힙 메모리에 실제 배열을 만들어 인자로 전달되는데, 지네릭 타입은 컴파일 시점에 소거되기 때문에 런타임에는 해당 배열의 타입 안정성을 보장할 수 없습니다. 만약 이 배열을 외부에서 잘못된 타입으로 참조하거나 형변환을 하면 런타임 에러(ClassCastException)가 발생할 수 있습니다. 이러한 상황을 힙 오염(heap pollution)이라고 합니다.

 

위 코드에서  새로운 ArrayList객체를 만들 때, ArrayList의 배열을 인자로 받는 생성자를 호출하고 있는데, 실제 ArrayList클래스에는 그런 생성자가 없습니다. 이는 java.util.Arrays에 정의된 별도의 내부 정적 클래스입니다.

private static class ArrayList<E> extends AbstractList<E> {
    private final E[] a;

    ArrayList(E[] array) {
        a = Objects.requireNonNull(array);
    }
    ...
}

이 내부 정적 클래스로 정의된 ArrayList를 보면 생성자의 인자로 받은 배열을 private final로 선언한 인스턴스 변수에 저장하기 때문에 외부에서 이 배열을 직접 접근할 수 없습니다. 따라서 힙 오염(heap pollution)이 발생할 위험이 없으므로 경고를 억제해도 안전합니다.

 

실제로 저런 메서드를 정의하고 사용하는 곳에 @SafeVarargs를 붙여도 경고가 해결되지 않고, 'varargs' 경고가 발생합니다. @SafeVarargs는 'unchecked' 경고는 억제할 수 있지만, 'varargs' 경고는 억제할 수 없기 때문에 일반적으로 @SafeVarargs와  @SuppressWarnings("varargs")를 같이 붙입니다.

만약 @SafeVarargs 대신에 @SuppressWarnings("unchecked")를 사용하여 경고를 억제하려고 하면, 메서드를 정의한 곳과 호출한 곳 모두 애너테이션을 붙어주어야 합니다.

 

 

메타 애너테이션

 

앞서 설명한 것과 같이 메타 애너테이션은 '애너테이션을 위한 애너테이션', 즉, 애너테이션에 붙이는 애너테이션으로 애너테이션을 정의할 때 애너테이션의 적용대상(target)이나 유지기간(retention)등을 지정하는데 사용됩니다.

 

@Target

애너테이션이 적용가능한 대상을 지정하는데 사용됩니다. 아래는 @SuppressWarnings 애너테이션의 정의인데, 이 애너테이션에 적용할 수 있는 대상을 @Target으로 지정하였습니다.

@Target({TYPE, FIELD, METHOD, PARAMETER, CONSTRUCTOR, LOCAL_VARIABLE, MODULE})
@Retention(RetentionPolicy.SOURCE)
public @interface SuppressWarnings {
    String[] value();
}

 

@Target으로 지정할 수 있는 애너테이션 적용대상의 종류는 아래와 같습니다.

대상 타입 의미
ANNOTATION_TYPE 애너테이션
CONSTRUCTOR 생성자
FIELD 필드(멤버변수, enum 상수)
LOCAL_VARIABLE 지역변수
METHOD 메서드
MODULE 모듈 (JDK 9)
PACKAGE 패키지
PARAMETER 매개변수
RECORD_COMPONENT 레코드 컴포넌트 (JDK 21)
TYPE 타입(클래스, 인터페이스, enum)
TYPE_PARAMETER 타입 매개변수 (JDK 8)
TYPE_USE 타입이 사용되는 모든 곳 (JDK 8)

 

TYPE  vs  TYPE_USE  vs  FIELD

 - TYPE : 타입 선언부

 - TYPE_USE : 타입이 사용되는 곳

 - FIELD: 필드 선언 그 자체

// TYPE
@TypeAnno
public class MyClass { }


// TYPE_USE
class Example {
    private @TypeUseAnno String name;         
    void method(@TypeUseAnno String param) {}
    List<@TypeUseAnno String> list;
}


// FIELD
class Example {
    @FieldAnno
    private String name;
}

 

@Retention

애너테이션이 유지(retention)되는 기간을 지정하는데 사용됩니다. 애너테이션 유지 정책(retention policy)의 종류를 다음과 같습니다.

유지 정책 의미
SOURCE 소스 파일에만 존재. 클래스파일에는 존재 X
CLASS 소스 + 클래스 파일에 존재. 런타임에서 JVM에 로드 X (기본값)
RUNTIME 소스 + 클래스 파일에 존재. 런타임에서 JVM에 로드

 

@Override나 @SuppressWarnings처럼 컴파일러가 사용하는 애너테이션은 유지 정책이 SOURCE인데, 컴파일러를 직접 작성할 것이 아니라면, 이 유지 정책은 필요 없습니다.

 

유지 정책 CLASS는 컴파일러가 애너테이션의 정보를 클래스 파일에 저장할 수 있게는 하지만, 클래스 파일이 JVM에 로딩될 때 애너테이션 정보가 무시되어 실행 시에 애너테이션 정보를 얻을 수 없습니다. 그래서 CLASS는 기본 정책임에도 잘 사용되지 않습니다.

 

마지막으로 유지 정책을 RUNTIME으로 하면, 실행 시에 JVM에 로드되기 때문에 '리플렉션(Reflection)'을 통해 클래스 파일에 저장된 애너테이션의 정보를 읽어서 처리할 수 있습니다.

 

@Documented

애너테이션에 대한 정보가 javadoc으로 작성한 문서에 포함되도록 합니다. 자바에서 제공하는 기본 애너테이션 중에 @Override와 @SuppressWarnings를 제외하고는 모두 이 메타 애너테이션이 붙어 있습니다.

 

@Inherited

애너테이션이 자손 클래스에 상속되도록 합니다. @Inherited가 붙은 애너테이션을 조상 클래스에 붙이면, 자손 클래스에도 해당 애너테이션이 붙은 것과 같이 인식됩니다.

 

@Repeatable

JDK 8 이전까지는 자바 문법 상 중복 애너테이션은 컴파일 에러가 발생했습니다. 그러나 Java 8에서 @Repeatable 애너테이션이 추가하면서, 여러 개의 중복 애너테이션을 내부적으로 하나의 컨테이너 애너테이션으로 묶는 방식으로 확장했습니다.

@interface ToDos {	// 컨테이너 애너테이션
    ToDo[] value();
}

@Repeatable(ToDos.class)
@interface Todo {
    String value();
}
@ToDo("delete test codes.")
@ToDo("override inherited methods")
class MyClass { ... }

 

위와 같이 사용하면 컴파일 시 자동으로 아래와 같이 배열로 감싸서 하나의 애너테이션으로 포장됩니다.

@ToDos({@ToDo("delete test codes."), @ToDo("override inherited methods")})

 

@Native

네이티브 메서드(native method)에 의해 참조되는 '상수 필드(constant field)'에 붙이는 애너테이션입니다. 아래는 java.lang.Long클래스에 정의된 상수입니다.

@Native public static final long MIN_VALUE = 0x8000000000000000L;
@Native public static final long MAX_VALUE = 0x7fffffffffffffffL;

 

네이티브 메서드란 JVM이 설치된 OS의 메서드를 말합니다. 네이티브 메서드는 보통 C언어로 작성되어 있는데, 자바에서는 메서드의 선언부만 정의하고 구현은 하지 않습니다. 그래서 추상 메서드처럼 선언부만 있고 몸통이 없습니다.

public class Object {
    public final native Class<?> getClass();
    public native int hashCode();
    ...
}

실제로 모든 클래스의 조상인 Object클래스의 메서드들은 대부분 네이티브 메서드입니다.

 

이러한 네이티브 메서드는 자바로 정의되어 있기 때문에 호출하는 방법은 자바의 일반 메서드와 다르지 않지만 실제로 호출되는 것은 OS의 메서드입니다. 물론, 아무런 내용도 없는 네이티브 메서드를 선언해 놓고 호출한다고 동작하는 것은 아니고, 네이티브 메서드와 OS의 메서드를 연결해주는 작업이 추가로 필요합니다. 이 역할은 바로 JVM 내부에 포함된 JNI(Java Native Interface)가 합니다.

 

애너테이션 타입 정의하기

 

새로운 애너테이션을 정의하는 방법은 다음과 같습니다. @ 기호를 붙이는 것을 제외하면 인터페이스를 정의하는 것과 동일합니다.

@interface 애너테이션명 {
    타입 요소이름();
    ...
}

엄밀히 말하자면 @Override는 애너테이션이고, Override는 애너테이션의 타입입니다.

 

애너테이션의 요소

애너테이션 내에 선언된 메서드를 '애너테이션의 요소(element)'라고 합니다.

애너테이션의 요소는 반환값이 있고 매개변수는 없는 추상 메서드의 형태를 가지며, 상속을 통해 구현하지 않아도 됩니다.

@interface TestInfo {
    int count() default 1;
    String testedBy();
    String[] testTools default {"JUnit", "Auto"};
    TestType testType();
    DateTime testDate();
}

 

 다만, 애너테이션을 적용할 때 요소의 기본값이 없다면, 이 요소들의 값을 빠짐없이 지정해주어야 합니다. 요소의 이름도 같이 적어주므로 순서는 상관없습니다.

만약 애너테이션의 요소가 하나뿐이고, 그 이름이 value라면 요소의 이름을 생략할 수 있습니다.
@TestInfo(
    count = 3, testedBy="Lee",
    testTools={"JUnit", "AutoTester"},
    testType=TestType.FIRST,
    testDate=@DateTime(yymmdd="251225")
)
public class NewCLass { ... }

위 예시 코드를 보면 알 수 있듯이 애너테이션의 요소에는 enum 타입 및 다른 애너테이션 타입도 포함할 수 있습니다.

 

마커 애너테이션 - Marker Annotation

값을 지정할 필요가 없는 경우, 애너테이션의 요소를 하나도 정의하지 않을 수 있습니다. @Override 애너테이션처럼 요소가 하나도 정의되지 않은 애너테이션을 마커 애너테이션이라고 합니다.

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}

 

 

애너테이션 런타임 조회

 

애너테이션 유지 정책을 RUNTIME으로 설정하면 Reflection API를 통해 애너테이션 정보를 가져올 수 있습니다.

 

다음과 같이 클래스 객체를 참조하여 해당 클래스에 대한 모든 정보를 조회할 수 있습니다.

Class<AnnotationTest> cls = AnnotationTest.class;

Annotation[] annoArr = cls.getAnnotations();
TestInfo anno = (TestInfo)cls.getAnnotation(TestInfo.class);

 

getAnnotations() 메서드를 사용할 경우, 모든 애너테이션의 조상인 Annotation인터페이스 타입 배열로 받아 올 수 있습니다.

또는 getAnnotation() 메서드를 사용해 원하는 애너테이션을 지정할 수도 있습니다.

 

 

레코드 (record)

 

레코드란 여러 값을 하나로 묶어서 다룰 수 있는 간단한 클래스를 편리하게 작성할 수 있는 기능으로 JDK 21부터 정식 기능으로 추가되었습니다. 클래스를 사용해도 되지만, 레코드는 클래스보다 훨씬 짧은 코드로 작성할 수 있어서 편리합니다.

 

예를 들어 좌표 상의 한 점을 표현하는 두 개의 값 x와 y를 하나로 묶는 불변(immutable) 클래스 Point를 작성하려면, 간단히 한 줄로 가능합니다.

record Point(int x, int y) {}

 

위 코드를 클래스로 작성하면 다음과 같습니다.

final class Point {
    private final int x;		// 불변
    private final int y;		// 불변

    Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() { return x; }	// getter
    public int y() { return y; }	// getter

    public boolean equals(Object obj) {
        return (obj instanceof Point p) && x == p.x && y == p.y;
    }

    public int hashCode() {
        return Objects.hash(x, y);
    }

    public String toString() {
        return String.format("Point[x = %d, y = %d]", x, y);
    }
}
record는 불변 클래스이기 때문에 setter는 없습니다.

 

레코드의 특징

 

열거형의 조상이 java.lang.Enum인 것처럼 모든 레코드의 조상은 추상 클래스 java.lang.Record입니다. 그래서 레코드는 다른 클래스를 상속받을 수 없고 final 클래스라 다른 클래스의 조상이 될 수도 없습니다. 다만 인터페이스를 구현하는 것은 가능합니다.

 

기존의 클래스처럼 레코드에 직접 코드를 추가하는 것도 가능하고 자동으로 추가된 생성자의 오버라이딩도 가능합니다. 다만 인스턴스 변수와 인스턴스 초기화 블럭은 추가할 수 없습니다.

 

컴팩트 생성자 (compact constructor)

 

레코드는 기본 생성자를 자동으로 만들지만, 필드 검증이나 로직을 추가하고 싶을 때 직접 정의할 수 있습니다. 이때 파라미터 선언을 생략하고 필드 이름 그대로 접근 가능한 블록 형태로 작성하는 생성자를 '컴팩트 생성자(compact constructor)'라고 합니다.

 

record 패턴 매칭

JDK 21부터 추가된 패턴 매칭을 사용하면 switch에서 레코드의 타입 검사를 다음과 같이 할 수 있습니다.

interface Shape {}
record Rectanlge(int width, int height) implements Shape {}
record Triangle(int width, int height) implements Shape {}
record Circle(int radius) implements Shape {}

static double getArea(Shape s) {
    return switch(s) {
        case Rectanlge(var w, var h) -> w * h;
        case Triangle(var w, var h) -> w * h * 0.5;
        case Circle(var r) -> r * r * Math.PI;
        default -> -1.0;
    }
}

이때 변수의 이름은 컴포넌트의 이름과 달라도 되며, 타입 추론(var)도 가능합니다.

record에서는 일반 클래스의 “필드(field)”를 컴포넌트(component)라고 부릅니다.

 

 

레코드의 중첩

 

레코드도 다음과 같이 내부 클래스처럼 클래스나 메서드 안에 넣을 수 있습니다. 클래스 내의 레코드는 항상 static이며, 생략 가능합니다.

class Circle {
    static record Point(int x, int y) {}	// static 생략 가능

    void method() {
        record Point(int x, int y) {}		// 지역 레코드
        ...
    }
}

 

마찬가지로 레코드 안에 레코드를 넣는 것도 가능합니다. 다만 중첩된 내부 레코드에서 외부 레코드의 컴포넌트에 접근하는 것은 허용되지 않습니다.

record Student(ClassRoom c, String name) {
    record ClassRoom(int grade, int ban) {
        String getName() {
            return name;	// Error: 외부 레코드 컴포넌트 접근
        }
    }
}

 

 

위와 같이 레코드가 중첩되어 있으면 객체에서 값을 꺼내는 것이 불편합니다. 

Student stu = new Student(new Student.ClassRoom(1, 1), "Bob");
Student.ClassRoom cr = stu.c();
int grade = cr.grade();
int ban   = cr.ban();
String name = stu.name();

System.out.println("grade = " + grade);
System.out.println("ban = " + ban);
System.out.println("name = " + name);

 

이때 instanceof 패턴 매칭의 구조 분해 문법을 사용하면 보다 간단히 값을 꺼낼 수 있습니다.

var s = new Student(new Student.ClassRoom(1, 1), "Bob");
if(!(s instanceof Student(Student.ClassRoom(var grade, var ban), var name))) return;

System.out.println("grade = " + grade);
System.out.println("ban = " + ban);
System.out.println("name = " + name);

 

 

지네릭 레코드

 

클래스처럼 레코드에도 지네릭스를 적용할 수 있습니다. 아래의 지네릭 레코드 Point는 생성할 때마다 타입 T를 다르게 지정할 수 있으므로 x, y타입이 다른 여러 종류의 Point레코드를 정의할 필요가 없습니다.

record Point<T extends Number> (T x, T y) { }

 

지네릭 레코드는 지네릭 클래스와 동일합니다.

 

레코드와 애너테이션

 

클래스처럼 레코드에도 애너테이션을 적용할 수 있습니다. 다음과 같이 애너테이션 Range를 정의했을 때

@Target(ElementType.RECORD_COMPONENT)
@Retention(RetentionPolicy.RUNTIME)
@interface Range {
    int min() default Integer.MIN_VALUE;
    int max() default Integer.MAX_VALUE;
}

 

다음과 같이 레코드 컴포넌트에 애너테이션을 붙일 수 있습니다.

record Point (
    @Range(min = 0, max = 10) int x,
    @Range(min = -10, max = 10) int y,
) { }
추가적으로 애너테이션 @Target에 METHOD를 추가하면, getter에도 애너테이션이 붙은 것으로 간주됩니다.

 

레코드 생성자에서 애너테이션 정보를 읽어오면 컴포넌트의 값 검사도 진행할 수 있습니다.

 

 

실드 클래스 (sealed class)

 

public 클래스를 작성하면, 어떤 클래스라도 이 클래스의 자식이 될 수 있습니다. 만약 상속에 제한을 두고 싶다면 디폴트로 접근 제한자를 좁혀서 패키지 내에서만 상속을 받도록 하던가, 아니면 final을 붙여서 상속을 받을 수 없도록 할 수 있습니다.

 

이처럼 상속에 제한이 적은 이유는 코드의 유연성과 확장성 때문이었습니다. 그러나 적절한 제한을 두는 것은 보다 견고한 코드를 작성할 수 있다는 장점도 있습니다. 그래서 JDK 17부터 추가된 것이 '실드 클래스(sealed class)'입니다.

 

실드 클래스란 상속에 구체적인 제한을 두는 것으로 부모 클래스에 상속을 받을 수 있는 자식 클래스를 명시합니다. 아래에 정의된 Type클래스는 ValType와 RefType외의 클래스는 상속이 허용(permit)되지 않습니다.

sealed interface Type permits ValType, RefType { }

final class ValType implements Type { }
final class RefType implements Type { }
final class NewType implements Type { }		// Error

 

이러한 제한을 통해 코드의 작성의도가 더 명확해지고, 의도하지 않은 잠재적인 실수가 컴파일러에 의해 걸러질 수 있습니다.

 

실드 클래스의 제약조건

 

실드 클래스는 키워드 'sealed'와 'permit'을 붙여야 하고, 'permit' 다음에는 반드시 하나 이상의 자식을 지정해야합니다. 그리고 그 자식은 반드시 'final', 'sealed', 'non-sealed' 중 하나이어야 합니다. 이 셋중에 어느 것도 붙이지 않으면 에러가 발생합니다.

sealed interface Type permits ValType, RefType, NewType { }

sealed class ValType implements Type permits Bool, Char {}
final class RefType implements Type {}
non-sealed class NewType implements Type {}
열거형과 레코드는 묵시적으로 final이므로 별도의 final 키워드가 없어도 에러가 발생하지 않습니다.
sealed 클래스와 그 하위(자식) 클래스가 같은 파일에 정의되어 있다면 permits절은 생략 가능합니다.

 

그리고 실드 클래스의 자식은 반드시 부모와 같은 모듈에 있어야 하며, 이름 없는 모듈의 경우 같은 패키지에 있어야 합니다. 또한 익명 클래스나 지역 클래스는 실드 클래스의 자식이 될 수 없습니다. 

 

실드 클래스와 switch식

 

실드 클래스의 또 다른 장점은 switch식과 함께 사용하면 컴파일러가 코드의 오류를 체크해서 알려줄 수 있다는 것입니다. 전에는 상속이 제한되어 있지 않아서 컴파일러가 코드를 체크하는데 한계가 있었으나 실드 클래스가 도입되면서 타입의 관계가 명확해졌기 때문에 컴파일러가 보다 꼼꼼한 체크를 할 수 있게 되었습니다.

 

이해를 돕기 위해 동일한 장점을 누릴 수 있는 열거형으로 예시를 들어보겠습니다.

enum Direction { EAST, WEST, NORTH }

String getDirection(Direction d) {
    return switch(d) {
        case EAST -> "EAST";
        case WEST -> "WEST";
        case NORTH -> "NORTH";
    };
}

위와 같이 코드를 작성한다면, 열거형의 모든 상수를 케이스로 다루었기 때문에 default 없이 switch식을 작성할 수 있습니다.

 

나중에 열거형 Direction에 새로운 상수 SOUTH가 추가된다면, switch식에서 컴파일 에러가 발생하여 처리되지 않은 케이스가 있다는 것을 알 수 있습니다. 이렇듯 변경사항이 발생했을 때 관련된 곳을 빠짐없이 변경하는 것이 쉽지 않은데, 컴파일러가 체크해서 알려준다는 것은 실수를 줄이는데 큰 도움이 됩니다. 

 

이러한 이유로 default를 사용하지 않고, case 만으로 switch식을 작성하는 것이 좋습니다. 이것이 가능한 이유는 열거형의 상수의 개수가 제한되어 있기 때문인데, 이는 실드 클래스로 자식 클래스의 개수를 제한하면 동일한 효과를 얻을 수 있습니다.

 

sealed interface Type permits ValType, RefType, NewType { };
	...
    
String typeOf(Type type) {
    return switch(type) {
        case ValType v -> "Val";
        case RefType r -> "Ref";
    }
}

위와 같이 switch식을 작성하면 컴파일 에러가 발생합니다. 만일 인터페이스 Type이 `sealed`가 아니었다면, switch 식에 default를 추가해야 할뿐만아니라 새로운 타입이 추가되어도 컴파일러가 에러를 체크할 수 없습니다.

부모 타입의 case보다 자식 타입의 case가 위에 있어야 하며, 자식 타입 case가 모두 처리되었다면 부모 타입의 case가 없어도 됩니다.

 

실드 클래스와 리플렉션 API

 

실드 클래스와 관련되어 리플렉션 API의 Class클래스에 getPermittedSubclasses() isSealed()가 추가되었습니다. isSealed()는 해당 타입이 'sealed'일 때 true를 반환하고, getPermittedSubclasses()로 실드 클래스의 자식들을 알아낼 수 있습니다.

 

앞서 설명한 바와 같이 실드 클래스의 자식들이 부모와 같은 파일에 정의되면 permit절을 생략할 수 있는데, 이때 getPermittedSubclasses()를 호출하면 자식 클래가 배열에 담겨서 반환되는 것을 확인할 수 있습니다.

 

getModifiers()는 타입에 붙어있는 모든 제어자를 반환하는데, 메서드는 하나의 값만 반환할 수 있으므로 제어자를 상수로 정의하고 이들의 합을 반환합니다.

public class Modifier {
    ...
    public static final int PUBLIC           = 0x00000001;
    public static final int PRIVATE          = 0x00000002;
    public static final int PROTECTED        = 0x00000004;
    ...
    public static final int FINAL            = 0x00000010;
    public static final int SYNCHRONIZED     = 0x00000020;
    public static final int VOLATILE         = 0x00000040;
    ...
}

 

만일 제어자가 public이면서 final인 클래스는 17을 반환합니다. 더해서 17이 되는 경우는 public 이면서 final인 경우 뿐입니다. 각 상수는 2의 제곱으로 정의되어 있어서 여러 상수를 더해도 서로 구별될 수 있습니다.

 

 

모듈 (module)

 

모듈은 패키지의 상위 집합으로, 여러 패키지를 하나로 묶을 수 있습니다. JDK 8까지는 패키지가 최상위 집합이었으나 JDK 9부터 모듈이라는 개념이 추가되어 모듈이 최상위 집합이 되었습니다.

 

모듈 개념이 도입되기 전에는 패키지의 캡슐화가 불완전해서 내부에서만 사용하는 코드가 외부에 노출되는 경우가 종종 발생했습니다. 이처럼 내부 코드가 외부에 노출되면 불필요한 의존성이 생겨서 변경에 불리해집니다. 처음 자바를 만들었을 당시 모든 코드를 하나의 패키지에 넣고 외부에 노출할 클래스는 public으로 하고 내부 코드는 public을 붙이지 않아서 외부에서 접근할 수 없게 할 계획이었습니다.

 

그런데, 클래스가 점점 많아져서 패키지를 둘로 나누면 클래스가 default 접근 제한자인 경우 다른 패키지에서 접근할 수 없게 됩니다. 

 

클래스의 접근 제한자를 public으로 바꾸면 되지만, 이렇게 하면 원하지 않는 패키지에서도 접근이 가능하게 되므로 불필요한 의존성이 생길 수 있습니다.

 

이럴때 두 패키지를 하나의 모듈로 묶어주고 원하는 패키지만 접근할 수 있게 모듈 설명자(module-info.java)를 작성하고 컴파일해서 모듈에 포함시키면 됩니다.

 

위의 그림과 같이 모듈을 구성하려면 다음과 같이 모듈 설명자를 작성해야 합니다.

module myModule {
    exports pkgA;

 

모듈의 이름은 myModule이고, 키워드 exports로 이 모듈의 pkgA패키지만 노출하여 모듈의 외부에서 접근할 수 있게 하였습니다. pkgB패키지는 노출하지 않았으므로 이 패키지의 클래스는 public이어도 myModule 외부인 pkgC에서 접근할 수 없습니다. 

물론 pkgA와 pkgB는 같은 모듈 내에 있으므로 노출하지 않아도 접근할 수 있습니다.

 

모듈 설명자 - module-info.java

 

앞서 살펴본 것처럼 모듈에는 모듈의 정보가 담겨있는 '모듈 설명자(module descriptor)'가 필요하며 모듈 설명자에는 모듈의 이름과 모듈이 어떤 모듈에 의존하고 어떤 패키지를 노출하는지 등의 정보가 적혀있습니다. 즉, 모듈 자신의 정보와 다른 모듈과의 관계에 대한 정보를 제공하는 것이 모듈 설명자입니다.

// module-info.java
module com.example.myModule {
    requires java.desktop;
    requires java.base;		// 필수 묘듈이라 생략 가능

    exports com.example.pkgA;
}

이처럼 모듈 설명자를 통해 모듈간의 의존 정보를 컴파일 타임과 런타임에 활용하면 모듈간의 불필요한 의존성이 생기는 것을 방지할 수 있습니다. 모듈화된 JDK에서 가장 필수적인 java.base 모듈은 의존성을 생략할 수 있습니다.

 

모듈 시스템이 도입되기 전에는 소스 파일의 import문을 직접 보지 않고는 실행전에 의존성을 확인할 방법이 없었습니다. 그래서 의존하는 모듈을 실수로 누락하기 쉽고, 누락하면 실행 중에 클래스를 찾을 수 없다는 ClassNotFoundException이 발생합니다.

 

JDK 9부터는 모듈 설명자(module-info.java)를 통해 의존성을 컴파일 타임뿐만아니라 런타임에도 확인이 가능하므로, 애플리케이션을 시작하면 먼저 모듈 설명자에 적힌 정보를 통해 의존성을 확인하고 누락된 모듈이 있으면 에러를 발생시키고 즉시 종료됩니다. 이렇게 함으로써 누락된 클래스에 의해 실행 중에 프로그램이 갑자기 종료되는 것을 상당히 줄일 수 있습니다.

 

모듈 이름

모듈 이름은 전역적으로 유일해야 하기 때문에 패키지 이름처럼 모듈의 이름은 위의 예시와 같이 도메인 이름을 거꾸로 쓰는 것이 일반적입니다. 모듈 선언에만 모듈 이름을 쓰기 때문에 모듈 설명자가 아닌 소스 파일에서 모듈 이름을 참조할 일은 없습니다.

모듈의 이름에 숫자를 포함시키면 경고가 발생합니다.

 

의존 모듈 명시와 패키지 노출 - requires, exports

위에서 작성한 모듈 설명자에서 소개한 requires는 해당 모듈이 필요로 하는 다른 모듈, 즉 의존 모듈을 지정할 때 사용합니다. 그리고 자신의 모듈에서 외부에 노출할 패키지를 지정할 때는 exports를 사용합니다. 특정 모듈에게만 노출할 때는 to를 같이 사용합니다.

 

리플렉션 허용 - open, opens

리플렉션은 자바의 Reflection API를 이용해서 코드의 정보를 실행중에 동적으로 알아내거나 변경하는 것을 말합니다. 프레임워크는 리플렉션을 이용해서 자동으로 처리해주는 기능을 제공하는 경우가 많습니다. 이런 프레임워크를 사용할 때, 우리가 작성한 코드의 정보를 프레임워크가 알아낼 수 있게 리플렉션을 허용하는 것이 opens입니다. 아래의 코드는 패키지 pkgA의 리플렉션을 moduleA와 moduleB에게 허용합니다.

module com.example.myModule {
    open com.example.pkgA to moduleA, moduleB;
}

 

만일 모든 패키지를 외부에 공개하려면 다음과 같이 module앞에 키워드 open을 붙입니다.

open module com.example { }

 

서비스 제공자 인터페이스 모듈 - uses, provides

JDK 6부터 서비스 제공자 인터페이스(SPI, Service Provider Interface)가 추가되었습니다. 이 기능은 인터페이스인 서비스(service)와 이 인터페이스의 구현체인 서비스 제공자(service provider)를 따로 작성해서, 서비스 로더(ServiceLoader클래스)를 통해 실행 중에 서비스 제공자(구현체)를 다른 것으로 바꿀 수 있게 하기 위한 것입니다.

 

JDK 9부터 여기에 모듈을 결합해서 서비스와 서비스 제공자를 별도의 모듈로 구성하고 모듈 설명자에 키워드 uses와 provides를 이용하여 기술합니다.

 

먼저 서비스를 사용하는 모듈은 키워드 uses로 모듈이 사용할 서비스(인터페이스)를 지정합니다.

module com.example.myModule {
    uses pkgA.MyService;
}

 

그리고 서비스를 제공하는 모듈은 키워드 provides로 서비스(MyService 인터페이스)를 지정하고 키워드 with 다음에는 서비스 제공자(MyServiceImpl 클래스)를 지정합니다.

module com.example.myModule {
    provides pkgA.MyService with impl.MyServiceImpl;
}

 

이름없는 모듈

 

모듈 설명자(module-info.java)가 없으면 자동으로 '이름 없는 모듈(unnamed module)'에 속하게 되는데, 클래스를 작성할 때 패키지를 지정하지 않으면 이름없는 패키지에 속하게 되는 것과 같습니다.

 

이름 없는 모듈의 모든 패키지는 다른 모듈에게 전부 노출(exports)되고 개방(opens)됩니다. 그리고 자동으로 java.se 모듈에 의존하게 되어 JDK의 모든 모듈을 사용할 수 있습니다. 이렇게 하는 이유는 모듈이 도입되기 이전의 코드와의 하위 호환성 때문일 뿐으로 이제는 반드시 모듈 설명자를 작성해야 합니다. 

 

다음과 같은 코드를 작성해보겠습니다.

public static void main(String[] args) {
    String question = "what is your favorite fruit?";
    String str = JOptionPane.showInputDialog(question);
    System.out.println(str);
}

위 코드를 실행하면 다음과 같은 창이 나타나면서 정상적으로 실행됩니다.

 

 

지금 위 코드는 별도의 모듈 설명자를 작성하지 않았기 때문에 '이름 없는 모듈'에 속하게 됩니다.

 

이제 다음과 같이 이름만 있고 내용이 없는 모듈 설명자(module-info.java)를 작성해서 src폴더 안에 추가하겠습니다.

module myModule {

}

이제 모듈 설명자가 있기 때문에 컴파일러가 모듈 myModule을 인식하게 되고, 정상적으로 실행되던 위의 코드는 컴파일 에러를 발생시킵니다.

 

컴파일러는 JOptionPane 클래스에 에러를 발생시키며 다음과 같은 에러 메시지를 보여줍니다.

Package 'javax.swing' is declared in module 'java.desktop', but module 'myModule' does not read it

 

이 에러는 위 코드가 java.desktop 모듈의 javax.swing패키지를 사용하는데, 이 모듈에 대한 의존성이 모듈 설명자에 적혀있지 않다는 뜻입니다. 모듈 설명자를 아래와 같이 변경하면 에러가 없어지고 다시 정상적으로 실행됩니다.

module myModule {
    requires java.desktop;
//  requires java.base;		// 필수 모듈이라 생략 가능
}

 

 

java.base모듈

 

JDK 1.0의 클래스 개수는 모두 200개도 안되었으나 점차 늘어나다 JDK 8에 이르러서는 수만 개로 늘어났습니다. 이 많은 클래스가 rt.jar라는 하나의 큰 파일(약65M)에 모두 포함되어있으니 사용하지 않는 클래스도 불필요하게 메모리에 올라갈 수 밖에 없었습니다.

 

2005년부터 이런 문제를 인식하고 큰 덩어리로 묶인 클래스를 더 작은 단위인 모듈로 나누는 작업을 시작했습니다. 단순히 클래스를 나누는 것이 아니라 클래스간의 의존 관계도 고려해야하므로 복잡하고 어려운 작업이었습니다. 그래서 JDK 7에 반영하기로 했던 계획이 계속 연기되다가 2017년이 되어서야 JDK 9에 '자바 플랫폼 모듈 시스템(Java Platform Module System)'이라는 이름으로 적용되었습니다.

 

비대했던 rt.jar는 73개(리눅스용 JDK 9 기준)의 모듈로 나누어졌고 모듈간의 의존성은 깔끔하게 정리되었습니다. 덕분에 유지보수하기 쉬워졌고 보안도 개선되었습니다.

 

아래의 그림은 JDK의 모듈간의 의존성을 그림을 표현한 것의 일부입니다. 가장 핵심 모듈인 java.base가 그림의 맨 아래에 있고, JDK의 모든 모듈에 의존하는 java.se 모듈이 맨 위에 있습니다. 

JDK의 전체 모듈 목록은 `java --list-modules` 명령으로 확인할 수 있습니다.

 

JDK의 핵심 모듈인 java.base에는 다음과 같은 주요 패키지가 포함되어 있습니다.

  • java.lang.*
  • java.util.*
  • java.io.*
  • java.net.*
  • java.time.*
  • java.math.*
  • java.nio.*

 

java.base 모듈은 원래 약 43M이지만, java.base.jmod라는 압축된 파일(약 18M)로 제공되는데 크기가 기존 rt.jar의 27%정도 밖에 되지 않습니다. 이 파일에는 5689개의 클래스와 그 밖의 리소스, 즉 이미지나 텍스트 파일, 설정 파일 등이 포함됩니다.

보통 네이티브 라이브러리 같은 바이너리 파일을 모듈에 포함시키는 경우만 모듈을 jmod파일로 압축하고, 보통은 jar로 만듭니다.

 

전이적 의존성과 순환 의존성

 

위와 같이 moduleEx1이 moduleEx2와 java.logging에 의존하고 있을 때, 이미 moduleEx2가 java.logging에 의존하고 있으므로 굳이 moduleEx1이 java.logging에 의존하는 것을 모듈 설명자에게 기술할 필요가 없습니다. 이럴 때 '전이적 의존성(transtive dependency)'을 이용하면 모듈간의 의존관계가 단순해 집니다.

 

module moduleEx2 {
    requires java.desktop;
    requires transitive java.logging;
}


module moduleEx1 {
    requires moduleEx2;
}

위와 같이 java.logging의 의존성을 'requires transitive'로 지정하면, moduleEx2를 의존하는 모든 모듈은 자동적으로 java.logging에 의존하게 됩니다.

 

집합 모듈

이처럼 전이적 의존성을 이용하면 모듈의 의존성을 간단히 할 수 있으며, 집합 모듈은 이를 이용해서 자주 사용되는 여러 모듈을 하나의 모듈로 묶은 것을 말합니다.

 

집합 모듈은 어떤 클래스나 패키지도 가지고 있지 않습니다. 오직 모듈 설명자 module-info.class만 가지고 있으며, java.se 모듈이 집합 모듈의 대표적인 예입니다. 이 모듈은 아래와 같이 여러 모듈에 전이적 종속성을 갖고 있습니다.

module java.se {
    requires transitive java.compiler;
    requires transitive java.datatransfer;
    requires transitive java.desktop;
    requires transitive java.logging;
    ...
}

 

따라서 JDK의 모든 모듈에 의존해야할 때, 다음과 같이 간단히 java.se 모듈 하나에만 의존하면 위와 같이 모든 모듈을 일일히 써주지 않아도 됩니다.

module myModule {
    requires java.se;
}

 

순환 의존성

모듈간의 의존 관계는 단방향이어야 합니다. 두 모듈이 서로 의존하는 양방향 의존 관계가 되면 안됩니다. 그리고 두 모듈의 의존 관계 단방향이어도 서로 순환하는 경우에도 안됩니다.

이처럼 모듈간의 의존성이 순환되는 경우 컴파일 에러가 발생합니다.

 

모듈이 순환 의존 관계에 있는 이유는 모듈 설계를 잘못했기 때문으로, 모듈 구성을 다시해서 순환 의존 관계를 해소해야 합니다. 두 모듈을 하나로 합치거나, 셋으로 나누거나, 인터페이스를 이용해서 의존성의 방향을 바꾸는 등의 방법이 있습니다.

 

모듈화된 jar

 

jar파일은 여러 클래스 파일을 하나로 묶은 것으로 ZIP압축 형식으로 되어 있습니다. 모듈도 하나의 jar파일로 묶을 수 있으며, 이런 모듈을 '모듈화된 jar(modular jar)'라고 합니다. 반대로 jar파일로 묶지 않고 모듈을 구성하는 것을 '펼쳐진 모듈(exploded module)'이라고 합니다. 둘 중에 어느 방식으로 모듈화해도 되지만, 모듈을 하나의 jar파일로 묶으면 버전 관리나 배포가 쉬워집니다.

 

JDK 9이전에도 jar파일이 있었고 모듈과 유사한 개념으로 사용되었지만 모듈 설명자가 없습니다. 그렇기 때문에 의존성 관리와 캡슐화가 부족합니다. 

 

펼쳐진 모듈을 jar파일로 묶는 방법은 다음과 같습니다.

$ jar -cvf moduleEx2.jar -C out/moduleEx2 .

 

모듈 moduleEx2가 moduleEx2.jar 라는 모듈화된 jar로 만들어질 것입니다. 잘 만들어졌는지 확인하는 명령어는 다음과 같습니다.

$ jar -tf moduleEx2.jar

 

그리고 IDE를 사용하면 좀 더 편리하게 모듈화된 jar파일을 생성할 수 있습니다. 

  • File  >  Project Structure  >  Artifacts  >  +  >  JAR  >  From modules with dependencies...

 

모듈 패스와 모듈 해석

자바 애플리케이션을 실행할 때, 아래와 같이 모듈 패스를 지정해주고 애플리케이션의 시작점인 main클래스를 지정해줘야 합니다.

위의 경우, 모듈 패스는 'out:jars' 이며 ':' 는 경로 구분자인데, 맥OS와 리눅스는 ':'이고, 윈도우의 경우 ';' 입니다.

모듈을 찾을 때 모듈 패스의 순서대로 out에서 먼저 찾고 그 다음 jars에서 찾습니다. 모듈은 jar파일의 형태일 수도 있고, 펼쳐진 모듈일 수도 있습니다. 

 

프로그램이 시작되면, 지정된 모듈 패스에 적힌 폴더를 차례대로 방문하여 main클래스가 포함된 모듈을 찾습니다. 메인 모듈의 모듈 설명자를 읽어서 이 모듈이 의존하는 모든 모듈을 모두 등록하고 각 모듈의 모듈 설명자를 확인해서 모듈을 등록하는 일을 반복합니다.

 

이것을 모듈 해석(module resolution)이라고 하며, 이 과정을 통해 누락된 모듈이 있는지 중복된 모듈이 확인하여 FindException을 발생시키고 프로그램이 종료됩니다. 이렇게 프로그램이 시작하자마자 모듈의 유효성 검사를 하기 때문에 실행 중에 해당 모듈이 없어서 종료되거나 원하지 않는 모듈이 사용되거나 하는 일을 방지할 수 있습니다.

 

뿐만 아니라 모듈 해석과정에서 모듈 간의 순환 의존성도 확인하며, 순환 의존성이 발견되면 ResolutionException이 발생합니다.

 

자동 모듈

 

언젠가 기존의 코드를 모두 모듈화시켜야 하겠지만 하루 아침에 그 많은 코드를 모듈화할 수 없기 때문에 하위 호환성을 중요시하는 자바가 선택한 방법은 바로 '이름없는 모듈(unnamed module)'과 '자동 모듈(automatic module)'입니다.

 

모듈화 되지 않은 클래스는 모두 하나의 모듈, 즉 이름 없는 모듈에 포함되고, 모듈화되지 않은 jar파일들은 모듈 패스에 추가하면 각기 하나의 자동 모듈이 됩니다. 이렇게 함으로써 기존의 코드를 전혀 변경하지 않고도 모든 코드가 모듈이 되어 모듈 시스템 내에서 함께 동작할 수 있게 됩니다.

 

모듈이 도입되기 전부터 관련 패키지를 jar파일로 묶어서 모듈처럼 사용했습니다. 기존의 수 많은 라이브러리들이 jar파일의 형태로 개발되고 공유되었습니다. 이런 jar파일은 모듈화된 jar와 달리 모듈 설명자(module-info.java)가 없습니다. 즉, 모듈 이름과 모듈 의존성, 노줄하거나 공개할 패키지 정보가 없다는 것입니다.

 

그래서 이름 없는 모듈과 자동 모듈은 모든 모듈에 의존하고 자신의 모든 패키지를 노출하고 공개하는 것으로 정했습니다. 단, public이 아닌 패키지는 제외되고, 그리고 자동으로 java.se 모듈에 의존하게 되어 JDK의 모든 모듈을 사용할 수 있게 됩니다. 자동 모듈의 이름은 jar내의 '/META/MANIFEST.MF' 파일의 'Automatic-Module-Name'항목에 의해 결정됩니다.

 

예를 들어 `junit-jupiter-api-5.4.0.jar`의 MANIFEST.MF파일의 내용을 보면 다음과 같은 한 줄이 포함되어 있는데, 자동 모듈의 이름을 무엇으로 할지가 적혀있습니다.

Automatic-Module-Name: org.junit.jupiter.api

 

기존의 jar파일을 모듈화했으면 가장 좋겠지만 그렇지 못한 경우에는 최소한 모듈 이름이라도 이렇게 제공하는 것입니다. 만일 위 내용이 없었다면 이 jar파일의 모듈 이름은 'junit.jupiter.api'로 자동 결정되었을 것입니다. 이 이름은 jar파일의 이름에서 '-'를 '.'로 바꾸고 마지막의 숫자(버전)를 제거한 것입니다.

jar파일이 모듈화된 jar인지 확인하려면 module-info.class가 포함되어 있는지 알아야 합니다.

 

 

'Lang > Java' 카테고리의 다른 글

[Java 21] (14) - thread 2  (0) 2025.11.13
[Java 21] (13) - thread 1  (0) 2025.11.11
[Java 21] (11) - collections framework  (0) 2025.11.03
[Java 21] (10) - date, time and formatting  (0) 2025.10.31
[Java 21] (9) - java.lang package & util classes  (0) 2025.10.24