프로젝트를 진행하며 아래처럼 생긴 클래스를 자주 만들었던 기억이 있습니다.
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Member {
private Long id;
/*
* 생략
*/
@AllArgsConstructor, @RequiredArgsConstructor, @NoArgsConstructor, @Builder, . . 등등 생성자와 관련된 어노테이션을 별다른 고민 없이 사용했었던 것 같습니다.
또 builder와 생성자 방식을 비교하는 많은 글을 통해 builder가 대세라는 것도 알 수 있었습니다.
이밖에 정적 팩토리 메서드도 객체를 생성하는 유용한 방법입니다.
이번 글에서는 다양한 방법으로 객체를 만들어보려고 합니다!
먼저, 생성자를 통해서 회원 객체를 만들어 보겠습니다.
회원은 이름, 이메일, 휴대폰번호, 나이를 필드로 가지고 있습니다. 아래 코드에는 모든 필드를 파라미터로 갖는 생성자가 있습니다.
public class Member {
private String name;
private String email;
private String phoneNumber;
private int age;
public Member(String name, String email, String phoneNumber, int age) {
this.name = name;
this.email = email;
this.phoneNumber = phoneNumber;
this.age = age;
}
}
new 키워드를 사용해 회원 객체를 만들어보겠습니다.
Member member = new Member("소은", "soeun1234@vmail.com", "010-1212-3434", 100);
회원 객체가 만들어졌습니다 !
lombok의 @AllArgsConstructor 어노테이션을 사용해보도록 하겠습니다.
아래와 같이 lombok 의존성을 추가합니다.
compileOnly 'org.projectlombok:lombok:1.18.36'
annotationProcessor 'org.projectlombok:lombok:1.18.36'
회원 클래스에서 직접 구현한 생성자를 제거하고, @AllArgsConstructor
를 붙입니다.
@AllArgsConstructor
public class Member {
private Long id;
private String name;
private String email;
private String phoneNumber;
private int age;
}
@AllArgsConstructor
어노테이션은 앞서 직접 구현했던 것과 같이 모든 필드를 파라미터로 갖는 생성자를 만듭니다.
Member wrongMember = new Member("소은", "010-1212-3434","soeun1234@vmail.com", 100);
실수로 파라미터의 순서를 바꿔 잘못된 회원 객체를 만들었다고 가정해보겠습니다. 이메일과 휴대폰 번호가 잘못 전달되었고, 이는 유효하지 않은 회원 객체입니다.
그러나, 두 가지 모두 String
타입이기 때문에 파라미터의 순서가 잘못되어도 컴파일러는 이를 인식하지 못합니다.
이렇게 잘못 생성된 회원 객체는 런타임에 예기치 못한 오류를 발생시킬 수 있습니다.
파라미터의 개수가 이보다 많아지게 된다면, 가독성이 떨어질 수 있습니다. 또한 개발자의 실수가 발생할 가능성이 높아집니다.
위의 두 가지 문제점에 대한 해결책으로 빌더 패턴이 흔히 제시됩니다.
Builder Pattern in Java - Baeldung에 따르면 빌더 패턴의 장점은 다음과 같습니다.
유연성: 실제 객체의 표현으로부터 생성 프로세스를 분리함으로써, 다양한 구성으로 객체를 생성할 수 있습니다. 여러 개의 생성자 또는 Setter 성격의 메서드를 둘 필요가 없어집니다.
가독성: 복잡한 객체의 생성 과정을 한 눈에 이해할 수 있도록 돕습니다.
불변성: 불변 객체를 생성해 스레드 안전성을 보장하고, 의도치 않은 수정을 방지합니다.
빌더 패턴을 사용하면 각 필드에 대해 명시적인 메서드를 호출하여 값을 설정할 수 있기 때문에, 앞서 살펴봤던 파라미터의 순서가 뒤바뀌는 실수를 방지할 수 있습니다.
특히 타입이 동일한 필드가 많을 때 장점이 드러난다고 느꼈습니다.
@Builder 어노테이션을 사용하면, Lombok이 빌더 클래스를 제공해줍니다.
@Builder
public class Member {
private String name;
private String email;
private String phoneNumber;
private int age;
}
회원 클래스 위에 @Builder 어노테이션을 추가했습니다. 이제 아래와 같이 객체를 생성할 수 있습니다.
Member jiyeon = Member.builder()
.name("지연")
.email("soeun1234@vmail.com")
.phoneNumber("010-2323-3434")
.age(200)
.build();
이제 이메일과 휴대폰번호를 전달할 때 파라미터의 순서를 고려하지 않아도 됩니다.
@Builder 어노테이션은 클래스, 생성자, 메서드 위에 붙일 수 있습니다.
lombok 공식문서에 따르면, 클래스 레벨에 @Builder 어노테이션을 붙일 경우 Lombok이 자동으로 모든 필드를 파라미터로 받는 package-private 생성자를 만들어줍니다.
즉, 아래 두가지는 동일하게 동작합니다.
@Builder
public class Member {
private String name;
private String email;
private String phoneNumber;
private int age;
}
@AllArgsConstructor(access = AccessLevel.PACKAGE)
@Builder
public class Member {
private String name;
private String email;
private String phoneNumber;
private int age;
}
다만, @NoArgsConstructor와 @Builder를 함께 사용하는 경우에는 위와 같이 동작하지 않습니다.
@NoArgsConstructor
@Builder
public class Member {
private String name;
private String email;
private String phoneNumber;
private int age;
}
@NoArgsConstructor는 파라미터가 없는 기본 생성자를 제공합니다.
public Member() {
}
Lombok의 @Builder를 클래스 레벨에 붙이게 되면, 전체 파라미터를 받는 생성자가 반드시 필요한데 이 기본 생성자와 충돌이 발생하는 것입니다.
그래서 이럴때는 아래와 같이 @AllArgsConstructor를 추가합니다.
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class Member {
private String name;
private String email;
private String phoneNumber;
private int age;
}
위에 제시했던 lombok 어노테이션 3종 세트를 가진 클래스가 만들어졌습니다.
위의 문제는 @Builder를 클래스 레벨이 아닌, 생성자 위에 붙여 해결할 수도 있습니다.
id 필드가 추가되었다고 가정해보겠습니다. id는 파라미터로 전달받는 것이 아닌 내부적으로 랜덤하게 생성되는 값입니다.
따라서 @AllArgsConstructor로 전체 파라미터를 받는 생성자를 추가하지 않고, 필요한 파라미터만 포함해 생성자를 만들고 그 위에 @Builder를 붙입니다.
@NoArgsConstructor
public class Member {
private String id;
private String name;
private String email;
private String phoneNumber;
private int age;
@Builder
public Member(String name, String email, String phoneNumber, int age) {
this.id = UUID.randomUUID().toString();
this.name = name;
this.email = email;
this.phoneNumber = phoneNumber;
this.age = age;
}
}
클래스 레벨에서 @Builder를 사용할 때와는 다르게 필요한 파라미터만 노출된다는 장점이 있습니다.
빌더 패턴의 장점으로 언급되는 "유연성"에는 아래와 같은 단점이 따릅니다.
@Builder를 사용하게 될 경우, 완전한 객체생성의 원자성을 보장하기 어렵습니다.
회원 객체는 아래와 같이 유연하게 생성될 수 있습니다.
/* 휴대전화번호와 나이만 갖는 회원 */
Member member1 = Member.builder()
.phoneNumber("010-1212-3434")
.age(200)
.build();
/* 휴대전화번호만 갖는 회원 */
Member member2 = Member.builder()
.phoneNumber("010-2323-4545")
.build();
/* 이름과 나이만 갖는 회원 */
Member member3 = Member.builder()
.name("회원3")
.age(200)
.build();
이를 장점으로 보기도 하지만, 필수적으로 포함해야 하는 파라미터를 빠뜨리는 치명적인 실수를 하게 될 가능성이 있습니다.
회원의 이름, 이메일, 전화번호, 나이가 필수 값이라고 가정해보겠습니다. 위와 같이 유효하지 않은 객체를 생성하더라도 컴파일 타임에 이를 체크하기 어렵습니다.
반면, 생성자 방식의 경우 꼭 포함해야 하는 파라미터가 빠졌을 때 컴파일 타임에 알아차릴 수 있습니다.
롬복이 만들어주는 빌더말고 직접 빌더를 만들어 사용하더라도 런타임 에러를 낼 수 있을뿐 컴파일 타임에 체크하기는 어렵다.
출처
Lombok이 제공하는 @Builder를 사용하지 않고 직접 빌더 패턴을 구현해보겠습니다.
public class Member {
private String name;
private String email;
private String phoneNumber;
private int age;
public Member(String name, String email, String phoneNumber, int age) {
this.name = name;
this.email = email;
this.phoneNumber = phoneNumber;
this.age = age;
}
public static class Builder {
private String name;
private String email;
private String phoneNumber;
private int age;
public Builder() {
}
public Builder(Member member) {
this.name = member.name;
this.email = member.email;
this.phoneNumber = member.phoneNumber;
this.age = member.age;
}
public Builder name(String name) {
this.name = name;
return this;
}
public Builder email(String email) {
this.email = email;
return this;
}
public Builder phoneNumber(String phoneNumber) {
this.phoneNumber = phoneNumber;
return this;
}
public Builder age(int age) {
this.age = age;
return this;
}
public Member build() {
/* int 타입 age 필드의 경우 초기화되었는지 확인이 어렵*/
if (name == null || email == null || phoneNumber == null) {
throw new IllegalStateException("필수 파라미터가 빠졌습니다.");
}
return new Member(name, email, phoneNumber, age);
}
}
}
build() 메서드에서 null인 필드를 확인하고, 예외를 발생시키고 있습니다. 다만, 여전히 아래와 같이 컴파일 타임에 유효하지 않은 객체를 만드는 것을 막지 못합니다.
/* 컴파일 에러 발생 X */
Member jiyeon = new Member.Builder()
.name("지연")
.build();
실수로 유효하지 않은 객체를 생성했을 때, 이를 컴파일 타임이 아닌 런타임에 알아차릴 수 있습니다. 여전히 원자적 객체 생성에 대한 고민이 해결되지 않았습니다.
생성자 방식에서 문제가 가장 문제가 되었던 점은 타입 안전성이 보장되지 않는다는 것이었습니다.
타입 안전성을 보장하기 위해 builder pattern을 고려하게 되었지만, 완전한 객체생성의 원자성을 보장하기 어렵다는 단점을 마주하게 되었습니다.
@Builder
를 사용하는 것이 의미가 있는 경우는 동일한 타입의 필드가 많을 때였습니다.
만약 동일한 타입의 필드가 많지 않다면, 원자적인 객체 생성이 가능한 생성자 방식을 택하는 것이 낫다고 판단했습니다.
그렇다면, 필드의 타입을 모두 다르게 하려면 어떻게 하면 좋을까요?
String
타입을Name
,PhoneNumber
로 바꾸면 되지 않을까?
→ VO(Value Object
)를 사용해서 타입 안전성을 보장하면 된다 ❗
그리고, 이렇게 타입 안전성이 보장된다면 생성자 방식을 택하는 것이 두 마리 토끼(타입 안전성, 원자적 객체 생성)를 모두 잡는 방법이라고 생각했습니다.
Email VO를 만들어보겠습니다.
@Getter
@ToString
// 3. 값으로 동등성이 비교됨
@EqualsAndHashCode
public class Email {
// 1. 불변성 (final 키워드, setter 성격의 메서드 없음)
private final String emailValue;
private static final String EMAIL_REGEX = "^[A-Za-z0-9+_.-]+@[A-Za-z0-9.-]+\\.[A-Za-z]{2,}$";
private static final Pattern EMAIL_PATTERN = Pattern.compile(EMAIL_REGEX);
public Email(String emailValue) {
// 2. 자가 유효성 검사 (유효한 값으로만 객체가 생성됨)
validateEmail(emailValue);
this.emailValue = emailValue;
}
private void validateEmail(String emailValue) {
if (!EMAIL_PATTERN.matcher(emailValue).matches()) {
throw new InvalidEmailException(emailValue);
}
}
}
VO는 세 가지 특징을 갖고 있습니다.
- 불변 객체이다.
- 자가 유효성 검사를 포함한다.
- 값으로 비교된다.
이 세 가지 특징은 위의 코드에서 확인할 수 있습니다. VO와 관련된 더 자세한 내용은 추후 작성해보도록 하겠습니다.
@AllArgsConstructor
public class Member {
private Name name;
private Email email;
private PhoneNumber phoneNumber;
private int age;
}
Member member = new Member("소은", "soeun1234@vmail.com", "010-1212-3434", 100);
이전에는 이름, 이메일, 휴대폰번호의 타입이 모두 String
으로 같아 파라미터의 순서를 바꾸어 전달해도 컴파일 타임에 이를 체크할 수 없었습니다.
Member member = new Member(new Name("소은"), new Email("soeun1234@vmail.com"), new PhoneNumber("010-1212-3434"), 100);
이제 타입이 모두 다르기 때문에, 생성자 방식을 사용하더라도 타입에 대한 안전성이 보장됩니다. 또한 필드중 하나라도 누락시 컴파일 타임에 에러를 만나게 됩니다.
builder의 장점은 필드들을 명확히 할당할 수 있고, 객체의 크기가 크더라도 필드들을 헷갈리지 않고 할당할 수 있다는 것입니다.
그러나, 유효하지 않은 객체를 생성할 수 있다는 위험성이 있습니다. 실제로 필드를 새로 추가하면서 객체를 생성하는 모든 코드를 꼼꼼히 수정하지 않아 치명적인 문제가 발생했던 적이 있습니다. builder로 객체를 생성할 때 새로 추가한 필드를 포함하지 않아도 컴파일 타임에 이를 확인할 수 없었기 때문입니다.
모든 선택에는 trade-off가 따르기 때문에 builder의 장점이 이를 상쇄한다고 생각할 수도 있습니다.
☝️ 다만, 앞서 이야기했듯이 빌더를 사용해야 하는 이유가 동일 타입이 많아서였다면, VO를 고려해볼 수 있습니다.
✌️ 또한 큰 객체의 생성은 객체의 분리를 고민해보아야 할 시점일 수 있습니다. 작은 객체를 만들게 되면, 빌더의 장점이 많이 사라지게 됩니다.
하지만 모든 타입을 VO로 구현할 수도 없고, 큰 객체를 만들어야만 하는 상황도 있습니다. 이럴 때는 builder나 아래에 소개될 정적 팩토리 메서드를 고민해볼 수 있습니다.
이쯤되면 어떡하자고..?ㅎㅎ
예전에는 builder가 무조건 정답이라고 알고 있었습니다. 다만 사용해보니 장단점이 있었고, 좋은 해결 방법도 찾을 수 있었습니다. 생성자 방식을 주된 객체 생성 방법으로 택했지만, 상황에 따라 builder를 사용하는 것이 더 적절한 경우도 있었습니다.
따라서 각 방식의 장단점을 알고, 요구사항에 맞게 적절한 판단을 하는 것이 중요하다고 생각합니다❗
이펙티브 자바라는 책에 따르면, "생성자 대신 정적 팩토리 메서드를 고려하라"고 합니다.
정적 팩토리 메서드는 객체 생성을 캡슐화하는 방법입니다. 객체의 생성에 대한 책임을 static 메서드가 대리합니다.
아래 DatabaseConnection.java는 데이터베이스 연결을 위한 클래스입니다.
public class DatabaseConnection {
private final String connectionString;
private Integer poolSize = 8;
public DatabaseConnection(String connectionString) {
this.connectionString = connectionString;
}
public DatabaseConnection(String connectionString, Integer poolSize) {
this.connectionString = connectionString;
this.poolSize = poolSize;
}
public void connect() {
System.out.println("---> connected successfully! \n connection-string: " + connectionString + "\n poolSize: " + poolSize);
}
}
두 가지 생성자가 있습니다.
하나는, 데이터베이스 연결 문자열만 받고 기본 풀 크기를 사용해 데이터베이스 연결을 수립합니다. 다른 하나는, 연결 문자열과 풀 크기를 모두 지정 가능합니다.
이 두 가지 생성자를 제공함으로써, 기본 환경을 사용하거나 커스터마이징 하고 싶을 때를 구분해 유연하게 데이터베이스 연결을 수립할 수 있습니다.
public class DatabaseConnection {
private final String connectionString;
private Integer poolSize = 8;
private static DatabaseConnection databaseConnection = null;
private DatabaseConnection(String connectionString) {
this.connectionString = connectionString;
}
private DatabaseConnection(String connectionString, Integer poolSize) {
this.connectionString = connectionString;
this.poolSize = poolSize;
}
public void connect() {
System.out.println("---> connected successfully! \n connection-string: " + connectionString + "\n poolSize: " + poolSize);
}
/**
* This method provide singleton instance
* @param connectionString
* @return
*/
public static DatabaseConnection getInstance(String connectionString) {
if (Objects.isNull(databaseConnection)) {
databaseConnection = new DatabaseConnection(connectionString);
}
return databaseConnection;
}
public static DatabaseConnection getInstanceWithPoolSize(String connectionString, Integer poolSize) {
return new DatabaseConnection(connectionString, poolSize);
}
}
정적 팩토리 메서드의 특징과 함께 위의 코드를 살펴보도록 하겠습니다.
1. 이름을 가진다.
생성자와 달리, 정적 팩토리 메서드는 getInstance(), getInstanceWithPoolSize()와 같이 이름을 가질 수 있습니다.
PoolSize를 직접 지정하고 싶을 때 getInstanceWithPoolSize()를 선택해야 한다는 사실을 알 수 있습니다.
2. 하나의 인스턴스만 생성하고 재사용할 수 있다.
즉, 정적 팩토리 메서드를 통해 싱글톤 패턴을 구현할 수 있습니다.
getInstance() 메서드를 보면, DatabaseConnection이 null일 때만 새로운 객체를 생성하고, 그렇지 않을 때는 이미 만들어진 객체를 재사용합니다.
또한 생성자의 접근 제한자를 private으로 설정했기 때문에, 정적 팩토리 메서드를 통해서만 객체를 생성할 수 있습니다. 이를 통해 불필요한 중복 객체를 방지하고, 싱글톤으로 사용할 수 있습니다.
3. 하위 타입을 리턴할 수 있다.
DatabaseConnection을 상속받은 MySqlConnection, PostgresConnection, OracleConnection이 있다고 가정해보겠습니다.
다음과 같이 하위 타입의 객체를 리턴할 수 있습니다.
public static DatabaseConnection getInstance(String connectionString) {
if (connectionString.startsWith("mysql")) {
return new MySqlConnection(connectionString);
} else if (connectionString.startsWith("postgresql")) {
return new PostgresConnection(connectionString);
} else if (connectionString.startsWith("oracle")) {
return new OracleConnection(connectionString);
}
throw new IllegalArgumentException("Unsupported database type");
}
4. 예외를 던질 수 있다.
객체를 생성하는 과정에서 예외가 발생할 수 있다면, 정적 팩토리 메서드를 고려해볼 수 있습니다.
위의 예시에서처럼 connectionString이 잘못되었을 때 예외를 던지는 등의 validation을 추가할 수 있습니다.
객체를 생성하는 세 가지 방법을 알아봤습니다.
이제 습관처럼 어노테이션을 붙이지 않고, 다양한 객체 생성 방법을 고민해 적절한 선택을 할 수 있게 되었습니다.
긴 글 읽어주셔서 감사합니다. 새해복 많이 받으세요!
@AllArgsConstructor - lombok
Builder - baldung
Builder pattern vs Constructor - stackoverflow
@Builder on Constructor - lombok
how many constructor arguments is too many - stackoverflow
builder pattern에 대한 고찰
adding custom validation in lombok
Step Builder
Effective Java Consider Static Factory Methods instead of Constructors
What are static factory methods? - stackoverflow
What are the disadvantages of Java constructors?
정리 되게 잘해주셨어요! 감사합니당!