✔️ 객체 생성과 파괴
객체를 만들어야 할 때와 만들지 말아야 할 때를 구분하는 법,
올바른 객체 생성 방법과 불필요한 생성을 피하는 방법,
제때 파괴됨을 보장하고 파괴 전에 수행해야 할 정리 작업을 관리하는 요령을 알아보자.
01. 생성자 대신 정적 팩터리 메서드를 고려하라
- 클라이언트가 클래스의 인스턴스를 얻는 전통적인 수단은 public 생성자다.
반면 클래스는 생성자와 별도로 정적 팩터리 메서드를 제공할 수 있다.
// 정적 팩터리 메서드
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}
- 정적 팩터리 메서드와 public 생성자는 각자의 쓰임새가 있으니 상대적인 장단점을 이해하고 사용하는 것이 좋다.
- 정적 팩터리 메서드의 장점
- 이름을 가질 수 있어 반환될 객체의 특성을 쉽게 묘사할 수 있다.
시그니처가 같은 생성자가 여러 개 필요할 것 같으면, 생성자를 정적 팩터리 메서드로 바꾸고 각각의 이름을 지어주자. - 호출될 때마다 인스턴스를 새로 생성하지 않아도 된다.
인스턴스를 미리 만들어 놓거나 새로 생성한 인스턴스를 캐싱하여 재활용하는 식으로 불필요한 객체 생성을 피할 수 있고
인스턴스를 통제할 수 있어 인스턴스가 단 하나뿐임을 보장할 수도 있다. - 반환 타입의 하위 타입 객체를 반환할 수 있는 능력이 있다.
반환할 객체의 클래스를 자유롭게 선택할 수 있게 하는 엄청난 유연성이 생기므로
구현 클래스를 공개하지 않고도 그 객체를 반환할 수 있어 API를 작게 유지할 수 있다. - 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.
반환 타입의 하위 타입이기만 하면 어떤 클래스의 객체를 반환하든 상관없다. - 정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다.
이런 유연함을 서비스 제공자 프레임워크를 만드는 근간이 된다.
클라이언트가 서비스의 인스턴스를 얻을 때 사용하는 서비스 접근 API에는 원하는 구현체의 조건을 명시할 수 있으며
조건을 명시하지 않으면 기본 구현체를 반환하거나 지원하는 구현체들을 하나씩 돌아가며 반환할 수 있게 한다.
- 이름을 가질 수 있어 반환될 객체의 특성을 쉽게 묘사할 수 있다.
- 정적 팩터리 메서드의 단점
- 상속을 하려면 public이나 protected 생성자가 필요하니 정적 팩터리 메서드만 제공하면 하위 클래스를 만들 수 없다.
- 정적 팩터리 메서드는 프로그래머가 찾기 어렵다.
생성자처럼 API 설명에 명확히 드러나지 않으므로 인스턴스화할 방법을 사용자가 알아내거나
API 문서를 잘 써넣고 메서드 이름도 널리 알려진 규약에 따라 짓는 식으로 문제를 완화해줘야 한다.
- 상속을 하려면 public이나 protected 생성자가 필요하니 정적 팩터리 메서드만 제공하면 하위 클래스를 만들 수 없다.
// 정적 팩터리 메서드에 흔히 사용하는 명명 방식들
- from : 매개변수를 하나 받아서 해당 타입의 인스턴스를 반환하는 형변환 메서드
- of : 여러 매개변수를 받아 적절한 타입의 인스턴스를 반환하는 집계 메서드
- valueOf : from과 of의 더 자세한 버전
- instance (getInstance) : 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지 않음
- create (newInstance) : getInstance와 같지만, 매번 새로운 인스턴스를 생성해 반환함을 보장함
- getType : getInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 펙토리 메서드를 정의할 때 사용
이때 Type은 팩터리 메서드가 반환할 객체의 타입 (list, bufferedReader, fileStore 등)
- newType : newInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 펙토리 메서드를 정의할 때 사용
- type : getType과 newType의 간결한 버전
02. 생성자에 매개변수가 많다면 빌더를 고려하라
- 매개변수의 수를 달리하는 방식인 점층적 생성자 패턴은 매개변수 개수가 많아지면 클라이언트 코드를 작성하거나 읽기 어렵다.
- 매개변수가 없는 생성자로 객체를 만든 후, 세터 메서드들을 호출해 원하는 매개변수의 값을 설정하는 방식인 자바빈즈 패턴은
객체 하나를 만들려면 메서드를 여러 개 호출해야 하고, 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태에 놓이게 된다.
이로 인해 생성이 끝난 객체를 수동으로 얼리고 얼리기 전에는 사용할 수 없도록 하기도 한다. - 반면 점층적 생성자 패턴의 안정성과 자바빈즈 패턴의 가독성을 겸비한 빌더 패턴은
클라이언트가 필요한 객체를 직접 만드는 대신, 필수 매개변수만으로 생성자를 호출해 빌더 객체를 얻는다.
그런 다음 빌더 객체가 제공하는 일종의 세터 메서드들로 원하는 선택 매개변수들을 설정한다.
마지막으로 매개변수가 없는 build 메서드를 호출해 필요한 객체를 얻게 된다.
// 매개변수가 6개인 식품 포장의 영양정보를 표현하는 클래스
public class NutritionFacts {
private final int servingSize;
private final int servings;
private final int calories;
private final int fat;
private final int sodium;
private final int carbohydrate;
public static class Builder {
// 필수 매개변수
private final int servingSize;
private final int servings;
// 선택 매개변수 - 기본값으로 초기화한다.
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 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;
}
public Builder sodium(int val) {
sodium = val;
return this;
}
public Builder carbohydrate(int val) {
carbohydrate = val;
return this;
}
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
private NutritionFacts(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
}
public class Test {
public static void main(String[] args) {
NutritionFacts cocaCola = new NutritionFacts
.Builder(240, 8) // 필수 매개변수만으로 생성자를 호출해 빌더 객체를 얻는다.
.calories(100) // 그런 다음 빌더 객체가 제공하는 일종의 세터 메서드들로 원하는 선택 매개변수들을 설정한다.
.sodium(35)
.carbohydrate(27)
.build(); // 마지막으로 매개변수가 없는 build 메서드를 호출해 필요한 객체를 얻게 된다.
}
}
- 빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기도 좋다.
각 계층의 클래스에 관련된 빌더를 멤버로 정의할 수 있다. 추상 클래스는 추상 빌더를, 구체 클래스는 구체 빌더를 갖게 한다.
// 피자 (추상 클래스)
public abstract class Pizza {
public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE }
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;
}
@Override
public String toString() {
return toppings + "로 토핑한 뉴욕 피자";
}
}
public class Test {
public static void main(String[] args) {
NyPizza pizza = new NyPizza
.Builder(SMALL)
.addTopping(SAUSAGE)
.addTopping(ONION)
.build();
}
}
- 또한 빌더 하나로 여러 객체를 순회하면서 만들 수 있고, 빌더에 넘기는 매개변수에 따라 다른 객체를 만들 수도 있다.
- 반면 객체를 만들려면 그에 앞서 빌더부터 만들어야 하며 코드가 장황하므로 매개변수가 4개 이상은 되어야 값어치를 한다.
03. private 생성자나 열거 타입으로 싱글턴임을 보증하라
- 싱글턴이란 인스턴스를 오직 하나만 생성할 수 있는 클래스를 말한다.
- 싱글턴을 만드는 방식은 셋이다.
- 첫 번째 방식인 public static 멤버가 final 필드인 방식은 private 생성자가 인스턴스를 초기화할 때 딱 한 번만 호출된다.
생성자는 private으로 감춰두고, 유일한 인스턴스에 접근할 수 있는 수단으로 public static 멤버를 마련해둔다.
public이나 protected 생성자가 없으므로 클래스 초기화 시 만들어진 인스턴스가 전체 시스템에서 하나뿐임이 보장된다.
이 방법은 해당 클래스가 싱글턴임이 API에 명백히 드러나므로 절대로 다른 객체를 참조할 수 없고 간결하다. - 두 번째 방식에서는 정적 팩터리 메서드를 public static 멤버로 제공한다.
생성자는 private으로 감춰두고, 유일한 인스턴스에 접근할 수 있는 수단으로 public static 멤버를 마련해둔다.
정적 팩터리 메서드는 항상 같은 객체의 참조를 반환하므로 제2의 인스턴스란 결코 만들어지지 않는다.
이 방법은 API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있어 호출하는 스레드별로 다른 인스턴스를 넘겨주게 할 수 있다.
또한 원한다면 정적 팩터리를 제네릭 싱글턴 팩터리로 만들 수도 있다. - 세 번째 방법은 원소가 하나인 열거 타입을 선언하는 것이다.
위 두 방법과 달리 제2의 인스턴스가 생기는 일을 완벽히 막아준다.
대부분 상황에서는 원소가 하나 뿐인 열거 타입이 싱글턴을 만드는 가장 좋은 방법이지만
만들려는 싱글턴이 Enum 외의 클래스를 상속해야 한다면 이 방법은 사용할 수 없다.
- 첫 번째 방식인 public static 멤버가 final 필드인 방식은 private 생성자가 인스턴스를 초기화할 때 딱 한 번만 호출된다.
// public static final 필드 방식의 싱글턴
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {
}
}
public class Test {
public static void main(String[] args) {
Elvis elvis = Elvis.INSTANCE;
}
}
// 정적 팩터리 방식의 싱글턴
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() {
}
public static Elvis getInstance() {
return INSTANCE;
}
}
public class Test {
public static void main(String[] args) {
Elvis elvis = Elvis.getInstance();
}
}
// 열거 타입 방식의 싱글턴
public enum Elvis {
INSTANCE;
}
public class Test {
public static void main(String[] args) {
Elvis elvis = Elvis.INSTANCE;
}
}
04. 인스턴스화를 막으려거든 private 생성자를 사용하라
- 단순히 정적 메서드와 정적 필드만을 담은 클래스를 만들고 싶을 때가 있다.
예) 기본 타입 값이나 배열 관련 메서드들을 모아놓은 Math, Arrays 클래스 등 - 정적 멤버만 담은 유틸리티 클래스는 인스턴스로 만들어 쓰려고 설계한 게 아니지만
생성자를 명시하지 않으면 컴파일러가 자동으로 기본 생성자를 만들어준다. - 이를 막기 위해 추상 클래스로 만들게 되더라도 하위 클래스를 만들어 인스턴스화할 수 있게 된다.
그리하여 사용자는 이 생성자가 자동 생성된 것인지 알 수 없어 문제이다. - 그러므로 컴파일러가 기본 생성자를 만드는 경우는 오직 명시된 생성자가 없을 때 뿐이므로
private 생성자를 추가해 클래스의 인스턴스화를 막도록 하며 직관적으로 나타내기 위해 적절한 주석을 달아두도록 한다.
// 인스턴스를 만들 수 없는 유틸리티 클래스
public class UtilityClass {
// 기본 생성자가 만들어지는 것을 막는다 (인스턴스화 방지용)
private UtilityClass() {
throw new AssertionError(); // 클래스 안에서 실수로라도 생성자를 호출하지 않도록 해준다.
}
}
05. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라
- 많은 클래스가 하나 이상의 자원에 의존한다.
- 가령 맞춤법 검사기는 사전에 의존하는데, 이런 클래스를 정적 유틸리티 클래스 또는 싱글턴으로 구현하는 경우가 흔하다.
하지만 두 방식 모두 사전을 단 하나만 사용한다고 가정한다는 점에서 그리 훌륭해 보이지 않다.
실전에서는 사전이 언어별로 따로 있고 특수 어휘용 사전을 별도로 두기도 한다.
// 정적 유틸리티를 잘못 사용한 예
public class SpellChecker {
private static final Lexicon dictionary = ...;
private SpellChecker { // 객체 생성 방지
}
public static boolean isValid(String word) {
...
}
public static List<String> suggestions(Stirng typo) {
...
}
}
// 싱글턴을 잘못 사용한 예
public class SpellChecker {
private final Lexicon dictionary = ...;
private SpellChecker(...) {
}
public static SpellChecker INSTANCE = new SpellChecker(...);
public static boolean isValid(String word) {
...
}
public static List<String> suggestions(Stirng typo) {
...
}
}
- 여러 사전을 사용할 수 있도록 하기 위해 final 한정자를 제거하고 다른 사전으로 교체하는 메서드를 추가할 경우
오류를 내기 쉬우며 멀티스레드 환경에서는 쓸 수 없으므로
사용하는 자원에 따라 달라지는 클래스에는 정적 유틸리티 클래스나 싱글턴 방식이 적합하지 않다. - 이를 위해 의존 객체 주입의 한 형태로, 인스턴스를 생성할 때 생성자에 필요한 자원을 넘겨주는 방식이 있다.
이는 맞춤법 검사기를 생성할 때 의존 객체인 사전을 주입해주면 된다.
자원이 몇 개든 의존 관계가 어떻든 상관없이 잘 동작하며 불변을 보장하여
여러 클래스가 의존 객체들을 안심하고 공유할 수 있기도 하다.
// 의존 객체 주입
public class SpellChecker {
private final Lexicon dictionary;
public SpellChecker(Lexicon dictionary) { // 인스턴스를 생성할 때 생성자에 필요한 자원을 넘겨줌
this.dictionary = Objects.requreNonNull(dictionary);
}
public static boolean isValid(String word) {
...
}
public static List<String> suggestions(Stirng typo) {
...
}
}
- 이 패턴의 변형으로는 생성자에 자원 팩터리를 넘겨주는 방식이 있다.
호출할 때마다 특정 타입의 인스턴스를 반복해서 만들어주는 객체인 자원 팩터리로 팩터리 메서드 패턴을 구현할 수 있다. - 의존 객체 주입이 유연성, 재사용성, 테스트 용이성을 개선해주긴 하지만,
의존성이 수천 개나 되는 큰 프로젝트에서는 코드를 어지럽게 만들기도 하므로
스프링 같은 의존 객체 주입 프레임워크를 사용하면 이런 어질러짐을 해소할 수 있다.
06. 불필요한 객체 생성을 피해라
- 똑같은 기능의 객체를 매번 생성하기보다는 객체 하나를 재사용하는 편이 나을 때가 많다.
특히 불변 객체는 언제든 재사용할 수 있다.
// 문장이 실행될 때마다 String 인스턴스를 새로 만듦
String s = new String("bikini");
// 문장이 실행될 때마다 하나의 String 인스턴스를 사용
String s = "bikini";
- 생성자 대신 정적 팩터리 메서드를 제공하는 불변 클래스에서는 이를 사용해 불필요한 객체 생성을 피할 수 있다.
생성자는 호출할 때마다 새로운 객체를 만들지만, 팩터리 메서드는 전혀 그렇지 않다.
불변 객체만이 아니라 가변 객체라 해도 사용 중에 변경되지 않을 것임을 안다면 재사용할 수 있다. - 특히 생성 비용이 아주 비싼 객체도 더러 있다. 이런 비싼 객체가 반복해서 필요하다면 캐싱하여 재사용하길 권한다.
// 값비싼 객체인 Pattern 인스턴스
public class RomanNumerals {
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();
}
}
- 뿐만 아니라 오토박싱은 기본 타입과 그에 대응하는 박싱된 기본 타입의 구분을 흐려주지만, 완전히 없애주는 것은 아니므로
박싱된 기본 타입보다는 기본 타입을 사용하고, 의도치 않은 오토박싱이 숨어들지 않도록 주의하자. - 거꾸로, 아주 무거운 객체가 아닌 다음에야 단순히 객체 생성을 피하고자 객체 풀을 만들지는 말자.
일반적으로 자체 객체 풀은 코드를 헷갈리게 만들고 메모리 사용량을 늘리고 성능을 떨어뜨린다.
07. 다 쓴 객체 참조를 해제하라
- 자바처럼 가비지 컬렉터를 갖춘 언어에서는 다 쓴 객체를 알아서 회수해간다.
그래서 자칫 메모리 관리에 더 이상 신경 쓰지 않아도 된다고 오해할 수 있지만 절대 사실이 아니다. - 스택을 사용하는 프로그램을 오래 실행하다 보면 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능이 저하될 것이다.
상대적으로 드물지만 심할 때는 디스크 페이징이나 OutOfMemoryError를 일으켜 프로그램이 예기치 않게 종료되기도 한다. - 스택이 커졌다 줄어들었을 때 스택에서 꺼내진 객체들을 더 이상 사용하지 않더라도
스택이 다 쓴 참조를 여전히 가지고 있기 때문에 가비지 컬렉터는 이를 회수하지 않게 되어 메모리 누수 문제가 발생한다.
// 메모리 누수가 일어나는 스택 프로그램을 제대로 구현
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 해당 참조를 다 썼을 때 null 처리를 통해 다 쓴 객체 참조 해제
return result;
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
- 가비지 컬렉션 언어에서는 의도치 않게 객체를 살려두는 메모리 누수를 찾기가 아주 까다롭다.
객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐 아니라 그 객체가 참조하는 모든 객체를 회수해가지 못한다.
그래서 단 몇 개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고 잠재적으로 성능에 악영향을 줄 수 있다. - 그러므로 해당 참조를 다 썼을 때 null 처리를 통해 참조 해제를 하면 된다.
만약 null 처리한 참조를 실수로 사용하려 하면 프로그램은 즉시 NullPointerException을 던지며 종료되게 된다. - 하지만 모든 객체를 다 쓰자마자 일일이 null 처리하는 것은 프로그램을 필요 이상으로 지저분하게 만들 뿐이므로
객체 참조를 null 처리하는 일은 예외적인 경우여야 한다. - 일반적으로 자기 메모리를 직접 관리하는 클래스라면 프로그래머는 항시 메모리 누수에 주의해야 한다.
원소를 다 사용한 즉시 그 원소가 참조한 객체를 다 null 처리해줘야 한다.
캐시와 리스너 혹은 콜백 역시 메모리 누수를 일으키는 주범이다.
08. finalizer와 cleaner 사용을 피하라
- 자바는 두 가지 객체 소멸자를 제공한다.
그 중 finalizer는 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요하다.
cleaner는 finalizer보다는 덜 위험하지만, 여전히 예측할 수 없고, 느리고, 일반적으로 불필요하다.- finalizer와 cleaner는 즉시 수행된다는 보장이 없으므로 제때 실행되어야 하는 작업은 절대 할 수 없다.
예컨대 파일 닫기와 같은 자원 회수를 finalizer나 cleaner에 맡기면 중대한 오류를 일으킬 수 있다. - finalizer나 cleaner를 얼마나 신속히 수행할지는 전적으로 가비지 컬렉터 알고리즘에 달렸으며,
이는 가비지 컬렉터 구현마다 천차만별이다. - 자바 언어 명세는 finalizer나 cleaner의 수행 시점뿐 아니라 수행 여부조차 보장하지 않는다.
따라서 상태를 영구적으로 수행하는 작업에서는 절대 finalizer나 cleaner에 의존해서는 안 된다. - 또한 finalizer 동작 중 발생한 예외는 무시되며, 처리할 작업이 남았더라도 그 순간에 종료되므로
자칫 마무리가 덜 된 상태로 남을 수도 있고,
다른 스레드가 이 훼손된 객체를 사용하려 한다면 어떻게 동작할지 예측할 수 없다. - 게다가 finalizer와 cleaner는 가비지 컬렉터의 효율을 떨어뜨리기 때문에 심각한 성능 문제도 동반하며
finalizera 공격에 노출되어 심각한 보안 문제를 일으킬 수도 있다.
- finalizer와 cleaner는 즉시 수행된다는 보장이 없으므로 제때 실행되어야 하는 작업은 절대 할 수 없다.
- 그러므로 파일이나 스레드 등 종료해야 할 자원을 담고 있는 객체의 클래스에서는 Autocloseable을 구현해주고,
클라이언트에서 인스턴스를 다 쓰고 나면 close 메서드를 호출하면 된다.
그리고 각 인스턴스는 자신이 닫혔는지를 추적하는 것이 좋으므로
close 메서드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고,
다른 메서드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면 IllegalStateException을 던지도록 한다. - 그렇다면 cleaner와 finalizer는 대체 어디에 쓰는 물건일까? 적절한 쓰임새가 두 가지 있다.
하나는 자원의 소유자가 close 메서드를 호출하지 않는 것에 대한 안정망 역할로 늦게라도 자원 회수를 하도록 한다.
두 번째는 자바 객체가 아니라 가비지 컬렉터가 존재를 알지 못하는 네이티브 피어와 연결된 객체를 회수하도록 한다.
// cleaner를 안전망으로 활용하는 AutoCloseable 클래스
public class Room implements AutoCloseable {
private static final Cleaner cleaner = Cleaner.create();
private static class State implements Runnable { // 청소가 필요한 자원
int numJunkPiles; // 방 안의 쓰레기 수
State(int numJunkPiles) {
this.numJunkPiles = numJunkPiles;
}
@Override
public void run() { // close 메서드나 cleaner가 호출
System.out.println("방 청소");
numJunkPiles = 0;
}
}
private final State state; // 방의 상태
private final Cleaner.Cleanable cleanable; // cleanable 객체
public Room(int numJunkPiles) {
state = new State(numJunkPiles);
cleanable = cleaner.register(this, state);
}
@Override
public void close() {
cleanable.clean();
}
}
public class Test {
public static void main(String[] args) {
try (Room myRoom = new Room(7)) {
System.out.println("안녕~"); // 안녕~ 방 청소
}
}
}
09. try-finally보다 try-with-resources를 사용하라
- 자바 라이브러리에는 close 메서드를 호출해 직접 닫아줘야 하는 자원이 많다.
하지만 자원 닫기는 클라이언트가 놓치기 쉬워서 예측할 수 없는 성능 문제로 이어지기도 한다.
이런 자원 중 상당수가 안전망으로 finalizer를 활용하고는 있지만 그리 믿을만하지 못하다. - 전통적으로 자원이 제대로 닫힘을 보장하는 수단으로 try-finally가 쓰였다.
나쁘지 않지만, 자원을 하나 더 사용한다면 예외 기록에 실수를 저지를 수 있으며 코드가 너무 지저분해지게 된다.
// try-finally 방식
public class TopLine {
static String firstLineOfFile(String path) throws IOException {
BufferedReader br = new BufferedReader(new FileReader(path));
try {
return br.readLine();
} finally {
br.close();
}
}
}
- 이러한 문제들은 try-woth-resources 덕에 모두 해결되었다.
이 구조를 사용하려면 해당 자원이 close 하나만을 정의한 AutoCloseable 인터페이스를 구현해야 한다.
자바 라이브러리와 서드파티 라이브러리들의 수많은 클래스와 인터페이스가 이미 AutoCloseable을 구현하거나 확장해뒀다.
그러므로 try-with-resources는 짧고 읽기 수월할 뿐 아니라 문제를 진단하기도 훨씬 좋다.
또한 보통의 try-finally에서처럼 catch 절을 쓸 수 있으므로 try 문을 더 중첩하지 않고도 다수의 예외를 처리할 수 있다.
// try-with-resources 방식
public class TopLine {
static String firstLineOfFile(String path) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
return br.readLine();
}
}
}
// try-with-resources + catch절
public class TopLineWithDefault {
static String firstLineOfFile(String path, String defaultVal) {
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
return br.readLine();
} catch (IOException e) {
return defaultVal;
}
}
}
'Java-Spring > 이펙티브 자바' 카테고리의 다른 글
[이펙티브 자바] 모든 객체의 공통 메서드 (0) | 2024.06.19 |
---|
✔️ 객체 생성과 파괴
객체를 만들어야 할 때와 만들지 말아야 할 때를 구분하는 법,
올바른 객체 생성 방법과 불필요한 생성을 피하는 방법,
제때 파괴됨을 보장하고 파괴 전에 수행해야 할 정리 작업을 관리하는 요령을 알아보자.
01. 생성자 대신 정적 팩터리 메서드를 고려하라
- 클라이언트가 클래스의 인스턴스를 얻는 전통적인 수단은 public 생성자다.
반면 클래스는 생성자와 별도로 정적 팩터리 메서드를 제공할 수 있다.
// 정적 팩터리 메서드
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}
- 정적 팩터리 메서드와 public 생성자는 각자의 쓰임새가 있으니 상대적인 장단점을 이해하고 사용하는 것이 좋다.
- 정적 팩터리 메서드의 장점
- 이름을 가질 수 있어 반환될 객체의 특성을 쉽게 묘사할 수 있다.
시그니처가 같은 생성자가 여러 개 필요할 것 같으면, 생성자를 정적 팩터리 메서드로 바꾸고 각각의 이름을 지어주자. - 호출될 때마다 인스턴스를 새로 생성하지 않아도 된다.
인스턴스를 미리 만들어 놓거나 새로 생성한 인스턴스를 캐싱하여 재활용하는 식으로 불필요한 객체 생성을 피할 수 있고
인스턴스를 통제할 수 있어 인스턴스가 단 하나뿐임을 보장할 수도 있다. - 반환 타입의 하위 타입 객체를 반환할 수 있는 능력이 있다.
반환할 객체의 클래스를 자유롭게 선택할 수 있게 하는 엄청난 유연성이 생기므로
구현 클래스를 공개하지 않고도 그 객체를 반환할 수 있어 API를 작게 유지할 수 있다. - 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.
반환 타입의 하위 타입이기만 하면 어떤 클래스의 객체를 반환하든 상관없다. - 정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다.
이런 유연함을 서비스 제공자 프레임워크를 만드는 근간이 된다.
클라이언트가 서비스의 인스턴스를 얻을 때 사용하는 서비스 접근 API에는 원하는 구현체의 조건을 명시할 수 있으며
조건을 명시하지 않으면 기본 구현체를 반환하거나 지원하는 구현체들을 하나씩 돌아가며 반환할 수 있게 한다.
- 이름을 가질 수 있어 반환될 객체의 특성을 쉽게 묘사할 수 있다.
- 정적 팩터리 메서드의 단점
- 상속을 하려면 public이나 protected 생성자가 필요하니 정적 팩터리 메서드만 제공하면 하위 클래스를 만들 수 없다.
- 정적 팩터리 메서드는 프로그래머가 찾기 어렵다.
생성자처럼 API 설명에 명확히 드러나지 않으므로 인스턴스화할 방법을 사용자가 알아내거나
API 문서를 잘 써넣고 메서드 이름도 널리 알려진 규약에 따라 짓는 식으로 문제를 완화해줘야 한다.
- 상속을 하려면 public이나 protected 생성자가 필요하니 정적 팩터리 메서드만 제공하면 하위 클래스를 만들 수 없다.
// 정적 팩터리 메서드에 흔히 사용하는 명명 방식들
- from : 매개변수를 하나 받아서 해당 타입의 인스턴스를 반환하는 형변환 메서드
- of : 여러 매개변수를 받아 적절한 타입의 인스턴스를 반환하는 집계 메서드
- valueOf : from과 of의 더 자세한 버전
- instance (getInstance) : 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지 않음
- create (newInstance) : getInstance와 같지만, 매번 새로운 인스턴스를 생성해 반환함을 보장함
- getType : getInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 펙토리 메서드를 정의할 때 사용
이때 Type은 팩터리 메서드가 반환할 객체의 타입 (list, bufferedReader, fileStore 등)
- newType : newInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 펙토리 메서드를 정의할 때 사용
- type : getType과 newType의 간결한 버전
02. 생성자에 매개변수가 많다면 빌더를 고려하라
- 매개변수의 수를 달리하는 방식인 점층적 생성자 패턴은 매개변수 개수가 많아지면 클라이언트 코드를 작성하거나 읽기 어렵다.
- 매개변수가 없는 생성자로 객체를 만든 후, 세터 메서드들을 호출해 원하는 매개변수의 값을 설정하는 방식인 자바빈즈 패턴은
객체 하나를 만들려면 메서드를 여러 개 호출해야 하고, 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태에 놓이게 된다.
이로 인해 생성이 끝난 객체를 수동으로 얼리고 얼리기 전에는 사용할 수 없도록 하기도 한다. - 반면 점층적 생성자 패턴의 안정성과 자바빈즈 패턴의 가독성을 겸비한 빌더 패턴은
클라이언트가 필요한 객체를 직접 만드는 대신, 필수 매개변수만으로 생성자를 호출해 빌더 객체를 얻는다.
그런 다음 빌더 객체가 제공하는 일종의 세터 메서드들로 원하는 선택 매개변수들을 설정한다.
마지막으로 매개변수가 없는 build 메서드를 호출해 필요한 객체를 얻게 된다.
// 매개변수가 6개인 식품 포장의 영양정보를 표현하는 클래스
public class NutritionFacts {
private final int servingSize;
private final int servings;
private final int calories;
private final int fat;
private final int sodium;
private final int carbohydrate;
public static class Builder {
// 필수 매개변수
private final int servingSize;
private final int servings;
// 선택 매개변수 - 기본값으로 초기화한다.
private int calories = 0;
private int fat = 0;
private int sodium = 0;
private int carbohydrate = 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;
}
public Builder sodium(int val) {
sodium = val;
return this;
}
public Builder carbohydrate(int val) {
carbohydrate = val;
return this;
}
public NutritionFacts build() {
return new NutritionFacts(this);
}
}
private NutritionFacts(Builder builder) {
servingSize = builder.servingSize;
servings = builder.servings;
calories = builder.calories;
fat = builder.fat;
sodium = builder.sodium;
carbohydrate = builder.carbohydrate;
}
}
public class Test {
public static void main(String[] args) {
NutritionFacts cocaCola = new NutritionFacts
.Builder(240, 8) // 필수 매개변수만으로 생성자를 호출해 빌더 객체를 얻는다.
.calories(100) // 그런 다음 빌더 객체가 제공하는 일종의 세터 메서드들로 원하는 선택 매개변수들을 설정한다.
.sodium(35)
.carbohydrate(27)
.build(); // 마지막으로 매개변수가 없는 build 메서드를 호출해 필요한 객체를 얻게 된다.
}
}
- 빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기도 좋다.
각 계층의 클래스에 관련된 빌더를 멤버로 정의할 수 있다. 추상 클래스는 추상 빌더를, 구체 클래스는 구체 빌더를 갖게 한다.
// 피자 (추상 클래스)
public abstract class Pizza {
public enum Topping { HAM, MUSHROOM, ONION, PEPPER, SAUSAGE }
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;
}
@Override
public String toString() {
return toppings + "로 토핑한 뉴욕 피자";
}
}
public class Test {
public static void main(String[] args) {
NyPizza pizza = new NyPizza
.Builder(SMALL)
.addTopping(SAUSAGE)
.addTopping(ONION)
.build();
}
}
- 또한 빌더 하나로 여러 객체를 순회하면서 만들 수 있고, 빌더에 넘기는 매개변수에 따라 다른 객체를 만들 수도 있다.
- 반면 객체를 만들려면 그에 앞서 빌더부터 만들어야 하며 코드가 장황하므로 매개변수가 4개 이상은 되어야 값어치를 한다.
03. private 생성자나 열거 타입으로 싱글턴임을 보증하라
- 싱글턴이란 인스턴스를 오직 하나만 생성할 수 있는 클래스를 말한다.
- 싱글턴을 만드는 방식은 셋이다.
- 첫 번째 방식인 public static 멤버가 final 필드인 방식은 private 생성자가 인스턴스를 초기화할 때 딱 한 번만 호출된다.
생성자는 private으로 감춰두고, 유일한 인스턴스에 접근할 수 있는 수단으로 public static 멤버를 마련해둔다.
public이나 protected 생성자가 없으므로 클래스 초기화 시 만들어진 인스턴스가 전체 시스템에서 하나뿐임이 보장된다.
이 방법은 해당 클래스가 싱글턴임이 API에 명백히 드러나므로 절대로 다른 객체를 참조할 수 없고 간결하다. - 두 번째 방식에서는 정적 팩터리 메서드를 public static 멤버로 제공한다.
생성자는 private으로 감춰두고, 유일한 인스턴스에 접근할 수 있는 수단으로 public static 멤버를 마련해둔다.
정적 팩터리 메서드는 항상 같은 객체의 참조를 반환하므로 제2의 인스턴스란 결코 만들어지지 않는다.
이 방법은 API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있어 호출하는 스레드별로 다른 인스턴스를 넘겨주게 할 수 있다.
또한 원한다면 정적 팩터리를 제네릭 싱글턴 팩터리로 만들 수도 있다. - 세 번째 방법은 원소가 하나인 열거 타입을 선언하는 것이다.
위 두 방법과 달리 제2의 인스턴스가 생기는 일을 완벽히 막아준다.
대부분 상황에서는 원소가 하나 뿐인 열거 타입이 싱글턴을 만드는 가장 좋은 방법이지만
만들려는 싱글턴이 Enum 외의 클래스를 상속해야 한다면 이 방법은 사용할 수 없다.
- 첫 번째 방식인 public static 멤버가 final 필드인 방식은 private 생성자가 인스턴스를 초기화할 때 딱 한 번만 호출된다.
// public static final 필드 방식의 싱글턴
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {
}
}
public class Test {
public static void main(String[] args) {
Elvis elvis = Elvis.INSTANCE;
}
}
// 정적 팩터리 방식의 싱글턴
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() {
}
public static Elvis getInstance() {
return INSTANCE;
}
}
public class Test {
public static void main(String[] args) {
Elvis elvis = Elvis.getInstance();
}
}
// 열거 타입 방식의 싱글턴
public enum Elvis {
INSTANCE;
}
public class Test {
public static void main(String[] args) {
Elvis elvis = Elvis.INSTANCE;
}
}
04. 인스턴스화를 막으려거든 private 생성자를 사용하라
- 단순히 정적 메서드와 정적 필드만을 담은 클래스를 만들고 싶을 때가 있다.
예) 기본 타입 값이나 배열 관련 메서드들을 모아놓은 Math, Arrays 클래스 등 - 정적 멤버만 담은 유틸리티 클래스는 인스턴스로 만들어 쓰려고 설계한 게 아니지만
생성자를 명시하지 않으면 컴파일러가 자동으로 기본 생성자를 만들어준다. - 이를 막기 위해 추상 클래스로 만들게 되더라도 하위 클래스를 만들어 인스턴스화할 수 있게 된다.
그리하여 사용자는 이 생성자가 자동 생성된 것인지 알 수 없어 문제이다. - 그러므로 컴파일러가 기본 생성자를 만드는 경우는 오직 명시된 생성자가 없을 때 뿐이므로
private 생성자를 추가해 클래스의 인스턴스화를 막도록 하며 직관적으로 나타내기 위해 적절한 주석을 달아두도록 한다.
// 인스턴스를 만들 수 없는 유틸리티 클래스
public class UtilityClass {
// 기본 생성자가 만들어지는 것을 막는다 (인스턴스화 방지용)
private UtilityClass() {
throw new AssertionError(); // 클래스 안에서 실수로라도 생성자를 호출하지 않도록 해준다.
}
}
05. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라
- 많은 클래스가 하나 이상의 자원에 의존한다.
- 가령 맞춤법 검사기는 사전에 의존하는데, 이런 클래스를 정적 유틸리티 클래스 또는 싱글턴으로 구현하는 경우가 흔하다.
하지만 두 방식 모두 사전을 단 하나만 사용한다고 가정한다는 점에서 그리 훌륭해 보이지 않다.
실전에서는 사전이 언어별로 따로 있고 특수 어휘용 사전을 별도로 두기도 한다.
// 정적 유틸리티를 잘못 사용한 예
public class SpellChecker {
private static final Lexicon dictionary = ...;
private SpellChecker { // 객체 생성 방지
}
public static boolean isValid(String word) {
...
}
public static List<String> suggestions(Stirng typo) {
...
}
}
// 싱글턴을 잘못 사용한 예
public class SpellChecker {
private final Lexicon dictionary = ...;
private SpellChecker(...) {
}
public static SpellChecker INSTANCE = new SpellChecker(...);
public static boolean isValid(String word) {
...
}
public static List<String> suggestions(Stirng typo) {
...
}
}
- 여러 사전을 사용할 수 있도록 하기 위해 final 한정자를 제거하고 다른 사전으로 교체하는 메서드를 추가할 경우
오류를 내기 쉬우며 멀티스레드 환경에서는 쓸 수 없으므로
사용하는 자원에 따라 달라지는 클래스에는 정적 유틸리티 클래스나 싱글턴 방식이 적합하지 않다. - 이를 위해 의존 객체 주입의 한 형태로, 인스턴스를 생성할 때 생성자에 필요한 자원을 넘겨주는 방식이 있다.
이는 맞춤법 검사기를 생성할 때 의존 객체인 사전을 주입해주면 된다.
자원이 몇 개든 의존 관계가 어떻든 상관없이 잘 동작하며 불변을 보장하여
여러 클래스가 의존 객체들을 안심하고 공유할 수 있기도 하다.
// 의존 객체 주입
public class SpellChecker {
private final Lexicon dictionary;
public SpellChecker(Lexicon dictionary) { // 인스턴스를 생성할 때 생성자에 필요한 자원을 넘겨줌
this.dictionary = Objects.requreNonNull(dictionary);
}
public static boolean isValid(String word) {
...
}
public static List<String> suggestions(Stirng typo) {
...
}
}
- 이 패턴의 변형으로는 생성자에 자원 팩터리를 넘겨주는 방식이 있다.
호출할 때마다 특정 타입의 인스턴스를 반복해서 만들어주는 객체인 자원 팩터리로 팩터리 메서드 패턴을 구현할 수 있다. - 의존 객체 주입이 유연성, 재사용성, 테스트 용이성을 개선해주긴 하지만,
의존성이 수천 개나 되는 큰 프로젝트에서는 코드를 어지럽게 만들기도 하므로
스프링 같은 의존 객체 주입 프레임워크를 사용하면 이런 어질러짐을 해소할 수 있다.
06. 불필요한 객체 생성을 피해라
- 똑같은 기능의 객체를 매번 생성하기보다는 객체 하나를 재사용하는 편이 나을 때가 많다.
특히 불변 객체는 언제든 재사용할 수 있다.
// 문장이 실행될 때마다 String 인스턴스를 새로 만듦
String s = new String("bikini");
// 문장이 실행될 때마다 하나의 String 인스턴스를 사용
String s = "bikini";
- 생성자 대신 정적 팩터리 메서드를 제공하는 불변 클래스에서는 이를 사용해 불필요한 객체 생성을 피할 수 있다.
생성자는 호출할 때마다 새로운 객체를 만들지만, 팩터리 메서드는 전혀 그렇지 않다.
불변 객체만이 아니라 가변 객체라 해도 사용 중에 변경되지 않을 것임을 안다면 재사용할 수 있다. - 특히 생성 비용이 아주 비싼 객체도 더러 있다. 이런 비싼 객체가 반복해서 필요하다면 캐싱하여 재사용하길 권한다.
// 값비싼 객체인 Pattern 인스턴스
public class RomanNumerals {
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();
}
}
- 뿐만 아니라 오토박싱은 기본 타입과 그에 대응하는 박싱된 기본 타입의 구분을 흐려주지만, 완전히 없애주는 것은 아니므로
박싱된 기본 타입보다는 기본 타입을 사용하고, 의도치 않은 오토박싱이 숨어들지 않도록 주의하자. - 거꾸로, 아주 무거운 객체가 아닌 다음에야 단순히 객체 생성을 피하고자 객체 풀을 만들지는 말자.
일반적으로 자체 객체 풀은 코드를 헷갈리게 만들고 메모리 사용량을 늘리고 성능을 떨어뜨린다.
07. 다 쓴 객체 참조를 해제하라
- 자바처럼 가비지 컬렉터를 갖춘 언어에서는 다 쓴 객체를 알아서 회수해간다.
그래서 자칫 메모리 관리에 더 이상 신경 쓰지 않아도 된다고 오해할 수 있지만 절대 사실이 아니다. - 스택을 사용하는 프로그램을 오래 실행하다 보면 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능이 저하될 것이다.
상대적으로 드물지만 심할 때는 디스크 페이징이나 OutOfMemoryError를 일으켜 프로그램이 예기치 않게 종료되기도 한다. - 스택이 커졌다 줄어들었을 때 스택에서 꺼내진 객체들을 더 이상 사용하지 않더라도
스택이 다 쓴 참조를 여전히 가지고 있기 때문에 가비지 컬렉터는 이를 회수하지 않게 되어 메모리 누수 문제가 발생한다.
// 메모리 누수가 일어나는 스택 프로그램을 제대로 구현
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
elements = new Object[DEFAULT_INITIAL_CAPACITY];
}
public void push(Object e) {
ensureCapacity();
elements[size++] = e;
}
public Object pop() {
if (size == 0)
throw new EmptyStackException();
Object result = elements[--size];
elements[size] = null; // 해당 참조를 다 썼을 때 null 처리를 통해 다 쓴 객체 참조 해제
return result;
}
private void ensureCapacity() {
if (elements.length == size)
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
- 가비지 컬렉션 언어에서는 의도치 않게 객체를 살려두는 메모리 누수를 찾기가 아주 까다롭다.
객체 참조 하나를 살려두면 가비지 컬렉터는 그 객체뿐 아니라 그 객체가 참조하는 모든 객체를 회수해가지 못한다.
그래서 단 몇 개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고 잠재적으로 성능에 악영향을 줄 수 있다. - 그러므로 해당 참조를 다 썼을 때 null 처리를 통해 참조 해제를 하면 된다.
만약 null 처리한 참조를 실수로 사용하려 하면 프로그램은 즉시 NullPointerException을 던지며 종료되게 된다. - 하지만 모든 객체를 다 쓰자마자 일일이 null 처리하는 것은 프로그램을 필요 이상으로 지저분하게 만들 뿐이므로
객체 참조를 null 처리하는 일은 예외적인 경우여야 한다. - 일반적으로 자기 메모리를 직접 관리하는 클래스라면 프로그래머는 항시 메모리 누수에 주의해야 한다.
원소를 다 사용한 즉시 그 원소가 참조한 객체를 다 null 처리해줘야 한다.
캐시와 리스너 혹은 콜백 역시 메모리 누수를 일으키는 주범이다.
08. finalizer와 cleaner 사용을 피하라
- 자바는 두 가지 객체 소멸자를 제공한다.
그 중 finalizer는 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요하다.
cleaner는 finalizer보다는 덜 위험하지만, 여전히 예측할 수 없고, 느리고, 일반적으로 불필요하다.- finalizer와 cleaner는 즉시 수행된다는 보장이 없으므로 제때 실행되어야 하는 작업은 절대 할 수 없다.
예컨대 파일 닫기와 같은 자원 회수를 finalizer나 cleaner에 맡기면 중대한 오류를 일으킬 수 있다. - finalizer나 cleaner를 얼마나 신속히 수행할지는 전적으로 가비지 컬렉터 알고리즘에 달렸으며,
이는 가비지 컬렉터 구현마다 천차만별이다. - 자바 언어 명세는 finalizer나 cleaner의 수행 시점뿐 아니라 수행 여부조차 보장하지 않는다.
따라서 상태를 영구적으로 수행하는 작업에서는 절대 finalizer나 cleaner에 의존해서는 안 된다. - 또한 finalizer 동작 중 발생한 예외는 무시되며, 처리할 작업이 남았더라도 그 순간에 종료되므로
자칫 마무리가 덜 된 상태로 남을 수도 있고,
다른 스레드가 이 훼손된 객체를 사용하려 한다면 어떻게 동작할지 예측할 수 없다. - 게다가 finalizer와 cleaner는 가비지 컬렉터의 효율을 떨어뜨리기 때문에 심각한 성능 문제도 동반하며
finalizera 공격에 노출되어 심각한 보안 문제를 일으킬 수도 있다.
- finalizer와 cleaner는 즉시 수행된다는 보장이 없으므로 제때 실행되어야 하는 작업은 절대 할 수 없다.
- 그러므로 파일이나 스레드 등 종료해야 할 자원을 담고 있는 객체의 클래스에서는 Autocloseable을 구현해주고,
클라이언트에서 인스턴스를 다 쓰고 나면 close 메서드를 호출하면 된다.
그리고 각 인스턴스는 자신이 닫혔는지를 추적하는 것이 좋으므로
close 메서드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고,
다른 메서드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면 IllegalStateException을 던지도록 한다. - 그렇다면 cleaner와 finalizer는 대체 어디에 쓰는 물건일까? 적절한 쓰임새가 두 가지 있다.
하나는 자원의 소유자가 close 메서드를 호출하지 않는 것에 대한 안정망 역할로 늦게라도 자원 회수를 하도록 한다.
두 번째는 자바 객체가 아니라 가비지 컬렉터가 존재를 알지 못하는 네이티브 피어와 연결된 객체를 회수하도록 한다.
// cleaner를 안전망으로 활용하는 AutoCloseable 클래스
public class Room implements AutoCloseable {
private static final Cleaner cleaner = Cleaner.create();
private static class State implements Runnable { // 청소가 필요한 자원
int numJunkPiles; // 방 안의 쓰레기 수
State(int numJunkPiles) {
this.numJunkPiles = numJunkPiles;
}
@Override
public void run() { // close 메서드나 cleaner가 호출
System.out.println("방 청소");
numJunkPiles = 0;
}
}
private final State state; // 방의 상태
private final Cleaner.Cleanable cleanable; // cleanable 객체
public Room(int numJunkPiles) {
state = new State(numJunkPiles);
cleanable = cleaner.register(this, state);
}
@Override
public void close() {
cleanable.clean();
}
}
public class Test {
public static void main(String[] args) {
try (Room myRoom = new Room(7)) {
System.out.println("안녕~"); // 안녕~ 방 청소
}
}
}
09. try-finally보다 try-with-resources를 사용하라
- 자바 라이브러리에는 close 메서드를 호출해 직접 닫아줘야 하는 자원이 많다.
하지만 자원 닫기는 클라이언트가 놓치기 쉬워서 예측할 수 없는 성능 문제로 이어지기도 한다.
이런 자원 중 상당수가 안전망으로 finalizer를 활용하고는 있지만 그리 믿을만하지 못하다. - 전통적으로 자원이 제대로 닫힘을 보장하는 수단으로 try-finally가 쓰였다.
나쁘지 않지만, 자원을 하나 더 사용한다면 예외 기록에 실수를 저지를 수 있으며 코드가 너무 지저분해지게 된다.
// try-finally 방식
public class TopLine {
static String firstLineOfFile(String path) throws IOException {
BufferedReader br = new BufferedReader(new FileReader(path));
try {
return br.readLine();
} finally {
br.close();
}
}
}
- 이러한 문제들은 try-woth-resources 덕에 모두 해결되었다.
이 구조를 사용하려면 해당 자원이 close 하나만을 정의한 AutoCloseable 인터페이스를 구현해야 한다.
자바 라이브러리와 서드파티 라이브러리들의 수많은 클래스와 인터페이스가 이미 AutoCloseable을 구현하거나 확장해뒀다.
그러므로 try-with-resources는 짧고 읽기 수월할 뿐 아니라 문제를 진단하기도 훨씬 좋다.
또한 보통의 try-finally에서처럼 catch 절을 쓸 수 있으므로 try 문을 더 중첩하지 않고도 다수의 예외를 처리할 수 있다.
// try-with-resources 방식
public class TopLine {
static String firstLineOfFile(String path) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
return br.readLine();
}
}
}
// try-with-resources + catch절
public class TopLineWithDefault {
static String firstLineOfFile(String path, String defaultVal) {
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
return br.readLine();
} catch (IOException e) {
return defaultVal;
}
}
}
'Java-Spring > 이펙티브 자바' 카테고리의 다른 글
[이펙티브 자바] 모든 객체의 공통 메서드 (0) | 2024.06.19 |
---|