Java로 프로그램을 작성하다 보면 다음과 같은 코드를 자주 만나게 된다.
List<String> names = new ArrayList<>();
Map<String, Integer> scores = new HashMap<>();
여기서 <String>, <Integer>처럼 사용할 타입을 지정하는 문법이 제네릭(Generic) 이다.
또한 주문 상태나 회원 등급처럼 사용할 수 있는 값이 제한되어 있다면 다음과 같이 enum을 사용한다.
OrderStatus status = OrderStatus.PAID;
메서드 위에 붙는 @Override, @Deprecated와 같은 표현도 자주 볼 수 있다.
@Override
public String toString() {
return "Member";
}
이처럼 제네릭, 열거 타입, 어노테이션은 서로 완전히 다른 문법처럼 보이지만 모두 프로그램의 타입 안정성을 높이고 코드의 의미를 명확하게 만드는 데 사용된다.
이번 글에서는 제네릭이 필요한 이유부터 제네릭 클래스와 메서드, 타입 제한, 와일드카드, 타입 소거를 살펴본다. 이어서 Enum을 이용해 제한된 값을 안전하게 관리하는 방법과 Annotation으로 코드에 메타데이터를 추가하는 방법까지 정리해보려고 한다.
제네릭은 클래스, 인터페이스, 메서드에서 사용할 타입을 외부에서 지정할 수 있도록 만드는 문법이다.
제네릭을 사용하면 다양한 타입의 객체를 하나의 코드로 처리하면서도 컴파일 단계에서 타입을 검사할 수 있다.
List<String> names = new ArrayList<>();
위 코드는 names 리스트가 String 객체만 저장하도록 제한한다.
names.add("Java");
// names.add(100);
// 컴파일 오류
잘못된 타입을 저장하려고 하면 프로그램 실행 전 컴파일 단계에서 오류를 발견할 수 있다.
제네릭이 도입되기 전에는 다양한 객체를 저장하기 위해 Object 타입을 많이 사용했다.
public class ObjectBox {
private Object value;
public void set(Object value) {
this.value = value;
}
public Object get() {
return value;
}
}
모든 클래스는 Object를 상속하므로 어떤 객체든 저장할 수 있다.
ObjectBox box = new ObjectBox();
box.set("Java");
하지만 값을 꺼낼 때마다 강제 타입 변환이 필요하다.
String value = (String) box.get();
실수로 잘못된 타입으로 변환하면 실행 중 ClassCastException이 발생한다.
box.set(100);
String value = (String) box.get();
이 코드는 컴파일될 수 있지만 실행 중 오류가 발생한다.
제네릭을 사용하면 이러한 문제를 컴파일 단계에서 방지할 수 있다.
클래스 이름 뒤에 <T>와 같이 타입 파라미터를 선언하면 제네릭 클래스를 만들 수 있다.
public class Box<T> {
private T value;
public void set(T value) {
this.value = value;
}
public T get() {
return value;
}
}
T는 실제 타입이 아니라 나중에 사용할 타입이 들어갈 자리다.
문자열을 저장하려면 다음과 같이 사용한다.
public class BoxExample {
public static void main(String[] args) {
Box<String> stringBox = new Box<>();
stringBox.set("Java");
String value = stringBox.get();
System.out.println(value);
}
}
정수를 저장하는 객체도 같은 클래스로 만들 수 있다.
Box<Integer> integerBox = new Box<>();
integerBox.set(100);
Integer number = integerBox.get();
하나의 Box 클래스를 여러 타입에 재사용하면서도 각 객체에는 지정된 타입만 저장할 수 있다.
타입 파라미터의 이름은 자유롭게 작성할 수 있지만 일반적으로 한 글자의 대문자를 사용한다.
| 이름 | 주로 사용하는 의미 |
|---|---|
T | Type |
E | Element |
K | Key |
V | Value |
N | Number |
R | Return 또는 Result |
예를 들어 Map은 키와 값을 사용하므로 다음과 같이 표현한다.
Map<K, V>
실제 사용 시에는 구체적인 타입을 전달한다.
Map<String, Integer> scores = new HashMap<>();
하나의 제네릭 클래스에 여러 타입 파라미터를 선언할 수도 있다.
public class Pair<K, V> {
private final K key;
private final V value;
public Pair(K key, V value) {
this.key = key;
this.value = value;
}
public K getKey() {
return key;
}
public V getValue() {
return value;
}
}
사용할 때는 각 타입을 지정한다.
public class PairExample {
public static void main(String[] args) {
Pair<String, Integer> score =
new Pair<>("김자바", 95);
System.out.println(score.getKey());
System.out.println(score.getValue());
}
}
인터페이스도 제네릭으로 선언할 수 있다.
public interface Repository<T> {
void save(T value);
T findById(long id);
}
구현 클래스에서 실제 타입을 지정한다.
public class MemberRepository
implements Repository<Member> {
@Override
public void save(Member value) {
System.out.println(
value.getName() + " 저장"
);
}
@Override
public Member findById(long id) {
return new Member(id, "김자바");
}
}
Repository<Member>로 구현했기 때문에 모든 메서드가 Member 타입을 기준으로 동작한다.
제네릭을 사용하는 가장 큰 이유는 타입 안정성과 코드 재사용성이다.
제네릭을 사용하지 않는 경우
Object로 저장
→ 값을 꺼냄
→ 강제 타입 변환
→ 실행 중 오류 가능
제네릭을 사용하는 경우
사용할 타입 지정
→ 컴파일러가 타입 검사
→ 형변환 없이 사용
제네릭을 사용하면 다음과 같은 장점이 있다.
제네릭 클래스에서 타입 인수를 생략한 형태를 Raw Type이라고 한다.
Box box = new Box();
컴파일은 가능하지만 제네릭의 타입 검사가 제대로 적용되지 않는다.
box.set("Java");
box.set(100);
서로 다른 타입의 값을 자유롭게 저장할 수 있기 때문에 타입 안정성을 잃는다.
String value = (String) box.get();
저장된 값이 Integer라면 실행 중 오류가 발생할 수 있다.
따라서 특별한 이유가 없다면 Raw Type을 사용하지 않아야 한다.
Box<String> box = new Box<>();
Raw Type이나 확인되지 않은 형변환을 사용하면 컴파일러가 경고를 표시한다.
@SuppressWarnings("rawtypes")
여러 경고를 동시에 억제할 수도 있다.
@SuppressWarnings({
"rawtypes",
"unchecked",
"unused"
})
다만 경고를 숨긴다고 코드의 문제가 해결되는 것은 아니다.
@SuppressWarnings는 경고가 발생하는 이유를 정확하게 이해하고, 해당 코드가 안전하다는 것을 보장할 수 있을 때만 제한적으로 사용해야 한다.
제네릭의 타입 인수에는 기본 타입을 직접 사용할 수 없다.
// Box<int> box;
// 사용할 수 없음
기본 타입 대신 래퍼 클래스를 사용해야 한다.
Box<Integer> box = new Box<>();
Box<Double> doubleBox = new Box<>();
컬렉션에서도 동일하다.
List<Integer> numbers = new ArrayList<>();
int 값은 오토박싱을 통해 Integer 객체로 변환되어 저장된다.
numbers.add(10);
개념적으로 다음과 같은 변환이 일어난다.
numbers.add(Integer.valueOf(10));
클래스의 타입 파라미터는 객체를 생성할 때 결정된다.
Box<String> stringBox = new Box<>();
Box<Integer> integerBox = new Box<>();
반면 static 멤버는 객체가 아니라 클래스에 소속된다.
따라서 클래스 타입 파라미터 T를 정적 필드나 정적 메서드에서 직접 사용할 수 없다.
public class Box<T> {
// 사용할 수 없음
// private static T value;
}
각 객체마다 T가 다를 수 있는데 정적 필드는 모든 객체가 하나를 공유하기 때문이다.
정적 메서드에서 제네릭이 필요하다면 메서드 자체에 별도의 타입 파라미터를 선언해야 한다.
public static <T> void print(T value) {
System.out.println(value);
}
메서드의 반환 타입 앞에 타입 파라미터를 선언하면 제네릭 메서드를 만들 수 있다.
public class GenericMethodExample {
public static <T> void print(T value) {
System.out.println(value);
}
public static void main(String[] args) {
print("Java");
print(100);
print(3.14);
}
}
호출할 때 전달한 인수를 기준으로 타입이 추론된다.
print("Java");
위 호출에서는 T가 String으로 결정된다.
명시적으로 타입을 지정할 수도 있다.
GenericMethodExample.<String>print("Java");
대부분의 경우 컴파일러가 타입을 추론하므로 직접 작성할 필요는 없다.
제네릭 메서드는 전달받은 타입과 동일한 타입의 값을 반환할 수 있다.
public class GenericMethodExample {
public static <T> T getFirst(T[] values) {
if (values == null || values.length == 0) {
return null;
}
return values[0];
}
public static void main(String[] args) {
String[] names = {"Java", "Spring"};
Integer[] numbers = {10, 20, 30};
String firstName = getFirst(names);
Integer firstNumber = getFirst(numbers);
System.out.println(firstName);
System.out.println(firstNumber);
}
}
모든 타입을 허용하지 않고 특정 타입과 그 하위 타입만 사용하도록 제한할 수 있다.
<T extends Number>
위 선언은 T가 Number이거나 Number의 하위 타입이어야 한다는 뜻이다.
public class NumberCalculator {
public static <T extends Number>
double doubleValue(T number) {
return number.doubleValue();
}
public static void main(String[] args) {
System.out.println(doubleValue(10));
System.out.println(doubleValue(3.14));
// doubleValue("Java");
// 컴파일 오류
}
}
Integer, Double, Long 등은 Number를 상속하므로 사용할 수 있다.
클래스뿐 아니라 인터페이스를 기준으로도 제한할 수 있다.
<T extends Comparable<T>>
여기서 extends는 클래스 상속뿐 아니라 인터페이스 구현까지 포함하는 의미로 사용된다.
public static <T extends Comparable<T>>
T max(T first, T second) {
if (first.compareTo(second) >= 0) {
return first;
}
return second;
}
public class MaxExample {
public static <T extends Comparable<T>>
T max(T first, T second) {
return first.compareTo(second) >= 0
? first
: second;
}
public static void main(String[] args) {
System.out.println(max(10, 20));
System.out.println(max("Java", "Spring"));
}
}
여러 타입 제한을 동시에 적용할 수 있다.
<T extends Parent & InterfaceA & InterfaceB>
클래스 제한이 있다면 가장 앞에 한 번만 작성해야 한다.
<T extends Number & Comparable<T>>
클래스는 하나만 사용할 수 있지만 인터페이스는 여러 개 지정할 수 있다.
Java의 제네릭 타입 정보는 주로 컴파일 단계에서 타입 검사를 위해 사용된다.
컴파일러는 제네릭 코드를 검사하고 필요한 형변환 등을 추가한 뒤 일부 타입 정보를 제거한다. 이를 타입 소거(Type Erasure) 라고 한다.
예를 들어 다음 코드가 있다고 가정해보자.
Box<String> stringBox = new Box<>();
Box<Integer> integerBox = new Box<>();
런타임에는 Box<String>과 Box<Integer>가 완전히 별개의 클래스 파일로 생성되는 것이 아니다.
기본적으로 같은 Box 클래스를 공유한다.
System.out.println(
stringBox.getClass()
== integerBox.getClass()
);
결과는 true다.
다음과 같은 제네릭 클래스가 있다고 가정해보자.
public class Box<T> {
private T value;
public T get() {
return value;
}
}
타입 파라미터에 제한이 없다면 컴파일 이후 개념적으로 Object를 기준으로 처리된다.
public class Box {
private Object value;
public Object get() {
return value;
}
}
한정 타입이 있다면 해당 상한 타입이 기준이 된다.
public class NumberBox<T extends Number> {
}
이 경우 T는 소거 후 Number를 기준으로 처리된다.
다음 코드는 사용할 수 없다.
public class Box<T> {
public T create() {
// return new T();
// 컴파일 오류
}
}
런타임에는 T가 어떤 구체적인 타입인지 알 수 없기 때문이다.
객체 생성이 필요하다면 생성자를 외부에서 전달받는 방법을 사용할 수 있다.
import java.util.function.Supplier;
public class Factory<T> {
private final Supplier<T> supplier;
public Factory(Supplier<T> supplier) {
this.supplier = supplier;
}
public T create() {
return supplier.get();
}
}
public class FactoryExample {
public static void main(String[] args) {
Factory<Member> factory =
new Factory<>(Member::new);
Member member = factory.create();
System.out.println(member);
}
}
다음 코드 역시 사용할 수 없다.
public boolean check(Object value) {
// return value instanceof T;
}
instanceof는 런타임에 실제 타입을 검사하지만, 런타임에는 T의 구체적인 타입 정보가 소거되기 때문이다.
필요하다면 Class<T> 객체를 전달받을 수 있다.
public class TypeChecker<T> {
private final Class<T> type;
public TypeChecker(Class<T> type) {
this.type = type;
}
public boolean check(Object value) {
return type.isInstance(value);
}
}
TypeChecker<String> checker =
new TypeChecker<>(String.class);
System.out.println(checker.check("Java"));
System.out.println(checker.check(100));
제네릭 타입의 배열을 직접 생성할 수 없다.
// T[] values = new T[10];
// 컴파일 오류
다음과 같은 배열도 생성할 수 없다.
// List<String>[] lists = new List<String>[10];
배열은 런타임에 자신의 요소 타입을 확인하지만 제네릭 타입 정보는 타입 소거로 인해 충분히 유지되지 않기 때문이다.
일반적으로 제네릭 데이터는 배열보다 List<T>와 같은 컬렉션으로 관리한다.
List<T> values = new ArrayList<>();
다음과 같은 클래스 관계가 있다고 가정해보자.
public class Animal {
}
public class Dog extends Animal {
}
Dog는 Animal의 자식이지만 다음 두 리스트는 서로 상속 관계가 아니다.
List<Dog>
List<Animal>
따라서 다음 대입은 불가능하다.
List<Dog> dogs = new ArrayList<>();
// List<Animal> animals = dogs;
// 컴파일 오류
만약 허용된다면 animals를 통해 Cat을 추가할 수 있게 되고, 원래 List<Dog>였던 리스트의 타입 안정성이 깨질 수 있기 때문이다.
여러 제네릭 타입을 유연하게 처리하려면 와일드카드를 사용한다.
<?>는 구체적인 타입을 알 수 없는 제네릭 타입을 의미한다.
List<?> values;
List<String>, List<Integer>, List<Member> 등을 모두 참조할 수 있다.
public static void printAll(List<?> values) {
for (Object value : values) {
System.out.println(value);
}
}
printAll(List.of("Java", "Spring"));
printAll(List.of(10, 20, 30));
어떤 타입인지 알 수 없기 때문에 값을 꺼낼 때는 Object로만 안전하게 받을 수 있다.
Object value = values.get(0);
새로운 값을 추가하는 것은 거의 불가능하다.
// values.add("Java");
// values.add(100);
실제 리스트가 어떤 타입인지 모르기 때문이다.
null은 모든 참조 타입에 저장할 수 있으므로 추가할 수 있다.
values.add(null);
다만 List.of()처럼 수정 불가능한 리스트라면 null 추가 여부와 관계없이 변경 자체가 불가능하다.
<? extends T>는 T 또는 T의 하위 타입을 의미한다.
List<? extends Animal>
다음 타입들을 참조할 수 있다.
List<Animal>
List<Dog>
List<Cat>
값을 꺼낼 때는 최소한 Animal이라는 사실이 보장된다.
public static void makeSounds(
List<? extends Animal> animals
) {
for (Animal animal : animals) {
animal.sound();
}
}
하지만 구체적인 리스트 타입을 알 수 없기 때문에 새로운 객체를 추가할 수 없다.
// animals.add(new Dog());
// 컴파일 오류
실제 리스트가 List<Cat>일 수도 있기 때문이다.
상한 제한 와일드카드는 데이터를 주로 읽는 경우에 적합하다.
<? super T>는 T 또는 T의 부모 타입을 의미한다.
List<? super Dog>
다음 타입들을 참조할 수 있다.
List<Dog>
List<Animal>
List<Object>
Dog 객체는 안전하게 추가할 수 있다.
public static void addDog(
List<? super Dog> values
) {
values.add(new Dog());
}
Dog의 자식 객체가 있다면 그 객체도 추가할 수 있다.
하지만 꺼낸 값의 정확한 타입은 알 수 없다.
Object value = values.get(0);
실제 리스트가 List<Object>일 수도 있기 때문에 Dog 타입이라고 보장할 수 없다.
와일드카드를 기억할 때 자주 사용하는 원칙이 PECS다.
Producer Extends
Consumer Super
데이터를 제공하고 꺼내는 생산자라면 extends를 사용한다.
List<? extends Animal>
데이터를 전달받아 저장하는 소비자라면 super를 사용한다.
List<? super Dog>
다음 복사 메서드를 보면 PECS를 이해하기 쉽다.
public static <T> void copy(
List<? extends T> source,
List<? super T> destination
) {
for (T value : source) {
destination.add(value);
}
}
source는 값을 꺼내는 생산자이므로 extends를 사용하고, destination은 값을 넣는 소비자이므로 super를 사용한다.
public class CopyExample {
public static void main(String[] args) {
List<Integer> source =
List.of(10, 20, 30);
List<Number> destination =
new ArrayList<>();
copy(source, destination);
System.out.println(destination);
}
public static <T> void copy(
List<? extends T> source,
List<? super T> destination
) {
for (T value : source) {
destination.add(value);
}
}
}
| 문법 | 의미 | 안전하게 꺼내는 타입 | 추가 가능한 값 |
|---|---|---|---|
List<?> | 알 수 없는 타입 | Object | null 정도 |
List<? extends Animal> | Animal 또는 하위 타입 | Animal | null 정도 |
List<? super Dog> | Dog 또는 상위 타입 | Object | Dog와 그 하위 타입 |
열거 타입은 변수가 가질 수 있는 값을 미리 정해 놓는 타입이다.
회원 등급이 다음 세 가지로만 구성된다고 생각해보자.
GOLD
SILVER
BRONZE
문자열로 관리하면 다음과 같은 문제가 생길 수 있다.
String grade = "GLOD";
오타가 있어도 단순 문자열이기 때문에 컴파일러가 문제를 발견하지 못한다.
상수로 관리할 수도 있다.
public static final String GOLD = "GOLD";
public static final String SILVER = "SILVER";
하지만 문자열이기 때문에 다른 의미의 문자열도 저장할 수 있다.
String grade = "ADMIN";
Enum을 사용하면 허용된 값만 저장하도록 제한할 수 있다.
열거 타입은 enum 키워드를 사용한다.
public enum Grade {
GOLD,
SILVER,
BRONZE
}
Enum 내부에 선언된 각각의 값을 열거 상수라고 한다.
열거 상수는 일반적인 상수 이름 규칙에 따라 대문자로 작성하고, 여러 단어로 구성되면 언더바를 사용한다.
public enum OrderStatus {
PAYMENT_PENDING,
PAID,
SHIPPING,
DELIVERED,
CANCELED
}
열거 타입 변수에는 해당 Enum에 선언된 상수와 null만 저장할 수 있다.
Grade grade = Grade.GOLD;
다른 Enum 타입이나 임의의 문자열을 저장할 수 없다.
// Grade grade = "GOLD";
// 컴파일 오류
비교할 때는 ==를 사용할 수 있다.
if (grade == Grade.GOLD) {
System.out.println("골드 회원입니다.");
}
각 열거 상수는 하나의 고정된 객체이므로 == 비교가 안전하다.
Enum은 switch문과 잘 어울린다.
public class GradeExample {
public static void main(String[] args) {
Grade grade = Grade.GOLD;
String benefit = switch (grade) {
case GOLD -> "10% 할인";
case SILVER -> "5% 할인";
case BRONZE -> "적립금 제공";
};
System.out.println(benefit);
}
}
모든 열거 상수를 처리하면 default를 생략할 수 있다.
새로운 Enum 상수가 추가되었을 때 처리하지 않은 부분을 컴파일러가 알려줄 수 있다는 장점도 있다.
Enum은 단순한 상수 모음이 아니다.
내부적으로 java.lang.Enum을 상속하는 특별한 클래스다.
따라서 다음 요소를 가질 수 있다.
회원 등급마다 할인율을 가지도록 만들 수 있다.
public enum Grade {
GOLD(0.10),
SILVER(0.05),
BRONZE(0.01);
private final double discountRate;
Grade(double discountRate) {
this.discountRate = discountRate;
}
public int calculateDiscount(int price) {
return (int) (price * discountRate);
}
public double getDiscountRate() {
return discountRate;
}
}
public class GradeExample {
public static void main(String[] args) {
Grade grade = Grade.GOLD;
int discount =
grade.calculateDiscount(100_000);
System.out.println(discount);
}
}
실행 결과는 다음과 같다.
10000
Enum이 자신의 데이터와 동작을 직접 가지기 때문에 등급별 조건문을 외부에 반복해서 작성하지 않아도 된다.
Enum 생성자는 외부에서 직접 호출할 수 없다.
// Grade grade = new Grade();
// 사용할 수 없음
열거 상수를 선언할 때 내부적으로 생성자가 호출된다.
GOLD(0.10)
Enum 생성자는 명시적으로 public이나 protected로 선언할 수 없으며 외부에서 객체를 추가로 생성할 수 없다.
values()는 Enum에 선언된 모든 상수를 배열로 반환한다.
public class EnumMethodExample {
public static void main(String[] args) {
for (Grade grade : Grade.values()) {
System.out.println(grade);
}
}
}
실행 결과는 다음과 같다.
GOLD
SILVER
BRONZE
valueOf()는 문자열과 이름이 일치하는 열거 상수를 반환한다.
Grade grade = Grade.valueOf("GOLD");
System.out.println(grade);
이름이 일치하지 않으면 IllegalArgumentException이 발생한다.
// Grade.valueOf("gold");
// 대소문자가 달라 오류 발생
외부 입력을 Enum으로 변환할 때는 예외 처리가 필요할 수 있다.
name()은 열거 상수의 이름을 문자열로 반환한다.
System.out.println(Grade.GOLD.name());
결과는 "GOLD"다.
ordinal()은 열거 상수가 선언된 순서를 반환한다.
System.out.println(Grade.GOLD.ordinal());
System.out.println(Grade.SILVER.ordinal());
결과는 각각 0, 1이다.
하지만 ordinal() 값을 데이터베이스 저장값이나 업무 로직에 사용하는 것은 피해야 한다.
Enum 선언 순서가 바뀌면 값도 함께 달라지기 때문이다.
GOLD = 0
SILVER = 1
중간에 새로운 상수가 추가되면 기존 값의 의미가 달라질 수 있다.
저장용 코드가 필요하다면 별도의 필드를 선언하는 것이 안전하다.
public enum Grade {
GOLD("G"),
SILVER("S"),
BRONZE("B");
private final String code;
Grade(String code) {
this.code = code;
}
public String getCode() {
return code;
}
}
열거 상수마다 서로 다른 동작을 구현할 수도 있다.
public enum Operation {
PLUS {
@Override
public int calculate(int left, int right) {
return left + right;
}
},
MINUS {
@Override
public int calculate(int left, int right) {
return left - right;
}
},
MULTIPLY {
@Override
public int calculate(int left, int right) {
return left * right;
}
};
public abstract int calculate(
int left,
int right
);
}
public class OperationExample {
public static void main(String[] args) {
int result =
Operation.PLUS.calculate(10, 20);
System.out.println(result);
}
}
이 방식은 Enum 상수별로 동작이 확실히 구분될 때 사용할 수 있다.
| 문자열 | Enum |
|---|---|
| 임의의 값 저장 가능 | 선언된 값만 저장 가능 |
| 오타를 컴파일러가 찾기 어려움 | 잘못된 상수는 컴파일 오류 |
| 관련 기능을 외부에 작성 | 필드와 메서드를 Enum 내부에 작성 가능 |
| 타입 구분이 약함 | 서로 다른 Enum 타입을 구분 |
| 리팩터링이 어려울 수 있음 | IDE 리팩터링 지원이 좋음 |
어노테이션은 소스 코드에 추가하는 메타데이터다.
일반 주석은 사람이 읽기 위한 설명이지만, 어노테이션은 컴파일러, JVM, 라이브러리, 프레임워크 등이 읽고 활용할 수 있다.
@Override
public String toString() {
return "Member";
}
@Override는 해당 메서드가 부모 클래스의 메서드를 재정의한 것임을 컴파일러에게 알려준다.
어노테이션은 코드의 실제 실행 내용을 직접 작성하기보다 코드에 대한 추가 정보나 처리 규칙을 전달하는 역할을 한다.
Java에서 자주 사용하는 기본 어노테이션은 다음과 같다.
| 어노테이션 | 역할 |
|---|---|
@Override | 오버라이딩 메서드임을 컴파일러에 알림 |
@Deprecated | 사용을 권장하지 않는 요소 표시 |
@SuppressWarnings | 특정 컴파일 경고 억제 |
@FunctionalInterface | 함수형 인터페이스임을 검증 |
@SafeVarargs | 가변 인자와 제네릭 관련 경고가 안전함을 표시 |
부모 클래스나 인터페이스의 메서드를 재정의했음을 나타낸다.
public class Member {
@Override
public String toString() {
return "Member";
}
}
메서드 이름이나 매개변수를 잘못 작성하면 컴파일러가 오류를 알려준다.
@Override
public String toStrings() {
return "Member";
}
Object에 toStrings()라는 메서드가 없으므로 컴파일 오류가 발생한다.
오버라이딩할 때는 항상 @Override를 작성하는 것이 좋다.
더 이상 사용을 권장하지 않는 클래스, 생성자, 메서드 등에 표시한다.
public class MemberService {
@Deprecated
public void oldRegister() {
System.out.println("이전 등록 방식");
}
public void register() {
System.out.println("새로운 등록 방식");
}
}
기존 코드와의 호환성을 위해 기능을 바로 삭제할 수 없지만 새로운 코드에서는 사용하지 않도록 안내할 때 사용한다.
문서화를 더 명확하게 하기 위해 Javadoc의 @deprecated 태그와 함께 사용할 수 있다.
/**
* @deprecated register()를 사용하세요.
*/
@Deprecated
public void oldRegister() {
}
Java 9부터는 추가 속성도 지정할 수 있다.
@Deprecated(
since = "2.0",
forRemoval = true
)
public void oldRegister() {
}
since는 사용 중단이 시작된 버전을 나타내고, forRemoval은 향후 제거 예정 여부를 나타낸다.
컴파일러가 발생시키는 특정 경고를 억제한다.
@SuppressWarnings("unused")
private void test() {
int number = 10;
}
여러 경고를 함께 억제할 수 있다.
@SuppressWarnings({
"rawtypes",
"unchecked"
})
public void legacyCode() {
}
경고를 단순히 숨기기 위한 용도로 남발하면 실제 문제를 놓칠 수 있다. 가능한 한 경고의 원인을 해결하고, 의도적으로 안전하다고 판단한 범위에만 적용해야 한다.
추상 메서드를 하나만 가지는 함수형 인터페이스에 사용한다.
@FunctionalInterface
public interface Calculator {
int calculate(int left, int right);
}
함수형 인터페이스는 람다 표현식의 대상이 될 수 있다.
Calculator add =
(left, right) -> left + right;
System.out.println(add.calculate(10, 20));
@FunctionalInterface를 작성한 인터페이스에 추상 메서드를 두 개 이상 선언하면 컴파일 오류가 발생한다.
@FunctionalInterface
public interface Calculator {
int calculate(int left, int right);
// void print();
// 추가하면 컴파일 오류
}
사용자 정의 어노테이션은 @interface를 사용해 선언한다.
public @interface Author {
}
인터페이스 선언과 비슷하지만 앞에 @가 붙는다.
사용할 때는 다음과 같이 작성한다.
@Author
public class MemberService {
}
어노테이션 내부에는 추상 메서드와 비슷한 형태로 속성을 선언한다.
public @interface Author {
String name();
String date();
}
사용할 때는 속성이름 = 값 형태로 작성한다.
@Author(
name = "김자바",
date = "2026-07-28"
)
public class MemberService {
}
어노테이션 속성에는 반환 타입만 작성하며 매개변수는 선언할 수 없다.
default 키워드로 속성의 기본값을 지정할 수 있다.
public @interface Author {
String name();
String date() default "미작성";
}
기본값이 있는 속성은 사용 시 생략할 수 있다.
@Author(name = "김자바")
public class MemberService {
}
속성 이름이 value이고 다른 필수 속성이 없다면 이름을 생략할 수 있다.
public @interface Description {
String value();
}
다음 두 사용법은 같다.
@Description(value = "회원 서비스")
@Description("회원 서비스")
Spring의 많은 어노테이션에서 value 속성 이름을 생략하는 형태를 볼 수 있다.
@RequestMapping("/members")
어노테이션 속성에는 제한된 타입만 사용할 수 있다.
대표적으로 다음 타입을 사용할 수 있다.
StringClasspublic @interface ApiInfo {
String name();
int version();
Class<?> responseType();
HttpMethod method();
String[] tags();
}
다음과 같은 일반 객체 타입은 속성으로 사용할 수 없다.
// Member member();
// 사용할 수 없음
메타 어노테이션은 다른 어노테이션의 적용 방법을 설정하는 어노테이션이다.
대표적인 메타 어노테이션은 다음과 같다.
| 메타 어노테이션 | 역할 |
|---|---|
@Target | 어노테이션을 적용할 수 있는 위치 지정 |
@Retention | 어노테이션 정보를 유지할 기간 지정 |
@Documented | Javadoc에 어노테이션 정보 포함 |
@Inherited | 자식 클래스에 어노테이션 상속 |
@Repeatable | 같은 어노테이션 반복 사용 허용 |
어노테이션을 적용할 수 있는 위치를 지정한다.
import java.lang.annotation.ElementType;
import java.lang.annotation.Target;
@Target(ElementType.METHOD)
public @interface LogExecution {
}
위 어노테이션은 메서드에만 사용할 수 있다.
@LogExecution
public void save() {
}
여러 위치를 허용할 수도 있다.
@Target({
ElementType.TYPE,
ElementType.METHOD
})
public @interface Audit {
}
자주 사용하는 ElementType은 다음과 같다.
| 값 | 적용 대상 |
|---|---|
TYPE | 클래스, 인터페이스, Enum |
FIELD | 필드 |
METHOD | 메서드 |
PARAMETER | 매개변수 |
CONSTRUCTOR | 생성자 |
LOCAL_VARIABLE | 지역 변수 |
ANNOTATION_TYPE | 어노테이션 |
TYPE_USE | 타입이 사용되는 위치 |
어노테이션 정보를 언제까지 유지할지 지정한다.
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.RUNTIME)
public @interface LogExecution {
}
유지 정책은 다음 세 가지다.
| 정책 | 유지 범위 |
|---|---|
SOURCE | 소스 코드까지만 유지 |
CLASS | 클래스 파일까지 유지, 런타임에는 보장되지 않음 |
RUNTIME | 런타임까지 유지하여 Reflection으로 조회 가능 |
컴파일러만 사용하는 어노테이션은 SOURCE가 적절할 수 있다.
프레임워크가 런타임에 어노테이션을 읽어야 한다면 RUNTIME을 사용한다.
Spring의 많은 어노테이션은 런타임에 Reflection으로 분석되므로 RUNTIME 정책을 사용한다.
@Documented가 적용된 어노테이션은 Javadoc 생성 시 문서에 포함될 수 있다.
import java.lang.annotation.Documented;
@Documented
public @interface ApiDescription {
}
@Inherited가 적용된 클래스 수준 어노테이션은 자식 클래스가 상속받은 것처럼 조회할 수 있다.
import java.lang.annotation.Inherited;
@Inherited
public @interface Role {
}
다만 인터페이스 구현이나 메서드 어노테이션까지 자동으로 상속되는 것은 아니다.
같은 어노테이션을 한 대상에 여러 번 작성할 수 있도록 한다.
import java.lang.annotation.Repeatable;
@Repeatable(Roles.class)
public @interface Role {
String value();
}
컨테이너 어노테이션도 필요하다.
public @interface Roles {
Role[] value();
}
사용할 때는 다음과 같이 작성할 수 있다.
@Role("ADMIN")
@Role("MANAGER")
public class MemberService {
}
런타임 유지 정책을 가진 어노테이션은 Reflection을 통해 읽을 수 있다.
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface ServiceInfo {
String name();
int version() default 1;
}
@ServiceInfo(
name = "회원 서비스",
version = 2
)
public class MemberService {
}
public class AnnotationExample {
public static void main(String[] args) {
Class<MemberService> type =
MemberService.class;
ServiceInfo info =
type.getAnnotation(
ServiceInfo.class
);
if (info != null) {
System.out.println(info.name());
System.out.println(info.version());
}
}
}
실행 결과는 다음과 같다.
회원 서비스
2
프레임워크는 이와 유사한 방식으로 클래스와 메서드에 붙은 어노테이션을 분석한 뒤 객체 생성, 요청 매핑, 유효성 검사 같은 기능을 수행한다.
Java 컬렉션과 여러 API는 대부분 제네릭으로 설계되어 있다.
List<Member>
Set<String>
Map<Long, Product>
Optional<Member>
ResponseEntity<UserResponse>
Page<ProductResponse>
제네릭 덕분에 하나의 API가 다양한 타입을 처리하면서도 컴파일 단계에서 타입 안정성을 제공할 수 있다.
메서드의 매개변수와 반환 타입을 설계할 때는 지나치게 광범위한 Object보다 구체적인 제네릭 타입을 사용하는 것이 좋다.
List<Member> findAll();
또한 컬렉션을 읽기만 하거나 채워 넣는 메서드를 작성할 때 와일드카드와 PECS 원칙을 활용할 수 있다.
실무에서는 상태값, 권한, 등급, 결제 방식처럼 가능한 값이 명확히 제한된 데이터에 Enum을 사용한다.
public enum OrderStatus {
CREATED,
PAID,
SHIPPING,
DELIVERED,
CANCELED
}
문자열 상수 대신 Enum을 사용하면 오타를 줄이고 타입 안정성을 높일 수 있다.
상태별 동작이 있다면 조건문을 외부에 늘어놓기보다 Enum이 직접 책임지도록 설계할 수도 있다.
status.canCancel();
status.getDescription();
다만 데이터베이스에 Enum을 저장할 때 ordinal() 순서를 사용하는 것은 피하고 문자열 이름이나 별도의 코드를 사용하는 것이 안전하다.
Spring에서는 어노테이션이 매우 광범위하게 사용된다.
@Controller
@Service
@Repository
@Component
@GetMapping
@PostMapping
@RequestBody
@PathVariable
@Transactional
@Valid
@NotNull
개발자는 어노테이션으로 의도를 표현하고, 프레임워크가 해당 정보를 읽어 필요한 기능을 수행한다.
하지만 어노테이션을 사용한다고 해서 실제 동작이 자동으로 생기는 것은 아니다. 어노테이션을 분석하고 처리하는 컴파일러, 라이브러리 또는 프레임워크 코드가 있어야 한다.
List<Object>와 List<?>List<Object>
Object 또는 모든 자식 객체를 저장할 수 있는 리스트다.
List<Object> values =
new ArrayList<>();
values.add("Java");
values.add(100);
반면
List<?>
구체적인 요소 타입을 알 수 없는 리스트를 참조한다.
List<?> values = List.of("Java");
List<String>일 수도 있고 List<Integer>일 수도 있으므로 새로운 값을 추가할 수 없다.
List<Object>
→ 요소 타입이 Object라고 확정
List<?>
→ 요소 타입을 알 수 없음
<T extends Animal>과 <? extends Animal><T extends Animal>
메서드나 클래스가 사용하는 타입 파라미터를 선언한다.
public static <T extends Animal>
void print(T value) {
}
<? extends Animal>
이미 선언된 제네릭 타입의 범위를 표현하는 와일드카드다.
List<? extends Animal> animals;
타입 파라미터는 타입 사이의 관계를 유지해야 할 때 유용하고, 와일드카드는 다양한 제네릭 타입을 유연하게 받을 때 유용하다.
public static final String GOLD = "GOLD";
문자열 상수는 값 자체만 제한적으로 제공한다.
public enum Grade {
GOLD,
SILVER
}
Enum은 독립적인 타입이며 필드, 메서드, 생성자를 가질 수 있다.
서로 다른 Enum 타입의 상수는 이름이 같아도 호환되지 않는다.
// 회원을 저장한다.
일반 주석은 사람이 읽는다.
@Override
어노테이션은 컴파일러나 프레임워크가 읽을 수 있다.
다만 어노테이션 자체가 실행 로직을 가진 것은 아니다. 어노테이션을 처리하는 별도의 코드가 있어야 한다.
public interface Payment {
}
객체가 구현해야 하는 기능의 규격을 정의한다.
public @interface Audit {
}
코드에 부착할 메타데이터 타입을 정의한다.
문법이 비슷하지만 목적은 완전히 다르다.
이번 글에서는 Java 제네릭, Enum, Annotation을 함께 정리했다.
T, E, K, V 등의 이름을 사용한다.<T extends Number>와 같이 타입의 범위를 제한할 수 있다.new T(), instanceof T, 제네릭 배열 생성 등이 제한된다.<?>는 요소의 구체적인 타입을 알 수 없는 제네릭 타입이다.<? extends T>는 데이터를 읽는 생산자에 적합하다.<? super T>는 데이터를 저장하는 소비자에 적합하다.switch와 함께 사용하기 좋다.values()는 모든 열거 상수를 반환한다.valueOf()는 이름과 일치하는 열거 상수를 반환한다.ordinal()은 선언 순서를 반환하지만 저장용 업무 값으로 사용하지 않는 것이 좋다.@interface로 선언한다.default로 기본값을 지정할 수 있다.value 속성은 조건에 따라 이름을 생략할 수 있다.@Target은 어노테이션의 적용 위치를 제한한다.@Retention은 어노테이션 정보가 유지되는 기간을 지정한다.컴파일 단계에서 타입 안정성을 확보하고 불필요한 형변환을 줄이며 하나의 코드를 여러 타입에 재사용하기 위해 사용한다.
제네릭 타입에서 타입 인수를 생략한 형태다.
List list = new ArrayList();
하위 호환성을 위해 남아 있지만 타입 안정성을 잃으므로 새로운 코드에서는 사용하지 않는 것이 좋다.
List<Object>와 List<?>의 차이는?List<Object>는 요소 타입이 Object로 확정된 리스트다. 다양한 객체를 추가할 수 있다.
List<?>는 요소 타입을 알 수 없는 리스트다. 구체적인 타입을 모르기 때문에 null 이외의 값을 안전하게 추가할 수 없다.
Producer Extends
Consumer Super
데이터를 꺼내는 생산자에는 extends, 데이터를 넣는 소비자에는 super를 사용하는 원칙이다.
기본적으로 불공변이다.
List<Dog>
는 List<Animal>의 하위 타입이 아니다.
와일드카드를 통해 제한적인 공변성과 반공변성을 표현할 수 있다.
List<? extends Animal>
List<? super Dog>
Enum은 독립적인 타입이므로 허용된 값만 저장할 수 있고, 필드와 메서드를 포함할 수 있다. 문자열 상수는 임의의 문자열과 구분되지 않아 타입 안정성이 약하다.
가능하지만 Enum은 각 상수가 하나의 고정 객체이므로 일반적으로 ==를 사용하는 것이 명확하고 안전하다.
인터페이스는 객체가 구현해야 하는 기능의 규격이다.
어노테이션은 코드에 부착하는 메타데이터 타입이다.
SOURCE
→ 소스 코드에서만 유지
CLASS
→ 클래스 파일까지 유지
RUNTIME
→ 실행 중에도 유지, Reflection 조회 가능
클래스 패스 스캔, Reflection, 프록시, Bean 후처리기 등의 기능을 이용해 어노테이션이 부착된 클래스와 메서드를 분석하고 추가 동작을 적용한다.