Java

Effective Java Chapter1_item03

yuni02 2025. 9. 29. 21:38

아래 내용은 effective java 내용을 읽고 요약 정리한 글이다. 

 

item03_private생성자나_열거타입으로_싱글턴임을_보증하라

1.  싱글턴 패턴 구현 방법

(1) 싱글턴이란?
인스턴스를 오직 하나만 생성할 수 있는 클래스를 말합니다. 함수와 같은 무상태 객체나 설계상 유일해야 하는 시스템 컴포넌트가 대표적인 예입니다.

(2) 싱글턴 구현의 3가지 방법

- public static final 필드 방식

public class Elvis {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() {...}
    public void leaveTheBuilding() {...}
}

 

  - 장점:
     - API에 싱글턴임이 명백히 드러남
     - 간결함

  - 주의사항: 리플렉션 공격 가능 (AccessibleObject.setAccessible 사용)

import java.lang.reflect.Constructor;

public class ReflectionAttack {
    public static void main(String[] args) {
        try {
            // 정상적인 방법으로 싱글턴 인스턴스 가져오기
            VulnerableSingleton instance1 = VulnerableSingleton.getInstance();
            System.out.println("instance1: " + instance1);
            
            // 리플렉션으로 private 생성자에 접근
            Class<VulnerableSingleton> clazz = VulnerableSingleton.class;
            Constructor<VulnerableSingleton> constructor = clazz.getDeclaredConstructor();
            
            // private 생성자를 접근 가능하게 만듦 (보안 위반!)
            constructor.setAccessible(true);
            
            // 새로운 인스턴스 생성 (싱글턴 위반!)
            VulnerableSingleton instance2 = constructor.newInstance();
            System.out.println("instance2: " + instance2);
            
            VulnerableSingleton instance3 = constructor.newInstance();
            System.out.println("instance3: " + instance3);
            
            // 서로 다른 인스턴스가 생성됨을 확인
            System.out.println("\n=== 싱글턴 위반 확인 ===");
            System.out.println("instance1 == instance2: " + (instance1 == instance2));
            System.out.println("instance2 == instance3: " + (instance2 == instance3));
            System.out.println("instance1.hashCode(): " + instance1.hashCode());
            System.out.println("instance2.hashCode(): " + instance2.hashCode());
            System.out.println("instance3.hashCode(): " + instance3.hashCode());
            
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

- 정적 팩토리 메서드 방식

public class Elvis {
    private static final Elvis INSTANCE = new Elvis();
    private Elvis() {...}
    public static Elvis getInstance() { return INSTANCE; }
    public void leaveTheBuilding() {...}
}


- 장점:
  - API 변경 없이 싱글턴이 아니게 변경 가능
  - 제네릭 싱글턴 팩토리로 만들 수 있음
  - 메서드 참조를 공급자로 사용 가능 (`Elvis::getInstance`)

 

- 열거 타입 방식 (권장)

public enum Elvis {
    INSTANCE;
    public void leaveTheBuilding() {...}
}


 - 장점:
   - 가장 간결함
   - 직렬화 자동 처리
   - 리플렉션 공격에 완벽히 안전함

 - 제약사항: Enum 외의 클래스 상속 불가
 - 직렬화 시 주의사항: 싱글턴 클래스를 직렬화할 때는 `readResolve` 메서드를 추가해야 합니다:

private Object readResolve() {
    return INSTANCE;
}


추가설명 

Q1. 정적 팩토리 메서드 방식에서는 메서드의 구현부만 변경하면 되기 때문에 API 변경 없이 싱글턴이 아니게 만들 수 있는 예시는?

- API 변경 없이 싱글턴이 아니게 변경

1. 스레드별로 다른 인스턴스 제공

public class Elvis {
    private static final ThreadLocal<Elvis> INSTANCES =
        ThreadLocal.withInitial(Elvis::new);

    private Elvis() {...}

    public static Elvis getInstance() {  // API는 동일!
        return INSTANCES.get();  // 스레드마다 다른 인스턴스
    }
}


2. 매번 새로운 인스턴스 생성

public class Elvis {
    private Elvis() {...}

    public static Elvis getInstance() {  // API는 동일!
        return new Elvis();  // 매번 새 인스턴스 생성
    }
}


3. 풀(Pool)에서 인스턴스 관리

public class Elvis {
    private static final List<Elvis> POOL = Arrays.asList(
        new Elvis(), new Elvis(), new Elvis()
    );
    private static int index = 0;

    private Elvis() {...}

    public static Elvis getInstance() {  // API는 동일!
        return POOL.get((index++) % POOL.size());  // 순환하며 반환
    }
}


public final 필드 방식에서는 `INSTANCE` 필드 자체가 final이므로 다른 객체를 참조하도록 변경할 수 없습니다. 싱글턴이 아니게 만들려면 클래스 전체를 수정해야 하므로 API 변경이 필요합니다.

핵심: 정적 팩토리 메서드는 메서드 호출을 통해 인스턴스에 접근하므로, 메서드 내부 로직만 바꾸면 클라이언트 코드는 그대로 두고도 동작을 변경할 수 있습니다.

Q2. 제네릭 싱글턴 팩토리는 타입 안전성을 보장하면서 하나의 객체를 여러 타입으로 활용할 수 있게 해주는 패턴이라는 예시는?

- 일반적인 예시: Collections.emptySet()

// 제네릭 싱글턴 팩토리의 대표적인 예
Set<String> stringSet = Collections.emptySet();
Set<Integer> intSet = Collections.emptySet();
Set<Long> longSet = Collections.emptySet();


실제로는 모두 같은 객체를 참조하지만, 컴파일 타임에는 각각 다른 타입으로 처리됩니다.

- 직접 구현해보기
  - 기본 구조

public class GenericSingleton<T> {
    // 실제 인스턴스는 하나만 존재
    private static final GenericSingleton<?> INSTANCE = new GenericSingleton<>();

    private GenericSingleton() {}

    // 제네릭 싱글턴 팩토리 메서드
    @SuppressWarnings("unchecked")
    public static <T> GenericSingleton<T> getInstance() {
        return (GenericSingleton<T>) INSTANCE;
    }

    public void doSomething(T item) {
        System.out.println("Processing: " + item);
    }
}


- 사용 예시

public class Main {
    public static void main(String[] args) {
        // 모두 같은 객체지만 타입이 다르게 처리됨
        GenericSingleton<String> stringSingleton = GenericSingleton.getInstance();
        GenericSingleton<Integer> intSingleton = GenericSingleton.getInstance();
        GenericSingleton<List<String>> listSingleton = GenericSingleton.getInstance();

        // 객체 동일성 확인
        System.out.println(stringSingleton == intSingleton); // true

        // 하지만 타입 안전성은 보장됨
        stringSingleton.doSomething("Hello");     // OK
        intSingleton.doSomething(42);             // OK
        // stringSingleton.doSomething(42);       // 컴파일 에러!
    }
}

 

- 실용적인 예시: Identity Function

public class IdentityFunction<T> implements Function<T, T> {
    private static final IdentityFunction<?> INSTANCE = new IdentityFunction<>();

    private IdentityFunction() {}

    @SuppressWarnings("unchecked")
    public static <T> IdentityFunction<T> getInstance() {
        return (IdentityFunction<T>) INSTANCE;
    }

    @Override
    public T apply(T t) {
        return t; // 입력값을 그대로 반환
    }
}

// 사용
Function<String, String> stringIdentity = IdentityFunction.getInstance();
Function<Integer, Integer> intIdentity = IdentityFunction.getInstance();

System.out.println(stringIdentity.apply("test")); // "test"
System.out.println(intIdentity.apply(100));       // 100


- 왜 정적 팩토리에서만 가능한가?
 - public final 필드 방식의 한계

public class BadExample<T> {
    // 이렇게 할 수 없음! 컴파일 에러
    public static final BadExample<T> INSTANCE = new BadExample<>();
    // Static 필드에서는 클래스의 타입 매개변수 사용 불가
}


 - 정적 팩토리 방식의 장점

public class GoodExample<T> {
    private static final GoodExample<?> INSTANCE = new GoodExample<>();

    // 메서드 레벨에서 타입 매개변수 선언 가능
    public static <T> GoodExample<T> getInstance() {
        return (GoodExample<T>) INSTANCE;
    }
}



 - 핵심: 정적 팩토리 메서드는 메서드 레벨에서 제네릭을 사용할 수 있기 때문에, 하나의 인스턴스를 여러 타입으로 안전하게 캐스팅해서 반환할 수 있습니다. 이는 메모리 효율성과 타입 안전성을 동시에 제공합니다.