Post

(Effective Java) 2장 객체 생성과 파괴

아이템 1. 생성자 대신 정적 팩터리 메서드를 고려하라

  • GoF에 나오는 팩터리 메서드와는 다르다.
  • public 생성자에 new를 써서 객체를 만드는게 아니라, 아래 처럼 팩터리 메서드를 사용하는 것을 말함.
  • public 생성자와 static factory 메서드는 각자 장단이 있지만, 후자가 유리한 경우가 더 많다.
1
2
3
public static Boolean valueOf(boolean b) {
    return b ? Boolean.TRUE : Boolean.FALSE;
}
  • 장점0. 같은 메서드 시그니처를 가진 목적이 다른 생성자를 만들 수 있다.
    • 책에는 나와있지 않지만 실제로 이런 경우에도 유용하다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
@Data
public static class DataArea {
    private final String id;
    private final String m1;
    private final String m2;
    private final String i1;
    private final String i2;
    public static DataArea ofM(String id, String m1, String m2) {
        return new DataArea(id, m1, m2, null, null);
    }
    public static DataArea ofI(String id, String i1, String i2) {
        return new DataArea(id, null, null, i1, i2);
    }
}
  • 장점1. 이름을 가질 수 있다. (가독성 향상)
    • 복잡한 생성 시 되게 유용하다. 예를 들면, 리스트를 받아서 해당 요소들을 하나로 merge한 새로운 객체를 만들 때?
    • 값이 소수인 BigInteger를 반환한다? 후자가 더 가독성이 좋다.
1
2
BigInteger(int, int, Random)    vs    BigInteger.probablePrime
// 여러 생성자가 필요한 상황이라면 static factory 메서드로 바꿀 단서가 될 지도 모른다.
  • 장점2. 호출될 때마다 인스턴스를 새로 생성하지 않아도 된다.
    • 생성해두었던 인스턴스를 캐싱하여 재활용하거나, 싱글턴으로 계속 같은걸 반환해주거나 하는게 가능함.
  • 장점3. 반환 타입의 하위 타입 객체를 반환할 수 있는 능력이 있다.
    • 리턴 타입은 interface로 하고, 실제 리턴은 그를 구현한 class의 인스턴스를 반환해주는 것으로 하면 구현 class의 상세를 숨길 수 있다.
    • API를 쓰는 사람으로 하여금 interface로 접근 하도록 유도할 수도 있고.
    • 구현 class들을 숨기게 되므로 API 명세도 훨씬 간단해진다. (e.g., java.util.Collections)

* 자바 8 이전에는 interface에 static 메서드를 선언할 수 없었기 때문에 이름이 “Type”인 인터페이스를 반환하는 static 메서드가 필요하면, “Types”라는 (인스턴스화 불가한) 동반 클래스(companion)를 만들고 그 안에 정의하는 것이 관례였다. 자바 8 부터는 interface가 static (public)메서드를 가질 수 있으므로 대체로 여기다 둔다.

  • 장점4. 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.
    • 역시 API를 사용하는 입장(클라이언트)에서는 어떤 클래스가 반환되는지 관심 없고, 인터페이스로 접근함. 때문에 다음 릴리즈 때 클래스를 아예 교체해도 된다. 자연히 의존성이 낮아진다.
  • 장점5. 정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다.
    • interface로 다루면 클래스가 없어도 되니까. DI 받을 때 interface로 받는 그런 맥락.
  • 단점1. 상속을 하려면 public이나 protected 생성자가 필요하니 정적 팩터리 메서드만 제공하면 하위 클래스를 만들 수 없다.
    • 하지만 상속보다 컴포지션을 사용(아이템 18)하라는 관점에서는 장점으로 받아들일 수도 있음.
  • 단점2. 정적 팩터리 메서드는 프로그래머가 찾기 어렵다.
    • 생성자는 명확한 반면 static factory는 좀 덜 명확하지. 그래서 메서드명을 컨벤션을 따라 잘 지어줘야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// from : ~로 부터 반환. 형변환해서 반환.
Date d = Date.from(instant);
// of : 집계해서 반환.
Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING);
// valueOf : from과 of의 더 자세한 버전
BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE);
// instance 혹은 getInstance : 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지 않음.
StackWalker luke = StackWalker.getInstance(options);
// get{Type} : getInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때.
FileStore fs = Files.getFileStore(path);
// new{Type} : newInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때.
BufferedReader br = Files.newBufferedReader(path);
// {type} : get{Type}과 new{Type}의 간결한 버전
List<Complaint> litany = Collections.list(legacyLitany);

Kotlin에서 operator invoke를 활용하면 static factory method 를 만드는 것도 가능은 하지만… 비추천. (effective kotlin Item 33)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/** 비추천 !! **/
data class User(
    val userId : String?,
    val userKey: Secret?
) {
    companion object {
        operator fun invoke(userId: String) =
            User(userId, null)
        operator fun invoke(userKey: Secret) =
            User(null, userKey)
    }
}
User(id, key)
User(id)
User(key)
  • kotlin에서도 java 처럼 이름을 가지는 정적 팩터리 메서드를 사용하는 것이 좋다.
  • 왜 invoke 사용을 비추천할까?
      1. 일단 이름을 달리 갈 수 없기 때문에 id와 key의 타입이 둘다 String인 경우 대응이 안된다는 생성자의 단점을 동일하게 가져간다.
      1. (추측) invoke는 lambda 호출을 지원하기 위한 operator의 성격이 강한데, 이를 객체 생성에 사용하는 것은 본래 목적에 맞지 않다.
1
2
3
4
5
6
val lambda = { x: Int -> 1 }
lambda.invoke(3)
lambda(3)
// java에서는 무조건 lambda.apply(...) 써야 하는 반면
// kotlin에서는 invoke 덕분에 lambda(...)가 가능.

아이템 2. 빌더를 고려하라

빌더는 다음같은 상황에서 유용하다

  • 생성자에 매개변수가 너무 많을 때
    • 자바는 named param이 안되니까, 파라미터 넘길 때 순서 틀리면 오류다
    • 근데 이건 생성자 매개변수를 풀어서 받지 않고 객체로 받으면 해결되는 경우가 많아서(굳이 풀어서 전달 할 필요가 없는 경우) 빈약한 이유이긴 하다.
    • 반면 아래 3가지는 빌더를 사용하면 좋은 상황을 나타내는 시그널임.
  • default 값을 지정하고 싶을 때
  • 생성자 파라미터에 null을 넘겨야 할 때
  • 필수 파라미터와 옵셔널 파라미터를 programmatic하게 강제하고 싶을 때

lombok의 @Builder를 많이 사용한다.

왜 빌더가 필요한가?

  • 생성자를 통해 만드는 경우, 넘길 필요가 없는 파라미터 일지라도 외부에서 어떤 값을 넘겨주어야만 한다는 단점이 있다.
    • java는 =default parameter를 지원하지 않기 때문에.
    • 보통 null을 넘겨야 해서 어글리하다.
    • 예를 들면 new User(name, null)
  • null을 넘기지 않고 필요한 파라미터만 넘기게끔 하려면, 생성자를 굉장히 overloading해야 한다.
    • 예를 들면
    • User(name, email)
    • User(name) // email을 넘길 필요가 없을 때
    • User(email) // name을 넘길 필요가 없을 때
  • 또는 빈 생성자를 호출한 다음에, setter를 차례대로 호출해주는 방식으로 초기화해야 하는데 이 방법은 객체가 완전히 생성되기 전까지는 일관성이 깨진 상태라는 단점이 있다.

상세

  • 위와 같은 케이스라면 빌더 패턴이 유리하다.
  • 또한, 빌더 패턴은 클라이언트가 매개변수의 순서를 잘못 전달하는 것도 방지할 수 있다는 부가적인 장점도 있다.
  • 빌더는 생성할 클래스 안에 static member class로 만들어 두는게 보통이다.
  • item2/builder/NutritionFacts.java
  • 뭔가 잘못되었다면 보통 IllegalArgumentException을 던진다.
  • 빌더 패턴은 상속을 통해 계층적으로 설계된 클래스와 함께 쓰기에 좋다.
  • item2/hierarchicalbuilder

    • Builder<TextendsBuilder<T>> 로 하위 타입을 반환할 수 있도록 했는데, 이런 것을 공변 반환 타이핑(covariant return typing)이라고 한다.

빌더 패턴의 단점은

  • 빌더를 생성해야 해서 성능이 떨어진다.(크지는 않다.)
  • 여러모로 매개변수가 4개 이상은 되어야 빌더 패턴을 쓰는 가치가 있다.
    • 그러나 API는 시간이 지날 수록 매개변수가 많아지는 경향이 있다.
    • 나중에 빌더 패턴으로 전환하게 되면, 애매한 상황이 생긴다. 그래서 애초에 빌더로 시작하는 편이 나을 때가 많다.
    • 그리고 매개변수 3개 이하여도, 생성자 파라미터로 null을 넘겨야 한다거나 default 값을 지정하고 싶다거나 하는 경우 Builder 를 쓰는 것이 더 낫다.

아이템 3. private 생성자나 enum 타입으로 싱글턴임을 보증하라

아이템 4. 인스턴스화를 막으려거든 private 생성자를 사용하라 아이템 5. 자원을 직접 명시하지 말고 의존 객체 주입(DI)을 사용하라

의존성 주입(DI, Dependency Injection)이란?

아이템 6. 불필요한 객체 생성을 피하라

  • 그러나 아주 무거운 객체가 아니면 사용자 정의 객체 풀(pool)을 만들지 말라.
  • DB connection 같은 경우 생성 비용이 워낙 비싸니 재사용하는 편이 낫지만, 일반적으로 사용자 정의 객체 풀은 코드를 헷갈리게 만들고 성능을 떨어트린다.
  • 요즘은 JVM 가비지 컬렉터 성능이 좋아서 웬만하면 직접 만든 객체 풀 보다 GC가 생성/삭제하도록 두는게 더 낫다.

아이템 7. 다 쓴 객체 참조를 해제하라.

  • 다 쓴 참조를 해제하는 가장 좋은 방법은 유효 범위(scope) 밖으로 벗어나는 것이다.
  • 그게 안되는 경우(전역이라거나.) 다 쓴 참조는 null을 할당하여 더 이상 가리키지 않도록 해 GC가 삭제할 수 있도록 한다.
    • 특히 배열의 경우 많이 놓치게 되는데, e[size] = null 따위로 해제해 줄 수 있도록 한다.
  • 메모리 누수가 자주 발생하는 케이스로는
    • 자기 메모리를 직접 관리하는 클래스
      • null을 할당해준다.
    • 캐시
      • WeakHashMap 등의 사용을 고려하거나,
      • 캐시 엔트리의 가치를 점차 떨어뜨리고 주기적으로 청소.
    • Listener, Callback. 등록만 하고 해지하지 않는 경우
      • 약한 참조로 저장하면 GC가 즉시 수거해가도록 할 수 있다. WeakHashMap에 키로 저장하면 약한 참조가 됨
    • 자료 구조 선택 가이드 : WeakHashMap

아이템 8. finalizer와 cleaner 사용을 피하라

  • C++에서는 직접 객체를 삭제해야 하니 객체가 사용하던 자원을 반환하는 destructor가 꼭 필요하지만, Java에는 GC가 객체를 삭제한다.
  • 그리고 GC가 어느 시점에 객체를 소멸시킬지 알 수 없기 때문에, finalizer의 호출 시점이 명확하지 않다.
    • 예를 들어 “파일 닫기” 같은 “자원 회수 작업”을 finalizer에게 맡기면, GC가 객체를 소멸시키기 전에 File Handle 개수가 꽉 차버릴 수 있다.
  • finalizer와 cleaner 대신, AutoCloseable을 구현하고 클라이언트에서 객체를 다 쓰고 close()를 호출해주도록 한다. ( + try-with-resources로 자신을 닫도록 만든다.)

아이템 9. try-finally 보다는 try-with-resources를 사용하라.

This post is licensed under CC BY 4.0 by the author.