티스토리 뷰

sopt 세미나를 마치고, 객체를 생성하는 방법 중 정적 팩토리 메서드 패턴과 필더 패턴을 배웠는데, 이 둘의 차이점이 무엇인지 자세하게 알기 위해 글을 작성하게 되었다. 사실 이 글을 작성하게 된 가장 큰 이유는.. 지금까지는 정적 팩토리 메서드 패턴만을 사용했는데, 빌더 패턴은 사용해본적이 없어서 이 둘의 차이점을 확실히 알고 두 가지 방법을 적절한 상황에 효과적으로 사용해보고 싶어서이다.ㅎ

 

객체를 생성하는 방법에는 여러가지가 있지만, 그 중 두가지 주요 방법으로 정적 팩토리 메서드빌더 패턴이 있다. 

이 두 방법은 객체를 생성하고 초기화하는 과정에서 각각 고유의 장점을 제공한다.

 

0. new 키워드로 객체 생성

MyClass obj = new MyClass();

 

new 키워드의 장점

1) 간단하다.

new 키워드를 사용한 객체 생성은 간단하고 직관적이다.

2) 성능

매우 단순한 객체 생성 작업에 있어서는 성능적으로 효율적이다.

 

new 키워드의 단점

1) 읽기 어려운 생성자

생성자의 매개변수가 많아지면 어떤 매개변수가 어떤 의미를 가지는지 코드만 봐서 알기 어렵다. 특히, 다수의 매개변수를 가진 생성자는 사용하기 어렵고 유지보수도 힘들다.

2) 객체 생성의 유연성 부족

생성자 이름은 항상 클래스 이름과 같아야 하기 때문에, 다양한 객체 생성을 위한 이름을 제공하지 못한다. 이를 통해 객체 생성의 의미를 구분할 수 없다

3) 객체 생성 제어가 어렵다.

생성자는 객체를 생성할 때마다 항상 새로운 객체를 반환하므로, 객체 재사용이나 캐싱 등의 전략을 구현하기 어렵다.

 

1. 정적 팩토리 메서드

정적 팩토리 메서드는 클래스의 인스턴스를 반환하는 정적 메서드이다. 이 메서드는 생성자와 비슷한 역할을 하지만, 직접적으로 생성자를 호출하는 대신 객체 생성의 세부 사항을 캡슐화 한다. 

더 자세히 설명해 보자면, 정적 팩토리 메서드는 객체를 만들 때 직접 '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);
    }
}

 

예를 들어, public 생성자를 사용해서 Person 인스턴스를 생성하려면 Person person = new Perons("김땡땡", 23) 처럼 코드를 작성해야 하는데, 이는 각각의 매개변수가 어떤 정보를 나타내는지 바로 알아보기 어려울 수 있다.

그러나 정적 팩토리 메서드를 이용하여 인스턴스를 생성하게 된다면, Person person = Person.nameAndAgeOf ("김땡땡", 23)과 같이 이름과 나이를 매개변수로 받아 'Person' 객체를 생성하고 반환한다. 이 메서드 명은 매개변수가 각각 이름과 나이임을 명확히 하여, 코드를 처음 보는 사람도 각 값의 의미를 쉽게 파악할 수 있다.

'of'는 일반적으로 매개변수를 여러 개 받아 적합한 타입의 인스턴스를 반환할 때 사용된다. 

 

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);
    }
}

 

예를 들어, 'getInstance' 메서드는 주어진 이름과 나이로 'Person' 객체를 생성한다. 하지만 매번 새로운 객체를 생성하는 것이 아니라, 'HashMap'을 사용하여 이미 생성된 객체를 캐싱하고, 이후 같은 이름과 나이로 'getInstance'를 호출하면, 새로운 객체를 생성하는 비용 없이 캐시에서 바로 객체를 반환받을 수 있다. 

 

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);
        }
    }
}

 

Human human = HumanFactory.createHuman("Person", "김땡땡", 23);
Human superhero = HumanFactory.createHuman("SuperHero", "이땡땡", 25);

System.out.println(human.getName()); // 출력: 김땡땡
System.out.println(superhero.getName()); // 출력: 이땡땡

 

예를 들어, 위 코드에서 'HumanFactory' 클래스의 'createHuman' 메서드는 'Human' 인터페이스 타입을 반환한다. 따라서 메서드를 사용하는 측에서는 반환되는 객체가 'Person' 인지, 'SuperHero' 인지 몰라도 된다. 즉, 사용자는 객체의 구체적인 타입에 대해 신경 쓸 필요 없이, 모든 반환 객체가 'Human' 인터페이스의 메서드를 지원함을 보장받는다. 

이런 방식은 메서드의 반환 타입을 일관되게 유지하면서도, 내부적으로 다양한 유형의 객체를 생성하고 반환할 수 있는 유연성을 제공한다.

 

정적 팩토리 메서드의 단점

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;
    }
}

 

예를 들어, 'Employee' 클래스는 'Person'을 상속받으려고 시도하지만, 'Person'의 생성자가 'private'이기 때문에 'Employee'에서 접근할 수 없다.

 

2) 다른 정적 메서드와 구분이 어렵다.

클래스에 여러 정적 메서드가 있을 때, 정적 팩토리 메서드와 다른 유틸리티 메서드들을 구분하기 어려울 수 있다.

이는 특히 클래스의 메서드가 많거나, 메서드 이름이 명확하지 않은 경우 코드를 이해하고 유지보수 하는데 어려움을 줄 수 있다.

 

따라서 정적 팩토리 메서드에는 일반적인 네이밍 규칙이 존재한다.

from 매개변수 하나를 받아 해당 타입의 인스턴스를 반환할 때 사용
of 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환할 때 사용
valueOf from과 of와 비슷하지만, 좀 더 구체적인 변환을 수행하거나 값의 타입을 바꾸는 데 사용
instance 또는 getInstance 인스턴스를 반환하지만, 항상 같은 인스턴스임을 보장하지 않을 때 사용/ 매개변수에 따라 다른 인스턴스를 반환할 수 있음
create 또는 newInstance 매번 호출할 때마다 새로운 인스턴스를 생성하여 반환
getType getInstance와 유사하지만, 다른 클래스에 팩토리 메서드가 정의되어 있을 때 사용/ 반환할 객체의 타입을 명시할 수 있음
newType newInstance와 유사하지만, 다른 클래스에 팩토리 메서드가 정의되어 있을 때 사용
type getType 또는 newType의 간결한 버전으로 사용될 수 있음/ 종종 반환할 객체의 타입을 간결하게 표현할 때 사용

 

결론

정적 팩토리 메서드와 public 생성자를 사용하는 방식은 각각의 장단점이 있기 때문에 이를 잘 이해하고 적절히 활용하는 것이 중요하다. 

특히 상속이 필요하지 않은 경우에는 정적 팩토리 메서드의 장점이 많기 때문에 public 생성자를 주로 사용했다면 방식을 변경해 보는 것도 좋겠다.

 

.

.

.

2. 빌더패턴

빌더패턴의 등장 배경

1) 매개변수가 많은 생성자 문제

객체의 생성 과정에서 매개변수의 수가 많아지면, 각각 다른 매개변수 조합으로 여러 생성자를 만들어야 하는 상황이 발생한다. 예를 들어, 'Person' 객체에 이름, 나이, 이메일, 주소 등 여러 정보가 필요하다고 가정해볼 때, 이런 정보들이 모두 필수가 아니라 선택적인 경우, 각 조합에 대해 별도의 생성자를 만들어야 하고 이는 코드를 복잡하게 만들며 유지보수를 어렵게 한다. 

public class Person {
    private String name;
    private int age;
    private String email;
    private String address;

    // 기본 생성자
    public Person() {}

    // 이름만 받는 생성자
    public Person(String name) {
        this.name = name;
    }

    // 이름과 나이만 받는 생성자
    public Person(String name, int age) {
        this(name);
        this.age = age;
    }

    // 이름, 나이, 이메일만 받는 생성자
    public Person(String name, int age, String email) {
        this(name, age);
        this.email = email;
    }

    // 이름, 나이, 이메일, 주소를 모두 받는 생성자
    public Person(String name, int age, String email, String address) {
        this(name, age, email);
        this.address = address;
    }

    // 나이와 이메일만 받는 생성자
    public Person(int age, String email) {
        this.age = age;
        this.email = email;
    }

    // 이메일만 받는 생성자
    public Person(String email) {
        this.email = email;
    }

    // 기타 등등 다른 조합들...
}

 

2) 자바빈즈 패턴의 일관성 문제

자바빈즈 패턴은 객체를 생성한 후, setter 메서드를 통해 객체의 상태를 설정한다. 이 패턴은 매개변수가 많은 객체를 다룰 때 유연하지만, 객체가 일관성 없는 상태에 놓일 위험이 있다. 객체 생성 후 모든 필수 필드가 적절히 설정되었는지 개발자가 직접 관리해야 하며, 멀티스레드 환경에서는 불변성을 보장하기가 어렵다.

public class Person {
    private String name;
    private int age;
    private String email;
    private String address;

    // 기본 생성자
    public Person() {}

    // name의 setter 메서드
    public void setName(String name) {
        this.name = name;
    }

    // age의 setter 메서드
    public void setAge(int age) {
        this.age = age;
    }

    // email의 setter 메서드
    public void setEmail(String email) {
        this.email = email;
    }

    // address의 setter 메서드
    public void setAddress(String address) {
        this.address = address;
    }
}
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의 생성자는 필수 매개변수 nameage를 받으며, 이후 emailaddress는 선택적으로 추가할 수 있다. 이 방식은 객체를 생성하는 과정에서 필수적인 정보만 초기 단계에서 요구하고, 추가 정보는 필요에 따라 설정할 수 있게 함으로써 유연성을 제공한다. 또한, build() 메서드를 호출하는 순간 완전하게 초기화된 Person 객체가 반환되며, 이 객체는 불변의 성격을 가진다. 이는 객체의 일관성을 보장하고 멀티스레드 환경에서도 안전하게 사용할 수 있게 한다.

 

Lombok을 사용한 빌더 패턴

빌더패턴은 Lombok의 '@Builder' 애너테이션을 사용하여 쉽게 구현할 수 있다. 

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' 애너테이션은

1. 각 필드에 대한 설정 메서드(setter와 유사)와 함께 최종적으로 객체를 생성하는 'build()' 메서드를 포함하는 정적 내부 클래스 'Builder'를 생성한다. 

 

2. '@AllArgsConstructor'의 기능을 내부적으로 활용하여 모든 매개변수를 받는 생성자를 만든다. 이 생성자는 보통 비공개(private 또는 protected)이다. 이 생성자는 Builder 클래스에서만 사용되어 객체의 인스턴스를 생성한다.

++) @Builder 어노테이션에 포함된 @AllArgsConstructor는 다른 명시적인 생성자 (기본 생성자 - @NoArgsConstructor 혹은 일부 매개변수만을 포함한 생성자 - @RequiredArgsConstructor) 가 선언되어 있다면 적용되지 않기 때문에  모든 매개변수가 포함된 생성자가 명시적으로 선언되지 않은 상태에서 @Builder 어노테이션을 사용할 경우 컴파일 에러가 나게 된다.

 

3. 각 필드에 대해 Builder 클래스 내에서 사용할 설정 메서드를 생성한다. 이 메서드들은 각각의 필드에 값을 설정하고, Builder 객체 자체를 반환하여 연속적인 호출이 가능하게 한다.

 

 

정리 - 언제 무엇을 사용하는 것이 좋을까?

정적 팩토리 메서드 사용이 적합한 경우

1. 메서드 이름을 통한 의미를 명확하게 전달하고 싶은 경우

 

2. 호출될 때마다 새 객체를 생성하지 않아도 되는 경우

 

3. 반환 타입의 하위 타입 객체를 반환하고자 하는 경우

 

4. 입력 매개변수에 따라 다른 클래스의 객체를 반환하고자 하는 경우

빌더 패턴 사용이 적합한 경우

1. 매개변수가 많은 객체를 생성해야 할 경우

 

2. 객체의 생성 과정이 복잡하거나 여러 단계를 필요로 할 경우

 

3. 불변 객체를 만들어야 할 경우

 

4. 조건부 필드를 유연하게 조합해야 할 경우

(선택적 매개변수가 많은 경우, 사용자는 필요한 필드만 선택하여 설정할 수 있으며, 각 설정은 명확하고 가독성이 높은 코드로 표현됨/

정적 팩토리 메서드나 생성자를 사용할 경우, 필수적으로 모든 필드에 대해 매개변수를 제공해야 하므로, 필요하지 않은 필드에도 인자를 전달해야 함)

공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2025/11   »
1
2 3 4 5 6 7 8
9 10 11 12 13 14 15
16 17 18 19 20 21 22
23 24 25 26 27 28 29
30
글 보관함