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;
}
}
}
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();
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 메소드는 해당하는 구체 하위 클래스를 반환하도록 선언
- 위와 같이 이용하면, 클라이언트가 형 변환에 신경쓰지 않고도 빌더를 유연하게 사용할 수 있음
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. 의존 객체 주입
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으로 스택에서 객체를 꺼냈을 때, 이 꺼내진 객체들을 다시 사용하지 않더라도 가비지 컬렉터가 회수하지 않음
7-2. 캐시로 인한 메모리 누수
- 객체 참조를 캐시에 넣고 그 객체를 다 쓴뒤로도 한참을 그냥 나두는 일이 있는데, 이러한 상황에서 메모리 누수가 발생함
- 엔트리가 살아있는 캐시가 필요한 상황에 WeakHashMap을 사용하면, 다 쓴 엔트리는 그 즉시 자동으로 삭제됨
- 쓰지 않는 엔트리를 정리하는 방법은 백그라운드 스레드를 활용하거나 캐시에 새 엔트리를 추가할 때 부수 작업으로 수행하는 방법이 있음
7-3. 리스너 & 콜백에 인한 메모리 누수
- 클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 뭔가 조치해주지 않는 한 콜백은 계속 쌓여 메모리 누수가 발생함
7-4. 정리
- 메모리 누수는 겉으로 잘 드러나지 않아 시스템에 수년간 잠복하는 사례도 있는데, 이는 철저한 코드 리뷰나 힙 프로파일러 같은 디버깅 도구를 동원해야만 발견되기 때문에 예방법을 미리 익혀두는 것이 좋음
8. finalizer와 cleaner 사용을 피하라
8-1. 자바의 객체 소멸자
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를 사용
- 코드가 더 짧고 분명해지며, 만들어지는 예외 정보도 훨씬 유용함 (정확하고 쉽게 자원을 회수할 수 있음)