책 읽고 정리하기/2022

Effective Java 3/E - 2장 객체 생성과 파괴

TwinParadox 2022. 1. 2. 22:56
728x90

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

클라이언트가 클래스의 인스턴스를 얻는 방법으로, 해당 클래스의 인스턴스를 반환하는 정적 메서드

예시를 찾아보자면, 박싱 클래스의 valueOf 메서드에서 이러한 형태를 볼 수 있다.

public static Long valueOf(long l) {
    int offset = true;
    return l >= -128L && l <= 127L ? Long.LongCache.cache[(int)l + 128] : new Long(l);
}

장점

이름을 가진다.

생성자만 활용하는 경우, 값으로 넣는 매개변수와 반환될 객체의 특성을 파악하기 어렵다.

생성자 추가 시 순서를 바꾸는 형태로 대응하는 방법보다 적절한 네이밍의 정적 팩토리 메서드가 더 좋다.

 

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

불변 클래스는 인스턴스를 미리 만들어놓거나 생성한 인스턴스를 캐싱해서 재활용한다.

앞서 언급한 Long.valueOf 역시 특정값들에 대해서는 호출될 때마다 인스턴스를 생성하지 않는다.

인스턴스 통제 클래스로 만들어 이점을 취할 수 있다.

 

반환 타입의 하위 타입 객체 반환 능력을 가진다.

반환할 객체 클래스를 선택할 수 있는 유연성을 확보한다.

Java 8부터는 인터페이스가 정적 메서드를 가질 수 있게 되어, companion 클래스를 따로 두지 않고, 그 자체를 인터페이스에 두어 처리할 수 있다. Java 9부터는 private 메서드까지 허용한다. 이 역시도, 정적 필드나 클래스는 public이어야만 한다.

 

입력 매개변수에 따라 매번 다른 클래스의 객체를 반환 가능하다.

반환 타입의 하위 타입이기만 하면, 어떤 클래스를 반환하든 상관 없어진다. ex) EnumSet

클라이언트가 클래스의 존재를 몰라도, 사용할 수 있으며 그 내부에 대해 알 필요 없이 사용 가능해진다.

 

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

JDBC 같은 서비스 제공자 프레임워크(Service Provider Framework)의 근간이 된다.

Connection을 서비스 인터페이스, DriverManager.registerDriver를 제공자 등록 API로, DriverManager.getConnection을 서비스 접근 API로, Driver를 서비스 제공자 인터페이스로 각각의 역할을 수행하게 한다.

 

단점

정적 팩토리 메서드만 제공하는 경우 하위 클래스 생성이 불가능하다.

상속을 위해서는 public, protected 생성자가 필요한데, 정적 팩토리 메서드만 제공하면 상속이 불가능하다.

컬렉션 프레임워크의 유틸리티 구현 클래스들은 상속이 불가능하다. 이 제약 조건이 오히려 장점으로 될 수도 있다.

 

정적 팩토리 메서드를 프로그래머가 찾기 어려울 수 있다.

레퍼런스 문서들을 보면 다양한 형태의 네이밍을 제공하고 있으니 이를 참고하자.

from, of, valueOf, instance(또는 getInstance), create(또는 newInstance), getType, newType, type

 

무작정 public 생성자만 제공하는 것보다 정적 팩토리 메서드를 쓰는 것이 더 이득인지 판단해보자.

 

 

Item 2. 생성자에 매개변수가 많다면 빌더를 고려하라.

정적 팩토리 메서드, 생성자를 활용하는 방법 모두 기본적으로 매개변수가 많을 경우에 대응이 어렵다.

매개변수를 점차 늘려나가는 점층적 생성자 패턴으로 이를 대응해볼 수도 있다.

public class Example {
    private String a;
    private int b;
    private long c;
    private double d;
    private String f;

    public Example(String a) { }
    public Example(String a, int b) { }
    public Example(String a, int b, long c) { }
    public Example(String a, int b, double d) { }
    ... // 다양한 형태의 public 생성자
}

목적에 맞는 생성자를 호출해서 활용하여 객체를 얻는 패턴이다. Javadoc과 IDE가 도와준다고 하더라도, 매개변수 수가 늘어나면 가독성 문제를 야기할 수밖에 없다.

 

public class Example {
    private String a;
    private int b;
    private long c;
    private double d;
    private String f;

    public Example() {}
    public setA(String a) {}
    public setB(int b) {}
    public setC(long c) {}
    public setD(double d) {}
    public setF(String f) {}
}

// 활용 예시
Example exam = new Example();
exam.setA(str);
exam.setB(i);

다른 대안으로는 매개변수 없는 생성자(NoArgs)를 만들어서 Setter로 값을 집어넣는 자바 빈즈(JavaBeans) 패턴이 있다. 생성자의 가독성 문제는 해결했지만, 여전히 매개변수 수만큼 Setter를 호출해야 하며, Setter로 객체를 완성시키기 전까지의 일관성이 무너진다. freeze 등을 사용해볼 수 있지만, 이 역시 프로그래머가 하나하나 다 해야 한다.

 

빌더(Builder) 패턴은 앞서 두 방법을 결합한다. Setter처럼 설정하려는 매개변수를 선택할 수 있으면서(가독성 확보), build 메서드 호출 시점에 완성된 객체를 한 번에 받을 수 있다.(일관성 확보)

Example exam = Example.builder().A(str).B(i).D(d).build();

 

일관성을 확보하면서 가독성도 확보하고 싶은 경우 빌더 패턴을 고려하자.

 

 

Item 3. private 생성자나 열거 타입으로 싱글턴임을 보장하라.

싱글턴을 만들 때 보통 두 가지 방식을 사용한다. 생성자를 private으로 감추고 public static final 필드를 쓰는 방식과, public static 정적 팩토리 메서드로 대응하는 방식이 있다.

public class SingletonEx { // 필드 방식
    public static final SingletonEx INSTANCE = new SingletonEx();
    private SingletonEx() { ... }

    public void doSomething() { ... }
}

public class SingletonEx { // 정적 팩토리 메서드 방식
    private static final SingletonEx INSTANCE = new SingletonEx();
    private SingletonEx() { ... }
    public static SingletonEx getInstacne() { return INSTANCE; }

    public void doSomething() { ... }
}

필드 방식은 싱글턴임이 명백히 드러나고, 간결함을 취할 수 있다. 정적 팩토리 메서드 방식은 API 변경하지 않고 싱글턴이 아니게 만들 수 있고, 메서드를 제네릭 메서드로 만들어낼 수 있다. private 생성자에서 출발하는 방식은 직렬화 과정에서 Serializable을 구현하기 위해 선언하는 것 외에도, readResolve를 제공해서 역직렬화 과정에서 가짜 인스턴스가 생성되는 것을 예방할 수 있다.

 

public enum SingletonEx {
    INSTANCE;
    
    public void doSomething() { }
}

Enum으로 싱글턴을 구현하는 방법도 고려해볼 수 있다. 이 방식은 필드 방식과 비슷하면서 간결하고, 직렬화 가능하며, 리플렉션도 막아낼 수 있다. 상속이 필요 없다면, 대부분의 상황에서 원소가 하나인 열거 타입이 싱글턴을 만드는 가장 좋은 방법이다.

 

 

Item 4. 인스턴스화 방지를 위해선 private 생성자를 사용하라

정적 멤버들만 담은 유틸리티 클래스를 인스턴스화하지 못하게 만드는 방법에 대해 고민해보자.

 

추상 클래스를 고려해볼 수 있지만, 이는 상속해서 하위 클래스를 만들면 인스턴스화가 가능한 데다가, 상속하라는 여지를 줄 수 있다. 컴파일러가 기본 생성자를 만드는 경우(명시적인 생성자가 없는 경우)만 방지하면 추상 클래스까지 가지 않아도 된다.

 

의도적으로 private 생성자를 추가하자. 인스턴스화 방지도 가능하고, 상속을 불가능하게 만들 수 있다.

 

 

Item 5. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라

사용하는 자원에 따라 동작이 달라지는 클래스는 유틸리티 클래스나 싱글턴 방식이 부적합하다. 인스턴스 생성 시점에 필요한 자원을 넘겨주는 것을 고려해볼 수 있는데, 일종의 의존 객체를 주입하는 방법이다.

 

public class MessageService {
    private final MessageSender;
    private List<String> consumers;
    
    public MessageService(Medium medium) {
        this.medium = Objects.requireNonNull(medium);
    }
    
    
    public void sendMessage() { ... }
    public void addConsumer(String consumer) { ... }
}

예시 코드의 MessageService는 어떤 Consumer들이 등록되면 그들한테 메시지를 보내는 로직(sendMessage)을 담고 있다. 여기서 Medium은 메시지를 보내는 전송 수단을 의미한다. 메시지 전송 수단을 명시하게 되면 EmailService, SMSService, SlackService와 같은 형태로 존재하겠지만 Medium을 주입해줘서 MessageService라는 하나의 클래스를 통해 처리가 가능해졌다.

 

의존 객체 주입 방식은 불변을 보장하고, 여러 클라이언트가 공유 가능하며, 생성자, 정적 팩토리 메서드, 빌더 모두에 응용이 가능하다.

 

이를 살짝 변형해서 생성자에 자원 팩토리를 넘겨주는 Java 8의 Supplier<T> 인터페이스도 있다. 여기서의 팩토리는 특정 타입의 인스턴스를 반복해서 만들어주는 객체다. Supplier<T>의 경우, 한정적 와일드카드 타입 사용으로 팩토리의 타입 매개변수를 제한한다. 이를 통해, 명시한 타입의 하위 타입이라면 어떠한 것이든 팩토리를 생성해 넘길 수 있다. 

 

클래스의 유연성, 재사용성, 테스트 용이성을 확보할 수 있으나, 많아지면 코드가 어지러워진다.

이럴 때는 잘 정리된 의존 객체 주입 프레임워크(스프링 같은)를 사용하여 해소 가능하다.

 

 

Item 6. 불필요한 객체 생성을 피하라

동일한 기능의 객체는 매번 생성할 필요가 없다. 당연한 이야기지만, 객체를 필요 없이 생성하는 건 그 비용이 낭비다.

 

불변 객체를 가져가면 재사용도 하면서, 안전성도 확보할 수 있지만, 직관성을 해치는 경우가 있다. Map의 keySet은 Map 안에 있는 키 값들을 모두 담은 Set을 반환한다. HashMap의 keySet 메서드를 가져와봤다.

public Set<K> keySet() {
    Set<K> ks = this.keySet;
    if (ks == null) {
        ks = new HashMap.KeySet();
        this.keySet = (Set)ks;
    }

    return (Set)ks;
}

내부 동작을 모르면, keySet을 호출할 때마다 새로운 Set을 생성한다고 생각할 수도 있지만, 실제 구현된 코드를 보면 그렇지 않다. 이를 통해 얻은 객체를 변경하면, 다른 객체도 변경되는 셈이다.

 

불필요한 객체 생성이 발생할 수 있는 또 다른 예시는 오토 박싱이다.

// AS-IS
public static long sum() {
    Long sum = 0L;
    for (long i = 0; i <= Integer.MAX_VALUE; i++) {
        sum += i; // 불필요한 인스턴스 생성
    }
    
    return sum;
}


// TO-BE
public static long sum() {
    long sum = 0L;
    for (long i = 0; i <= Integer.MAX_VALUE; i++) {
        sum += i;
    }
    
    return sum;
}

로직이 가볍고 의미 상 큰 차이가 없다고 하더라도, 성능 상 문제를 야기한다. 박싱된 기본 타입보다는 기본 타입을 사용하고, 의도하지 않은 오토 박싱이 발생하지 않는지 확인하여 제거해주는 것이 좋다.

 

 

Item 7. 다 쓴 객체 참조를 해제하라

간혹 객체의 다 쓴 참조(obsolete reference)를 가지고 있어서 디스크 페이징, OOM이 발생할 수가 있다. 다 쓴 참조는, 앞으로 다시는 쓰지 않을 참조를 의미하는데 이런 것들에 대해서는 null 처리로 아예 참조를 해제하는 것이 좋다. 이런 접근 방식은 추후 NPE(NullPointerException)으로 대응할 수 있게 해 주기 때문에 이점이 추가적으로 생긴다. 물론 모든 객체를 이렇게 하는 것이 아니라, 예외적인 경우에 적용한다. ex) 자기 메모리를 직접 관리하는 클래스

 

캐시와 리스너/콜백에서도 해제 없이 쌓아두기만 하면서 객체 해제되지 않는 문제를 야기할 수 있다.

 

 

Item 8. finalizer와 cleaner 사용을 피하라

finalizer는 예측 불가능에, 상황에 따라 위험하며 불필요하고, cleaner는 덜 위험해도 여전히 예측 불가능에, 느리다.

자바에서 이 두 가지 객체 소멸자는 C++의 파괴자(destructor)는 자원을 회수하는 가장 보편적인 방법인데 자바는 이걸 가비지 컬렉터에 맡겨버리는 데다가, 실행 시점을 파악하기 어렵다. (보통 가비지 컬렉터의 알고리즘에 따라 결정된다.)

결론적으로, 제때 실행되어야 하는 작업, 상태를 영구적으로 수정하는 작업에는 소멸자에 의존하지 말아야 한다.

 

그럼에도 이것들을 사용하는 이유는, 크게 두 가지다.

  • FileInput/OutputStream, ThreadPoolExecutor같은 클래스의 close 메서드를 호출하지 않았을 때 안전망
  • 네이티브 메서드를 통해 기능을 위임한 네이티브 객체(=네이티브 피어)와 연결된 객체에서 사용

 

 

Item 9. try-finally보다는 try-with-resources를 사용하라

전통적으로 자원을 제대로 닫힘을 보장하는 수단으로 try-finally를 썼다. 이 방식은, 사용하는 자원이 하나라도 더 늘어나면 try 구문이 깊어지고 그 안에서 또 Exception으로 인해 제대로 해제되지 않는 이슈가 발생할 수 있다.

void copy(String src, String dst) throws IOException {
    InputStream in = new FileInputStream(src);
    try {
        OutputStream out = new FileOutputStream(dst);
        try { // 리소스 열 때마다 이 구문이 추가되어야 한다...
            byte[] buf = new byte[BUFFER_SIZE];
            int n;
            while ((n = in.read(buf)) >= 0) {
                out.write(buf, 0, n);
            }
        } finally {
            out.close();
        }
    } finally {
        in.close();
    }
}

try-finally는 depth에도 문제가 있지만, Exception이 덮어지는 문제가 발생할 수 있다. 물리적 문제로 out.write에서 Exception이 발생했는데, 같은 원인으로 finally 구문(FileOutputStream 해제)에서도 발생하게 된다. 이 상황에서는 finally의 Exception으로 덮어 쓰인다. 이러한 문제를 개선할 수 있지만, 코드가 지저분해지고 그럴만한 필요가 없다.

 

자바 7부터는 try-with-resources 구문으로 이를 처리할 수 있다. AutoCloseable 인터페이스를 구현한 클래스들에 한해서 사용이 가능하다. 리소스를 반드시 해제해야 할 때, 가능하면 예외 없이 try-with-resources를 적용하자. 기존의 try-finally보다 더 나은 코드와 결과를 취할 수 있다.

 

void copy(String src, String dst) throws IOException {
    try (InputStream in = new FileInputStream(src);
        OutputStream out = new FileOutputStream(dst)) {
        byte[] buf = new byte[BUFFER_SIZE];
        int n;
        while ((n = in.read(buf)) >= 0) {
            out.write(buf, 0, n);
        }
    } // finally가 없어도 된다.
}

위 예제에서 2-depth였던 try 구문이 1-depth로 줄었고, 공개할 Exception만 보존하고 나머지를 숨길 수 있어졌다.

 

 

728x90
728x90