Framework & Library/Lombok

[Java/Lombok] 하나의 클래스에서 여러 @Builder 선언 시 의도와 다른 객체 생성

sechoi 2023. 6. 2. 00:51

문제 상황

@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Test implements Serializable {

    private Integer a;
    private Integer b;
    private String c;

    @Builder(builderMethodName = "bcBuilder")
    private Test(Integer b, String c) {
        this.a = 1004;
        this.b = b;
        this.c = c;
    }

    @Builder
    private Test(Integer a, Integer b, String c) {
        this.a = a;
        this.b = b;
        this.c = c;
    }

    @Builder(builderMethodName = "aBuilder")
    private Test(Integer a) {
        this.a = a;
        this.b = 1004;
        this.c = "1004";
    }

}

다음과 같은 클래스가 있다. 파라미터에 따라 생성자를 여러 개 생성할 수 있듯, 빌더도 여러 개 생성하고 method name을 다르게 설정해 구분해 놓았다.

 

의도대로라면 aBuilder를 사용했을 때 a만 입력값으로 저장되고 나머지는 해당 생성자에서 지정한 기본값으로 저장되어야 한다. 하지만 실제로 생성 된 객체를 보면 다음과 같이 나온다.

Test test = Test.aBuilder().a(1).build();

/* 의도한 결과
a = 1, b = 1004, c = "1004" */

/* 실제 결과
a = 1004, b = null, c = null */

 

원인

이를 해결하기 위해 인텔리제이에서 바이트코드를 디컴파일 한 Test 클래스를 살펴보았다.

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

public class Test implements Serializable {
    private Integer a;
    private Integer b;
    private String c;

    private Test(Integer b, String c) {
        this.a = 1004;
        this.b = b;
        this.c = c;
    }

    private Test(Integer a, Integer b, String c) {
        this.a = a;
        this.b = b;
        this.c = c;
    }

    private Test(Integer a) {
        this.a = a;
        this.b = 1004;
        this.c = "1004";
    }

    public static TestBuilder bcBuilder() {
        return new TestBuilder();
    }

    public static TestBuilder builder() {
        return new TestBuilder();
    }

    public static TestBuilder aBuilder() {
        return new TestBuilder();
    }

    protected Test() {
    }

    public static class TestBuilder {
        private Integer b;
        private String c;
        private Integer a;

        TestBuilder() {
        }

        public TestBuilder b(final Integer b) {
            this.b = b;
            return this;
        }

        public TestBuilder c(final String c) {
            this.c = c;
            return this;
        }

        public Test build() {
            return new Test(this.b, this.c);
        }

        public TestBuilder a(final Integer a) {
            this.a = a;
            return this;
        }
    }
}

코드를 살펴보면 빌더 자체는 세 개로 잘 나누어져 있었다. 하지만 TestBuilder 클래스 내부를 보면, build 메서드는 오직 하나다. 즉 처음 @Builder 어노테이션을 붙인 생성자를 가지고 build 메서드를 생성함을 알 수 있다.

 

따라서 위 aBuilder를 사용한 문제 상황에선 유저가 넣어준 a 값이 무시되고 bcBuilder의 생성자에서 지정해준 a의 값으로 들어가는 것이다. 또한 b와 c에 관해서 유저가 값을 넣어주지 않았으므로 null로 저장된다.

 

오류를 방지하기 위해선 @Builder는 모든 파라미터가 들어간 생성자에 붙여주어야 한다. 그렇다면 위 코드처럼 멤버 변수에 대해 default 값을 지정하고 싶으면 어떻게 해야할까?

 

@ToString
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Test implements Serializable {

    private Integer a = 1004;
    private Integer b = 1004;
    private String c = "1004";
    
    @Builder
    // 생략

}

 

가장 먼저 위와 같은 방법이 떠오를 것이나 이는 틀렸다. 값을 넣어주지 않으면 지정해준 default 값이 무시되고 null로 저장된다. @Builder를 사용할 때 default 값을 지정하기 위해서는 @Builder.Default를 추가해주어야 한다.

 

@ToString
@Builder
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class Test implements Serializable {

    @Builder.Default
    private Integer a = 1004;
    private Integer b;
    private String c;

}

그리고 해당 어노테이션을 사용하면 클래스 부분에 @Builder를 선언해야 한다. 아닐 시 다음과 같은 경고가 발생한다.

warning: @Builder.Default requires @Builder or @SuperBuilder on the class for it to mean anything.

 

이렇게 고치면 원하는 대로 빌더를 통해 객체가 생성된다.

//
// Source code recreated from a .class file by IntelliJ IDEA
// (powered by FernFlower decompiler)
//

public class Test implements Serializable {
    private Integer a;
    private Integer b;
    private String c;

    private static Integer $default$a() {
        return 1004;
    }

    public static TestBuilder builder() {
        return new TestBuilder();
    }

    protected Test() {
        this.a = $default$a();
    }

    private Test(final Integer a, final Integer b, final String c) {
        this.a = a;
        this.b = b;
        this.c = c;
    }

    public static class TestBuilder {
        private boolean a$set;
        private Integer a$value;
        private Integer b;
        private String c;

        TestBuilder() {
        }

        public TestBuilder a(final Integer a) {
            this.a$value = a;
            this.a$set = true;
            return this;
        }

        public TestBuilder b(final Integer b) {
            this.b = b;
            return this;
        }

        public TestBuilder c(final String c) {
            this.c = c;
            return this;
        }

        public Test build() {
            Integer a$value = this.a$value;
            if (!this.a$set) {
                a$value = Test.$default$a();
            }

            return new Test(a$value, this.b, this.c);
        }
    }
}

빌드 된 파일을 통해서도 의도대로 구현되었음을 확인할 수 있다.