☕️자바 𝗝𝗔𝗩𝗔

[JAVA] @Builder 동작 원리, @Builder.Default, @Singular

비타민찌 2022. 9. 16. 18:34
728x90

1. @Builder

@Builder 란 Lombok에서 제공하는 어노테이션으로, 생성자 인자를 메서드 체인을 통해 명시적으로 대입하여 생성자를 호출할 수 있게 빌더 클래스를 생성 해준다. 빌더 클래스와 IDE의 자동 완성 기능을 같이 활용하면 생성자 작성 시 오기입 확률과 인자를 누락할 확률을 낮출 수 있다.

 

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 생성자, 메서드 또는 클래스 레벨에서 쓰일 수 있으며 클래스 레벨에서 쓰일 경우 기본적으로 전체 멤버를 생성자의 매개값으로 갖는 private 생성자를 만들어 준다.

 

이 생성자는 @NoArgsConstructor, @RequiredArgsConstructor 또는 어떤 생성자도 클래스 내부에 선언하지 않았을 경우에만 생성된다. 반대로 위의 두 조건 중 하나를 했을 경우, 모든 필드를 매개값으로 하는 생성자를 자동으로 선언해서 사용한다. 따라서 이 경우 @All ArgsConstructor가 없으면 컴파일 에러가 발생한다.

 

 

다음은 @Builder 어노테이션을 사용한 BuildMe 클래스이다. 

import lombok.Builder;

@Builder
public class BuildMe {
    private String name;
    private int age;
}

위 클래스 파일의 바이트 코드를 살펴보자.

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

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

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

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

        BuildMeBuilder() {
        }

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

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

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

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

인텔리제이에서 클래스의 바이트 코드를 확인하는 방법:

build > classes > 파일경로 > 파일명 클릭

 

먼저 BuildMe 클래스에는 public이 아닌 package private한 생성자가 생성되었다.

(Java에서 접근제어자를 따로 명시해주지 않으면 package 레벨로 동작한다.)

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

 

모든 필드를 기반으로 생성자를 구성한다면 final 필드는 어떨까?

keyword 라는 final 필드를 추가해서 컴파일 해보자.

@Builder
public class BuildMe {
    private String name;
    private int age;
    private final String keyword = "V1";
}
public class BuildMe {
    private String name;
    private int age;
    private final String keyword = "V1";

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

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

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

        BuildMeBuilder() {
        }

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

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

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

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

final 필드는 제외하고 클래스의 생성자와 빌더 클래스의 메서드가 구현된 것을 볼 수 있다.

이미 초기화된 필드는 변경할 수 없기 때문에 아예 생성자도 지원하지 않는 것이다. 그렇다면 초기화되지 않은 final 필드는 어떨까?

@Builder
public class BuildMe {
    private String name;
    private int age;
    private final String keyword;
}
public class BuildMe {
    private String name;
    private int age;
    private final String keyword;

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

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

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

        BuildMeBuilder() {
        }

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

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

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

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

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

이 경우 final인 keyword 필드에 값을 설정하지 않아도 아무런 컴파일 에러가 발생하지 않는다.

빌더 클래스는 클래스와 동일한 필드를 내부적으로 유지하지만 private, non-static, non-final 속성을 가지기 때문이다.

빌더 패턴에서는 한 번 설정한 속성을 여러번 메서드를 호출하여 다시 설정할 수 있어야 하기 때문에 final 키워드는 적합하지 않다.

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

 

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

 

이번에는 BuildMe 객체에서 사용자 이름을 받을 때 접두어를 붙이고 나이를 한 살 더했다. 그리고 이 생성자에 @Builder 어노테이션을 붙였다. 생성된 바이트 코드는 어떨까?

public class BuildMe {

    private String name;
    private int age;

    @Builder
    public BuildMe(String name, int age) {
        this.name = "hi, " + name;
        this.age = age + 1;
    }
}
public class BuildMe {
    private String name;
    private int age;

    public BuildMe(String name, int age) {
        this.name = "hi, " + name;
        this.age = age + 1;
    }

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

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

        BuildMeBuilder() {
        }

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

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

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

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

이번에도 빌더 클래스가 자동으로 생성됐다.

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

그러나 클래스 레벨과 생성자 레벨에는 한 가지 차이점이 있는데

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

 

이렇게 생성자를 직접 생성해서 @Builder를 적용하면

빌더로 설정하도록 제공하는 항목 역시 직접 고를 수 있다는 장점이 있다.

특히 JPA 엔티티 같은 경우 영속되기 전에는 식별자가 존재하지 않아서 필연적으로 null 값을 가져야 하는데

이런 경우 생성자로 null 값을 전달하기보다는 아예 생성자에서 null 값을 받지 않도록 직접 구성하는 편이 좋을 것이다.

 

2. Builder.Default

Builder는 값을 설정하지 않으면 자동으로 null을 채워준다.

@Builder
@ToString
public class BuildMe {

    private String name;
    private int age;
    private String nickname;
}


public class BuildMeTest {

    public static void main(String[] args) {

        BuildMe buildMe = BuildMe.builder()
                .name("mj")
                .build();

        System.out.println(buildMe.toString());
    }
}

위 코드처럼 name 필드에만 값을 넣어주면 age에는 0이, nickname에는 null 값이 들어가는데,

이 때 직접 기본값을 설정해주고 싶다면 Lombok 1.16.16 이후 버전에 추가된 @Builder.Default를 사용할 수 있다.

@Builder
@ToString
public class BuildMe {

    private String name;

    @Builder.Default
    private int age = 20;

    @Builder.Default
    private String nickname = "kitty";
}


public class BuildMeTest {

    public static void main(String[] args) {

        BuildMe buildMe = BuildMe.builder()
                .name("mj")
                .build();

        System.out.println(buildMe.toString());
    }
}

이렇게 필드에 어노테이션을 추가한뒤 필드에 기본값을 직접 지정해주면 된다.

 

사실 이 글을 작성하게 된 계기가 이 녀석 때문이다.ㅜㅜ 다음 에러를 만나고 하루 정도 @Builder 동작 원리에 대해 다시 공부하며 쓴 글..

@Builder will ignore the initializing expression entirely. If you want the initializing expression to serve as default, add @Builder.Default. If it is not supposed to be settable during building, make the field final.

@Builder 는 초기화 표현을 완전히 무시한다. 초기화 하고 싶으면 @Builder.Default 를 사용하거나, final을 사용하라.

 

 

3. @Singular 어노테이션

@Singular 어노테이션을 사용하면 리스트 같은 컬렉션 객체를 빌더 패턴으로 다룰 때 리스트 객체 자체를 넘기는 게 아니라 해당 리스트에 요소를 추가하는 방식으로 생성할 수 있다.

import lombok.Builder;
import lombok.Singular;

import java.util.List;

@Builder

public class BuildMe {

    private String name;
    private int age;
    
    @Singular("nickname")
    private List<String> nickname;

}

위 코드처럼 추가 메서드를 구현하기 위한 필드에 @Singular 어노테이션을 붙여준다.

그러면 빌더 클래스에 다음과 같은 메서드가 추가된다.

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

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

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

                this.nickname.addAll(nickname);
                return this;
            }
        }
        
        
        public BuildMe.BuildMeBuilder clearNickname() {
            if (this.nickname != null) {
                this.nickname.clear();
            }

            return this;
        }

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

            return new BuildMe(this.name, this.age, nickname);
        }

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

 

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

 

 

 

 

 

출처: https://www.baeldung.com/lombok-builder-singular

https://projectlombok.org/features/Builder

https://projectlombok.org/api/lombok/Builder

https://velog.io/@park2348190/Lombok-Builder

https://velog.io/@hsbang_thom/Lombok-Builder.Default

728x90