객체를 만들어야 할 때와 만들지 말아야 할 때를 구분하는 방법
올바른 객체 생성 방법과 불필요한 생성을 피하는 방법
제때 파괴됨을 보장하고 파괴 전에 수행해야 할 정리 작업을 관리하는 방법
1. 생성자 대신 정적 팩토리 메서드를 고려하라
정적 팩토리 메서드는 디자인 패턴에서의 팩토리 메서드를 의미하는 게 아님!
1.1 팩토리 메서드의 장점 5가지
1) 이름을 가질 수 있다
생성자에 넘기는 매개변수와 생성자 자체만으로는 반환될 객체의 특성을 제대로 설명하지 못한다
반면 정적 팩토리는 이름만 잘 지으면 반환될 객체의 특성을 쉽게 묘사할 수 있다
예를 들어
생성자 BigInteger(int, int, Random) vs 정적 팩토리 메서드 BigInteger.probabalPrime
어느 쪽이 값이 소수인 BigInteger를 반환한다는 의미를 더 잘 설명할 것 같은지 생각해 보자
당연히 후자이다
2) 호출될 때마다 인스턴스를 새로 생성하지 않다도 된다
이 장점을 활용하면 불변 클래스는 인스턴스를 미리 만들어 놓거나
새로 생성한 인스턴스를 캐싱하여 재활용하는 식으로 불필요한 객체 생성을 피할 수 있다
예를 들어 Boolean.valueOf(boolean) 메서드는 객체를 아예 생성하지 않는다
따라서 같은 객체가 자주 요청되는 상황이라면 성능을 상당히 끌어올려 준다
3) 반환 타입의 하위 타입 객체를 반환할 수 있는 능력이 있다
API를 만들 때 이 유연성을 응용하면 구현 클래스를 공개하지 않고도 그 객체를 반환할 수 있어서 API를 작게 유지할 수 있다
이는 인터페이스를 정적 팩토리 메서드의 반환 타입으로 사용하는 인터페이스 기반 프레임워크를 만드는 핵심 기술이기도 하다
자바 8 전에는 인터페이스에 정적 메서드를 선언할 수 없었다
(Type인 인터페이스를 반환하는 정적 메서드가 필요하면 Types라는 동반 클래스를 만들어 그 안에 정의하는 방식을 사용)
자바 8부터는 인터페이스가 정적 메서드를 가질 수 없다는 제한이 풀렸기 때문에 public 정적 멤버들 상당수를 그냥 인터페이스 자체에 두면 된다
하지만 정적 메서드들을 구현하기 위한 코드 중 많은 부분은 여전히 별도의 package-private 클래스에 두어야 할 수 있다
자바 8에서도 인터페이스에는 public 정적 멤버만 허용하기 때문!
자바 9에서는 private 정적 메서드까지 허락하지만 정적 필드와 정적 멤버 클래스는 여전히 public 이어야 한다
4) 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있다
반환 타입의 하위 타입이기만 하면 어떤 클래스의 객체를 반환하든 상관없다
예를 들어 EnumSet 클래스는 public 생성자 없이 오직 정적 팩토리만 제공하는데
OpenJDK에서는 원소의 수에 따라 두 가지 하위 클래스 중 하나의 인스턴스를 반환한다
원소가 64개 이하면 원소들은 long 변수 하나로 관리하는 RegularEnumSet의 인스턴스를,
원소가 65개 이상이면 long 배열로 관리하는 JumboEnumSet의 인스턴스를 반환한다
클라이언트는 이 두 클래스의 존재를 모른다 알필요도 없다
EnumSet의 하위 클래스이기만 하면 되는 것이다
5) 정적 팩토리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않다도 된다
이 장점은 서비스 제공자 프레임워크를 만드는 근간이 된다
대표적인 서비스 제공자 프레임워크로는 JDBC가 있다
서비스 제공자 프레임워크에서의 제공자(provider)는 서비스 구현체이다
그리고 이 구현체들을 클라이언트에 제공하는 역할을 프레임워크가 통제하여, 클라이언트를 구현체로부터 분리해 준다
서비스 제공자 프레임워크는 3개의 핵심 컴포넌트로 이뤄진다
JDBC로 예를 들어 보자
구현체의 동작을 정의하는 서비스 인터페이스 - Connection
제공자가 구현체를 등록할 때 사용하는 제공자 등록 API - DriverManager.registerDriver
클라이언트가 서비스의 인스턴스를 얻을 때 사용하는 서비스 접근 API - DriverManager.getConnection
public static void main(String[] args) {
Connection con = null;
String driver = "com.mysql.jdbc.Driver";
try {
Class.forName(driver);
con = DriverManager.getConnection(url, user, pw);
.......
}
1.2 팩토리 메서드의 단점 2가지
1) 상속을 하려면 public이나 protected 생성자가 필요하니 정적 팩토리 메서드만 제공하면 하위 클래스를 만들 수 없다
정적 팩토리 메서드만 존재하는 클래스는 상속받을 수 없다
어찌 보면 이 제약은 상속보다 컴포지션을 사용하도록 유도하고 불변 타입으로 만들려면 이 제약을 지켜야 한다는 점에서 오히려 장점으로 받아들일 수도 있다
2) 정적 팩토리 메서드는 프로그래머가 찾기 어렵다
생성자처럼 API설명에 명확히 드러나지 않으니 사용자는 정적 팩토리 메서드 방식의 클래스를 인스턴스화할 방법을 알아내야 한다
API 문서를 잘 써놓고 메서드 이름을 널리 알려진 규약을 따라 짓는 식으로 문제를 완화해줘야 한다
흔히 사용하는 명명 방식들은 아래와 같다
/* from : 매게 변수를 하나 받아 해당 타입의 인스턴스를 반환하는 형 변환 메서드 */
Date d = Date.from(Instant.now());
/* of: 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환하는 집계 메서드 */
Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING);
/* valueOf: from 과 of의 더 자세한 버전 */
BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE);
/* instance 혹은 getInstance: 매개변수로 명시한 인스턴스를 반환하지만, 같은 인스터임을 보장하지는 않는다 */
StackWalker luke = StackWalker.getInstance(options);
/* create 혹은 newInstance : instance 혹은 getInstance와 같지만, 매번 새로운 인스턴스를 생성해 반환함을 보장한다 */
Object newArray = Array.newInstance(classObject, arrayLen);
/* getType : getInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩토리 메서드를 정의할 때 쓴다. "Type"은 팩토리 메서드가 반환할 객체의 타입이다 */
FileStore fs = Files.getFileStore(path);
/* newType: newInstance와 같으나, 생성할 클래스가 아닌 다른 클래스에 팩토리 메서드를 정의할 때 쓴다. "Type"은 팩토리 메서드가 반환할 객체의 타입이다 */
BufferedReader br = Files.newBufferedReader(path);
/* type: getType과 newType의 간결한 버전 */
List<Complaint> litany = Collections.list(legacyLitany);
- 핵심 정리
정적 팩토리 메서드와 public 생성자는 각자의 쓰임새가 있으니 상대적인 장단점을 이해하고 사용하는 것이 좋다
그렇다고 하더라도 정적 팩토리를 사용하는 게 유리한 경우가 더 많으므로
무작정 public 생성자를 제공하던 습관이 있다면 고치자
2. 생성자에 매개변수가 많다면 빌더를 고려하라
정적 팩토리와 생성자에는 똑같은 제약이 하나 있다
선택적 매개변수가 많을 때 적절히 대응하기 어렵다는 점이다
- 점층적 생성자 패턴
확장하기 어렵고 매개변수 개수가 다를 때마다 생성자를 늘려줘야 한다
public NutritionFacts(int servingSize, int servings) {
this(servingSize, servings, 0);
}
public NutritionFacts(int servingSize, int servings, int calories) {
this(servingSize, servings, calories, 0);
}
public NutritionFacts(int servingSize, int servings, int calories, int fat) {
this(servingSize, servings, calories, fat, 0);
}
public NutritionFacts(int servingSize, int servings, int calories, int fat, int sodium) {
this(servingSize, servings, calories, fat, sodium, 0);
}
...
...
NutritionFacts cocaCola = new NutritionFacts(240, 8, 100, 0, 35, 27);
- 자바빈즈 패턴
객체 하나를 만들기 위해 여러 메서드(set)를 호출해야 하고, 객체가 완전히 생성되기 전까지는 일관성이 무너진 상태에 놓이게 된다
public void setServingSize(int servingSize) {
this.servingSize = servingSize;
}
public void setServings(int servings) {
this.servings = servings;
}
public void setCalories(int calories) {
this.calories = calories;
}
public void setFat(int fat) {
this.fat = fat;
}
public void setSodium(int sodium) {
this.sodium = sodium;
}
public void setCarbohydrate(int carbohydrate) {
this.carbohydrate = carbohydrate;
}
...
...
NutritionFacts cocaCola = new NutritionFacts();
nutritionFacts2.setServingSize(240);
nutritionFacts2.setServings(8);
nutritionFacts2.setCalories(100);
nutritionFacts2.setSodium(35);
nutritionFacts2.setCarbohydrate(27);
- 빌더 패턴
점층적 생성자 패턴의 안전성과 자바빈즈 패턴의 가독성을 겸비한 패턴이다
필수로 받아야 하는 매개변수보다 선택적 매개변수가 많다면 빌더 패턴을 사용하자
필요한 매개변수만 build 메서드 호출을 통해 객체를 얻는다
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 cocaCola = new NutritionFacts.Builder(240, 8)
.calories(100)
.sodium(35)
.carbohydrate(27)
.build();
빌더 패턴은 불변 객체이며 가독성도 좋다
빌더 패턴은 (파이썬, 스칼라에 있는) 명명된 선택적 매개변수를 흉내 낸 것이다
빌더 패턴은 계층적으로 설계된 클래스와 함께 쓰기에 좋다
- 핵심 정리
생성자나 정적 팩토리가 처리해야 할 매개변수가 많다면 빌더 패턴을 선택하는 게 더 낫다
매개변수 중 다수가 필수가 아니거나 같은 타입이면 특히 더 그렇다
빌더는 점층적 생성자보다 클라이언트 코드를 읽고 쓰기가 훨씬 간결하고, 자바빈즈보다 훨씬 안전하다
3. private 생성자나 열거 타입으로 싱글턴임을 보증하라
싱글턴(singleton)이란 인스턴스를 오직 하나만 생성할 수 있는 클래스를 말한다
무상태(stateless) 객체나 설계상 유일해야 하는 시스템 컴포넌트가 대표적인 예이다
그런데 클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기가 어려워질 수 있다
타입을 인터페이스로 정의한 다음 그 인터페이스를 구현해서 만든 싱글턴이 아니라면
싱글턴 인스턴스를 가짜(mock) 구현으로 대체할 수 없기 때문이다
싱글턴을 만드는 방식은 보통 둘 중 하나다
두 방식 모두 생성자는 private으로 감춰두고, 유일한 인스턴스에 접근할 수 있는 수단으로 public static 멤버를 하나 마련해 둔다
- public static final 필드 방식의 싱글턴
parivate 생성자는 public static final 필드인 Elvis.INSTANCE를 초기화할 때 딱 한 번만 호출된다
public이나 protected 생성자가 없으므로 Elvis 클래스가 초기화될 때 만들어진 인스턴스가 전체 시스템에서 하나뿐임이 보장된다
클라이언트는 손 쓸 방법이 없다
단 예외가 한 가지 있는데 리플렉션 API인 AccessibleObject.setAccessible을 사용해 호출하는 방법이다
이러한 리플렉션 공격을 방어하려면 생성자를 수정하여 두 번째 객체가 생성되려 할 때 예외를 던지게 하면 된다
public 필드 방식의 큰 장점은 해당 클래스가 싱글턴임이 API에 명백히 드러난다는 것이다
public static 필드가 final이니 절대로 다른 객체를 참조할 수 없으며 또한 간결하다
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {
...
}
public void leaveTheBuilding() {
...
}
- 정적 팩토리 메서드 방식의 싱글턴
Elvis.getInstance는 항상 같은 객체의 참조를 반환하므로 제2의 Elvis 인스턴스란 결코 만들어지지 않는다 (역시 리플렉션을 통한 예외는 똑같이 적용된다)
정적 팩토리 메서드 방식의 장점은 API를 바꾸지 않고도 싱글턴이 아니게 변경할 수 있다는 점이다
유일한 인스턴스를 반환하던 팩토리 메서드가 호출하는 스레드별로 다른 인스턴스를 넘겨주게 할 수 있다
또한 원한다면 정적 팩토리를 제네릭 싱글턴 팩토리로 만들 수 있다는 점과
정적 팩토리의 메서드 참조를 공급자(supplier)로 사용할 수 있다는 점이다
예를 들어 Elvis::getInstance를 Supplier <Elvis>로 사용하는 식이다
이러한 장점들이 굳이 필요하지 않다면 public 필드 방식을 사용하는 것이 좋다
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() {
...
}
public static Elvis getInstance() {
return INSTANCE;
}
public void leaveTheBuilding() {
...
}
}
- 싱글턴임을 보장해 주는 readResolve 메서드
둘 중 하나의 방식으로 만든 싱글턴 클래스를 직렬화하려면 단순히 Serializable을 구현한다고 선언하는 것만으로는 부족하다
모든 인스턴스 필드를 일시적(transient)이라고 선언하고, readResolve 메서드를 제공해야 한다
이렇게 하지 않으면 직렬화된 인스턴스를 역직렬화할 때마다 새로운 인스턴스가 만들어진다
가짜 Elvis가 탄생한다는 뜻이다
가짜 Elvis 탄생을 예방하고 싶다면 Elvis 클래스에 다음의 readResolve 메서드를 추가하자
private Object readResolve() {
// '진짜' Elvis를 반환하고, 가짜 Elvis는 가비지 컬렉터에 맡긴다.
return INSTANCE;
}
- 열거 타입 방식의 싱글턴 - 바람직한 방법
싱글턴을 만드는 세 번째 방법은 원소가 하나인 열거 타입을 선언하는 것이다
public 필드 방식과 비슷하지만, 더 간결하고 추가 노력 없이 직렬화할 수 있고
심지어 아주 복잡한 직렬화 상황이나 리플렉션 공격에서도 제2의 인스턴스가 생기는 일을 완벽히 막아준다
조금 부자연스러워 보일 수는 있으나 대부분 상황에서는 원소가 하나뿐인 열거 타입이 싱글턴을 만드는 가장 좋은 방법이다
단, 만들려는 싱글턴이 Enum 외의 클래스를 상속해야 한다면 이 방법은 사용할 수 없다
(열거 타입이 다른 인터페이스를 구현하도록 선언할 수는 있다)
public enum Elvis() {
INSTANCE;
public void leaveTheBuilding() {
...
}
}
4. 인스턴스화를 막으려거든 private 생성자를 사용하라
이따금 단순히 정적 메서드와 정적 필드만을 담은 클래스를 만들고 싶을 때가 있을 것이다
객체 지향적으로 사고하지 않는 이들이 종종 남용하는 방식이기에 그리 곱게 보이지는 않지만, 분명 나름의 쓰임새가 있다
- java.lang.Math와 java.util.Arrays처럼 기본 타입 값이나 배열 관련 메서드들을 모아 놓을 때
- java.util.Collections처럼 특정 인터페이스를 구현하는 객체를 생성해 주는 정적 메서드(혹은 팩토리)를 모아 놓을 때(자바 8부터는 이런 메서드를 인터페이스에 넣을 수 있다)
- 마지막으로 final 클래스와 관련한 메서드들을 모아 놓을 때
(final 클래스를 상속해서 하위 클래스에 메서드를 넣는 건 불가능하기 때문이다)
정적 멤버만 담은 유틸리티 클래스는 인스턴스로 만들어 쓰려고 설계한 게 아니다
하지만 생성자를 명시하지 않으면 컴파일러가 자동으로 기본 생성자를 만들어 준다
즉, 매개변수를 받지 않는 public 생성자가 만들어지며, 사용자는 이 생성자가 자동 생성된 것인지 구분할 수 없다
실제로 공개된 API들에서도 이처럼 의도치 않게 인스턴스화할 수 있게 된 클래스가 종종 목격되곤 한다
추상 클래스를 만드는 것으로는 인스턴스화를 막을 수 없다
하위 클래스를 만들어 인스턴스화하면 그만이다
이를 본 사용자는 상속해서 쓰라는 뜻으로 오해할 수 있으니 더 큰 문제다
다행히도 인스턴스를 막는 방법은 아주 간단하다
컴파일러가 기본 생성자를 만드는 경우는 오직 명시된 생성자가 없을 때뿐이지 private 생성자를 추가하면 클래스의 인스턴스화를 막을 수 있다
public class UtilityClass {
// 기본 생성자가 만들어지는 것을 막는다(인스턴스화 방지용)
private UtilityClass() {
throw new AssertionError();
}
...
...
}
5. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라
많은 클래스가 하나 이상의 자원에 의존한다
가령 맞춤법 검사기는 사전(dic-tionary)에 의존하는데, 이런 클래스를 정적 유틸리티 클래스로 구현한 모습을 드물지 않게 볼 수 있다
정적 유틸리티를 잘못 사용한 경우 유연하지 않고 테스트하기 어렵다
public class SpellChecker {
private static final Lexicon dictionary = ...;
private SpellChgecker() {} // 객체 생성 방지
public static boolean isValid(String word) {
...
}
public static List<String> suggestions(String typo) {
...
}
}
비슷하게 싱글턴으로 구현하는 경우도 흔하다
싱글턴을 잘못 사용한 경우 유연하지 않고 테스트하기 어렵다
public class SpellChecker {
private final Lexicon dictionary = ...;
private SpellChgecker(...) {}
private static SpellChecker INSTANCE = new SpellChecker(...);
public static boolean isValid(String word) {
...
}
public static List<String> suggestions(String typo) {
...
}
}
두 방식 모두 사전을 단 하나만 사용한다고 가정한다는 점에서 좋은 방법이 아니다
실전에서는 사전이 언어별로 따로 있고 특수 어휘용 사전을 별도로 두기도 한다
심지어 테스트용 사전도 필요할 수 있으므로, 사전 하나로 이 모든 쓰임에 대응할 수 있기를 바라는 건 너무 순진한 생각이다
그렇다면 SpellChecker 가 여러 사전을 사용할 수 있도록 하기 위해선 어떻게 해야 할까?
바로 인스턴스를 생성할 때 생성자에 필요한 자원(사전)을 넘겨주는 방식을 사용하면 된다
이는 의존 객체 주입의 한 형태로, 맞춤법 검사기를 생성할 때 의존 객체인 사전을 주입해 주면 된다
public class SpellChecker {
private final Lexicon dictionary;
public SpellChgecker(Lexicon dictionary) {
this.dictionary = Objects.requireNonNull(dictionary);
}
public static boolean isValid(String word) {
...
}
public static List<String> suggestions(String typo) {
...
}
}
- 핵심 정리
클래스가 내부적으로 하나 이상의 자원에 의존하고, 그 자원이 클래스 동작에 영향을 준다면
싱글턴과 정적 유틸리티 클래스는 사용하지 않는 것이 좋다
이 자원들을 클래스가 직접 만들게 해서도 안 된다
대신 필요한 자원을(혹은 그 자원을 만들어주는 팩토리) 생성자(혹은 정적 팩토리나 빌더)에 넘겨주자
의존 객체 주입이라 하는 이 기법은 클래스의 유연성, 재사용성, 테스트 용이성을 기막히게 개선해 준다
6. 불필요한 객체 생성을 피하라
똑같은 기능의 객체를 매번 생성하기보다는 객체 하나를 재사용하는 편이 나을 때가 많다
재사용은 빠르고 세련되다. 특히 불변 객체는 언제든 재사용할 수 있다
생성 비용이 아주 비싼 객체가 더러 있다
이런 비싼 객체가 반복해서 필요하다면 캐싱하여 재사용하길 권한다
(안타깝게도 자신이 만드는 객체가 비싼 객체인지를 매번 명확히 알 수는 없다...)
예를 들어 주어진 문자열이 유효한 로마 숫자인지 확인하는 메서드를 작성한다고 해보자
다음은 정규표현식을 활용한 가장 쉬운 해법이다
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 메서드를 사용한다는 데 있다
String.matches는 정규표현식으로 문자열 형태를 확인하는 가장 쉬운 방법이지만, 성능이 중요한 상황에서 반복해 사용하기엔 적합하지 않다
이 메서드가 내부에서 만드는 정규표현식용 Pattern 인스턴스는, 한 번 쓰고 버려져서 곧바로 가비지 컬렉션 대상이 된다
Pattern은 입력받은 정규표현식에 해당하는 유한 상태 머신(finite state machine)을 만들기 때문에 인스턴스 생성 비용이 높다
성능을 개선하려면 필요한 정규표현식을 표현하는 불변 Pattern 인스턴스를 클래스 초기화(정적 초기화) 과정에서 직접 생성해 캐싱해 두고, 나중에 isRomanNumeral 메서드가 호출될 때마다 이 인스턴스를 재사용하는 방법을 사용하면 된다
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();
}
}
개선된 isRomanNumeral 방식의 클래스가 초기화된 후 이 메서드를 한 번도 호출하지 않는다면 ROMAN 필드는 쓸데없이 초기화된 꼴이다
isRomanNumeral 메서드가 처음 호출될 때 필디를 초기화하는 지연 초기화로 불필요한 초기화를 없앨 수는 있지만, 권하지 않는다
지연 초기화는 코드를 복잡하게 만드는데, 성능은 크게 개선되지 않을 때가 많기 때문이다
객체가 불변이라면 재사용해도 안전함이 명백하다
하지만 훨씬 덜 명확하거나, 심지어 직관에 반대되는 상황도 있다
예를 들어 Map 인터페이스의 KeySet 메서드는 Map 객체 안의 키 전부를 담은 Set 뷰를 반환한다
KeySet을 호출할 때마다 새로운 set 인스턴스가 만들어지리라고 예상할 수 있지만
사실은 매번 같은 Set 인스턴스를 반환할지도 모른다
반환된 Set 인스턴스가 일반적으로 가변이더라도 반환된 인스턴스들은 기능적으로 모두 똑같다
즉, 반환한 객체 중 하나를 수정하면 다른 모든 객체가 따라서 바뀐다
모두가 똑같은 Map 인스턴스를 대변하기 때문이다
따라서 KeySet이 뷰 객체를 여러 개 만들어도 상관은 없지만, 그럴 필요도 없고 이득도 없다
불필요한 객체를 만들어내는 또 다른 예로 오토박싱(auto boxing)을 들 수 있다
오토박싱은 프로그래머가 기본 타입과 박싱 된 기본 타입을 섞어 쓸 때 자동으로 상호 변환해 주는 기술이다
오토박싱은 기본 타입과 그에 대응하는 박싱 된 기본 타입의 구분을 흐려주지만, 완전히 없애주는 것은 아니다
의미상으로는 별다를 것 없지만 성능에서는 그렇지 않다
다음 메서드를 보자. 모든 양의 정수의 총합을 구하는 메서드로 int는 충분히 크지 않으니 long을 사용해 계산하고 있다
static long sum(){
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
return sum;
}
끔찍하게 느리다
sum 변수를 long이 아닌 Long으로 선언해서 불필요한 Long 인스턴스가 약 231개 만들어진 것이다
(대략, long 타입인 i가 Long 타입인 sum에 더해질 때마다)
단순히 sum의 타입을 long으로만 바꿔줘도 속도 차이가 아주 크다
박싱 된 기본 타입보다는 기본 타입을 사용하고, 의도치 않은 오토박싱이 숨어들지 않도록 주의하자
기존 객체를 재사용해야 한다면 새로운 객체를 만들지 마라
새로운 객체를 만들어야 한다면 기존 객체를 재사용하지 마라
하지만 방어적 복사가 필요한 상황에서 객체를 재사용했을 때의 피해가,
필요 없는 객체를 반복 생성했을 때의 피해보다 훨씬 크다는 사실을 기억하자
방어적 복사에 실패하면 언제 터져 나올지 모르는 버그와 보안 구멍으로 이어지지만, 불필요한 객체 생성은 그저 코드 형태와 성능에만 영향을 준다
7. 다 쓴 객체 참조를 해제하라
자바처럼 가비지 컬렉터를 갖춘 언어라면 다 쓴 객체를 알아서 회수해 가니 메모리 관리에 더 이상 신경 쓰지 않아도 된다고 오해할 수 있는데, 절대 사실이 아니다
스택을 구현한 다음 코드를 통해 메모리 누수가 발생한다는 사실을 알아보자
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);
}
}
}
특별히 문제는 없어 보인다
하지만 꼭꼭 숨어있는 문제가 있다. 이는 바로 메모리 누수이다
이 스택을 사용하는 프로그램을 오래 실행하다 보면 점차 가비지 컬렉션 활동과 메모리 사용량이 늘어나 결국 성능이 저하될 것이다
상대적으로 드문 경우긴 하지만 심할 때는 디스크 페이징이나 OutOfMemoryError를 일으켜 프로그램이 예기치 않게 종료되기도 한다
이 코드에서 스택이 커졌다가 줄어들었을 때 스택에서 꺼내진 객체들을 가비지 컬렉터가 회수하지 않아서 메모리 누수가 일어난다
프로그램에서 그 객체들을 더 이상 사용하지 않더라도 말이다
이 스택이 그 객체들의 다 쓴 참조(obsolete reference)를 여전히 가지고 있기 때문이다
여기서 다 쓴 참조란 문자 그대로 앞으로 다시 쓰지 않을 참조를 뜻 한다
앞의 코드에서는 elements 배열의 '활성 영역' 밖의 참조들이 모두 여기에 해당한다
활성 영역은 인덱스가 size보다 작은 원소들로 구성된다
이를 해결하는 방법은 간단하다
해당 참조를 다 썼을 때 null 처리(참조 해제)하면 된다
다음은 pop 메서드를 제대로 구현한 코드이다
public Object pop(){
if(size == 0) {
throw new EmptyStackException();
}
Object result = elements[--size];
elements[size] = null; // 다 쓴 참조 해제
return result;
}
다 쓴 참조를 null 처리하면 다른 이점도 따라온다
만약 null 처리한 참조를 실수로 사용하려 하면 프로그램은 즉시 NullPointerException을 던지며 종료된다
하지만 모든 객체를 다 쓰자마자 일일이 null 처리를 하는 것은 바람직하지 않다
프로그램을 필요 이상으로 지저분하게 만들 뿐이다
객체 참조를 null 처리하는 일은 예외적인 경우여야 한다
다 쓴 참조를 해제하는 가장 좋은 방법은 그 참조를 담은 변수를 유효 범위(scope) 밖으로 밀어내는 것이다
변수의 범위를 최소가 되게 정의했다면 이 일은 자연스럽게 이뤄진다
- 그렇다면 언제 null 처리를 해야 할까? Stack 클래스는 왜 메모리 누수에 취약했던 걸까?
바로 스택이 자기 메모리를 직접 관리하기 때문이다
이 스택은 elements 배열로 저장소 풀을 만들어 원소들을 관리한다
배열의 활성 영역에 속한 원소들이 사용되고 비활성 영역은 쓰이지 않는다
하지만 가비지 컬렉터는 이 사실을 알 길이 없다
가비지 컬렉터 입장에서는 비활성 영역에서 참조하는 객체도 똑같이 유요한 객체다
그러므로 프로그래머는 비활성 영역이 되는 순간 null 처리해서 해당 객체를 더는 쓰지 않을 것임을 가비지 컬렉터에게 알려야 한다
일반적으로 자기 메모리를 직접 관리하는 클래스라면 프로그래머는 항시 메모리 누수에 주의해야 한다
원소를 다 사용한 즉시 그 원소가 참조한 객체들을 다 null 처리해줘야 한다
- 캐시 역시 메모리 누수를 일으키는 주범이다
객체 참조에 넣고 나서, 이 사실을 까맣게 잊은 채 그 객체를 다 쓴 뒤로도 한참을 그냥 놔두는 일을 자주 접할 수 있다
운 좋게 캐시 외부에서 Key를 참조하는 동안만 엔트리가 살아 있는 캐시가 필요한 상황이라면 WeakHashMap을 사용해 캐시를 만들자
다 쓴 엔트리는 그 즉시 자동으로 제거될 것이다
단, WeakHashMap은 이러한 상황에서만 유용하다는 사실을 기억하자
캐시를 만들 때 보통은 캐시 엔트리의 유효 기간을 정확히 정의하기 어렵기 때문에 시간이 지날수록 엔트리의 가치를 떨어뜨리는 방식을 흔히 사용한다
이런 방식에서는 쓰지 않는 엔트리를 이따금 청소해줘야 한다
ScheduledThreadPoolExecutor 같은 백그라운드 스레드를 활용하거나 캐시에 새 엔트리를 추가할 때 부수 작업으로 수행하는 방법이 있다
LinkedHashMap은 removeEldestEntry 메서드를 써서 후자의 방식으로 처리한다
더 복잡한 캐시를 만들고 싶다면 java.lang.ref 패키지를 직접 활용해야 할 것이다
- 메모리 누수의 세 번째 주범은 바로 리스너(listener) 혹은 콜백(callback)이라 부르는 것이다
클라이언트가 콜백을 등록만 하고 명확히 해지하지 않는다면, 뭔가 조치해주지 않는 한 콜백은 계속 쌓여갈 것이다
이럴 때 콜백에 약한 참조(weak reference)로 저장하면 가비지 컬렉터가 즉시 수거해 간다
예를 들어 WeakHashMap에 키로 저장하면 된다
- 핵심 정리
메모리 누수는 겉으로 잘 드러나지 않아 시스템에 수년간 잠복하는 사례도 있다
이런 누수는 철저한 코드 리뷰나 힙 프로파일러 같은 디버깅 도구를 동원해야만 발견되기도 한다
그래서 이런 종류의 문제는 예방법을 익혀두는 것이 매우 중요하다
8. finalizer와 cleaner 사용을 피하라
자바는 두 가지 객체 소멸자를 제공한다
그중 finalizer는 예측할 수 없고, 상황에 따라 위험할 수 있어 일반적으로 불필요하다
오동작, 낮은 성능, 이식성 문제의 원인이 되기도 한다
finalizer도 나름의 쓰임새가 몇 가지 있기는 하지만 기본적으로 쓰지 말아야 한다
그래서 자바 9에서는 finalizer를 사용 자제(deprecated) API로 지정하고 cleaner를 그 대안으로 소개했다
cleaner는 finalizer보다는 덜 위험하지만, 여전히 예측할 수 없고, 느리고, 일반적으로 불필요하다
finalizer와 cleaner는 즉시 수행된다는 보장이 없다
객체에 접근할 수 없게 된 후 finalizer나 cleaner가 실행되기까지 얼마나 걸릴지 알 수 없다
즉, finalizer와 cleaner로는 제때 실행되어야 하는 작업은 절대 할 수 없다
예를 들어 파일 닫기를 finalizer나 cleaner에 맡기면 중대한 오류를 일으킬 수 있다
시스템이 동시에 열 수 있는 파일 개수에 한계가 있기 때문이다
시스템이 finalizer나 cleaner 실행을 게을리해서 파일을 계속 열어 둔다면 새로운 파일을 열지 못해 프로그램이 실패할 수 있다
finalizer나 cleaner를 얼마나 신속히 수행할지는 전적으로 가비지 컬렉터 알고리즘에 달렸으며, 이는 가비지 컬렉터 구현마다 천차만별이다
자바 언어 명세는 finalizer나 cleaner의 수행 시점뿐 아니라 수행 여부조차 보장하지 않는다
접근할 수 없는 일부 객체에 딸린 종료 작업을 전혀 수행하지 못한 채 프로그램이 중단될 수도 있다는 얘기다
따라서 프로그램 생애주기와 상관없는, 상태를 영구적으로 수정하는 작업에서는 절대 finalizer나 cleaner에 의존해서는 안된다
예를 들어 데이터베이스 같은 공유 자원의 영구 락(lock) 해제를 finalizer나 cleaner에 맡겨 놓으면 분산 시스템 전체가 서서히 멈출 것이다
finalizer와 cleaner는 심각한 성능 문제도 동반하며,
finalizer를 사용한 클래스는 finalizer 공격에 노출되어 심각한 보안 문제를 일으킬 수 있다
- 그렇다면 파일이나 스레드 등 종료해야 할 자원을 담고 있는 객체의 클래스에서 finalizer나 cleaner를 대신해 줄 묘안은 무엇일까?
AutoCloseable을 구현해 주고, 클라이언트에서 인스턴스를 다 쓰고 나면 close 메서드를 호출하면 된다
각 인스턴스는 자신이 닫혔는지를 추적하는 것이 좋다
다시 말해 close 메서드에서 이 객체는 더 이상 유효하지 않음을 필드에 기록하고
다른 메서드는 이 필드를 검사해서 객체가 닫힌 후에 불렸다면 IllegalStateException을 던지는 것이다
- 그럼 대체 finalizer나 cleaner는 대체 어디에 쓰는 물건일까?
적절한 쓰임새가 두 가지 있다
1) 하나는 자원의 소유자가 close 메서드를 호출하지 않는 것에 대비한 안전망 역할이다
finalizer나 cleaner가 즉시(혹은 끝까지) 호출되리라는 보장은 없지만, 클라이언트가 하지 않은 자원 회수를 늦게라도 해주는 것이 아예 안 하는 것보다는 나으니 말이다
이런 안전망 역할의 finalizer를 작성할 때는 그럴만한 값어치가 있는지 심사숙고하자
자바 라이브러리의 일부 클래스는 안전망 역할의 finalizer를 제공한다
FileInputStream, FileOutputStream, ThreadPoolExecutor가 대표적이다
2) finalizer와 cleaner를 적절히 활용하는 두 번째 예는 네이티브 피어(native peer)와 연결된 객체에서다
네이티브 피어란 일반 자바 객체가 네이티브 메서드를 통해 기능을 위임한 네이티브 객체를 말한다
네이티브 피어는 자바 객체가 아니니 가비지 컬렉터는 그 존재를 알지 못한다
그 결과 자바 피어를 회수할 때 네이티브 객체까지 회수하지 못한다
finalizer나 cleaner가 나서서 처리하기에 적당한 작업이다
단, 성능 저하를 감당할 수 있고 네이티브 피어가 심각한 자원을 가지고 있지 않을 때만 해당된다
성능 저하를 감당할 수 없거나 네이티브 피어가 사용하는 자원을 즉시 회수해야 한다면 앞서 설명한 close 메서드를 사용해야 한다
cleaner는 사용하기에 조금 까다롭다
다음의 Room 클래스로 이 기능을 설명해 보겠다
방(room) 자원을 수거하기 전에 반드시 청소(clean) 해야 한다고 가정해 보자
Room 클래스는 AutoCloseable을 구현한다
사실 자동 청소 안전망이 cleaner를 사용하지 말지는 순전히 내부 구현 방식에 관한 문제다
즉, finalizer와 달리 cleaner는 클래스의 public API에 나타나지 않는다는 이야기다
import java.lang.ref.Cleaner;
public class Room implements AutoCloseable {
private static final Cleaner cleaner = Cleaner.create();
// 청소가 필요한 자원. 절대 Room을 참조해서는 안 된다!
private static class State implements Runnable{
int numJunkPiles; // Room 안의 쓰레기 수
State(int numJunkPiles){
this.numJunkPiles = numJunkPiles;
}
// close 메서드나 cleaner가 호출한다.
@Override
public void run() {
System.out.println("방 청소");
numJunkPiles = 0;
}
}
// 방의 상태. Cleanable과 공유한다
private final State state;
// Cleanable 객체. 수거 대상이 되면 방을 청소한다
private final Cleaner.Cleanable cleanable;
public Room(int numJunkPiles){
state = new State(numJunkPiles);
cleanable = cleaner.register(this, state);
}
@Override
public void close() throws Exception {
cleanable.clean();
}
}
static으로 선언된 중첩 클래스인 State는 cleaner가 방을 청소할 때 수거할 자원들을 담고 있다
이 예에서는 단순히 방 안의 쓰레기 수를 뜻하는 numJunkPiles 필드가 수거할 자원에 해당한다
더 현실적으로 만들려면 이 필드는 네이티브 피어를 가리키는 포인터를 담은 final long 변수여야 한다
State는 Runnable을 구현하고, 그 안의 run 메서드는 cleanable에 의해 딱 한 번만 호출될 것이다
이 cleanable 객체는 Room 생성자에서 cleaner에 Room과 State를 등록할 때 얻는다
run 메서드가 호출되는 상황은 둘 중 하나다
보통은 Room의 Close 메서드를 호출할 때다
close 메서드에서 Cleanable의 clean을 호출하면 이 메서드 안에서 run을 호출한다
혹은 가비지 컬렉터가 Room을 회수할 때까지 클라이언트가 close를 호출하지 않는다면,
cleaner가 State의 run 메서드를 호출해 줄 것이다 (보장할 순 없다)
State 인스턴스는 '절대로' Room 인스턴스를 참조해서는 안된다
Room 인스턴스를 참조할 경우 순환참조가 생겨 가비지 컬렉터가 Room 인스턴스를 회수해갈 기회가 오지 않는다
State가 정적 중첩 클래스인 이유가 여기에 있다
정적이 아닌 중첩 클래스는 자동으로 바깥 객체의 참조를 갖게 되기 때문이다
이와 비슷하게 람다 역시 바깥 객체의 참조를 갖기 쉬우니 사용하지 않는 것이 좋다
앞서 이야기한 대로 Room의 cleaner는 단지 안전망으로만 쓰였다
- 클라이언트가 모든 Room 생성을 try-with-resources 블록으로 감쌌다면 자동 청소는 전혀 필요하지 않다
다음은 잘 짜인 클라이언트 코드의 예다
public class Adult {
public static void main(String[] args) {
try(Room room = new Room(7)){
System.out.println("안녕~");
}
}
}
기대한 대로 Adult 프로그램은 "안녕~"을 출력한 후, 이어서 "방 청소"를 출력한다
이번엔 잘못된 예를 살펴보자
public class Teenager {
public static void main(String[] args) {
new Room(99);
System.out.println("아무렴");
}
}
System.exit을 호출할 때의 cleaner 동작은 구현하기 나름이다. 청소가 이뤄질지는 보장하지 않는다.
- 핵심 정리
cleaner(자바 8까지는 finalizer)는 안전망 역할이나 중요하지 않은 네이티브 자원 회수용으로만 사용하자
물론 이런 경우라도 불확실성과 성능 저하에 주의해야 한다
9. try-finally 보다는 try-with-resources를 사용하라
자바 라이브러리에는 close 메서드를 호출해 직접 닫아줘야 하는 자원이 많다
InputStream, OutputStream, java.sql.Connection 등이 좋은 예다
자원 닫기는 클라이언트가 놓치기 쉬워서 예측할 수 없는 성능 문제로 이어지기도 한다
- 전통적인 자원이 제대로 닫힘을 보장하는 수단으로 try-finally가 쓰였다
예외가 발생하거나 메서드에서 반환되는 경우를 포함해서 말이다
static String firstLineOfFile(String path) throws IOException {
BufferedReader br = new BufferedReader(new FileReader(path));
try {
return br.readLine();
} finally {
br.close();
}
}
나쁘지 않지만 자원을 하나 더 사용한다면 어떨까?
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[100];
int n;
while((n = in.read(buf)) >= 0) {
out.write(buf, 0, n);
}
} finally {
out.close();
}
} finally {
in.close();
}
}
코드가 지저분해지며 실수를 저지르기 쉽다
또한 try-finally 문을 제대로 사용한 앞의 두 코드 예제에조차 미묘한 결점이 있다
예외는 tryh 블록과 finally 블록 모두에서 발생할 수 있다
예로 기기에 물리적 문제가 생길 경우 firstLineOfFile 메서드 안의 readLine 메서드가 예외를 던지고, 같은 이유로 close 메서드도 실패할 것이다
이런 상황이라면 두 번째 예외가 첫 번째 예외를 완전히 집어삼키게 된다
이로 인해 실제 오류 파악이 힘들게 되고 디버깅을 몹시 어렵게 한다
- 이러한 문제들은 자바 7에서 생긴 try-with-resources 덕에 모두 해결되었다
이 구조를 사용하려면 해당 자원이 AutoCloseable 인터페이스를 구현해야 한다
단순히 void를 반환하는 close 메서드 하나만 덩그러니 정의한 인터페이스다
자바 라이브러리와 서드파티 라이브러리들의 수많은 클래스와 인터페이스가 이미 AutoCloseable을 구현하거나 확장해 뒀다
만일 닫아야 하는 자원을 뜻하는 클래스를 작성한다면 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 Exception {
try (InputStream in = new FileInputStream(src);
OutputStream out = new FileOutputStream(dst)) {
byte[] buf = new byte[100];
int n;
while ((n = in.read(buf)) >= 0) {
out.write(buf, 0, n);
}
}
}
try-with-resources 버전이 가독성이 더 좋을 뿐 아니라 문제를 진단하기도 훨씬 좋다
firstLineOfFile 메서드를 생각해 보자
readLine과 close 호출 양쪽에서 예외가 발생하면, close에서 발생한 예외는 숨겨지고 readLine에서 발생한 예외가 기록된다
이처럼 실전에서는 프로그래머에게 보여줄 예외 하나만 보존되고 여러 개의 다른 예외가 숨겨질 수도 있다
이렇게 숨겨진 예외들도 그냥 버려지지는 않고, 스택 추적 내역에 '숨겨졌다(suppressed)'는 꼬리표를 달고 출력된다
또한 자바 7에서 Throwable에 추가된 getSuppressed 메서드를 사용하면 프로그램 코드에서 가져올 수도 있다
try-with-resources에서도 catch 절을 쓸 수 있다
catch 절 덕분에 try 문에 더 중첩하지 않고도 다수의 예외를 처리할 수 있다
static String firstLineOfFile(String path) {
try (BufferedReader br = new BufferedReader(new FileReader(path))) {
return br.readLine();
} catch (IOException e) {
return defaultVal;
}
}
- 핵심 정리
꼭 회수해야 하는 자원을 다룰 때는 try-finally 말고, try-with-resources를 사용하자
예외는 없다. 코드는 더 짧고 분명해지고, 만들어지는 예외 정보도 훨씬 유용하다
try-finally로 작성하면 실용적이지 못할 만큼 코드가 지저분해지는 경우라도,
try-with-resources로는 정확하고 쉽게 자원을 회수할 수 있다
마무리 느낀점
몰랐던 내용이 너무 많았다
생소한 용어도 많아서 이해가 완벽하게 되지 않은 부분도 아직 있지만 많은 도움이 되었다
특히 try-with-resources 같은 경우는 실무에서 많이 사용하게 될 것 같다
내용을 잊지 않도록 주기적으로 정리한 내용을 확인해야겠다
'개발 서적 > 이펙티브 자바' 카테고리의 다른 글
이펙티브 자바 3/E - 5.제네릭(1) (0) | 2023.07.14 |
---|---|
이펙티브 자바 3/E - 4.클래스와 인터페이스(2) (3) | 2023.06.18 |
이펙티브 자바 3/E - 4.클래스와 인터페이스(1) (1) | 2023.06.10 |
이펙티브 자바 3/E - 3.모든 객체의 공통 메서드 (1) | 2023.05.20 |
이펙티브 자바 3/E - 1.들어가기 (0) | 2023.04.19 |