2025. 5. 30. 17:25ㆍspring
객체를 생성하는 방법에는 두가지 주요 방법으로 정적 팩토리 메소드와 빌더 패턴이 있다.
두 가지 사용법에 대해 '왜' 사용하면 좋은지에 대해 생각을 해본적이 없었던것 같아 내용을 정리해보려고 한다.
이 두 가지 방법은 객체를 생성하고 초기화하는 과정에서 각자 고유의 장점을 제공한다.
정적 팩토리 메소드 (=인스턴스를 반환)
정적 팩토리 메소드는 클래스의 인스턴스를 반환하는 정적 메소드이다.
이 메소드는 생성자와 비슷한 역할을 하지만,
직접적으로 생성자를 호출하는 대신
객체 생성의 세부사항을 캡슐화한다.
객체를 만들때 new키워드를 사용하여 생성자를 호출하는 방법 대신,
클래스 내부에 정의된 static메소드를 통해 객체를 생성하고 반환한다.
이를 통해 객체 생성 로직을 메소드 내에 숨겨 사용자에게 보다 명확하고 관리가 쉬운 인터페이스를 제공할 수 있다.
정적 팩토리 메소드 장점
1. 이름을 통해 메소드의 기능을 명확하게 전달할 수 있다.
public class Person {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
// 'of' 패턴 사용
public static Person nameAndAgeOf(String name, int age) {
return new Person(name, age);
}
}
2. 메소드를 호출하는 시점에 객체 생성 방식을 조절할 수 있다.
정적 팩토리 메소드는 객체 생성과정을 캡슐화하여,
호출 시 내부적으로 객체를 캐싱하거나
특정 조건에 따라 다른 유형의 객체를 생성하는 등의 유연한 로직을 구현할 수 있다.
예를 들어, 생성되는 객체를 캐싱하여 필요할때마다 같은 인스턴스를 재사용하거나,
입력된 매개변수에 따라 다른 유형의 객체를 반환할 수 있다.
이러한 접근은 메모리 사용을 최적화하고, 로직을 변경할 때 기존코드를 수정하지 않아도 된다.
public class Person {
private String name;
private int age;
// 캐시를 위한 HashMap
private static final Map<String, Person> cache = new HashMap<>();
// 생성자는 private로 선언하여 외부에서 직접 호출할 수 없게 함
private Person(String name, int age) {
this.name = name;
this.age = age;
}
// 정적 팩토리 메서드
public static Person getInstance(String name, int age) {
String key = name + age; // 캐시 키 생성
if (!cache.containsKey(key)) {
// 캐시에 없는 경우 새 객체 생성 및 캐시에 추가
cache.put(key, new Person(name, age));
}
// 캐시된 객체 반환
return cache.get(key);
}
}
3. 반환 타입의 하위 타입 객체를 반환할 수 있어 유연성이 높다.
정적 팩토리 메소드는 구현하는 인터페이스 타입을 반환함으로써,
메소드를 호출하는 사용자에게 구체적인 클래스 타입을 숨기고 유연성을 제공할 수 있다.
이는 다형성을 활용하는 방식으로, 실제 반환되는 객체의 클래스 타입을 변경해도 사용자 코드를 변경할 필요가 없다.
// Human 인터페이스 정의
public interface Human {
String getName();
int getAge();
}
// Person 클래스, Human 인터페이스 구현
public class Person implements Human {
private String name;
private int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
@Override
public String getName() {
return name;
}
@Override
public int getAge() {
return age;
}
}
// SuperHero 클래스, Human 인터페이스 구현
public class SuperHero implements Human {
private String name;
private int age;
private String superPower;
public SuperHero(String name, int age, String superPower) {
this.name = name;
this.age = age;
this.superPower = superPower;
}
@Override
public String getName() {
return name;
}
@Override
public int getAge() {
return age;
}
public String getSuperPower() {
return superPower;
}
}
// 팩토리 클래스
public class HumanFactory {
public static Human createHuman(String type, String name, int age) {
if (type.equals("SuperHero")) {
return new SuperHero(name, age, "Invisibility");
} else {
return new Person(name, age);
}
}
}
정적 팩토리 메소드 단점
1. 상속을 사용할 때 제한이 있을 수 있다.
정적 팩토리 메소드는 주로 private 또는 final 생성자를 사용하기 때문에 정적 팩토리 메소드를 사용하는 클래스는 상속을 받기 어렵거나 확장하기 어려울 수 있다.
이러한 접근 방식은 클래스의 인스턴스화를 정적 메소드로 제한하며
이 클래스를 확장하는 자식클래스에서 부모클래스의 생성자에 접근할 수 없게 만든다.
public class Person {
private String name;
private int age;
// private 생성자로 인해 외부에서 직접 인스턴스화할 수 없음
private Person(String name, int age) {
this.name = name;
this.age = age;
}
public static Person create(String name, int age) {
return new Person(name, age);
}
}
// 이하의 코드는 Person 클래스를 상속받으려 할 때 문제가 발생함
public class Employee extends Person {
private String department;
public Employee(String name, int age, String department) {
super(name, age); // 오류! Person 클래스의 생성자가 private이므로 접근할 수 없음
this.department = department;
}
}
2. 다른 정적 메소드와 구분이 어렵다
클래스에 여러 정적 메서드가 있을 때, 정적 팩토리 메서드와 다른 유틸리티 메서드들을 구분하기 어려울 수 있다.
이는 특히 클래스의 메서드가 많거나, 메서드 이름이 명확하지 않은 경우 코드를 이해하고 유지보수 하는데 어려움을 줄 수 있다.
따라서 정적 팩토리 메서드에는 일반적인 네이밍 규칙이 존재한다.
from 매개변수 하나를 받아 해당 타입의 인스턴스를 반환할 때 사용
of | 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환할 때 사용 |
valueOf | from과 of와 비슷하지만, 좀 더 구체적인 변환을 수행하거나 값의 타입을 바꾸는 데 사용 |
instance 또는 getInstance | 인스턴스를 반환하지만, 항상 같은 인스턴스임을 보장하지 않을 때 사용/ 매개변수에 따라 다른 인스턴스를 반환할 수 있음 |
create 또는 newInstance | 매번 호출할 때마다 새로운 인스턴스를 생성하여 반환 |
getType | getInstance와 유사하지만, 다른 클래스에 팩토리 메서드가 정의되어 있을 때 사용/ 반환할 객체의 타입을 명시할 수 있음 |
newType | newInstance와 유사하지만, 다른 클래스에 팩토리 메서드가 정의되어 있을 때 사용 |
type | getType 또는 newType의 간결한 버전으로 사용될 수 있음/ 종종 반환할 객체의 타입을 간결하게 표현할 때 사용 |
빌더 패턴 등장 배경
1) 매개변수가 많은 생성자 문제
2) 자바 빈즈 패턴의 일관성 문제
자바빈즈 패턴은 객체를 생성한 후, setter 메서드를 통해 객체의 상태를 설정한다. 이 패턴은 매개변수가 많은 객체를 다룰 때 유연하지만, 객체가 일관성 없는 상태에 놓일 위험이 있다. 객체 생성 후 모든 필수 필드가 적절히 설정되었는지 개발자가 직접 관리해야 하며, 멀티스레드 환경에서는 불변성을 보장하기가 어렵다.
Person person = new Person();
person.setName("김땡땡");
person.setAge(23);
person.setEmail("kimkim@naver.com");
person.setAddress("어딘가에 살고있음");
빌더 패턴
이러한 문제를 해결해줄 수 있는 방법이 바로 빌더 패턴을 사용하는 것이다.
빌더 패턴은 복잡한 객체의 생성과정을 단순화하기 위해 사용되는데,
필수 매개변수만으로 객체를 생성하고,
선택적 매개변수는 빌더 클래스의 메서드를 통해 설정할 수 있게 한다.
빌더는 최종적으로 완성된 객체를 반환하는 'build' 메서드를 포함한다.
이러한 빌더는 보통 클래스 내부에 정적 멤버 클래스로 정의하는 게 일반적이라고 한다.
public class Person {
// 필수 매개변수
private final String name;
private final int age;
// 선택적 매개변수
private final String email;
private final String address;
private Person(Builder builder) {
this.name = builder.name;
this.age = builder.age;
this.email = builder.email;
this.address = builder.address;
}
public static class Builder {
// 필수 매개변수
private final String name;
private final int age;
// 선택적 매개변수 - 초기 값은 null
private String email = null;
private String address = null;
// 생성자에서 필수 매개변수를 설정
public Builder(String name, int age) {
this.name = name;
this.age = age;
}
// 선택적 매개변수를 위한 메서드들
public Builder email(String email) {
this.email = email;
return this;
}
public Builder address(String address) {
this.address = address;
return this;
}
public Person build() {
return new Person(this);
}
}
}
빌더 패턴을 사용하여 필수 매개변수(name, age)만으로 'Person' 객체를 생성하고, 선택적 매개변수(email, address)는 나중에 설정할 수 있도록 설계할 수 있다.
Person person = new Person.Builder("김땡땡", 23)
.email("kimkim@naver.com")
.address("어딘가에 살고있음")
.build();
이 예시에서 Builder의 생성자는 필수 매개변수 name과 age를 받으며, 이후 email과 address는 선택적으로 추가할 수 있다. 이 방식은 객체를 생성하는 과정에서 필수적인 정보만 초기 단계에서 요구하고, 추가 정보는 필요에 따라 설정할 수 있게 함으로써 유연성을 제공한다. 또한, build() 메서드를 호출하는 순간 완전하게 초기화된 Person 객체가 반환되며, 이 객체는 불변의 성격을 가진다. 이는 객체의 일관성을 보장하고 멀티스레드 환경에서도 안전하게 사용할 수 있게 한다.
Lombok을 사용한 빌더 패턴
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class Person {
private final String name;
private final int age;
private final String email;
private final String address;
}
//위의 Person 클래스에 @Builder를 적용하면 Lombok은 다음과 같은 Builder 클래스를 생성
public class Person {
private final String name;
private final int age;
private final String email;
private final String address;
private Person(String name, int age, String email, String address) {
this.name = name;
this.age = age;
this.email = email;
this.address = address;
}
public static class Builder {
private String name;
private int age;
private String email;
private String address;
public Builder name(String name) {
this.name = name;
return this;
}
public Builder age(int age) {
this.age = age;
return this;
}
public Builder email(String email) {
this.email = email;
return this;
}
public Builder address(String address) {
this.address = address;
return this;
}
public Person build() {
return new Person(name, age, email, address);
}
}
}
위 코드에서 '@Builder' 애너테이션은
- 각 필드에 대한 설정 메서드(setter와 유사)와 함께 최종적으로 객체를 생성하는 'build()' 메서드를 포함하는 정적 내부 클래스 'Builder'를 생성한다.
2. '@AllArgsConstructor'의 기능을 내부적으로 활용하여 모든 매개변수를 받는 생성자를 만든다. 이 생성자는 보통 비공개(private 또는 protected)이다. 이 생성자는 Builder 클래스에서만 사용되어 객체의 인스턴스를 생성한다.
++) @Builder 어노테이션에 포함된 @AllArgsConstructor는 다른 명시적인 생성자 (기본 생성자 - @NoArgsConstructor 혹은 일부 매개변수만을 포함한 생성자 - @RequiredArgsConstructor) 가 선언되어 있다면 적용되지 않기 때문에 모든 매개변수가 포함된 생성자가 명시적으로 선언되지 않은 상태에서 @Builder 어노테이션을 사용할 경우 컴파일 에러가 나게 된다.
3. 각 필드에 대해 Builder 클래스 내에서 사용할 설정 메서드를 생성한다. 이 메서드들은 각각의 필드에 값을 설정하고, Builder 객체 자체를 반환하여 연속적인 호출이 가능하게 한다.
정리 - 언제 무엇을 사용하는 것이 좋을까?
정적 팩토리 메서드 사용이 적합한 경우
- 메서드 이름을 통한 의미를 명확하게 전달하고 싶은 경우
- 호출될 때마다 새 객체를 생성하지 않아도 되는 경우
- 반환 타입의 하위 타입 객체를 반환하고자 하는 경우
- 입력 매개변수에 따라 다른 클래스의 객체를 반환하고자 하는 경우
빌더 패턴 사용이 적합한 경우
- 매개변수가 많은 객체를 생성해야 할 경우
- 객체의 생성 과정이 복잡하거나 여러 단계를 필요로 할 경우
- 불변 객체를 만들어야 할 경우
- 조건부 필드를 유연하게 조합해야 할 경우
(선택적 매개변수가 많은 경우, 사용자는 필요한 필드만 선택하여 설정할 수 있으며, 각 설정은 명확하고 가독성이 높은 코드로 표현됨/
정적 팩토리 메서드나 생성자를 사용할 경우, 필수적으로 모든 필드에 대해 매개변수를 제공해야 하므로, 필요하지 않은 필드에도 인자를 전달해야 함)
위 글을 정리해본 후,
정적 팩토리 메소드와 빌더패턴을 각각 언제 사용하면 좋은지 이론적으로는 이해가 되나,
막상 프로젝트에 적용해보려고하니 어떤 것을 사용해야할지 좀 고민이 들었다. 무조건인것은 없으니까.
일단, dto생성에는 builder패턴을, 도메인과 관련된 부분은 정적 팩토리 메소드를 사용하여 나만의 기준점을 가져보자,