[Item 2] 생성자에 매개변수가 많다면 빌더를 고려하라.

손현경 (보름)·2023년 6월 18일

effective-java

목록 보기
2/4

생성자에 선택적 매개변수가 많다면, 실수를 유발하기 쉽습니다. 이는 정적 팩터리도 마찬가지입니다.

다음은 선택적 매개변수가 많은 예시 클래스입니다.

public class Person {

  private final long id;        // 필수
  private final String name;    // 필수
  private final int age;        // 필수
  private final String job;     // 선택
  private final String address; // 선택
  private final int weight;     // 선택

id, name, age는 필수 요소이며 job, address, weight는 선택 요소입니다.


점층적 생성자 패턴

이럴 때 점층적 생성자 패턴을 사용할 수 있습니다. 다음 예시 코드와 함께 점층적 생성자 패턴을 설명하겠습니다.

public Person(long id, String name, int age) {
  this(id, name, age, null);
}

public Person(long id, String name, int age, String job) {
  this(id, name, age, job, null);
}

public Person(long id, String name, int age, String job, String address) {
  this(id, name, age, job, address, 0);
}

public Person(long id, String name, int age, String job, String address, int weight) {
  this.id = id;
  this.name = name;
  this.age = age;
  this.job = job;
  this.address = address;
  this.weight = weight;
}
  • 필수 매개변수만 받는 생성자에서 job이라는 선택적 매개변수를 받는 생성자를 job에 null을 주어 호출합니다.
  • job을 매개변수로 받는 생성자에서, 이번에는 address까지 매개변수로 받는 생성자를 호출합니다. 마찬가지로 address 값에는 null을 줍니다.
  • 이렇게 연쇄적으로 생성자가 생성자를 호출하는 패턴을 점층적 생성자 패턴이라 합니다. 매개변수로 null, 0 등을 주어 default 값을 설정할 수 있겠습니다.

단점

  • 이 패턴은 매개변수의 개수가 늘어날수록, 코드를 작성하거나 읽기 어렵다는 단점이 있습니다.
  • 위의 예제에서 실수로 프로그래머가 job과 address의 순서를 바꿔서 작성하는 등의 오류를 범하기도 쉽겠습니다.
    public Person(long id, String name, int age, String job, String address) {
      this(id, name, age, address, job, 0);
    }

자바 빈즈 패턴

두 번째 방법으로 자바 빈즈 패턴을 사용하는 방법이 있습니다. 역시 코드로 설명하겠습니다.

public class Person {
  private long id;        // 필수
  private String name;    // 필수
  private int age;        // 필수
  private String job;     // 선택
  private String address; // 선택
  private int weight;     // 선택

  public Person() {
  }

  public void setId(long id) {
    this.id = id;
  }

  public void setName(String name) {
    this.name = name;
  }

  public void setAge(int age) {
    this.age = age;
  }

  public void setJob(String job) {
    this.job = job;
  }

  public void setAddress(String address) {
    this.address = address;
  }

  public void setWeight(int weight) {
    this.weight = weight;
  }
}
  • 아무 매개변수도 받지 않는 생성자를 하나 두고, setter 메서드를 모든 필드에 대해 둡니다.
  • 이제 매개변수의 개수가 많아져도, setter만 추가하면 됩니다.

단점

  • 객체 생성할 때 수많은 setter을 호출해야 해서, 번거로워졌습니다.
    Person person = new Person();
    person.setId(1);
    person.setName("PERSON");
    person.setAddress("BUSAN");
    person.setAge(20);
    person.setJob("STUDENT");
    person.setWeight(100);
  • 모든 setter를 호출하기 전(== 객체를 완성하기 전)까지는, 객체의 일관성이 무너진 상태입니다.
    • 즉, 클래스를 불변으로 만들 수 없습니다.

불변에 대해서는 item 17에서 더 자세히 다루도록 하겠습니다.


빌더 패턴

점층적 생성자 패턴의 안정성과 자바 빈즈 패턴의 가독성을 겸비한 패턴입니다.

public class Person {
  private final long id;        // 필수
  private final String name;    // 필수
  private final int age;        // 필수
  private final String job;     // 선택
  private final String address; // 선택
  private final int weight;     // 선택

  public static class Builder {

    private final long id;        // 필수
    private final String name;    // 필수
    private final int age;        // 필수

    private String job = "STUDENT";   // 선택
    private String address = "KOREA"; // 선택
    private int weight = 0;           // 선택

    public Builder(long id, String name, int age) {
      this.id = id;
      this.name = name;
      this.age = age;
    }

    public Builder job(String job) {
      this.job = job;
      return this;
    }

    public Builder address(String address) {
      this.address = address;
      return this;
    }

    public Builder weight(int weight) {
      this.weight = weight;
      return this;
    }

    public Person build() {
      return new Person(this);
    }
  }

  public Person(Builder builder) {
    this.id = builder.id;
    this.name = builder.name;
    this.age = builder.age;
    this.job = builder.job;
    this.address = builder.address;
    this.weight = builder.weight;
  }
}
  • inner class로 Builder를 둡니다.
  • Builder에는 생성하고자 하는 객체와 동일한 조건으로 필드를 둡니다. 선택 필드의 경우 default값을 설정할 수 있습니다.
    • 선택 필드의 setter 메서드를 호출하지 않을 경우, default 값으로 값이 설정됩니다. 점층적 생성자 패턴에서 null, 0 등의 default 값을 생성자에 인자로 넘겨준 것과 동일한 결과를 얻을 수 있습니다.
  • 빌더의 setter 메서드는 값을 설정한 후 자기 자신을 반환합니다. 즉 연쇄적인 호출이 가능합니다.
  • Builder를 인자로 받는 생성자를 선언하고, Builder의 값을 받아 설정합니다.
Person person = new Builder(1, "SON", 20)
        .address("BUSAN")
        .weight(30)
        .build();

빌더 패턴을 사용하여 객체를 생성한 모습입니다. job 메서드를 호출하지 않았는데, 콘솔에 출력해보면

default로 설정해 둔 STUDENT 가 출력됨을 확인할 수 있습니다.

단점

객체를 만들기 전에 빌더를 만들어야 하는 수고가 있습니다. 따라서 매개변수가 3개 이하라면, 굳이 빌더 패턴을 사용하지 않아도 될 수 있습니다.

하지만 api의 확장 가능성을 항상 염두해 둔다면 시작부터 빌더 패턴을 사용하는 것도 나쁘지 않다고 합니다.


백엔드 프로젝트에서 사용하고 있는 빌더 패턴

@Component
public class CommentTestHelper {

  @Autowired
  CommentRepository commentRepository;

  @Autowired
  MemberTestHelper memberTestHelper;

  @Autowired
  PostTestHelper postTestHelper;

  public Comment generate() {
    return this.builder().build();
  }

  public CommentBuilder builder() {
    return new CommentBuilder();
  }

  public final class CommentBuilder {

    private Member member;
    private Post post;
    private Comment parent;
    private String content;
    private String ipAddress;

    private CommentBuilder() {

    }

    public CommentBuilder member(Member member) {
      this.member = member;
      return this;
    }

    public CommentBuilder post(Post post) {
      this.post = post;
      return this;
    }

    public CommentBuilder parent(Comment parent) {
      this.parent = parent;
      return this;
    }

    public CommentBuilder content(String content) {
      this.content = content;
      return this;
    }

    public CommentBuilder ipAddress(String ipAddress) {
      this.ipAddress = ipAddress;
      return this;
    }

    public Comment build() {
      return commentRepository.save(Comment.builder()
          .member(member != null ? member : memberTestHelper.generate())
          .post(post != null ? post : postTestHelper.generate())
          .parent(parent)
          .content(content != null ? content : "댓글내용")
          .ipAddress(ipAddress != null ? ipAddress : "0.0.0.0")
          .build());
    }
  }
}

위 코드는 백엔드 프로젝트의 테스트 코드에서 도입한 빌더 패턴입니다. 테스트 코드를 짤 때, 객체 생성을 편하게 하도록 하기 위해서 작성되었습니다. 메서드 단위로 뜯어서 설명해보겠습니다.

public CommentBuilder builder() {
  return new CommentBuilder();
}

CommentTestHelper.builder() 메서드는 CommentBuilder 생성자를 호출하고 있습니다. 빌더를 호출할 때 가장 먼저 호출하는 메서드입니다.

CommentBuilder는 Commet 스펙에 맞게 필드와 setter 메서드를 가지고 있습니다. 각각을 필요에 따라 호출하면 됩니다.

public Comment build() {
  return commentRepository.save(Comment.builder()
      .member(member != null ? member : memberTestHelper.generate())
      .post(post != null ? post : postTestHelper.generate())
      .parent(parent)
      .content(content != null ? content : "댓글내용")
      .ipAddress(ipAddress != null ? ipAddress : "0.0.0.0")
      .build());
}

마지막으로 build()를 호출하면 되는데, 필드 값이 null 일 경우(빌더의 setter 메서드를 호출해서 값을 설정하지 않았을 경우) default 값을 설정하는 코드가 추가되어 있습니다.

이는 db에 값이 들어갈 때, not null 처리된 컬럼에 값을 채워넣기 위해 설정해두었습니다.

+) 책의 예제에서는 생성자를 호출했지만, 백엔드 코드에서는 Comment의 빌더를 호출하고 CommentBuilder의 값을 채워넣고 있습니다.

public Comment generate() {
  return this.builder().build();
}

generate() 메서드는 기본 객체를 생성할 때 CommentTestHelper.builder().build(); 대신 호출하도록 만들어졌습니다.


Lombok의 @Builder

앞에서 빌더 패턴의 단점이, 객체를 생성하기 전에 빌더를 작성해야 한다는 점이였습니다. 하지만 Lombok에서 제공하는 @Builder를 사용하기만 하면 수고 없이 빌더를 사용할 수 있습니다. 프로젝트에서는 private 생성자에 @Builder를 붙여 사용하고 있습니다.

@Builder
private Comment(Member member, Post post, Comment parent, String content, String ipAddress) {
  this.member = member;
  this.post = post;
  this.parent = parent;
  this.content = content;
  this.ipAddress = ipAddress;
}

클래스에도 @Builder를 붙일 수 있습니다. 실제로 리뉴얼2 이전 프로젝트에서는 클래스 단위에 @Builder를 붙였었습니다.

@Entity
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder // 클래스 단위에 붙인 빌더 어노테이션
@ToString
@Table(name = "comment")
public class CommentEntity {

If a member is annotated, it must be either a constructor or a method. If a class is annotated, then a package-private constructor is generated with all fields as arguments (as if @AllArgsConstructor(access = AccessLevel.PACKAGE) is present on the class), and it is as if this constructor has been annotated with @Builder instead. 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.
출처 - 공식 문서

클래스 단위에 @Builder를 붙이는 것은 @AllArgsContructor(access = PACKAGE)를 추가하고, 이로 인해 만들어진 생성자에 @Buider를 붙이는 것과 동일하다고 합니다.

이 방식의 단점에 대한 자세한 설명은 아래 블로그 링크에 잘 나와있습니다. 그렇기에 리뉴얼2 프로젝트에서는 클래스 단위가 아니라 직접 만든 생성자 단위에 @Builder 어노테이션을 붙이는 방식을 택하고 있습니다.

공식 문서를 통해 알아보는 @NoArgsConstructor, @RequiredArgsConstructor, @AllArgsConstructor + 사용시 주의사항(단점)

Lombok-Builder의-동작-원리

TODO: @Builer 어노테이션을 붙이고 생성된 클래스 파일을 IntelliJ로 바이트 코드 분석해보기

profile
빛나는 개발자가 되는 그날까지...

1개의 댓글

comment-user-thumbnail
2023년 6월 27일

item 17

답글 달기