Lombok 너의 내부가 알고싶어!(feat: Rexbok 만들기)

DevSeoRex·2023년 8월 21일
15
post-thumbnail

@Getter
@Setter
@NoArgsConstructor
public class Car {

    private String name;
    private int price;
}

Java 개발자라면 누구나 한번쯤 접해보았을 보일러 플레이트 코드 정리의 끝판왕 Lombok입니다.
Spring 의존성을 주입할때도 @RequiredArgsConstructor 를 활용해 참 편하게 주입받아 왔는데요.

그렇다면 Lombok은 애너테이션을 붙이면 어떻게 작성하지도 않은 코드를 만들어주게 되는 것일까요?

🧚 AST(Abstract Syntax Tree) & Annotation Processor

Lombok의 동작을 이해하기 위해서는 두가지 개념에 대한 이해가 필요합니다.
첫 번째는 AST(Abstract Syntax Tree), 두 번째는 Annotation Processor 입니다.

Annotation Processor

Annotation Processor는 소스 코드에 포함된 애너테이션을 처리하고 이를 기반으로 추가적인 작업을 수행하는 도구입니다.

Java 컴파일러에 의해 호출되며, 컴파일 시간에 코드를 생성하거나 수정하는 등의 작업을 수행할 수 있습니다.

AST(Abstract Syntax Tree)

AST는 추상 구문 트리라고도 부르며, Java에만 국한된 개념이 아닌 컴파일러가 필요한 프로그래밍 언어라면 어디서든 들어볼 수 있는 용어입니다.

AST는 컴파일 과정 중 구분 분석의 결과물로, 개발자가 작성한 Java 코드를 파싱하여 만들어집니다.
AST가 추상 구문 트리라고 불리는 이유는 구체적인 코드의 세부사항이나 문법적 세부사항은 나타내지 않기 때문입니다.

public class Car {

    private String name;
    private int price;
}

이런 간단한 클래스가 있다고 해보겠습니다.
이 클래스의 소스코드를 파싱하여 AST를 생성하면 어떤 형태가 나올까요?

손으로 그리니 알아보기가 힘드네요.. 똥손 사과드립니다.
가장 상위 노드는 클래스 선언(Class Declaration)이 됩니다.
클래스 선언부의 아래에는 접근제어 수준을 나타내는 노드, 클래스의 식별자(이름), Body(내용)이 있습니다.

Body에는 현재 Car 클래스가 필드만 있어서 Field Declartion만 표현했지만 메서드를 가지고 있다면
Method Declartion도 Body에 연결되게 됩니다.

두 개의 필드를 가지고 있지만, 간단하게 그림을 그리기 위해 String 타입의 name만 표현했습니다.

그렇다면 왜 Lombok의 동작을 이해하려면 ASTAnnotation Processor를 이해해야 할까요?

Lombok의 애너테이션을 소스코드에 붙이게 되면, 소스코드파싱하고 AST를 만든 뒤에 붙인 애너테이션에 따라서 Annotation Processor가 동작하게 되면서 기존 AST수정하게 됩니다.

수정된 AST를 이용해 바이트 코드를 만들게 되고, 생성된 class 파일디컴파일 해보면 내가 작성하지 않은 메서드가 들어가 있는 것을 볼 수 있습니다.

Car 클래스에 메서드가 없었으나, @Getter 또는 @Setter를 이용하여 Method Declaration을 생성해서 기존 ASTBody에 삽입하게 되는 것입니다.

🤔 직접 만들어보는 Lombok(feat: Rexbok 제작하기)

먼저 프로젝트는 gradle multi-module 방식으로 구성했습니다.

가장 중요한 annotation 프로젝트부터 기본 셋팅을 하겠습니다.

plugins {
    id 'java'
}

// JDK 11 버전을 사용
sourceCompatibility = 11

group = 'org.example'
version = '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {
    implementation 'org.apache.commons:commons-lang3:3.12.0'
    testImplementation 'junit:junit:4.13.1'

    compileOnly 'com.google.auto.service:auto-service:1.0-rc7'
    annotationProcessor 'com.google.auto.service:auto-service:1.0-rc7'
}

test {
    useJUnitPlatform()
}

// com.sun.tools.javac 패키지는 현재 접근이 불가능한 상태이므로, --add-exports 옵션을 줘야한다.
compileJava {
    doFirst {
        options.compilerArgs = [
                '--add-exports', 'jdk.compiler/com.sun.tools.javac.processing=ALL-UNNAMED',
                '--add-exports', 'jdk.compiler/com.sun.tools.javac.tree=ALL-UNNAMED',
                '--add-exports', 'jdk.compiler/com.sun.tools.javac.util=ALL-UNNAMED',
                '--add-exports', 'jdk.compiler/com.sun.tools.javac.code=ALL-UNNAMED'
        ]
    }
}

제가 작업한 JDK 11 버전 기준으로는 com.sun.tools.javac 패키지는 외부 접근이 불가한 상태입니다.
따라서 컴파일 옵션을 줘서 빌드해야 문제가 생기지 않습니다.

Lombok이 사용하는 패키지의 클래스들은 공개 목적으로 만들어진 API가 아니였습니다.
따라서 AST를 조작하는 것은 사실상 해킹이라고 합니다.

하위 호환성을 중요하게 여겨서 결함조차 잡지 않는 Java 진영에서 황급히 이 패키지를 접근 불가하게 처리한 것도 이런 이유에서 비롯된 것이 아닐까 생각합니다.

3가지 기능을 만들어보겠습니다.

  1. Getter
  2. Setter
  3. NoArgsConstructor

@Getter 만들기

@Getter를 만들기 전에, 모든 Processor에서 사용하는 TreeModifier 클래스를 작성하겠습니다.

public class TreeModifier {

    private Trees trees;
    private Context context;
    private TreeMaker treeMaker;
    private Names names;
    private TreePathScanner<Object, CompilationUnitTree> scanner;

    public TreeModifier(ProcessingEnvironment processingEnvironment) {
        final JavacProcessingEnvironment javacProcessingEnvironment = (JavacProcessingEnvironment) processingEnvironment;
        this.trees = Trees.instance(processingEnvironment);
        this.context = javacProcessingEnvironment.getContext();
        this.treeMaker = TreeMaker.instance(context);
        this.names = Names.instance(context);
    }

    public void setClassDefModifyStrategy(Consumer<JCTree.JCClassDecl> strategy) {
        this.scanner = new TreePathScanner<>() {
            @Override
            public Trees visitClass(ClassTree node, CompilationUnitTree compilationUnitTree) {
                JCTree.JCCompilationUnit compilationUnit = (JCTree.JCCompilationUnit) compilationUnitTree;
                if (compilationUnit.sourcefile.getKind() == JavaFileObject.Kind.SOURCE) {
                    compilationUnit.accept(new TreeTranslator() {
                        /*
                        *  AST 클래스 노드를 순회하며 AST를 재정의 한다.
                        *  -> 컴파일 타임에 추상 구문 트리(Abstract Syntax Tree)를 조작(원하는 메서드 삽입)
                        * */
                        @Override
                        public void visitClassDef(JCTree.JCClassDecl jcClassDecl) {
                            super.visitClassDef(jcClassDecl);
                            /*
                            *    AST 수정이 이루어지는 부분이다.
                            *    AST 수정 전략은 외부에서 주입 받아 사용할 수 있다.
                            * */
                            strategy.accept(jcClassDecl);
                        }
                    });
                }

                return trees;
            }
        };
    }


    /*
    *  AST를 순회하면서 AST를 조작한다.
    *  -> 원하는 메서드를 실제 삽입하는 부분이다.
    * */
    public void modifyTree(Element element) {
        if (Objects.nonNull(scanner)) {
            final TreePath path = trees.getPath(element);
            scanner.scan(path, path.getCompilationUnit());
        }
    }

    public TreeMaker getTreeMaker() {
        return treeMaker;
    }

    public Names getNames() {
        return names;
    }
}

TreeModifier 클래스는 AST 수정 전략을 주입받아서, AST를 조작합니다.
modifyTree 메서드가 실질적으로 AST를 조작하는 부분입니다.

이제 @Getter를 붙이면 모든 필드에 대해 Getter가 생성되도록 GetterProcessor 클래스를 작성해보겠습니다.

/*
*  Annotation Processor를 쉽게 등록하려는 목적으로 @AutoService를 사용한다.
*  com.rex.annotation 패키지의 Getter 애너테이션을 지원한다.
*  JDK 11 버전을 지원한다.
* */
@AutoService(Processor.class)
@SupportedAnnotationTypes("com.rex.annotation.Getter")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class GetterProcessor extends AbstractProcessor {


    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        // TreeModifier의 객체를 만들어준다.
        TreeModifier treeModifier = new TreeModifier(processingEnv);

        // AST 조작에 필요한 전략을 셋팅해준다.
        treeModifier.setClassDefModifyStrategy(getAppendGetterStrategy(treeModifier));

        // @Getter 애너테이션이 붙은 모든 타입(클래스, 열거타입, 인터페이스)들을 순회한다.
        for (Element element : roundEnv.getElementsAnnotatedWith(Getter.class)) {
            // @Getter 애너테이션이 클래스가 아닌 곳에 있는 경우, 에러 메시지를 빌드 로그에 남겨준다.
            if (element.getKind() != ElementKind.CLASS) {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Annotation Not Supported");
            } else {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Processing >> " + element.getSimpleName().toString());
                // @Getter 애너테이션이 클래스에 붙어 있는 경우, 모든 필드에 대해 Getter를 생성해주는 메서드를 AST 트리 조작으로 삽입한다.
                treeModifier.modifyTree(element);
            }
        }
        return true;
    }


    private Consumer<JCTree.JCClassDecl> getAppendGetterStrategy(TreeModifier treeModifier) {
        TreeMaker treeMaker = treeModifier.getTreeMaker();
        Names names = treeModifier.getNames();

        /*
        *  AST 조작 전략은 Consumer로 제공한다.
        *  -> Consumer로 제공시 이 전략을 사용하는 곳에서 accept만 호출하면 설정된 전략대로 AST 조작이 이루어진다.
        *
        * */
        return jcClassDecl -> {
            List<JCTree> members = jcClassDecl.getMembers();
            for (JCTree member : members) {
                /*
                *  모든 멤버를 순회하면서, 멤버가 변수(Field)일 경우에만 Getter를 만들어준다.
                *  AST에 삽입할 메서드는 JCTree.JCMethodDecl 타입이며, append 메서드를 활용해 삽입할 메서드를 붙여준다.
                * */
                if (member instanceof JCTree.JCVariableDecl) {
                    JCTree.JCMethodDecl getter = createGetter(treeMaker, names, (JCTree.JCVariableDecl) member);
                    jcClassDecl.defs = jcClassDecl.defs.append(getter);
                }
            }
        };
    }

    private JCTree.JCMethodDecl createGetter(TreeMaker treeMaker, Names names, JCTree.JCVariableDecl member) {

        // 필드의 이름을 문자열로 반환받는다.
        String memberName = member.name.toString();

        // 메서드의 명명 규칙은 get + 필드 이름의 첫 글자를 대문자로 + 나머지 문자
        String methodName = "get" + memberName.substring(0, 1).toUpperCase() +
                memberName.substring(1);

        // 메서드가 잘 생성되고 있는지 빌드 로그로 확인할 수 있다.
        processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, methodName);

        return treeMaker.MethodDef(
                treeMaker.Modifiers(1), // flag 번호에 따라 접근 제어나 추상 메서드 등 다양한 제어를 할 수 있다. 1번은 public 이다.
                names.fromString(methodName), // 메서드의 이름은 아까 지어놓은 대로 getXXXX 과 같다.
                (JCTree.JCExpression) member.getType(), // 대상 필드의 타입 그대로 반환하도록 타입을 지정해준다.
                List.nil(), // 필요하지 않은 부분이라도 List를 인수로 받고 있기 때문에 빈 리스트를 넣어준다.
                List.nil(),
                List.nil(),
                /*
                *  메서드의 본문에 해당하는 부분이다.
                *  Block의 flag는 들여쓰기 수준을 말한다.
                *  Ident 메서드는 AST 노드 중 하나로 식별자를 나타낸다. 이렇게 반들어진 식별자는 필드 변수를 나타내는 AST 구조 표현에 사용된다.
                * */
                treeMaker.Block(1,
                  List.of(treeMaker.Return(treeMaker.Ident(member.getName())))
                ),
                null
        );
    }
}

주석은 상세히 달아놓았지만, 한 부분씩 뜯어보겠습니다.

@AutoService(Processor.class)
@SupportedAnnotationTypes("com.rex.annotation.Getter")
@SupportedSourceVersion(SourceVersion.RELEASE_11)

@AutoService를 이용하면 서비스 제공자 클래스를 간편하게 등록할 수 있는 기능을 제공합니다.
이렇게 하지 않으면 매번 서비스 제공자 클래스 등록 코드를 작성해야 하기 때문에 코드의 중복도 발생할 수 있습니다.

@SupportedAnnotationTypes는 어떤 애너테이션에 대해 이 프로세서가 동작할 것인지 정의합니다.
즉, 이 프로세서가 지원애너테이션타입을 지정하는 부분입니다.

@SupportedSourceVersion 은 지원하려는 JDK 버전을 명시하는 부분입니다.
현재 저는 JDK 11 버전을 지원하도록 작성했으므로 11 이외의 버전에서는 동작하지 않습니다.

@Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        // TreeModifier의 객체를 만들어준다.
        TreeModifier treeModifier = new TreeModifier(processingEnv);

        // AST 조작에 필요한 전략을 셋팅해준다.
        treeModifier.setClassDefModifyStrategy(getAppendGetterStrategy(treeModifier));

        // @Getter 애너테이션이 붙은 모든 타입(클래스, 열거타입, 인터페이스)들을 순회한다.
        for (Element element : roundEnv.getElementsAnnotatedWith(Getter.class)) {
            // @Getter 애너테이션이 클래스가 아닌 곳에 있는 경우, 에러 메시지를 빌드 로그에 남겨준다.
            if (element.getKind() != ElementKind.CLASS) {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Annotation Not Supported");
            } else {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Processing >> " + element.getSimpleName().toString());
                // @Getter 애너테이션이 클래스에 붙어 있는 경우, 모든 필드에 대해 Getter를 생성해주는 메서드를 AST 트리 조작으로 삽입한다.
                treeModifier.modifyTree(element);
            }
        }
        return true;
    }

이 부분이 AST를 조작하는 핵심 메서드 입니다.
이 메서드 에서는TreeModifer의 객체를 생성하고, 조작에 필요한 전략셋팅해줍니다.

@Getter 애너테이션이 붙은 모든 클래스를 순회하면서, modifyTree 메서드를 호출해 AST조작합니다.

마지막에 truereturn 하는 이유는 무엇일까요?
그 이유는 javax.annotation.processing.AbstractProcessor 클래스의 규약 때문입니다.

  • true
    true를 반환할때는 Annotation Processor가 처리할 요소가 없거나 모든 요소에 대한 처리가 완료되었음을 알려주는 표시입니다.
  • false
    Annotation Processors는 아직 처리해야 할 요소가 남아있다는 표시입니다. 다음 라운드를 진행하지 말아야 하며, 더 많은 작업을 해야한다는 것을 의미합니다.
private Consumer<JCTree.JCClassDecl> getAppendGetterStrategy(TreeModifier treeModifier) {
        TreeMaker treeMaker = treeModifier.getTreeMaker();
        Names names = treeModifier.getNames();

        /*
        *  AST 조작 전략은 Consumer로 제공한다.
        *  -> Consumer로 제공시 이 전략을 사용하는 곳에서 accept만 호출하면 설정된 전략대로 AST 조작이 이루어진다.
        *
        * */
        return jcClassDecl -> {
            List<JCTree> members = jcClassDecl.getMembers();
            for (JCTree member : members) {
                /*
                *  모든 멤버를 순회하면서, 멤버가 변수(Field)일 경우에만 Getter를 만들어준다.
                *  AST에 삽입할 메서드는 JCTree.JCMethodDecl 타입이며, append 메서드를 활용해 삽입할 메서드를 붙여준다.
                * */
                if (member instanceof JCTree.JCVariableDecl) {
                    JCTree.JCMethodDecl getter = createGetter(treeMaker, names, (JCTree.JCVariableDecl) member);
                    jcClassDecl.defs = jcClassDecl.defs.append(getter);
                }
            }
        };
    }

이 부분은 Getter를 생성하는 AST 수정 전략을 얻어오는 메서드입니다.
클래스가 가진 모든 멤버(필드, 메서드)를 순회하면서 찾은 멤버가 필드일때만 Getter를 추가하도록 jcClassDecl의 defs에 append 해줍니다.

Getter를 추가하려면 appendprepend 두 가지가 사용가능 한데, append를 사용하면 소스코드의 뒤쪽에 붙여지고 prepend를 사용하면 소스코드의 앞에 붙여지는 것 이외에는 큰 변화가 없습니다.

private JCTree.JCMethodDecl createGetter(TreeMaker treeMaker, Names names, JCTree.JCVariableDecl member) {

        // 필드의 이름을 문자열로 반환받는다.
        String memberName = member.name.toString();

        // 메서드의 명명 규칙은 get + 필드 이름의 첫 글자를 대문자로 + 나머지 문자
        String methodName = "get" + memberName.substring(0, 1).toUpperCase() +
                memberName.substring(1);

        // 메서드가 잘 생성되고 있는지 빌드 로그로 확인할 수 있다.
        processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, methodName);

        return treeMaker.MethodDef(
                treeMaker.Modifiers(1), // flag 번호에 따라 접근 제어나 추상 메서드 등 다양한 제어를 할 수 있다. 1번은 public 이다.
                names.fromString(methodName), // 메서드의 이름은 아까 지어놓은 대로 getXXXX 과 같다.
                (JCTree.JCExpression) member.getType(), // 대상 필드의 타입 그대로 반환하도록 타입을 지정해준다.
                List.nil(), // 필요하지 않은 부분이라도 List를 인수로 받고 있기 때문에 빈 리스트를 넣어준다.
                List.nil(),
                List.nil(),
                /*
                *  메서드의 본문에 해당하는 부분이다.
                *  Block의 flag는 들여쓰기 수준을 말한다.
                *  Ident 메서드는 AST 노드 중 하나로 식별자를 나타낸다. 이렇게 반들어진 식별자는 필드 변수를 나타내는 AST 구조 표현에 사용된다.
                * */
                treeMaker.Block(1,
                  List.of(treeMaker.Return(treeMaker.Ident(member.getName())))
                ),
                null
        );
    }

이 부분이 실제 Getter의 메서드를 만들어내는 부분입니다.
필드의 이름get 키워드를 혼합해서 메서드의 이름을 만들어내고, 중간에 이 메서드가 잘 호출됬는지 확인합니다.

processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, methodName);

println을 사용하지 않고, 이렇게 메시지를 확인하는 이유는 컴파일 타임에 발생하는 로그이기 때문에 println을 사용하면 아무것도 확인할 수 없기 때문입니다.

각각의 내용은 주석으로 달아 두었으니 설명은 생략하겠습니다.

@Setter 만들기

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface Setter {
}

간단하게 애너테이션을 먼저 정의해줍니다.
컴파일타임에만 이 애너테이션의 정보가 필요하기 때문에 RetentionPolicySOURCE 입니다.

@AutoService(Processor.class)
@SupportedAnnotationTypes("com.rex.annotation.Setter")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class SetterProcessor extends AbstractProcessor {


    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        // TreeModifier의 객체를 만들어준다.
        TreeModifier treeModifier = new TreeModifier(processingEnv);

        // AST 조작에 필요한 전략을 셋팅해준다.
        treeModifier.setClassDefModifyStrategy(getAppendSetterStrategy(treeModifier));

        // @Setter 애너테이션이 붙은 모든 타입(클래스, 열거타입, 인터페이스)들을 순회한다.
        for (Element element : roundEnv.getElementsAnnotatedWith(Setter.class)) {
            if (element.getKind() != ElementKind.CLASS) {
                // @Setter 애너테이션이 클래스가 아닌 곳에 있는 경우, 에러 메시지를 빌드 로그에 남겨준다.
                processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Annotation Not Supported");
            } else {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Processing >> " + element.getSimpleName().toString());
                // @Setter 애너테이션이 클래스에 붙어 있는 경우, 모든 필드에 대해 Setter를 생성해주는 메서드를 AST 트리 조작으로 삽입한다.
                treeModifier.modifyTree(element);
            }
        }
        return true;
    }


    private Consumer<JCTree.JCClassDecl> getAppendSetterStrategy(TreeModifier treeModifier) {
        TreeMaker treeMaker = treeModifier.getTreeMaker();
        Names names = treeModifier.getNames();

        /*
         *  AST 조작 전략은 Consumer로 제공한다.
         *  -> Consumer로 제공시 이 전략을 사용하는 곳에서 accept만 호출하면 설정된 전략대로 AST 조작이 이루어진다.
         *
         * */
        return jcClassDecl -> {
            List<JCTree> members = jcClassDecl.getMembers();
            for (JCTree member : members) {
                /*
                 *  모든 멤버를 순회하면서, 멤버가 변수(Field)일 경우에만 Setter를 만들어준다.
                 *  AST에 삽입할 메서드는 JCTree.JCMethodDecl 타입이며, append 메서드를 활용해 삽입할 메서드를 붙여준다.
                 * */
                if (member instanceof JCTree.JCVariableDecl) {
                    JCTree.JCMethodDecl setter = createSetter(treeMaker, names, (JCTree.JCVariableDecl) member);
                    jcClassDecl.defs = jcClassDecl.defs.append(setter);
                }
            }
        };
    }

    private JCTree.JCMethodDecl createSetter(TreeMaker treeMaker, Names names, JCTree.JCVariableDecl member) {
        // 필드의 이름을 문자열로 반환받는다.
        String memberName = member.name.toString();

        // 메서드의 명명 규칙은 set + 필드 이름의 첫 글자를 대문자로 + 나머지 문자
        String methodName = "set" + memberName.substring(0, 1).toUpperCase() +
                memberName.substring(1);

        // 메서드가 잘 생성되고 있는지 빌드 로그를 통해 확인할 수 있다.
        processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, methodName);

        /*
        *  파라미터가 있는 메서드이기 때문에, 필요한 파라미터를 설정해주어야 한다.
        *  파라미터의 이름, 파라미터의 타입을 넣어주어야 한다.
        *  owner는 사용하지 않는 인수이므로 null을 넣어준다.
        * */
        JCTree.JCVariableDecl param = treeMaker.Param(names.fromString("_" + memberName), member.vartype.type, null);


        return treeMaker.MethodDef(
                treeMaker.Modifiers(1), // public 접근 제어자를 가진다.
                names.fromString(methodName), // 메서드의 이름은 setXXXX와 같은 규칙으로 생성된다.
                treeMaker.TypeIdent(TypeTag.VOID), // 반환 타입이 없으므로, VOID를 지정해준다.
                List.nil(), // 필요 없는 인수는 List.nil() 로 emptyList를 넣어준다.
                List.of(param), // Getter와 다르게 파라미터가 있으므로, 파라미터를 넣어준다.
                List.nil(),
                /*
                *  들여쓰기 레벨은 1로 지정한다.
                *  treeMaker.Exec는 AST에서 실행문(Statement)를 나타내는 노드를 생성한다.
                *  treeMaker.Assign은 AST에서 대입문(Assignment)을 나타내는 노드를 생성한다.
                *  treeMaker.Ident는 식별자를 생성하는 역할을 한다.
                *  즉, member라는 이름을 가진 변수에, param.name 이름으로 들어오는 파라미터의 값을 대입하는 대입문을
                *  실행하라 라는 의미다.
                * */
                treeMaker.Block(1,
                  List.of(treeMaker.Exec(treeMaker.Assign(treeMaker.Ident(member), treeMaker.Ident(param.name))))
                ),
                null
        );
    }
}

코드는 Getter를 생성하는 부분과 대부분 일치합니다.
다른 부분은 createSetter 부분인데 이 부분은 설명드리고 넘어가겠습니다.

private JCTree.JCMethodDecl createSetter(TreeMaker treeMaker, Names names, JCTree.JCVariableDecl member) {
        // 필드의 이름을 문자열로 반환받는다.
        String memberName = member.name.toString();

        // 메서드의 명명 규칙은 set + 필드 이름의 첫 글자를 대문자로 + 나머지 문자
        String methodName = "set" + memberName.substring(0, 1).toUpperCase() +
                memberName.substring(1);

        // 메서드가 잘 생성되고 있는지 빌드 로그를 통해 확인할 수 있다.
        processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, methodName);

        /*
        *  파라미터가 있는 메서드이기 때문에, 필요한 파라미터를 설정해주어야 한다.
        *  파라미터의 이름, 파라미터의 타입을 넣어주어야 한다.
        *  owner는 사용하지 않는 인수이므로 null을 넣어준다.
        * */
        JCTree.JCVariableDecl param = treeMaker.Param(names.fromString("_" + memberName), member.vartype.type, null);


        return treeMaker.MethodDef(
                treeMaker.Modifiers(1), // public 접근 제어자를 가진다.
                names.fromString(methodName), // 메서드의 이름은 setXXXX와 같은 규칙으로 생성된다.
                treeMaker.TypeIdent(TypeTag.VOID), // 반환 타입이 없으므로, VOID를 지정해준다.
                List.nil(), // 필요 없는 인수는 List.nil() 로 emptyList를 넣어준다.
                List.of(param), // Getter와 다르게 파라미터가 있으므로, 파라미터를 넣어준다.
                List.nil(),
                /*
                *  들여쓰기 레벨은 1로 지정한다.
                *  treeMaker.Exec는 AST에서 실행문(Statement)를 나타내는 노드를 생성한다.
                *  treeMaker.Assign은 AST에서 대입문(Assignment)을 나타내는 노드를 생성한다.
                *  treeMaker.Ident는 식별자를 생성하는 역할을 한다.
                *  즉, member라는 이름을 가진 변수에, param.name 이름으로 들어오는 파라미터의 값을 대입하는 대입문을
                *  실행하라 라는 의미다.
                * */
                treeMaker.Block(1,
                  List.of(treeMaker.Exec(treeMaker.Assign(treeMaker.Ident(member), treeMaker.Ident(param.name))))
                ),
                null
        );
    }

메서드의 이름을 만드는 부분까지는 동일합니다.
여기서 다른 점은 파라미터를 받아야 한다는 점입니다. JCTree.JCVariableDecl 타입의 파라미터 객체를 만듭니다.

여기서 주의할점은, 멤버의 이름(필드명) 앞에 언더 하이픈(_)또는 구분할 수 있는 문자를 넣어주어야 한다는 것입니다.
만약 넣어주지 않고 멤버의 이름을 그대로 사용한다면 메서드는 생성 되지만 대입문이 없는 메서드가 나오게 됩니다.

아까와 달라진 부분은, 파라미터가 생겼다는 것이고 반환타입이 없기 때문에 VOID형의 ENUM을 넣어주었다는 점입니다.

treeMakerBlock 메서드는 본문을 만들어주는 부분입니다.
여러 메서드가 중첩되어 있는데 주석과 같이 풀어보면 간단합니다.

treeMaker.Exec는 실행문을 나타내는 노드를 생성합니다.
이 메서드를 호출하면서 인자로 treeMaker.Assign이 반환한 값을 인자로 제공합니다. treeMaker.Assign은 대입문을 나타내는 노드를 생성하는 역할을 해줍니다.

treeMaker.Ident는 식별자를 생성하는 역할을 하는데,
일련의 과정들을 종합해서 풀어보면, member라는 이름을 가진 변수에 _member를 대입하라는 뜻입니다.

그래서 나중에 직접 @Setter를 사용해서 바이트코드를 디컴파일 해보면 이런 결과가 나오게 되는 것입니다.

@NoArgsConstructor 만들기

JPA를 사용하면서 기본 생성자를 만들때 참 애용했던 친구였는데 이렇게 만들어보니 새로웠습니다.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.SOURCE)
public @interface NoArgsConstructor {

    AccessLevel accessLevel() default AccessLevel.PUBLIC;
}

public enum AccessLevel {

    /*
    *   기본 생성자 생성시 접근 제어 수준을 지정할 수 있는 열거 타입
    *   PUBLIC -> public
    *   PROTECTED -> protected
    *   PACKAGE -> default
    *   PRIVATE -> private
    * */
    PUBLIC, PROTECTED, PRIVATE, PACKAGE
}

애너테이션은 AccessLevel을 포함하고 있고, 이 값에 따라서 접근제어 레벨이 다른 생성자를 만들어주게 됩니다.

@AutoService(Processor.class)
@SupportedAnnotationTypes("com.rex.annotation.NoArgsConstructor")
@SupportedSourceVersion(SourceVersion.RELEASE_11)
public class NoArgsProcessor extends AbstractProcessor  {

    @Override
    public boolean process(Set<? extends TypeElement> annotations, RoundEnvironment roundEnv) {
        TreeModifier treeModifier = new TreeModifier(processingEnv);

        for (Element element : roundEnv.getElementsAnnotatedWith(NoArgsConstructor.class)) {
            if (element.getKind() != ElementKind.CLASS) {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.ERROR, "Annotation Not Supported");
            } else {
                processingEnv.getMessager().printMessage(Diagnostic.Kind.NOTE, "Processing >> " + element.getSimpleName().toString());

                // AccessLevel에 따라 생성자의 접근 제어 레벨이 변동되기 때문에 애너테이션에 접근해서 값을 뽑아온다.
                AccessLevel accessLevel = element.getAnnotation(NoArgsConstructor.class).accessLevel();
                // AST 조작 전략을 셋팅해준다.
                treeModifier.setClassDefModifyStrategy(getAppendNoArgsStrategy(treeModifier, accessLevel));
                treeModifier.modifyTree(element);
            }
        }
        return true;
    }


    private Consumer<JCTree.JCClassDecl> getAppendNoArgsStrategy(TreeModifier treeModifier, AccessLevel accessLevel) {
        TreeMaker treeMaker = treeModifier.getTreeMaker();
        Names names = treeModifier.getNames();

        return jcClassDecl -> {
            ListBuffer<JCTree> newMembers = new ListBuffer<>();
            for (JCTree member : jcClassDecl.defs) {
                /*
                *  컴파일러는 기본적으로 파라미터 있는 생성자가 없다면, 파라미터가 없는 public 생성자를 넣어주게 된다.
                *  이럴 경우 생성자가 중복 정의되기 때문에 원하는 접근 제어 레벨의 생성자를 삽입할때 에러가 발생한다.
                *  따라서 이미 매개변수 없는 생성자가 있다면 제외해야 한다.
                * */
                if (member instanceof JCTree.JCMethodDecl) {

                    // 메서드가 생성자인지 확인한다.
                    boolean isConstructor = ((JCTree.JCMethodDecl) member).name.toString().equals("<init>");

                    // 생성자이면서 파라미터가 있다면 다시 넣어준다.
                    if (isConstructor && !((JCTree.JCMethodDecl) member).getParameters().isEmpty()) {
                        newMembers.add(member);
                    } else if (!isConstructor) {
                        // 생성자가 아니라면 메서드이므로 추가한다.
                        newMembers.append(member);
                    }
                } else {
                    // 메서드가 아닌 멤버는 전부 넣어준다.
                    newMembers.append(member);
                }

            }
            jcClassDecl.defs = newMembers.toList();
            JCTree.JCMethodDecl noArgs = createNoArgs(treeMaker, names, accessLevel);
            jcClassDecl.defs = jcClassDecl.defs.append(noArgs);
        };
    }

    private JCTree.JCMethodDecl createNoArgs(TreeMaker treeMaker, Names names, AccessLevel accessLevel) {

        long flag = 0L;

        switch (accessLevel) {
            case PUBLIC:
                flag = 1L;
                break;
            case PACKAGE:
                flag = 0L;
                break;
            case PRIVATE:
                flag = 2L;
                break;
            case PROTECTED:
                flag = 4L;
                break;
        }


        return treeMaker.MethodDef(
                treeMaker.Modifiers(flag),
                names.init,
                treeMaker.TypeIdent(TypeTag.VOID),
                List.nil(),
                List.nil(),
                List.nil(),
                treeMaker.Block(0, List.nil()),
                null
        );
    }
}

이 부분은 @Getter & @Setter와 상이한 부분이 많아서 뜯어보겠습니다.

// AccessLevel에 따라 생성자의 접근 제어 레벨이 변동되기 때문에 애너테이션에 접근해서 값을 뽑아온다.
AccessLevel accessLevel = element.getAnnotation(NoArgsConstructor.class).accessLevel();

// AST 조작 전략을 셋팅해준다.
treeModifier.setClassDefModifyStrategy(getAppendNoArgsStrategy(treeModifier, accessLevel));
treeModifier.modifyTree(element);

애너테이션의 accessLevel 값을 이용해서 AST 조작을 해야하기 때문에 뽑아서 전략을 생성하는 메서드에 넘겨줘야 합니다.

private Consumer<JCTree.JCClassDecl> getAppendNoArgsStrategy(TreeModifier treeModifier, AccessLevel accessLevel) {
        TreeMaker treeMaker = treeModifier.getTreeMaker();
        Names names = treeModifier.getNames();

        return jcClassDecl -> {
            ListBuffer<JCTree> newMembers = new ListBuffer<>();
            for (JCTree member : jcClassDecl.defs) {
                /*
                *  컴파일러는 기본적으로 파라미터 있는 생성자가 없다면, 파라미터가 없는 public 생성자를 넣어주게 된다.
                *  이럴 경우 생성자가 중복 정의되기 때문에 원하는 접근 제어 레벨의 생성자를 삽입할때 에러가 발생한다.
                *  따라서 이미 매개변수 없는 생성자가 있다면 제외해야 한다.
                * */
                if (member instanceof JCTree.JCMethodDecl) {

                    // 메서드가 생성자인지 확인한다.
                    boolean isConstructor = ((JCTree.JCMethodDecl) member).name.toString().equals("<init>");

                    // 생성자이면서 파라미터가 있다면 다시 넣어준다.
                    if (isConstructor && !((JCTree.JCMethodDecl) member).getParameters().isEmpty()) {
                        newMembers.add(member);
                    } else if (!isConstructor) {
                        // 생성자가 아니라면 메서드이므로 추가한다.
                        newMembers.append(member);
                    }
                } else {
                    // 메서드가 아닌 멤버는 전부 넣어준다.
                    newMembers.append(member);
                }

            }
            jcClassDecl.defs = newMembers.toList();
            JCTree.JCMethodDecl noArgs = createNoArgs(treeMaker, names, accessLevel);
            jcClassDecl.defs = jcClassDecl.defs.append(noArgs);
        };
    }

기본 생성자를 만들어줄때의 문제는 몇가지가 있습니다.
컴파일러는 생성자가 없을 경우, 기본 생성자를 넣어줍니다. 또는 사용자가 직접 정의해둔 생성자가 있을 수 있습니다.

이럴 경우 생성자 중복 정의라는 컴파일 에러가 발생하게 됩니다.
따라서 멤버를 모두 순회하면서 멤버가 메서드라면, 생성자인지 먼저 확인하게 됩니다.

생성자라면 매개변수가 없는 생성자일 경우, 넣어주지 않는 방식으로 컴파일러가 기본적으로 만들어준 생성자를 AST에서 제외시킵니다.

매개변수가 있는 생성자또는, 일반 메서드, 필드 등은 모두 List에 넣어주고 AST에 추가시켜줍니다.

private JCTree.JCMethodDecl createNoArgs(TreeMaker treeMaker, Names names, AccessLevel accessLevel) {

        long flag = 0L;

        switch (accessLevel) {
            case PUBLIC:
                flag = 1L;
                break;
            case PACKAGE:
                flag = 0L;
                break;
            case PRIVATE:
                flag = 2L;
                break;
            case PROTECTED:
                flag = 4L;
                break;
        }


        return treeMaker.MethodDef(
                treeMaker.Modifiers(flag),
                names.init,
                treeMaker.TypeIdent(TypeTag.VOID),
                List.nil(),
                List.nil(),
                List.nil(),
                treeMaker.Block(0, List.nil()),
                null
        );
    }

매개변수 없는 생성자 메서드를 만드는 부분입니다.
accessLevel의 값에 따라서 접근 제어 flag 값을 설정해주게 됩니다.

flag 값에 따라서 접근 제어 레벨변경되게 되는 것입니다.

메서드를 만드는 부분에서도, 본문과 return Type이 없고 이름도 클래스의 이름과 같기 때문에 간단한 코드로 메서드를 정의할 수 있습니다.

열심히 만들었다면 이제 사용해봐야 하는 시간이겠죠?
한 번 사용해보겠습니다.

😀 직접 써보자! - Rexbok 출시

이제 application 프로젝트로 가서, build.gradle작성해줍니다.
dependencies에서 annotation 프로젝트의존하고 있는 것을 볼 수 있습니다.

plugins {
    id 'java'
}

sourceCompatibility = 11

group = 'org.example'
version = '1.0-SNAPSHOT'

repositories {
    mavenCentral()
}

dependencies {

    testImplementation 'org.junit.jupiter:junit-jupiter-api:5.6.0'
    testImplementation 'junit:junit:4.13.1'
    testRuntimeOnly 'org.junit.jupiter:junit-jupiter-engine'

    compileOnly project(':annotation')
    implementation project(':annotation')
    annotationProcessor project(':annotation')


    testImplementation project(':annotation')
    testAnnotationProcessor project(':annotation')

}


test {
    useJUnitPlatform()
}

@Getter@Setter 애너테이션을 붙인 Car 클래스를 정의해보겠습니다.

@Getter
@Setter
public class Car {

    private String name;
    private int price;
}

컴파일된 바이트 코드를 디컴파일해서 보면, Getter & Setter가 생성되어 있는 것을 볼 수 있습니다.

public static void main(String[] args) {
        Car car = new Car();
        car.setName("benz");
        car.setPrice(2000);

        System.out.println(car.getName()); // benz
        System.out.println(car.getPrice()); // 2000
    }

코드는 정상 동작하는 것을 볼 수 있습니다.

인텔리제이를 사용중이라면 편집기에서 빨간색으로 표시되는 문제를 만날 수 있습니다.
하지만 코드는 정상 동작하기 때문에 신경쓰지 않아도 됩니다.

Lombok은 인텔리제이에서 플러그인을 제공해 이런 문제가 없지만, 직접 만든 Annotation Processor의 경우 편집기에서 인식하지 못한다고 합니다.

마지막으로 NoArgsConstructor를 테스트 해보겠습니다.

@NoArgsConstructor(accessLevel = AccessLevel.PROTECTED)
public class Car {

    private String name;
    private int price;
}

Protected 레벨의 생성자가 만들어지는지 확인해 보겠습니다.

@NoArgsConstructor(accessLevel = AccessLevel.PRIVATE)
public class Car {

    private String name;
    private int price;
}

Private 레벨의 생성자도 한 번 테스트 해보겠습니다.

잘 동작하는 것을 확인할 수 있습니다.
이 모든 코드는 레포에 올려두었으니 언제든 확인하실 수 있습니다.

Lombok을 만들어보고 공부하며 느낀 점..!!

Lombok을 사용하면 얼마나 개발 생산성이 올라가는지 몸소 체험하고 있기 때문에 늘 고마운 존재였습니다.
Lombok내부 동작 원리를 보면서, 가져다 쓰는 것은 쉬웠지만 내부에는 심오한 기술들이 많이 숨어있었구나를 느끼게 되었습니다.

앞으로도 사용하는 기술에 대해 사용을 넘어서서 꼭 내부 구현을 확인하고 직접 이해보는 시간을 가져야겠다는 확신을 가지게 된 좋은 실험이였던 것 같습니다.

오늘도 읽어주셔서 감사합니다!

🙇

참고한 레퍼런스

8개의 댓글

comment-user-thumbnail
2023년 8월 21일

이런 변태같은 개발자...

1개의 답글
comment-user-thumbnail
2023년 8월 21일

쾅✸인 DevSeoR❂ex♛

1개의 답글
comment-user-thumbnail
2023년 8월 27일

렉스 복 배포 부탁합니다.

1개의 답글
comment-user-thumbnail
2023년 9월 18일

항상 좋은 글 감사합니다~!ㅎㅎㅎ

1개의 답글