티스토리 뷰

728x90
반응형

클래스에서 객체를 생성한다고 할 때, 아마 가장 먼저 떠올리는 방법이 생성자일 것이다. 생성자는 가장 안전하면서도 일반적인 방법이다. 다만, 전달해야할 파라미터가 많아지면 순서에 제약을 받기도 하고, 어떤 파라미터를 전달해야 하는지 가독성 측면에서도 좋지 않다. 이럴 때 고려해 볼 수 있는 방법 중에는 정적 팩토리 메서드나 빌더 패턴 등이 있는데, 각 방법마다 장단점이 있기 때문에 팀의 컨벤션이나 상황에 따라 유연하게 선택할 수 있어야 한다.


정적 팩토리 메서드(Static Factory Method)

정적 팩토리 메서드는 쉽게 말해 객체를 생성한 뒤 반환하는 static 메서드를 정의하는 방법이다. 해당 클래스의 객체를 생성자로 생성하는 대신, static 메서드를 호출해 간접적으로 생성된 객체를 반환받게 된다. static 메서드가 마치 객체를 생성해주는 “공장” 역할을 하기 문에 정적 팩토리 메서드 패턴이라고 한다.

예를 들어 보자.

class Water {

    private String name;

    // private 생성자 (외부에서 호출 불가능)
    private Water(String name) {
        this.name = name;
    }

    // 정적 팩토리 메서드
    public static Water nameOf(String name) {
        return new Water(name);  // private 생성자를 호출해서 객체 생성 후 반환
    }
}

위 예시처럼 private 생성자를 두고, 정적 팩토리 메서드가 이 생성자를 호출해 객체를 생성한 뒤 반환하는 방식이 가장 일반적이다. 이렇게 하면 클라이언트는 생성자를 호출할 수 없기 때문에 정적 팩토리 메서드를 통해 생성된 객체를 반환받아야 한다.

public static void main(String[] args) {
    Water samdasu = Water.nameOf("삼다수");
}

또한 우리가 자주 사용하는 Integer.valueOf() 도 대표적인 정적 팩토리 메서드다.

정적 팩토리 메서드의 장점

이펙티브 자바(저자 - 조슈아 블로크)에서 소개하는 “생성자보다 정적 팩토리 메서드가 나은 점 5가지"를 소개해 보겠다.

1. 이름을 가질 수 있다

생성자는 클래스 이름과 똑같아야 한다는 제약이 있다. 반면, 정적 팩토리 메서드는 생성하는 객체의 특성에 맞게 이름을 지어줄 수 있다. 위 예로 들었던 Water.nameOf() 메서드를 보면, 파라미터로 넘기는 이름을 가진 물 객체를 생성하겠다고 쉽게 읽힌다.

만약 물의 용량을 기준으로 객체를 생성하고 싶다면, Water.capacityOf() 라는 이름의 정적 팩토리 메서드를 의도에 맞게 새로 정의하면 된다.

class Water {

    private String name;
    private int capacity;

    private Water(String name) {
        this.name = name;
    }

    private Water(int capacity) {
        this.capacity = capacity;
    }

    // 정적 팩토리 메서드
    public static Water nameOf(String name) {
        return new Water(name);
    }

    // 정적 팩토리 메서드
    public static Water capacityOf(int capacity) {
        return new Water(capacity);
    }
}

두 방식을 생성자로 정의하기 위해서는 오버로딩(Overloading)를 해야하는데, 클라이언트 입장에서 각 생성자가 어떤 역할을 하는지 정확히 알 수 없기 때문에 실수를 유발하기 쉽다.

public static void main(String[] args) {
        // 정적 팩토리 메서드 사용
    Water samdasu = Water.nameOf("삼다수");
    Water water500ml = Water.capacityOf(500);

        // 생성자 사용 (이름은 알아볼 만한데, 500은 뭘 의미하는 건지 알기 쉽지 않고, 코드 작성도 헷갈림)
    Water 삼다수 = new Water("삼다수");
    Water 물500ml = new Water(500);
}

2. 호출될 때마다 인스턴스를 항상 새로 생성하지 않아도 된다.

인스턴스를 미리 만들어 놓거나 새로 생성한 인스턴스를 캐싱하여 재활용하는 식으로 불필요한 객체 생성을 피할 수 있다. 대표적인 불변 객체(Immutable class)인 래퍼 클래스(Wrapper Class)들은 객체를 항상 새로 생성하지 않고 가능하다면 내부적으로 캐싱을 사용한다.

예를 들어 Integer 클래스는 기본적으로 -128 ~ 127 범위에 있는 값들을 미리 메모리에 올려 두고 캐시된 객체를 반환한다. 이는 자바의 기본 타입인 byte 범위와 일치해 가장 자주 사용되는 숫자이기 때문이다.

// Integer.valueOf() 내부 구현 코드 일부

/**
 * This method will always cache values in the range -128 to 127, inclusive, 
 * and may cache other values outside of this range.
 */
@IntrinsicCandidate
public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}

또한 Boolean 같은 경우에는 객체를 아에 새로 생성할 필요가 없기도 하다. 만약 어떤 객체가 생성 비용이 많이 든다면, 유의미한 성능 향상을 가져올 수 있다.

그리고 인스턴스 생성을 통제할 수도 있다. 의도적으로 생성자의 접근 제어자를 private 으로 닫아 두고, 오로지 정적 팩토리 메서드로만 객체를 생성하게 할 수 있다. 이렇게 하면, 미리 생성해 둔 동일한 객체를 반환할 수 있기 때문에 해당 클래스가 싱글톤(singleton)임을 보장하는 하나의 방법이 된다.

class Singleton {

    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }
}

Singleton 클래스는 객체를 생성하기 위해 getInstance() 메서드를 호출해야 하는데, 내부 필드에 생성된 instance 를 반환하기 때문에, 이 메서드로 반환 받은 객체는 항상 같은 객체임을 보장하게 된다.

3. 반환 타입의 하위 타입 객체를 반환할 수 있는 능력이 있다.

보통 인터페이스와 구현 클래스가 있을 때, 객체 지향 특성 중 다형성을 최대한 활용하기 위해서 인터페이스 타입으로 다루는 것이 유연한 코드 작성에 유리하다.

class 삼다수 implements Water {
}

class 백산수 implements Water {
}

interface Water {

    public static Water get삼다수() {
        return new 삼다수();
    }

    public static Water get백산수() {
        return new 백산수();
    }
}

Water 인터페이스에서 원하는 물 객체를 정적 팩토리 메서드로 반환해 주는데, 이때 반환 타입을 인터페이스 타입으로 반환할 수 있다. 여기서 얻는 장점은 구현 클래스를 외부에 공개하지 않아도, 사용하고자 하는 구현 클래스 객체를 인터페이스 타입으로 반환받을 수 있다는 것이다.

하지만 JAVA 8 이전에는 인터페이스에 static 메서드를 선언할 수 없었기 때문에 클래스 이름에 s 를 붙인 동반 클래스(companion class)를 만들어 그 안에 정의하곤 했다. 대표적인 예로 java.util.Collections 클래스가 있다. 소스 코드의 주석 첫 줄을 보면 다음과 같이 작성되어 있다.

This class consists exclusively of static methods that operate on or return collections.

→ 이 클래스는 컬렉션에서 동작하거나 컬렉션을 반환하는 정적 메서드로만 구성된다.

4. 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.

다형성을 확장한 방식으로, 매개변수에 따라 분기를 해서 조건에 따라 다른 하위 클래스를 반환할 수 있다.

interface Coffee {

    static Coffee getCoffee(boolean takeOut) {
        if (takeOut) {
            return new 테이크아웃컵();
        } else {
            return new 머그컵();
        }
    }
}

위 예시에서는 사용자가 테이크아웃인지에 따라서 커피를 어떤 컵에 따라줄지 결정해 반환한다. 또 다른 예로 스프링에서는 내부적으로 DI 컨테이너가 매개변수(빈 이름, 타입 등)에 따라 다양한 구현 객체(빈)를 반환한다.

// 스프링 컨테이너 생성
ApplicationContext context = new AnnotationConfigApplicationContext(AppConfig.class);

Repository jpaRepository = context.getBean("JpaRepository", JpaRepository.class);
Repository myBatisRepository = context.getBean("MyBatisRepository", MyBatisRepository.class);

Repository 를 어떻게 구현했는지에 따라서 다른 빈을 얻을 수 있다. Repository 를 의존하고 있는 ServiceRepository 를 주입받게 되면, 이게 JPA로 구현된 구현체인지, MyBatis로 구현된 구현체인지 알 수도, 알 필요도 없을 것이다.

5. 정적 팩토리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다.

좀 더 쉽게 풀어서 말하자면, 정적 팩토리 메서드가 반환하는 반환 타입인 인터페이스나 추상 클래스만 정의해 두고, 실제 구현 클래스는 나중에 정의하거나 다른 곳에서 제공될 수 있다는 의미다.

JDBC를 예로 들어 보자. 아래는 정적 팩토리 메서드를 사용해 Connection 객체를 얻는 코드다.

Connection conn = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "user", "pass");

이 메서드를 호출할 때, 실제 어떤 클래스의 인스턴스가 반환되는지는 추측만 할 뿐, 코드 관점에서는 알 수가 없다. 왜냐하면 런타임에 JDBC 드라이버(JAR 파일)에 등록된 클래스가 동적으로 로드되고 반환될 것이기 때문이다. 위 경우 MySQL 드라이버에서 제공하는 com.mysql.cj.jdbc.ConnectionImpl 구현 클래스가 반환될 것이다. 만약 다른 드라이버를 등록하면, 구현 클래스도 그때 변경되는 것이다. getConnection 메서드를 작성할 당시에는 이런 구현체들을 알 필요가 없다. 외부 라이브러리나 플러그인에서 Connection 인터페이스를 구현한 뒤 넣어줄 것이다. 이렇게 설계와 구현을 분리할 수 있어 유연한 아키텍처의 기반이 된다.

💡 JDBC는 대표적인 서비스 제공자 프레임워크(service provider framework)다. 클라이언트는 프레임워크가 제공하는 API를 사용할 때 원하는 구현체의 조건을 명시하게 된다. 위 예시에서는 getConnection 정적 팩토리 메서드가 API 역할을 하고 있다.

빌더 패턴(Builder Pattern)

정적 팩토리 메서드와 생성자는 매개변수가 많을 경우 대응이 어렵다. 어떤 매개변수는 항상 필요할 수도 있고, 어떤 매개변수는 거의 사용하지 않을 수 있다. 이럴 경우, 보통 여러 개의 생성자나 메서드를 만들어 사용자가 선택할 수 있게 한다. 하지만 그 개수가 점점 많아지면, 그것도 쉽지 않다.

예를 들어 마라탕을 주문한다고 가정해 보자. 마라탕은 사용자의 입맛대로 여러 가지 재료들을 선택적으로 넣게 된다. 때문에 다양한 마라탕이 제조될 수 있고, 그 종류도 너무 다양하다. 이걸 온전히 생성자나 정적 팩토리 메서드만으로 구현한다고 상상해 보면 쉽지 않을 것이다.

이럴 때는 빌더 패턴(Builder pattern)을 고려해 볼 수 있다. 빌더는 복잡한 객체들을 단계별로 생성할 수 있도록 하는 생성 디자인 패턴이다.

빌더 패턴에서는 먼저 객체를 대신 생성해주는 빌더 클래스를 내부에 정의한다.

public class 마라탕 {
    private enum 마라 {약간, 중간, 많이}
    private final 마라 마라정도;
    private final int 소고기;
    private final int 청경채;
    private final int 숙주;
    private final int 중국당면;

    public static class Builder {
        // 필수 매개변수
        private final 마라 마라정도;

        // 선택 매개변수
        private int 소고기 = 0;
        private int 청경채 = 0;
        private int 숙주 = 0;
        private int 중국당면 = 0;

        public Builder(마라 마라정도) {
            this.마라정도 = 마라정도;
        }

        public Builder 소고기(int amount) {
            소고기 = amount;
            return this;
        }
        public Builder 청경채(int amount) {
            청경채 = amount;
            return this;
        }
        public Builder 숙주(int amount) {
            숙주 = amount;
            return this;
        }
        public Builder 중국당면(int amount) {
            중국당면 = amount;
            return this;
        }

        public 마라탕 build() {
            return new 마라탕(this);
        }
    }

    public 마라탕(Builder builder) {
        this.마라정도 = builder.마라정도;
        this.소고기 = builder.소고기;
        this.청경채 = builder.청경채;
        this.숙주 = builder.숙주;
        this.중국당면 = builder.중국당면;
    }

    public static void main(String[] args) {
        Builder 마라Builder = new Builder(마라.중간);

        마라Builder.소고기(150).숙주(120).중국당면(120).청경채(120)
    }
}

위 예제를 기준으로 사용자 입장에서 빌더를 사용해 마라탕을 제조해 보자.

먼저 사용자는 필수 매개변수만으로 빌더 객체를 얻는다.

Builder 마라Builder = new Builder(마라.중간);

이 객체로 원하는 재료를 추가해 마라탕을 제조하면 된다. 빌더의 세터 메서드들은 빌더 자신을 반환하기 때문에 연쇄적으로 호출할 수 있다.

// 모든 재료 넣기
마라Builder.소고기(150).숙주(120).중국당면(120).청경채(120);

// 중국당면 빼기
마라Builder.소고기(150).숙주(120).청경채(120);

그리고 마지막에 build() 메서드를 호출해 제조된 마라탕 객체를 얻으면 된다. 이를 메서드 체이닝(method chaining) 방식으로 한 줄에 작성할 수 있다.

public static void main(String[] args) {
    마라탕 마라탕 = new Builder(마라.중간)
            .소고기(150)
            .숙주(120)
            .중국당면(120)
            .청경채(120)
            .build();
}

위와 같이 빌더 패턴을 사용하면 코드를 작성할 때 다른 매개변수를 추가, 삭제하기 유연하고, 사용자 입장에서도 훨씬 가독성 좋게 객체를 생성할 수 있다.

또 다른 예로 자바에서 자주 사용하는 StringBuilder 가 있다. 일반적으로 다양한 문자열을 조립해야할 때, 순간순간마다 문자열을 조립하다 보면 문자열이 불변 객체기 때문에 새로운 객체 생성이 매번 일어나 성능이 저하된다.

public static void main(String[] args) {
        String s = "동해물과";
        s += " 백두산이";  // 새로운 객체 생성
        s += " 마르고";  // 새로운 객체 생성
        s += " 닳도록";  // 새로운 객체 생성
}

이때 StringBuilder 를 사용하면, 추가한 문자열을 내부적으로 char 배열에 추가한 뒤, 나중에 한 번에 String 객체를 생성하기 때문에 가독성도 좋고 객체 생성에 대한 오버헤드도 줄일 수 있다.

public static void main(String[] args) {
    String s = new StringBuilder("동해물과")
            .append(" 백두산이")
            .append(" 마르고")
            .append(" 닳도록")
            .toString();
}

Lombok의 @Builder 애노테이션 사용

Lombok을 사용하면 위 빌더 클래스를 구현하는 수고스러움을 덜 수 있다. 클래스 위에 @Builder 를 붙여주면 컴파일 시 클래스 내부에 자동으로 빌더 메서드가 생성된다.

@Builder
@AllArgsConstructor(access = AccessLevel.PRIVATE)
public class 마라탕 {
    private enum 마라 {약간, 중간, 많이}

    private final 마라 마라정도;
    private final int 소고기;
    private final int 청경채;
    private final int 숙주;
    private final int 중국당면;

    // 필수 파라미터 빌더 메서드 구현
    public static 마라탕Builder builder(마라 마라정도) {

        // 빌더의 파라미터 검증
        if(마라정도 == null) {
            throw new IllegalArgumentException("필수 파라미터 누락");
        }

        return new 마라탕Builder().마라정도(마라정도);
    }

    public static void main(String[] args) {
        마라탕 my마라탕 = 마라탕.builder(마라.중간)
                .소고기(150)
                .숙주(120)
                .중국당면(120)
                .청경채(120)
                .build();
    }
}
  • @AllArgsConstructor(access = AccessLevel.PRIVATE) : @Builder 가 전체 전체 멤버 변수를 인자로 갖는 생성자를 자동 생성해 주는데, 이를 private 생성자로 설정
  • 마라탕Builder : 자동 생성되는 빌더 클래스 이름으로 규칙은 “클래스 이름 + Builder”다.

이렇게 Lombok을 사용하면 간단하게 빌더 패턴을 구현할 수 있으므로 주로 사용되는 방식이다. 또한, 필수 파라미터도 설정하고 검증 로직까지 추가할 수 있다. 반환 타입을 보면 빌더 클래스를 반환하고 있는데 이는 빌더 패턴이 지연 빌더가 가능하기 때문이다.

빌더 패턴을 언제 사용하면 좋을까?

  • 생성자나 정적 팩토리 메서드로는 파라미터가 너무 많을 때
  • 초기화 검증을 멤버 변수별로 분리하고 싶을 때
  • 객체 생성을 지연시켜 동일한 객체의 변형(variant)을 쉽게 만들고 싶을 때
  • 객체 생성 후에 불변(immutable) 상태로 만들고 싶을 때

특히 객체 생성 이후 내부 상태가 변하지 않는 불변 상태로 만드는 것은 매우 중요하다. 멀티 스레드로 동작하는 프로젝트에서 중간에 객체의 상태가 변경되면 예상과 다른 결과가 도출될 수 있고, 작업 도중에 예외가 발생해도 마찬가지다. 이를 디버깅 과정에서 발견하기는 정말로 쉽지 않다. 하지만 불변 객체는 Thread-Safe 하기 때문에 동기화를 고려하지 않아도 돼서 유지보수에 유리하다.

따라서 빌더 패턴을 사용할 때 build() 를 호출해 객체를 생성하고 나면, 이후 객체를 변경할 수 없게 하는게 중요하다. 위 빌더 클래스를 구현한 코드를 보면 객체를 변경할 수 있는 메서드들은 모두 빌더 클래스 안에 정의되어 있고, 멤버 변수들을 final 로 선언하게 된다. 따라서 이미 생성된 객체는 멤버 변수를 변경할 방법이 없어 불변 객체가 된다.


그래서 언제 생성자, 정적 팩토리 메서드, 빌더 패턴을 사용할까

먼저 생성자는 필드 수가 적을 때 가장 최적의 선택이 된다. 가장 간단한 구현 방법이고, 성능도 빠르며 별도의 추가 작업 없이 불변 객체를 생성할 수 있다.

정적 팩토리 메서드는 생성자보다는 좀 더 의미있는 이름을 부여할 수 있다. 또한 캐싱을 이용해 객체를 재사용하거나 다형성 활용, 싱글톤같은 부가적인 전략도 사용할 수 있다.

마지막으로 빌더 패턴은 필드가 확장 가능성이 있는 대부분의 클래스에서 일반적으로 사용하기 좋다. 가독성, 유지보수 측면에서도 우월하기 때문에 무난하게 사용할 수 있다.

  생성자 정적 팩토리 메서드 빌더 패턴
필드 수 적을 때 가장 간단하고 최적 좋음 과함
필드 수 많을 때 코드 복잡 오버로드 많아짐 좋음
필수/선택 파라미터 구분 어려움 생성자보다는 쉬움 자연스럽게 가능
가독성 낮음 (순서 중요) 이름으로 의미 부여 가능 가장 명확
동일한 객체 변형 어려움 제한적 빌드 지연으로 쉽게 가능
성능 가장 빠름 빠름 약간 느릴 수 있음

References

728x90
반응형
댓글