자바 개발을 하다보면 대부분 Lombok을 사용하여 개발 편의성을 올리곤 한다. Lombok이 편한 건 맞지만 @Data 어노테이션을 잘못 사용했다간 객체 생성부터 어긋나기 시작해 버린다. Getter, Setter, RequiredArgsConstructor, ToString, EqualsAndHashCode, Value를 모두 한번에 적용하는데, 위험하지 않은 게 이상한 일이다.
무분별한 Setter 사용과 ToString으로 인한 순환 참조 문제 등 @Data나 @Setter 어노테이션의 위험성은 익히 알려져 있는 것 같은데, 함께 많이 쓰는 @Builder 어노테이션에 대해서는 상대적으로 위험성이 덜 알려진 거 같다.(내 기분이..) 나처럼 하수들이 막 쓰면 안될 정도로 '이거 좀 위험한데' 싶은 구석이 많아서 오늘은 디자인패턴 중 빌더(Builder) 패턴에 대해 간단하게 살펴보고 lombok의 @Builder 어노테이션 사용 시 주의사항을 알아보자.
Builder 패턴
디자인 패턴 중 빌더(Builder) 패턴은 객체 생성 단계를 캡슐화할 때 사용한다. 객체의 생성 과정과 표현 방법을 분리해서 동일한 생성 과정이라도 각각 다른 표현 결과를 만들 수 있다. 주로 복합 객체 구조를 구축하는데 많이 쓰인다.
Builder 패턴 구조
Builder 패턴 객체
AbstractBuilder - Foo 객체를 생성하기 위한 추상 인터페이스 정의
ConcreateBuilder - AbstractBuilder 추상 인터페이스를 구현. builder part들을 모아 실제 객체를 만들어서 복합 구조를 만든다.
Client - 클라이언트가 빌더에게 객체 생성 요청. 빌더 인터페이스를 사용하는 객체를 만든다. 최종적으로 getResultFoo() 메소드를 호출하여 완성된 객체를 가져온다.
Builder 패턴 예제 코드
- User 클래스
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.function.Consumer;
public class User {
private int id;
private String name;
private String emailAddress;
private boolean isVerified;
private LocalDateTime createdAt;
private List<Integer> friendUserIds;
public User(Builder builder) {
this.id = builder.id;
this.name = builder.name;
this.emailAddress = builder.emailAddress;
this.isVerified = builder.isVerified;
this.createdAt = builder.createdAt;
this.friendUserIds = builder.friendUserIds;
}
public int getId() {
return id;
}
public String getName() {
return name;
}
public Optional<String> getEmailAddress() {
return Optional.ofNullable(emailAddress);
}
public boolean isVerified() {
return isVerified;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public List<Integer> getFriendUserIds() {
return friendUserIds;
}
public static Builder builder(int id, String name) {
return new Builder(id, name);
}
public static class Builder {
private int id;
private String name;
public String emailAddress;
public boolean isVerified;
public LocalDateTime createdAt;
public List<Integer> friendUserIds = new ArrayList<>(); // 기본값(빈값) 설정
public Builder(Builder builder) {
this.id = builder.id;
this.name = builder.name;
this.emailAddress = builder.emailAddress;
this.isVerified = builder.isVerified;
this.createdAt = builder.createdAt;
this.friendUserIds = builder.friendUserIds;
}
private Builder(int id, String name) {
this.id = id;
this.name = name;
}
// 하나로 합쳐서 build 가능한 메서드
public Builder with(Consumer<Builder> consumer) {
consumer.accept(this);
return this;
}
public Builder withEmailAddress(String emailAddress) {
this.emailAddress = emailAddress;
return this;
}
public Builder withIsVerified(boolean isVerified) {
this.isVerified = isVerified;
return this;
}
public Builder withCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
return this;
}
public Builder withFriendUserids(List<Integer> friendUserIds) {
this.friendUserIds = friendUserIds;
return this;
}
public User build() {
return new User(this);
}
}
@Override
public String toString() {
return "User [id=" + id + ", " + (name != null ? "name=" + name + ", " : "")
+ (emailAddress != null ? "emailAddress=" + emailAddress + ", " : "") + "isVerified=" + isVerified
+ ", " + (friendUserIds != null ? "friendUserIds=" + friendUserIds : "") + "]";
}
}
- User 클래스 빌더 사용 예제
// 개별적으로 생성
User user1 = User.builder(101, "Alice")
.withEmailAddress("alice@gmail.com")
.withCreatedAt(LocalDateTime.now())
.withIsVerified(true)
.build();
// with 메서드 하나로 생성
User user2 = User.builder(102, "Bob")
.with((builder) -> {
builder.isVerified = true;
builder.friendUserIds = Arrays.asList(101, 103, 104);
builder.emailAddress = "Bob@gmail.com";
builder.createdAt = LocalDateTime.now();
}).build();
위에선 2가지 유형을 보여주고 있다. 개별적으로 하나씩 입력하는 것과 with 메서드 하나를 이용하는 방식이다. 보통 lombok으로 자동 생성한 빌더 패턴은 첫번째 방식과 유사하다.
Lombok @Builder 어노테이션
그럼 lombok의 @Builder 어노테이션을 클래스 위에 붙이면 어떻게 되는지 확인 해 보자.
import java.time.LocalDateTime;
import java.util.List;
import lombok.Builder;
@Builder
public class Foo {
private int id;
private String name;
private String emailAddress;
private boolean isVerified;
private LocalDateTime createdAt;
private List<Integer> friendUserIds;
}
아래는 lombok이 컴파일 시점에 바이트코드를 변환하여 빌더 패턴을 주입한 결과물이다.
import java.time.LocalDateTime;
import java.util.List;
public class Foo {
private int id;
private String name;
private String emailAddress;
private boolean isVerified;
private LocalDateTime createdAt;
private List<Integer> friendUserIds;
Foo(int id, String name, String emailAddress, boolean isVerified, LocalDateTime createdAt, List<Integer> friendUserIds) {
this.id = id;
this.name = name;
this.emailAddress = emailAddress;
this.isVerified = isVerified;
this.createdAt = createdAt;
this.friendUserIds = friendUserIds;
}
public static FooBuilder builder() {
return new FooBuilder();
}
public static class FooBuilder {
private int id;
private String name;
private String emailAddress;
private boolean isVerified;
private LocalDateTime createdAt;
private List<Integer> friendUserIds;
FooBuilder() {
}
public FooBuilder id(int id) {
this.id = id;
return this;
}
public FooBuilder name(String name) {
this.name = name;
return this;
}
public FooBuilder emailAddress(String emailAddress) {
this.emailAddress = emailAddress;
return this;
}
public FooBuilder isVerified(boolean isVerified) {
this.isVerified = isVerified;
return this;
}
public FooBuilder createdAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
return this;
}
public FooBuilder friendUserIds(List<Integer> friendUserIds) {
this.friendUserIds = friendUserIds;
return this;
}
public Foo build() {
return new Foo(this.id, this.name, this.emailAddress, this.isVerified, this.createdAt, this.friendUserIds);
}
public String toString() {
return "Foo.FooBuilder(id=" + this.id + ", name=" + this.name + ", emailAddress=" + this.emailAddress + ", isVerified=" + this.isVerified + ", createdAt=" + this.createdAt + ", friendUserIds=" + this.friendUserIds + ")";
}
}
}
생성자에 매개변수를 받을 수도 있고, 아래처럼 빌더를 통해 값을 구성해 객체를 생성할 수도 있다.
Foo foo = Foo.builder()
.name("달리")
.emailAddress("12345")
.build();
lombok을 이용해 어노테이션 하나 붙여줬을 뿐인데 이렇게 편하다니!
하지만, 뭐든지 너무 편하면 부작용을 의심해 봐야 한다. 그럼 앞서 언급했던 주의해야 할 점은 뭐가 있을까.
Lombok @Builder 사용 시 주의사항
위에서 보다시피 클래스 위에 @Builder 어노테이션을 작성할 경우 모든 멤버 변수를 받는 기본 생성자를 만들고 그에 맞춰 빌더 패턴 코드가 완성된다. DB와 연동한다 했을 때 통상 id나 createdAt 값 등 DB가 자동적으로 생성해 주는 값에 의존하는 경우가 많은데 이때 이 값들을 지정할 수 있도록 열려있는 구조는 문제가 될 여지가 다분하다.
Foo foo = Foo.builder()
.id(100) // id를 자동으로 생성해준다면 이렇게 id를 넣어줄 필요가 없다.
.createdAt(LocalDateTime.now().minusDays(1) // 의도하지 않은 다른 값을 넣을 수 있다.
.build(); // (정책적으로 정해둔) 필수값이 빠져도 생성됨
만약 객체 생성 때 필수값이 있더라도, 모든 멤버 변수를 대상으로 하는 빌더에선 필수값을 넣지 않아도 생성되는 불상사가 발생할 수 있다.
그래서 결론적으로,
작업에 필요한 매개변수를 설정한 생성자 위에 @Builder 어노테이션을 붙여서 사용하도록 하자.
@Builder
public Foo(String name, String emailAddress) {
this.name = name;
this.emailAddress = emailAddress;
this.isVerified = false;
this.createdAt = LocalDateTime.now();
this.friendUserIds = new ArrayList<>();
}
위의 예제에선 초기 고정값이 없는 name과 email만 입력받고 있다. 이렇게 컴파일 해보면,
위 이미지처럼 다른 멤버 변수는 닫혀 있어 실수를 방지할 수 있다. 세상에 완벽한 사람이 없듯이 뭐든 트레이드오프가 있다는 걸 명심하고, 미연의 사고는 시스템으로 해결할 수 있도록 주의하자.
빌더 메서드 이름도 변경 할 수 있다. 아래처럼 @Builder 어노테이션 옆에 옵션을 줄 수 있다. 이로써 빌더가 다양할 때 보다 명시적으로 사용할 수 있게 되었다.
@Builder(builderMethodName = "createFoo")
// 사용할 때
Foo.createFoo()
.name("Bob")
.emailAddress("가나다라@gmail.com")
.build();
덧.
아래 Yun님의 글이 정리가 잘 되어 있어서 추천드린다.
[참고]
GoF 디자인 패턴
헤드퍼스트 디자인패턴 - 한빛미디어
'개발 > Java|Spring' 카테고리의 다른 글
스프링 핵심원리 기본 - IoC, DI, ApplicationContext, 의존관계 주입 등 (0) | 2023.04.06 |
---|---|
좋은 객체 지향 설계의 5가지 원칙 SOLID (0) | 2023.03.29 |
대량 이미지 동일한 사이즈로 분할하기(JAVA) (0) | 2023.02.13 |
JWT 토큰 라이브러리 java-jwt와 jjwt 간단 비교 (0) | 2023.02.09 |
Spring Security JWT 토큰으로 인증하기 (0) | 2023.02.07 |