Singleton 패턴 - Lazy Holder와 Enum

방지환·2026년 1월 8일

Java

목록 보기
13/19

1. Lazy Holder 패턴

기본 개념

Lazy Holder 패턴은 static 내부 클래스를 이용해서 싱글톤 인스턴스를 지연 생성(Lazy Initialization)하는 방법입니다.

핵심 원리

public class Singleton {
    
    // 1. private 생성자 - 외부에서 new 불가
    private Singleton() {
        System.out.println("Singleton 인스턴스 생성됨!");
    }
    
    // 2. static 내부 클래스 - 핵심!
    // 이 클래스는 Singleton 클래스가 로딩될 때 로딩되지 않음
    // getInstance()가 호출될 때 처음 로딩됨
    private static class LazyHolder {
        // 3. LazyHolder가 로딩될 때 인스턴스 생성
        private static final Singleton INSTANCE = new Singleton();
    }
    
    // 4. 외부에서 인스턴스를 가져오는 메서드
    public static Singleton getInstance() {
        // 이 메서드가 처음 호출될 때 LazyHolder 클래스가 로딩됨
        return LazyHolder.INSTANCE;
    }
    
    // 5. 실제 비즈니스 메서드들
    public void doSomething() {
        System.out.println("작업 수행 중...");
    }
}

동작 과정 상세 설명

public class Main {
    public static void main(String[] args) throws InterruptedException {
        
        // === 1단계 ===
        System.out.println("1. 프로그램 시작");
        // 아직 Singleton 클래스도 로딩 안됨
        
        Thread.sleep(1000);
        
        // === 2단계 ===
        System.out.println("\n2. Singleton 클래스 참조");
        Class clazz = Singleton.class;
        // Singleton 클래스만 로딩됨
        // LazyHolder 클래스는 아직 로딩 안됨!
        
        Thread.sleep(1000);
        
        // === 3단계 ===
        System.out.println("\n3. getInstance() 첫 호출");
        Singleton s1 = Singleton.getInstance();
        // 이때! LazyHolder 클래스가 처음 로딩됨
        // LazyHolder의 INSTANCE가 생성됨
        // 출력: "Singleton 인스턴스 생성됨!"
        
        Thread.sleep(1000);
        
        // === 4단계 ===
        System.out.println("\n4. getInstance() 두 번째 호출");
        Singleton s2 = Singleton.getInstance();
        // 이미 생성된 INSTANCE를 반환
        // 생성자 호출 안됨!
        
        System.out.println("\n5. 같은 인스턴스? " + (s1 == s2));  // true
    }
}

실행 결과:

1. 프로그램 시작

2. Singleton 클래스 참조

3. getInstance() 첫 호출
Singleton 인스턴스 생성됨!

4. getInstance() 두 번째 호출

5. 같은 인스턴스? true

시각적으로 이해하기

[프로그램 시작]
    ↓
[Singleton.class 참조]
    ↓ (Singleton 클래스만 메모리에 로드)
    ↓ (LazyHolder는 아직 로드 안됨!)
    ↓
[getInstance() 첫 호출]
    ↓
    → LazyHolder 클래스 로드 시작
    → LazyHolder.INSTANCE 초기화 시작
    → new Singleton() 실행
    → 인스턴스 생성 완료
    → INSTANCE에 저장
    ↓
[getInstance() 반환]
    ↓
    → 저장된 INSTANCE 반환 (생성 안함)

Thread-Safe 보장 원리

// 여러 Thread가 동시에 getInstance() 호출
public class MultiThreadTest {
    public static void main(String[] args) {
        // 10개의 Thread가 동시에 getInstance() 호출
        for (int i = 0; i < 10; i++) {
            final int threadNum = i;
            new Thread(() -> {
                Singleton instance = Singleton.getInstance();
                System.out.println("Thread " + threadNum + ": " + 
                    System.identityHashCode(instance));
            }).start();
        }
    }
}

실행 결과:

Singleton 인스턴스 생성됨!
Thread 0: 123456789
Thread 1: 123456789
Thread 2: 123456789
Thread 3: 123456789
...
(모두 같은 해시코드 = 같은 인스턴스)

왜 Thread-Safe 한가?

  1. JVM의 클래스 로더가 보장

    • 클래스 로딩은 JVM이 내부적으로 동기화함
    • 한 번만 로딩되는 것을 보장
  2. static final의 특성

    • static final 변수는 클래스 로딩 시 단 한 번만 초기화됨
    • 멀티 스레드 환경에서도 안전
// JVM이 내부적으로 이렇게 동작
synchronized (LazyHolder.class) {
    if (!LazyHolder.isLoaded()) {
        // 클래스 로딩
        // INSTANCE 초기화
        LazyHolder.markAsLoaded();
    }
}

실무 예제 1: 데이터베이스 연결 관리

public class DatabaseConnection {
    
    private Connection connection;
    
    // private 생성자 - DB 연결 초기화
    private DatabaseConnection() {
        try {
            System.out.println("DB 연결 초기화 중...");
            // 실제로는 시간이 오래 걸리는 작업
            Thread.sleep(2000);
            
            this.connection = DriverManager.getConnection(
                "jdbc:mysql://localhost:3306/mydb",
                "user",
                "password"
            );
            System.out.println("DB 연결 완료!");
        } catch (Exception e) {
            throw new RuntimeException("DB 연결 실패", e);
        }
    }
    
    // Lazy Holder
    private static class Holder {
        private static final DatabaseConnection INSTANCE = 
            new DatabaseConnection();
    }
    
    public static DatabaseConnection getInstance() {
        return Holder.INSTANCE;
    }
    
    public Connection getConnection() {
        return connection;
    }
    
    public void executeQuery(String sql) {
        System.out.println("쿼리 실행: " + sql);
        // 실제 쿼리 실행 로직
    }
}

// 사용
public class Application {
    public static void main(String[] args) {
        System.out.println("애플리케이션 시작");
        System.out.println("여러 작업 수행 중...");
        
        // 필요한 시점에만 DB 연결 생성
        System.out.println("\nDB 작업 필요!");
        DatabaseConnection db = DatabaseConnection.getInstance();
        db.executeQuery("SELECT * FROM users");
    }
}

실행 결과:

애플리케이션 시작
여러 작업 수행 중...

DB 작업 필요!
DB 연결 초기화 중...
DB 연결 완료!
쿼리 실행: SELECT * FROM users

실무 예제 2: 설정 관리자

public class ConfigManager {
    
    private Properties properties;
    
    private ConfigManager() {
        System.out.println("설정 파일 로딩 중...");
        properties = new Properties();
        
        try {
            // 설정 파일 읽기 (시간 소요)
            FileInputStream fis = new FileInputStream("config.properties");
            properties.load(fis);
            System.out.println("설정 로딩 완료!");
        } catch (IOException e) {
            // 기본 설정 사용
            properties.setProperty("app.name", "MyApp");
            properties.setProperty("app.version", "1.0.0");
            properties.setProperty("max.connections", "100");
        }
    }
    
    private static class Holder {
        private static final ConfigManager INSTANCE = new ConfigManager();
    }
    
    public static ConfigManager getInstance() {
        return Holder.INSTANCE;
    }
    
    public String getProperty(String key) {
        return properties.getProperty(key);
    }
    
    public int getIntProperty(String key, int defaultValue) {
        String value = properties.getProperty(key);
        return value != null ? Integer.parseInt(value) : defaultValue;
    }
}

// 사용
public class Service {
    public void start() {
        // 필요할 때만 설정 로드
        ConfigManager config = ConfigManager.getInstance();
        
        String appName = config.getProperty("app.name");
        int maxConnections = config.getIntProperty("max.connections", 10);
        
        System.out.println("앱 이름: " + appName);
        System.out.println("최대 연결 수: " + maxConnections);
    }
}

장단점

장점:

  • ✅ Lazy Loading: 필요할 때만 인스턴스 생성
  • ✅ Thread-Safe: synchronized나 volatile 불필요
  • ✅ 성능 우수: Lock이 없어 빠름
  • ✅ 구현이 비교적 간단

단점:

  • ❌ 직렬화(Serialization) 문제: 추가 처리 필요
  • ❌ 리플렉션 공격에 취약: 강제로 생성자 호출 가능
  • ❌ 내부 클래스 개념 이해 필요

2. Enum 싱글톤

기본 개념

Enum(열거형)은 Java에서 상수들의 집합을 정의하는 특별한 클래스입니다. Enum의 각 상수는 해당 Enum 타입의 인스턴스이며, JVM이 자동으로 싱글톤을 보장합니다.

Enum 기초부터 이해하기

1단계: 기본 Enum

// 가장 단순한 Enum - 요일
public enum Day {
    MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY
}

// 사용
public class EnumBasic {
    public static void main(String[] args) {
        Day today = Day.MONDAY;
        System.out.println(today);  // MONDAY
        
        // 비교
        if (today == Day.MONDAY) {
            System.out.println("월요일입니다!");
        }
        
        // switch 문
        switch (today) {
            case MONDAY:
                System.out.println("한 주의 시작");
                break;
            case FRIDAY:
                System.out.println("불금!");
                break;
        }
    }
}

2단계: Enum은 클래스다!

// Enum에 필드와 메서드 추가
public enum Day {
    // 각 상수는 Day 클래스의 인스턴스
    MONDAY("월요일", 1),
    TUESDAY("화요일", 2),
    WEDNESDAY("수요일", 3),
    THURSDAY("목요일", 4),
    FRIDAY("금요일", 5),
    SATURDAY("토요일", 6),
    SUNDAY("일요일", 7);
    
    // 필드
    private final String koreanName;
    private final int dayNumber;
    
    // 생성자 (항상 private)
    Day(String koreanName, int dayNumber) {
        this.koreanName = koreanName;
        this.dayNumber = dayNumber;
    }
    
    // 메서드
    public String getKoreanName() {
        return koreanName;
    }
    
    public int getDayNumber() {
        return dayNumber;
    }
    
    public boolean isWeekend() {
        return this == SATURDAY || this == SUNDAY;
    }
}

// 사용
public class EnumWithFields {
    public static void main(String[] args) {
        Day today = Day.FRIDAY;
        
        System.out.println(today.getKoreanName());  // 금요일
        System.out.println(today.getDayNumber());    // 5
        System.out.println(today.isWeekend());       // false
        
        // 모든 Enum 상수 순회
        for (Day day : Day.values()) {
            System.out.println(day + " = " + day.getKoreanName());
        }
    }
}

3단계: Enum은 싱글톤이다!

public enum Day {
    MONDAY, TUESDAY;
}

public class EnumSingleton {
    public static void main(String[] args) {
        Day day1 = Day.MONDAY;
        Day day2 = Day.MONDAY;
        
        // 같은 인스턴스!
        System.out.println(day1 == day2);  // true
        System.out.println(System.identityHashCode(day1));  // 같은 해시코드
        System.out.println(System.identityHashCode(day2));  // 같은 해시코드
    }
}

Enum 싱글톤 패턴

public enum Singleton {
    INSTANCE;  // 이게 싱글톤 인스턴스!
    
    // 필드
    private int value;
    private String data;
    
    // 생성자 - Enum 상수가 생성될 때 딱 한 번만 호출됨
    Singleton() {
        System.out.println("Singleton 인스턴스 생성!");
        this.value = 0;
        this.data = "초기 데이터";
    }
    
    // 메서드들
    public int getValue() {
        return value;
    }
    
    public void setValue(int value) {
        this.value = value;
    }
    
    public String getData() {
        return data;
    }
    
    public void setData(String data) {
        this.data = data;
    }
    
    public void doSomething() {
        System.out.println("작업 수행: " + data);
    }
}

사용 방법

public class Main {
    public static void main(String[] args) {
        // INSTANCE로 접근
        Singleton s1 = Singleton.INSTANCE;
        s1.setValue(100);
        s1.setData("첫 번째 데이터");
        s1.doSomething();
        
        // 다른 곳에서 접근해도 같은 인스턴스
        Singleton s2 = Singleton.INSTANCE;
        System.out.println(s2.getValue());  // 100 (s1에서 설정한 값)
        System.out.println(s2.getData());   // "첫 번째 데이터"
        
        // 같은 인스턴스 확인
        System.out.println(s1 == s2);  // true
    }
}

실행 결과:

Singleton 인스턴스 생성!
작업 수행: 첫 번째 데이터
100
첫 번째 데이터
true

Enum 싱글톤이 안전한 이유

1. JVM이 자동으로 싱글톤 보장

// Enum은 내부적으로 이렇게 동작
public final class Singleton extends Enum {
    // JVM이 자동으로 생성
    public static final Singleton INSTANCE = new Singleton();
    
    private Singleton() {
        // 생성자는 JVM만 호출 가능
    }
}

2. 리플렉션 공격 방어

public class ReflectionAttackTest {
    public static void main(String[] args) {
        try {
            // Lazy Holder 패턴 - 리플렉션으로 공격 가능
            Constructor constructor1 = 
                LazyHolderSingleton.class.getDeclaredConstructor();
            constructor1.setAccessible(true);
            LazyHolderSingleton instance1 = constructor1.newInstance();
            LazyHolderSingleton instance2 = constructor1.newInstance();
            System.out.println("LazyHolder 공격 성공: " + (instance1 != instance2));
            
            // Enum 싱글톤 - 리플렉션 방어!
            Constructor constructor2 = 
                EnumSingleton.class.getDeclaredConstructor();
            constructor2.setAccessible(true);
            EnumSingleton instance3 = constructor2.newInstance();  // 예외 발생!
            
        } catch (Exception e) {
            System.out.println("Enum 방어 성공: " + e.getMessage());
            // "Cannot reflectively create enum objects" 출력
        }
    }
}

3. 직렬화/역직렬화 안전

public class SerializationTest {
    public static void main(String[] args) throws Exception {
        // Enum 싱글톤 직렬화
        Singleton instance1 = Singleton.INSTANCE;
        instance1.setValue(100);
        
        // 직렬화
        ObjectOutputStream oos = new ObjectOutputStream(
            new FileOutputStream("singleton.ser"));
        oos.writeObject(instance1);
        oos.close();
        
        // 역직렬화
        ObjectInputStream ois = new ObjectInputStream(
            new FileInputStream("singleton.ser"));
        Singleton instance2 = (Singleton) ois.readObject();
        ois.close();
        
        // 같은 인스턴스! (Enum은 자동으로 보장)
        System.out.println(instance1 == instance2);  // true
        System.out.println(instance2.getValue());     // 100
    }
}

실무 예제 1: 애플리케이션 컨텍스트

public enum ApplicationContext {
    INSTANCE;
    
    private final Map beans = new ConcurrentHashMap<>();
    private boolean initialized = false;
    
    ApplicationContext() {
        System.out.println("ApplicationContext 초기화");
    }
    
    public void initialize() {
        if (initialized) {
            return;
        }
        
        // 빈 등록
        beans.put("userService", new UserService());
        beans.put("orderService", new OrderService());
        beans.put("emailService", new EmailService());
        
        initialized = true;
        System.out.println("빈 초기화 완료");
    }
    
    @SuppressWarnings("unchecked")
    public  T getBean(String name, Class type) {
        Object bean = beans.get(name);
        if (bean == null) {
            throw new IllegalArgumentException("Bean not found: " + name);
        }
        return (T) bean;
    }
    
    public void registerBean(String name, Object bean) {
        beans.put(name, bean);
    }
}

// 사용
public class Application {
    public static void main(String[] args) {
        // 어디서든 같은 컨텍스트 접근
        ApplicationContext context = ApplicationContext.INSTANCE;
        context.initialize();
        
        // 빈 가져오기
        UserService userService = context.getBean("userService", UserService.class);
        userService.createUser("홍길동");
        
        // 다른 곳에서도 같은 컨텍스트
        ApplicationContext ctx2 = ApplicationContext.INSTANCE;
        OrderService orderService = ctx2.getBean("orderService", OrderService.class);
        orderService.createOrder("ORDER-001");
    }
}

실무 예제 2: 로거

public enum Logger {
    INSTANCE;
    
    private final DateTimeFormatter formatter = 
        DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
    
    public void info(String message) {
        log("INFO", message);
    }
    
    public void error(String message) {
        log("ERROR", message);
    }
    
    public void warn(String message) {
        log("WARN", message);
    }
    
    private void log(String level, String message) {
        String timestamp = LocalDateTime.now().format(formatter);
        System.out.println(String.format("[%s] [%s] %s", 
            timestamp, level, message));
    }
}

// 사용 - 어디서든 같은 로거
public class Service {
    public void processOrder() {
        Logger.INSTANCE.info("주문 처리 시작");
        
        try {
            // 주문 처리 로직
            Logger.INSTANCE.info("주문 처리 완료");
        } catch (Exception e) {
            Logger.INSTANCE.error("주문 처리 실패: " + e.getMessage());
        }
    }
}

실무 예제 3: 캐시 매니저

public enum CacheManager {
    INSTANCE;
    
    private final Map cache = new ConcurrentHashMap<>();
    
    private static class CacheEntry {
        Object value;
        long expireTime;
        
        CacheEntry(Object value, long ttl) {
            this.value = value;
            this.expireTime = System.currentTimeMillis() + ttl;
        }
        
        boolean isExpired() {
            return System.currentTimeMillis() > expireTime;
        }
    }
    
    public void put(String key, Object value, long ttlMillis) {
        cache.put(key, new CacheEntry(value, ttlMillis));
    }
    
    public Object get(String key) {
        CacheEntry entry = cache.get(key);
        
        if (entry == null) {
            return null;
        }
        
        if (entry.isExpired()) {
            cache.remove(key);
            return null;
        }
        
        return entry.value;
    }
    
    public void clear() {
        cache.clear();
    }
    
    public int size() {
        // 만료된 항목 제거
        cache.entrySet().removeIf(e -> e.getValue().isExpired());
        return cache.size();
    }
}

// 사용
public class UserService {
    public User getUser(String userId) {
        // 캐시 확인
        User cached = (User) CacheManager.INSTANCE.get("user:" + userId);
        if (cached != null) {
            System.out.println("캐시에서 조회");
            return cached;
        }
        
        // DB에서 조회
        System.out.println("DB에서 조회");
        User user = findUserFromDB(userId);
        
        // 캐시에 저장 (5분)
        CacheManager.INSTANCE.put("user:" + userId, user, 5 * 60 * 1000);
        
        return user;
    }
}

장단점

장점:

  • 가장 간결: 코드가 매우 단순
  • 완벽한 Thread-Safe: JVM이 보장
  • 리플렉션 공격 방어: 자동으로 방어
  • 직렬화 안전: 추가 코드 불필요
  • 구현 실수 불가능: 컴파일러가 보장

단점:

  • Lazy Loading 불가: 클래스 로딩 시 생성됨
  • 상속 불가: Enum은 다른 클래스를 상속할 수 없음 (인터페이스 구현은 가능)
  • Android에서 비권장: 메모리 사용량이 상대적으로 높음

3. Lazy Holder vs Enum 비교

코드 비교

// Lazy Holder - 더 복잡
public class LazyHolderSingleton {
    private LazyHolderSingleton() {}
    
    private static class Holder {
        private static final LazyHolderSingleton INSTANCE = 
            new LazyHolderSingleton();
    }
    
    public static LazyHolderSingleton getInstance() {
        return Holder.INSTANCE;
    }
}

// Enum - 매우 간결
public enum EnumSingleton {
    INSTANCE;
}

// 사용도 간결
LazyHolderSingleton.getInstance().doSomething();  // Lazy Holder
EnumSingleton.INSTANCE.doSomething();              // Enum

상세 비교표

특징Lazy HolderEnum
코드 복잡도중간 (내부 클래스 필요)매우 낮음
Lazy Loading✅ 가능❌ 불가 (클래스 로딩 시 생성)
Thread-Safe✅ (JVM 보장)✅ (JVM 보장)
리플렉션 방어❌ 취약✅ 자동 방어
직렬화 안전❌ 추가 코드 필요✅ 자동 보장
상속✅ 가능❌ 불가 (인터페이스 구현만 가능)
메모리효율적약간 더 사용
권장도높음매우 높음

선택 가이드

// 무거운 객체 + Lazy Loading 필요 → Lazy Holder
public class HeavyResourceManager {
    private HeavyResourceManager() {
        // 10초 걸리는 초기화
        loadHeavyResources();
    }
    
    private static class Holder {
        private static final HeavyResourceManager INSTANCE = 
            new HeavyResourceManager();
    }
    
    public static HeavyResourceManager getInstance() {
        return Holder.INSTANCE;
    }
}

// 일반적인 경우 → Enum (권장!)
public enum ConfigManager {
    INSTANCE;
    
    private Properties config = new Properties();
    
    public String getProperty(String key) {
        return config.getProperty(key);
    }
}

// 상속이 필요한 경우 → Lazy Holder
public abstract class BaseService {
    public abstract void process();
}

public class ServiceManager extends BaseService {
    private ServiceManager() {}
    
    private static class Holder {
        private static final ServiceManager INSTANCE = new ServiceManager();
    }
    
    public static ServiceManager getInstance() {
        return Holder.INSTANCE;
    }
    
    @Override
    public void process() {
        // 구현
    }
}

최종 권장사항

// 일반적인 경우: Enum 사용 (90% 케이스)
public enum ApplicationConfig {
    INSTANCE;
    // 간단하고 안전함
}

// 특별한 경우만 Lazy Holder 사용:
// 1. 초기화가 무겁고 사용 안 될 수도 있는 경우
// 2. 상속이 필요한 경우
// 3. 인터페이스 구현만으로는 부족한 경우

0개의 댓글