최근에 나는 자바를 내 입맛대로, 올바르게 사용하고 있는가에 대한 의문이 생겨 공부 거리를 찾아보니 이펙티브 자바라는 책을 발견했습니다. 이전에도 한번 읽어보려고 노력했으나 ITEM 1 부터 무슨 소린지 이해할 수 가 없어서 포기했었는데 다시 시도해보고 싶어졌습니다. 여전히 무슨 내용인지 모르겠지만… 결국 반복학습으로 이해할 날이 올 것을 기대하며 제 나름의 이해한 내용을 남겨봅니다.
이 내용을 이해하기엔 아직 부족해서 설명이 다소 두루뭉술할 수 있습니다…
1. 생성자 대신 static 팩토리 메소드를 고려해라
가장 기본 적인 인스턴스 생성 방법 - public 생성자
정적 팩토리 메소드는 의도가 뚜렷한 인스턴스 생성 가능 ex) Boolean valueOf()
public static Boolean valueOf(boolean b) {
return b ? Boolean.TRUE : Boolean.FALSE;
}
기대효과
1. 이름을 가질 수 있다. 똑같은 파라미터타입 을 받는 생성자는 중복될 수 없도록 되어있습니다.
public static Foo(String name)
public static Foo(String major) 불가
2. 반드시 새로운 객체를 만들 필요가 없습니다.
3. 리턴 타입의 하위 타입 객체를 반환할 수 있습니다.
4. 리턴하는 객체의 클래스가 입력 매개변수에 따라 매번 다를 수 있습니다.
5. 리턴하는 객체의 클래스가 public static 팩토리 메소드를 작성할 시점에 반드시 존재하지 않아도 된다. (다소 이해 x)
인터페이스 정의: Vehicle 인터페이스를 정의합니다.
팩토리 클래스 작성: VehicleFactory 클래스를 작성합니다. 이 클래스는 Car와 Bike 클래스를 반환하도록 설계되어 있지만, 이 시점에서 Car와 Bike 클래스는 아직 존재하지 않습니다.
구현체 추가: Car와 Bike 클래스를 나중에 추가합니다.
팩토리 메서드 사용: VehicleFactory를 통해 Vehicle 객체를 생성하고 사용합니다.
이 과정을 통해, 정적 팩토리 메서드를 작성할 때 반드시 반환할 객체의 클래스가 존재할 필요는 없으며, 나중에 필요에 따라 추가될 수 있음을 알 수 있습니다. 이러한 유연성 덕분에 서비스 제공자 프레임워크와 같은 유연한 설계를 구현할 수 있습니다.
결론 - 생성자보다 static 팩토리 메소드는 더 유연한 특징을 갖고 있다.
2. 생성자 매개변수가 많은 경우에 빌더 사용을 고려해라
생성자를 관리하는 방법
1. 점층적 생성자 패턴 매개변수별로 모든 생성자 정의하는 방식으로 코드 가독성이 떨어지고 에러를 찾기도 힘듭니다.
package me.catsbi.effectivejavastudy.item2.domain;
public class NutritionalInformation {
private final int calorie; //칼로리 - 필수
private final int sodium; //나트륨
private final int carbohydrate; // 탄수화물 - 필수
private final int sugars; //당류
private final int fat; //지방 - 필수
private final int transFat; // 트랜스지방
private final int saturatedFat; //포화지방
private final int cholesterol; //콜레스테롤
private final int protein; // 단백질 - 필수
public NutritionalInformation() {
this(0, 0, 0, 0, 0, 0, 0, 0, 0);
}
public NutritionalInformation(int calorie, int carbohydrate, int fat, int protein) {
this(calorie, 0, carbohydrate, 0, fat, 0, 0, 0, protein);
}
public NutritionalInformation(int calorie, int sodium, int carbohydrate, int sugars, int fat, int transFat, int saturatedFat, int cholesterol, int protein) {
this.calorie = calorie;
this.sodium = sodium;
this.carbohydrate = carbohydrate;
this.sugars = sugars;
this.fat = fat;
this.transFat = transFat;
this.saturatedFat = saturatedFat;
this.cholesterol = cholesterol;
this.protein = protein;
}
}
2. 자바 빈즈 패턴 (setter 방식) 기본 생성자 생성후 setter를 사용해서 필드값을 설정하는 방법입니다. 가독성은 개선되었지만 setter 특성상 어디서든 필드값이 수정될 수 있다보니 객체의 불변성을 보장할 수 가 없습니다.
3. 빌더 패턴 필요한 필수 매개변수만으로 정적 팩토리 메서드를 이용해 빌더 객체를 얻은 뒤 빌더 객체가 제공하는 세터 메서드들로 필요한 선택 매개변수를 입력하는데, 각각의 세터 메서드는 값을 설정한 뒤 자기자신(Builder)을 반환하기 때문에 연속해서 메소드를 호출할 수 있고, 마지막으로 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();
// Subclasses must override this method to return "this"
protected abstract T self();
}
Pizza(Builder<?> builder) {
toppings = builder.toppings.clone(); // See Item 50
}
}
@Builder를 사용해서 코드를 줄일 수도 있지만 정확히 내부 코드가 어떻게 동작하는지 알기 어렵습니다.
3. private 생성자 또는 enum 타입을 사용해서 싱글톤으로 만들 것
싱글톤으로 만드는 법
1. public static 멤버가 final 필드인 방식
public class Elvis {
public static final Elvis INSTANCE = new Elvis();
private Elvis() {
}
public void speak() {
System.out.println("elvis");
}
}
2. 정적 팩토리 메소드를 public static으로 제공하는 방식
public class Elvis {
private static final Elvis INSTANCE = new Elvis();
private Elvis() {
}
public static Elvis getInstance() {
return INSTANCE;
}
public void speak() {
System.out.println("elvis");
}
}
3. 열거 타입
public enum Elvis {
INSTANCE;
public void speak() {
System.out.println("elvis");
}
}
1, 2번에서 발생할 수 있는 문제 - 직렬화를 적용할때 implements Serializable 만으로 부족합니다. 역직렬화 할 때마다 호출되는 타입의 인스턴스가 여러개가 될 수 있기 때문에 필드를 transient로 선언하고 readResolve()를 재정의해야합니다.
3번이 적절할거 같지만 이건 너무 이상적이고 현실적으로는 @Autowired 사용
4. 인스턴스화를 막으려거든 private 생성자를 사용하라
유틸리티 클래스
인스턴스가 필요 없지만 생성자를 명시지 않으면 컴파일러가 자동으로 public 생성자를 생성하는 문제가 있습니다.
→ abstract로 만들어서 막을 수 있습니다.
하지만 상속받는 경우 인스턴스 생성 가능합니다.
→ private 생성자를 추가하고 throw assertion error을 설정합니다.
5. 자원을 직접 명시하지 말고 의존 객체 주입을 사용하라.
문제
의존성에 따라 행동을 달리 해야하는 경우 static class와 싱글톤 사용은 부적절하다.
// 정적 유틸리티 클래스
public class SpellChecker {
private static final Lexicon dictionary = new Lexicon();
// EnglishDictionary
private SpellChecker() {
}
public static boolean isValid(String word) {
// dictionary를 사용한 로직
}
public static List<String> suggestions(String typo) {
// dictionary를 사용한 로직
}
}
// 싱글톤
public class SpellChecker {
private final Lexicon dictionary = new Lexicon();
public static SpellChecker INSTANCE = new SpellChecker();
private SpellChecker() {
}
public static boolean isValid(String word) {
// dictionary를 사용한 로직
}
public static List<String> suggestions(String typo) {
// dictionary를 사용한 로직
}
}
Lexicon dictionary가 가변적이고 어떤 dictionary냐에 따라 행동이 달라진다면 문제가 생깁니다.
해결법
- final을 지우고 다른 사전으로 변경가능하도록 메서드를 추가 - 멀티 쓰레드에서 동시성 문제 가능
- 내부 자원을 외부에서 주입받는 방식
public class SpellChecker {
private final Lexicon dictionary;
public SpellChecker(Lexicon dictionary) {
this.dictionary = dictionary;
}
public static boolean isValid(String word) {
// dictionary를 사용한 로직
}
public static List<String> suggestions(String typo) {
// dictionary를 사용한 로직
}
}
6. 불필요한 객체 생성을 피하라
1. 같은 값의 string은 new String() 아닌 리터럴로 선언
new String()은 매번 주소를 할당하며 리터럴 선언은 String Constant Pool에서 재사용됩니다.
2. new Boolean() 대신 Boolean.valueOf 사용
생성자 대신 static 팩토리 메소드를 고려해라에서도 언급했듯이 Boolean.valueOf는 Boolean 안에 Boolean.True, False를 반환합니다.
// a == b
Boolean a = new Boolean("true");
Boolean b = new Boolean("true");
3. 정규 표현식 패턴 캐싱 String.matches()는 내부에서 정규 표현식용 Pattern 인스턴스는 일회성이라 사용 후 버려지는데 만약 재사용이 많이 된다면 비용이 커집니다. 따라서 Pattern을 캐싱 해두는것이 좋습니다.
public 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})$");
}
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})$");
public static boolean isRomanNumeral(String s) {
return ROMAN.matcher(s).matches();
}
}
어댑터 (이해 부족…)
객체가 불변이라면 재사용해도 문제가 없습니다.
하지만 불변이 보장되지 않는 상황도 있는데, 어댑터 패턴을 생각해보면 실제 객체를 연결해주는 제 2의 인터페이스 역할을 하는 어댑터같은 경우 사용자는 이 어댑터를 사용할 때 뒷 단에서 매번 같은 인스턴스가 반환될 지 동일한 내용에 대해서 동일한 인스턴스를 반환해줄지 알 수 없습니다.
Map 인터페이스가 제공하는 keySet은 Map이 뒤에 있는 Set 인터페이스의 뷰를 제공합니다. keySet을 호출할 때마다 새로운 객체가 나올거 같지만 사실 같은 객체를 리턴하기 때문에 리턴 받은 Set 타입의 객체를 변경하면, 결국에 그 뒤에 있는 Map 객체를 변경하게 됩니다.
public class UsingKeySet {
public static void main(String[] args) {
Map<String, Integer> menu = new HashMap<>();
menu.put("Burger", 8);
menu.put("Pizza", 9);
Set<String> names1 = menu.keySet();
Set<String> names2 = menu.keySet();
names1.remove("Burger");
System.out.println(names2.size()); // 1
System.out.println(menu.size()); // 1
}
}
불필요한 객체를 만들어주는 또 한 가지 경우 - 오토박싱
private static long sum(){
Long sum = 0L;
for(long i=0; i <= Integer.MAX_VALUE; i++){
sum += i;
}
return sum;
}
Long인 sum과 long인 i가 계속해서 더해지는데 더해질때마다 오토박싱되어 Long 인스턴스가 생성된다.
오토박싱은 에러가 발생하지 않으니 알아차리기 어렵습니다.
객체 생성의 비용이 크니까 피하라는것이 아닙니다. JVM의 작은 객체의 생성, 회수는 그리 부담을 주진 않습니다. 반복 생성은 성능에만 영향을 주지만 객체를 방어적으로 복사하는 (defensive copy) 방식의 실패는 버그와 보안문제로 직행됩니다.
7. 다 쓴 객체 참조를 해제하라.
자바에 가비지 컬렉터만 믿고 메모리 관리를 안해도 된다고 생각할 수 있지만 몇몇의 경우 따로 관리가 필요합니다.
객체 참조가 해제되면 수거 대상이 되지만 참조 객체가 리스트에 저장되어 있다면 사용되지 않더라도 수거 대상이 되지 않습니다.
public class Stack {
private Object[] elements;
private int size = 0;
private static final int DEFAULT_INITIAL_CAPACITY = 16;
public Stack() {
this.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];
}
}
stack pop은 실제로 값을 빼진 않습니다. 다 쓴 참조 주소들을 여전히 갖고 있기 때문에 수거 되지 않습니다.
메모리 누수 원인 & 해결책
1. stack의 예시에서 문제를 해결하는 법은 참조를 해제(null 처리) 해주면 됩니다. 그렇다고 해서 매번 null로 설정해 줄 필요는 없으며 더 좋은 방법은 scope 밖으로 밀어내는 것입니다.
2. 캐시
캐시에 객체 참조를 넣고 잊어버리면 메모리에 쌓여있게 됩니다. 이를 해결하기 위해서는 WeakHashMap을 사용하거나 특정시간이 지나면 자동으로 지우는 백그라운드 쓰레드를 사용해야 합니다.
WeakHashMap
자바 레퍼런스 방식에는 여러가지가 있습니다. soft, phantom, strong, weak 등. 우리가 일반적으로 쓰는 방식은 strong 방식입니다. weak reference는 strong reference가 사라졌을때 자동으로 같이 참조가 사라지게 됩니다. 말로는 설명이 어려워서 코드를 보시면 이해가 쉽습니다.
import java.lang.ref.WeakReference;
public class WeakReferenceExample {
public static void main(String[] args) {
// 강한 참조로 객체 생성
MyObject strongRef = new MyObject("Hello, WeakReference!");
// 약한 참조로 객체 참조
WeakReference<MyObject> weakRef = new WeakReference<>(strongRef);
// 강한 참조를 null로 설정하여 객체가 가비지 컬렉터의 대상이 되도록 함
strongRef = null;
// GC 강제 실행
System.gc();
// 약한 참조로 객체 접근 시도
MyObject retrievedObject = weakRef.get();
if (retrievedObject != null) {
System.out.println("Object is still alive: " + retrievedObject.getMessage());
} else {
System.out.println("Object has been garbage collected");
}
}
static class MyObject {
private String message;
public MyObject(String message) {
this.message = message;
}
public String getMessage() {
return message;
}
}
}
3. 리스너, 콜백 콜백을 등록만 한다면 쌓여만 갑니다. 이 또한 WeakHashMap을 활용하여 해결할 수 있습니다
8. finalizer와 cleaner 사용을 피하라
문제
- 우선순위가 낮아서 다른 작업이 바쁘면 실행순서가 계속 밀려서 언제 실행될지 모릅니다.
- 실행 시점뿐 아니라 실행 여부조차 보장되지 않습니다.
- 성능 저하
- 보안문제 - 죽어야하는 인스턴스가 finalize가 실행되지 않아 계속 남아있을수 있고 다른 스태틱 변수에 접근 가능
대안책
- try-with-resoureces를 사용
그러면 어떠한 때에만 사용해야 하는가?
- close가 호출되지 않을 것을 대비한 안전망
- 네이티브 피어와 연결된 객체 - 이 경우 GC에서 관리하지 않기 때문에 직접 회수해야합니다.
9. try-finally보다는 try-with-resources를 사용하라.
우리는 try 블럭을 사용하여 close 메서드를 호출해서 닫아주곤 합니다. 하지만 try 블럭 내에 또 다른 try 블럭이 있다면 어떻게 될까요?
1. 코드가 장황해짐
//worst case
//try 문이 중복되어 가독성도 떨어질뿐 아니라 자원을 명시적으로 closing하는 것도 지저분하다.
public String firstLineOfFile(String path, String fileName) throws IOException {
BufferedReader reader = new BufferedReader(new FileReader(path));
try {
FileOutputStream outputStream = new FileOutputStream(fileName);
try {
String s = reader.readLine();
outputStream.write(s.getBytes(), 0, s.length());
} finally {
outputStream.close();
}
return reader.readLine();
} finally {
reader.close();
}
}
2. 디버깅이 어려워짐
reader.readLine();에서 에러가 발생하고 outputStream.close();에서도 에러가 발생한다고 하면 첫번째로 에러가 발생했던 reader.readLine()이 덮어씌워져 버립니다. 우리는 대게 최초 에러 발생 위치를 알고싶어하지만 이겨우에는 second error가 보여지게 되어 디버깅이 힘들어집니다.
//good case
//try-with-resources를 사용한다.
public String firstLineOfFileV2(String path, String fileName) throws IOException{
try(BufferedReader reader = new BufferedReader(new FileReader(path));
FileOutputStream out = new FileOutputStream(fileName)){
String s = reader.readLine();
out.write(s.getBytes(), 0, s.length());
return s;
}
}
try-with-resources는 AutoCloseable 또는 Closeable 인터페이스를 구현한 모든 객체에 대해서 try 블럭이 끝나면 자동으로 닫힙니다.
# reference
chatgpt
https://steady-coding.tistory.com/619
'Back-end' 카테고리의 다른 글
Java Serializable/Deserializable (1) | 2024.05.02 |
---|---|
Github actions pull_request_target (0) | 2024.01.10 |
[JVM 동작원리] 3. Execution engine (0) | 2023.10.16 |
[JVM 동작원리] 2. Runtime Data Area (0) | 2023.10.12 |
[JVM의 동작원리] 1. 클래스 로더 (0) | 2023.10.10 |