들어가기 전에
<자바의 신> p.191
자바는 클래스의 객체를 보다 간편하게 만들기 위해서
여러 가지 매개변수를 갖는 여러 생성자를 가질 수 있다.
자바에는 기본 생성자란 것이 있어서 개발자가 직접 생성자를 만들지 않아도 컴파일 시점에서 자동으로 생성해 준다. 하지만 특정한 매개변수 별로 생성자를 만들고 싶다면 개발자가 직접 전달 받고 싶은 매개변수를 정해 생성자를 만들어야 하는데 이때의 매개변수 개수는 제한이 없고, 생성자 또한 몇 개를 만들어도 상관 없다.
개인적으로 생성자에 대해 깊게 고민해 본 적이나 코드를 개선해야 하는 상황을 겪어본 적이 없어서 이렇게 제한 없이 생성되는 생성자를 어떻게 보완할 수 있을지에 대해 생각해보지 못했기 때문에 이 포스팅을 작성해 본다.
우선은 매개변수가 제각기 다른 생성자가 제한 없이 생성되면 어떤 문제가 발생할지 생각해보자.
점점 생성자는 늘어나고
A 개발자가 Home 이라는 클래스를 만들면서 다양한 매개변수를 가진 생성자를 만든다.
"name만 가진 생성자로 만든 객체는 a가 필요할 때 사용하고 name, owner만 가진 생성자는 b가 필요할 때 사용하고,..."
public class Home {
...
public Home() {
}
public Home(String name) {
this.name = name;
}
public Home(String name, String owner) {
this.name = name;
this.owner = owner;
}
public Home(String name, String owner, String address) {
this.name = name;
this.owner = owner;
this.address = address;
}
...
}
B 개발자는 Home 클래스의 생성자를 이용해 b를 위해 필요한 객체를 생성하려고 한다. 이때 다음과 같이 많은 생성자가 있다면 B 개발자는 이렇게 생각하지 않을까?
"그래서 여기서 도대체 뭘 쓰라는 거야?"
생성자 뿐만이 아니라 어떤 코드를 작성하든 의도를 알 수 없는 코드는 혼란을 야기한다. A 개발자는 어떠한 주석도 남기지 않고 혼자만의 의도를 가지고 여러 생성자를 만들었지만 정작 생성자를 사용하는 B 개발자는 어떤 선택을 해야할지 혼란스럽기만 하다. 지금은 변수가 6개이지만 만약 더 많은 변수가 선언되어 있다면 어떻게 될까? 주석이나 개발 문서가 남겨져 있다면 상관 없을까? 아니다. 개발자는 일일이 그 많은 생성자들의 정보를 확인하고 싶지 않다.
그렇다면 만약 A개발자가 어떤 의도 없이 누구에게 언제 필요할지 몰라서 매개변수 개수마다 생성자를 만들면 괜찮은 걸까? 아니다. 코드의 길이만 늘어날 뿐, 최소한으로 필요한 생성자를 만들어 사용해 관리하기 편하도록 하는 것이 좋다.
그렇다면 문제점은 다음과 같다.
- 같은 이름으로 만들어진 다양한 생성자들로는 만들어진 의도를 파악할 수 없다.
- 생성자가 너무 많으면 관리하기가 어렵다.
정적 팩토리 메서드
다음과 같이 매개변수가 3개인 생성자로 VIP 등급을 만든다고 할 때 정적 팩토리 메서드를 이용하면 어떻게 될까?
public class Grade {
private String name;
private String phone;
private int age;
...
public Grade(String name, String phone, int age) {
this.name = name;
this.phone = phone;
this.age = age;
}
}
정확히 3개의 매개변수를 통해 어떤 등급의 객체를 만들 수 있는지 이름을 통해 확인할 수 있다.
public static Grade createVIPgrade(String name, String phone, int age) {
return new Grade(name, phone, age);
}
원한다면 다음과 같이 VIP 고객 객체를 만들기 위해 값을 미리 지정해서 사용할 수도 있다.
public static Grade createVIPclient(String name, String grade) {
return new Grade(name, "VIP");
}
정적 팩토리 메서드는 이미 Java 내에서 많이 구현되어 있는데 그중 Optional 클래스의 구현을 살펴보자.
Optional<String> value1 = Optional.empty();
Optional<String> value2 = Optional.of("winter");
Optional<String> value3 = Optional.ofNullable(null);
of() 메서드는 매개변수를 전달받아 새로운 객체를 생성해 반환해주고, ofNullable() 메서드는 nullable한 매개변수를 전달 받아 null이면 empty() 메서드를 통해 비어 있는 Optional 객체를 반환, 매개변수 값이 존재하면 of() 메서드를 호출하여 값이 저장된 Optional 객체를 반환한다. 이렇듯 메서드가 가진 이름을 통해 어떤 역할을 하는지 유추할 수 있고 그로 인해 의도와 맞지 않게 생성자를 사용하는 일도 방지할 수 있다.
장점
1. 호출될 때마다 인스턴스를 새로 생성하지 않아도 된다.
- 인스턴스를 미리 만들어 놓거나 생성된 인스턴스를 재사용해서 불필요하게 객체를 생성하지 않는다.
- 인스턴스를 통제해서 클래스르 싱글톤으로 만들거나 인스턴스화를 막을 수 있다.
// 출처 : https://www.baeldung.com/java-constructors-vs-static-factory-methods
public static User getSingletonInstance(String name, String email, String country) {
if (instance == null) {
synchronized (User.class) {
if (instance == null) {
instance = new User(name, email, country);
}
}
}
return instance;
}
2. 반환 타입의 하위 타입 객체를 반환할 수 있다.
- 반환할 객체의 클래스를 자유롭게 선택할 수 있다.
- API를 만들 때 구현 클래스를 공개하지 않고도 객체를 반환할 수 있어 API를 작게 유지할 수 있다
3. 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.
- 클라이언트가 원하는 상위 클래스의 하위 클래스라면 반환받는 객체가 어느 클래스의 인스턴스인지 중요하지 않다.
4. 정적 팩토리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다.
- 클라이언트가 서비스의 인스턴스를 반환받아 사용할 때 클래스의 구현체나 생성자를 신경쓰지 않고 구현되어 있는 서비스를 제공받는다.
단점
이렇듯 정적 팩토리 메서드는 많은 장점을 가지고 있지만 상속에 필요한 생성자가 없기 때문에 정적 팩토리 메서드만 존재할 경우에는 상속을 구현할 수 없다. 그리고 생성자처럼 한눈에 알아보기는 쉽지 않기 때문에 아래의 명명 규칙을 잘 지켜서 메서드를 생성하고 API 문서를 꼼꼼히 작성해야 한다.
명명 규칙
이름 | 설명 | 예제 |
from | 매개변수를 하나 받아서 해당 타입의 인스턴스를 반환하는 형변환 메서드 | Date d = Date.from(instant); |
of | 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환하는 집계 메서드 | Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING); |
valueOf | from과 of의 더 자세한 버전 | BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE); |
instance 혹은 getInstance | (매개변수를 받는다면) 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지 않는다. | StackWalker luke = StackWalker.getInstance(options); |
create 혹은 newInstance | instance 혹은 getInstance와 같지만, 매번 새로운 인스턴스를 생성해 반환함을 보장한다. | Object newArray = Array.newInstance(classObject, arrayLen); |
getType | getInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩토리 메서드를 정의할 때 쓴다. "Type"은 팩토리 메서드가 반환할 객체의 타입이다. | FileStore fs = Files.getFileStore(path); |
newType | newInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩토리 메서드를 정의할 때 쓴다. "Type"은 팩토리 메서드가 반환할 객체의 타입이다. | BufferedReader br = Files.newBufferedReader(path); |
type | getType과 newType의 간결한 버전 | List<Complaint> litany = Collections.list(legacyLitany); |
출처 : 이펙티브 자바
생성자와의 차이점
- 생성자는 표준 명명 규칙을 따라 클래스로 이름이 정해져 있지만, 정적 팩토리 메서드는 기능에 따라 이름을 자유롭게 설정해서 사용하는 개발자가 어떤 객체를 반환할지 알 수 있다.
- 생성자는 반환 타입을 가질 수 없지만, 정적 팩토리 메서드는 하위 타입이나 기본 타입의 박싱 클래스를 반환할 수 있다.
- 생성자 내부에서는 단일 책임 원칙을 위해 객체 초기화만 수행할 수 있지만, 정적 팩토리 메서드는 초기화 이외의 추가적인 기능을 구현할 수 있다.
- 생성자는 힙 내부에 새 객체를 생성하기 때문에 캐싱된 인스턴스를 반환할 수 없지만, 정적 팩토리 메서드는 항상 새 객체를 생성하는 대신 동일한 불변 클래스의 인스턴스를 반환할 수 있다.
- 생성자 내부의 첫 번째 줄은 super() 또는 this()여야 하지만(생략되었다면 컴파일 시점에 생성), 정적 팩토리 메서드에서는 필수 요건이 아니다.
하지만 이 두 가지에도 공통된 단점이 있는데 바로 매개변수가 많을 때 유연하게 대응하기 어렵다는 점이다. DTO 클래스 같은 경우 전달해야 하는 값이 많다 보면 생성자에 매개변수가 많이 들어가게 되는데 코드 상에서는 컴파일 오류가 발생하기 때문에 순서나 타입이 다를 때 쉽게 잡아낼 수 있지만 클라이언트 쪽에서 순서를 바꿔 값을 전달할 경우에는 엉뚱한 값이 들어갈 확률이 높다. 그리고 확실히 이렇게 매개변수가 많아질 경우에 코드의 가독성도 좋지 않다.
또한 이렇게 여러 개의 매개변수가 있을 때 어떤 변수는 필수이고 어떤 변수는 선택적으로 사용해야 할 때는 어떻게 처리해야 할지 생각해 보자. 필수로 사용할 변수가 들어갈 생성자를 만들고 setter 메서드를 만들어 선택적으로 변수 값을 지정해주면 어떨까? 이렇게 되면 변수 값을 선택적으로 초기화할 수 있을지 몰라도 불변성을 잃게 된다. 이때 불변성을 유지하면서 선택적으로 변수를 사용할 수 있는 방법이 바로 Builder이다.
Builder
Builder를 구현한 User 클래스이다. 총 다섯 개의 변수 중 2개는 필수로 전달받아야 할 값이고 나머지 3개는 선택적으로 사용할 수 있다. static 클래스로 내부에 정의된 UserBuilder에서 각각의 변수를 초기화할 수 있고 User 클래스의 생성자는 이 UserBuilder를 매개변수로 전달 받아 객체를 생성한다. setter를 생성하지 않았기 때문에 불변성을 유지할 수 있다.
// 출처 : https://howtodoinjava.com/design-patterns/creational/builder-pattern-in-java/
public class User
{
private final String firstName; // 필수
private final String lastName; // 필수
private final int age; // 선택
private final String phone; // 선택
private final String address; // 선택
private User(UserBuilder builder) {
this.firstName = builder.firstName;
this.lastName = builder.lastName;
this.age = builder.age;
this.phone = builder.phone;
this.address = builder.address;
}
// 불변성을 유지하기 위해 getter만 생성한다.
public String getFirstName() {
return firstName;
}
public String getLastName() {
return lastName;
}
public int getAge() {
return age;
}
public String getPhone() {
return phone;
}
public String getAddress() {
return address;
}
@Override
public String toString() {
return "User: "+this.firstName+", "+this.lastName+", "+this.age+", "+this.phone+", "+this.address;
}
public static class UserBuilder
{
private final String firstName;
private final String lastName;
private int age;
private String phone;
private String address;
public UserBuilder(String firstName, String lastName) {
this.firstName = firstName;
this.lastName = lastName;
}
public UserBuilder age(int age) {
this.age = age;
return this;
}
public UserBuilder phone(String phone) {
this.phone = phone;
return this;
}
public UserBuilder address(String address) {
this.address = address;
return this;
}
public User build() {
User user = new User(this);
return user;
}
}
}
첫 줄에는 필수 변수 값을 전달하지만 나머지 변수들은 선택적으로 값을 전달하는 것을 알 수 있다. 이렇게 Builder를 사용하면 코드가 생성자에 비해 길어지지만 가독성은 훨씬 좋아진다.
// 출처 : https://howtodoinjava.com/design-patterns/creational/builder-pattern-in-java/
public static void main(String[] args)
{
User user1 = new User.UserBuilder("Lokesh", "Gupta")
.age(30)
.phone("1234567")
.address("Fake address 1234")
.build();
User user2 = new User.UserBuilder("Jack", "Reacher")
.age(40)
.phone("5655")
//no address
.build();
User user3 = new User.UserBuilder("Super", "Man")
//No age
//No phone
//no address
.build();
}
마지막으로 Lombok의 @Builder 어노테이션을 사용하면 Builder 구현 코드를 직접 작성하지 않아도 편하게 기능을 사용할 수 있다. 위에서 필수적으로 들어가야 할 필드들과 선택할 수 있는 필드를 구분한 것처럼 구현할 수는 없지만 똑같이 선택적으로 필드에 값을 넣어서 객체를 생성하는 것은 같다.
Before:
@Builder
class Example<T> {
private T foo;
private final String bar;
}
After:
class Example<T> {
private T foo;
private final String bar;
private Example(T foo, String bar) {
this.foo = foo;
this.bar = bar;
}
public static <T> ExampleBuilder<T> builder() {
return new ExampleBuilder<T>();
}
public static class ExampleBuilder<T> {
private T foo;
private String bar;
private ExampleBuilder() {}
public ExampleBuilder foo(T foo) {
this.foo = foo;
return this;
}
public ExampleBuilder bar(String bar) {
this.bar = bar;
return this;
}
@java.lang.Override public String toString() {
return "ExampleBuilder(foo = " + foo + ", bar = " + bar + ")";
}
public Example build() {
return new Example(foo, bar);
}
}
}
정리
- 생성자는 매개변수 개수의 제한 없이 무한으로 생성할 수 있다.
- 무분별하게 생성된 생성자로 인해 만들어진 의도를 파악하기 어려울 때 정적 팩토리 메서드를 사용해 보완할 수 있다.
- 정적 팩토리 메서드는 메서드 이름을 통해 명시적으로 반환 객체를 드러낼 수 있는 것 외에도 유연한 구현이 가능해 자바 내에도 구현되어 있는 메서드들이 많다.
- 매개변수 개수가 많고 선택적으로 다뤄야 할 때는 빌더를 사용한다.
면접 예상 질문
- 생성자를 제한 없이 생성할 수 있을 때의 단점은 무엇인가요?
- 생성자와 정적 팩토리 메서드의 차이점은 무엇인가요?
- 생성자와 빌더의 차이점은 무엇인가요?
참고
- <자바의 신>
- <이펙티브 자바>
- https://www.baeldung.com/java-constructors-vs-static-factory-methods
- https://velog.io/@cjh8746/%EC%A0%95%EC%A0%81-%ED%8C%A9%ED%86%A0%EB%A6%AC-%EB%A9%94%EC%84%9C%EB%93%9CStatic-Factory-Method
- https://howtodoinjava.com/design-patterns/creational/builder-pattern-in-java/
- https://jaehoney.tistory.com/99
'Java' 카테고리의 다른 글
[Java] 다중 상속이 가지는 문제 (0) | 2023.02.03 |
---|---|
[Java] 컴포지션을 선택하는 이유 (0) | 2023.01.31 |
[Java] String의 불변성은 SCP로부터 (0) | 2023.01.27 |
[Java] Method Area, PermGen 그리고 Metaspace (0) | 2022.10.06 |
JUnit5 (0) | 2022.09.11 |