객체생성 패턴 정리 - 빌더 패턴,정적 팩토리 매서드
- -
객체를 생성하는 방법에는 여러 가지가 있지만, 그 중에서도 정적 팩토리 매서드와 빌더 패턴이 자주 사용된다.
이 두 가지 방법은 각각 고유한 장점과 사용 목적이 있기 때문에, 상황에 따라 적절히 선택해야한다!
📌객체생성 패턴 정리
1. new 키워드를 사용한 기본 객체 생성
가장 기본적인 객체 생성 방법은 new 키워드를 사용하는 것이다.
User user = new User();
장점
- 직관적이고 쉽게 사용할 수 있다
- 단순 객체 생성 시 성능 면에서 유리하다
단점
- 가독성 부족: 생성자의 매개변수가 많아지면 각 값이 어떤 의미를 가지는지 파악하기 어렵다.
- 유연성 부족: 생성자 이름은 항상 클래스 이름과 같아야 하므로, 다양한 객체를 생성하기 위한 이름을 제공하지 못한다.
2. 정적 팩토리 매서드
정적 팩토리 매서드는 객체를 만들어 반환하는 클래스의 정적(static) 매서드이다.
생성자와 마찬가지로 객체를 생성하지만, 달리 직접 new 키워드를 사용하여 생성자를 호출하는 방법 대신, 클래스 내부에 정의된 static 매서드를 통해 객체를 생성하고 반환환한다.
이를 통해, 객체 생성 로직을 매서드 내에 숨겨 사용자가 더 직관적으로 이해하고 쉽게 사용할 수 있는 방법을 제공한다.
정적 팩토리 매서드 장점
- 메서드 이름으로 객체 생성의 의미를 명확히 전달할 수 있다.정적 팩토리 매서드는 이름을 자유롭게 지을 수 있어, 생성자의 역할과 목적을 명확히 전달할 수 있다. 이를 통해 코드의 가독성과 유지보수성이 향상된다
public class User {
private String name;
private int age;
public User(String name, int age) {
this.name = name;
this.age = age;
}
// 정적 팩토리 메서드 사용
public static User nameAndAgeOf(String name, int age) {
return new User(name, age);
}
}
예를 들어, 생성자로 객체를 생성할 때는 아래와 같이 작성해야 한다.
User user = new User("홍길동", 25);
여기서는 "홍길동"과 25가 각각 어떤 값을 의미하는지 직관적으로 알기 어렵다.
하지만, 정적 팩토리 매서드를 사용하면
User user = User.nameAndAgeOf("홍길동", 25);
메서드 이름을 통해 "홍길동"은 이름이고, 25는 나이라는 것을 바로 알 수 있다. 이렇게 작성하면 코드를 처음 보는 사람도 각 값의 의미를 쉽게 이해할 수 있게 된다!
- 'of'는 일반적으로 매개변수를 여러 개 받아 적합한 타입의 인스턴스를 반환할 때 사용된다.
2. 메서드를 호출하는 시점에 객체 생성 방식을 조절할 수 있다.
정적 팩토리 매서드는 객체 생성 과정을 매서드 내부에 감춰 두기 때문에, 내부적으로 객체를 캐싱하거나 조건에 따라 다른 객체를 반환하는 등의 유연한 로직을 구현할 수 있다.
public class User {
private String name;
private int age;
private static final Map<String, User> cache = new HashMap<>();
private User(String name, int age) {
this.name = name;
this.age = age;
}
// 정적 팩토리 메서드: 캐싱 로직 추가
public static User getInstance(String name, int age) {
String key = name + age; // 캐싱 키 생성
if (!cache.containsKey(key)) {
cache.put(key, new User(name, age)); // 새로운 객체 생성 후 캐시에 추가
}
return cache.get(key); // 캐시된 객체 반환
}
}
위 코드에서 getInstance는 항상 새로운 객체를 생성하는 대신, 이미 생성된 객체를 캐싱해둔다. 동일한 이름과 나이로 다시 호출하면 캐싱된 객체를 반환하므로, 메모리 사용을 최적화할 수 있다!
User user1 = User.getInstance("홍길동", 25);
User user2 = User.getInstance("홍길동", 25);
System.out.println(user1 == user2); // true (같은 객체)
3.반환 타입의 하위 타입 객체를 반환할 수 있어 유연성이 높다.
정적 팩토리 매서드는 반환 타입으로 인터페이스나 상위 클래스를 지정할 수 있다.
이를 통해, 구체적인 구현 클래스를 숨기고, 상황에 따라 다른 하위 타입의 객체를 반환할 수 있다.
// 인터페이스 정의
public interface Member {
String getInfo();
}
// 일반 사용자 클래스
public class RegularUser implements Member {
private String name;
public RegularUser(String name) {
this.name = name;
}
@Override
public String getInfo() {
return "일반 사용자: " + name;
}
}
// 관리자 클래스
public class AdminUser implements Member {
private String name;
public AdminUser(String name) {
this.name = name;
}
@Override
public String getInfo() {
return "관리자: " + name;
}
}
// 정적 팩토리 메서드 활용
public class MemberFactory {
public static Member createMember(String type, String name) {
if ("admin".equals(type)) {
return new AdminUser(name);
} else {
return new RegularUser(name);
}
}
}
Member regular = MemberFactory.createMember("regular", "홍길동");
Member admin = MemberFactory.createMember("admin", "김관리자");
System.out.println(regular.getInfo()); // 출력: 일반 사용자: 홍길동
System.out.println(admin.getInfo()); // 출력: 관리자: 김관리자
이렇게 하면 호출하는 쪽에서는 반환된 객체가 어떤 구체 클래스인지 몰라도 된다.
인터페이스만 알면 되기 때문에 유연한 설계가 가능해진다.
정적 팩토리 매서드 단점
1. 상속의 제약
정적 팩토리 매서드를 사용하는 클래스는 보통 생성자를 private로 선언한다.
이렇게 하면 외부에서 생성자를 호출할 수 없으므로, 상속이 어렵거나 불가능하다.
public class User {
private String name;
private User(String name) {
this.name = name;
}
public static User create(String name) {
return new User(name);
}
}
// 상속 시도
public class Employee extends User {
private String department;
public Employee(String name, String department) {
super(name); // 오류! User의 생성자가 private이므로 접근 불가
this.department = department;
}
}
2. 다른 정적 메서드와 혼동 가능
정적 메서드가 많은 클래스에서는 정적 팩토리 메서드와 유틸리티 메서드의 역할을 구분하기 어려울 수 있다.
이런 경우, 네이밍 규칙을 사용해 가독성을 높이는 것이 좋다
예시: 네이밍 규칙
- from: 하나의 매개변수로 객체를 생성할 때 사용
- of: 여러 매개변수로 객체를 생성할 때 사용
- getInstance: 기존 객체를 반환하거나 새 객체를 생성할 때 사용
- create: 매번 새로운 객체를 생성할 때 사용
User user1 = User.from("홍길동"); // 하나의 값으로 객체 생성
User user2 = User.of("홍길동", 25); // 여러 값으로 객체 생성
3. 빌더 패턴
빌더 패턴의 등장 배경
(1) 점층적 생성자 패턴의 문제
점층적 생성자 패턴은 필수 매개변수와 선택 매개변수를 다양한 조합으로 처리하기 위해 생성자를 오버로딩한다.
public class User {
private String name; // 필수 매개변수
private int age; // 필수 매개변수
private String email; // 선택 매개변수
private String address; // 선택 매개변수
public User(String name, int age) {
this.name = name;
this.age = age;
}
public User(String name, int age, String email) {
this(name, age);
this.email = email;
}
public User(String name, int age, String email, String address) {
this(name, age, email);
this.address = address;
}
}
문제점
1. 매개변수 순서 문제: 매개변수 순서를 외우기 어렵고, 잘못된 순서로 값을 전달할 위험이 있다.
User user = new User("홍길동", 25, "서울", "hong@example.com"); // 주소와 이메일 순서가 혼동될 수 있음
2. 선택적 매개변수 처리의 어려움: 특정 값을 생략하려면, 0이나 기본값을 억지로 넣어야 한다.
User user = new User("홍길동", 25, null); // 이메일을 설정하지 않을 경우 null 강제 입력
3. 생성자 증가: 선택적 매개변수가 많아질수록 생성자 수가 기하급수적으로 늘어나 코드가 복잡해진다
(2) 자바빈즈 패턴의 문제
자바빈즈 패턴은 기본 생성자를 사용한 후, 값을 setter로 설정하는 방식이다.
public class User {
private String name;
private int age;
private String email;
private String address;
public User() {}
public void setName(String name) {
this.name = name;
}
public void setAge(int age) {
this.age = age;
}
public void setEmail(String email) {
this.email = email;
}
public void setAddress(String address) {
this.address = address;
}
}
이 패턴은 매개변수가 많은 객체를 만들 때는 유용하지만, 필수 값이 빠지거나 잘못 설정된 상태로 객체가 만들어질 위험이 있다. (일관성 유지 어려움)
또한, 객체가 생성된 후에도 setter 매서드를 통해 값이 변경될 수 있기 때문에, 완전한 초기화가 보장되지 않거나 멀티스레드 환경에서 안정적으로 동작하지 않을 수 있다.
이러한 문제를 해결하기 위해 빌더 패턴을 통한 초기화 방법이 권장된다.
빌더 패턴
빌더 패턴은 별도의 Builder 클래스를 만들어 매소드를 통해 단계별로 값을 입력받은 뒤, 최종적으로 build() 매소드로 하나의 인스턴스를 생성하여 리턴하는 패턴이다.
복잡한 객체를 생성할 때, 객체의 필수 속성과 선택 속성을 분리하여 직관적으로 작성할 수 있다.
빌더 패턴을 사용하면 다음과 같은 문제들을 해결할 수 있다.
- 매개변수의 순서가 헷갈리는 점층적 생성자 패턴의 문제.
- 객체 생성 후 일관성을 유지하기 어려운 자바빈즈(JavaBeans) 패턴의 문제.
- 생성자 오버로딩이 복잡해지는 문제.
빌더 패턴 사용법을 간단히 살펴보면, UserBuilder 클래스의 메서드를 체이닝(Chaining) 형태로 호출하여 단계별로 속성을 설정하고, 마지막에 build() 메서드를 호출해 최종적으로 User 객체를 생성하는 방식이다.
public static void main(String[] args) {
// 생성자 방식
User userWithConstructor = new User(1, "홍길동", 25, "hong@example.com");
// 빌더 방식
User userWithBuilder = new UserBuilder(1) // 필수 속성
.name("홍길동") // 선택 속성
.age(25) // 선택 속성
.email("hong@example.com") // 선택 속성
.build();
}
빌더 패턴의 구현은 어렵지 않고 빠르게 작성할 수 있다. 예를 들어, User 클래스 객체 생성을 담당하는 별도의 UserBuilder 클래스를 구현할 수 있다.
public class User {
private final String name; // 필수 속성
private final int age; // 필수 속성
private final String email; // 선택 속성
private final String address; // 선택 속성
private User(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; // 필수 속성
private String email; // 선택 속성
private String address; // 선택 속성
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 User build() { // 최종 객체 생성
return new User(this);
}
}
}
<사용 코드>
public static void main(String[] args) {
// 필수 속성만 설정
User user1 = new User.Builder("홍길동", 25)
.build();
// 필수 속성 + 선택 속성
User user2 = new User.Builder("김철수", 30)
.email("kim@example.com")
.address("서울시 강남구")
.build();
System.out.println(user1);
System.out.println(user2);
}
빌더 패턴의 주요 장점
1. 객체 생성 과정을 일관되게 표현
기존의 생성자 방식은 매개변수 순서가 헷갈리기 쉽다. 하지만 빌더 패턴은 각 매개변수의 이름이 메서드로 명시되어, 설정 과정이 훨씬 직관적이다
// 생성자 방식
User user1 = new User("홍길동", 25, "hong@example.com", "서울");
// 빌더 방식
User user2 = new User.Builder("홍길동", 25)
.email("hong@example.com")
.address("서울")
.build();
2. 디폴트 값 설정
빌더 패턴은 선택적 속성에 대해 디폴트 값을 설정할 수 있다. 설정하지 않으면 기본값을 사용하고, 필요 시 사용자 정의 값으로 덮어쓸 수 있다.
public static class Builder {
private String email = "default@example.com"; // 기본값
private String address = "default address"; // 기본값
public Builder email(String email) {
this.email = email;
return this;
}
public Builder address(String address) {
this.address = address;
return this;
}
}
<사용 예시>
User user = new User.Builder("홍길동", 25)
.build(); // 기본값 사용
System.out.println(user); // email=default@example.com, address=default address
3. 필수 속성과 선택 속성의 분리
필수 속성은 Builder의 생성자로 강제 설정하고, 선택 속성은 메서드로 추가 설정한다. 이를 통해 객체 생성의 일관성과 안전성을 보장한다.
User user = new User.Builder("홍길동", 25) // 필수 속성
.email("hong@example.com") // 선택 속성
.build();
Lombok을 사용한 빌더 패턴
빌더 패턴은 Lombok의 @Builder 애너테이션을 활용하면 더욱 간단하게 구현할 수 있다.
Lombok의 @Builder 애너테이션을 사용하면, 빌더 클래스를 직접 작성하지 않아도 된다!
import lombok.Builder;
import lombok.Getter;
@Getter
@Builder
public class User {
private final int id; // 필수 속성
private final String name; // 선택 속성
private final int age; // 선택 속성
private final String email; // 선택 속성
}
위의 코드에서 Lombok은 자동으로 다음과 같은 UserBuilder 클래스를 생성한다.
public class User {
private final int id;
private final String name;
private final int age;
private final String email;
private User(int id, String name, int age, String email) {
this.id = id;
this.name = name;
this.age = age;
this.email = email;
}
public static class UserBuilder {
private int id;
private String name;
private int age;
private String email;
public UserBuilder id(int id) {
this.id = id;
return this;
}
public UserBuilder name(String name) {
this.name = name;
return this;
}
public UserBuilder age(int age) {
this.age = age;
return this;
}
public UserBuilder email(String email) {
this.email = email;
return this;
}
public User build() {
return new User(id, name, age, email);
}
}
}
Lombok 빌더 사용 예시
Lombok의 빌더를 사용하는 방식은 다음과 같다.
public static void main(String[] args) {
User user = User.builder() // 빌더 시작
.id(1) // 필수 속성
.name("홍길동") // 선택 속성
.age(25) // 선택 속성
.email("hong@example.com") // 선택 속성
.build(); // 객체 생성
System.out.println(user);
}
<출력 결과>
User{id=1, name='홍길동', age=25, email='hong@example.com'}
Lombok @Builder 어노테이션의 주요 특징
- 자동 생성된 빌더 클래스
- 각 필드에 대한 설정 메서드(예: id(int id) 등)와 최종적으로 객체를 생성하는 build() 메서드를 포함한 정적 내부 클래스 UserBuilder를 생성한다..
- 생성자(@AllArgsConstructor)
- Lombok은 내부적으로 @AllArgsConstructor를 사용해 모든 필드를 초기화하는 생성자를 생성한다. 이 생성자는 외부에서 호출할 수 없으며, 빌더 클래스에서만 사용된다.
- 체이닝 메서드
- 빌더 메서드는 반환값으로 this를 제공하여 메서드를 체이닝 형태로 호출할 수 있게 한다.
⇒ Lombok의 @Builder를 사용하면 UserBuilder 클래스를 직접 작성할 필요가 없으며, 가독성이 높고 유지보수가 쉬운 코드를 작성할 수 있다!
📌정리 - 정적 팩토리 메서드와 빌더 패턴, 언제 사용하면 좋을까?
정적 팩토리 메서드를 사용하는 것이 좋은 경우
- 메서드 이름으로 의미를 명확히 전달하고 싶을 때
- 객체를 생성하는 의도를 메서드 이름으로 쉽게 알릴 수 있다.
- ex) User.createAdmin() (관리자 생성)
- 항상 새로운 객체를 만들 필요가 없을 때
- 같은 조건에서 동일한 객체를 재사용하도록 설계할 수 있다.
- ex) 이미 생성된 객체를 캐싱해서 반환.
- 반환 타입이 여러 종류일 때
- 같은 메서드로 다양한 하위 타입 객체를 반환할 수 있다.
- ex) Animal.create("Dog") → Dog 객체, Animal.create("Cat") → Cat 객체.
- 입력 값에 따라 다른 객체를 만들어야 할 때
- 매개변수에 따라 특정 로직을 처리하고 다른 객체를 생성한다.
- ex) Shape.of("Circle") → Circle 객체, Shape.of("Rectangle") → Rectangle 객체.
빌더 패턴을 사용하는 것이 좋은 경우
- 매개변수가 많은 객체를 만들어야 할 때
- 설정해야 할 속성이 많아 생성자가 너무 복잡해질 때 사용하면 유리하다.예: 이름, 나이, 이메일, 주소 등 여러 속성을 가진 객체.
- 객체를 단계별로 구성해야 할 때
- 객체를 만들기 위해 여러 단계의 설정이 필요한 경우 적합하다.예: 필수 필드를 먼저 설정한 뒤, 선택 필드를 추가하는 방식.
- 불변 객체를 만들어야 할 때
- 객체 생성 후 값이 바뀌지 않도록 하고 싶다면 빌더 패턴이 유용하다.예: 데이터베이스 연결 설정, API 요청 객체 등.
- 선택적 필드 조합이 유연해야 할 때
- 필요한 필드만 설정하도록 하고, 필요 없는 필드는 기본값으로 처리한다.예: User.builder().id(1).name("홍길동").build() (나이, 이메일은 설정하지 않음).
소중한 공감 감사합니다