최근 프로젝트에서 Custom Annotation을 만들 때 @Retention(RetentionPolicy.RUNTIME)
를 사용해 Annotation이 Runtime까지 유지되도록 설정해 사용했다.
SpringBoot를 사용하다 보면 @Controller
, @Service
, @Repository
, @Component
, @Transactional
등 자주 사용되는 애너테이션을 살펴보면 항상 @Retention(RetentionPolicy.RUNTIME)
를 포함하고 있다.
RetentionPolicy
라는 Enum이 존재한다는 것에서 다른 타입도 존재한다는 것을 알 수 있다. 자연스럽게 다른 RetentionPolicy
들은 언제 사용하는지 궁금증이 생겨 알아보았다.
RetentionPolicy
는 java.lang.annotation
패키지에서 찾아볼 수 있다.
public enum RetentionPolicy {
/**
* Annotations are to be discarded by the compiler. */
SOURCE,
/**
* Annotations are to be recorded in the class file by the compiler * but need not be retained by the VM at run time. This is the default * behavior. */
CLASS,
/**
* Annotations are to be recorded in the class file by the compiler and * retained by the VM at run time, so they may be read reflectively. * * @see java.lang.reflect.AnnotatedElement
*/
RUNTIME
}
주석의 내용에 따르면 RetentionPolicy
에 따른 동작은 다음과 같다.
SOURCE
CLASS
RUNTIME
이해한 바가 맞다면, javac
로 컴파일 했을 때 바이트코드엔 RetentionPolicy.SOURCE
를 적용한 애너테이션은 사라지고, RetentionPolicy.CLASS
, RetentionPolicy.RUNTIME
을 적용한 애너테이션은 남아 있어야 한다.
간단하게 애너테이션을 만들고 바이트코드를 확인해보자.
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
@Retention(RetentionPolicy.SOURCE)
@interface SourceAnnotation {}
@Retention(RetentionPolicy.CLASS)
@interface ClassAnnotation {}
@Retention(RetentionPolicy.RUNTIME)
@interface RuntimeAnnotation {}
RetentionPolicy.SOURCE
, RetentionPolicy.CLASS
, RetentionPolicy.RUNTIME
을 각각 적용한 커스텀 애너테이션 SourceAnnotation
, ClassAnnotation
, RuntimeAnnotation
을 만들었다.
public class Main {
@SourceAnnotation
@ClassAnnotation
@RuntimeAnnotation
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
메서드에 애너테이션들을 적용하고, 컴파일 후 바이트코드를 살펴보자.
javac ClassAnnotation.java RuntimeAnnotation.java SourceAnnotation.java Main.java
javap -v Main.class
Main.class
Classfile /Main.class
Last modified 2025. 3. 24.; size 550 bytes
SHA-256 checksum 12f8559ad79f05eb956f9d94f23bd5f079917ee8dd906f956068fd067bb9d50f
Compiled from "Main.java"
public class com.Main
minor version: 0
major version: 67
flags: (0x0021) ACC_PUBLIC, ACC_SUPER
this_class: #21 // com/Main
super_class: #2 // java/lang/Object
interfaces: 0, fields: 0, methods: 2, attributes: 1
Constant pool:
#1 = Methodref #2.#3 // java/lang/Object."<init>":()V
#2 = Class #4 // java/lang/Object
#3 = NameAndType #5:#6 // "<init>":()V
#4 = Utf8 java/lang/Object
#5 = Utf8 <init>
#6 = Utf8 ()V
#7 = Fieldref #8.#9 // java/lang/System.out:Ljava/io/PrintStream;
#8 = Class #10 // java/lang/System
#9 = NameAndType #11:#12 // out:Ljava/io/PrintStream;
#10 = Utf8 java/lang/System
#11 = Utf8 out
#12 = Utf8 Ljava/io/PrintStream;
#13 = String #14 // Hello World!
#14 = Utf8 Hello World!
#15 = Methodref #16.#17 // java/io/PrintStream.println:(Ljava/lang/String;)V
#16 = Class #18 // java/io/PrintStream
#17 = NameAndType #19:#20 // println:(Ljava/lang/String;)V
#18 = Utf8 java/io/PrintStream
#19 = Utf8 println
#20 = Utf8 (Ljava/lang/String;)V
#21 = Class #22 // com/Main
#22 = Utf8 com/Main
#23 = Utf8 Code
#24 = Utf8 LineNumberTable
#25 = Utf8 main
#26 = Utf8 ([Ljava/lang/String;)V
#27 = Utf8 RuntimeVisibleAnnotations
#28 = Utf8 Lcom/RuntimeAnnotation;
#29 = Utf8 RuntimeInvisibleAnnotations
#30 = Utf8 Lcom/ClassAnnotation;
#31 = Utf8 SourceFile
#32 = Utf8 Main.java
{
public com.Main();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 3: 0
public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: (0x0009) ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=1, args_size=1
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello World!
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return
LineNumberTable:
line 9: 0
line 10: 8
RuntimeVisibleAnnotations: <--- RetentionPolicy.RUNTIME 애너테이션
0: #28()
com.RuntimeAnnotation
RuntimeInvisibleAnnotations: <--- RetentionPolicy.CLASS 애너테이션
0: #30()
com.ClassAnnotation
}
SourceFile: "Main.java"
실제로 컴파일된 바이트코드를 확인해보니 컴파일 시점에 폐기되어 com.SourceAnnotation
을 확인할 수 없었다.
바이트코드까지 생존한 com.RuntimeAnnotation
, com.ClassAnnotation
도 차이가 있는데, RUNTIME은 RuntimeVisibleAnnotations
, CLASS는 RuntimeInvisibleAnnotations
에 포함된 상태로 출력되었다.
이후 리플렉션으로 실험해보니 실제로 출력된 것처럼, RuntimeVisibleAnnotations
는 리플렉션으로 런타임에 확인할 수 있었지만, RuntimeInvisibleAnnotations
는 리플렉션으로도 확인할 수 없었다.
바이트 코드, 리플렉션 실험에서 RetentionPolicy.SOURCE
, RetentionPolicyCLASS
는 리플렉션을 사용해 런타임에 동적으로 처리하는게 불가능하다는 것을 확인했다.
RetentionPolicy.RUNTIME
만을 사용해봤기에 몇 가지 의문점이 생겼다.
애너테이션을 리플렉션으로 활용할 수 없다면 RetentionPolicy.SOURCE
, RetentionPolicy.CLASS
은 어디에 사용되는지
RetentionPolicy.CLASS
는 바이트코드까지만 존재하고 JVM에는 유지되지 않는데, 그럼 RetentionPolicy.SOURCE
와 어떤 차이점이 있는 것인지
대부분의 Retention엔 RetentionPolicy.RUNTIME
가 적용된 것 같았고, RetentionPolicy.SOURCE
가 적용된 애너테이션은 많지 않았다. org.intellij.lang.annotations
, org.jetbrains.annotations
등 IDE에서 사용되는 경우가 조금 있었다.
흔히 사용하던 애너테이션에 RetentionPolicy.SOURCE
가 적용된 경우가 있었다.
supertype의 메서드를 오버라이딩하거나 인터페이스의 메서드를 구현할 때 사용했던 @Override
에RetentionPolicy.SOURCE
가 적용되어 있었다.
/**
* Indicates that a method declaration is intended to override a * method declaration in a supertype. If a method is annotated with * this annotation type compilers are required to generate an error * message unless at least one of the following conditions hold: * * <ul><li>
* The method does override or implement a method declared in a
* supertype. * </li><li>
* The method has a signature that is override-equivalent to that of
* any public method declared in {@linkplain Object}.
* </li></ul>
*
* @author Peter von der Ahé
* @author Joshua Bloch
* @jls 8.4.8 Inheritance, Overriding, and Hiding
* @jls 9.4.1 Inheritance and Overriding
* @jls 9.6.4.4 @Override
* @since 1.5
*/@Target(ElementType.METHOD)
@Retention(RetentionPolicy.SOURCE)
public @interface Override {
}
@Override
가 어떤 역할을 하는지 확인해보기 위해 간단하게 실험해보았다.
오버라이딩했지만 @Override
를 붙이지 않아도 에러가 발생하지 않는다.
반대로 오버라이딩 안했는데 @Override
를 붙이면 에러가 발생한다.
/Users/socra167/IdeaProjects/annotation-retention/src/main/java/com/SubClass.java:5: error: method does not override or implement a method from a supertype
@Override
^
open-jdk 에서 에러 메시지로 검색해 다음 코드를 발견했다.
@Override
를 적용했지만, 실제로는 오버라이딩 하지 않은 경우,
static method에 @Override
를 적용한 경우에 해당하는 오류 메시지를 출력하도록 동작한다.
비슷하게, @SuppressWarnings
, @Serial
도 컴파일러에서 처리를 위한 애너테이션으로 RetentionPolicy.SOURCE
가 적용되어 있었다.
이런 사용 용도를 보아 컴파일러에 의해 폐기되는 RetentionPolicy.SOURCE
는 컴파일 단계에서 사용하기 위한 목적으로 사용한다는 것을 알 수 있다.
앞에서 알아본 바에 따르면 @Override
는 개발자의 실수를 줄이기 위해 사용되고, @SuppressWarnings
는 개발자가 컴파일 경고를 인지했다는 의미로 경고를 제외시킬 목적으로 사용된다.
다른 목적으로 사용되는 Retention.SOURCE
애너테이션도 있다.
Lombok의 @Getter
, @Setter
도 Retention.SOURCE
가 적용되어 있다.
@Target({ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.SOURCE)
public @interface Getter
@Target({ElementType.FIELD, ElementType.TYPE})
@Retention(RetentionPolicy.SOURCE)
public @interface Setter
Lombok은 @Getter
, @Setter
를 붙이면 컴파일 시점에 바이트코드에 실제로 코드를 생성해 Getter, Setter를 만들어준다.
@Getter
, @Setter
를 적용한 Mushroom.java 클래스
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class Mushroom {
private String name;
private int age;
private int height;
}
컴파일된 Mushroom.class 바이트코드
컴파일된 바이트코드에 getter, setter가 추가되어 있다.
public com.Mushroom();
descriptor: ()V
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
LineNumberTable:
line 8: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/Mushroom;
public java.lang.String getName();
descriptor: ()Ljava/lang/String;
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #7 // Field name:Ljava/lang/String;
4: areturn
LineNumberTable:
line 9: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/Mushroom;
RuntimeInvisibleAnnotations:
0: #28()
lombok.Generated
public int getAge();
descriptor: ()I
flags: (0x0001) ACC_PUBLIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1: getfield #13 // Field age:I
4: ireturn
LineNumberTable:
line 10: 0
LocalVariableTable:
Start Length Slot Name Signature
0 5 0 this Lcom/Mushroom;
RuntimeInvisibleAnnotations:
0: #28()
lombok.Generated
...
코드 생성은 컴파일 시점에만 사용되고, 이후로는 유지할 필요가 없으므로 RetentionPolicy.SOURCE
를 선택하는 건 합리적이다.
하지만 외부 라이브러리인 Lombok은 어떻게 컴파일러를 수정하지 않고도 이런 동작이 가능할까?
Lombok을 사용해보았다면 annotation processor
알림을 보고 설정한 적이 있을 것이다.
Annotation Processor는 게임에 모드를 추가하는 것과 비슷하다.
@Override
같은 기본 애너테이션은 자바 컴파일러에서 알아서 해석하고 처리한다.
하지만 개발자가 직접 정의한 애너테이션을 처리하려면 Annotation Processor를 사용해야 한다.
Annotation Processor를 사용해서 컴파일 시점에 코드를 추가하거나 처리하는 작업이 가능해진다.
javac 명령어로 annotation processor를 적용하기
javac -processor MyAnnotationProcessor MyClass.java
Annotation Processor는 Lombok말고도
@Service
, @Autowired
등으로 빈 등록, 의존성 주입 등 메타데이터를 설정할 때,@Entity
, @Id
가 적용된 엔티티 클래스를 바탕으로 SQL 쿼리를 자동 생성할 때(위 애너테이션들은 Annotation Processor에서도 처리하지만, 리플렉션으로도 사용되기 때문에 RetentionPolicy.RUNTIME
이 적용되어 있다.)
Annotation Processor에 대한 내용은 따로 정리해보겠다.
RetentionPolicy.CLASS
가 가장 어디에 사용하는지 짐작하기 어려운 Retention 정책이었다.
RetentionPolicy.CLASS
는 애너테이션이 클래스 파일에 포함되지만, JVM에선 유지하지 않아서 런타임에는(리플렉션으로) 사용할 수 없다.
알아보니 RetentionPolicy.CLASS
가 RetentionPolicy.SOURCE
보다 유의미하게 쓰이는 경우는 바이트코드 분석/조작이 거의 유일한 것 같다.
Spring에서 프록시 생성에 사용하는 바이트 조작 라이브러리 ByteBuddy에서 RetentionPolicy.CLASS
가 적용된 애너테이션을 찾을 수 있었다.
net.bytebuddy.implementation.auxiliary.AuxiliaryType
/**
* A marker to indicate that an auxiliary type is part of the instrumented types signature. This information can be used to load a type before * the instrumented type such that reflection on the instrumented type does not cause a {@link NoClassDefFoundError}.
*/@Retention(RetentionPolicy.CLASS)
@Target(ElementType.TYPE)
@interface SignatureRelevant {
/* empty */
}
net.bytebuddy.implementation.auxiliary.TrivialType
public DynamicType make(String auxiliaryTypeName,
ClassFileVersion classFileVersion,
MethodAccessorFactory methodAccessorFactory) {
return new ByteBuddy(classFileVersion)
.with(TypeValidation.DISABLED)
.with(MethodGraph.Empty.INSTANCE) // avoid parsing the graph
.subclass(Object.class, ConstructorStrategy.Default.NO_CONSTRUCTORS)
.annotateType(eager
? Collections.singletonList(AnnotationDescription.Builder.ofType(SignatureRelevant.class).build(false))
: Collections.<AnnotationDescription>emptyList())
.name(auxiliaryTypeName)
.modifiers(DEFAULT_TYPE_MODIFIER)
.make();
}
결국 일반적으로 커스텀 애너테이션을 리플렉션
으로 사용할 거라면 RetentionPolicy.RUNTIME
을 사용하면 된다.
그래도 궁금하던 다른 RetentionPolicy
들이 어떤 목적으로 사용되는지 알게 되었다!
물론 RetentionPolicy.RUNTIME
애너테이션을 anotation processor으로 사용하거나 바이트 코드 분석, 조작하는 것도 가능하다.
예를 들면, Spring 프레임워크에서
@Service
,@Controller
등은 바이트코드 분석으로도 사용하고, 리플렉션으로도 사용한다.Spring 프레임워크에서 빈 등록, DI(의존성 주입) 과정에서 리플렉션으로 활용하고 있어 RUNTIME이 적용되어 있다.
@Service
,@Controller
등의 빈 등록을 위해 내부의@Component
로 컴포넌트 스캔 중
클래스 파일 내의 특정 애너테이션을 분석할 때 Spring은.class
바이트코드의 메타 데이터를 스캔한다. 이 과정에서는 바이트코드까지만 유지해도 되므로RetentionPolicy.CLASS
로도 가능하다.
https://github.com/spring-projects/spring-framework/blob/main/spring-context/src/main/java/org/springframework/context/annotation/ClassPathScanningCandidateComponentProvider.java#L313하지만 이후 빈 등록, 의존성 주입 과정에서 컴포넌트를 등록할 때 리플렉션 API를 사용해 클래스에 적용된 애너테이션을 실행 중에 읽고, 해당 클래스가 빈으로 등록될 수 있도록 처리하기 때문에
RetentionPolicy.RUNTIME
이 필요하다.
https://github.com/spring-projects/spring-framework/blob/main/spring-context/src/main/java/org/springframework/context/annotation/AnnotationConfigApplicationContext.java#L91
RetentionPolicy
마다 정해진 역할이 독립적으로 있는 게 아니라 애너테이션의 유지 시간이 길어짐에 따라 할 수 있는게 확장된다고 보아야겠다.
피터 파커(Peter Parker) 이론에 따르면 큰 힘에는 큰 책임이 따른다. RetentionPolicy
도 컴파일 단계에서만 필요한 에너테이션을 RUNTIME까지 유지하면 불필요한 자원 낭비가 발생하므로 사용하는 목적에 맞게 최소한의 유지 정책을 설정하는게 합리적이다.
JVM의 메모리 효율성과 리플렉션 성능을 고려해서, 애너테이션을 사용하려는 용도에 맞게 최소한의 유지 정책을 설정하면 되겠다.
원초적인 질문이지만,
Spring 프레임워크에서 사용하는 대부분의 기본 애너테이션(@Service, @Autowired 등)은 왜 모두 RetentionPolicy.RUNTIME을 사용하는 건가요?