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

NAKTA·2023년 11월 14일
1

effective java 3e

목록 보기
1/5
post-thumbnail

🌱 들어가면서

프로그래밍이란 마치 작은 세계를 창조하는 것과 같다고 생각했다. 그 세계 속에서 객체는 주인공이자 배역, 그리고 어떤 면에서는 작은 조력자이기도 하다. 그렇기에 객체가 어떻게 태어나고, 어떻게 움직이고, 어떻게 사라지는지를 고민하는 것은 자바 프로그래밍을 하는데에 있어서 객체생명주기에 대한 고민이 절로 떠오르게 된다.

💡 객체의 생명주기란?
객체가 생성된 후부터 폐기될 때 까지의 기간으로, 객체가 생성되어 메모리에 올라가는 시점부터 더이상 사용하지 않아 파괴되면서 메모리에서 사라지게 되는 기간을 말한다.



🙃 객체 생성과 파괴가 중요한 이유

그렇다면 객체의 생명주기가 왜 중요할까?
결론부터 말하면 자바에서 객체 생성과 파괴를 고려해야 하는 이유는 프로그램의 성능, 메모리 관리, 코드 가독성, 유지보수성 등 여러 측면에서 중요한 영향을 미친다.
차근차근 알아보자.

1. 객체 생성 비용

객체 생성은 비싸고 비용이 많이 드는 작업이다. 64비트 JDK에서 객체는 12바이트의 헤더, 8바이트의 배수로 이뤄지기 때문에 최소 16바이트를 소비한다. 또한 객체 참조에도 메모리를 소비한다. 수치로만 봤을 때는 적은 비용이라고 생각이 들지만, 전체 프로그램을 생각한다면 무시할 수 없는 비용이다. 예를 들어서, primitive int는 4바이트에 불과하지만, Integer 객체를 사용할 때는 16바이트(객체) + 8바이트(참조)로 5배나 되는 메모리를 사용하게 된다.

2. 메모리 누수

메모리 누수란 더 이상 사용하지 않는 객체들이 힙(Heap) 영역에 남아있어 불필요하게 메모리를 차지하고 있는 상황을 의미한다. 사용하지 않는 객체들이 계속 메모리에 상주하고 있으면 성능 저하를 야기하고, 최악의 상황에는 Out of Memory Error를 뱉어내게 되므로 주의가필요하다.

3. 가비지 컬렉션 (Garbage Collection)

자바에서는 위와 같은 메모리 누수를 방지하기 위해서 Garbage Collection을 도입하여 더 이상 참조되지 않는 객체를 탐지하여 자동으로 메모리에서 해제해주는 런타임 시스템이 있다. 보기에는 만능인 것처럼 보이지만, 가비지 컬렉션도 결국 하나의 프로그램이기 때문에 가비지 컬렉션이 더 자주 발생하도록 유발된다면 프로그램의 전체적인 성능에 영향을 미칠 수 밖에 없다. 특히 가비지 컬렉션으로 해제되지 않는 자원을 가진 객체 (InputStream, Socket) 등을 가질 경우에는 명시적으로 자원을 해제하는 것이 필요하다.



🙃 객체 생성 방법

1. 생성자

인스턴스가 생성될 때마다 호출되는 인스턴스 초기화 메서드로 객체가 생성될 때마다 초기화 해주는 메서드로 생각하면 된다.

public class Person {
    private String name;
    private String major;

    public Person(String name, String major) {
        this.name = name;
        this.major = major;
    }

    public static void main(String[] args) {
        Person person = new Person("mark", "IT");
    }
}

2. 정적 팩토리 메서드

객체 생성의 역할을 하는 클래스 메서드로 말로 설명하기는 어려운 느낌이 있으므로 간단한 예시를 보자.

public class Person {
    private String name;
    private String major;

    private Person() {}

    public static Person of(String name, String major) {
    	Person person = new Person();
        person.name = name;
        person.major = major
        return person;
    }

    public static void main(String[] args) {
        Person person = Person.of("mark", "IT");
    }
}

위의 코드를 보면 정적 팩토리 메서드를 사용해서 new 키워드를 사용해 생성자를 직접 호출하지 않고 static method를 이용해 객체를 생성하여 객체를 생성했다.



🙃 정적 팩토리 메서드의 장점

왜 굳이 위와 같이 static method를 사용해서 객체를 생성할까? 오히려 위의 생성자 방식보다 코드가 더 복잡해 보이기도 한다. 하지만, 이러한 정적 팩토리 메서드 방식에는 여러가지 장점이 있다.


1. 이름을 가질 수 있다.

자바의 생성자의 이름은 클래스의 이름과 반드시 같아야 한다.
이는, 자바에서 정의하는 생성자의 특징이나 제한사항으로 반드시 지켜져야 한다.

하지만, 정적 팩토리 메서드는 객체를 생성할 때 메서드에 이름을 지어줄 수 있다!
이게 무슨 의미일까? 아래 예제 코드를 보자.

public class Laptop {
    private String brand;
    private int price;

    public Laptop(String brand, int price) {
        this.brand = brand;
        this.price = price;
    }

    public Laptop(String brand) {
        this.brand = brand;
    }

    public Laptop(int price) {
        this.price = price;
    }

    public static void main(String[] args) {
        Laptop laptopWithBoth = new Laptop("SAMSUNG", 123_450);
        Laptop laptopWithBrand = new Laptop("LG");
        Laptop laptopWithPrice = new Laptop(456_780);
    }
}

위의 코드는 생성자 방식을 이용해서 객체를 생성하는 코드이다.

brandprice 모두 초기화 값을 주고 만드는 객체와 각각의 초기화 값만 주고 만드는 객체로 총 3개의 객체가 만들어졌다. 해당 코드의 큰 특징은 객체를 만들 때 모두 new Laptop 생성자를 사용해서 만들었다는 것이다.

이렇게 생성된 객체들은 어떤 의도인지, 어떤 의미를 가지는지 파악하기가 어렵다는 단점이 있다.

이번엔 정적 팩토리 메서드를 사용한 방법을 보자.

public class Laptop {
    private String brand;
    private int price;

    private Laptop() {}

    public static Laptop of(String brand, int price) {
        Laptop laptop = new Laptop();
        laptop.brand = brand;
        laptop.price = price;
        return laptop;
    }

    public static Laptop withBrand(String brand) {
        Laptop laptop = new Laptop();
        laptop.brand = brand;
        return laptop;
    }

    public static Laptop withPrice(int price) {
        Laptop laptop = new Laptop();
        laptop.price = price;
        return laptop;
    }

    public static void main(String[] args) {
        Laptop laptopWithBoth = Laptop.of("SAMSUNG", 123_450);
        Laptop laptopWithBrand = Laptop.withBrand("LG");
        Laptop laptopWithPrice = Laptop.withPrice(456_780);
    }
}

위의 코드를 보면 객체 생성을 할 때 호출하는 메서드가 이름 을 가진다는 것을 알 수 있다.

of, withBrand, withPrice의 이름을 가짐으로써 생성된 객체들이 어떤 의도로 생
성 되었는지 명확하게 알 수 있다.


추가로, 생성자는 "하나의 시그니처로 생성자를 하나만 만들 수 있다" 라는 제약이 있다.
역시 무슨 의미인지 모르겠으므로 아래 예제 코드를 보자.

public class Laptop {
    private String brand;
    private String model;
    private int price;

    public Laptop(String brand) {
        this.brand = brand;
    }

    public Laptop(String model) {
        this.model = model;
    }
}

해당 코드는 컴파일 에러가 발생한다.
왜일까? 그 이유는 매개변수 타입과 수가 같은 생성자를 중복해서 선언했기 때문이다.
사람의 입장에서는 같은 String 타입이라도 brandmodel의 차이점을 알고있지만,
컴퓨터의 입장에서는 brandmodel 상관없이 같은 String 타입으로 보이기 때문에 생성자가 호출되면 어떤 생성자를 호출할지 모르기 때문이다.

하지만, 정적 팩토리 메서드 방법에서는 어떨까?

public class Laptop {
    private String brand;
    private String model;
    private int price;

    private Laptop(){}

    public static Laptop withBrand(String brand) {
        Laptop laptop = new Laptop();
        laptop.brand = brand;
        return laptop;
    }

    public static Laptop withModel(String model) {
        Laptop laptop = new Laptop();
        laptop.model = model;
        return laptop;
    }
}

해당 코드는 당연하게도 컴파일 에러가 발생하지 않는다.
brandmodel 을 넣어주는 생성자 메서드의 이름이 각각 다르기 때문에 선언된 메서드 이름을 호출하여 원하는 필드에 값을 넣어 객체를 생성하는 것이 가능하다.


📑 정리

  • 객체 생성 정적 메서드에 이름을 부여하여 코드 가독성을 높일 수 있다.
  • 자바에서 제공하는 생성자의 매개변수 제약 조건이 사라진다.


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

정적 팩토리 메서드는 객체의 생성을 책임지고 있으므로 인스턴스의 생성 방식에 대해서 관리할 수 있다. 이 말은 다음과 같은 의미를 말한다.

  1. 항상 새로운 객체가 반환되도록 할 수 있다.
  2. 새로운 객체를 생성을 금지하고 항상 같은 객체만 반환하게 할 수 있다.

생성자를 사용하면 new 키워드가 붙기 때문에 항상 새로운 객체를 생성한다. 하지만 Collections 처럼 클래스 메서드만 이용하는 것과 같이 객체를 만들지 않도록 하거나 또는 객체를 오로지 하나만 쓸 수 있도록 만들고 싶은 경우에 정적 팩토리 메서드는 빛을 발한다.

먼저 객체화 불가 클래스 예제 코드를 보자.

public class Collections {
  private Collections() {
  }

  @SuppressWarnings("unchecked")
  public static <T extends Comparable<? super T>> void sort(List<T> list) {
      list.sort(null);
  }
 
  @SuppressWarnings({"unchecked", "rawtypes"})
  public static <T> void sort(List<T> list, Comparator<? super T> c) {
      list.sort(c); 
  }

	...
}
  

다음 코드는 익숙하게 사용했던 java.util 내의 Collections 유틸리티 클래스 코드 일부이다. 유틸리티 클래스는 주로 관련된 메서드 집힙을 static method로 제공하여 특정 작업을 수행하는데 사용되는 클래스로 해당 클래스는 상태를 저장하지 않기 때문에 객체를 만들 필요가 없다.

다음은 객체를 단 하나만 생성하는 예제 코드를 보자.

public class Singleton {
    private static Singleton INSTANCE = new Singleton();

    private Singleton(){}

    public static Singleton getInstance() {
        return INSTANCE;
    }
}

다음 코드는 static methodgetInstance() 를 호출하면 항상 같은 객체를 반환하게 만드는 클래스 코드이다. 이 말은 무엇을 의미할까?

정적 팩토리 메서드는 객체 생성을 통제할 수 있다는 말이 된다.

정적 팩토리 메서드를 이용하면 객체 생성을 통제할 수 있는 클래스인 인스턴스 통제 클래스 (instance-controlled class) 를 만들 수 있고 단 하나의 객체만을 생성하고 사용할 수 있게 만드는 불변 클래스 (noninstantiable) 를 만들 수도 있다.

💡 인스턴스 통제 클래스 (instance-controlled class) 란?
특정 클래스의 인스턴스 생성을 제어하거나 관리하는 디자인 패턴을 나타내며, 이 디자인 패턴은 일반적으로 싱글톤(Singleton) 패턴과 관련이 있다.

💡 하위 타입이란? 불변 클래스 (noninstantiable) 란?
한번 생성되면 내부 상태가 변경되지 않는 클래스를 말한다. 즉, 객체의 상태(멤버 변수)를 수정할 수 없는 클래스를 의미한다.


📑 정리

  • 불필요한 객체의 생성을 막을 수 있다.
  • 상황에 따라서 객체 생성을 통제하는 것이 가능하다.


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

💡 하위 타입이란?
객체 지향 프로그래밍에서 상속 관계에서 나타나는 개념으로, 하위 타입은 부모 클래스 또는 인터페이스를 상속받은 클래스나 인터페이스를 의미한다.
이미지 출처

자기 자신이 아닌 반환할 객체의 클래스를 선택할 수 있다는건 클래스에서 만들어줄 객체의 클래스를 선택할 수 있는 유연함이 생긴다는 말과 같다.

아래 예제 코드를 보자.

public class Membership {
    static Membership of(int point) {
        if (point >= 120_000) {
            return new VIP();
        } else if (point >= 100_000){
            return new Gold();
        } else {
            return new White();
        }
    }
}

class VIP extends Membership {
}

class Gold extends Membership {
}

class White extends Membership {
}

다음 코드는 Membership 클래스의 static method를 호출하여
하위 타입인 VIP, Gold, White 객체를 만들어 반환하는 코드이다.

위 방식의 장점은 Membership 클래스 내부에서 하위 타입을 구현한 것도 아니고,
Membership 클래스는 하위 타입 생성을 연결해주는 역할만을 하기 때문에,
구현 로직은 숨길 수 있으면서 API를 경량화 할수 있기 때문에 난이도를 낮출 수 있다.

프로그래머의 입장에서 해당 메서드만 보고 객체를 가져올 것임을 알기 때문에,
굳이 하위 타입을 찾아볼 필요가 없게된다.


📑 정리

  • 하위 타입의 객체를 유연하게 반환할 수 있다.
  • 하위 타입의 구현체를 숨길 수 있다.
  • API를 경량화 할 수 있다.
  • 프로그래머의 API 사용 난이도를 낮춰줄 수 있다.


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

4번 내용은 3번 내용과 맥락이 같다고 볼 수 있다.

3번에서 봤던 예제 코드를 다시 살펴보자.

public class Membership {
    static Membership of(int point) {
        if (point >= 120_000) {
            return new VIP();
        } else if (point >= 100_000){
            return new Gold();
        } else {
            return new White();
        }
    }
}

class VIP extends Membership {
}

class Gold extends Membership {
}

class White extends Membership {
}

Membership 클래스 내의 static method 로직을 보면,
매개변수로 주어진 point 값에 따라서 VIP, Gold, White 객체를 선택적으로 반환하고 있다는 것을 알 수 있다.


📑 정리

  • 선택적으로 하위 타입의 객체를 반환할 수 있다.


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

말이 너무 어려우므로, 코드를 같이 살펴보자.

import java.util.Optional;
import java.util.ServiceLoader;

public interface Item {
    String type();

    static Item getInstance() {
        ServiceLoader<Item> serviceLoader = ServiceLoader.load(Item.class);

        Optional<Item> item = serviceLoader.findFirst();

        return item.orElseThrow(() -> new IllegalStateException("No implementation found"));
    }
}

class ImportantItem implements Item {
    @Override
    public String type() {
        return "Important";
    }
}

class NormalItem implements Item {
    @Override
    public String type() {
        return "Normal";
    }
}

다음 코드는 메서드와 반환할 타입만 정해두고 실제로 반환될 클래스는 나중에 구현하는게 가능하다. 협업을 진행할 때 인터페이스를 합의해서 만들고 static method 를 미리 만들어 두는 것이 가능할 것 같다.

예제 코드는 다음과 같이, 나와있지만 실제로 findFirst() 메서드를 호출했을 때, 어떤 구현 클래스를 가져올지 모르기 때문에, 실제로 활용하는 환경에서는 어떻게 사용하는지 궁금하다.


📑 정리

  • 구현 클래스가 없어도 정적 팩토리 메서드를 이용해 객체를 반환하는 것이 가능하다.


🙃 정적 팩토리 메서드의 단점

1. 상속을 하려면 public 혹은 protected 생성자가 필요하니 정적 팩토리 메서드만 제공하면 하위 클래스를 만들 수 없다.

정적 팩토리 메서드를 사용하면 private 생성자를 이용하므로 상속 을 할 수 없다.
하지만, 조합 원칙이나 불변 타입을 만드는 관점에서 볼 때는 장점이 되기도 한다.

실제로 정적 팩토리 메서드를 이용하려면 해당 클래스의 역할과 책임을 잘 파악해서 관계를 어떻게 맺을지 고민해보는 것이 필요해 보인다.

💡 조합 원칙이란?
객체 지향 프로그래밍에서 코드를 구성할 때, 상속보다는 객체를 조합하여 사용하라는 원칙을 의미한다. 이는 유연성, 재사용성, 복잡성 감소 등의 이점을 제공하여 코드의 유지보수와 확장이 더 용이하도록 한다.
상속 보다는 조합을 사용하자


📑 정리

  • 정적 팩토리 메서드만을 이용해 객체를 생성할 경우 상속이 불가능하다.
  • 조합 원칙이나 불변 타입 관점에서 볼 때는 장점이 될 수 있다.


2. 정적 팩토리 메서드는 프로그래머가 찾기 어렵다.

생성자는 자바의 기본 문법이기 때문에 보자마자 바로 파악할 수 있지만, 정적 팩토리 메서드는 하나의 기법으로 프로그래머가 해당 클래스에 정적 팩토리 메서드를 사용했다는 사실을 알아야 파악할 수 있다는 단점이 있다. 이를 완화하기 위해서는 널리 통용되는 이름을 정적 팩토리 메서드 이름으로 사용하는 방법이 있다.


💡 정적 팩토리 메서드 네이밍 방식

메서드설명예제
from매개변수를 하나 받아 해당 타입의 인스턴스를 반환(형변환 method)Date d = Date.from(instant);
of여러 매개변수를 받아 적합한 타입의 인스턴스를 반환하는 집계 메서드Set faceCards = EnumSet.of(JACK, QUEEN, KING);
valueOffrom과 of의 더 자세한 버전Set faceCards = BigInteger.valueOf(Integer.MAX_VALUE);
instance getInstance매개변수를 받을 경우 매개변수로 명시한 인스턴스를 반환하지만 같은 인스턴스임을 보장하지는 않음StackWalker luke = StackWalker.getInstance(options);
create newInstanceinstance 혹은 getInstance와 같지만 매번 새로운 인스턴스를 생성해 반환한다.Object newArr = Array.newInstance(classObj,arrayLen);
getTypegetInstance와 같으나 생성할 클래스가 아닌 다른 클레스의 팩터리 메서드를 정의할 때 사용한다.FileStore fs = Files.getFileStore(path)
newTypenewInstance와 같으나 생성할 클래스가 아닌 다른 클레스의 팩터리 메서드를 정의할 때 사용한다.BufferedReader br = Files.newBufferedReader(path);
typegetType과 newType의 간결한 버전List litany = Collections.list(legachLitancy);



🔎 참조

profile
느려도 확실히

0개의 댓글