본문 바로가기

(11.20-2) Java 프로그래밍 - GoF 디자인 패턴, 싱글턴 구현 방식

@starweb2025. 11. 20. 18:33

[ 14주차 - 1120 ] - 오후

    금일 커리큘럼
        ├ 09:00 ~ 12:00 Devops (other posting...)
        └ 13:00 ~ 18:00 Java (GoF 디자인 패턴 개념, 싱글턴 패턴)

GoF 디자인 패턴

GoF(Gang of Four)가 제안한 23가지 디자인 패턴을 의미

디자인 패턴의 가치

  • 재사용성 : 이미 검증된 설계 패턴을 사용하여 코드의 재사용성을 높임
  • 유지보수성 : 코드의 구조를 명확히 하여 유지보수를 용이하게 함
  • 유연성 : 변화하는 요구사항에 대응하기 쉬운 설계를 가능하게 함
  • 커뮤니케이션 : 개발자들 간에 공통된 용어와 개념을 제공하여 의사소통을 원활하게 함
  • 객체지향 극대화 : 낮은 결합도와 높은 응집도를 가진 객체지향 설계를 촉진함

디자인 패턴의 분류

  • 생성 패턴(Creational) : 객체 생성과 관련된 패턴
  • 구조적 패턴(Structural) : 클래스와 객체의 조합과 관련된 패턴
  • 행위 패턴(Behavioral) : 객체 간의 상호작용과 책임 분배와 관련된 패턴

생성 패턴의 종류 ( 5 )

  • 싱글턴 패턴(Singleton) : 클래스의 인스턴스가 하나만 생성되도록 보장하는 패턴
  • 팩토리 패턴(Factory) : 객체 생성 로직을 캡슐화하여 클라이언트 코드와 분리하는 패턴
  • 추상 팩토리 패턴(Abstract Factory) : 관련된 객체들의 군을 생성하는 인터페이스를 제공하는 패턴
  • 빌더 패턴(Builder) : 복잡한 객체의 생성 과정을 단계별로 분리하여 객체를 생성하는 패턴
  • 프로토타입 패턴(Prototype) : 기존 객체를 복제하여 새로운 객체를 생성하는 패턴

구조적 패턴의 종류 ( 7 )

  • 어댑터 패턴(Adapter) : 서로 호환되지 않는 인터페이스를 가진 클래스들을 연결하는 패턴
  • 브리지 패턴(Bridge) : 구현부에서 추상층을 분리하여 독립적으로 변형할 수 있게 하는 패턴
  • 컴포지트 패턴(Composite) : 객체들을 트리 구조로 구성하여 부분-전체 계층을 표현하는 패턴
  • 데코레이터 패턴(Decorator) : 객체에 추가적인 기능을 동적으로 부여하는 패턴
  • 퍼사드 패턴(Facade) : 복잡한 서브시스템에 대한 단순화된 인터페이스를 제공하는 패턴
  • 플라이웨이트 패턴(Flyweight) : 많은 수의 유사한 객체들을 효율적으로 공유하는 패턴
  • 프록시 패턴(Proxy) : 다른 객체에 대한 접근을 제어하는 대리자 객체를 제공하는 패턴

행위 패턴의 종류 ( 11 )

  • 책임 연쇄 패턴(Chain of Responsibility) : 요청을 처리할 객체를 연결하여 요청을 전달하는 패턴
  • 커맨드 패턴(Command) : 요청을 객체로 캡슐화하여 다양한 요청을 처리하는 패턴
  • 인터프리터 패턴(Interpreter) : 언어의 문법을 표현하는 클래스들을 구성하는 패턴
  • 이터레이터 패턴(Iterator) : 컬렉션의 요소들에 접근하는 방법을 제공하는 패턴
  • 중재자 패턴(Mediator) : 객체들 간의 상호작용을 중재하는 객체를 제공하는 패턴
  • 메멘토 패턴(Memento) : 객체의 상태를 저장하고 복원하는 패턴
  • 옵저버 패턴(Observer) : 객체의 상태 변화에 따라 의
  • 상태 패턴(State) : 객체의 상태에 따라 행동을 변경하는 패턴
  • 전략 패턴(Strategy) : 알고리즘을 캡슐화하여 교환 가능하게 하는 패턴
  • 템플릿 메서드 패턴(Template Method) : 알고리즘의 구조를 정의하고 일부 단계를 서브클래스에서 구현하도록 하는 패턴
  • 비지터 패턴(Visitor) : 객체 구조에 새로운 연산을 추가하는 패턴

스프링부트에서 자주쓰이던 패턴

생성 패턴

  • 싱글턴 패턴 : 스프링의 기본 Bean 스코프가 싱글턴
    • bean 객체가 애플리케이션 당 하나만 생성되어 공유되는 개념만 동일하고,
    • 구현 방식은 일반 싱글턴과 다름.
  • 팩토리 패턴 : 스프링의 BeanFactory, FactoryBean
  • 빌더 패턴 : Lombok @Builder, DTO/엔티티 생성 시 사용

구조적 패턴

  • 어댑터 패턴 : 스프링 MVC의 HandlerAdapter
  • 데코레이터 패턴 : Spring Security 필터 체인, HttpMessageConverter 래핑
  • 퍼사드 패턴 : 스프링 MVC에서 Service 계층이 퍼사드 역할
    • 컨트롤러가 서비스 계층에 단순화된 인터페이스로 접근하도록 되어있고,
    • 서비스 계층이 내부적으로 복잡한 비즈니스 로직을 처리하기 때문임
    • 즉, 컨트롤러는 접근만 <-> 서비스는 로직처리만
  • 프록시 패턴 : AOP, @Transactional, CGLIB/JDK 동적 프록시 기반

행위 패턴

  • 책임 연쇄 패턴 : Spring Security 필터 체인
  • 커맨드 패턴 : @RequestMapping → 요청을 메서드 호출로 연결
  • 옵저버 패턴 : ApplicationEventPublisher, @EventListener
  • 전략 패턴 : 인터페이스 기반 빈 교환 구조 (DI 핵심)
  • 템플릿 메서드 패턴 : JdbcTemplate, RestTemplate, AbstractController

2. Singleton Pattern

  • 클래스의 인스턴스가 하나만 생성되도록 보장하는 디자인 패턴
  • 전역 접근점을 제공하여 어디서든 동일한 인스턴스에 접근 가능

싱글턴 패턴이 필요한 경우

  • 1) 애플리케이션 전체에서 공유되는 리소스 관리 (예: 설정 정보, 로깅, DB 연결 등)
// DB 연결 관리 예시

// Config가 일반 클래스일 때 -----------------

// 잘못된 예 ❌ : DB 연결 객체가 여러 개 생성될 수 있음
Config config1 = new Config();
Config config2 = new Config();


// Config가 싱글턴 패턴 클래스일 때 -----------------

// 올바른 예 ✅ : 싱글턴 패턴을 사용하여 하나의 인스턴스만으로 사용
Config config = Config.getInstance();
Config config2 = Config.getInstance();
// config1 == config2는 동일한 인스턴스
  • 2) 로그 관리 (예: 로그를 기록하는 Logger)
// Logger가 일반 클래스일 때 -----------------
// 잘못된 예 ❌ : Logger 객체가 여러 개 생성될 수 있음
Logger logger1 = new Logger();
Logger logger2 = new Logger();


// Logger가 싱글턴 패턴 클래스일 때 -----------------
// 올바른 예 ✅ : 싱글턴 패턴을 사용하여 하나의 인스턴스만으로 사용
Logger logger1 = Logger.getInstance();
Logger logger2 = Logger.getInstance();
// logger1 == logger2는 동일한 인스턴스

싱글턴 구현 - 즉시 초기화 방식

  • 클래스 로딩 시점에 인스턴스를 미리 생성하여 제공
  • ✅ 장점 : 구현 간단, 멀티스레드 환경에서 안전, 동기화 비용이 없음
  • ❌ 단점 : 인스턴스가 필요하지 않아도 메모리를 차지할 수 있음
public class Singleton {
    // 1. 클래스 로딩 시점에 인스턴스 생성
    private static final Singleton instance = new Singleton();

    // 2. private 생성으로 외부 생성 차단
    private Singleton() {
        // 초기화 코드
    }

    // 3. 전역 접근점을 제공하는 메서드
    public static Singleton getInstance() {
        return instance; // 같은 인스턴스 반환해줌
    }
}

싱글턴 구현 - 지연 초기화 방식

  • 인스턴스가 처음 필요할 때 생성하는 방식임
  • ✅ 장점 : 인스턴스가 필요할 때만 생성되어 메모리 절약 가능
  • ❌ 단점 : 멀티스레드 환경에서 동기화 문제 발생 가능, 구현이 복잡해짐
    • 여러 스레드가 동시에 getInstance() 호출 시 여러 인스턴스가 생성될 수 있기 때문
public class Singleton {
    // 1. 인스턴스를 저장할 private static 변수
    private static Singleton instance;

    // 2. private 생성으로 외부 생성 차단
    private Singleton() {
        // 초기화 코드
    }

    // 3. 전역 접근점을 제공하는 메서드
    public static synchronized Singleton getInstance() {
        // 4. 인스턴스가 없을 때만 생성
        if (instance == null) {
            instance = new Singleton();
        }
        return instance; // 같은 인스턴스 반환해줌
    }
}

싱글턴 구현 - 이중 검증 잠금 방식

  • 지연 초기화 방식의 단점을 보완한 방법
  • ✅ 장점 : 멀티스레드 환경에서 안전하면서도 동기화 비용을 줄임
  • ❌ 단점 : 구현이 다소 복잡함
public class Singleton {
    // 1. 인스턴스를 저장할 private static 변수
    // volatile : 멀티스레드 환경에서 인스턴스 변수의 가시성을 보장
    private static volatile Singleton instance;

    // 2. private 생성으로 외부 생성 차단
    private Singleton() {
        // 초기화 코드
    }

    // 3. 전역 접근점을 제공하는 메서드
    public static Singleton getInstance() {
        // 4. 첫 번째 검증 (인스턴스가 없을 때만 동기화)
        if (instance == null) {
            synchronized (Singleton.class) {
                // 5. 두 번째 검증 (동기화된 블록 내에서 다시 확인)
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance; // 같은 인스턴스 반환해줌
    }
}
  • volatile 키워드는 멀티스레드 환경에서 변수의 가시성을 보장
    • volatile는 변수에 대한 읽기/쓰기가 메인 메모리에서 직접 이루어지도록 하여,
    • 여러 스레드가 동시에 접근할 때 최신 값을 보장함
  • synchronized 블록을 사용하여 동기화 처리
    • synchronized = 한번에 하나의 메서드만

싱글턴 구현 - Static Inner Class 방식 (권장함)

  • 내부클래스로 선언하여 인스턴스를 생성하는 방식
  • ✅ 장점 : 멀티스레드 환경에서 안전, 지연 초기화 가능, 구현이 간단
  • ❌ 단점 : 특별한 단점 없음
public class Singleton {
    // 1. private 생성으로 외부 생성 차단
    private Singleton() {
        // 초기화 코드
    }

    // 2. static inner class를 이용한 인스턴스 생성
    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }

    // 3. 전역 접근점을 제공하는 메서드
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE; // 같은 인스턴스 반환해줌
    }
}
  • 내부 클래스로 선언된 SingletonHolder 클래스는
  • Singleton 클래스가 로드될 때는 로드되지 않고,
  • getInstance() 메서드가 처음 호출될 때 로드되어 인스턴스를 생성함
  • 이러한 방법으로 지연 초기화멀티스레드 안전성을 동시에 보장함

etc. 싱글턴에 대해 짚고넘어가기

스프링에서는 ?

  • 스프링 프레임워크는 기본적으로 빈(Bean)들을 싱글턴으로 관리
  • 스프링 컨테이너가 애플리케이션 당 하나의 빈 인스턴스를 생성하고 공유
  • 개발자가 구현하는 방식과 스프링 Bean의 싱글턴 방식 자체가 다름

다른이유 ?

  • 스프링 싱글턴은 스프링 컨테이너가 관리하는 범위 내에서의 싱글턴
  • 즉, 스프링 컨테이너가 여러 개일 경우 각 컨테이너마다 별도의 싱글턴 인스턴스가 존재할 수 있음
  • 반면, 전통적인 싱글턴 패턴은 JVM 전체에서 하나의 인스턴스를 보장함

싱글턴 패턴 큰 단점

1. 테스트 어려움

  • 싱글턴 인스턴스는 전역 상태를 가지므로, 테스트 간에 상태가 공유될 수 있음
  • 이는 테스트의 독립성을 해칠 수 있으며, 테스트 간의 상호작용을 유발함
  • 싱글턴 인스턴스를 초기화하거나 재설정하는 방법을 제공하여 테스트 환경을 제어해야 함

2. 전역 상태 문제

  • 싱글턴 인스턴스가 전역 상태를 가지므로 어디든 접근가능 (숨겨진 의존성 발생)
  • 객체 지향 설계 원칙 중 하나인 의존성 주입(DI) 원칙을 위반할 수 있음

3. 멀티스레드 환경 문제

  • 스레드 안전성을 보장하지 않는 싱글턴 구현은 멀티스레드 환경에서 문제가 발생할 수 있음
  • 적절한 동기화 메커니즘을 사용하여 스레드 안전성을 확보해야 함

4. 확장성 불가

  • 만약 인스턴스가 여러 개 필요해지면 싱글턴 패턴은 적합하지 않음
  • 예를 들어, 데이터베이스 연결 풀과 같이 여러 인스턴스가 필요한 경우에는 쓰기 힘듬

실무에서는 어떨때 쓰일까 ?

1. 설정 관리 (Configuration)

  • 애플리케이션 설정 정보를 중앙에서 관리
AppConfig — 설정 파일자동 로드 + 파일변경 감지
  • 용도 : 설정 파일을 로드하고, 변경 사항을 감지하여 자동으로 재로드하는 기능 제공
  • 특징 : WatchService를 사용하여 파일 변경 감지, Properties 객체로 설정 관리
import java.io.IOException;
import java.nio.file.*;
import java.util.Properties;

/**
 * 애플리케이션 설정을 관리하는 싱글턴
 * - 설정 파일(config.properties)을 로드
 * - 파일 변경을 감지하여 자동으로 리로드(핫 리로드)
 */
public class AppConfig {
    private final Properties props = new Properties();

    private AppConfig() {
        load();
        watchConfigFile();
    }

    private static class Holder {
        private static final AppConfig INSTANCE = new AppConfig();
    }

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

    /**
     * 설정 파일 로드
     * config.properties 파일을 읽어서 props에 저장
     */
    private void load() {
        try {
            props.load(Files.newInputStream(Paths.get("config.properties")));
            System.out.println("[Config] 설정 로드 완료.");
        } catch (IOException e) {
            throw new RuntimeException("설정 파일 로드 실패", e);
        }
    }

    /**
     * 설정 파일 변경 감지
     * - WatchService 로 특정 파일이 변경되면 load() 호출
     * - 백그라운드 스레드에서 감시
     */
    private void watchConfigFile() {
        Thread watcher = new Thread(() -> {
            try {
                WatchService watchService = FileSystems.getDefault().newWatchService();
                Paths.get(".").register(
                        watchService,
                        StandardWatchEventKinds.ENTRY_MODIFY
                );

                while (true) {
                    WatchKey key = watchService.take();
                    for (WatchEvent<?> event : key.pollEvents()) {
                        Path changed = (Path) event.context();
                        if (changed.getFileName().toString().equals("config.properties")) {
                            System.out.println("[Config] 변경 감지 → 재로드");
                            load();
                        }
                    }
                    key.reset();
                }

            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        });

        watcher.setDaemon(true); // 데몬 스레드 → JVM 종료 시 자동 종료
        watcher.start();
    }

    public String get(String key) {
        return props.getProperty(key);
    }
}
// config.properties 파일 내용 예시 :
// db.url=jdbc:mysql://localhost:3306/newdb

// 사용 예시
AppConfig config = AppConfig.getInstance();

String dbUrl = config.get("db.url");
System.out.println("DB URL: " + dbUrl);

// 출력결과 :
// DB URL: jdbc:mysql://localhost:3306/newdb

// 이후 config.properties 파일을 수정할 경우

// 출력결과 :
// [Config] 변경 감지 → 재로드

String newDbUrl = config.get("db.url");
System.out.println("DB URL: " + newDbUrl);
// 출력결과 :
// DB URL: jdbc:mysql://localhost:3306/updateddb

2. 로깅 관리 (Logging)

  • 애플리케이션 로그를 중앙에서 관리
AsyncLogger — 비동기 큐 기반 로깅 시스템
  • 용도 : 로그 메시지를 비동기적으로 파일에 기록하여 성능 향상
  • 특징 : BlockingQueue를 사용하여 로그 메시지 큐잉, 별도 스레드에서 파일 기록
import java.io.FileWriter;
import java.io.IOException;
import java.util.concurrent.*;

/**
 * 비동기 로깅 싱글턴
 * - 로그를 즉시 파일에 쓰지 않고 BlockingQueue에 넣어둠
 * - 별도 로거 스레드가 큐에서 꺼내 파일에 기록 → 성능 향상
 */
public class AsyncLogger {

    // 로그 메시지가 저장될 큐(생산자-소비자 모델)
    private final BlockingQueue<String> queue = new LinkedBlockingQueue<>();

    private AsyncLogger() {
        startWorker();
    }

    private static class Holder {
        private static final AsyncLogger INSTANCE = new AsyncLogger();
    }

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

    /**
     * 로그를 큐에 넣는 메서드 — 즉시 리턴하므로 매우 빠름
     */
    public void log(String message) {
        queue.offer(message);
    }

    /**
     * 별도 스레드가 queue.take()로 메시지를 계속 받으며 파일에 기록
     */
    private void startWorker() {
        Thread worker = new Thread(() -> {
            try (FileWriter writer = new FileWriter("app.log", true)) {

                while (true) {
                    // 큐가 비어있으면 블록됨 → CPU 낭비 없음
                    String msg = queue.take();
                    writer.write(msg + "\n");
                    writer.flush();
                }

            } catch (IOException | InterruptedException e) {
                throw new RuntimeException("AsyncLogger 오류", e);
            }
        });

        worker.setDaemon(true);
        worker.start();
    }
}
// 사용 예시
AsyncLogger logger = AsyncLogger.getInstance();

logger.log("애플리케이션 시작");

Runnable task = () -> {
    logger.log("스레드 로그: " + Thread.currentThread().getName());
};

new Thread(task).start();
new Thread(task).start();
new Thread(task).start();

try {
    int result = 10 / 0;
} catch (Exception e) {
    logger.log("에러 발생: " + e.getMessage());
}

logger.log("애플리케이션 종료");

3. DB 연결 풀 (DB Connection Pool)

  • 데이터베이스 연결을 효율적으로 관리하고 재사용
DBConnectionPool — 싱글턴 DB 연결 풀
  • 용도 : 일정 수의 DB 커넥션을 미리 생성해두고 재사용하여 성능 향상
  • 특징 : ConcurrentLinkedQueue를 사용하여 커넥션 풀 관리
    • 별도 헬스체커 스레드가 커넥션 상태 점검
    • 오래된 idle 커넥션 정리 스레드 포함
import java.sql.Connection;
import java.sql.DriverManager;
import java.util.concurrent.*;
import java.util.*;

/**
 * Connection Pool 구현
 * - 일정 수의 DB 커넥션을 미리 생성해 풀에 저장
 * - 사용 요청이 오면 꺼내주고, 반납하면 다시 저장
 * - 헬스 체크 스레드가 주기적으로 커넥션 상태 확인
 * - idle cleaner가 오래된 커넥션 정리 후 새로 생성
 */
public class ConnectionPool {

    private final int POOL_SIZE = 10; // 풀 크기 설정
    private final Queue<Connection> pool = new ConcurrentLinkedQueue<>();
    private final Map<Connection, Long> lastUsedTime = new ConcurrentHashMap<>();

    // DB 커넥션 정보
    private final String DB_URL = "jdbc:mysql://localhost:3306/mydb";
    private final String DB_USER = "root";
    private final String DB_PASSWORD = "비밀번호";

    // idle 커넥션 정리 주기 및 커넥션 생존체크 주기
    private final int IDLE_CLEANER_TIME = 5 * 60 * 1000; // 5분
    private final int HEALTH_CHECK_TIME = 30 * 1000; // 30초


    private ConnectionPool() {
        initPool();
        startHealthChecker();
        startIdleCleaner();
    }

    private static class Holder {
        private static final ConnectionPool INSTANCE = new ConnectionPool();
    }

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

    /**
     * 초기 풀 생성
     */
    private void initPool() {
        for (int i = 0; i < POOL_SIZE; i++) {
            pool.add(createConnection());
        }
    }

    /**
     * 새로운 DB 커넥션 생성
     */
    private Connection createConnection() {
        try {
            Connection conn = DriverManager.getConnection(DB_URL, DB_USER, DB_PASSWORD);
            lastUsedTime.put(conn, System.currentTimeMillis());
            return conn;
        } catch (Exception e) {
            throw new RuntimeException("DB 연결 실패", e);
        }
    }

    /**
     * 커넥션 가져오기
     * - pool이 비어있으면 신규 생성
     */
    public Connection getConnection() {
        Connection conn = pool.poll();
        if (conn == null) {
            conn = createConnection();
        }
        lastUsedTime.put(conn, System.currentTimeMillis());
        return conn;
    }

    /**
     * 커넥션 반납
     */
    public void returnConnection(Connection conn) {
        lastUsedTime.put(conn, System.currentTimeMillis());
        pool.offer(conn);
    }

    /**
     * 커넥션 살아있는지 주기적으로 체크 (30초마다)
     */
    private void startHealthChecker() {
        Thread checker = new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(HEALTH_CHECK_TIME);

                    for (Connection conn : pool) {
                        if (!conn.isValid(2)) {
                            pool.remove(conn);
                            pool.offer(createConnection());
                        }
                    }

                } catch (Exception e) {
                    System.out.println("헬스체커 오류: " + e.getMessage());
                }
            }
        });

        checker.setDaemon(true);
        checker.start();
    }

    /**
     * 오래된 idle 커넥션 제거 (5분 이상 미사용)
     */
    private void startIdleCleaner() {
        Thread cleaner = new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(IDLE_CLEANER_TIME);

                    long now = System.currentTimeMillis();

                    for (Connection conn : pool) {
                        if (now - lastUsedTime.get(conn) > IDLE_CLEANER_TIME) {
                            pool.remove(conn);
                            pool.offer(createConnection());
                        }
                    }

                } catch (Exception e) {
                    System.out.println("IdleCleaner 오류: " + e.getMessage());
                }
            }
        });

        cleaner.setDaemon(true);
        cleaner.start();
    }
}
// 사용 예시
DBConnectionPool pool = DBConnectionPool.getInstance();

Connection conn = null;
ResultSet rs = null;

try {
    // 쿼리 실행 등 작업 수행
    conn = pool.getConnection();
    rs = conn.prepareStatement("SELECT * FROM users").executeQuery();
    while (rs.next()) {
        System.out.println("User: " + rs.getString("username"));
    }

} catch (SQLException e) {
    e.printStackTrace();
} finally {
    try {
        if (rs != null) rs.close(); // ResultSet 은 닫아도 OK
    } catch (Exception ex) {
        ex.printStackTrace();
    }

    // 커넥션 반납
    pool.returnConnection(conn);  // close() 절대 X
    // 반납 후 재사용 됨
}

4. 캐시 관리 (Caching)

  • 자주 사용하는 데이터를 메모리에 저장하여 빠른 접근 제공
CacheManager — LRU 캐시 + 만료 스레드
  • 용도 : LRU(Least Recently Used) 기반의 캐시 관리 + TTL(만료시간) 기능
  • 특징 : LinkedHashMap을 사용하여 LRU 구현, 별도 스레드가 TTL 만료된 항목 정리
import java.util.*;

/**
 * LRU + TTL(만료시간) 캐시 매니저
 * - LRU 기반의 LinkedHashMap 사용
 * - TTL만료 스레드가 주기적으로 오래된 캐시 삭제
 */
public class CacheManager {

    private static final int MAX_SIZE = 100;

    /**
     * LinkedHashMap + accessOrder = true → LRU 작동
     */
    private final Map<String, CacheEntry> cache =
            new LinkedHashMap<>(16, 0.75f, true) {
                @Override
                protected boolean removeEldestEntry(Map.Entry<String, CacheEntry> eldest) {
                    return size() > MAX_SIZE;
                }
            };

    private CacheManager() {
        startCleaner();
    }

    private static class Holder {
        private static final CacheManager INSTANCE = new CacheManager();
    }

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

    /**
     * 캐시 저장
     */
    public synchronized void put(String key, Object value, long ttlMillis) {
        cache.put(key, new CacheEntry(value, System.currentTimeMillis() + ttlMillis));
    }

    /**
     * 캐시 조회 + 만료 체크
     */
    public synchronized Object get(String key) {
        CacheEntry entry = cache.get(key);

        if (entry == null) return null;

        if (System.currentTimeMillis() > entry.expireTime) {
            cache.remove(key);
            return null;
        }

        return entry.value;
    }

    /**
     * TTL이 지난 데이터를 삭제하는 스레드
     * - 10초마다 캐시 스캔 후 만료된 항목 제거
     */
    private void startCleaner() {
        Thread cleaner = new Thread(() -> {
            while (true) {
                try {
                    Thread.sleep(10000);

                    long now = System.currentTimeMillis();
                    Iterator<Map.Entry<String, CacheEntry>> it = cache.entrySet().iterator();

                    while (it.hasNext()) {
                        Map.Entry<String, CacheEntry> entry = it.next();
                        if (now > entry.getValue().expireTime) {
                            it.remove();
                        }
                    }

                } catch (Exception e) {
                    System.out.println("캐시 클리너 오류: " + e.getMessage());
                }
            }
        });

        cleaner.setDaemon(true);
        cleaner.start();
    }

    /** 캐시 엔트리 구조 */
    static class CacheEntry {
        Object value;
        long expireTime;

        CacheEntry(Object value, long expireTime) {
            this.value = value;
            this.expireTime = expireTime;
        }
    }
}
CacheManager cache = CacheManager.getInstance();

// 캐시 저장 (키, 값, TTL 5분)
cache.put("user_123", userObject, 5 * 60 * 1000);

// 캐시 조회
Object user = cache.get("user_123");
if (user == null) {
    // 캐시 미스 or 만료
}

// 캐시 갱신
cache.put("user_123", updatedUserObject, 5 * 60 * 1000);

// 캐시 삭제 (즉시 삭제되지는 않음, 10초 후 Cleaner가 지움)
cache.remove("user_123");

싱글턴 핵심 요약

  • 패턴 의미 : 클래스의 인스턴스를 단 하나만 생성하고 전역적으로 접근
  • 구현 방법 : Eager, Lazy, Double-Checked Locking, Static Inner Class
  • 권장 방식 : Static Inner Class (성능, 안전성, 코드 가독성 우수)
  • 적용 사례 : 설정 관리, 로거, DB 연결 풀, 캐시
  • 주의 사항 : 테스트 어려움, 전역 상태 문제, 과도한 사용 지양
starweb
@starweb :: starweb 님의 블로그

starweb 님의 블로그 입니다.

공감하셨다면 구독도 환영합니다!

목차