본문 바로가기

개발/Java

[이펙티브 자바] 생성자 대신 Static Factory Method를 고려하라

반응형

Effective Java 3/E, 조슈아 블로치

Item 1. 생성자 대신 정적 팩토리 메서드를 고려하라


정적 팩토리 메서드(Static Factory Method)는 클래스에 정적 메서드를 정의하고, 생성자 대신 객체를 생성할 수 있게 만드는 기법입니다. 간단하게 정리하면 Method 호출 방식으로 객체를 생성하는 것입니다.

대표적인 예로 Java의 Wrapper 클래스에서 사용되는 것을 확인할 수 있습니다. 그중 하나인 Boolean은 다음과 같은 API를 제공합니다.

public static Boolean valueOf(boolean b) {
	return b ? Boolean.TRUE : Boolean.FALSE;
}

 

public static void main(String args[]) {
    Boolean bool1 = new Boolean(true); // new 연산자 사용
    Boolean bool2 = Boolean.valueOf(true); // 정적 팩토리 메서드 사용
}

 

정적 팩토리 메서드 장점


1) 이름을 가진 생성자

생성자는 기본적으로 이름을 가질 수 없습니다. new BigInteger(~) 과 BigInter.probablePrime(~) 둘 중 어떤 메서드가 더욱 '소수인 BigInteger를 생성' 하는 의미로 알 수 있나요? 정적 팩토리 메서드를 통해 생성자의 역할과 의도를 명확하게 전달할 수 있습니다.

예시로 ISBN 또는 제목을 인자로 전달받아 책 객체를 생성하는 클래스가 있다고 가정하겠습니다.

class Book {
    private String title;
    private long isbn;

    // ISBN을 인수로 받는 생성자
    Book(long isbn) {
        if (isbn == 9788966262281L) {
            this.isbn = isbn;
            this.title = "채식주의자";
        } else if (isbn == 9788998139766L) {
            this.isbn = isbn;
            this.title = "소년이온다";
        }
    }

    // 제목을 인수로 받는 생성자
    Book(String title) {
        if ("채식주의자".equals(title)) {
            this.title = title;
            this.isbn = 9788966262281L;
        } else if ("소년이온다".equals(title)) {
            this.title = title;
            this.isbn = 9788998139766L;
        }
    }
}
Book book1 = new Book(9788966262281L); // 채식주의자의 ISBN
Book book2 = new Book("소년이온다);

new 키워드를 통해 객체를 생성하는 코드를 봤을 때, 생성자 인자로 ISBN 또는 책 제목을 전달해야 한다는 사실을 직관적으로 이해할 수 없습니다. 또한 코드 내부를 확인하기 전까지는 어떠한 인자를 필요로 하는지 파악하는 데도 쉽지 않습니다. 심지어 ISBN은 ISBN10과 ISBN13 체계로 나뉩니다. 예시는 ISBN13을 기준으로 작성했지만, ISBN10까지 지원하도록 클래스를 확장한다면, 객체를 생성할 때 더욱 혼란스러울 것입니다.

class Book {
    private String title;
    private long isbn;

    private Book(String title, long isbn) {
        this.title = title;
        this.isbn = isbn;
    }

    static Book createByIsbn(long isbn) {
        if (isbn == 9788966262281L) {
            return new Book("채식주의자", isbn);
        } else if (isbn == 9788998139766L) {
            return new Book("소년이온다", isbn);
        }
        throw new IllegalArgumentException("일치하는 책이 없습니다.");
    }

    static Book createByTitle(String title) {
        if ("채식주의자".equals(title)) {
            return new Book(title, 9788966262281L);
        } else if ("소년이온다".equals(title)) {
            return new Book(title, 9788998139766L);
        }
        throw new IllegalArgumentException("일치하는 책이 없습니다.");
    }
}

생성자에는 private 접근 제어자로 설정하고, 정적(Static)인 메서드를 통해 private 생성자에 접근합니다.

Book book1 = Book.createByIsbn(9788966262281L); // 채식주의자의 ISBN
Book book2 = Book.createByTitle("소년이온다");

정적 팩토리 메서드를 통한 객체 생성 예시입니다. 메소드명을 보고 어떤 역할을 수행하는지 직관적으로 바로 알 수 있습니다. 

2) 인스턴스 통제 클래스

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

정적 팩토리 메서드는 인스턴스 재사용을 통해 불필요한 메모리 할당을 줄입니다. 반면 new 키워드의 객체는 생성할 때마다 새로운 인스턴스를 갖습니다. 이는 서로 다른 주소의 객체가 매번 Heap 메모리에 쌓이게 되면서, 메모리뿐만 아니라 Garbage Collection(가비지 컬렉션)의 부담도 발생합니다.

java.lang.Integer 클래스의 정적 팩토리 메서드인 valueOf를 보겠습니다. (아래 IntegerCache는 참고입니다.)

public static Integer valueOf(int i) {
    if (i >= IntegerCache.low && i <= IntegerCache.high)
        return IntegerCache.cache[i + (-IntegerCache.low)];
    return new Integer(i);
}
static class IntegerCache {
    static final int low = -128; // 캐시 범위의 하한
    static final int high; // 캐시 범위의 상한
    static final Integer[] cache; // 캐시 배열

	// Static Initialization Block
    static {
        high = 127; // 캐시 범위의 상한 설정
        cache = new Integer[high - low + 1]; // 캐시 배열 초기화
        for (int i = low; i <= high; i++) {
            cache[i + (-low)] = new Integer(i); // 캐시 배열에 Integer 객체 생성 및 저장
        }
    }
}

valueOf(int i)에서 인자로 받은 i가 캐싱된 숫자 범위 내에 있다면, 캐시 배열에서 초기에 생성했던 인스턴스를 반환합니다. 반대로 존재하지 않는다면, 새로운 인스턴스를 만들도록 구현되었습니다. 

캐싱을 통해 인스턴스 재사용을 구현함으로써 new 키워드만을 사용할 때보다 메모리 사용을 최적화하고 불필요한 객체 생성을 줄일 수 있습니다.

3) 하위 타입 객체 반환

정적 팩토리 메서드를 사용하면, 반환할 객체의 클래스를 자유롭게 선택할 수 있습니다. 이를 통해 확장성과 유연성을 높일 수 있습니다. new 생성자과 정적 팩토리 메서드 각각의 하위 타입을 생성하는 예시를 보도록 하겠습니다.

3-1) 정적 팩토리 메서드 사용

// Shape 인터페이스
interface Shape {
    void draw();
}

// Circle 클래스
class Circle implements Shape {
    @Override
    public void draw() {
        System.out.println("Circle drawn");
    }
}

// Square 클래스
class Square implements Shape {
    @Override
    public void draw() {
        System.out.println("Square drawn");
    }
}

// ShapeFactory 클래스
class ShapeFactory {
    // 정적 팩토리 메서드
    public static Shape createShape(String type) {
        if ("circle".equalsIgnoreCase(type)) {
            return new Circle(); // Circle 객체 반환
        } else if ("square".equalsIgnoreCase(type)) {
            return new Square(); // Square 객체 반환
        }
        throw new IllegalArgumentException("Unknown shape type");
    }
}

// 사용 예
public class Main {
    public static void main(String[] args) {
        Shape circle = ShapeFactory.createShape("circle");
        circle.draw(); // 출력: Circle drawn

        Shape square = ShapeFactory.createShape("square");
        square.draw(); // 출력: Square drawn
    }
}

3-2) new 키워드 사용

// Shape 인터페이스
interface Shape {
    void draw();
}

// Circle 클래스
class Circle implements Shape {
    @Override
    public void draw() {
        System.out.println("Circle drawn");
    }
}

// Square 클래스
class Square implements Shape {
    @Override
    public void draw() {
        System.out.println("Square drawn");
    }
}

// 사용 예
public class Main {
    public static void main(String[] args) {
        Shape circle = new Circle(); // Circle 객체 직접 생성
        circle.draw(); // 출력: Circle drawn

        Shape square = new Square(); // Square 객체 직접 생성
        square.draw(); // 출력: Square drawn
    }
}

각각의 방식을 비교해 보겠습니다.

먼저 새로운 도형을 추가한다고 가정하겠습니다. 정적 팩토리 메서드의 경우는 ShapeFactory만 수정하면 됩니다. 반면 new 키워드의 경우에는 호출 위치에서 직접 생성해야 하므로, 즉 클라이언트 코드를 수정해야 합니다. 만약 해당 클래스를 여러 클라이언트 위치에서 호출하다면 어떨까요? new 키워드는 모든 클라이언트 코드를 수정해줘야 하지만, 정적 팩토리 메서드의 경우엔 역시나 ShapeFactory만 수정하면 됩니다. 결합 정도가 다르다는 것을 느낄 수 있습니다.

물론 new 키워드의 경우엔 직관적이고, 간단하기 때문에 꼭 좋지 않다고 말할 수는 없습니다.

두 번째로는 객체 존재 여부입니다. new 키워드는 객체를 생성할 때 클래스가 존재해야 합니다. 반면 정적 팩토리 메서드는 작성하는 시점에 반환할 객체의 클래스가 존재하지 않아도 됩니다.

Java8 이후 인터페이스에서 정적 메서드 선언 가능
// Shape 인터페이스
interface Shape {
    void draw();

    // 정적 팩토리 메서드
    static Shape create(String type) {
        if ("circle".equalsIgnoreCase(type)) {
            return new Circle(); // Circle 객체 반환
        } else if ("square".equalsIgnoreCase(type)) {
            return new Square(); // Square 객체 반환
        }
        throw new IllegalArgumentException("Unknown shape type");
    }
}

4) 인자에 따라 다른 클래스 객체 반환

위 예시에서 많이 보았습니다. 입력받는 인자와 조건을 통해 생성하는 객체가 다르도록 조작할 수 있습니다. 실제로 java.util.EnumSet의 정적 팩토리 메서드는 아래와 같이 구현되었습니다.

public static <E extends Enum<E>> EnumSet<E> noneOf(Class<E> elementType) {
    Enum<?>[] universe = getUniverse(elementType);
    if (universe == null)
        throw new ClassCastException(elementType + " not an enum");

    if (universe.length <= 64)
        return new RegularEnumSet<>(elementType, universe);
    else
        return new JumboEnumSet<>(elementType, universe);
}

 

정적 팩토리 메서드 단점


1) private 생성자일 경우 상속 불가능

객체 생성 로직을 캡슐화하기 위하여, private로 설정하면 해당 클래스의 인스턴스를 외부에서 직접 생성할 수 없습니다. 일반적으로 하위 클래스에서는 부모 클래스의 생성자를 호출하면서 상속을 해야 합니다. 하지만 부모 클래스의 생성자가 private으로 되어 있으면, 컴파일 오류가 발생하게 됩니다. 즉, 부모 클래스를 기반으로 하는 기능 재사용성과 확장성이 제한됩니다.

하지만 완전한 단점이라고 보기는 어렵습니다. 상속 관계가 아닌, 컴포지션 사용 유도와 불변타입으로 만들기 위해서는 이 제약을 지켜야 한다고 합니다. 다시 말하면 때로는 객체의 유연성을 해치며 문제를 만들 수 있는 상속을 방지하기 위하여 제약을 둘 수 있다는 것입니다.

기존 클래스가 새로운 클래스의 구성요소가 되는 것 > (HAS-A) 관계

2) API 문서에서 명확하게 드러나지 않는 설명

정적 팩토리 메서드는 프로그래머가 찾기 어렵다.
자바독(JavaDoc)은 Java 클래스를 문서화 하는 도구입니다. 자바독에서 클래스의 생성자는 생성자로써 분류되어 노출되지만, 정적 팩토리 메서드는 일반 메스드이기 때문에 개발자가 직접 문서를 찾아야 합니다. 아래는 Boolean 클래스의 자바독 문서입니다. 

Boolean 의 Javadoc 문서

 

정적 팩토리 메서드 명명 방식


단점 2를 보완하기 위해서 널리 알려진 규약을 통해 정적 팩토리 메서드를 명명하는 것이 좋습니다. 이펙티브 자바에서 소개하는 네이밍 컨벤션은 아래와 같습니다.

1) from : 매개변수를 1개 받고, 인스턴스를 반환하는 형변환 메서드

Date d = Date.from(instant);

2) of : 여러 매개변수를 받고, 인스턴스를 반환하는 집계 메서드

Set<Rank> faceCards = BigInteger.valueOf(Integer.MAX_VALUE);

3) valueOf : from, of의 자세한 버전

BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE);

4) instance/getInstance : 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지 않음.

StackWalker luke = StackWalker.getInstance(options);

5) create/newInstance : instance/getInstance와 같지만, 매번 새로운 인스턴스를 생성하여 반환

Object newArray = Array.newInstance(classObject, arrayLen);

6) getType : getInstance와 같으나, 생성한 클래스가 아닌 다른 클래스에 Factory Method 정의할 때 사용.

BufferedReader br = Files.newBufferedReader(path);

7) type : getType과 newType의 간결한 버전

List<Complaint> litany = Collections.list(legacyLitany);

 

결과적으로


정적 팩토리 메서드의 장단점에 대해서 알아보았습니다. 이팩티브 자바 저자는 상대적인 장단점이 존재하겠지만, '정적 팩토리'를 사용하는 것이 유리한 경우가 많다고 합니다. 무엇보다 목적에 맞게 사용하는 것이 중요할 것입니다.

이제까지 개발을 하면서 정적 팩토리 메서드를 많이 사용했습니다. 이유는 '성능적으로 좋다'라고 생각했기 때문입니다. 하지만 인스턴스를 재사용한다는 점만 알고, 자세한 원리를 몰랐던 저는 단순하게 객체가 Heap 메모리에 저장되는 것이 아니기 때문에 메모리 이점이 있구나라고 생각했습니다. 

사실은 인스턴스 재활용은 캐싱 구현 방식을 추가로 적용할 때였고, 성능적 이점에 대해서는 정확히 어떤 부분에서 그런 것인지 생각하지 못했습니다. 그래서 마지막으로 항상 궁금했던 부분을 정리하려고 합니다.

정적 팩토리 메서드와 new 키워드를 통한 객체 생성 각각의 방식을 과하게 사용한다면,

1) 정적 팩토리 메서드

캐시 내 객체를 유지하기 위해 시스템의 메모리 사용량이 증가하여 할당할 수 있는 메모리가 줄어들고, 메모리 오버헤드까지 발생합니다. 뿐만 아니라 관리되지 않은 객체는 메모리에 남아 있기 때문에 장기적으로 프로그램의 성능을 저하시킬 수 있습니다.

이 외에도 아래와 같은 문제를 초래합니다.
- 캐시와 실체 데이터 간의 불일치
- 캐시 갱신 로직으로 인한 복잡성
- 캐시 사용 최적화
- 초기 객체를 미리 생성하면서 애플리케이션 시작 시 성능 저하
- 여러 스레드가 동시에 자원을 공유하면서 경쟁 상태(Race condition) 발생

1) new 키워드를 통한 객체 생성

매번 새로운 객체를 생성하기 때문에 메모리와 CPU 리소스 낭비가 발생합니다. 위에서도 언급했듯이 불필요한 객체가 많아지면서 가비지 컬렉션의 동작 빈도가 증가합니다. (가비지 컬렉션 동작 또한 성능에 악영향)

반응형