Annotation이란 ?
애노테이션이란 무엇일까요?
사실 우리 모두가 이미 정의된 애노테이션을 쓰고 있습니다. 예를 들면, @Override 어노테이션을 사용하여 메소드를 재정의하고 싱글톤 패턴을 사용하기 위해 @Singleton을 사용하고 @NonNull, @StringRes, @IntRes 등과 같은 애노테이션을 사용합니다. 이러한 각가의 애노테이션에 대해서 설명하고자 하는건 아닙니다. 새로운 애노테이션을 만들고, 또 동작하는 원리에 대해서 알아보고자 합니다.
애노테이션은 자바 소스 코드에 추가 할 수있는 메타 데이터의 한 형태입니다. 클래스, 인터페이스, 메소드, 변수, 매개 변수 등에 추가 할 수 있습니다. 애노테이션은 소스 파일에서 읽을 수도 있고, 컴파일러에 의해 생성된 클래스 파일에 내장되어 읽힐 수도 있으며, Runtime에 Java VM에 의해 유지되어 리플렉션에 의해 읽어 낼 수도 있습니다.
Annotation Processor란?
일반적으로 애노테이션에 대한 코드베이스를 검사, 수정 또는 생성하는데 사용됩니다. 본질적으로 애노테이션 프로세서는 java 컴파일러의 플러그인의 일종입니다. 애노테이션 프로세서를 적재적소에 잘 사용한다면 개발자의 코드를 단순화 할 수 있습니다.
Why Annotation?
왜 애노테이션을 써야할까요?
첫번째 이유, 빠릅니다.
Annotation Processor는 실제로 javac 컴파일러의 일부이므로 모든 처리가 런타임보다는 컴파일시간에 발생합니다. Annotation Processor가 정말 빠른 이유입니다.
두번째 이유, 리플렉션을 사용하지 않습니다.
자바의 리플렉션은 런타임에 많은 예외를 발생시킵니다. 어느 누구도 예외처리를 많이 하는것을 원치는 않습니다. 또한 리플렉션을 비용이 큰 작업이며, Annotation Processor는 리플렉션이 없이 프로그램의 의미 구조를 알수 있게 해줍니다.
세번째 이유, Boilerplate code를 생성해줍니다.
Annotation Processor를 사용하는 가장 큰 이유이자 유용한 기능은 바로 보일러플레이트 코드 생성입니다. ButterKnife, Room, Retrofit등 많은 라이브러리들이 반복되는 지루한 코드로부터 벗어나고자 애노테이션을 사용하고 있습니다.
Annotation Processor는 어떻게 동작하나요?
애노테이션 처리는 여러 라운드에 걸쳐 수행됩니다.
- 자바 컴파일러가 컴파일을 수행합니다. (자바 컴파일러는 애노테이션 프로세서에 대해 미리 알고 있어야 합니다.)
- 실행되지 않은 애노테이션 프로세서들을 수행합니다. (각각의 프로세서는 모두 각자에 역할에 맞는 구현이 되어있어야합니다.)
- 프로세서 내부에서 애노테이션이 달린 Element(변수, 메소드, 클래스 등)들에 대한 처리를 합니다. (보통 이곳에서 자바 클래스를 생성합니다.)
- 컴파일러가 모든 애노테이션 프로세서가 실행되었는지 확인하고, 그렇지 않다면 반복해서 위 작업을 수행합니다.
백문이불여일견, 아래의 이미지를 확인하시면 이해가 쉽게 될 것입니다.
나만의 애노테이션 만들기 (인텐트 빌더 만들기)
다음과 같은 준비를 합니다.
- 새로운 프로젝트를 만듭니다. (자동생성되는 app모듈 포함)
- 새로운 자바 모듈을 만듭니다. 이 모듈의 이름은 annotaion으로 하겠습니다. 단지 커스텀 애노테이션 정의만 하는 모듈이 될것입니다. (이때 안드로이드 스튜디오에 의해 자동생성되는 MyClass와 같은 파일은 삭제하셔도 무관합니다)
- 새로운 애노테이션 프로세서 모듈을 만듭니다. 이 모듈도 자바 모듈입니다. 이 모듈은 모든 계산 및 코드 생성을 수행합니다.
총 3개의 모듈(app, annotation, annotation_processor)이 준비 되었는지 확인합니다.
다음과 같은 의존성을 가집니다.
app/build.gradle
dependencies { ... implementation project(':annotation') annotationProcessor project(':annotation_processor') }
annotation_processor/build.gradle
dependencies { implementation project(':annotation') }
모든 준비가 끝났다면, 이제 애노테이션을 만들기 위해 annotation모듈을 선택하여 CharlesIntent.java파일을 하나 만들도록 하겠습니다.
@Target(ElementType.TYPE) @Retention(RetentionPolicy.SOURCE) public @interface CharlesIntent { }
@interface
컴파일러에게 사용자 정의 애노테이션임을 알려줍니다. CharlesIntent라는 애노테이션을 정의합니다.
@Target
애노테이션을 지정하기 위한 타겟을 정의합니다.(클래스, 메소드, 변수 등)
public enum ElementType { TYPE, //class, interface, enum 등에 애노테이션을 지정할 때 FIELD, //멤버변수에 애노테이션을 지정할 때 METHOD, //메소드에 애노테이션을 지정할 때 PARAMETER, //매개변수에 애노테이션을 지정할 때 CONSTRUCTOR, //생성자에 애노테이션을 지정할 때 LOCAL_VARIABLE, //지역 변수 애노테이션을 지정할 때 ANNOTATION_TYPE, //애노테이션타입에 애노테이션을 지정할 때 PACKAGE, //패키지에 애노테이션을 지정할 때 TYPE_PARAMETER, //매개변수 타입에 애노테이션을 지정할 때 TYPE_USE; //타입 사용시에 애노테이션을 지정할 때 private ElementType() { } }
@Retention
사용자 정의 애노테이션이 저장되는 타입을 나타냅니다. 3가지 타입이 있습니다.
- SOURCE : 컴파일러에 의해 분석되고 저장되지 않습니다.
- CLASS : 클래스 파일에 저장되고 런타임에는 유지되지 않습니다
- RUNTIME : 클래스 파일에 저장하고 리플렉션에 의해 런타임에 사용가능합니다.
애노테이션을 만들었다면 다음과 같이 MainActivity에 애노테이션을 달아봅시다.
@CharlesIntent public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); } }
애노테이션 프로세서 만들기
annotation_processor모듈을 선택하여 CharlesProcessor.java파일을 하나 만들고 다음과 같이 작성합니다.
public class CharlesProcessor extends AbstractProcessor { @Override public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnv) { return false; } }
AbstractProcessor를 상속하면, process를 반드시 구현해야합니다. 나중에 이부분에서 애노테이션 처리를 하게 됩니다. 몇가지 더 작성해보도록 하겠습니다.
public class CharlesProcessor extends AbstractProcessor { @Override public synchronized void init(ProcessingEnvironment processingEnvironment) { super.init(processingEnvironment); //프로세싱에 필요한 기본적인 정보들을 processingEnvironment 부터 가져올 수 있습니다. } @Override public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) { System.out.println("애노테이션 프로세싱!!");//프로세싱이 되는지 확인하기 위한 로그 확인용입니다. return false; } @Override public Set<String> getSupportedAnnotationTypes() { return new HashSet<String>(){ { add(CharlesIntent.class.getCanonicalName());// 어떤 애노테이션을 처리할 지 Set에 추가합니다. } }; } @Override public SourceVersion getSupportedSourceVersion() { return SourceVersion.latestSupported();//지원되는 소스 버전을 리턴합니다. } }
- init() : 파일을 생성하기 위해 필요한 Filer나 디버깅에 필요한 Messager, 각종 유틸클래스들을 이곳에서 받을 수 있습니다.
- process() : 프로세서의 핵심 부분입니다. 이곳에서 클래스, 메소드, 필드 등에 추가한 애노테이션을 처리하고 처리에 대한 결과로 자바 파일을 생성할 수 있습니다.
- getSupportedAnnotationType() : 어떤 애노테이션들을 처리할 지 Set형태로 리턴하게 됩니다.
- getSupportedSourceVersion() : 일반적으로 최신의 자바 버전을 리턴합니다.
getSupportedAnnotationTypes에 제가 만든 CharlesIntent 애노테이션을 추가하였지만 아직 프로세서가 동작하지 않습니다. 컴파일러에게 제가 만든 프로세서를 등록하는 작업이 필요 합니다.
애노테이션 프로세서 등록하는 방법
애노테이션 프로세서를 등록하기 위해서는 annotation_processor 모듈레벨에서 다음과 같이 폴더를 생성합니다.
annotation_processor/src/main/resources/META-INF/services
그런 뒤 파일을 하나 만듭니다. 파일이름은 반드시 다음과 같아야 합니다.
javax.annotation.processing.Processor
파일을 열어 애노테이션 프로세서의 패키지명을 포함하고 있는 CanonicalName을 적습니다.
com.charlezz.annotation_processor.CharlesProcessor
Warning : 패키지명은 만드신 모듈의 패키지명을 적으셔야합니다.
이제 app모듈을 빌드 해봅니다. 애노테이션 프로세서가 정상적으로 작동되었다면, 다음과 같은 로그를 확인 하실 수 있습니다.
애노테이션 프로세서 등록하는 쉬운방법
위의 절차가 까다롭다면 구글의 auto-service 라이브러리를 이용하는 방법도 있습니다. 원리는 같지만, 간단하게 @AutoService 애노테이션을 이용하여 자동으로 애노테이션 프로세서를 컴파일러 등록하는 메타데이터 파일을 생성해줍니다.
annotation_processeor모듈에 다음과 같이 auto-service 의존성을 추가합니다.
implementation 'com.google.auto.service:auto-service:1.0-rc5'
그런 뒤, 애노테이션 프로세서파일을 열어 다음과 같이 애노테이션을 추가합니다.
@AutoService(Processor.class) public class CharlesProcessor extends AbstractProcessor { ... }
@AutoService(Processor.class)만 추가해주면 끝입니다.
이제 app을 다시 빌드하면 메타데이터가 생성되고, 애노테이션 프로세서가 동작하는것을 확인하실 수 있습니다.
JavaPoet으로 파일 생성하기
JavaPoet은 java 소스 파일을 생성하기위한 라이브러리입니다. 이 라이브러리와 애노테이션 프로세서를 이용하여 인텐트 빌더를 만들겁니다.
JavaPoet에 자세한 내용은 JavaPoet github를 참조해주세요.
먼저 annotation_processor모듈에 JavaPoet 의존성을 추가합니다.
implementation 'com.squareup:javapoet:1.11.1'
public class CharlesProcessor extends AbstractProcessor { private static final ClassName intentClass = ClassName.get("android.content", "Intent"); private static final ClassName contextClass = ClassName.get("android.content", "Context"); private static final String METHOD_PREFIX_NEW_INTENT = "intentFor"; ArrayList<MethodSpec> newIntentMethodSpecs = new ArrayList<>(); private String packageName; @Override public synchronized void init(ProcessingEnvironment processingEnvironment) { super.init(processingEnvironment); } @Override public boolean process(Set<? extends TypeElement> set, RoundEnvironment roundEnvironment) { final Set<? extends Element> elements = roundEnvironment.getElementsAnnotatedWith(CharlesIntent.class); for (Element element : elements) { if(packageName==null){ Element e = element; while (!(e instanceof PackageElement)) { e = e.getEnclosingElement(); } packageName = ((PackageElement)e).getQualifiedName().toString(); } if (element.getKind() != ElementKind.CLASS) { processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "CharlesIntent can only use for classes!"); return false; } newIntentMethodSpecs.add(generateMethod((TypeElement) element)); } if (roundEnvironment.processingOver()) { try { generateJavaFile(newIntentMethodSpecs); return true; } catch (IOException ex) { processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, ex.toString()); } } return true; } @Override public Set<String> getSupportedAnnotationTypes() { return new HashSet<String>(){ { add(CharlesIntent.class.getCanonicalName()); } }; } @Override public SourceVersion getSupportedSourceVersion() { return SourceVersion.latestSupported(); } private MethodSpec generateMethod(TypeElement element) { return MethodSpec .methodBuilder(METHOD_PREFIX_NEW_INTENT + element.getSimpleName()) .addModifiers(Modifier.PUBLIC, Modifier.STATIC) .addParameter(contextClass, "context") .returns(intentClass) .addStatement("return new $T($L, $L)", intentClass, "context", element.getQualifiedName() + ".class") .build(); } private void generateJavaFile(List<MethodSpec> methodSpecList) throws IOException { System.out.println("methodSpecList Count = "+methodSpecList.size()); final TypeSpec.Builder builder = TypeSpec.classBuilder("Charles"); builder.addModifiers(Modifier.PUBLIC, Modifier.FINAL); for (MethodSpec methodSpec : methodSpecList) { builder.addMethod(methodSpec); } final TypeSpec typeSpec = builder.build(); JavaFile.builder(packageName, typeSpec) .build() .writeTo(processingEnv.getFiler()); } }
빌드를 한번 수행하고나면 다음과 같이 Charels.java파일이 생성된것을 확인할 수 있습니다.
public final class Charles { public static Intent intentForMainActivity(Context context) { return new Intent(context, com.charlezz.annotationprocessorstudy.MainActivity.class); } public static Intent intentForSecondActivity(Context context) { return new Intent(context, com.charlezz.annotationprocessorstudy.SecondActivity.class); } }
이제 다음과 같이 액티비티를 시작할 수 있습니다.
예제 프로젝트는 github에서 확인하실 수 있습니다.
Conclusion
애노테이션 프로세서를 이용하여 Boilerplate 코드를 없애는 방법에 대해서 알아보았습니다. 최대한 간단한 예제로 만들려고 인텐트 빌더를 만드는 것을 택했는데도 쉽지 않아보입니다 ㅠㅠ 코드를 조금만(?) 손본다면 Intent의 Extra를 추가할 수도 있습니다. 사실 이미 그런 컨셉을 가진 라이브러리들이 많습니다. 부디 가지고 계신 좋은 아이디어를 통해 인텐트 빌더가 아닌 Retrofit이나 Dagger만큼이나 유용한 라이브러리를 우리 모두 만들어봅시다
2개의 댓글
익명 · 2019년 12월 11일 3:19 오후
“`
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
public @interface CharlesIntent {
}
“`
RUNTIME보단 SOURCE가 더 적절하지 않을까요??
Charlezz · 2019년 12월 11일 11:31 오후
그렇네요 알려주셔서 감사합니다.