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

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

이번 목표는 단순하다. 프로그램 전체에서 인스턴스가 오직 하나만 존재해야 하는 타입을, 설계와 코드로 보증하는 것이다.

여기서 보증이라는 단어가 핵심이다. 관례로 하나만 쓰자는 약속이 아니라, 깨뜨리기 어려운 구조를 만들라는 이야기다. “우리 팀에서 이 객체는 하나만 쓰기로 했어요”라는 협약은 언젠가 반드시 깨진다. 코드가 그 불변식을 강제해야 한다.


싱글턴이 필요한 이유

싱글턴이 필요한 이유는 보통 이 세 가지로 수렴한다.

1. 상태를 하나로 공유해야 한다
애플리케이션 설정(Configuration), 글로벌 레지스트리, 캐시 정책 같은 것들이다. 여러 인스턴스가 각자의 상태를 들고 있으면 무엇이 “진짜” 상태인지 알 수 없게 된다.

2. 비용이 큰 객체를 재사용해야 한다
스레드 풀, 커넥션 풀, 정규식 컴파일러처럼 초기화 비용이 크거나 공유 자원을 관리하는 객체는 여러 곳에서 각자 생성하면 안 된다. 특히 데이터베이스 커넥션 풀은 잘못 관리하면 시스템 전체가 커넥션 고갈로 멈추는 사고로 이어진다.

3. 전역 접근이 필요하다
공통 유틸리티, 전략 제공자처럼 어디서든 접근해야 하지만 인스턴스를 계속 새로 만들 이유가 없는 것들이다.

그러나 싱글턴은 전역 상태와 결합되기 쉬워서 설계가 흐려지는 위험도 있다. 숨겨진 의존성이 생기고, 테스트가 어려워지며, 시스템 간 결합도가 높아진다. 그래서 이 글은 싱글턴을 만들라는 글이 아니라, 만들 거면 깨지지 않게 만들라는 글이다.


1. 싱글턴이 깨지는 대표 경로

싱글턴이 깨진다는 말은 같은 타입의 인스턴스를 두 개 이상 만들 수 있다는 뜻이다. 주로 다음 경로로 깨진다.

  1. 생성자를 막지 않았거나 우회가 가능하다 — 가장 흔한 실수. new를 막는 것만으로는 부족하다.
  2. 리플렉션이 private 생성자를 강제로 호출한다setAccessible(true)는 접근 제어를 무시한다.
  3. 직렬화와 역직렬화가 새 인스턴스를 만든다 — 역직렬화는 생성자를 거치지 않는다.
  4. 클래스 로더가 분리되어 로더마다 인스턴스가 생긴다 — 애플리케이션 서버, 플러그인 아키텍처, OSGi 환경에서 발생한다.

아이템 3은 1~3을 실전 수준으로 막는 방법을 설명한다. 4번은 애플리케이션 서버, 플러그인 아키텍처, OSGi 같은 환경에서 등장하는데, 이때는 싱글턴의 의미 자체가 로더 단위인지 프로세스 단위인지 먼저 정의해야 한다. 클래스 로더 분리 문제는 대부분 컨테이너 위임 모델(parent-first delegation)로 제어하는 것이 정석이다.


2. public static final 필드 방식

가장 직관적인 싱글턴이다. 클래스 안에 public static final 인스턴스를 하나 만들어 둔다.

1
2
3
4
5
6
7
8
9
10
11
public final class Elvis {
    public static final Elvis INSTANCE = new Elvis();

    private Elvis() {
        // 리플렉션 공격 방어는 별도로 필요
    }

    public void sing() {
        System.out.println("Thank you, thank you very much.");
    }
}

이 방식의 핵심은 세 가지다.

  • 생성자를 private으로 막는다
  • 인스턴스를 static final 필드로 선언한다
  • 클래스 초기화 시점(<clinit>)에 단 한 번 객체를 생성한다

왜 스레드 안전한가

이 질문이 핵심이다. synchronized도 없는데 왜 안전한가?

답은 JVM의 클래스 초기화 규칙에 있다. JVM 명세(Java Language Specification §12.4)는 클래스 초기화가 오직 한 번만 수행되며, 이 과정은 JVM 레벨에서 내부적으로 동기화된다고 보장한다. 더 구체적으로는 각 클래스와 인터페이스에 대응하는 초기화 락(initialization lock)이 존재하고, 클래스가 처음 로드될 때 이 락을 획득한 스레드만 <clinit>을 실행한다. 나머지 스레드는 초기화가 완료될 때까지 블록된다.

1
2
3
4
5
6
Class Load
 → JVM이 초기화 락 획득
 → <clinit> 실행 (단 한 번)
   → INSTANCE = new Elvis()
 → 락 해제
 → 이후 모든 스레드는 동일한 INSTANCE를 읽음

volatile이 필요 없고 double-checked locking도 불필요한 이유가 바로 여기에 있다. static final 필드는 초기화 이후 재할당 불가이므로 가시성 문제도 발생하지 않는다.

장점

구현이 가장 단순하다. 코드가 짧고 실수할 여지가 거의 없다. API를 보는 순간 “이 클래스는 싱글턴이다”라는 의도가 명확히 드러난다.

JVM 레벨에서 안전성이 보장된다. 개발자가 별도의 동기화 코드를 작성할 필요가 없다. 이는 double-checked locking처럼 미묘한 실수가 끼어들 여지가 없다는 뜻이기도 하다.

성능 걱정이 없다. 초기화 비용은 클래스 로드 시점에 단 한 번 발생하고, 이후 INSTANCE 접근은 단순한 정적 필드 읽기다.

단점

리플렉션에 취약하다. 이것이 가장 치명적인 구멍이다.

1
2
3
4
5
6
Constructor<Elvis> c = Elvis.class.getDeclaredConstructor();
        c.setAccessible(true);
        Elvis e1 = c.newInstance();  // 두 번째 인스턴스 생성!
        Elvis e2 = Elvis.INSTANCE;

        System.out.println(e1 == e2);  // false

private 생성자는 리플렉션 앞에서 무력하다. 자세한 방어 방법은 6절에서 다룬다.

클래스가 싱글턴이라는 사실이 타입 레벨에서 고정된다. 나중에 싱글턴을 풀거나 구현체를 바꾸려면 호출부의 Elvis.INSTANCE를 모두 수정해야 한다. API 변경이 곧 깨지는 변경(breaking change)이 된다.

초기화 지연(Lazy Initialization)이 어렵다. INSTANCE 필드는 클래스가 로드되는 시점에 무조건 생성된다. 초기화 비용이 크고, 사용되지 않을 수 있는 상황에서는 낭비가 될 수 있다.


3. 정적 팩터리 방식

이번에는 필드 대신 메서드로 인스턴스를 제공한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public final class Elvis {
    private static final Elvis INSTANCE = new Elvis();

    private Elvis() {
    }

    public static Elvis getInstance() {
        return INSTANCE;
    }

    public void sing() {
        System.out.println("Thank you, thank you very much.");
    }
}

겉보기에는 필드 방식과 같아 보인다. INSTANCEprivate으로 바뀌고 메서드가 생긴 것뿐이다. 그러나 메서드가 존재하는 순간 설계의 선택지가 열린다. 이게 핵심이다.

유연성의 차이

public static final 필드 방식은 필드 자체가 API다. 나중에 싱글턴이 아니어도 되는 상황이 와도 변경하기가 어렵다. 필드를 없애거나 타입을 바꾸면 그 필드를 참조하는 모든 코드가 깨진다.

반면 getInstance() 메서드는 구현 세부 사항을 숨긴다. 다음처럼 정책을 바꿔도 호출부는 수정할 필요가 없다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 호출부는 Elvis.getInstance()로 동일
// 구현 내부만 바뀐다

// 옵션 1: 계속 싱글턴
public static Elvis getInstance() {
        return INSTANCE;
        }

// 옵션 2: 스레드별로 다른 인스턴스 제공 (ThreadLocal)
public static Elvis getInstance() {
        return threadLocal.get();
        }

// 옵션 3: 팩터리가 서브타입을 반환
public static Singer getInstance() {
        return isLegacyMode ? new LegacyElvis() : INSTANCE;
        }

메서드 참조로 Supplier에 연결하기

정적 팩터리 싱글턴은 Java 8의 Supplier<T>와 자연스럽게 연결된다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 메서드 참조로 Supplier 생성 가능
Supplier<Elvis> elvisSupplier = Elvis::getInstance;

// 이를 활용한 DI 구조
public class Concert {
    private final Supplier<Elvis> performer;

    public Concert(Supplier<Elvis> performer) {
        this.performer = performer;
    }

    public void start() {
        performer.get().sing();
    }
}

    // 실제 사용
    Concert concert = new Concert(Elvis::getInstance);

    // 테스트에서는 가짜 객체 주입
    Concert testConcert = new Concert(() -> fakeElvis);

이 패턴의 장점은 싱글턴의 구체적인 타입에 의존하지 않고, “뭔가를 제공해주는 것”이라는 추상에 의존한다는 점이다. 이는 아이템 5의 의존 객체 주입 원칙과도 자연스럽게 연결된다.

제네릭 싱글턴 팩터리

정적 팩터리는 제네릭과 결합하면 더 강력해진다. 같은 객체를 여러 타입으로 제공할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
// 항등 함수(identity function) 싱글턴
@SuppressWarnings("unchecked")
public static <T> UnaryOperator<T> identityFunction() {
        return (UnaryOperator<T>) IDENTITY_FN;
        }

private static final UnaryOperator<Object> IDENTITY_FN = t -> t;

// 사용 시 타입 추론이 동작
        String[] strings = {"a", "b", "c"};
        UnaryOperator<String> sameString = GenericSingleton.identityFunction();

이 기법은 Collections.emptyList(), Collections.emptyMap() 같은 JDK 내부에서도 광범위하게 활용된다.


4. 생성자를 사용하는 방법 3가지, 무엇을 더 고민해야 하나

강의에서 생성자를 사용하는 방법이 세 갈래로 정리되는 이유는 실무에서 싱글턴의 요구가 매번 같지 않기 때문이다. 같은 싱글턴이라도 다음 질문에 따라 선택이 갈린다.

  • 초기화 시점을 늦춰야 하는가
  • 테스트에서 교체 가능해야 하는가
  • 인터페이스 기반으로 바뀔 가능성이 있는가
  • 직렬화, 리플렉션 공격을 어디까지 막아야 하는가
  • 성능이 극도로 민감한가

이 질문들에 따라 해법이 달라진다. 아이템 3에서 제시하는 표준 해법은 결국 두 가지로 수렴한다.

  • 보안성과 단순성을 최우선으로 보면 열거 타입
  • 유연성이 필요하면 정적 팩터리 또는 필드 방식에 readResolve 같은 보강

지연 초기화를 꼭 원한다면 정적 내부 클래스 방식이 함께 언급되곤 한다. 아이템 3의 본문은 아니지만, 싱글턴의 구현 현실에서 빠지지 않는다.


4-1. 지연 초기화: 정적 내부 클래스 Holder 패턴

아이템 3의 본문에서 직접 다루지는 않지만, 실무에서 싱글턴 구현 시 자주 등장하는 패턴이다. public static final 필드 방식의 “즉시 초기화” 문제를 우아하게 해결한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public final class LazyElvis {
    private LazyElvis() {
    }

    // Holder 클래스는 LazyElvis가 로드될 때 함께 로드되지 않는다.
    // getInstance()가 최초로 호출될 때 비로소 로드된다.
    private static class Holder {
        private static final LazyElvis INSTANCE = new LazyElvis();
    }

    public static LazyElvis getInstance() {
        return Holder.INSTANCE;
    }
}

동작 원리

클래스 로딩 메커니즘을 이용한다. JVM은 클래스를 실제로 사용될 때 로드한다. Holder라는 내부 클래스는 LazyElvis 클래스가 로드될 때 함께 로드되지 않는다. Holder.INSTANCE를 참조하는 getInstance() 메서드가 최초로 호출될 때 비로소 Holder 클래스가 로드되고, 그 시점에 INSTANCE가 생성된다.

1
2
3
4
5
LazyElvis 클래스 로드 시: Holder는 로드되지 않음
                          ↓
getInstance() 최초 호출 시: Holder 클래스 로드
                          → <clinit> 실행 → INSTANCE = new LazyElvis()
                          → 이후 호출은 이미 생성된 INSTANCE 반환

이 과정은 클래스 초기화 규칙에 의해 JVM이 동기화해준다. synchronized 키워드 없이도 스레드 안전하고, 초기화가 지연된다. 성능 패널티도 없다.

다만 직렬화와 리플렉션 문제는 여전히 따로 챙겨야 한다. Holder 패턴은 초기화 지연과 스레드 안전성만 해결해주는 것이지, 싱글턴의 다른 취약점을 자동으로 닫아주지는 않는다.

Holder 패턴 vs Double-Checked Locking

과거에 지연 초기화를 위해 double-checked locking(DCL)이 자주 쓰였다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// DCL 방식 — volatile이 필수지만 실수가 잦다
public class Elvis {
    private volatile static Elvis instance;  // volatile 없으면 버그!

    public static Elvis getInstance() {
        if (instance == null) {
            synchronized (Elvis.class) {
                if (instance == null) {
                    instance = new Elvis();
                }
            }
        }
        return instance;
    }
}

volatile 없이 DCL을 쓰면 명령어 재정렬(instruction reordering) 때문에 초기화가 완료되지 않은 객체의 참조가 반환될 수 있다. 이 버그는 재현이 어려워서 실무에서 오래 살아남는다. Holder 패턴은 이런 위험이 없다. 가능하면 Holder 패턴을 쓰는 것이 낫다.


5. 열거 타입 방식: 왜 최종 해법인가

이번 글의 결론은 명확하다. 싱글턴은 가능하면 열거 타입(enum)으로 구현하라.

1
2
3
4
5
6
7
8
9
10
public enum Elvis {
    INSTANCE;

    public void sing() {
        System.out.println("Thank you, thank you very much.");
    }
}

// 사용
Elvis.INSTANCE.sing();

코드가 극도로 짧다. 그런데 이것이 단순히 문법적인 편의 때문이 아니다. 열거 타입 방식이 강력한 이유는 설계자가 직접 막기 어려운 구멍을 JVM 레벨에서 닫아주기 때문이다.

사람이 규칙을 지키는 방식(필드 방식, 정적 팩터리 방식)보다, 언어가 강제로 보증하는 방식에 가깝다.

5.1 리플렉션이 enum을 뚫지 못하는 이유

앞서 setAccessible(true)로 private 생성자를 뚫는 예시를 봤다. enum에는 이 공격이 통하지 않는다.

1
2
3
4
5
6
7
8
9
10
// 일반 클래스는 뚫린다
Constructor<RegularElvis> c = RegularElvis.class.getDeclaredConstructor();
c.setAccessible(true);
RegularElvis fake = c.newInstance();  // 성공 — 싱글턴 파괴

// enum은 막힌다
Constructor<EnumElvis> ec = EnumElvis.class.getDeclaredConstructor(String.class, int.class);
ec.setAccessible(true);
EnumElvis fake = ec.newInstance("FAKE", 1);  // java.lang.IllegalArgumentException 발생!
// "Cannot reflectively create enum objects"

그 이유는 java.lang.reflect.Constructor#newInstance() 내부에 enum 타입 체크가 하드코딩되어 있기 때문이다.

1
2
3
// OpenJDK Constructor.newInstance() 내부 (단순화)
if ((clazz.getModifiers() & Modifier.ENUM) != 0)
    throw new IllegalArgumentException("Cannot reflectively create enum objects");

핵심은 “private 생성자를 만들었다”가 아니라, “언어/런타임이 enum 생성 경로 자체를 통제한다”는 점이다. 개발자가 방어 코드를 추가하지 않아도 JVM이 강제한다.

5.2 직렬화에도 강한 이유

일반 클래스 기반 싱글턴은 Serializable을 구현하는 순간 역직렬화로 새 인스턴스가 만들어질 수 있다. 이 문제는 7절에서 상세히 다룬다.

enum은 이 문제가 처음부터 없다. Java의 직렬화 명세(Object Serialization Specification §1.12)는 enum 상수의 직렬화 및 역직렬화를 특별하게 정의한다.

  • 직렬화: enum 상수는 상수의 이름(name)만 기록된다. 인스턴스 상태는 저장하지 않는다.
  • 역직렬화: 저장된 이름으로 Enum.valueOf(Class, String)을 호출해 기존 상수를 그대로 반환한다. 새 인스턴스를 생성하지 않는다.
1
2
3
4
5
Elvis e1 = Elvis.INSTANCE;
byte[] bytes = serialize(e1);
Elvis e2 = deserialize(bytes);

System.out.println(e1 == e2);  // true — enum은 역직렬화 후에도 동일 인스턴스

readResolve() 같은 추가 코드가 필요 없다.

5.3 코드가 짧아지는 것이 보안/품질에 직접 연결된다

싱글턴은 이상적으로 단순해야 한다. 구현이 복잡해질수록 버그가 숨어들 표면이 넓어진다.

일반 클래스로 완전한 싱글턴을 만들려면 다음을 모두 챙겨야 한다.

  • private 생성자 + created 플래그 (리플렉션 방어)
  • readResolve() 구현 (역직렬화 방어)
  • 모든 인스턴스 필드에 transient 선언
  • final 클래스 선언 (상속을 통한 우회 방어)
  • 상속 구조에서의 추가 고려

이 중 하나라도 빠뜨리면 싱글턴이 깨질 수 있다. 실무에서 이 조합을 완벽하게 유지하기란 쉽지 않다. enum 싱글턴은 그 실수를 할 표면적 자체가 줄어든다.

5.4 enum 싱글턴의 한계

enum이 항상 정답은 아니다. 다음 상황에서는 다른 방식을 선택해야 한다.

상속이 필요한 경우

enum은 java.lang.Enum을 상속하므로 다른 클래스를 상속받을 수 없다. 어떤 추상 클래스의 하위 타입이어야 한다면 enum은 탈락이다.

다만 인터페이스 구현은 가능하다. 상속 대신 인터페이스로 추상화하면 다형성을 유지할 수 있다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
public interface Singer {
    void sing();
}

// enum도 인터페이스는 구현 가능
public enum Elvis implements Singer {
    INSTANCE;

    @Override
    public void sing() {
        System.out.println("Thank you, thank you very much.");
    }
}

// 의존은 인터페이스로 받는다
public class Concert {
    private final Singer performer;

    public Concert(Singer performer) {
        this.performer = performer;
    }
}

// 실제 사용
Concert concert = new Concert(Elvis.INSTANCE);

// 테스트에서는 가짜 객체 주입
Concert testConcert = new Concert(new FakeSinger());

프레임워크 제약이 강한 경우

JPA, Spring Data 같은 프레임워크는 종종 기본 생성자가 필요하거나, 프록시 객체를 생성하기 위해 클래스를 상속하거나 인스턴스를 직접 생성한다. 이런 프레임워크 관리 빈(Bean)은 프레임워크가 생명주기를 통제하므로, 싱글턴을 직접 구현하기보다 컨테이너 스코프(Spring의 @Scope("singleton") 등)로 위임하는 것이 더 자연스럽다.

초기화 순서/시점이 극도로 민감한 경우

enum 상수는 클래스 초기화 시점에 생성된다. 초기화 과정에서 다른 클래스의 초기화를 트리거하는 순환 의존이 있다면 초기화 순서 문제(initialization order problem)가 발생할 수 있다. 이 경우 Holder 패턴으로 초기화 시점을 명시적으로 제어하는 것이 더 안전하다.


6. 리플렉션으로 싱글턴이 깨지는 방식과 방어

6.1 어떻게 깨지는가

private 생성자는 일반적인 경로로는 호출할 수 없다. 그러나 리플렉션을 사용하면 우회가 가능하다.

1
2
3
4
5
6
7
8
9
10
11
public final class Elvis {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() {}
}

// 리플렉션 공격
Constructor<Elvis> constructor = Elvis.class.getDeclaredConstructor();
constructor.setAccessible(true);  // 접근 제어 무시
Elvis newInstance = constructor.newInstance();

System.out.println(Elvis.INSTANCE == newInstance);  // false — 싱글턴 파괴!

setAccessible(true)는 Java의 접근 제어(private/protected 등)를 리플렉션 API 호출에 한해서 무시하도록 만든다. 이는 JVM이 허용하는 정상적인 기능이다. 즉, private 생성자는 실수로 인한 직접 호출을 막을 뿐, 의도적인 우회를 막지는 못한다.

6.2 왜 private 생성자만으로는 부족한가

Java의 접근 제어는 컴파일 타임 보호에 중점을 둔다. 소스 코드 수준에서 new Elvis()를 호출하면 컴파일 오류가 발생한다. 그러나 리플렉션은 런타임에 동작하며, 컴파일 타임 접근 제어를 우회할 수 있도록 설계된 API다. 이는 버그가 아니라 Java의 의도적인 설계이고, 테스트 프레임워크, 직렬화 프레임워크, DI 컨테이너 등이 이 기능에 의존한다.

Java 9의 모듈 시스템(JPMS)은 opens 지시어가 없는 패키지에 대한 deep reflection을 제한할 수 있다. 그러나 모듈을 사용하지 않는 클래스패스 기반 애플리케이션에서는 여전히 제한이 없다.

6.3 생성자 방어 코드

두 번째 인스턴스 생성을 막으려면 생성자 안에 방어 코드를 넣는다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public final class GuardedElvis {
    private static final GuardedElvis INSTANCE = new GuardedElvis();
    private static volatile boolean created = false;

    private GuardedElvis() {
        if (created) {
            throw new IllegalStateException("싱글턴 인스턴스는 이미 존재합니다.");
        }
        created = true;
    }

    public static GuardedElvis getInstance() {
        return INSTANCE;
    }
}

이 코드는 두 번째 인스턴스를 만들려 할 때 예외를 던진다. 그러나 완전하지 않다.

공격 시나리오 1: 클래스 초기화 전에 리플렉션 호출
JVM은 INSTANCE 필드 초기화와 생성자 호출을 <clinit> 내부에서 수행한다. 그러나 리플렉션으로 GuardedElvis.class를 로드하지 않고 생성자를 먼저 호출하면 createdfalse인 상태에서 두 번 호출될 수 있다.

공격 시나리오 2: created 필드 자체를 리플렉션으로 조작

1
2
3
4
5
6
Field field = GuardedElvis.class.getDeclaredField("created");
field.setAccessible(true);
field.set(null, false);  // 방어 플래그를 리셋
Constructor<GuardedElvis> c = GuardedElvis.class.getDeclaredConstructor();
c.setAccessible(true);
GuardedElvis fake = c.newInstance();  // 성공

리플렉션은 필드도 조작할 수 있다. 방어 플래그 자체가 공격 대상이 되는 것이다.

결론적으로 생성자 기반 싱글턴은 리플렉션을 완전히 막을 수 없다. 막을 수 있다(두 번째 생성 방지)와 안전하다(어떤 경우에도 하나만 존재)는 다른 이야기다. 완전한 보증이 필요하다면 enum을 사용해야 한다.

6.4 현실적인 평가

측면 필드/팩터리 방식 enum 방식
기본 상태 리플렉션에 열려 있음 리플렉션 공격 불가
방어 코드 추가 시 부분적으로 막을 수 있음 필요 없음
필드 조작 리플렉션으로 조작 가능 조작 불가
보안 기본값 불안전 안전

생성자 기반 싱글턴의 현실적인 평가

장점

  • 구현이 단순하다
  • JVM 초기화 규칙 덕분에 스레드 안전하다
  • 성능 부담이 없다

단점

  • 리플렉션 공격에 취약하다
  • 직렬화 시 readResolve를 반드시 구현해야 한다
  • 코드가 점점 복잡해질 수 있다

7. 직렬화로 싱글턴이 깨지는 방식과 readResolve

싱글턴이 Serializable을 구현하는 순간 또 다른 문제가 발생한다.

7.1 직렬화가 싱글턴을 파괴하는 원리

1
2
3
4
5
public class Elvis implements Serializable {
    public static final Elvis INSTANCE = new Elvis();
    private Elvis() {}
    public void sing() {}
}

이 클래스를 직렬화했다가 역직렬화하면 어떻게 될까?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Elvis e1 = Elvis.INSTANCE;

// 직렬화
ByteArrayOutputStream baos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(baos);
oos.writeObject(e1);
oos.close();

// 역직렬화
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bais);
Elvis e2 = (Elvis) ois.readObject();
ois.close();

System.out.println(e1 == e2);        // false!
System.out.println(e1.equals(e2));   // false! (equals 오버라이드 없으면)

e1e2는 서로 다른 객체다. 싱글턴이 깨졌다.

7.2 왜 이런 일이 발생하는가

일반 객체 생성과 역직렬화의 차이를 보면 이해된다.

일반 객체 생성

1
2
3
4
5
new Elvis()
 → JVM이 메모리 할당
 → 생성자 호출 (this 초기화)
 → 필드 초기화 코드 실행
 → 참조 반환

역직렬화

1
2
3
4
5
ObjectInputStream.readObject()
 → JVM이 메모리 직접 할당 (sun.misc.Unsafe 또는 내부 API 사용)
 → 생성자 호출 없음! (Object의 생성자만 호출)
 → 직렬화된 바이트에서 필드 값 복원
 → 참조 반환

핵심은 역직렬화가 생성자를 호출하지 않는다는 점이다. 따라서 private 생성자는 전혀 관여하지 않는다. static final INSTANCE와도 무관하게 새 객체가 메모리에 생성된다. 이것이 Java 직렬화의 설계다.

7.3 readResolve로 해결하기

이를 막는 표준 해법이 readResolve() 메서드다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import java.io.Serial;
import java.io.Serializable;

public final class Elvis implements Serializable {
    @Serial
    private static final long serialVersionUID = 1L;

    private static final Elvis INSTANCE = new Elvis();

    private Elvis() {}

    public static Elvis getInstance() {
        return INSTANCE;
    }

    public void sing() {}

    // 역직렬화 후 반환할 객체를 결정하는 훅 메서드
    @Serial
    private Object readResolve() {
        // 역직렬화로 생성된 객체를 버리고 기존 싱글턴 인스턴스 반환
        return INSTANCE;
    }
}

readResolve()는 역직렬화 흐름의 마지막 단계에 끼어드는 훅 메서드다.

1
2
3
4
5
6
ObjectInputStream.readObject()
 → 객체 생성 (생성자 없이)
 → 필드 복원
 → readResolve() 존재 여부 확인
   → 있으면: readResolve()가 반환한 객체로 교체
   → 없으면: 새로 만든 객체 그대로 반환

결과적으로 역직렬화로 새로 만든 객체는 readResolve()INSTANCE를 반환하면서 GC 대상이 되고, 호출부는 기존 INSTANCE를 받는다.

1
2
3
4
Elvis e1 = Elvis.getInstance();
Elvis e2 = deserialize(serialize(e1));

System.out.println(e1 == e2);  // true — readResolve 덕분에 같은 인스턴스

7.4 transient 선언이 왜 중요한가

readResolve()가 있더라도 인스턴스 필드를 transient로 선언하지 않으면 미묘한 보안 취약점이 생긴다.

1
2
3
4
5
6
7
8
9
// 위험한 코드 — 인스턴스 필드가 transient가 아니다
public final class Elvis implements Serializable {
    public static final Elvis INSTANCE = new Elvis();
    private String favoriteSong = "Hound Dog";  // transient가 아님!

    private Object readResolve() {
        return INSTANCE;
    }
}

역직렬화 과정에서 새 객체가 일시적으로 생성되는 순간, 그 객체는 직렬화된 필드 값을 가진 완전한 객체다. 만약 공격자가 직렬화 스트림을 조작해서 readResolve()가 반환하기 전에 이 임시 객체를 다른 곳에 저장해두는 공격(serialization proxy attack)을 시도하면, readResolve()INSTANCE를 반환하더라도 임시 객체 자체에는 여전히 접근할 수 있다.

모든 인스턴스 필드를 transient로 선언하면 이 임시 객체에 의미 있는 상태가 없어서 공격이 무력화된다.

1
2
3
4
5
6
7
8
public final class Elvis implements Serializable {
    public static final Elvis INSTANCE = new Elvis();
    private transient String favoriteSong = "Hound Dog";  // transient 선언

    private Object readResolve() {
        return INSTANCE;
    }
}

실무적으로는, 직렬화가 필요한 싱글턴이라면 enum을 쓰는 것이 이 모든 고민을 한 번에 해결한다.

7.5 직렬화 관련 전체 흐름 비교

상황 일반 클래스 (readResolve 없음) 일반 클래스 (readResolve 있음) enum
역직렬화 후 동일 인스턴스 ❌ 새 인스턴스 ✅ 동일 인스턴스 ✅ 동일 인스턴스
추가 코드 필요 readResolve() + transient 없음
필드 조작 공격 취약 부분적으로 취약 불가

8. 메서드 참조와 함수형 인터페이스로 싱글턴 다루기

아이템 3이 싱글턴 구현 이야기로 끝나면 반쪽짜리다. 실무에서는 싱글턴을 어떻게 주입하고 조합하느냐가 더 자주 문제 된다.

8.1 싱글턴 직접 참조의 문제

다음 코드를 보자.

1
2
3
4
5
public class Concert {
    public void start() {
        Elvis.INSTANCE.sing();  // 싱글턴에 강하게 결합
    }
}

ConcertElvis에 강하게 결합되어 있다. Elvis를 교체하거나 테스트에서 가짜 객체로 대체하는 것이 불가능하다. 이는 싱글턴 패턴의 가장 큰 실용적 문제이기도 하다.

8.2 Supplier로 싱글턴 제공자를 표현하기

싱글턴 자체를 직접 넘기는 대신, 제공자(provider) 형태로 넘기면 테스트와 조합이 쉬워진다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import java.util.function.Supplier;

public class Concert {
    private final Supplier<Singer> performerSupplier;

    // 구체적인 싱글턴 타입이 아닌 제공자를 받는다
    public Concert(Supplier<Singer> performerSupplier) {
        this.performerSupplier = performerSupplier;
    }

    public void start() {
        Singer performer = performerSupplier.get();
        performer.sing();
    }
}

사용할 때는 메서드 참조로 깔끔하게 연결된다.

1
2
3
4
5
6
7
8
9
10
11
12
// 정적 팩터리 싱글턴 — 메서드 참조 사용
Concert concert1 = new Concert(Elvis::getInstance);

// 필드 방식 싱글턴 — 람다로 감싸기
Concert concert2 = new Concert(() -> Elvis.INSTANCE);

// enum 싱글턴 — 람다로 감싸기
Concert concert3 = new Concert(() -> EnumElvis.INSTANCE);

// 테스트에서는 가짜 객체 주입
Singer fakeSinger = () -> System.out.println("[FAKE] La la la");
Concert testConcert = new Concert(() -> fakeSinger);

Concert는 이제 Elvis가 어떻게 구현되었는지 전혀 알지 못한다. 싱글턴인지, 매번 새로 생성되는지, 테스트 더블인지도 모른다. 오직 “호출하면 Singer를 준다”는 계약만 안다.

8.3 함수형 인터페이스와 전략으로서의 싱글턴

싱글턴은 상태를 가지는 전역 객체로만 쓰이지 않는다. 전략 객체를 하나만 두고 재사용하는 용도로도 자주 쓰인다. Comparator, Predicate, Function 같은 것들이 대표적이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import java.util.function.Predicate;

// 재사용 가능한 싱글턴 전략들
public final class Predicates {
    private Predicates() {}  // 인스턴스화 방지

    // 싱글턴 Predicate — 한 번만 생성, 계속 재사용
    public static final Predicate<String> HAS_TEXT =
            s -> s != null && !s.isBlank();

    public static final Predicate<String> IS_EMAIL =
            s -> s != null && s.contains("@");

    // 메서드 참조로도 노출
    public static Predicate<String> hasText() {
        return HAS_TEXT;
    }
}

// 사용
List<String> inputs = List.of("hello", "", null, "world");
inputs.stream()
      .filter(Predicates.HAS_TEXT)   // 싱글턴 Predicate 재사용
      .forEach(System.out::println);

// 메서드 참조로 사용
inputs.stream()
      .filter(Predicates::hasText)   // Predicate<String>을 반환하는 메서드 참조
      .forEach(System.out::println);

이 형태는 사실상 함수형 싱글턴이다. 인스턴스는 하나고, 호출부는 재사용한다. 람다와 static final의 조합은 불변 전략 객체를 표현하는 자연스러운 방법이다.

8.4 Guarded Supplier — 한 번만 초기화 보증

실무에서는 싱글턴의 생성 시점을 더 정밀하게 제어하고 싶을 때도 있다. Supplier를 한 번만 실행하도록 래핑하는 패턴이다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
import java.util.function.Supplier;

public final class MemoizedSupplier<T> implements Supplier<T> {
    private final Supplier<T> delegate;
    private volatile T value;

    public MemoizedSupplier(Supplier<T> delegate) {
        this.delegate = delegate;
    }

    @Override
    public T get() {
        if (value == null) {
            synchronized (this) {
                if (value == null) {
                    value = delegate.get();
                }
            }
        }
        return value;
    }

    public static <T> Supplier<T> memoize(Supplier<T> supplier) {
        return new MemoizedSupplier<>(supplier);
    }
}

// 사용
Supplier<ExpensiveResource> resource = MemoizedSupplier.memoize(ExpensiveResource::new);
resource.get();  // 이 시점에 처음 생성
resource.get();  // 캐시된 인스턴스 반환

Google Guava의 Suppliers.memoize()가 이 패턴의 대표적인 구현이다.


9. 실무 선택 기준 정리

지금까지 살펴본 방식들을 종합하면 다음과 같은 선택 기준이 나온다.

선택 흐름도

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
싱글턴이 정말 필요한가?
│
├── YES
│    │
│    ├── 클래스 상속이 필요한가?
│    │    ├── YES → 정적 팩터리 방식 + readResolve (직렬화 시)
│    │    └── NO
│    │         │
│    │         ├── 프레임워크가 인스턴스를 관리하는가? (Spring Bean 등)
│    │         │    ├── YES → 컨테이너 스코프로 위임 (직접 구현 X)
│    │         │    └── NO
│    │         │         │
│    │         │         ├── 직렬화가 필요한가?
│    │         │         │    ├── YES → enum (가장 안전)
│    │         │         │    └── NO
│    │         │         │         │
│    │         │         │         ├── 초기화를 지연해야 하는가?
│    │         │         │         │    ├── YES → Holder 패턴
│    │         │         │         │    └── NO → enum 또는 public static final 필드
│    │         │         │         │
│    │         │         │         └── 리플렉션 방어가 최우선인가?
│    │         │         │                ├── YES → enum
│    │         │         │                └── NO → public static final 또는 정적 팩터리
│    │         │         │
│    │         │         └── 테스트에서 교체 가능해야 하는가?
│    │         │                ├── YES → 정적 팩터리 + Supplier 주입
│    │         │                └── NO → enum
│    │         │
│    └── 모든 경우 → 의존은 Supplier<T>로 주입하는 것을 고려하라
│
└── NO → 싱글턴 없이 DI 컨테이너나 팩터리로 해결 가능한지 재검토

방식별 비교 요약

방식 구현 복잡도 리플렉션 방어 직렬화 안전 테스트 편의 지연 초기화
public static final 필드 ★☆☆ 단순 ❌ 취약 ❌ 취약 ❌ 어려움 ❌ 불가
정적 팩터리 ★☆☆ 단순 ❌ 취약 ❌ 취약 ✅ 가능 ❌ 불가
Holder 패턴 ★★☆ 보통 ❌ 취약 ❌ 취약 ✅ 가능 ✅ 가능
enum ★☆☆ 단순 ✅ 안전 ✅ 안전 △ 제한적 ❌ 불가

핵심 원칙

  • 가장 안전한 싱글턴이 필요하면 enum을 우선 고려하라 — 기본값이 안전하다
  • 유연성이 필요하면 정적 팩터리 방식을 선택하라 — 구현을 숨기고 변경에 열려 있다
  • 직렬화가 끼면 enum 또는 readResolve를 반드시 챙겨라 — 역직렬화는 생성자를 우회한다
  • 리플렉션 방어를 완벽히 하려면 enum이 유일한 선택이다 — 언어 레벨에서 보장된다
  • 싱글턴을 직접 참조로 고정하지 말고, Supplier로 감싸서 주입하라 — 테스트와 유지보수가 쉬워진다

결론

싱글턴이라는 패턴은 단순해 보이지만, 제대로 구현하려면 JVM의 클래스 초기화 모델, 리플렉션 API, Java 직렬화 메커니즘을 이해해야 한다.

Effective Java가 이 항목에서 말하고자 하는 핵심은 결국 하나다. “규칙을 사람이 지키는 방식”보다 “언어가 강제하는 방식”이 더 안전하다.

필드 방식과 정적 팩터리 방식은 개발자가 올바른 코드를 작성할 것을 믿는다. enum은 JVM이 올바름을 강제한다. 보안과 일관성이 최우선이면 enum이고, 유연성과 테스트 편의성이 우선이면 정적 팩터리에 Supplier 주입 패턴을 더한다.

마지막으로, 싱글턴이 정말 필요한지 먼저 물어보는 것도 중요하다. 전역 상태는 항상 설계의 복잡성을 높인다. DI 컨테이너가 관리하는 Bean이나 함수형 스타일의 불변 전략 객체가 더 나은 해법일 수도 있다.


다음 글에서는 인스턴스화를 막는 패턴 (private 생성자로 인스턴스화를 막아라) 을 정리한다.