Lombok @Builder의 동작 원리

하루히즘·2021년 11월 28일
45
post-custom-banner

서론

보일러플레이트 메서드(getter/setter, constructor 등)를 직접 작성하지 않아도 대신 작성해주는 Lombok를 최근에 많이 활용하고 있다. 그나마 setter 메서드같은 경우는 값을 변경시키는 메서드는 그 목적을 알 수 있도록 명명하라는 조언에 따라 사용을 지양하고 있고 @Getter, @NoArgsConstructor, @RequiredArgsConstructor 정도를 자주 사용하고 있다.

그 중에서 최근에 많이 쓰는 것은 @Builder 어노테이션인데 클래스 레벨에 붙이거나 생성자에 붙여주면 파라미터를 활용하여 빌더 패턴을 자동으로 생성해준다. 확실히 편하고 좋은데 이 패턴이 어떤 원리로 동작하는지 좀 알아봐야겠다고 생각해서 이 포스트를 작성하게 됐다.

본론

클래스 레벨 어노테이션

@Builder는 기본적으로 메서드, 생성자에만 붙일 수 있는데 API Javadoc의 설명을 읽어보면 다음과 같은 내용이 있다.

If a class is annotated, then a private constructor is generated with all fields as arguments (as if @AllArgsConstructor(access = AccessLevel.PRIVATE) is present on the class), and it is as if this constructor has been annotated with @Builder instead.

즉 클래스 레벨에서 @Builder 어노테이션을 붙이면 모든 요소를 받는 package-private 생성자가 자동으로 생성되며 이 생성자에 @Builder 어노테이션을 붙인 것과 동일하게 동작한다고 한다. 즉 클래스 레벨도 결국은 중간 단계를 거쳐 생성자 레벨로 변환되어 동작한다는 것이다.

@Builder
public class BuildMe {

    private String username;
    private int age;

}

위와 같은 BuildMe 클래스에 @Builder 어노테이션을 붙였을 때 생성된 클래스 파일을 IntelliJ의 도움을 받아 바이트 코드 분석해보면 다음과 같은 클래스로 변한 것을 볼 수 있다.

public class BuildMe {
    private String username;
    private int age;

    BuildMe(String username, int age) {
        this.username = username;
        this.age = age;
    }

    public static BuildMe.BuildMeBuilder builder() {
        return new BuildMe.BuildMeBuilder();
    }

    public static class BuildMeBuilder {
        private String username;
        private int age;

        BuildMeBuilder() {
        }

        public BuildMe.BuildMeBuilder username(String username) {
            this.username = username;
            return this;
        }

        public BuildMe.BuildMeBuilder age(int age) {
            this.age = age;
            return this;
        }

        public BuildMe build() {
            return new BuildMe(this.username, this.age);
        }

        public String toString() {
            return "BuildMe.BuildMeBuilder(username=" + this.username + ", age=" + this.age + ")";
        }
    }
}

먼저 BuildMe 클래스에는 public이 아닌 package-private한 생성자가 생성된 것을 볼 수 있다. Java에서는 접근제어자 미기재 시 package 레벨로 동작한다는 것을 잊지 말자.

그리고 클래스의 필드와 동일한 필드를 가지고 필드 이름의 setter 메서드를 제공하는 빌더 클래스(BuildMe.BuildMeBuilder)가 자동으로 생성된 것을 볼 수 있다. 클래스에서 빌더 객체를 생성할 때는 builder() 메서드로 빌더 클래스의 인스턴스를 생성하고 빌더 클래스에서는 build() 메서드로 실제 생성자를 호출하여 객체를 생성하는 것을 볼 수 있다.

정확한 빌더 패턴을 직접 구현하지 않고도 사용할 수 있게 되었다. 그런데 모든 필드를 기반으로 생성자를 구성한다면 final 필드는 어떨까?

@Builder
public class BuildMe {

    private String username;
    private int age;
    private final String keyword = "VERSION 1.0";

}

위처럼 keyword 라는 final 필드를 추가해서 컴파일해봤다.

public class BuildMe {
    private String username;
    private int age;
    private final String keyword = "VERSION 1.0";

    BuildMe(String username, int age) {
        this.username = username;
        this.age = age;
    }

    public static BuildMe.BuildMeBuilder builder() {
        return new BuildMe.BuildMeBuilder();
    }

    public static class BuildMeBuilder {
        private String username;
        private int age;

        BuildMeBuilder() {
        }

        public BuildMe.BuildMeBuilder username(String username) {
            this.username = username;
            return this;
        }

        public BuildMe.BuildMeBuilder age(int age) {
            this.age = age;
            return this;
        }

        public BuildMe build() {
            return new BuildMe(this.username, this.age);
        }

        public String toString() {
            return "BuildMe.BuildMeBuilder(username=" + this.username + ", age=" + this.age + ")";
        }
    }
}

이 경우 final 필드는 제외하고 클래스의 생성자와 빌더 클래스의 메서드가 구현된 것을 볼 수 있다. 이미 초기화된 필드는 변경할 수 없기 때문에 아예 생성자도 지원하지 않는 것이다. 그렇다면 초기화되지 않은 final 필드는 어떨까?

@Builder
public class BuildMe {

    private String username;
    private int age;
    private final String keyword;

}

위처럼 keyword 필드를 초기화하지 않고 빌더 패턴을 적용해보았다.

public class BuildMe {
    private String username;
    private int age;
    private final String keyword;

    BuildMe(String username, int age, String keyword) {
        this.username = username;
        this.age = age;
        this.keyword = keyword;
    }

    public static BuildMe.BuildMeBuilder builder() {
        return new BuildMe.BuildMeBuilder();
    }

    public static class BuildMeBuilder {
        private String username;
        private int age;
        private String keyword;

        BuildMeBuilder() {
        }

        public BuildMe.BuildMeBuilder username(String username) {
            this.username = username;
            return this;
        }

        public BuildMe.BuildMeBuilder age(int age) {
            this.age = age;
            return this;
        }

        public BuildMe.BuildMeBuilder keyword(String keyword) {
            this.keyword = keyword;
            return this;
        }

        public BuildMe build() {
            return new BuildMe(this.username, this.age, this.keyword);
        }

        public String toString() {
            return "BuildMe.BuildMeBuilder(username=" + this.username + ", age=" + this.age + ", keyword=" + this.keyword + ")";
        }
    }
}

이 경우 finalkeyword 필드에 값을 설정하지 않아도 아무런 컴파일 에러가 발생하지 않았는데 그 이유는 빌더 클래스에 있었다. 빌더 클래스는 클래스와 동일한 필드를 내부적으로 유지하지만 private, non-static, non-final 속성을 가지기 때문이다. 빌더 패턴에서는 한 번 설정한 속성을 여러번 메서드를 호출하여 다시 설정할 수 있어야 하기 때문에 final 키워드는 적합하지 않다.

그래서 finalkeyword 역시 빌더에서는 일반 전역 변수로 존재하게 되고 생성자로 실제 클래스의 객체를 생성할 때도 전역 변수의 기본값인 null이 전달되기 때문에 final 필드를 초기화하지 않아도 문제가 없는 것이다.

이런 특징 때문에 반드시 초기화되어야 하는 필드의 경우 lombok의 @Builder.Default 속성을 사용하거나 선언 시점에 또는 생성자에서 초기화하는 편이 좋을 것이다.

클래스 레벨의 특징으로는 다른 생성자가 이미 있을 경우 제대로 동작하지 않을 수 있다는 점이 있다.

Note that this constructor is only generated if you haven't written any constructors and also haven't added any explicit @XArgsConstructor annotations. In those cases, lombok will assume an all-args constructor is present and generate code that uses it; this means you'd get a compiler error if this constructor is not present.

이 경우 해당 생성자가 클래스 레벨에서 요구하는 모든 필드를 주입하는 생성자가 아니더라도 lombok은 해당 생성자가 all-args 생성자라고 생각하고 빌더 코드를 생성하기 때문에 컴파일 시점에 오류가 발생할 수 있다. 예를 들어 아래처럼 몇몇 필드만 파라미터로 받는 생성자가 있다고 하자.

@Builder
public class BuildMe {

    private String username;
    private int age;

    public BuildMe(String username) {
        this.username = username;
        this.age = 1;
    }
}

이 경우 username 필드는 생성자의 파라미터로 주입받지만 age 필드는 주입받지 않고 생성자 로직에 의해 생성된다. 하지만 이전의 바이트 코드에서 봤듯이 빌더 클래스는 BuildMe 클래스의 username, age 파라미터를 받는 생성자를 사용할 것이다. 즉 정의되지 않은 생성자를 사용하기 때문에 컴파일 에러가 발생하는 것이다.

그래서 클래스 레벨에서 어노테이션을 사용하는 경우 주의해서 사용해야 할 것이다.

생성자 레벨 어노테이션

이번에는 별도의 생성자를 직접 만들고 빌더 패턴을 적용해보았다. 공식 문서에서는 생성자 메서드로 @Builder를 활용할 시 다음과 같은 방식으로 동작한다고 한다.

The effect of @Builder is that an inner class is generated named TBuilder, with a private constructor. Instances of TBuilder are made with the method named builder() which is also generated for you in the class itself (not in the builder class).

즉 private 생성자를 가지는 클래스Builder 라는 이름의 내부 빌더 클래스를 생성하여 빌더 패턴을 구현한다. 빌더 클래스를 생성하고자 하는 클래스 외부에서 빌더 인스턴스를 만들어서 사용할 이유는 없기 때문에 private 생성자로 접근을 차단한다.

public class BuildMe {

    private String username;
    private int age;

    @Builder
    public BuildMe(String username, int age) {
        this.username = "Mr/Mrs. " + username;
        this.age = age + 1;
    }

}

이번에는 생성자 로직을 변경해서 BuildMe 객체에서 사용자 이름을 받을 때 접두어를 붙이고 나이는 한 살 더하도록 했다. 그리고 이 생성자에 @Builder 어노테이션을 붙였는데 그러면 실제로 생성된 바이트 코드는 어떨까?

public class BuildMe {
    private String username;
    private int age;

    public BuildMe(String username, int age) {
        this.username = "Mr/Mrs. " + username;
        this.age = age + 1;
    }

    public static BuildMe.BuildMeBuilder builder() {
        return new BuildMe.BuildMeBuilder();
    }

    public static class BuildMeBuilder {
        private String username;
        private int age;

        BuildMeBuilder() {
        }

        public BuildMe.BuildMeBuilder username(String username) {
            this.username = username;
            return this;
        }

        public BuildMe.BuildMeBuilder age(int age) {
            this.age = age;
            return this;
        }

        public BuildMe build() {
            return new BuildMe(this.username, this.age);
        }

        public String toString() {
            return "BuildMe.BuildMeBuilder(username=" + this.username + ", age=" + this.age + ")";
        }
    }
}

이번에도 빌더 클래스가 자동으로 생성된 것을 볼 수 있었다. 생성자 로직에서 어떤 일을 하던지 간에 빌더 클래스는 단순히 값을 담고 있다가 생성자로 주입시키기만 하기 때문에 큰 차이는 없는 것이다.

그러나 클래스 레벨과 생성자 레벨에는 한 가지 차이점이 있는데 클래스 레벨에서는 가능한 모든 필드에 대하여 빌더 메서드를 생성했다면 생성자 레벨에서는 생성자의 파라미터 필드에 대해서만 빌더 메서드를 생성한다는 점이다.

The TBuilder class contains 1 method for each parameter of the annotated constructor / method (each field, when annotating a class), which returns the builder itself.

public class BuildMe {

    private String username;
    private int age;

    @Builder
    public BuildMe(String username) {
        this.username = "Mr/Mrs. " + username;
        this.age = 1;
    }

}

그래서 생성자가 모든 필드를 다루지 않도록 변경해보고 다시 컴파일해봤다.

public class BuildMe {
    private String username;
    private int age;

    public BuildMe(String username) {
        this.username = "Mr/Mrs. " + username;
        this.age = 1;
    }

    public static BuildMe.BuildMeBuilder builder() {
        return new BuildMe.BuildMeBuilder();
    }

    public static class BuildMeBuilder {
        private String username;

        BuildMeBuilder() {
        }

        public BuildMe.BuildMeBuilder username(String username) {
            this.username = username;
            return this;
        }

        public BuildMe build() {
            return new BuildMe(this.username);
        }

        public String toString() {
            return "BuildMe.BuildMeBuilder(username=" + this.username + ")";
        }
    }
}

그래서 빌더 패턴 역시 age 필드에 값을 설정하는 메서드가 제거된 것을 볼 수 있다.

이처럼 클래스 레벨과 달리 생성자를 직접 생성해서 @Builder 를 적용하면 빌더로 설정하도록 제공하는 항목 역시 직접 고를 수 있다는 장점이 있다. 특히 JPA 엔티티 같은 경우 영속되기 전에는 식별자가 존재하지 않아 필연적으로 null 값을 가져야 한다. 이런 경우 생성자로 null 값을 전달하기보다는 아예 생성자에서 null 값을 받지 않도록 직접 구성하는 편이 좋을 것이다.

@Singular

이번에 공식 문서를 읽으면서 새로 알게 된 부분인데 @Singular 어노테이션을 사용하면 리스트 같은 컬렉션 객체를 빌더 패턴으로 다룰 때 리스트 객체 자체를 넘기는 게 아니라 해당 리스트에 요소를 추가하는 방식으로 생성할 수 있다. 물론 모든 자료구조에 대해 지원하는 것은 아니지만 Java의 List, Set, Iterable 등 자주 사용하는 자료구조에 대해서는 적용할 수 있다.

@Builder
@Getter
public class BuildMe {

    private String username;
    private int age;
    @Singular("alias") private List<String> alias;

}

위의 클래스처럼 추가 메서드를 구현하기 위한 필드에 @Singular 어노테이션을 붙여준다. 그러면 빌더 클래스에 다음과 같은 메서드가 추가된다.

public class BuildMe {
    ... // 불필요한 부분 생략

    public static class BuildMeBuilder {
        private String username;
        private int age;
        private ArrayList<String> alias;
        
        ...

        public BuildMe.BuildMeBuilder alias(String alias) {
            if (this.alias == null) {
                this.alias = new ArrayList();
            }

            this.alias.add(alias);
            return this;
        }

        public BuildMe.BuildMeBuilder alias(Collection<? extends String> alias) {
            if (alias == null) {
                throw new NullPointerException("alias cannot be null");
            } else {
                if (this.alias == null) {
                    this.alias = new ArrayList();
                }

                this.alias.addAll(alias);
                return this;
            }
        }

        public BuildMe.BuildMeBuilder clearAlias() {
            if (this.alias != null) {
                this.alias.clear();
            }

            return this;
        }

        public BuildMe build() {
            List alias;
            switch(this.alias == null ? 0 : this.alias.size()) {
            case 0:
                alias = Collections.emptyList();
                break;
            case 1:
                alias = Collections.singletonList((String)this.alias.get(0));
                break;
            default:
                alias = Collections.unmodifiableList(new ArrayList(this.alias));
            }

            return new BuildMe(this.username, this.age, alias);
        }
        ...
    }
}

alias(String alias) 처럼 자료구조에 실제로 데이터를 추가하는 메서드와 alias(Collection<? extends String> alias) 처럼 자료구조 자체를 주입하는 메서드, 자료구조를 비우는 clearAlias() 메서드가 자동으로 구현된 것을 볼 수 있다. 특히 연산 과정에서 발생할 수 있는 NPE 역시 자동으로 방지되는 것을 볼 수 있다.

BuildMe userA = BuildMe.builder()
                .age(25)
                .username("USER_A")
                .alias("ALIAS1")
                .alias("ALIAS2")
                .alias("ALIAS3").build();
userA.getAlias().forEach(System.out::println);

이를 사용하는 측에서는 위처럼 간단하게 데이터를 추가할 수 있다.

ALIAS1
ALIAS2
ALIAS3

결론

평소에 자주 사용하던 어노테이션이었지만 그 구현을 직접 바이트 코드로, 정확히는 인텔리제이가 분석해준 코드로 보니 어떤 식으로 코드가 변하는지 알 수 있었다. 특히 클래스 레벨과 생성자 레벨이 어떤 차이가 있고 주의해야 할 점은 무엇인지 알 수 있어서 좋은 경험이었다.

스프링 애플리케이션도 빌드 후 클래스 파일이 어떻게 변할지 궁금한데 한 번 확인해봐야겠다.

참고

@Builder
Annotation Type Builder

profile
YUKI.N > READY?
post-custom-banner

16개의 댓글

comment-user-thumbnail
2022년 5월 13일

lombok 빌더 어노테이션에 대하여 깊이있게 잘읽었습니다.

1개의 답글
comment-user-thumbnail
2022년 5월 19일

좋은 글 감사합니다! Builder를 이해하는데 도움이 되었습니다!

1개의 답글
comment-user-thumbnail
2022년 8월 3일

좋은 글이네여~ 감사합니다

1개의 답글
comment-user-thumbnail
2023년 10월 22일

감사합니다, 선생님! 빌더 애너테이션을 무감각하게 사용만 해보고 이렇게 파고들어보진 않았는데, 덕분에 어떤 식으로 메서드들을 만들어주는지 쉽게 알 수 있었습니다!

1개의 답글
comment-user-thumbnail
2023년 10월 30일

와 잘읽었습니다. 감사합니다!

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

좋은글 감사합니다

1개의 답글
comment-user-thumbnail
2024년 2월 3일

안녕하세요 좋은 글 잘 읽었습니다. 혹시 이 글의 일부분을 발췌해가도 괜찮을까요? 출처는 꼭 표시하겠습니다.

1개의 답글
comment-user-thumbnail
2024년 3월 12일

항상 다른 글들을 읽어보면서 클래스 레벨과 생성자 레벨의 차이가 뭘까봤는데, 전자는 모든 필드값을 넣어줘야 하는 것이고, 후자는 커스터마이징한 빌더를 만들 수 있다는거군요 감사합니다 :)

1개의 답글