이펙티브 자바 2장 - 객체 생성과 파괴 (item1~9)

Soohyeok Kim·2025년 6월 22일

item1. 생성자 대신 정적 팩토리 메서드를 고려해라

정적 팩토리 메서드로 생성자(new) 대신 클래스의 인스턴스를 얻을 수 있음

정적 팩토리 메서드가 생성자보다 좋은 점 5가지

  • 이름을 가질 수 있음
    • 메서드명으로 어떤 인스턴스를 생성시키는지 의도를 잘 표현할 수 있음
  • 호출될 때마다 인스턴스를 새로 생성하지 않아도 됨
    • 내부적으로 객체를 캐싱하거나 하나의 인스턴스만 반환할 수 있음
  • 반환 타입의 하위 타입 객체를 반환할 수 있는 능력이 있음
    • 반환 타입이 인터페이스거나 추상 클래스면, 구현체를 다양하게 제공가능
  • 입력 매개변수에 따라 매번 다른 클래스의 객체를 반환할 수 있음
  • 정적 팩토리 메서드를 작성하는 시점에는 반환할 객체의 클래스가 존재하지 않아도 됨
    • 반환할 인터페이스만 먼저 설계해두고 구현은 미래에 주입하거나 로딩해도 됨
    • 구현 안된 클래스 대신 Mocking하면 되기 때문에 테스트 유연성도 높음
    • 런타임에 구현체는 있어야함!

단점 2가지

  • 정적 팩토리 메서드만 제공하면 하위 클래스를 만들 수 없음
    • 생성자를 보통 private으로 만들기 때문에 상속을 할 수 없기 때문
  • 정적 팩토리 메서드는 프로그래머가 찾기 어려움
    • 정적 메서드 이름을 직접 정할 수 있다는 점 때문에 직관적이지가 않다는 뜻..
      • 정적 팩토리 메서드에 흔히 사용하는 이름 규칙을 사용하면 어느정도 해소됨

정적 팩토리 메서드에 흔히 사용하는 명명 방식들

  • from : 매개변수를 하나 받아서 해당 타입의 인스턴스를 반환하는 형변환 메서드
    Date d = Date.from(instance);
  • of : 여러 매개변수를 받아 적합한 타입의 인스턴스를 반환하는 집계 메서드
    Set<Rank> faceCards = EnumSet.of(JACK, QUEEN, KING);
  • valueOf : of와 from 보다 좀 더 구체적 의미 포함
    BigInteger prime = BigInteger.valueOf(Integer.MAX_VALUE);
  • getInstance : (매개변수가 있으면) 매개변수로 명시한 인스턴스 반환 (같은 인스턴스임을 보장하지는 않음)
    StackWalker luke = StackWalker.getInstance(options);
  • create : getInstance와 같지만, 매번 새로운 인스턴스를 생성해 반환함을 보장함
    Object newArray = Array.create(classObject, arrayLen);
  • getOOO (type) : getInstance와 같으나, 다른 클래스에 팩터리 메서드를 정의할 때 씀
    FileStore fs = Files.getFileStore(path); // Files 클래스 안에서 FileStore를 얻고 있음
  • newOOO (type) : create와 같으나, 다른 클래스에 팩터리 메서드를 정의할 때 씀
    BufferedReader br = Files.newBufferedReader(path); // Files 클래스 안에서 새로운 BufferedReader를 생성하고 있음
  • OOO (type) : getOOO과 newOOO의 간결버전
    List<Complaint> litany = Collections.list(legacyListany);

정적 팩토리 메서드와 public 생성자는 각각의 쓰임새가 있지만 정적 팩토리를 쓰는게 유리한 경우가 많음


item2. 생성자에 매개변수가 많다면 빌더를 고려해라

  • 생성자의 매개변수가 많아지면 호출 코드의 가독성/안정성이 심각하게 떨어짐
  • 점층적 생성자 패턴이나 자바빈 패턴은 한계가 있음
  • Builder 패턴은 복잡한 객체 생성 시 가장 유연하고 명확한 대안임

점층적 생성자 패턴

  • 생성자를 계속 늘려가는 어찌보면 무식한 방법..
  • 배개변수 순서가 바뀌면 버그 지옥이 발생함
  • 인자의 의미를 파악하기 어려움
    NutritionFacts facts = new NutritionFacts(240, 8, 100, 0, 35, 5); // 숫자가 각각 뭐를 뜻하는거지?

자바빈 패턴

  • 매개변수 없는 기본 생성자로 객체를 생성하고 setter를 사용하는 방식
  • setter를 쓰기 때문에 불변성을 보장할 순 없다 (수정가능성 때문에 필요할 때 적용못함)
  • setter로 모든 인자가 저장되기전에 객체가 사용될 수 있는 위험이 있음

빌더 패턴

  • 불변 객체를 안전하게 생성할 수 있음
  • named parameter를 쓰는 것처럼 객체를 생성할 수 있음
public class User {
    private final String name;
    private final int age;

    public static class Builder {
        private String name = "";
        private int age = 0;

        public Builder name(String val) {
            this.name = val;
            return this;
        }

        public Builder age(int val) {
            this.age = val;
            return this;
        }

        public User build() {
            return new User(this);
        }
    }

    private User(Builder builder) {
        this.name = builder.name;
        this.age = builder.age;
    }
}

// 무슨 인자에 어떤 값이 들어가는지 명확함
User user = new User.Builder()
    .name("홍길동")
    .age(28)
    .build();

매개변수가 많다면 빌더 패턴을 쓰는게 낫다.


item3. private 생성자나 열거 타입으로 싱글턴임을 보증하라

  • 싱글턴(singleton): 인스턴스를 단 하나만 생성할 수 있도록 보장하는 패턴임
  • 2가지 방법 추천:
    1. private 생성자 + public static 필드/메서드
    2. enum 타입

private 생성자 방식

public class Elvis {
    private static final Elvis INSTANCE = new Elvis(); // 클래스가 처음 로딩될 때 딱 한 번만 실행됨

    private Elvis() {} // 외부에서 생성 불가, 초기화 할 때 한 번만 호출됨

    public static Elvis getInstance() { return INSTANCE; }

    public void eat() {
        System.out.println("밥은 먹고 하자");
    }
}
  • 간단하고 getInstance()를 통해 전역 접근이 가능함
  • 리플렉션으로 생성자 접근하면 인스턴스 추가 생성이 되긴 함..

enum 방식

public enum Elvis {
    INSTANCE;

    public void eat() {
        System.out.println("밥은 먹고 하자");
    }
}
  • 코드가 public 필드 방식과 비슷하지만 더 간결하고 쉽게 직렬화 가능함
  • 리플렉션에도 안전함

직렬화 / 역직렬화

직렬화 (Serialization)

  • 객체바이트 스트림으로 변환하는 과정
  • 주로 파일 저장, 네트워크 전송, 캐싱 할 때 사용함

역직렬화 (Deserialization)

  • 바이트 스트림을 다시 객체로 복원하는 과정
  • 직렬화된 데이터를 읽어서 원래 객체처럼 다시 사용 가능함

여기서 문제

  • 역직렬화할 때, new를 호출하지 않지만 JVM 내부적으로 새로운 객체가 생성됨
    => 싱글턴이 깨진다

부자연스러워 보여도 대부분 원소가 하나뿐인 enum 방식이 싱글턴을 만드는 가장 좋은 방식임


item4. 인스턴스화를 막으려거든 private 생성자를 사용하라

  • 어떤 클래스는 인스턴스로 만들 목적이 전혀 없음
  • 예: Math, Collections, Arrays 같은 유틸리티 클래스 같은 녀석들 (굳이 생성할 필요 X)
  • 이런 클래스는 생성자를 private으로 만들어 인스턴스화를 막아야 함

private이 없으면요?

  • 생성자를 명시하지 않으면 컴파일러가 자동으로 기본 생성자를 추가
  • new 키워드로 사용자가 인스턴스를 생성할 수 있게 됨
    • 의도된 것인지 자동 생성된 것인지 구분할 수 없음

그래서 private 붙이는 겁니다

  • private 생성자를 추가해주기만 하면 클래스의 인스턴스화를 막을 수 있음
public class UtilityClass {
    // 생성자를 private으로 막음
    private UtilityClass() {
        // 굳이 던질 필요는 없는데 클래스 내부에서 실수로 생성자 호출 못하게 개발자 실수 방지하는 역할
        throw new RuntimeException("인스턴스화 금지!"); 
    }

    public static void doSomething() {
        // ...
    }
}
  • 얘는 인스턴스로 생성하지 않을 녀석이라는 의도를 명확하게 표현 가능함
  • 생성자가 private이기 때문에 상속이 안된다는 점은 있음

item5. 자원을 직접 명시하지 말고 의존 객체 주입(DI)을 사용하라

이것도 설계 핵심

  • 객체 내부에서 필요한 자원 을 직접 생성 new 하지 말고, 외부에서 주입받자
  • 클래스가 사용하는 의존성을 스스로 만들지 말고 생성자, 메서드, 설정자 등을 통해 주입

직접 생성하는 상황 ❌

public class SpellChecker {
    private final Dictionary dictionary = new KoreanDictionary(); // 직접 생성
    // private final Dictionary dictionary = new JapaneseDictionary(); // 바꾸고 싶으면 이런짓 해야함

    public boolean isValid(String word) {
        return dictionary.contains(word);
    }
}
  • SpellChecker는 KoreanDictionary에 강하게 결합됨 (강결합)
  • 테스트 할 때, 다른 Dictionary를 사용할 수 없어서 테스트하기 어려움
  • 재사용도 어려움 (다른 언어에 쓸 수 없음)

의존 객체 주입 (Dependency Injection)를 하자 👍

// 인터페이스
public interface Dictionary {
    boolean contains(String word);
}
// 콘크리트 클래스 2개
public class KoreanDictionary implements Dictionary {
    @Override
    public boolean contains(String word) {
        // 안녕하세요
    }
}

public class JapaneseDictionary implements Dictionary {
    @Override
    public boolean contains(String word) {
        // 콘니찌와
    }
}
// Dictionary를 주입받을 사용 객체
public class SpellChecker {
    private final Dictionary dictionary;

    // 생성자 주입 방식 -> SpeelChecker가 생성될 때 dictionary가 정해짐
    public SpellChecker(Dictionary dictionary) {
        this.dictionary = dictionary;
    }

    public boolean isValid(String word) {
        return dictionary.contains(word);
    }
}
// main
public class Main {
    public static void main(String[] args) {
        // 한국어 사전
        SpellChecker koreanChecker = new SpellChecker(new KoreanDictionary()); // KoreanDictionary를 주입
        System.out.println(koreanChecker.isValid("안녕하세요"));

        // 일본어 사전
        SpellChecker japaneseChecker = new SpellChecker(new JapaneseDictionary()); // JapaneseDictionary를 주입
        System.out.println(japaneseChecker.isValid("콘니찌와"));
    }
}
  • Dictionary는 인터페이스고 실제 구현체는 외부에서 주입하는 방식
  • 다양한 Dictionary 구현체를 자유롭게 바꿔 끼울 수 있음
    • 유연성이 증가함
    • 테스트도 쉬워짐 (stub이나 mock을 주입할 수 있기 때문)
    • 결합도도 감소함 (KoreanDictionary나 JapaenseDictionary의 구현을 알 필요가 없어졌음)
  • 여러 DI 방식이 있지만 생성자 주입이 가장 좋음
  • 스프링 같은 프레임워크를 쓰면 쉽게 DI를 해주기때문에 개발자가 신경쓸게 적어져서 좋음

item6. 불필요한 객체 생성을 피하라

  • 객체를 무조건 새로 생성하는 것보다, 재사용 가능한 객체는 재사용하는 것이 낫다
  • 특히 불변 객체 는 캐싱하거나 상수로 재사용하면 성능과 메모리 측면에서 유리함
  • 반복적으로 객체를 생성하면 GC 부하 증가 + 성능 저하 가능성 있음

불필요한 객체 생성하는 간단한 예시

String s = new String("bikini"); // 'new String()' is redundant
Boolean b = new Boolean(true);  // 'Boolean(boolean)' is deprecated since version 9 and marked for removal 

redundant(중복)인 이유?

  • "bikini" 는 String 리터럴 풀(string pool) 에 저장되는 불변 상수임
  • new String("bikini")를 쓰면 기존 풀에 있는 "bikini"를 참조하지 않고, 다시 새로운 String 객체를 heap에 생성함
  • 즉, 동일한 값의 String을 굳이 하나 더 만드는 불필요한 행동
String s = "bikini"; // 리터럴을 직접 사용 (string pool에서 재사용)

문자열 상수 풀?

  • 자바에서 문자열 리터럴은 String Pool이라는 특수한 메모리 영역에 저장됨 (Heap 영역)
    • "bikini"와 같은 리터럴은 JVM 시작 시 클래스 로딩 단계에서 상수 풀에 등록됨
  • 그 이후 "bikini"라는 리터럴이 등장하면 이미 풀에 있는 동일 객체를 재사용함
String a = "bikini";
String b = "bikini";
System.out.println(a == b); // true

Heap에 저장이 된다는 건?

  • 문자열 리터럴은 여전히 JVM이 클래스 로딩 시 자동으로 intern() 처리해서 재사용함
    • String.intern()은 컴파일 타임에 자동으로 해당 문자열을 String Pool에 등록해서 하나의 객체로 재사용함
  • 하지만 이제 GC가 필요하면 이 문자열 풀에 있는 객체도 정리할 수 있음
    • 그래서 Heap 공간이 부족하면 오래 쓰이지 않은 리터럴도 GC될 수 있음

Boolean 생성자는 JDK 9부터 deprecated됨

Boolean은 불변이고, 캐시된 상수 인스턴스를 쓰면 되기 때문

// Boolean.TRUE 반환
Boolean b = Boolean.valueOf(true); // Unnecessary boxing (내부에서 Boolean.TRUE 리턴)

Boolean b2 = Boolean.TRUE;

생성자 대신 정적 팩터리 메서드 를 제공하는 불변 클래스에서는 정적 팩터리 메서드를 사용해라

(item1)

  • 생성자는 호출할 때마다 새로운 객체를 만들지만 팩터리 메서드는 그렇지 않음
  • new Boolean()이 JDK 9부터 Deprecated된 것도 같은 이유

생성 비용이 비싼 객체는 캐싱하여 재사용하라

  • 객체 생성 비용이 크거나 내부에 무거운 리소스를 가지는 경우 (Pattern, Connection, Formatter 등)
  • 자주 사용되는 경우 캐싱이 확실히 유리
  • valueOf(), static final, 팩터리 메서드 등으로 캐싱
Pattern p = Pattern.compile("[a-z]+"); // 컴파일 비용이 큼

// 매번 컴파일
boolean result = "[a-z]+".matches("hello");

// static final로 캐싱
private static final Pattern PATTERN = Pattern.compile("[a-z]+");
boolean result = PATTERN.matcher("hello").matches();

오토박싱도 불필요한 객체를 만들어낸다

wrapper class 타입으로 초기화 한 예시

Long sum = 0L;

for (long i = 0; i <= Integer.MAX_VALUE; i++) {
    // sum += i는 내부적으로 sum = Long.valueOf(sum.longValue() + i); 로 변환됨;
    sum += i; // 오토박싱이 돼서 Long 객체 수억 개가 생성됨 (성능 저하, GC 부하)
}

primitive 타입으로 초기화 한 예시

long sum = 0L; // primitive 타입으로 초기화

for (long i = 0; i <= Integer.MAX_VALUE; i++) {
    sum += i; 
}
  • 가능하면 기본형을 사용하고, 객체형은 nullable 하거나 컬렉션에서만 사용할 것

"객체 생성은 무조건 비싸다"는 오해

  • 예전 JVM에서는 GC가 비효율적이었고, 작은 객체라도 생성 자체가 성능 병목이었음
  • 하지만 요즘 JVM은 매우 최적화되어 있음
  • 작은 객체 생성은 eden 영역에서 빠르게 처리되고 회수도 쉽기 때문에 부담이 크지 않음

=> 재사용할 수 있는건 재사용하고 반복적이고 쓸데없는 객체 생성을 피하라는 뜻

커넥션 풀처럼 아주 무거운 객체가 아니라면 직접 객체 풀을 만들지 마라

  • 객체 풀은 일반적으로 초기화 비용이 매우 높은 객체(Connection, Thread 등) 에만 유효

  • String, Point, UserDto 같은 가벼운 객체에 대해 객체 풀을 만들면

    • 메모리 낭비
    • 가독성 저하
    • GC가 메모리 회수를 못함

    => 오히려 성능 저하

JVM은 이미 효율적임

  • 현대 JVM은 Eden -> Survivor -> Old 영역을 효율적으로 관리함
  • 자주 생성되고 짧게 사라지는 객체는 GC가 엄청 빠르게 회수하므로 객체 풀보다 낫다

Eden Survivor Old 영역

  • Eden 영역
    • 새로운 객체가 처음 생성되는 곳 (new 연산자)
    • 대부분의 객체는 여기서 생성되고 여기서 사라짐
    • 가비지 컬렉터의 주요 대상이 되는 공간
    • GC가 발생하면 살아있는 객체만 Survivor로 옮기고, 나머지는 제거됨
  • Survivor 영역 (S0, S1)
    • Eden에서 살아남은 객체가 이동되는 임시 보존 공간
    • 두 개의 영역(S0, S1) 이 번갈아 가며 사용됨
      • 한 쪽은 복사 대상
      • 한 쪽은 복사받는 대상
  • Old 영역
    • Survivor 영역을 여러 번 거친 "장수 객체" 가 올라가는 공간
    • 대부분의 객체는 Old까지 가지 못하고 GC에서 사라짐
    • Full GC가 발생할 때 정리 대상
    • Old 영역은 Full GC 대상이므로 GC 속도가 매우 느림 여기서 병목이 자주 발생함

item7. 다 쓴 객체 참조를 해제하라

  • GC가 있다고 해서 메모리 누수가 절대 안 생기는 건 아님
  • 필요 없는 객체를 계속 참조하고 있으면 GC의 수거 대상이 되지 않음
  • 사용이 끝난 객체 참조는 명시적으로 null 처리하거나 컬렉션에서 제거할 것

객체를 계속 참고하는 상황

public class Stack {
    private Object[] elements = new Object[100]; // 객체 참조
    private int size = 0;

    public void push(Object e) {
        elements[size++] = e;
    }

    public Object pop() {
        if (size == 0) throw new EmptyStackException();
        
        return elements[--size]; // null 처리를 안해서 요소는 줄었지만 참조는 남아 있음
    }
}
  • pop()을 해도 elements 배열에는 내부적으로 이전 객체 참조가 남아 있음
    • 프로그램에서 Stack을 사용하지 않더라도 Stack이 다 쓴 참조(obsolete reference)를 여전히 가지고 있음
    • 가바지 콜렉터가 스택에서 꺼내진 객체들을 회수하지 않음
  • GC는 이 객체를 "아직 사용 중"이라고 판단하고 회수하지 않음
  • 객체 참조 하나를 살려두면 GC는 그 객체를 포함해서 그 객체가 참조하는 모든 객체들을 회수하지 못함 (스노우볼 굴러감)
  • 결국 쌓여서 메모리 누수 발생
public Object pop() {
    if (size == 0) throw new EmptyStackException();
    
    Object result = elements[--size];
    
    elements[size] = null; // 참조 해제 (GC 회수가 가능해짐)
    
    return result;
}

무조건 null 처리하지 마라 – scope 밖으로 밀어내는 게 더 좋다

  • 지역 변수의 참조는 해당 범위를 벗어나면 자동으로 사라짐
void process() {
    BigObject obj = new BigObject(); // 지역 변수로 객체 참조
    obj.doSomething();
    // obj = null; 이렇게 안 해도 scope 끝나면 GC 대상 됨
}
  • 괜히 모든 객체를 null로 만드는 데 혈안이 되지않아도 됨
  • 참조 자체가 scope에서 사라지게 구조화하는 게 더 안전하고 깔끔함

null 처리는 자기 메모리를 직접 관리하는 클래스만

자기 메모리 관리 클래스?

  • 컬렉션, 배열, 큐, 스택처럼 내부에 객체 참조를 직접 유지하는 구조
  • 내부 배열이나 리스트에 사용자 객체를 담고 유지하는 구조
  • 해당 구조가 객체를 참조하고 있으면 외부에서 아무리 scope를 벗어나도 GC 대상이 아님
  • 따라서 이 경우는 명시적인 null 처리 or remove() 가 예외적으로 꼭 필요함

캐시도 메모리 누수의 주범

  • 객체를 Map 등에 저장하고 사용이 끝난 후에도 제거하지 않으면 계속 남아있음
Map<String, Object> cache = new HashMap<>();
cache.put("user:1", new User("Soohyeok", 28, "+82")); // 사용 안 해도 계속 유지됨
  • WeakHashMap를 써서 캐시를 구현하면 됨
    • 키가 GC되면 항목도 자동 제거
Map<Object, String> cache = new WeakHashMap<>();

리스너, 콜백도 메모리 누수 주범

  • 어떤 객체가 리스너나 콜백에 자신(this)을 등록하고
  • 그 후 명시적으로 제거하지 않으면, 해당 객체는 계속 참조됨 -> GC 대상이 아님

item8. finalizer와 cleaner 사용을 피하라

  • finalize()Cleaner예측 불가능하고, 느리고 위험하기 때문에 사용을 피해야 함
  • 언제 실행될지 예측할 수 없고, 성능/보안 문제도 발생할 수 있음

finalizer

  • 객체가 GC에 의해 수거되기 전에 자동 호출되는 메서드

Cleaner (Java 9)

  • finalizer보다 안전하지만 여전히 예측 불가능함

item9. try-finally보다 try-with-resources를 써라

  • sql Connection이나 InputStream같은 자원은 반드시 쓰고나서 해제해야함
  • try-finally로 처리했지만 try-with-resources가 더 안전함

전통 방식 (try-finally)

static String firstLineOfFile(String path) throws IOException {
    BufferedReader br = new BufferedReader(new FileReader(path));
    try {
        return br.readLine();
    } finally {
        br.close(); // 예외가 나도 무조건 닫아야 하니까 finally에 둠
    }
}
  • finally에서 예외가 발생할 수 있다는 문제가 있음 (실수 유발 가능)
  • 앞의 예외가 덮어질 수 있음

개선 방식 (try-with-resources)

static String firstLineOfFile(String path) throws IOException {
    try(BufferedReader br = new BufferedReader(new FileReader(path))) {
        return br.readLine();
    }
}
  • br이 AutoCloseable을 구현하면 자동으로 close()를 호출함
  • 코드가 간결하고 명확함
  • 예외가 중첩돼도 모두 추적 가능함

AutoCloseable을 구현한 자원을 사용할 땐 무조건 try-with-resources를 쓸 것

profile
백엔드 개발자

0개의 댓글