최근 프로젝트에서 Custom Annotation을 만들 때 @Retention(RetentionPolicy.RUNTIME)를 사용해 Annotation이 Runtime까지 유지되도록 설정해 사용했다.

SpringBoot를 사용하다 보면 @Controller, @Service, @Repository, @Component, @Transactional 등 자주 사용되는 애너테이션을 살펴보면 항상 @Retention(RetentionPolicy.RUNTIME)를 포함하고 있다.

RetentionPolicy 라는 Enum이 존재한다는 것에서 다른 타입도 존재한다는 것을 알 수 있다. 자연스럽게 다른 RetentionPolicy들은 언제 사용하는지 궁금증이 생겨 알아보았다.


RetentionPolicy

RetentionPolicyjava.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

  • Annotation은 컴파일러에 의해 제거된다.

CLASS

  • 컴파일러가 Annotation을 클래스 파일에 기록해야 하지만 실행 시 VM에 의해 유지될 필요는 없다. 기본값으로 동작한다.

RUNTIME

  • 컴파일러가 Annotation을 클래스 파일에 기록해야 한다.
  • 실행 시 VM에 의해 유지되므로 리플렉션으로 읽을 수 있다.

애너테이션이 바이트코드에 포함되는지 확인하기

이해한 바가 맞다면, 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만을 사용해봤기에 몇 가지 의문점이 생겼다.

  1. 애너테이션을 리플렉션으로 활용할 수 없다면 RetentionPolicy.SOURCE, RetentionPolicy.CLASS은 어디에 사용되는지

  2. RetentionPolicy.CLASS는 바이트코드까지만 존재하고 JVM에는 유지되지 않는데, 그럼 RetentionPolicy.SOURCE와 어떤 차이점이 있는 것인지

RetentionPolicy.SOURCE

대부분의 Retention엔 RetentionPolicy.RUNTIME가 적용된 것 같았고, RetentionPolicy.SOURCE가 적용된 애너테이션은 많지 않았다. org.intellij.lang.annotations, org.jetbrains.annotations 등 IDE에서 사용되는 경우가 조금 있었다.

흔히 사용하던 애너테이션에 RetentionPolicy.SOURCE가 적용된 경우가 있었다.

supertype의 메서드를 오버라이딩하거나 인터페이스의 메서드를 구현할 때 사용했던 @OverrideRetentionPolicy.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&eacute;  
 * @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에서 RetentionPolicy.SOURCE

Lombok의 @Getter, @SetterRetention.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은 어떻게 컴파일러를 수정하지 않고도 이런 동작이 가능할까?

Annotation Processor

Lombok을 사용해보았다면 annotation processor 알림을 보고 설정한 적이 있을 것이다.
Annotation Processor는 게임에 모드를 추가하는 것과 비슷하다.

@Override 같은 기본 애너테이션은 자바 컴파일러에서 알아서 해석하고 처리한다.
하지만 개발자가 직접 정의한 애너테이션을 처리하려면 Annotation Processor를 사용해야 한다.
Annotation Processor를 사용해서 컴파일 시점에 코드를 추가하거나 처리하는 작업이 가능해진다.

javac 명령어로 annotation processor를 적용하기

javac -processor MyAnnotationProcessor MyClass.java

Annotation Processor는 Lombok말고도

  • Spring에서 @Service, @Autowired 등으로 빈 등록, 의존성 주입 등 메타데이터를 설정할 때,
  • Spring Data JPA에서 @Entity, @Id가 적용된 엔티티 클래스를 바탕으로 SQL 쿼리를 자동 생성할 때
    에도 사용된다.

(위 애너테이션들은 Annotation Processor에서도 처리하지만, 리플렉션으로도 사용되기 때문에 RetentionPolicy.RUNTIME이 적용되어 있다.)

Annotation Processor에 대한 내용은 따로 정리해보겠다.

RetentionPolicy.CLASS

RetentionPolicy.CLASS가 가장 어디에 사용하는지 짐작하기 어려운 Retention 정책이었다.
RetentionPolicy.CLASS는 애너테이션이 클래스 파일에 포함되지만, JVM에선 유지하지 않아서 런타임에는(리플렉션으로) 사용할 수 없다.

알아보니 RetentionPolicy.CLASSRetentionPolicy.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의 메모리 효율성과 리플렉션 성능을 고려해서, 애너테이션을 사용하려는 용도에 맞게 최소한의 유지 정책을 설정하면 되겠다.

2개의 댓글

comment-user-thumbnail
2025년 3월 26일

원초적인 질문이지만,

Spring 프레임워크에서 사용하는 대부분의 기본 애너테이션(@Service, @Autowired 등)은 왜 모두 RetentionPolicy.RUNTIME을 사용하는 건가요?

1개의 답글

관련 채용 정보

Powered by GraphCDN, the GraphQL CDN