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

idkwhattodo·2023년 11월 30일
0

이펙티브자바

목록 보기
2/2
post-thumbnail

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

1-1. 이름을 가질 수 있다

  • 생성자에 넘기는 매개변수와 생성자 자체만으로는 반환될 객체의 특성을 제대로 설명하기 힘들지만, 정적 팩토리 메소드는 이름만 잘 지으면 반환될 객체의 특성을 쉽게 묘사할 수 있음
    • 생성자 BigInteger(int, int, Random)보다 정적 팩토리 메소드인 BigInteger.probablePrime이 값이 소수인 BigInteger를 반환한다는 의미를 더 잘 설명할 수 있음
  • 하나의 시그니처로는 생성자 하나만 만들 수 있지만, 이름을 가질 수 있는 정적 팩토리 메소드는 이런 제약이 없음

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

  • 불변 클래스(Immutable class)는 인스턴스를 미리 만들어 놓거나, 새로 생성한 인스턴스를 캐싱하여 재활용하는 식으로 불필요한 객체 생성을 피할 수 있음
    • 예를 들어, Boolean.valueOf(boolean) 메소드는 객체를 아예 생성하지 않음
  • 따라서 생성 비용이 큰 객체가 자주 요청되는 상황이라면, 정적 팩토리 메소드를 통해 성능을 끌어 올릴 수 있음

1-3. 반환 타입의 하위 타입 객체를 반환할 수 있는 능력이 있다

  • 정적 팩토리 메소드는 반환할 객체의 클래스를 자유롭게 선택할 수 있음
  • 이러한 유연성을 API를 만들 때 응용하면, 구현 클래스를 공개하지 않고도 객체를 반환할 수 있어 API를 작게 유지할 수 있음
  • 예를 들어, 자바 컬렉션 프레임 워크는 핵심 인터페이스들에 45개의 유틸리티 구현체를 제공하는데, 이 구현체 대부분을 단 하나의 인스턴스화 불가 클래스인 java.util.Collections에서 정적 팩토리 메소드를 통해 얻게 함

1-4. 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다

  • 반환 타입의 하위 타입이기만 하면, 어떤 클래스의 객체를 반환하든 상관 없음
  • 예를 들어, public 생성자 없이 오직 정적 팩토리만 제공하는 EnumSet 클래스는 원소의 갯수에 따라 다른 인스턴스를 반환하는데, 클라이언트는 반환되는 객체가 어느 클래스의 인스턴스인지 알 필요가 없음

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

  • 이러한 유연함은 서비스 제공자 프레임워크(ex. JDBC)를 만드는 근간이 됨
  • 서비스 제공자 프레임워크의 3가지 핵심 컴포넌트
    • 구현체의 동작을 정의하는 서비스 인터페이스
    • 제공자가 구현체를 등록할 때 사용하는 제공자 등록 API
    • 클라이언트가 서비스의 인스턴스를 얻을 때 사용하는 서비스 접근 API
  • 클라이언트는 서비스 접근 API를 사용할 때, 원하는 구현체의 조건을 명시할 수 있으며, 조건을 명시하지 않으면 기본 구현체를 반환하거나 지원하는 구현체들을 하나씩 돌아가며 반환함

1-6. 정적 팩토리 메소드의 단점

  • 상속을 하려면 public이나 protected 생성자가 필요하기 때문에, 정적 팩토리 메소드만 제공하면 하위 클래스를 만들 수 없음
  • 정적 팩토리 메소드는 프로그래머가 찾기 어려움
    • 생성자처럼 API 설명에 명확하게 드러나지 않기 때문에 사용자는 정적 팩토리 메소드 방식 클래스를 인스턴스화 할 방법을 알아내야 함

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

2-1. 빌더를 사용해야 하는 이유

  • 점층적 생성자 패턴 ⇒ 매개변수 갯수가 많아지면 클라이언트 코드를 작성하거나 읽기 어려움
  • 자바빈즈 패턴 ⇒ 객체 하나를 만드려면 메소드를 여러개 호출해야 하고, 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태에 놓이게 되며, 클래스를 불변으로 만들 수 없음
  • 빌더 패턴
    • STEP 1) 클라이언트가 필요한 객체를 직접 만드는 대신, 필수 매개변수만으로 생성자(혹은 정적 팩토리)를 호출해 빌더 객체를 얻음
    • STEP 2) 빌더 객체가 제공하는 일종의 세터 메소드들로 원하는 선택 매개변수들을 설정
    • STEP 3) 매개변수가 없는 build 메소드를 호출해 원하는 객체를 얻음

2-2. 빌더 패턴의 활용

public class NutritionFacts {
  private final int servingSize;
  private final int servings;
  private final int calories;
  private final int fat;

  public static class Builder {
    // 필수 매개변수
		private final int servingSize;
		private final int servings;

		// 선택 매개변수 -> 기본값으로 초기화
		private int calories = 0;
		private int fat = 0;

		public Builder(int servingSize, int servings) {
			this.servingSize = servingSize;
			this.servings = servings
		}

		public Builder calories(int val) {
			calories = val;
			return this;
		}

		public Builder fat(int val) {
			fat = val;
			return this;
		}
  }
}
  • 빌더의 세터 메소드들은 빌더 자신을 반환하기 때문에 아래와 같이 연쇄적으로 호출할 수 있음
    NutritionFacts coke = new NutritionFacts.Builder(240, 8).calories(100).build();
    
  • 빌더 패턴을 사용하면 잘못된 매개변수에 대한 유효성 검사 코드를 추가할 수 있음

2-3. 빌더 패턴과 계층적으로 설계된 클래스

public abstract class Pizza {
	public enum Topping { HAM, ONION, PEPPER }
	final Set<Topping> toppings;

	abstract static class Builder<T extends Builder<T>> {
		EnumSet<Topping> toppings = EnumSet.noneOf(Topping.class);
		public T addTopping(Topping topping) {
			toppings.add(Objects.requireNonNull(topping));
			return self();
		}

		abstract Pizza build();

		// 하위 클래스는 이 메소드를 overriding하여 this를 반환하도록 해야 함
		protected abstract T self();
	}

	Pizza(Builder<?> builder) {
		toppings = builder.toppings.clone();
	}
}
public class NyPizza extends Pizza {
	public enum Size { SMALL, MEDIUM, LARGE }
	private final Size size;

	public static class Builder extends Pizza.Builder<Builder> {
		private final Size size;

		public Builder(Size size) {
			this.size = Objects.requireNonNull(size);
		}

		@Override
		public NyPizza build() {
			return new NyPizza(this);
		}

		@Override
		protected Builder self() {
			return this;
		}
	}

	private NyPizza(Builder builder) {
		super(builder);
		size = builder.size;
	}
}
public class Calzone extends Pizza {
	private final boolean sauceInside;

	public static class Builder extends Pizza.Builder<Builder> {
		private boolean SauceInside = false; // 기본값

		public Builder sauceInside() {
			sauceInside = true;
			return this;
		}

		@Override
		public Calzone build() {
			return new Calzone(this);
		}

		@Override
		protected Builder self() {
			return this;
		}
	}

	private Calzone(Builder builder) {
		super(builder);
		sauceInside = builder.sauceInside;
	}
}
  • 각 하위 클래스의 빌더가 정의한 build 메소드는 해당하는 구체 하위 클래스를 반환하도록 선언
    • NyPizza.Builder는 NyPizza를 반환하고, Calzone.Builder는 Calzone를 반환
      NyPizza pizza = new NyPizza.Builder(SMALL).addTopping(ONION).build();
      Calzone calzone = new Calzone.Builder().addTopping(HAM).sauceInside().build();
      
  • 위와 같이 이용하면, 클라이언트가 형 변환에 신경쓰지 않고도 빌더를 유연하게 사용할 수 있음

2-4. 핵심 정리

  • 생성자나 정적 팩토리가 처리해야 할 매개변수가 많다면 빌더 패턴을 선택하자
  • 특히, 매개변수 중 다수가 필수가 아니거나 같은 타입이면 더더욱

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

3-1. 싱글톤을 만드는 방식

  • public static final 필드 방식
    public class Elvis {
      public static final Elvis INSTANCE = new Elvis();
    
      public void leaveTheBuilding() { ...}
    }
    
    • private 생성자는 public static final 필드인 Elvis.INSTANCE를 초기화할 때 딱 한번 호출되며, public이나 protected 생성자가 없으므로 Elvis 클래스의 인스턴스는 전체에서 단 하나뿐임이 보장됨
    • public static final 필드 방식의 장점은 해당 클래스가 싱글톤임이 API에 명확히 드러난다는 것
  • 정적 팩토리 방식
    public class Elvis {
      private static final Elvis INSTANCE = new Elvis();
      private Elvis() { ... }
      public static Elvis getInstance() {
        return INSTANCE;
      }
    
      public void leaveTheBuilding() { ... }
    }
    
    • Elvis.getInstance는 항상 같은 객체의 참조를 반환하므로, 제2의 Elvis 인스턴스는 만들어지지 않음
    • 정적 팩토리 방식의 장점
      • API를 바꾸지 않고도 싱글톤이 아니게 변경할 수 있음
      • 정적 팩토리를 제너릭 싱글톤 팩토리로 만들 수 있음
      • 정적 팩토리의 메소드 참조를 공급자로 사용할 수 있음 (Elvis::getInstance를 Supplier<Elivs>로 사용)
  • 열거 타입
    public enum Elvis {
      INSTANCE;
    
      public void leaveTheBuilding() { ... }
    }
    
    • public 필드 방식과 비슷하지만, 더 간결하고 추가 노력없이 직렬화 할 수 있으며, 복잡한 직렬화 상황이나 리플렉션 공격에도 제2의 인스턴스가 생기는 일을 막아줌
    • 대부분의 상황에서 원소가 하나뿐인 열거 타입이 싱글톤을 만드는 가장 좋은 방법
      • 다만, 만드려는 싱글톤이 Enum 외의 클래스를 상속해야 한다면 이 방법은 사용할 수 없음

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

  • 추상 클래스로 만드는 것으로는 인스턴스화를 막을 수 없음 (하위 클래스를 만들어 인스턴스화를 하면 되기 때문)
  • 컴파일러는 생성자를 명시하지 않으면 자동으로 기본 생성자를 만들어 주기 때문에, private 생성자를 추가하면 클래스의 인스턴스화를 막을 수 있음
public class UtilityClass {
  // 인스턴스화를 방지하기 위해 기본 생성자가 생기는 것을 막음
  private UtilityClass() {
    throw new AssertionError();
  }
}
  • 명시적 생성자가 private이기 때문에 클래스 바깥에서 접근할 수 없으며, 상속이 불가함(하위 클래스에서 상위 클래스의 생성자에 접근이 불가함)
  • 다만, 클래스 바깥에서도 안에서도 호출을 할 수 없는 생성자이기 때문에 직관적이지 않으므로 주석을 달아주는 것이 좋음

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

5-1. 잘못된 사용

  • 정적 유틸리티를 잘못 사용한 예
    public class spellChecker {
      private static final Lexicon dictionary= ...;
    
      private SpellChecker() {} // 객체 생성 방지
    
      public static boolean isValid(String word) { ... }
      public static List<String> suggestions(String typo) { ... }
    }
    
    • 유연하지 않고 테스트하기 어려움
  • 싱글톤을 잘못 사용한 예
    public class spellChecker {
      private static final Lexicon dictionary= ...;
    
      private SpellChecker(...) {}
      public static SpellChecker INSTANCE = new SpellChecker(...);
    
      public static boolean isValid(String word) { ... }
      public static List<String> suggestions(String typo) { ... }
    }
    
    • 유연하지 않고 테스트하기 어려움

5-2. 의존 객체 주입

  • 사용하는 자원에 따라 동작이 달라지는 클래스에는 정적 유틸리티 클래스나 싱글톤 방식은 적합하지 않으며, 인스턴스를 생성할 때 생성자에 필요한 자원을 넘겨주는 의존 객체 주입 방식이 적합함
    public class SpellChecker {
      private final Lexicon dictionary;
    
      public SpellChecker(Lexicon dictionary) {
        this.dictionary = Object.requireNonNull(dictionary);
      }
    
      public boolean isValid(String word) { ... }
      public List<String> suggestions(String typo) { ... }
    }
    
    • 위의 예시는 자원이 몇 개든, 의존 관계가 어떻든 상관없이 잘 동작하며, 불변을 보장하여 여러 클라이언트가 의존 객체들을 안심하고 공유할 수 있음
    • 또한, 의존 객체 주입은 생성자, 정적 팩토리, 빌더 모두에 똑같이 응용할 수 있음

5-3. 정리

  • 클래스가 내부적으로 하나 이상의 자원에 의존하고, 그 자원이 클래스 동작에 영향을 준다면 필요한 자원(혹은 그 자원을 만들어주는 팩토리)을 생성자(혹은 정적 팩토리 빌더)에 넘겨주는 의존 객체 주입을 사용하자
  • 의존 객체 주입은 클래스의 유연성, 재사용성, 테스트 용이성을 개선해줌

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

6-1. 불필요한 객체 생성을 피하고, 객체를 재사용해야 하는 이유

  • 똑같은 기능의 객체를 매번 생성하기보단 객체 하나를 재사용하는 것이 나을 때가 많음
    • String s = new String("apple"); => X
    • String s = "apple"; => O
  • 생성자 대신, 불변 클래스는 정적 팩토리 메소드를 사용해 불필요한 객체 생성을 피할 수 있음
    • Boolean(String) 생성자 대신 Boolean.valueOf(String) 팩토리 메소드 사용
    • 생성자는 호출할 때마다 새로운 객체를 만들지만, 정적 팩토리 메소드는 그렇지 않음
    • 불변 객체만이 아니라 가변 객체라 하더라도 사용 중에 변경이 되지 않을 것임을 안다면 재사용 할 수 있음

6-2. 생성 비용이 비싼 객체는 재사용하자

  • AS-IS
    static boolean isRomanNumeral(String s) {
      return s.matches("...");
    }
    
    • String.matches는 정규표현식으로 문자열 형태를 확인하는 가장 쉬운 방법이지만, 정규표현식용 Pattern 인스턴스는 한 번 쓰고 버려져 가비지 컬렉팅 대상이 되기 때문에 성능이 중요한 상황에서 반복해 사용하기엔 적합하지 않음
  • 개선
    public class RomanNumerals {
      private static final Pattern ROMAN = pattern.compile("...");
    
      static boolean isRomanNumeral(String s) {
        return ROMAN.matcher(s).matches();
      }
    }
    
    • 필요한 정규표현식을 표현하는, 불변인 Pattern 인스턴스를 클래스 초기화(정적 초기화) 과정에서 직접 캐싱해두고, 나중에 isRomanNumeral 메소드가 호출될 때마다 이 인스턴스를 재활용함

6-3. 불필요한 객체를 만들어 내는 경우 - 오토박싱

  • 오토박싱은 프로그래머가 기본 타입과 박싱된 타입을 섞어쓸 때, 상호 변환해주는 기술
  • 다만, 오토박싱은 기본 타입과 그에 대응하는 박싱된 기본 타입의 구분을 흐려주지만, 완전히 없애주는 것은 아님
private static long sum() {
  Long sum = 0L;
  for (long i = 0; i <= Integer.MAX_VALUE; i++) {
    sum += 1;
  }

  return sum;
}
  • 위의 예시에서 sum 변수를 long이 아닌 Long으로 선언하여 loop가 돌 때마다 Long 인스턴스가 만들어짐
  • 따라서, 박싱된 기본 타입보다는 그냥 기본 타입을 사용하고, 의도치 않은 오토박싱이 숨어들지 않도록 주의해야함

6-4. 객체 생성과 재사용의 고찰

  • 객체 생성은 피하고, 무조건 재활용해야 하는 것은 아님
    • 작은 객체를 생성하고 회수하는 일은 JVM에 크게 부담되는 것은 아니며, 오히려 이런 경우 프로그램의 명확성, 간결성, 기능을 위해 더 좋은 것일 수 있음
    • 다만, 커넥션 풀에서 DB 연결과 같은 경우는 생성 비용이 비싸기 때문에 재사용하는 편이 나음
  • "기존 객체를 재사용해야 한다면 새로운 객체를 만들지 마라"라는 개념보다 "새로운 객체를 만들어야 한다면 기존 객체를 재사용하지 마라"라는 방어적 복사의 개념에서 문제가 발생하면 피해가 더 큼
    • 방어적 복사가 필요한 상황에서 객체를 재사용하게 되면 버그와 보안에 문제가 되지만, 불필요한 객체 생성은 그저 코드 형태와 성능에만 영향을 줌

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

7-1. 다 쓴 객체로 인한 메모리 누수

public class Stack {
  private Object[] elements;
  private int size = 0;
  private static final int DEFAULT_INITIAL_CAPACITY = 16;

  public Stack() { ... }

  public void push(Object e) {
    ...
    elements[size++] = e;
  }

  public Object pop() {
    if (size == 0)
      throw new EmptyStackException();
    return elements[--size];
  }

  /**
   * 원소를 위한 공간을 적어도 하나 이상 확보
   * 배열 크기를 늘려야 할 때마다 두 배씩 늘림
   */
   private void ensureCapacity() {
     if (elements.length == size) {
       elements = Arrays.copyOf(elements, 2 * size + 1);
     }
   }
}
  • 자바는 가비지 컬렉터로 다 쓴 객체를 자동으로 회수해 가지만, 이 때문에 메모리 누수가 발생하는 지점이 있다면 발견하기 어려움
  • 위의 코드는 언뜻 보면 문제가 없어보이지만, 스택에 push 후 pop으로 스택에서 객체를 꺼냈을 때, 이 꺼내진 객체들을 다시 사용하지 않더라도 가비지 컬렉터가 회수하지 않음
    • 만일 이러한 객체 참조 하나를 살려두면, 가비지 컬렉터는 객체와 그 객체가 참조하는 모든 객체를 회수하지 못하기 때문에 잠재적으로 성능에 악영향을 줄 수 있음
    • 간단한 해법으로는 해당 참조를 다 썼을 때, null 처리(참조 해제) 하면 됨
      public Object pop() {
        if (size == 0) throw new EmptyStackException();
      
        Object result = elements[--size];
        elements[size] = null; // 참조 해제
      }
      
    • 다 쓴 참조를 null 처리를 하게되며 얻는 또 다른 장점은, null 처리한 참조를 실수로 사용하려하면 NullPointerException으로 프로그램을 바로 종료 시킬 수 있음
      • 참고로, 이렇게 프로그램 오류는 가능한 초기에 발견하는 것이 좋음
      • 또한, 객체 참조를 null 처리하는 일은 예외적인 경우여야 하기 때문에, 모든 객체를 다 쓰자마자 일일히 null 처리하는 것은 좋지 않음
    • 다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위(scope) 밖으로 밀어내는 것

7-2. 캐시로 인한 메모리 누수

  • 객체 참조를 캐시에 넣고 그 객체를 다 쓴뒤로도 한참을 그냥 나두는 일이 있는데, 이러한 상황에서 메모리 누수가 발생함
  • 엔트리가 살아있는 캐시가 필요한 상황에 WeakHashMap을 사용하면, 다 쓴 엔트리는 그 즉시 자동으로 삭제됨
  • 쓰지 않는 엔트리를 정리하는 방법은 백그라운드 스레드를 활용하거나 캐시에 새 엔트리를 추가할 때 부수 작업으로 수행하는 방법이 있음

7-3. 리스너 & 콜백에 인한 메모리 누수

  • 클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 뭔가 조치해주지 않는 한 콜백은 계속 쌓여 메모리 누수가 발생함

7-4. 정리

  • 메모리 누수는 겉으로 잘 드러나지 않아 시스템에 수년간 잠복하는 사례도 있는데, 이는 철저한 코드 리뷰나 힙 프로파일러 같은 디버깅 도구를 동원해야만 발견되기 때문에 예방법을 미리 익혀두는 것이 좋음

8. finalizer와 cleaner 사용을 피하라

8-1. 자바의 객체 소멸자

  • finalizer
    • 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요
    • 쓰임새가 몇가지 있기는 하지만, 기본적으로 사용해서는 안됨
  • cleaner
    • finalizer보다는 덜 위험하지만, 여전히 예측할 수 없고, 느리고, 불필요함
  • 자바에서는 try-with-resources와 try-finally를 사용해 해결하자 ⇒ 객체 생성을 막으려면 예외를 던지는 것만으로도 충분
    public class Adult {
    	public static void main(String[] args) {
    		try (Room myRoom = new Room(7)) {
    			System.out.println("Hello~");
    		}
    	}
    }

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

9-1. try-finally

  • 전통적으로 자원이 제대로 닫힘을 보장하는 수단으로 try-finally가 쓰였으나, 자원을 회수하는 최선의 방책이 아님
    static String firstLineOfFile(String Path) throws IOException {
    	BufferedReader br = new BufferedReader(new FileReader(path));
    	try {
    		return br.readLine();
    	} finally {
    		br.close();
    	}
    }
  • 아래와 같이 자원이 둘 이상이라면 try-finally 방식은 너무 지저분해짐
    static 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로 이동하기 때문에, 첫 번째로 예외가 발생한 부분을 확인하기 어려워 디버깅을 매우 어렵게 만듬

9-2. try-with-resources로 개선

static String firstLineOfFile(String path) throws IOException {
	try (BufferedReader br = new BufferedReader(new FileReader(path))) {
		return br.readLine();
	}
}
static 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);
	}
}
  • try-with-resources가 더 짧고 읽기 수월하며, 문제를 진단하기에도 훨씬 좋음

9-3. try-with-resources + catch

static String firstLineOfFile(String path, String defaultVal) {
	try (BufferedReader br = new BufferedReader(new FileReader(path))) {
		return br.readLine();
	} catch (IOException e) {
		return defaultVal;
	}
}
  • catch 절 덕분에 try문을 더 중첩하지 않고도 예외 처리가 가능해짐

9-4. 핵심 정리

  • 꼭 회수해야 하는 자원을 다룰때는 try-finall 말고 try-with-resources를 사용
  • 코드가 더 짧고 분명해지며, 만들어지는 예외 정보도 훨씬 유용함 (정확하고 쉽게 자원을 회수할 수 있음)
profile
공부겅부

0개의 댓글