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

flaxinger·2022년 1월 16일
0

이펙티브자바

목록 보기
2/3
post-thumbnail

본 글은 이펙티브 자바 3판을 기반으로 쓰여졌으며, 정보 공유보다는 개인적인 노트/생각 정리를 위해 작성되었습니다. 책이 워낙 어려워 종종 잘못 이해한 부분도 있을 터이니 독자의 이해를 부탁드립니다.

목차


  1. 생성자 vs 정적 팩토리 메소드
  2. 빌더는 왜 사용하는가
  3. 싱글턴의 정의와 구현법
  4. 인스턴스화 막기
  5. Dependency Injection
  6. 불필요한 객체 생성을 피하라
  7. 참조 해제가 필용한 경우
  8. Finalizer/Cleaner vs AutoClosable
  9. try-finally vs try-with



1. 생성자 vs 정적 팩토리 메소드


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

책의 첫 주제로, 이와 관련한 좋은 글이 이미 많음으로 요점만 정리하겠다.
전통적인 생성자 함수와 비교했을 때 정적 팩토리 메소드(이하 Static Factory Method)는 아래 5가지 장점을 갖는다.

  • 이름을 가질 수 있다.
    Static Factory Method는 (클래스명과 동일해야하는 생성자와 달리) 고유의 이름을 가질 수 있다. 이는 여러개의 Static Factory Method가 있을 때 그 용도를 파악하는 것을 쉽게 해준다. 매개 변수를 달리하여 여러개의 생성자를 만든다고 할 때 각 생성자의 목적이 불분명한 것과 대비된다.

  • 호출 시 새 인스턴스를 생성할 필요가 없다.
    일반적으로 생성자는 새로운 인스턴스를 생성한다. 하지만 불변 클래스의 경우 새로운 인스턴스를 생성하지 않고 기존 인스턴스를 캐싱 후 재활용하므로 생성자를 사용하면 매우 비효율적이다.

  • 하위 클래스를 반환할 수 있다.
    생성자를 사용하면 하위 클래스를 반환할 수 없다. 예컨데 Animal 클래스와 이를 상속하는 Dog, Hamster 클래스가 있다고 생각해보자. Animal() 생성자는 Animal만 리턴할 수 있지만 Static Factory Method를 사용하면 Dog이든 Hamster든 마음대로 리턴할 수 있다. 따라서 생성자에 비해 엄청난 유연성을 제공한다.

  • 함수가 하나의 하위 클래스만 반환하지 않아도 된다
    책에서는 EnumSet이 예시로 나오는데, 새로운 인스턴스를 만들 때 원소의 수에 따라 RegularEnumSet, JumboEnumSet 클래스가 반환된다고 한다. 더 중요한 것은 사용자가 어느 클래스가 반환되었는지 알 필요가 없고, 추후 이 구현법을 손쉽게 변경할 수 있다는 것이다.

  • 동적으로 반환할 객체를 로딩한다
    생성자와 달리 Static Factory Method는 컴파일 타임에 클래스가 존재하지 않아도 된다. 저자는 JDBC의 예를 들었는데 아직은 완전히 이해가 가지 않아 향후 보완할 예정.

  • 테코블: 형변환 시 용이하다
    스프링을 사용하다보면 DTO<->Entity간에 형변환이 자유로워야 하는데, 이 때 생성자를 사용하게 되면 한 객체의 내부 구현을 알아야한다(이걸 알아야 생성자 매개변수로 사용할테니까). 하지만 Static Factory Method는 이러한 부분을 효과적으로 추상화시켜준다.

반면 Static Factory Method는 아래 약점도 존재한다.

  • 상속을 하려면 public, protected 생성자가 필요하다
    생성자 없이(혹은 private 생성자가 있고) Static Factory Method만 가지고 있는, 클래스는 상속이 불가능하다. 이후 item 17, 18에 추가 내용이 나오는데, 단점으로만 생각하기는 어렵다고 한다.

  • 프로그래머가 찾기 어렵다
    API 문서에 생성자는 발견하기 쉬운 반면 Static Factory Method는 다소 찾기 어려운 특성이 있다.

결론은 웬만하면 Static Factory Method를 활용하자.

2. 빌더는 왜 사용하는가


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

스프링 클론 코딩을 한 적이 있는데, 빌더를 굉장히 애용하는 것을 보고 무슨 장점이 있을까 궁금했었다. 물론 롬복 빌더 어노테이션을 사용했기에 책의 예제처럼 복잡하게 구현할 필요는 없었다. 그럼 빌더는 왜 쓰는 것일까. 책에서는 이와 관련된 선택지를 다양하게 다루므로 이를 요약하도록 하겠다.

  • Telescoping Constructor Pattern
    길고 어려운 이름이지만 그냥 무식하게 목적에 따라 여러개의 생성자를 만드는 패턴을 일컫는다. 예컨데 다음과 같은 식이다.

    public Dog(int age, int height){...}
    public Dog(int age, int height, String owner){...}
    public Dog(int age, int height, String owner, int numFriends){...}

    물론 가장 긴 생성자를 사용하고 null, 0, ""를 넘겨줄 수 있지만 멤버 변수가 많다면 이 방식이 굉장히 노가다이다.
    생성자 매개변수가 많지 않을 때는 물론 이 패턴을 사용해도 아무 문제가 없지만 현업에서는 언제든 클래스가 확장될 수 있으므로 권장되지 않는다. 그냥 조슈아 블로크 선생님 말 듣자

  • Java Beans Pattern
    이 방법은 보다 진일보한 방식으로 일단 클래스를 만들고 Setter로 변수 값을 설정하여 코드의 가독성이 좋고 사용이 편리하다. 하지만 이러한 방식은 Setter가 여러번 실행되어야하기 때문에 인스턴스 생성 시점부터 마지막 Setter의 실행까지 일관성이 보장되지 않는다. 객체 일관성은 불변 클래스를 만들 때 중요한데, 위에서 말한 이유로 불변 클래스는 Java Beans Pattern으로 만들지 못한다. 더불어 스레드 안전성을 얻기 위해 추가 구현이 필수적이다(이때 freezeing 기법이 사용될 수 있지만 런타임 오류에 취약하다고 한다).

  • Builder Pattern
    일반적으로 빌더 패턴은 클래스 안에 Builder라는 정적 멤버 클래스를 생성하여 사용한다. Builder 클래스는 세터 함수들을 포함하고 있고, 각 세터 함수는 Set 후에 자기 자신을 리턴하기에 Setter 함수를 연쇄적으로 사용할 수 있다. 이후 build()라는 함수를 사용하면 Builder를 클래스 생성자의 매개변수로 넣고 클래스 인스턴스를 리턴한다. 설명이 미흡하지만 예제를 찾으면 쉽게 나오니 참고하면 된다.
    그럼 이게 Java Beans Pattern이랑 뭐가 다른거냐라고 한다면 당연히 이 방식은일관성을 보장한다. 특정 클래스를 만드는 과정에서 Builder는 Setter를 여러번 사용하기 때문에 일관성이 없지만 클래스는 초기화된 이후 일관성을 유지한다. 어찌 보면 Java Beans Pattern의 약점을 Builder에게 넘기는 셈이다.
    이와 더불어 Builder Pattern은 varargs(가변인수) 또한 사용하기 용이하다. 다만 위 방법보다는 미세한 overhead가 있다는 단점이 있지만 통상 서비스를 만들 때는 무조건 Builder Pattern이 Best Practice이다.

3. 싱글턴의 정의와 구현법


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

싱글턴을 만드는 여러 방법이 있고 책에서 여러 방법을 논하지만 결론은 Enum Type으로 클래스를 만드는 것이 월등히 좋으므로 다른 방법은 괜히 헷갈리니까 생략한다.
우선 싱글턴이 뭘까? 싱글턴은 오직 하나만 생성할 수 있는 클래스를 의미하는데, 예컨데 설계상 유일해야하는 시스템 컴포넌트는 싱글턴으로 구현해야한다. 구체적인 예로는 Database Connection Pool이 있다.
이 때 생성자를 private으로 하고 static final 인스턴스 하나만을 멤버 변수로 정의하는 방법이 있으나 직렬화-역직렬화할 때마다 새로운 인스턴스가 만들어져 싱글턴이 아니게 되어 이를 방지하는 추가 구현을 해주어야한다.
이 때 원소가 하나인 Enum Type으로 싱글턴을 만들면 별 노력 없이 싱글턴의 직렬화/역직렬화가 가능하다. 단 Enum 외의 클래스를 상속할 수 없다는 문제가 있기는 하다. 아래는 코드 예시이다.

public enum Elvis {
    INSTANCE; 			// 유일한 인스턴스
    
    void leaveTheBuilding() {} 	// 멤버 함수
}

TODO: 직렬화 추가 조사

4. 인스턴스화 막기


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

대충하는게 아니라 그냥 이 장이 엄청 짧다. 요지는 가끔 인스턴스화 되면 안되는 클래스(예로 Static Factory Method만 있는 클래스 - Static Utility Class라고 함)가 있는데, 이것이 인스턴스화 되지 않는 것을 어떻게 보장하느냐이다. 이때 Virtual Class로 만들어 인스턴스화를 막으려고 할 수 있는데 이는 상속 후 인스턴스와 하면 되므로 방지가 되지 않는다. 그렇다고 생성자를 만들지 않으면 컴파일러가 기본 생성자를 만들어 버린다. 따라서 private 생성자를 만들면 이 모든 것을 방지할 수 있다(public, protected 생성자의 부재는 상속도 막아버린다).

5. Dependency Injection


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

스프링 공부를 하면서 개인적으로 IoC, Dependency Injection의 개념이 잘 안잡혔었는데, 그도 그럴 것이 관련 글은 대부분 뜬구름 잡는 소리만 하고 추상적이다. 이번 장은 이 부분을 이해하는 것에 있어 상당히 많은 도움이 되었다.
Dependency Injection Pattern은 말그대로 인스턴스 생성시 의존 객체를 주입하는 방식으로, 정적 클래스나 싱글턴이 아닌 다른 자원에 따라 동작이 달라지는(즉 의존성이 있는) 클래스에 사용된다.
이미 책의 예시 코드는 많이 공유되어 있으니 간략하게 설명만 하겠다. 오타를 고쳐주는 SpellChecker 클래스가 있다고 하자. 이때 다양한 언어가 있고 각 언어를 Dictionary 형태로 저장하고 있다고 치자. 예로 Language의 하위 클래스에 English, Korean이 있다고 하자. 이 때 언어별 SpellChecker가 필요하고 SpellChecker의 멤버 변수 중에 Language가 있어, 주입하는 언어에 따라 해당 언어의 오타를 교정한다고 치자. 이때 SpellCheckerLanguage에 의존적이다. 이렇듯 의존 관계가 있을 때 Language 인스턴스를 먼저 만들고 이를 SpellChecker 생성자의 매개 변수로 넣어주는 것을 Dependency Injection이라고 한다. Spring도 이와 동일한 패턴, 즉 의존성이 없는 것부터 초기화하여 이에 의존하는 객체의 생성자에 넣어주는 방식으로 Dependency를 해결해주며, 이를 보다 Fancy한 용어로 Inversion Of Control이라고 부른다. 정확히 말하면 DI는 IoC의 일종이라고 한다. 머리가 아프니 오늘은 여기까지만 알아보자
막상 쓰고 보니 나도 뜬 구름 잡는 소리만 한것 같다.

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


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

해당 장은 조금 다양하게 불필요한 객체 생성에 대해 다루는데 이를 하나씩 짚어 보겠다. 더불어 여기 언급된 내용 뿐 아니라 전반적으로 객체 재상용에 대해 고민하도록 하는 장이니 보다 넓게 고민해야할 것 같다.

  • 불변 객체의 재사용
    이 부분은 Java "==", Equals와 Hashcode 글에서 다루었으므로 너무 길게 다루지 않겠다. 대표적으로 문자열의 경우 불변 객체이므로 한 한문자열에 대해 힙에 하나의 주소만 사용해야하며, 계속 새로운 인스턴스를 만든다면 굉장히 비효율적임을 설명한다. 자바 primitive type이나 String의 경우 auto-boxing 혹은 Factory Method으로 이를 보장할 수 있다.

  • 객체의 생성 비용
    개발을 할 때 항상 효율성을 생각해야하는데, 이 때 특정 함수, 클래스의 내부 구현을 고려해야할 때가 있다. 책에서는 Stringmatches() 함수로 예를 든다. matches() 함수는 Regex으로 문자열 형태를 확인하지만 이때 생성된 Pattern 인스턴스는 한번만 쓰이고 버려진다. 이때 함수는 매개변수로 주어진 Regex에 맞는 Finite State Machine(갓토마타)을 만들기에 매번 이 인스턴스를 만들고 폐기하면 비용이 너무 크다. 이럴 때는 직접 Pattern 인스턴스를 만들고 matcher() 함수로 재활용하는 것이 가장 효율적이다.
    다만 이 부분을 모든 객체 생성이 비싸다는 의미로 받아들이면 안된다. 일반적으로 요즘 JVM에서 객체를 생성하고 회수하는 것은 큰 부담이 되지 않으니 아주 무거운 객체가 아닌 다음에야 굳이 재사용을 위해 객체 풀을 만들거나 명확성, 간결성을 포기하면서까지 재사용을 하지 말자.

  • Auto-Boxing의 잘못된 예
    Auto-Boxing을 할 때 주의해야되는 점 중 하나는 Wrapper Class는 Auto Boxing 후 캐싱을 하더라도 새로운 값에 대해서는 계속 새로운 인스턴스를 만든다는 것이다. 책에서 나온 예는 다음과 같다.

    private static long sum(){
        Long sum = 0L;
        for(long i = 0; i <= Integer.MAX_VALUE; i++){
            sum+=i;
        }
        return sum;
    }

    이 때 sum을 long이 아닌 wrapper class Long으로 한 것에 대한 실행 속도 차이는 10배라고 한다(Long으로 했을 때 10배 느리다). Long에 long을 더하는 행위는 AutoBoxing이 발생하고 매번 기존 값보다 큰 값으로 sum이 바뀌므로(물론 기존 글에도 언급했듯 숫자 타입은 캐싱이 굉장히 한정적이지만) sum은 Integer.MAX_VALUE만큼의 새로운 인스턴스가 생긴다. 물론(이 경우는 식만 바꾸면 되긴 하지만) 부득이하게 많은 인스턴스를 생성해야될 때도 있지 않을까 생각한다. 하지만 primitive type으로만 할 수 있을 때는 무조건 Autoboxing을 피해야한다. 이는 굳이 서비스 개발 뿐 아니라 코딩 테스트에서도 유용하게 써먹을 것 같다.

7. 참조 해제가 필요한 경우


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

해당 장에는 Stack의 잘못된 구현에 대한 코드 예시가 나오고 설명에 중요하지만 글의 길이를 고려하여 생략하였으니 관련 자료를 찾아보시길 바랍니다.

"자바는 GC가 있는데?"라는 생각이 든다면 정상이다. 하지만 GC가 있어도 직접 메모리를 해제해줘야할 때가 있는데 이는 직접 메모리를 관리하는 객체가 있을 때 해당한다.
이를 설명하기 위해 저자는 Stack의 잘못된 구현 예시를 사용하는데, 요약하자면 Stack에Object[] 배열을 사용하고 pop()을 할 때 stack의 size(혹은 index)를 줄이고 해당 위치의 값을 null 처리하지 않으면 해당 값을 더이상 사용하지 않음에도 GC가 이를 찾지 못해 메모리 누수가 발생한다는 것이다. 따라서 이런 경우 직접 null 처리를 하여 GC에게 알려주어야 한다.
WeakHashMap을 활용해 효과적인 캐시를 만드는 내용도 언급되었으나 이 부분은 아직 와닿지 않아서 생략하겠다.

8. Finalizer/Cleaner vs AutoClosable


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

이번 장에서는 다소 큰 문제가 있었다. 아니 finalizer와 cleaner가 뭐냐고요 ㅠㅠ.

우선 Finalizer부터 보자. Finalizer 클래스는 주로 finalize() 메소드로 사용되지 않는 객체를 소멸하는 것에 사용된다. 하지만 Finalizer는 많은 단점이(아래 Cleaner와 함께 다루겠다) 있어 Java 9 부터는 Deprecated Class가 된다.

Cleaner는 Finalizer의 완벽한 대체제는 아니지만 GC를 통해 사용하지 않는 객체를 지운다는 점에서 유사하다.

Finalizer와 Cleaner를 사용하면 안되는 이유는 아래와 같다.

  • Finalizer와 Cleaner는 제때 실행되지 않는다.
    Finalizer와 Cleaner는 각각 스레드가 할당되는데, 해당 스레드는 Schedule 되어야 돌아가므로 바로 실행되지 않는다. 이 때 문제는 Finalizer의 스레드 우선순위가 통상 낮다는 것이다. 특히 제때 클래스가 소멸되지 않아 OutOfMemoryError가 나기도 하기에 Finalizer는 무조건 피해야한다.

  • 수행 여부를 보장하지 않는다
    Finalizer나 Cleaner나 빨리 실행되지 않는 것을 떠나 아예 실행이 되지 않을 수도 있다.

  • Finalizer는 Finalizer 공격에 취약하다
    이 부분은 어차피 finalizer를 쓰지 않을테니 생략하겠다.

이에 대한 해결책으로 AutoClosable이 있다. 클래스를 만들 때 implements AutoClosable을 해주면 객체를 다 쓴 후 close() 함수를 사용할 수 있다. 이는 앞선 Finalizer와 Cleaner보다 빠르고 확실하다.
다만 Finalizer와 달리 Cleaner는 보험용으로 사용해주는 것이 좋다. 이는 간혹 사용자가 close()를 하지 않을 것을 대비하는 것이다. 비효율적이더라도 클래스를 소멸하지 것보다야 났기 때문.

TODO: 향후 GC 관련하여 공부를 더 하고 다시 정리

9. try-finally vs try-with


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

굳이 메모리 때문이 아니더라도 꼭 닫아줘야하는 자원이 있다. 예로 InputStream, OutputStream, java.sql.Connection 등이 있다. 이러한 자원에 대해 Java 7 이전까지는 try-finally문만 제공되었는데, try-finally는 제대로 자원을 회수하도록 짜기 어렵고 중첩 try문의 경우 둘 다 예외가 발생해도 내부 예외만 출력된다는 문제가 있다. 이에 try-with-resource 형태를 사용해주는 것이 좋다. try-with-resource는 자원 사용 후 자동으로 close()를 해주고 예외 발생 시 모두 확인할 수 있다. close()를 사용하니 여기에 사용하는 클래스는 AutoClosable을 implement해줘야한다. 이 부분은 말로면 설명하기 힘드니 책의 예시를 추가하겠다.

좋지 않은 방법

static String firstLineOfFile(String path) throws IOException {
    BufferedReader br = new BufferedReader(new FileReader(path));
    try{
    	return br.readLine();
    } finally {
    	br.close();
    }
}

책에서 권장하는 방법

static String firstLineOfFile(String path) throws IOException {
    try(BufferedReader br = new BufferedReader(new FileReader(path))){
    	return br.readLine();
    } catch (IOException e) {
    	return dfaultVal;
    }
}

Java 9 이상에서의 처리 방법

static String firstLineOfFile(String path) throws IOException {
    BufferedReader br = new BufferedReader(new FileReader(path));
    try(br){
    	return br.readLine();
    } catch (IOException e) {
    	return dfaultVal;
    }
}

용어 정리


시그니처: 자바 메서드의 이름과 매개변수를 통틀어 시그니처라고 지칭한다
Immutable Class(불변 클래스): 한번 생성되면 변경할 수 없도록 보장된 클래스를 말한다.

출처

이펙티브 자바 3판
정적 팩토리 메서드는 왜 사용할까

profile
부족해도 부지런히

0개의 댓글