문제 상황
@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);
}
}
}
빌드 된 파일을 통해서도 의도대로 구현되었음을 확인할 수 있다.