BOOK 6 - 이펙티브 자바(2)
2장 객체 생성과 파괴
생성자 대신 정적 팩터리 메서드를 고려하라
- 클래스는 생성자와 별도로 static factory method를 제공할 수 있다.
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE: Boolean.FALSE;
}
static factory method의 장점과 단점
장점
- 이름을 가질 수 있다.
- 메서드 이름을 잘 지으면 반환될 객체의 특성을 쉽게 묘사할 수 있다.
- 생성자는 매개변수와 생성자 자체만으로 반환될 객체의 특성을 설명하기 어렵다.
- 호출될 때마다 인스턴스를 새로 생성하지는 않아도 된다.
- 인스턴스를 미리 만들어 놓거나, 새로 생성한 인스턴스를 캐싱하여 재활용하는 식으로 불필요한 객체 생성을 피할 수 있다.
- 생성 비용이 큰 같은 객체가 자주 요청되는 상황이라면 성능을 상당히 끌어올려준다.
- 플라이웨이트 패턴(Flyweight pattern)
- 정적 팩터리 메서드 방식의 클래스는 언제 어느 인스턴스를 살아있게 할지 통제할 수 있는 instance-controlled 클래스가 된다.
- 인스턴스를 통제하면 클래스를 Singleton으로 만들 수도 있고, noninstantiable(인스턴스화 불가) 하게 만들수도 있다.
- 반환 타입의 하위 타입 객체를 반환할 수 있다.
- 반환할 객체 타입을 자유롭게 선택할 수 있는 엄청난 유연성이 생긴다.
- API를 만들 때 이 유연성을 응용하면 구현 클래스를 공개하지 않고도 그 객체를 반환할 수 있어 API를 작게 유지할 수 있다.
- 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다.
- 반환 타입의 하위 타입이기만 하면 어떤 클래스의 객체를 반환하든 상관없다.
- 다음 릴리즈에서 성능을 개성한 또 다른 클래스의 객체를 반환해도 된다.
- 클라이언트는 팩터리가 반환하는 객체가 어느 클래스의 인스턴스인지 알 필요가 없다.
- 정적 팩터리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 된다.
- 이런 유연함은 service provider framework를 만드는 근간이 된다.
단점
-
상속을 하기 위해서는
public/protected생성자가 필요하기 때문에 static factory method만 제공하면 하위 클래스를 만들 수 없다. -
개발자가 정적 팩터리 메서드를 찾기 어렵다.
- 생성자처럼 API 설명에 명확하게 드러나지 않아 사용자가 클래스를 인스턴스화할 방법을 알아내야 한다.
-
정적 팩터리 메서드에서 흔히 사용하는 명명 방식은 다음이 있다.
-
from: 매개변수 하나를 받아서 해당 타입 인스턴스 반환하는 형변환 메서드 -
of: 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환하는 집계 메서드 -
valueOf -
instance/getInstance: 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스턴스임을 보장하지는 않는다. -
create/newInstance:instance/getInstance와 같지만, 매번 새로운 인스턴스를 생성해 반환함을 보장한다. -
getType:getInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 쓴다.FileStore fs = Files.getFileStore(path); -
newType:newInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩터리 메서드를 정의할 때 쓴다.BufferedReader br = Files.newBufferedReader(path); -
type:getType/newType의 간결한 버전List<Complaint> litany = Collections.list(legacyLitany);
-
생성자에 매개변수가 많다면 빌더를 고려하라
- 생성자나 정적 팩터리 메서드는 하나의 제약이 있다.
- 선택적 매개변수가 많을 때 적절히 대응하기 어렵다.
- 생성자는 사용자가 설정하길 원치 않는 매개변수까지 포함하고 있는 경우가 많아 어쩔 수 없이 그런 매개변수까지 값을 지정해줘야 한다.
고전적 해결방법
- 점층적 생성자 패턴
- 필수 매개변수만 받는 생성자, 필수 매개변수에 선택 매개변수 1개, 2개, 3개, … 받는 생성자 형태로 생성자를 늘려가는 방식
- 코드를 읽을 때 각 값의 의미가 무엇인지 파악하기 어렵고 버그로 이어질 수 있다.
- 자바빈즈 패턴
- 매개변수 없는 생성자로 객체를 만든 후, setter 메서드들을 호출해 원하는 매개변수의 값을 설정하는 방식
- 점층적 생성자 패턴에 비해서 코드가 길어졌지만 인스턴스를 만들기 쉽고, 읽기 쉬운 코드가 된다.
- 객체 하나를 만드려면 메서드 여러개를 호출해야 하고, 객체가 완전히 생성되기 전까지는 일관성(consistency)이 무너진 상태에 놓이게 된다.
- 클래스를 불변으로 만들 수 없고, 스레드 안정성을 얻으려면 추가 작업을 해줘야 한다.
빌더 패턴 (Builder pattern)
- 클라이언트는 객체를 직접 만드는 대신, 필수 매개변수 만으로 생성자를 호출해 빌더 객체를 얻는다.
- 빌더 객체가 제공하는 일종의 setter 메서드들로 원하는 선택 매개변수들을 설정한다.
- 마지막으로
build메서드를 호출해 필요한 객체를 얻는다.
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;
}
}
NutritionFacts클래스는 불변이며, 모든 매개변수의 기본값들을 한 곳에 모아두었다.- 빌더 패턴을 이용한 코드는 빌더의 세터 메서드들이 빌더 자신을 반환하기 때문에 method chaining으로 호출된다.
- 클라이언트 코드는 읽기 쉬워진다.
계층적 클래스의 빌더 패턴
- 추상 클래스는 추상 빌더를, 구현 클래스는 구체 빌더를 갖게 한다.
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();
// 하위 클래스에서 override해서 this를 반환하도록 한다.
protected abstract T self();
}
Pizza(Builder<?> builder) {
toppings = builder.toppings.clone();
}
}
Pizza.Builder클래스는 재귀적 타입 한정을 이용하는 제네릭 타입이다.- 추상 클래스인
self()를 사용해 하위 클래스에서 형변환하지 않고도 method chaining을 지원할 수 있다. Pizza의 하위 클래스인NyPizza클래스를 보자.
public class NyPizza extends Pizza {
public enum Size { SMALL, MEDIUM, LARGE }
private fianl 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;
}
}
private 생성자나 열거 타입으로 싱글턴임을 보증하라
- 클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기 어려워질 수 있다.
- 싱글턴 인스턴스를 mock 구현으로 대체할 수 없기 때문이다.
- 싱글턴을 만드는 방식은 보통 두가지 중 하나다.
public static final 필드 방식
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
}
- private 생성자는
Elvis.INSTANCE를 초기화할 때 딱 한번 호출된다. - public / protected 생성자가 없으므로 Elvis 클래스의 인스턴스가 전체 시스템에서 하나뿐임이 보장된다.
정적 팩터리 방식
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() { ... }
public static Elvis getInstance() {
return INSTANCE;
}
}
- 이 방식의 장점은 API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있다는 점이다.
- 두번째 장점은 원한다면 정적 팩터리를 제네릭 싱글턴 팩터리로 만들 수 있다는 점이다.
- 세번째는 정적 팩터리의 메서드 참조를 supplier로 사용할 수 있다는 점이다.
Elvis::getInstance를Supplier<Elvis>로 사용하는 식이다.
싱글턴 클래스의 직렬화
-
싱글턴 클래스를 직렬화하려면 단순히
Serializable을 구현한다고 선언하는 것만으로는 부족하다. -
모든 인스턴스 필드를
transient라고 선언하고,readResolve메서드를 제공해야 한다.- 이렇게 하지 않으면 직렬화된 인스턴스를 역직렬화할 때 새로운 인스턴스가 만들어진다.
private Object readResolve() { return INSTANCE; } -
이 외에 싱글턴을 만드는 세번째 방법은 원소가 하나인 열거 타입을 선언하는 방식이다.
열거 타입 방식
public enum Elvis {
INSTANCE;
...
}
-
public 필드 방식과 비슷하지만, 더 간결하고, 추가 노력없이 직렬화할 수 있다.
-
부자연스러워 보일 수 있으나, 대부분 상황에서 원소가 하나뿐인 enum 타입이 싱글턴을 만드는 가장 좋은 방법이다.
인스턴스화를 막으려거든 private 생성자를 사용하라
- 정적 멤버만 담은 유틸리티 클래스는 인스턴스로 만들어 쓰려고 설계한 것이 아니다.
- 하지만 생성자를 명시하지 않으면 컴파일러가 자동으로 기본 생성자를 만들어준다.
- 인스턴스화를 막기 위해서는 컴파일러가 기본 생성자를 만들지 않도록 해야 한다.
- 컴파일러가 기본 생성자를 만드는 경우는 명시된 생성자가 없을 때이므로 private 생성자를 추가하면 된다.
public class UtilityClass {
private UtilityClass() {
throw new AssertionError();
}
...
}
- 생성자를 private으로 선언했으므로, 해당 클래스를 상속할 수 없게 하는 효과도 있다.
- 모든 생성자는 명시적이든 묵시적이든 상위 클래스의 생성자를 호출하기 때문이다.
자원을 직접 명시하지 말고 의존 객체 주입을 사용하라
- 많은 클래스가 하나 이상의 자원에 의존한다.
- 이런 클래스들을 정적 유틸리티 클래스로 구현한 경우가 드물지 않다.
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) { ... }
}
- 위는 인스턴스 생성을 방지한 유틸리티 클래스로 구현한 방식이고, 아래는 싱글턴으로 구현한 방식이다.
- 두 방식 모두
Lexicon을 단 하나만 사용한다고 가정한다는 점에서 좋아보이지 않는다.- 실전에서는 사전이 언어별로 따로 있고, 특수 어휘용 사전, 테스트용 사전 등등이 있을 수 있다.
- 사용하는 자원에 따라 동작이 달라지는 클래스는 정적 유틸리티 클래스나 싱글턴 방식이 적합하지 않다.
- 클래스는 여러 자원 인스턴스를 지원해야 하며, 클라이언트가 원하는 자원을 사용해야 한다.
- 이 조건을 만족하기 위해 인스턴스를 생성할 때 필요한 자원을 넘겨주는 방식 을 사용한다.
- 이는 의존 객체 주입의 한 형태이다.
public class SpellChecker {
private final Lexicon dictionary;
public SpellChecker(Lexicon dictionary) {
this. dictionary = Objects.requireNonNull(dictionary);
}
public static boolean isValid(String word) { ... }
public static List<String> suggestions(String typo) { ... }
}
- 의존 객체 주입 패턴은 자원이 몇개든 의존 관계가 어떻든 상관없이 잘 동작한다.
-
불변을 보장하여 같은 자원을 사용하려는 여러 클라이언트가 의존 객체들을 안심하고 공유할 수 있기도 하다.
-
이 패턴의 변형으로, 생성자에 자원 팩터리를 넘겨주는 방식이 있다.
- factory란, 호출할 때마다 특정 타입의 인스턴스를 반복해서 만들어주는 객체이다.
Supplier<T>인터페이스가 팩터리를 표현한 예다.Supplier<T>를 입력으로 받는 메서드는 일반적으로 한정적 wildcard 타입을 사용해 팩터리의 타입 매개변수를 제한한다.
- 클라이언트는 자신이 명시한 타입의 하위 타입이라면 무엇이든 생성할 수 있는 factory를 넘길 수 있다.
- 아래 코드는 클라이언트가 제공한 팩터리가 생성한 타일(Tile)들로 구성된 모자이크(Mosaic)를 만드는 메서드다.
Mosaic create(Supplier<? extends Tile> tileFactory) { ... }
불필요한 객체 생성을 피하라
- 똑같은 기능의 객체를 매번 생성하기 보다는 객체 하나를 재사용하는 편이 나을 때가 많다.
- 생성 비용이 비싼 객체들은 반복해서 필요하다면 캐싱하여 재사용하는 것이 좋다.
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})$");
}
- 위 코드에서
String.matches메서드는 내부적으로 정규표현식용Pattern인스턴스를 만드는데, 이 인스턴스는 한번 쓰고 버려져 GC 대상이 된다.Pattern은 입력받은 정규표현식에 해당하는 유한 상태 머신을 만들기 때문에 인스턴스 생성 비용이 높다.- 성능이 중요한 상황에서 반복적으로 사용하기 적절하지 않다.
- 성능을 개선하기 위해서
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();
}
}
- auto boxing도 불필요한 객체를 만들어내는 또 다른 예이다.
- auto boxing은 개발자가 기본 타입과 박싱된 기본 타입을 섞어 쓸 때 자동으로 상호 변환해주는 기술이다.
private static long sum() {
Long sum = 0L;
for(long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
return sum;
}
- 위 코드는 정확히 동작하기는 하지만 성능상 매우 안 좋고, 느리다.
sum변수를Long타입으로 선언해 불필요한Long인스턴스가 약 2^31개나 만들어지기 때문이다.long타입인i변수가sum변수에 더해질 때마다 새로운Long인스턴스가 생성된다.
-
박싱된 기본 타입보다는 기본 타입을 사용하고, 의도치 않은 auto boxing이 발생하지 않도록 주의하는 것이 좋다.
- 객체 생성은 무조건 비싸니 피해야하는 것은 아니다.
- 오히려 아주 무거운 객체가 아닌 이상 단순히 객체 생성을 피하고자 객체 pool을 만들지 말자.
- 데이터베이스 연결 같은 경우는 객체 생성 비용이 비싸기 때문에 재사용하는 것이 좋지만, 일반적으로는 자체 객체 풀은 코드를 복잡하게 만들고, 메모리 사용량을 늘리고, 성능을 떨어뜨린다.
다 쓴 객체 참조를 해제하라
- 자바는 가비지 컬렉터가 알아서 다 쓴 객체를 회수해가기 때문에 메모리 관리를 하지 않아도 된다고 생각할 수 있지만, 그렇지 않다.
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();
}
return elements[--size];
}
private void ensureCapacity() {
if(elements.length == size) {
elements = Arrays.copyOf(elements, 2 * size + 1);
}
}
}
- 위 코드는 특별한 문제 없이 동작하지만, 이 스택을 사용하는 프로그램을 오래 실행하다보면 점차 메모리 사용량이 늘어나 성능이 저하될 것이다.
- 위 코드에서는 스택에서 아이템을 pop할 때 꺼내진 객체들이 더이상 사용되지 않더라도 GC가 회수해가지 않는다.
- 이 스택이 그 객체들을 여전히 참조하고 있기 때문이다.
- 객체 참조 하나를 살려두면, GC는 그 객체뿐만 아니라 그 객체가 참조하는 모든 객체, 그리고 또 그 객체가 참조하는 모든 객체, …를 전부 회수해가지 못한다.
- 단 몇 개의 객체가 매우 많은 객체를 회수되지 못하게 할 수 있고, 성능에 악영향을 줄 수 있다.
- 이를 해결하기 위해서는 다 쓴 참조는 null 처리하면 된다.
public Object pop() {
if (size == 0) {
throw new EmptyStackException();
}
Object result = elements[--size];
elements[size] = null;
return result;
}
Stack클래스처럼 자기 메모리를 직접 관리하는 클래스는 항상 메모리 누수에 주의해야 한다.
메모리 누수의 다른 원인들
캐시
- 객체 참조를 캐시에 넣어놓고, 객체를 다 쓴 뒤로도 한참을 그냥 놔두는 경우가 많다.
- 보통 캐시 entry의 유효 기간을 정확히 정의하기 어렵기 때문에, 시간이 지날수록 entry의 가치를 떨어뜨리는 방식을 흔히 사용한다.
ScheduledThreadPoolExecutor같은 백그라운드 스레드를 활용하거나 캐시에 새로운 entry를 추가할 때 부수작업으로 쓰지않는 entry를 청소해줘야 한다.
listener와 callback
- 클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면 콜백은 계속 쌓여갈 것이다.
- 콜백을 약한 참조(weak reference)로 저장하면 GC가 즉시 수거해간다.
finalizer와 cleaner 사용을 피하라
- 자바는 두가지의 객체 소멸자를 제공한다.
-
하지만 이 두가지는 예측할 수 없고, 상황에 따라 위험할 수 있고, 불필요하다.
- finalizer와 cleaner는 즉시 수행된다는 보장이 없다.
- finalizer와 cleaner를 얼마나 신속히 수행할지는 전적으로 가비지 컬렉터 알고리즘에 달렸으며, 구현마다 천차만별이다.
- 예를 들어 파일 닫기를 finalizer나 cleaner에게 맡기면 언제 실행될지 알 수 없어 시스템이 동시에 열 수 있는 파일 개수를 초과해 문제를 일으킬 수 있다.
- finalizer와 cleaner는 심각한 성능 문제도 동반한다.
finalizer나 cleaner를 대신할 방법
- 파일이나 스레드 등 종료해야할 자원을 담고 있는 객체의 클래스에서 finalizer나 cleaner를 대신할 방법은 무엇일까
AutoCloseable을 구현해주고, 클라이언트에서 인스턴스를 다 쓰고 나면close메서드를 호출하면 된다.- 일반적으로 예외가 발생해도 제대로 종료되도록
try-with-resources를 사용해야 한다.
- 일반적으로 예외가 발생해도 제대로 종료되도록
try-finally 보다는 try-with-resources 를 사용하라
- 자바 라이브러리에는
close메서드를 호출해 직접 닫아줘야 하는 자원이 많다.InputStream,OutputStream,java.sql.Connection등이 그 예이다.
- 이런 자원들 중 상당수가 안전망으로 finalizer를 활용하고 있지만, finalizer는 그리 믿을만하지 못하다.
try-finally
- 전통적으로 자원을 닫는 수단으로
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-with-resources
- 이 구조를 사용하려면 해당 자원이
AutoCloseable인터페이스를 구현해야 한다.void를 반환하는close메서드를 정의한 인터페이스이다.- 자바 라이브러리와 3rd party 라이브러리들의 수많은 클래스와 인터페이스가 이미
AutoCloseable을 구현해뒀다.
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);
}
}
- 기기에 문제가 생겨
firstLineOfFile메서드의readLine에서 예외가 발생하고, 같은 이유로close에서도 예외가 발생하는 경우를 생각해보자.close에서 발생한 예외는 숨겨지고,readLine에서 발생한 예외가 기록될 것이다.- 숨겨진 예외들도 버려지지는 않고 stack trace에 “suppressed” 라는 꼬리표를 달고 출력된다.
- 앞의
try-finally에서는close에서 발생한 예외가 처음 발생한readLine의 예외를 집어 삼켜버려 첫번째 예외에 관한 정보는 남지 않게 되는 문제가 있다.- 이는 처음 발생한 예외를 숨겨버려 디버깅을 어렵게 한다.
catch
try-finally에서처럼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;
}
}