2025년 11월 19일
이론
카테고리 : Effective Java
조회 : 13|6분 읽기

Effective Java 챕터2

Effective Java 챕터2

🎯 Item 1: 생성자 대신 정적 팩터리 메서드를 고려하라

🔍 원문 요약

  • 개념: 인스턴스를 얻는 전통적 방식인 public 생성자 대신, 정적 팩터리 메서드를 제공할 수 있음.
  • 장점:
    • 이름을 가질 수 있어 의미 전달이 명확함.
    • 매번 객체를 새로 만들 필요 없음(인스턴스 통제).
    • 반환 타입의 하위 타입을 반환할 수 있음.
    • 매개변수에 따라 서로 다른 클래스 객체 반환 가능.
    • 작성 시점에 실제 구현 클래스가 없어도 되는 유연한 구조.
  • 단점:
    • 생성자를 제공하지 않으면 하위 클래스 생성 불가.
    • 문서에서 생성자보다 검색이 어려울 수 있음.
  • 명명 규칙: from, of, valueOf, getInstance, newInstance 등.

이어서

정적 팩터리 메서드는 사실 자바 API 곳곳에서 이미 사용되고 있는 강력한 패턴입니다. 특히 Boolean.valueOf, EnumSet.of, LocalDate.of 등을 떠올리면 이해하기 쉽습니다.
실제로 대규모 서비스에서는 “객체 생성을 통제”한다는 것이 굉장한 힘을 발휘합니다.
  • 캐싱 전략 가능
  • 스레드 풀·커넥션 풀처럼 제한된 자원 관리 가능
  • 특정 조건에서 객체 재사용 가능
  • API의 추상화를 강화해 캡슐화 수준 높임
정적 팩터리는 생성자보다 항상 좋은 것은 아니지만, “단일 진입점으로 인스턴스를 제공하는 구조”를 만들 때 압도적인 장점을 가집니다.

🎯 Item 2: 생성자에 매개변수가 많다면 빌더를 고려하라

🔍 원문 요약

  • 점층적 생성자 패턴은 가독성과 유지보수성이 떨어짐.
  • 자바빈즈 패턴은 객체 완성 전까지 일관성이 깨지고, 불변성 보장이 어려움.
  • 해결책: 빌더 패턴.
  • 장점: 가독성·안정성·호환성 모두 뛰어남. 계층 구조 클래스에도 적합.

✨ 설명

실무에서 가장 자주 겪는 고통 중 하나는…
new User(true, false, true, false, "A", null, 3, 5)
같은 코드를 마주했을 때입니다.
이제는 빌더 패턴이 사실상 표준입니다.
java
1User user = User.builder()
2    .name("홍길동")
3    .age(21)
4    .address("서울")
5    .marketingAgree(true)
6    .build();
이 정도면 읽기도 좋고 실수도 줄고 유지보수에도 강합니다. 특히 옵션이 많아질수록 빌더 패턴의 가치는 기하급수적으로 커집니다.

🎯 Item 3: private 생성자나 열거 타입으로 싱글턴임을 보증하라

🔍 원문 요약

  • 싱글턴 = 인스턴스 1개만 존재해야 하는 객체.
  • 구현 방법:
    • public static final 방식
    • 정적 팩터리 방식
    • Enum 방식(가장 안전함)

✨ 설명

싱글턴 패턴은 자바 개발자라면 누구나 아는 개념이지만, 제대로 구현하는 사람은 드뭅니다. 특히 직렬화/역직렬화, 리플렉션 공격을 막으려면 고려할 것이 많습니다.
그래서 Effective Java는 Enum 기반 싱글턴을 강력히 권장합니다.
java
1public enum Settings {
2    INSTANCE;
3}
이게 가장 간단하면서도 가장 안전합니다.

🔧 추가 예시: Effective Java 문서의 Elvis Enum 싱글턴 실제 코드

문서에서 소개된 Elvis 예시는 Enum 기반 싱글턴이 어떻게 동작하는지 가장 직관적으로 보여주는 대표 코드입니다.
아래에 "정의 → 사용" 흐름을 따라 실제 코드 예시를 더해, 싱글턴 방식이 어떻게 쓰이는지 명확히 이해할 수 있도록 정리했습니다.

1) Enum 싱글턴 정의하기

class가 아니라 enum으로 선언하고, 내부에 유일한 인스턴스 이름 하나만 적어 두면 됩니다.
필요한 메서드들은 일반 클래스처럼 자유롭게 추가할 수 있습니다.
java
1public enum Elvis {
2    INSTANCE; // 딱 한 개 존재하는 싱글턴 객체
3
4    // 일반 클래스처럼 메서드 정의 가능
5    public void leaveTheBuilding() {
6        System.out.println("Elvis has left the building!");
7    }
8}

2) 이렇게 호출해서 사용합니다

싱글턴이라 해서 특별한 문법이 필요한 게 아닙니다.
그냥 Elvis.INSTANCE 로 접근하면 끝입니다.
java
1public class Main {
2    public static void main(String[] args) {
3        // new Elvis() 같은 생성은 불가능 + 필요도 없음
4        Elvis.INSTANCE.leaveTheBuilding();
5    }
6}

📌 요약

  • Elvis.INSTANCE = 싱글턴 객체 그 자체
  • 생성자 호출 없음
  • getInstance() 필요 없음
  • 직렬화/역직렬화, 리플렉션 공격까지 기본적으로 안전함
Enum 싱글턴이 "가장 간단하면서도 가장 강력한 싱글턴 방식"이라 불리는 이유가 바로 이것입니다!

🔐 추가로 알아야 할 점: 기존 싱글턴 방식이 위험했던 이유

Enum 싱글턴이 강조되는 이유는 단순히 편해서가 아니라, 전통적인 싱글턴 방식이 구조적으로 여러 가지 심각한 취약점을 갖고 있기 때문입니다. 대표적으로 두 가지 문제가 있습니다.

1) 리플렉션 공격 취약점

생성자를 private으로 막아두어도, 권한이 있는 코드라면 다음과 같은 방식으로 무시하고 강제로 두 번째 인스턴스를 생성할 수 있습니다.
java
1Constructor<Elvis> cons = Elvis.class.getDeclaredConstructor();
2cons.setAccessible(true);
3Elvis another = cons.newInstance();  // ❌ 싱글턴이 깨짐!
즉, private 생성자만으로는 충분하지 않습니다. 이를 막으려면 생성자 안에서
java
1if (alreadyCreated) throw new RuntimeException();
같은 복잡한 방어 코드를 또 넣어야 합니다.
하지만 Enum은 애초에 리플렉션으로 생성자를 호출할 수 없도록 JVM 차원에서 보호되기 때문에, 추가적인 방어 코드가 필요 없습니다.

2) 직렬화(Serialization) 문제

전통적 싱글턴 클래스는 직렬화하면 다음 단계에서 싱글턴이 깨집니다.
java
1// 역직렬화 시 새 객체가 생성됨 → 싱글턴 파괴
2ObjectInputStream in = ...;
3Elvis e2 = (Elvis) in.readObject();
이를 막기 위해서는 다음과 같이 복잡한 처리가 필요합니다.
java
1private Object readResolve() {
2    return INSTANCE; // 기존 인스턴스 강제 반환
3}
모든 필드에 transient를 붙여야 할 수도 있고, 코드가 지저분해지며 실수 가능성도 높습니다.

🏆 결론: Enum 싱글턴이 왜 최종 솔루션인가?

  • 리플렉션 공격 차단 → JVM 레벨 보안
  • 직렬화/역직렬화 시 싱글턴 자동 보존 → 별도 코드 필요 없음
  • 스레드 세이프 → 동기화 고려 불필요
  • 코드 자체도 짧고 명확함

🎯 Item 4: 인스턴스화를 막으려거든 private 생성자를 사용하라

🔍 원문 요약

  • 유틸리티 클래스는 인스턴스를 만들게 하면 안 됨.
  • 컴파일러는 생성자가 없으면 자동으로 기본 생성자 추가 → 반드시 private 생성자를 명시해야 함.
  • 실수 방지 및 상속 차단 효과.

✨ 설명

유틸리티 클래스에 다음과 같은 생성자가 보인다면… 100% 잘못된 설계입니다.
java
1public class MathUtils {}
Java 컴파일러는 기본 생성자를 자동 생성하므로, 아래와 같이 명시적으로 막아야 합니다.
java
1private MathUtils() {
2    throw new AssertionError();
3}
이 패턴은 팀 개발에서 특히 중요합니다. 실수로 인스턴스를 생성하거나 상속하려는 시도를 원천 차단할 수 있기 때문입니다.

🎯 Item 5: 자원을 직접 명시하지 말고 의존 객체 주입(DI)을 사용하라

🔍 원문 요약

  • 정적 유틸리티나 싱글턴에 의존하는 구조는 유연하지 않고 테스트하기 어려움.
  • 해결책: 생성자에 필요한 자원을 주입하는 DI 구조.

✨ 설명

의존 객체 주입(DI)은 현대 백엔드 개발에서 사실상 필수입니다.
예를 들어 아래 코드는 테스트가 매우 어렵습니다.
java
1SpellChecker checker = new SpellChecker(new DefaultDictionary());
하지만 아래와 같이 DI를 쓰면:
java
1SpellChecker checker = new SpellChecker(testDictionary);
테스트 환경을 유연하게 구성할 수 있습니다.
특히 Spring 기반 개발에서는 DI가 프로젝트 전반을 지배하는 핵심 패턴입니다.

🎯 Item 6: 불필요한 객체 생성을 피하라

🔍 원문 요약

  • 객체 재사용은 성능과 메모리 측면에서 매우 중요함.
  • 문자열 리터럴 사용, Pattern 캐싱, 오토박싱 주의 등.

✨ 설명

객체를 매번 생성하는 것은 단순히 “비효율”이 아니라, 대규모 트래픽 환경에서는 곧 성능 병목이 됩니다.
예시) 정규표현식 매번 컴파일 → CPU 지옥
java
1Pattern.matches(regex, input); // 매번 Pattern 생성
정답은:
java
1private static final Pattern REGEX = Pattern.compile("...");
이처럼 GC 부담까지 줄일 수 있어 고성능 백엔드 설계에서 핵심적인 원칙입니다.

🎯 Item 7: 다 쓴 객체 참조를 해제하라

🔍 원문 요약

  • Java라도 메모리 누수 가능.
  • Stack·Cache·Listener/Callback 등에서 객체가 계속 참조되면 GC가 회수 불가.

✨ 설명

메모리 누수는 개발자가 가장 찾기 어려운 버그입니다.
예:
  • 캐시 맵에서 값이 계속 누적됨
  • 리스너 등록 후 해제하지 않음
  • ThreadLocal 사용 후 remove() 호출 안 함
특히 ThreadLocal 누수는 WAS 같은 스레드 풀 환경에서 실제 장애로 이어질 수 있는 심각한 문제입니다.

🎯 Item 8: finalizer와 cleaner 사용을 피하라

🔍 원문 요약

  • finalizer/cleaner는 예측 불가능하고 성능에 악영향.
  • 보안 문제까지 발생.
  • 해결책: AutoCloseable + try-with-resources.

✨ 설명

finalize()는 사실상 deprecated된 기술입니다. (Java 18에서 완전 제거됨)
왜냐하면:
  • 언제 실행될지 알 수 없음
  • 실행되지 않을 수도 있음
  • GC와 결합되어 성능이 심각하게 떨어짐
  • 안전하지 않음
대신 반드시 아래 패턴을 사용해야 합니다.
java
1try (FileInputStream fis = new FileInputStream(path)) {
2    ...
3}

🎯 Item 9: try-finally보다는 try-with-resources를 사용하라

🔍 원문 요약

  • try-finally는 코드가 지저분하고 예외가 덮일 위험 있음.
  • try-with-resources가 더 안전하고 가독성 강함.

✨ 설명

Java 7 이후로는 자원 정리는 무조건 try-with-resources입니다.
이 코드는:
java
1try {
2    InputStream in = ...
3} finally {
4    in.close();
5}
이렇게 단 한 줄로 대체됩니다:
java
1try (InputStream in = ...) {
2}
게다가 숨겨진 예외까지 suppressed로 기록해 디버깅 효율이 매우 높습니다.

🔎 추가 정리: 왜 try-with-resources가 반드시 필요할까?

try-with-resources는 자바 7부터 도입된 구문으로, 파일·DB 커넥션처럼 반드시 닫아야 하는(close) 자원 관리의 최종 표준 방식입니다.
이전 방식인 try-finally는 다음과 같은 결정적인 문제 때문에 더 이상 권장되지 않습니다.

1) 기존 방식(try-finally)의 문제점

1) 코드가 지저분함

자원이 하나면 그나마 괜찮지만, 두 개 이상이면 try 안에 또 try가 들어가는 방식으로 코드가 폭발적으로 복잡해집니다.
java
1static void copy(String src, String dst) throws IOException {
2    InputStream in = new FileInputStream(src);
3    try {
4        OutputStream out = new FileOutputStream(dst);
5        try {
6            byte[] buf = new byte[BUFFER_SIZE];
7            int n;
8            while ((n = in.read(buf)) >= 0)
9                out.write(buf, 0, n);
10        } finally {
11            out.close();
12        }
13    } finally {
14        in.close();
15    }
16}
이 방식은 가독성도 낮고, 실수도 나오기 쉽습니다.

2) 예외 삼켜짐(Exception Masking) ← 가장 치명적

  • try 블록에서 예외가 발생해 finally로 넘어갔는데,
  • 거기서 close() 호출 시 또 예외가 발생하면…
👉 두 번째 예외(close 중 발생)가 첫 번째 예외(중요한 원인)를 덮어버립니다.
결과적으로 로그에는 close() 실패만 찍히고 진짜 문제는 사라집니다.
디버깅 난도는 극적으로 상승합니다.

2) try-with-resources는 무엇이 다른가?

java
1try (InputStream in = new FileInputStream(src);
2     OutputStream out = new FileOutputStream(dst)) {
3
4    byte[] buf = new byte[BUFFER_SIZE];
5    int n;
6    while ((n = in.read(buf)) >= 0)
7        out.write(buf, 0, n);
8}

✔ 코드가 매우 간결해짐

  • 중첩 없음
  • 가독성 높음
  • 유지보수 편함

✔ 예외 처리 품질이 압도적으로 좋아짐

  • 가장 처음 발생한 예외가 메인 예외로 유지됨
  • close() 중 발생한 예외는 suppressed 예외로 함께 기록됨
  • 디버깅 시 모든 정보가 남아 정확한 원인 파악 가능

3) 사용 조건

try-with-resources를 쓰려면 자원이 반드시 AutoCloseable 인터페이스를 구현해야 합니다.
  • InputStream, OutputStream, Connection, PreparedStatement 등 자바 라이브러리 대부분의 자원은 이미 구현되어 있음.

📌 정리

  • try-finally는 예외를 덮어버리기 때문에 위험함
  • 복잡한 자원 관리 코드가 간결하게 정리됨
  • 디버깅 품질이 좋아지고 유지보수성이 뛰어남
  • 자바 7 이후로는 사실상 필수 표준 방식
즉, 자원을 사용하는 모든 코드에서 try-with-resources를 쓰는 것이 가장 안전하고 올바른 선택입니다.