[이펙티브 자바3판] 2장 객체 생성과 파괴

2021. 3. 21. 03:15 JAVA/Effective Java Book

이펙티브자바 3판이 드디어 번역되어 출판되었다. (2달전에 2판샀는데 다 읽지도않았는데...)

 

해당 내용은 이펙티브 자바 3판 (조슈아 블로크 지음, 이복연 옮김)를 읽고 나같은 초심자의 눈으로 이해한 내용을 정리해보았다. (정리된 글만 보는 것보단 이 책은 꼭 사길..바랍니다)

 

 

책에 있는 내용을 기반으로 썼지만, 책에 없는 내용도 조금 적었다. (자바빈 패턴에서 필수인자 받기, 직렬화, Weak Reference 등)

 

 

2장의 아이템 목록

  1. 생성자 대신 정적 팩터리 메서드를 고려하라
  2. 생성자에 매개변수가 많다면 빌더를 고려하라
  3. private 생성자나 열거 타입으로 싱글턴임을 보증하라
  4. 인스턴스화를 막으려거든 private 생성자를 사용하라
  5. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라
  6. 불필요한 객체 생성을 피하라
  7. 다 쓴 객체 참조를 해제하라
  8. finalizer와 cleaner 사용을 피하라
  9. try-finally보다는 try-with-resources를 사용하라

0. 서문

  • 언어를 잘 다룬다는 것은? 문법, 어휘, 용법의 삼박자. 이펙티브 자바에선 용법을 다룬다!

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

  • class의 인스턴스를 어떻게 얻냐? 라고 했을 때, 바로 떠오르는 것은 public 생성자이다. new Redboy();
  • 하지만 생성자 대신 static factory method 를 써보는 것은 어떨까? 아래와 같은 장점들을 가질 수 있다.

1-1. 장점

  • 이름을 가질 수 있다! new Redboy() vs Redboy.getRedboyInstance()
    • ex. BigInteger(int, int, Random)과 BigInteger.probblePrime() 중에 어떤 것이 소수인 BigInteger를 반환한다는 의미가 명확한가?
  • 시그니처에 대해 제약이 없다. 생성자는 이름을 가질 수 없기때문에 오로지 파라미터로만 시그니처를 다르게 하여 만들 수 있다. 따라서 같은 타입의 파라미터를 받으면서 생성자를 다르게 하고 싶어도 할 수가 없다. 이럴 때 static factory method 는 제약이 없으므로 유리하다.
  • 반드시 새로운 인스턴스를 안만들어도 된다. new Redboy()는 필연적으로 인스턴스를 생성한다. 인스턴스를 계속 만들지 않아도 되는 상황에선 static factory method 를 사용함으로써, 인스턴스 통제 클래스로 만들 수 있다.
    • 인스턴스 통제(instance-controlled) 클래스는 싱글턴과 같이 인스턴스를 1개 혹은 N개로 제어하고 있는 클래스를 말한다.
    • ex. public static Boolean valueOf(boolean b) { return b ? Boolean.TRUE : Boolean.FALSE; }
  • 반환 타입의 하위 타입 인스턴스를 만들 수도 있다. 생성자의 경우에는 반환형 클래스가 딱 정해져있지만, static factory method 는 하위타입을 반환할수도 있다. 자바8부터는 인터페이스 public static 을 추가할 수 있다.
    • API 사용자 입장에선 뭘 반환하는지 신경안써도 되서, 개념적 무게가 매우 줄어든다.
    • 이를 응용하여 parameter 에 따라 하위타입의 반환 클래스를 바꿀 수가 있는데, 대표적인 예제가 EnumSet 이다.
      • 원소가 64개 이하면 long 변수 하나로 관리하는 RegularEnumSet 을 반환하고, 65개 이상이면 long 배열로 관리하는 JumboEnumSet 을 반환한다.

1-2. 단점

  • static public 메소드만 제공하는 클래스는 상속할 수 없다. 상속을 하려면 pubilc 이나 protected 생성자가 필요하기 때문이다.
  • 생성자는 java docs에 명확히 나오지만, static factory method 는 일반 메소드일 뿐이므로 docs에서 특별하게 취급하지 않는다. 따라서 사용자가 인스턴스화하려고 했는데, 생성자가 없으면 static factory method 를 찾으러 문서를 뒤적거릴 수 있다. 아래는 static factory 메소드명의 관례이다.
    • from : 매개변수 하나 받아서 인스턴스화 ex. Date.from()
    • of : 여러 매개변수를 받아서 인스턴스화 ex. Enumset.of()
    • valueOf : from과 of의 자세한 버전 ex. BigInteger.valueOf()
    • getInstance : 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지 않는다.
    • create, newInstance : 매번 새로운 인스턴스를 생성해 반환한다.

결론은 무조건 생성자만 쓰는 습관은 고치자

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

  • 생성자와 static factory method 모두 똑같은 단점이 있는데, optional parameter 가 많다면 불편해진다. 예를들어 NutritionFacts(영양정보)라는 class가 있다고 생각해보자. 2개의 필수 인자와 4개의 선택인자가 있다고 가정한다.

2-1. 점층적 생성자 패턴

NutritionFacts salmon = new NutritionFacts(123, 2, 34, 4, 5555, "salmon");
NutritionFacts rice = new NutritionFacts(null, 2, null, 4, 1234, "rice");

N번째 인자에 넘기는 것이 무엇인지 해당 생성자의 시그니처를 반드시 봐야만 한다. (물론 intelliJ 가 이런것들을 똑똑하게 해주지만..)

책에서처럼 점층적 생성자로도 대응하는 것은 한계가 있다. 매개변수가 많아질 수록 많은 생성자를 작성해야하고 복잡해진다.

2-2. 자바빈 패턴

NutritionFacts salmon = new NutritionFacts();
salmon.setXX(123);
salmon.setYY("2");

인자없이 빈 생성자로 인스턴스를 만든후 setter 메소드로 값을 주입하는 방식이다. 원하는 것을 호출해주면 되겠다. 필수인자를 강제하고 싶으면 생성자와 섞어 쓸 수도 있다.

NutritionFacts salmon = new NutritionFacts(123, 2);
salmon.setXX(1234);
salmon.setYY("salmon");

그러나 자바빈 패턴에서는 클래스를 불변으로 만들 수 없다는 치명적인 단점이 존재한다. 어디서나 setter 가 호출될 수 있다. 또한 (보통 VO로만 많이 써서 실제로 그럴일은 많이 없겠지만..) 인스턴스가 중간에 다른 쓰레드에 의해 사용되어버리는 경우, 안정적이지 않은 상태로 사용될 수 있다. 따라서 이런 경우 쓰레드안전성까지 보장(locking, synchronized 등)해줘야 한다.

2-3. 빌더(Builder) 패턴

위의 점층적 생성자와 자바빈의 장점만 모아둔 가장 좋은 방법인 빌더 패턴이 있다. 필요한 객체를 직접 만드는 대신 Builder 객체를 얻은 후 setter 들을 호출한 뒤 build()를 통해 필요한 객체를 얻는 것이다.

NutritionFacts salmon = new NutritionFacts.Builder()
                                .calories(123)
                                .sodium(2)
                                .carbohydrate(5555)
                                .build();

필수 인자라면 new NutritionFacts.Builder("필수", "인자") 처럼 쓸 수도 있다. build()가 호출되는 시점에서 검증, 불변화 등도 할 수 있다.

예제 : http://sjh836.tistory.com/135

  • 위 예제는 밖에서 수정할 수가 없는 불변이다.

단점으로는 Builder부터 짜야한다는 것이다.

2-4. 실무에 유용! lombok의 @Builder 어노테이션을 쓰자

https://projectlombok.org/features/Builder

위에서 빌더패턴의 유일한 단점은 builder를 짜야한다는 것이다. 코드량이 많다보니 한 스크롤은 그냥 먹을 것이다. 안그래도 override할 것많은데 이런 boilerplate 코드들이 많으면 좋지 않다. 이럴 때 lombok의 @Builder 어노테이션을 쓰면 유용하다. class 위에 어노테이션만 달면 바로 클래스명.Builder().build(); 를 사용가능해진다.

단점으로는 위처럼 필수인자를 쓸수 없으며, build() 호출 시점에 검증 등을 통해 예외를 던지는 커스터마이징을 할 수 없다.

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

싱글턴(singleton)이란 인스턴스를 오직 하나만 생성할 수 있는 클래스를 말한다. 일단 생성자를 private으로 감춘다.

3-1. public static final 멤버변수

public static final Redboy INSTANCE = new Redboy(); 은 static 영역이 로딩될 때 딱 1번만 호출된다. 따라서 Redboy.INSTANCE 는 싱글턴이 보장된다.

3-2. static factory method

private static final Redboy INSTANCE = new Redboy();
public static Redboy getInstance() {
    return INSTANCE;
}

이 방식이 위 방식에 비해 갖는 장점은 API 변경에 매우 유연하며, 메소드 레퍼런스로 Redboy::getInstance처럼 사용이 가능하다. (당연한 이야기지만 생성자 레퍼런스(Redboy::new)는 싱글턴이 아니다)

역직렬화 시 싱글턴이 깨지는 이슈

위 두 방법 모두, 역직렬화 할 때 같은 타입의 인스턴스가 여러개 생길 수 있다. 예를들어 이미 직렬화된 Redboy 를 다시 역직렬화할 때, private static final Redboy INSTANCE 이라면 여러 인스턴스가 생길 수 있는 것이다. 따라서 책에서는 transient 키워드를 추가하고, readResolve()를 구현해서 어떤 값이 들어오든 버리고 transient Redboy INSTANCE 를 반환하도록 하였다.

public class Redboy implements Serializable {
	private static final transient Redboy INSTANCE = new Redboy();

	public static Redboy getInstance() {
		return INSTANCE;
	}
	
	public Object readResolve() {
		return INSTANCE;
	}
}

직렬화/역직렬화 알고가자!

  • 직렬화(Serialize) : JVM 메모리영역에 존재하는 인스턴스를 byte형태로 구워버림
  • 역직렬화(Deserialize) : byte형태를 다시 JVM에 올리는 것
    • 웹,앱개발자는 익숙하겠지만 json, xml로 많이들 직렬화/역직렬화 한다.
  • transient는 Serialize하는 과정에 제외하고 싶은 경우 선언하는 키워드
  • readResolve() : 역직렬화시 호출된다.
    • 정확히는 클래스의 멤버변수(레퍼런스 타입)가 serializable 하지않을 경우, 이 멤버변수를 직렬화/역직렬화 해주기 위해 호출된다.
  • writeReplace() : readResolve의 반대로 직렬화 시 호출된다.

3-3. enum

public enum Redboy {
    INSTANCE;
}

3-1번과 비슷하지만 매우 간결하고, 직렬화 걱정이 없다. 하지만 이것은 javap 로 디컴파일 해보면 3-1번과 비슷하다는 것을 알 수 있다.

C:\Users\Redboy\Desktop>javac Redboy.java

C:\Users\Redboy\Desktop>javap Redboy.class
Compiled from "Redboy.java"
public final class Redboy extends java.lang.Enum<Redboy> {
    public static final Redboy INSTANCE;
    public static Redboy[] values();
    public static Redboy valueOf(java.lang.String);
    static {};
}

아이템4. 인스턴스화를 막으려거든 private 생성자를 사용하라

유틸성 클래스는 보통 인스턴스화해서 쓰기보단, static method 들로 구성하고 많이 쓴다.

public class JacksonUtils { public static <T> T convertObject(...) public static String writeValueAsString(...) }

그러나 자바 컴파일러는 JacksonUtils.java를 컴파일할 때 default 생성자(public JacksonUtils)를 추가해버린다. 사용자가 인스턴스화할 수 있는 여지를 주는 것이다. 이것을 방지하기 위해 abstract class 를 만들 수 있지만, 상속해서 인스턴스화하라는 뜻으로 오해를 살 수도 있다.

따라서 가장 좋은 해법은 생성자의 접근제어자를 private 으로 하는 것이다. 또한 이것은 상속을 막는 효과도 있다. (유틸성 클래스를 상속하진 않으니..)

public class JacksonUtils {
    private JacksonUtils() {}
    public static <T> T convertObject(..) {...}
    public static String writeValueAsString(..) {...}
}

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

많은 클래스가 하나 이상의 자원(bean)에 의존한다. 이럴 때 정적 유틸성 클래스나 싱글턴을 사용하게 되면 문제가 생긴다. 아래는 잘못된 구현의 예제이다.

// 한국어사전에 기반한 맞춤법검사기 유틸이다. 이러면 문제는 다른 언어사전으로 어떻게 갈아끼울 것인가?
public class SpellChecker {
    private static final Lexicon dictionary = new KoreanDic();
    private SpellChecker() {}
    public static boolean isValid(String word) { 
        // dictionary 를 이용한 검증로직..
    }
    public static List<String> suggestions(String typo) {
        // dictionary 를 이용한 제안로직..
    }
}

// 한국어사전에 기반한 맞춤법검사기 싱글톤이다. 이러면 문제는 다른 언어사전으로 어떻게 갈아끼울 것인가?
public class SpellChecker {
    private final Lexicon dictionary = new KoreanDic();
    private SpellChecker() {}
    public boolean isValid(String word) { 
        // dictionary 를 이용한 검증로직..
    }
    public List<String> suggestions(String typo) {
        // dictionary 를 이용한 제안로직..
    }
}

사용하는 자원에 따라 동작이 달라지는 클래스에는 정적 유틸리티 클래스나 싱글톤 방식이 적합하지 않다! 이럴 때는 인스턴스를 생성할 때 생성자, static factory method, builder로 필요한 자원을 넘겨주는 방식이 좋다. 이것을 의존객체 주입 패턴 이라 한다.

private final Lexicon dictionary;
public SpellChecker(Lexicon dictionary) {
    this.dictionary = dictionary;
}

의존객체를 통째로 넘겨주는 것이 아니라, 한 단계 더 들어가서 의존객체를 생성하는 Factory를 넘겨주게 할 수도 있다. 자바8에서는 특히 이것을 명확하게 나타낼 수 가 있는데, Supplier 를 아래처럼 이용하는 것이다.

public SpellChecker(Supplier<? extends Lexicon> dicFactory) {
    this.dictionary = dicFactory.get();
}

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

가장 쉬운 예제는 문자열인데, new String("hello"); 보단 "hello"; 가 극단적으로 좋다. 전자는 새로운 인스턴스가 만들어져 heap영역에 올라가고, 후자의 문자열리터럴은 상수풀에 올라가기 때문이다. 비슷한 이유로 new Boolean(String) 대신 Boolean.valueOf(String)이 좋다.

6-1. 비싼 객체

Pattern 같은 비싼 객체들은 한번 쓰고 버리는 것 보단 캐싱해서 쓰는 것이 좋다.

// AS-IS
static boolean isRomanNumeral(String s) {
    return s.matches("^(?=.)M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");
}

// TO-BE
private static final Pattern ROMAN = Pattern.compile("^(?=.)M*(C[MD]|D?C{0,3})(X[CL]|L?X{0,3})(I[XV]|V?I{0,3})$");

static boolean isRomanNumeral(String s) {
    return ROMAN.matcher(s).matches();
}

6-2. 어댑터

책에서는 어댑터의 경우를 들면서 애매모호한 상황을 설명하고 있다. 어댑터는 실제 작업은 뒷단 객체에 위임하고 자신은 제2의 인터페이스 역할을 해주는 객체이다. 어댑터는 뒷단 객체만 관리하면 되므로, 뒷단 객체 하나 당 하나씩만 만들어지면 된다.

Map의 keySet()은 Set 어댑터를 반환하므로, 아래와 같은 경우 혼동이 생길 수 있다. (아래 예제코드는 백기선님 포스팅을 참조했다.)

    Map<String, Integer> serviceSinceMap = new HashMap<>();
    serviceSinceMap.put("Kakao", 2010);
    serviceSinceMap.put("Naver", 1999);

    Set<String> test1 = serviceSinceMap.keySet();
    Set<String> test2 = serviceSinceMap.keySet();

    test1.remove("Kakao");
    System.out.println(test1 == test2); // true
    System.out.println(test1.size()); // 1
    System.out.println(test2.size()); // 1
    System.out.println(serviceSinceMap.size()); // 1

serviceSinceMap.keySet(); 할 때 마다 새로운 Set이 만들어지는게 아니라 같은 Set인스턴스이다.

6-3. 오토박싱

오토박싱은 기본타입과 레퍼런스 타입 간에 자동으로 상호변환해주는 기술이다. 개발자 입장에서 구분을 흐리게해서 편하게 사용가능하게 해주지만, 그 경계가 완전히 없어진 것은 아니다.

Long sum = 0l;
for (long i = 0 ; i <= Integer.MAX_VALUE ; i++) {
    sum += i;
}

이 코드는 매우 느리며, 쓸데없이 Long 객체를 2의 31제곱개나 만든다. 단순히 기본타입 long으로 바꾸기만해도 매우 개선된다. 따라서 특별한 이유가 없다면, 박싱된 기본타입보다는 기본타입을 사용하자.

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

java는 GC가 자동으로 다 쓴 객체를 회수해주긴 하지만, 아래의 몇몇 경우에 개발자가 직접 메모리를 관리함으로써 GC가 회수를 하지않아, OOM 등 문제가 발생할 수 있다.

7-1. 메모리를 직접관리하는 예제

public class Stack {
    private Object[] elements;
    ...
    public Object pop() {
        if (size == 0) throw new EmptyStackException();
        return elements[--size]; // 문제점
    }
    ...
}

Object[]은 Arrays.copyOf()를 통해 길이조절이 되는 상황이다. pop() 메소드에서 size를 감소시키나, 해당 값은 그대로 두고 있으므로 메모리 누수가 발생하는 것을 볼 수 있다.

이 경우 명시적으로 null 을 할당해줌으로써 참조해제를 통해 GC를 돌릴 수 있다.

Object result = elements[--size];
elements[size] = null;
return result;

이처럼 메모리를 직접 관리하는 class는 개발자가 특히 조심해야한다.

7-2. cache

캐시를 직접 구현할 경우, 객체를 캐시에 넣어두고 잊는 등 문제의 소지가 있다. WeakHashMap, LinkedHashMap.removeEldestEntry, 백그라운드 쓰레드를 돌리며 캐시 해제 등 방법을 책에서 제시하고있다.

(캐시 라이브러리 쓰자..)

WeakHashMap

Weak Reference 를 알아야한다. java 에서는 3가지 참조 유형이 있다.

  1. Strong Reference : Integer value = 1; GC대상이 아니다.
  2. Soft Reference : SoftReference<Integer> key = new SoftReference<Integer>(value); value 가 null 이 되어 참조되지 않을때 GC대상이 된다. 그러나 Weak Reference와 다르게 메모리가 부족하지않으면 굳이 GC하지 않는다.
  3. Weak Reference : WeakReference<Integer> key = new WeakReference<Integer>(value); value 가 null 이 되어 참조되지 않을때 GC대상이 된다. 무조건 다음 GC 때 사라진다.

WeahHashMap은 Weak Reference의 특성을 구현한 HashMap이다.

WeakHashMap<Integer, String> map = new WeakHashMap<>();
Integer key1 = 129;
Integer key2 = 130;
map.put(key1, "value1");
map.put(key2, "value2");
System.out.println(map.keySet()); // [130, 129]
key1 = null;
System.gc();
System.out.println(map.keySet()); // [130]

위에서 GC가 돌면 map에는 130:"value2" 만 남게된다.

7-3. 콜백

7-2번과 마찬가지로 put되었는데 계속 냅두면 쌓인다. Weak Reference를 사용한 WeakHashMap에 저장해두면 좋다.

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

https://docs.oracle.com/javase/9/docs/api/java/lang/ref/Cleaner.html

Finalizer는 예측불가능하고 위험하며, 대부분 불필요하다. 성능도 안좋아진다. (자바9부터는 deprecated가 되었고, 대안으로 cleaner를 소개했다.) 그러나 cleaner 역시 finalizer보다 덜 위험하지만 여전히 문제는 비슷하다.

책에서는 Finalizer를 까고있는 내용을 요약하면..

  1. 언제 실행될지 알 수 없다. 실행을 보장X
    • 자바 스펙에 실행 시점을 명확히 하지 않음.
    • 인스턴스가 finalization 큐에 들어간 후 언제 실행될 지 알 수 없다. 아예 안될 수도..
  2. 성능 저하
  3. 예외 발생 시 무시 : 보통 예외가 발생하면 stack trace 가 출력되지만, finalize 내에선 무시되고 처리한다.

finalizer와 cleaner 를 쓰는 적절히 쓰는 곳

자원 반납에 쓸 close 메소드를 클라이언트가 호출하지 않았다는 가정 하에, 물론 실제로 Finalizer나 Cleaner가 호출될지 안될지 언제 호출될지도 모르긴 하지만, 안하는 것 보다는 나으니까. 실제로 자바에서 제공하는 FileInputStream, FileOutputStream, ThreadPoolExecutor 그리고 java.sql.Connection에는 안전망으로 동작하는 finalizer가 있다.

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

자바 라이브러리에는 close 메서드를 통해 닫아야하는 자원들이 있다. 자바 7이전에서는 try-finally를 이용해 close()를 호출했다. 그러나 2개 이상의 자원을 사용할 때 이것은 복잡해진다.

InputStream in = new FileInputStream(src);
try {
    OutputStream out = new FileOutputStream(dst);
    try {
        ...
    } finally {
        out.close();
    }
} finally {
    in.close();
}

이것은 복잡함의 문제뿐만 아니라 finally에서 예외가 터지면 다른 예외가 덮힌다는 복잡한 문제까지 있다. 이럴 경우 디버깅이 힘들어진다.

public class Redboy implements AutoCloseable {
	public void doWork() throws RuntimeException {
        throw new RuntimeException();
    }

    @Override
    public void close() throws RuntimeException {
        throw new RuntimeException();
    }
}

// RedboyClient main 메소드
Redboy redboy = null;
try {
    redboy = new Redboy();
    redboy.doWork();
} finally {
    if (redboy != null) {
        redboy.close();
    }
}

// 실행결과, close() 메소드에서 발생한 예외만 잡혔다.
Exception in thread "main" java.lang.RuntimeException
	at effective.Redboy.close(Redboy.java:10)
	at effective.Client.main(RedboyClient.java:50)

자바7부터는 try-with-resources 가 새로 나왔다. 이것은 AutoCloseable를 구현하고 아래와 같이 써주면, 자동으로 닫아준다. 물론 자원 여러개도 된다. (AutoCloseable를 구현하지 않았다면, 사용할 수 없다)

try (InputStream in = new FileInputStream(src);
     OutputStream out = new FileOutputStream(dst)) {
    ...
} catch (..) {}

위와는 다르게 close에서 발생한 예외를 숨겨주고 stacktrace에서 suppressed 라는 태그를 달고 출력된다. 즉, 뒤에 발생한 에러는 첫번째 발생한 에러 뒤에다 쌓아두고(suppressed) 처음 발생한 에러를 중요시 여긴다. 그리고 Throwable의 getSuppressed 메소드를 사용해서 뒤에 쌓여있는 에러를 코드에서 사용할 수도 있다.



출처: https://sjh836.tistory.com/168?category=679845 [빨간색코딩]