오늘은 소프트웨어 디자인 패턴에 있어 빌더 패턴 (Builder pattern) 을 다룰려고 한다.
빌더 패턴을 직접 구현하지 않았더라도 현업에서는 기본적으로 쓰고 있는 패턴이라고 믿는다.
자바 스프링에서는 @Builder 어노테이션으로 클라스에 붙이면 instantiation을 할 필요없이 바로 원하는 초기 속성 값들을 바로 넣을 수 있게 된다.
일반적인 클라스의 제작
예를 들어보면 일반적인 경우 우리는 클라스 변수들이 private 일때 보통 Constructor을 통해서나 setter 와 getter로 값을 설정하고 불러온다.
import java.time.LocalDate;
public class NonBuilder {
private String name; //이름
private int age; //나이
private LocalDate dateOfBirth; //생일
NonBuilder(String name, int age, LocalDate dateOfBirth) {
this.name = name;
this.age = age;
this.dateOfBirth = dateOfBirth;
}
}
Constructor을 통한 초기값 설정은 비효율 적이고 제한적이다. 물론, 용도와 기능에 따라서 일부로 제한을 하는 경우가 있다. 가령, name, age, dateOfBirth가 클라스 인스턴스를 제작할 때 값들을 바로 줄 수 있고 필 수히 필요할 때 쓴다.
그러나 만약 이름과 나이만 알지만 생일을 모른다면 null을 보내면 되는데 이 경우에 혹시나 생일을 참조해 무언가를 하는 함수나 프로세스가 있다면 NullPointerException이 발생할 것이다.
그러면 컨스트럭터 수를 모든 경우의 수로 만들자고 하기에는 너무나도 비 효율적이다.
import java.time.LocalDate;
public class NonBuilder {
private String name;
private int age;
private LocalDate dateOfBirth;
NonBuilder(String name, int age, LocalDate dateOfBirth) {
this.name = name;
this.age = age;
this.dateOfBirth = dateOfBirth;
}
NonBuilder(String name, int age) {
this.name = name;
this.age = age;
}
NonBuilder(int age, LocalDate dateOfBirth) {
this.age = age;
this.dateOfBirth = dateOfBirth;
}
NonBuilder(String name) {
this.name = name;
}
NonBuilder(int age) {
this.age = age;
}
NonBuilder(LocalDate dateOfBirth) {
this.dateOfBirth = dateOfBirth;
}
}
위처럼 n개의 변수를 초기 설정해야 할 때 n! == (n * (n-1) * (n-2) * ... * 1) 개수만큼 적기에는 너무나도 번거로운 일이고 코드 확장에 너무나도 불리하다. 심지어 전달 되는 매개변수의 순서도 맞아야 한다.
그렇다고 Setter와 Getter도 마찬가지 이다. 초기화 해줄 변수들이 많아지면 코드 줄이 계속 길어지고 지저분해진다.
그렇다고 Data encapsulation을 위반하는 빌드 변수들의 퍼블릭 선언도 매우 위험하다. 상속과 합성에 있어 접근이 수월해져, 자꾸 외부에서 직접적인 Referencing을 하게 됨으로서 코드간의 의존성을 불필요하게 높이기 때문이다.
이럴때 용이하게 사용되는것이 Builder 패턴이다. 심지어 Builder는 매개변수들의 순서도 상관이 없어지게 된다!
빌더 클라스의 제작
import java.time.LocalDate;
public class BuilderExample {
private String name;
private int age;
private LocalDate dateOfBirth;
private BuilderExample(BuilderExampleBuilder builder) {
this.name = builder.name;
this.age = builder.age;
this.dateOfBirth = builder.dateOfBirth;
}
public String getName() {
return this.name;
}
public int getAge() {
return this.age;
}
public LocalDate getDateOfBirth() {
return this.dateOfBirth;
}
public static class BuilderExampleBuilder {
private String name;
private int age;
private LocalDate dateOfBirth;
public BuilderExampleBuilder name(String name) {
this.name = name;
return this;
}
public BuilderExampleBuilder age(int age) {
this.age = age;
return this;
}
public BuilderExampleBuilder dateOfBirth(LocalDate dateOfBirth) {
this.dateOfBirth = dateOfBirth;
return this;
}
public BuilderExample build() {
return new BuilderExample(this);
}
}
}
위 코드처럼 빌더 클라스를 제작해주고 다음 코드와 같이 제공할 수 있는 값들만 선택적으로 보낼 수 있는 이점이 있다.
import java.time.LocalDate;
public class BuilderRunner {
private void runBuilderExamplesOnlyNameArgs() {
BuilderExample builderExample =
new BuilderExample.BuilderExampleBuilder()
.name("Marco")
.build();
System.out.println("---------Only name arg builder---------");
System.out.println(builderExample.getName());
System.out.println(builderExample.getAge());
System.out.println(builderExample.getDateOfBirth());
}
private void runBuilderExamplesAllArgs() {
BuilderExample builderExample =
new BuilderExample.BuilderExampleBuilder()
.name("Marco")
.age(24)
.dateOfBirth(LocalDate.of(1923, 11, 22))
.build();
System.out.println("---------All args builder---------");
System.out.println(builderExample.getName());
System.out.println(builderExample.getAge());
System.out.println(builderExample.getDateOfBirth());
}
public static void main(String[] args) {
BuilderRunner instance = new BuilderRunner();
instance.runBuilderExamplesAllArgs();
instance.runBuilderExamplesOnlyNameArgs();
}
}
빌더를 처음 써보시는 분이라면 코드를 보다보면 다음과 같이 적혀 있을 것이다.
new BuilderExample.BuilderExampleBuilder() .name("Marco") .age(24) .dateOfBirth(LocalDate.of(1923, 11, 22)) .build();
우리는 이 기법을 chaining 혹은 pipeline이라고 부른다. semi-column 으로 인해 코드줄 끊김 없이 계속 지정해서 메서드를 호출 하고 값을 세팅 할 수 있기 때문이다.
실행하면 다음과 같이 출력 된다.
---------All args builder---------
Marco
24
1923-11-22
---------Only name arg builder---------
Marco
0
null
두번째 출력 문단을 보면 마지막 두개의 값이 0과 null로 int의 primitive type default value인 0와 오브젝트 기본값 null로 출력된다. 앞 서 말했듯이 null-safe 코드로 만들고 싶다면 초기값을 설정해 주거나 강제적으로 사용자에게 값을 넣게 만드는 방법이 있다. 이 경우, 본인은 이름과 생일 필드를 강제로 하고 나이 칸을 기본으로 계산해주는 코드로 바꿔보겠다.
import java.time.LocalDate;
public class BuilderExample {
private String name;
private int age;
private LocalDate dateOfBirth;
private BuilderExample(BuilderExampleBuilder builder) {
this.name = builder.name;
this.age = builder.age;
this.dateOfBirth = builder.dateOfBirth;
}
public String getName() {
return this.name;
}
public int getAge() {
return this.age;
}
public LocalDate getDateOfBirth() {
return this.dateOfBirth;
}
public static class BuilderExampleBuilder {
private String name;
private int age;
private LocalDate dateOfBirth;
private String memo;
public BuilderExampleBuilder requiredFields(String name, LocalDate dateOfBirth) {
this.name = name;
this.dateOfBirth = dateOfBirth;
this.age = LocalDate.now().getYear() - dateOfBirth.getYear();
return this;
}
public BuilderExampleBuilder age(int age) {
this.age = age;
return this;
}
public BuilderExample build() {
if (this.name == null) {
throw new RuntimeException("Required to provide name");
}
if (this.dateOfBirth == null) {
throw new RuntimeException("Required to provide date of birth");
}
return new BuilderExample(this);
}
}
}
import java.time.LocalDate;
public class BuilderRunner {
private void runBuilderExamplesRequiredArgs() {
System.out.println("---------Required args builder---------");
BuilderExample builderExample =
new BuilderExample
.BuilderExampleBuilder()
.requiredFields("Marco", LocalDate.of(1922,11,22))
.build();
System.out.println(builderExample.getName());
System.out.println(builderExample.getAge());
System.out.println(builderExample.getDateOfBirth());
}
private void runBuilderExamplesOnlyAgeArgs() {
try {
System.out.println("---------Only age arg builder---------");
BuilderExample builderExample =
new BuilderExample.BuilderExampleBuilder()
.age(24)
.build();
System.out.println(builderExample.getName());
System.out.println(builderExample.getAge());
System.out.println(builderExample.getDateOfBirth());
} catch (RuntimeException exception) {
System.out.println(exception + "\n");
}
}
public static void main(String[] args) {
BuilderRunner instance = new BuilderRunner();
instance.runBuilderExamplesOnlyAgeArgs();
instance.runBuilderExamplesRequiredArgs();
}
}
다음 코드들을 실행시키면 다음과 같이 출력된다.
---------Only age arg builder---------
java.lang.RuntimeException: Required to provide name
---------Required args builder---------
Marco
102
1922-11-22
이름과 생년 필드들을 강제로 넣게 만들고 만약 하나라도 비어 있으면 경고문구를 띄우고 처리를 안하도록 만들었다.
나이는 자동으로 계산되었다.
쉬운 방법 - lombok's @ Annotiation
빌터 패턴의 문제점은 빌더패턴을 적용하는 클라스 자체의 코드 길이가 길어진다는 단점이 있다. 필드들이 100개가 넘거간다면 분명 놓치는 부분도 생길 수 도 있다. 이때 자바 스프링의 라이브러리에서 제공하는 편의한 기능이 있다.
바로 lombok 아티펙트이다. Lombok Documentation: https://projectlombok.org/features/Builder
Getter, Setter, Builder, AllArgsConstructor, NoArgsConstructor 등 우리가 수동적으로 적어야 하는 코드를 Annotation하나로 줄여주는 녀석이다.
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>RELEASE</version>
<scope>compile</scope>
</dependency>
위의 디펜던시 설정을 pom.xml에 추가하자(maven)
그 다음, 우리는 다음 기능을 쓰면 끝이다.
@Builder
클라스 정의하는 코드 상단에 위와 같은 Annotation을 적어주면 클라스 안에 빌터 클라스를 제작 할 필요가 없어진다.
import lombok.*;
import java.time.LocalDate;
@Builder
@Getter
class BuilderExample {
@NonNull
private String name;
private int age;
@NonNull
private LocalDate dateOfBirth;
public static BuilderExampleBuilder builder(final String name, final LocalDate dateOfBirth) {
return new BuilderExampleBuilder() {
@Override
public BuilderExample build() {
Assert.notNull(name);
Assert.notNull(dateOfBirth);
super.name(name);
super.dateOfBirth(dateOfBirth);
super.age(LocalDate.now().getYear() - dateOfBirth.getYear());
return super.build();
}
};
}
}
이것이 끝이다! 그 많은 줄들을 이렇게 획기 적으로 줄였다.
@NonNull 은 build()를 콜할 때 필요한 초기 변수 값을 확인하고 @Getter은 말 그대로 Getter 메서드를 자동화 해서 외부에서 getter 메서드를 콜하면 필드 값을 쉽게 참조 할 수 있게 만든다.
또한 builder의 build클라스를 Override하여 null check를 강제해준다
그렇게 아래 코드를 실행하면 다음과 같이 찍힌다.
import java.time.LocalDate;
public class BuilderRunner {
private void runBuilderExamplesRequiredArgs() {
System.out.println("---------Required args builder---------");
BuilderExample builderExample =
BuilderExample.builder("Marco", LocalDate.of(1922,11,22))
.build();
System.out.println(builderExample.getName());
System.out.println(builderExample.getAge());
System.out.println(builderExample.getDateOfBirth());
}
private void runBuilderExamplesOnlyAgeArgs() {
try {
System.out.println("---------Only age arg builder---------");
BuilderExample builderExample =
new BuilderExample.BuilderExampleBuilder()
.name(null)
.age(24)
.build();
System.out.println(builderExample.getName());
System.out.println(builderExample.getAge());
System.out.println(builderExample.getDateOfBirth());
} catch (RuntimeException exception) {
System.out.println(exception + "\n");
}
}
public static void main(String[] args) {
BuilderRunner instance = new BuilderRunner();
instance.runBuilderExamplesOnlyAgeArgs();
instance.runBuilderExamplesRequiredArgs();
}
}
---------Only age arg builder---------
java.lang.NullPointerException: name is marked non-null but is null
---------Required args builder---------
Marco
102
1922-11-22
실무 예시
본인이 실제로 사용한 실무 어플리케이션 사용 용도이다.
- CLI (Command Line Interface) 커맨드를 사용자로 부터 읽을 때
이 부분이 Builder의 제일 유익한 사용법이 아니었나 생각된다. 사용자는 존재하지 않는 커맨드를 입력할 때도 있고, 값을 이상하게 전달하거나 커맨드 배열의 순서를 뒤죽박죽 썪어서 입력하게 되는데. Builder 패턴이 이 모든것을 해결 해줬다.
특정 커맨드가 잘 못되면 바로 알려주고 입력 값에 따라 커맨드 제안을 해줄 수 있는 기능을 넣을 수 있다 (Git의 git clonr 을 입력하면 git clone을 의미 하십니까? 와 같다. - https://www.geeksforgeeks.org/python-word-similarity-using-spacy/ 참고) - Object Parsing - 외부 입력 값으로 부터 POJO를 만들어 나갈 때 유용하다.
- DB Object domain/entities - DB 오브젝트를 mapping, unmapping 할때 쉽게쉽게 설정 할 수 있다
- UnitTest - 테스팅 할때 오브젝트 인스턴스 하는것 만큼 귀찮은게 없다. Builder는 코드수를 줄여줌으로써 새로운 오브젝트 선언을 쉽게 해준다.
Builder와 비슷 한 기능 - Record
Record는 Builder와 비슷한 기능을 하지만 완전히 제한적이다. 굳이 비교하자면 NoArgsConstrutor와 비슷하며 Immutable이다. 한번 생성되면 고칠 수가 없으다.
이렇게 오브젝트 생성 방법에 종류가 많은 이유는 다 있다. 오브젝트 도메인 클라스를 만드실 때 Record냐, Builder냐, 일반 인스턴스로 가야 하는지 고민을 해봐야 하고 그 이유에 대해서는 나중에 따로 설명하겠다.
이 고려 사항들은 수정 빈도, 필드 변수 참조 빈도에 따라 나뉘는 것 같다. 똑같이, Builder도 남발하면 좋은 것은 아니다. 모든것에 Cost에 따른 용도가 있는 것이다.
'프로그래밍 기법' 카테고리의 다른 글
소프트웨어 디자인 패턴 - 데코레이터(DecoratorPattern - Structual Patterns - 1) (2) | 2024.02.26 |
---|---|
소프트웨어 디자인 패턴 - 팩토리 패턴 (Factory Pattern - Creational Design Pattern - 4) (0) | 2024.01.30 |
소프트웨어 디자인 패턴 - Prototype Pattern (Creational Design Pattern - 3) (2) | 2024.01.14 |
소프트웨어 디자인 패턴 - 싱글톤패턴 Singleton Pattern (Creational Design Pattern - 1) (2) | 2023.12.31 |
소프트웨어 디자인패턴(Software Design Pattern) (0) | 2023.12.29 |