9 minute read

2장 객체 생성과 파괴

2장은 객체를 만들어야 할 때와 만들지 말아야 할 때를 구분하는 법, 올바른 객체 생성 방법과 불필요한 생성을 피하는 방법 등을 알아본다.

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


클래스는 생성자와 별도로 정적 팩터리 메서드를 제공할 수 있다.

그 클래스의 인스턴스를 반환하는 단순한 정적 메서드 말이다.

// Bollean에서 발췌한 정적 팩터리 메서드의 예
public static Boolean valueOf(boolean b) {
	return b ? Boolean.TRUE : Boolean.FALSE;
}

장점

  1. 이름을 가질 수 있다.
    • 정적 팩토리는 이름만 잘 지으면 반환될 객체의 특성을 쉽게 묘사할 수 있다.
    • 한 클래스에 시그니처가 같은 생성자가 여러 개 필요할 것 같으면, 생성자를 정적 팩토리 메서드로 바꾸고 각각의 차이를 잘 드러내는 이름을 지어주자.
  2. 호출될 때마다 인스턴스를 새로 생성하지는 않아도 된다.
    • 불변 클래스는 인스턴스를 미리 만들어 놓거나 새로 생성한 인스턴스를 캐싱하여 재활용하는 식으로 불필요한 객체 생성을 피할 수 있다.
  3. 반환 타입의 하위 타입 객체를 반환할 수 있는 능력이 있다.
    • 반환할 객체의 클래스를 자유롭게 선택할 수 있게 하는 ‘엄청난 유연성’을 제공할 수 있다.
    • API를 만들 때 이 유연성을 응용하면 구현 클래스를 공개하지 않고도 그 객체를 반환할 수 있어 API를 작게 유지할 수 있다. 이는 인터페이스를 정적 팩터리 메서드의 반환 타입으로 사용하느 인터페이스 기반 프레임워크를 만드는핵심 기술이기도 하다.

    자바 컬렉션 프레임워크는 핵심 인터페이스들에 수정 불가나 동기화 등의 기능을 덧붙인 총 45개의 유틸리티 구현체를 제공한다.

  4. 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.
    • 반환 타입의 하위 타입이기만 하면 어떤 클래스의 객체를 반환하든 상관없다.
  5. 정적 팩토리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다.

단점

  1. 상속을 하려면 public이나 protected 생성자가 필요하니 정적 팩토리 메서드만 제공하면 하위 클래스를 만들 수 없다.
    • 오히려 이 제약은 상속보다 컴포지션을 사용하도록 유도하고 불변 타입으로 만들려면 이 제약을 지켜야 한다는 점에서 오히려 장점으로 받아들일 수도 있다.
  2. 정적 팩토리 메서드는 프로그래머가 찾기 어렵다.
    • 생성자처럼 API 설명에 명확히 드러나지 않으니 사용자는 정적 팩토리 메서드 방식 클래스를 인스턴스화할 방법을 알아내야 한다.

자주 쓰는 명명 규칙

  • form: 매개변수를 하나 받아서 해당 타입의 인스턴스를 반환하는 형변환 메서드
  • of: 여럴 매개 변수를 받아 적합한 타입의 인스턴스를 반환하는 집계 메서드
  • valueOf : from과 of의 더 자세한 버전
  • instance 혹은 getInstance : (매개변수를 받는다면) 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지는 않는다.
  • create 혹은 newInstance : 매번 새로운 인스턴스를 생성해 반환함을 보장한다.
  • getType : getInstance와 같으나, 생성할 클래스가 아닌 서로 다른 클래스에 팩토리 메서드를 정의할 때 쓴다.
  • newType: newInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩토리 메서드를 정의할 때 쓴다. “Type”은 팩토리 메서드가 반환할 객체의 타입이다.

정적 팩토리 메서드와 public 생성자는 각자의 쓰임새가 있으니 상대적인 장단점을 이해하고 사용하는 것이 좋다. 그렇다고 하더라도 정적 팩터리를 사용하는 게 유리한 경우가 더 많으므로 무작정 pubilc 생성자를 제공하던 습관이 있다면 고치자.

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


정적 팩터리와 생성자에는 똑같은 제약이 하나 있다. 선택적 매개변수가 많을 때 적절히 대응하기 어렵다는 것이다. 예전에는 점층적 생성자 패턴을 즐겨 사용했지만, 매개변수 개수가 많아지면 클라이언트 코드를 작성하거나 읽기 어렵기 때문에 지양한다.

두 번째 대안으로는 자바빈즈 패턴을 사용할 수 있지만, “자바빈즈 패턴에서는 객체 하나를 만들려면 메서드를 여러 개 호출해야 하고, 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태에 놓이게 된다.

이처럼 일관성이 무너지는 문제 때문에 자바빈즈 패턴에서는 클래스를 불변으로 만들 수 없으며 스레드 안전성을 얻으려면 프로그래머가 추가 작업을 해줘야만 한다.

세 번째 대안으로 점층적 생성자 패턴의 안전성과 자바빈즈 패턴의 가독성을 갖춘 빌더 패턴을 사용할 수 있다.

클라이언트는 필요한 객체를 직접 만드는 대신, 필수 매개변수만으로 생성자를 호출해 빌더 객체를 얻는다. 그런 다음 빌더 객체가 제공하는 일종의 세터 메서드들로 원하는 선택 매개변수들을 설정하낟. 마지막으로 매개변수가 없는 build 메서드를 호출해 필용한 객체를 얻는다.

빌더 패턴이 장점만 있는 것은 아니다. 객체를 만들려면, 그에 앞서 빌더부터 만들어야 한다. 빌더 생성 비용이 크지는 않지만, 성능에 민감한 상황에서는 문제가 될 수 있다. 점층적 생성사 패턴보다는 코드가 장황해서 매개변수가 4개 이상은 되어야 값어치를 한다.

생성자나 정적 팩터리가 처리해야 할 매개변수가 많다면 빌더 패턴을 선택하는 게 더 낫다. 매개변수 중 다수가 필수가 아니거나 같은 타입이면 특히 더 그렇다. 빌더는 점층적 생성자보다 클라이언트 코드를 읽고 쓰기가 훨씬 간결하고, 자바빈즈보다 훨씬 안전하다.

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

클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기가 어려워질 수 있다.

타입을 인터페이스로 정의한 다음 그 인터페이스를 구현해서 만든 싱글턴이 아니라면 싱글턴 인스턴스를 가짜 구현으로 대체할 수 없기 때문이다.

싱글턴을 만드는 방식은 보통 둘 중 하나다. 두 방식 모두 생성자는 private으로 감춰두고, 유일한 인스턴스에 접근할 수 있는 수단으로 public static 멤버를 하나 마련해둔다.

리플렉션을 이용해 private 생성자를 호출할 수 있다. 이러한 공격을 방어하려며 생성자를 수정하여 두 번째 객체가 생성되려 할 때 예외를 던지게 하면 된다.

public class Elvis {
	public static final Elvis INSTANCE = new Elvis();
	private Elvis(){}
	public void leavetheBuilding() {}
  • public 필드의 장점은 해당 클래스가 싱글턴임이 API에 명백히 드러난다는 점이다. public static 필드가 final이니 절대로 다른 객체를 참조할 수 없다. 두 번째 장점은 간결함이다.
public class Elvis {
	public static final Elvis INSTANCE = new Elvis();
	private Elvis(){}
	public static Elvis getInstance() {return INSTANCE;}

  • 정적 팩터리 방식의 첫 번째 장점은 API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있다는 점이다. 유일한 인스턴스를 반환하던 팩터리 메서드가 (예를 들면) 호출하는 스레드별로 다른 인스턴스를 넘겨주게 할 수 있다.
  • 두 번째 장점은 원한다면 정적 팩토리를 제네릭 싱글톤 팩토리를 만들 수 있다는 점이다.
  • 세 번재 장점은 정적 팩토리의 메서들 참ㅈ모를 공급자(supplier)로 사용할 수 있다는 점이다. 가령 Elvis::getinstance를 Supplier로 사용하는 식이다ㅏ.

둘 중 하나의 방식으로 만든 싱글턴 클래스를 직렬화하려면 단순히 Serializable을 구현한다고 선언하는 것만으로는 부족하다. 모든 인스턴스 필드를 일시적으로(transient)이라고 선언하고 readResolve 메서드를 제공해야 한다.

이렇게 하지 않으면 직렬화된 인스턴스를 역직렬화할 때마다 새로운 인스턴스가 만들어진다.

싱글톤 방식을 만드는 세 번째 방법은 원소가 하나인 열거 타입을 선언하는 것이다.

public enum Elvis{
	INSTANCE;

	public void leaveTheBuilding() {}
}

public 필드 방식과 비슷하지만, 더 간결하고, 추가 노력 없이 직렬화할 수 있고, 심지어 아주 복잡한 직렬화 상황이나 리플렉션 공격에서도 제2의 인스턴스가 생기는 일을 완벽히 막아준다.

대부분 상황에서는 원소가 하나뿐인 열거 타입이 싱글턴을 만드는 가장 좋은 방법이다.

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

정적 멤버만 담은 유틸리티 클래스는 인스턴스로 만들어 쓰려고 설계한 게 아니다. 하지만 생성자를 명시하지 않으면 컴파일러가 자동으로 기본 생성자를 만들어준다.

실제로 공개된 API 들에서도 이처럼 의도치 않게 인스턴스화할 수 있게 된 클래스가 종종 목격되곤 한다.

추상 클래스로 만드는 것으로는 인스턴스화를 막을 수 없다.

  • 하위 클래스를 만들어 인스턴스화하면 그만이다.
  • 이를 본 사용자는 상속해서 쓰라는 뜻으로 오해할 수 있으니 더 큰 문제다.

다행히고 인스턴스화를 막는 방법은 아주 간단하다. 컴파일러가 기본 생성자를 만드는 경우는 오직 명시된 생성자가 없을 때뿐이니 private 생성자를 추가하면 클래스의 인스턴스화를 막을 수 있다.

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

클래스가 내부적으로 하나 이상의 자원에 의존하고, 그 자원이 클래스 동작에 영향을 준다면 싱글턴과 정적 유틸리티 클래스는 사용하지 않는 것이 좋다. 이 자원들을 클래스가 직접 만들게 해서도 안 된다. 대신 필요한 자원을 (혹은 그 자원을 만들어주는 팩토리를) 생성자에 (혹은 정적 팩터리나 빌더에) 넘겨주자. 의존 객체 주입이라 하는 이 기법은 클래스의 유연성, 재사용성, 테스트 용이성을 기막히게 개선해준다.

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

똑같은 기능의 객체를 매번 생성하기보다는 객체 하나를 재사용하는 편이 나을 때가 많다. 재사용은 빠르고 세련되다. 특히 불변 객체는 언제는 재사용할 수 있다.

다음 코드는 하지 말아야 할 극단적인 예다 String s = new String(”bikini”); 이 문장은 실행될 때마다 String 인스턴스를 새로 만든다.

String s = “bikini” 이 코드는 새로우 인스턴스를 매번 만드는 대신 하나의 String 인스턴스를 사용한다. 나아가 이 방식을 사용한다면 같은 가상 머신 안에서 이와 똑같은 문자열 리터럴을 사용하는 모든 코드가 같은 객체를 재사용함이 보장된다.

생성자 대신 정적 팩토리 메서드를 제공하는 불변 클래스에서는 정적 팩토리 메서드를 사용해 불필요한 객체 생성을 피할 수 있다. 예컨데 Boolean(String) 생성자 대신 Boolean.valueOf(String) 팩토리 메서드를 사용하는 것이 좋다.

다음 예로는 String.matches는 정규표현식으로 문자열 형태를 확인하는 가장 쉬운 방법이지만, 성능이 중요한 상황에서는 반복해 사용하기엔 적합하지 않다. 이 메서드가 내부에서 만드는 정규표현식용 Pattern 인스턴스는 한 번 쓰고 버려져서 곧바로 GC의 대상이 된다.

성능을 개선하려면 필요한 정규표현식을 표현다는 Pattern 인스턴스를 클래스 초기화 과정에서 직접 생성해 캐싱해두고, 나중에 해당 인스턴스를 재사용한다.

오토박싱은 기본 타입과 그에 대응하는 박싱된 기본 타입의 구분을 흐려주지만, 완전히 없애주는 것은 아니다. 의미상으로는 별다를 것 없지만 성능에서는 그렇지 않다.

박싱된 기본 타입보다는 기본 타입을 사용하고, 의도치 않은 오토박싱이 숨어들지 않도로 주의하자.

이번 아이템을 ‘객체 생성은 비싸니 피해야 한다’로 오해하면 안 된다. 특히나 요즘의 JVM에서는 별다른 일을 하지 않는 작은 객체를 생성하고 회수하는 일이 크게 부담되지는 않는다. 프로그램의 명확성, 간결성, 기능을 위해서 객체를 추가로 생성하는 것이라면 일반적으로는 좋은 일이다.

거꾸로, 아주 무거운 객체가 아닌 다음에야 단순히 객체 생성을 피하고자 여러분만의 객체 풀을 만들지는 말자. 물론 객체 풀을 만드는 게 나은 예가 있긴 하다. 일반적으로는 자체 풀은 코드를 헷갈리게 만들고 메모리 사용량을ㅇ 늘리고 성능을 떨어뜨린다. 요즘JVM의 GC는 상당히 잘 최적화되어서 가변운 객체용을 다룰 때는 직접 만든 객체 풀보다 훨씬 빠르다.

방아적 복사가 필용한 상황에서 객체를 사용했을 때의 피해가, 필요 없는 객체를 생성했을 때의 피해보다 훨씬 크다는 사실을 기억하자.

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

GC를 갖춘 언어로 넘어오면 프록래머의 삶이 훨씬 평안해진다. 그래서 자칫 메모리 관리에 더 이상 신경 쓰지 않아도 된다고 오해할 수 있는데, 절대 사실이 아니다.

public Object pop() {
	return elements[--size];
}
  • 위의 코드에서는 스택이 커졌다가 줄어들었을 때 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않는다. 프로그램에서 그 객체를 더 이상 사용하지 않더라도 말이다.

이 스택이 그 객체들의 다 쓴 참조를 여전히 가지고 있기 때문이다. 여기서 다 쓴 참조란 문자 그대로 앞으로 다시 쓰지 않을 참조를 뜻한다.

객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐 아니라 그 객체가 참조하는 모든 객체를 회수해가지 못한다. 그래서 단 몇 개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고 잠재적으로 성능에 악영향을 줄 수 있다.

해법은 간단하다. 해당 참조를 다 썼을 때 null처리하면 된다.

다 쓴 참조를 null 처리하면 다른 이점도 따라온다. 만약 null 처리한 참조를 실수로 사용하려 하면 프로그램을 즉시 NullPointerExcpetion을 던지며 종료된다.

객체 참조를 null 처리하는 일은 예외적인 경우여야 한다. 다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위 밖으로 밀어내는 것이다. 변수의 범위를 최소가 되게 정의했다면 자연스럽게 이뤄진다.

Stack은 왜 메모리 누수에 취약한 걸까? 바로 스택이 자기 메모리를 직접 관리하기 때문이다. 일반적으로 자기 메모리를 직접 관리하는 클래스라면 프로그래머는 항시 메모리 누수에 주의해야 한다.

메모리 누수는 겉으로 잘 드러나지 않아 시스템에 수년간 잠복하는 사례도 있다. 이런 누수는 철저한 코드 리뷰나 힙 프로파일러 같은 디버깅 도구를 동원해야만 발견되기도 한다. 그래서 이런 종류의 문제는 예방법을 익혀두는 것이 매우 중요하다.

finalizer와 cleaner 사용을 피하라.

  • finalizer는 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요하다.
    • 기본적으로 쓰지 말아야 한다.
  • cleaner는 finalizer 보다는 덜 위험하지만, 여전히 예측할 수 없고, 느리고, 일반적으로 불필요하다.
  • finalizer와 cleaner로는 제때 실행되어야 하는 작업은 절대 할 수 없다.
    • 언제 실행될지는 GC 알고리즘에 달려있으며, 이는 GC 구현마다 천차만별이다.
  • 따라서 프로그램 생애주기와 상관없는, 상태를 영구적으로 수정하는 작업에서는 절대 finalier나 cleaner에 의존해서는 안 된다.
  • final이 아닌 클래스를 finalizer 공격으로부터 방어하려면 아무 일도 하지 않는 finalize 메서드를 만ㄷ르고 final로 선언하자.

그렇다면 파일이나 스레드 등 종료해야 할 자원을 담고 있는 객체의 클래스에서 finalizer나 cleaner를 대신해줄 묘안은 무엇일까? 그저 AutoCloseable을 구현해주고, 인스턴스를 다 쓰고 나면 close 메서드를 호출하면 된다.

(with try-with-resources)

cleaner는 안전망 역할이나 중요하지 않은 네이티브 회수용으로만 사용하자. 물론 이런 경우라도 불확실성과 성능 저하에 주의해야 한다.

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

자바 라이브러리에는 close 메서드를 호출해 직접 닫아줘야 하는 자원이 많다.

자원 닫기는 클라이언트가 놓치기 쉬워서 예측할 수 없는 성능 문제로 이어지기도 한다

try-with-resources 구조를 사용하려면 해당 자원이 AutoCloseable 인터페이스를 구현해야 한다.

단순히 void를 반환하는 close메서드 하나만 덩그러니 정의한 인터페이스다.

static void copy(String src, String dst) throws IOE{
        try(InputStream in=new FileInputStream(src)
        OutputStream out=new FileOutputStream(dst)){
        }
}

try-with-resources 버전이 짧고 읽기 수월할 뿐 아니라 문제를 진단하기도 훨씬 좋다.

꼭 회수해야 하는 자원을 다룰 때는 try-finally 말고, try-with-resources를 사용하자. 예외는 없다. 코드는 더 짧고 분명해지고, 만들어지는 예외 정보도 훨씬 유용하다. try-finally로 작성하면 실용적이지 못할 만큼 코드가 지저분해지는 경우라도, try-with-resources로는 정확하고 쉽게 자원을 회수할 수 있다.