-
싱글톤 패턴의 동시성 이슈와 해결책Java 2024. 12. 22. 12:30
1. 싱글톤 패턴의 정의와 필요성
소프트웨어 개발에서 싱글톤 패턴은 객체를 하나만 생성하여 공유하는 설계 패턴으로, 주로 애플리케이션의 전역 상태를 관리하거나 고정된 리소스를 효율적으로 활용하기 위해 사용한다. 데이터베이스 연결 풀, 로그 관리, 설정 정보 등과 같이 애플리케이션 전역에서 단일 객체만 필요하거나 공유되어야 하는 경우에 적합하다.
하지만 싱글톤 패턴을 구현할 때에는 동시성 문제를 해결해야 합니다. 특히 멀티스레드 환경에서는 동일한 객체가 여러 번 생성되지 않도록 주의해야 하며, 객체 초기화 방식에 따라 성능과 메모리 점유 효율성이 달라질 수 있다. 싱글톤 패턴의 구현방식을 알아보고 java에서부터 kotlin까지 어떤 방식으로 싱글톤 패턴을 구현하는지 알아보자.
2. 싱글톤 패턴의 동시성 문제
싱글톤 패턴은 GoF의 디자인 패턴에서 객체 생성에 대한 패턴이다. 싱글톤 패턴은 애플리케이션 내부에서 단 1개만 생성하는 패턴이다. DB Connection Pool처럼 객체 생성 비용이 크거나 애플리케이션 전역에서 사용해야 하는 경우에 유용한 패턴이다. 단 1개만 생성해야하는 조건 때문에 여러 스레드가 동시에 같은 객체를 생성하려고 한다면 1개가 아닌 여러개의 객체가 생성되는 동시성 문제가 발생한다.
3. 싱글톤 패턴을 구현하는 방식
싱글톤 패턴을 구현하는 방식은
getInstance()
메서드를 통해서 유일한 객체 인스턴스를 제공하는 방식으로 구현할 수 있다.이를 어떻게 구현했냐에 따라서 6개의 구현 방식이 있다.
- lazy initialization
- eager initialization
- thread safe lazy initialization
- double checked lock
- lazy holder
- enum
이제 각각의 구현방식을 알아보고 어떤 장점과 단점이 있는지 알아보자.
3.1 lazy initialization
lazy initialization방식은
getInstance()
메서드를 호출하는 시점에 생성된 객체가 있는지 확인하고 없다면 새로운 객체를 생성하고 있다면 생성된 객체를 반환하는 방식이다.lazy initialization방식은 여러개의 스레드가 동시에
getInstance()
를 호출하면if(INSTANCE == null)
코드에 동시에 진입하면서 객체가 여러개 생성되는 문제가 발생한다. 따라서, 멀티스레드 환경에서는 단 1개의 객체를 생성하기 위한 조건을 지키지 못한다.class Singleton { private static Singleton INSTANCE; private Singleton() {}; public static Singleton getInstance() { if(INSTANCE == null) { INSTANCE = new Singleton(); } return INSTANCE; } }
class Singleton private constructor() { companion object { private var INSTANCE: Singleton? = null fun getInstance(): Singleton { return INSTANCE ?: Singleton().apply { INSTANCE = this } } } }
3.2 eager initialization
eager initialization방식은 객체가 JVM에 올라오는 시점에 미리 생성하는 방식이다.
getInstance()
를 호출하면 이미 생성되어 있는 객체를 반환하는 형태로 구현한 방식이다.static final로 필드를 선언함으로써 멀티 스레드 환경에서도 스레드 세이프를 보장한다. 다만, 객체를 사용하기 전에 미리 메모리에 올리는 방식이여서 객체의 크기가 크다면 용량을 처음부터 잡아먹는 문제가 있다. 또한, 객체의 생성에 시간이 오래 걸리면 객체 초기화로 인해서 애플리케이션 최초 실행시간이 오래걸릴수 있다.
class Singleton { private static final Singleton INSTANCE = new Singleton(); private Singleton() {}; public static Singleton getInstance() { return INSTANCE; } }
class Singleton private constructor() { companion object { private var INSTANCE: Singleton = Singleton() fun getInstance(): Singleton { return INSTANCE } } }
3.3 thread safe lazy initialization
lazy initialization방식에
getInstance()
메서드를 동기화 시켜서 단 한개의 스레드만 접근하도록 개선한 방법이다. 스레드세이프하지만 synchronized로 인해서 성능저하가 발생한다.class Singleton { private static Singleton INSTANCE; private Singleton() {}; public static synchronized Singleton getInstance() { if(INSTANCE == null) { } return INSTANCE; } }
class Singleton private constructor() { companion object { private var INSTANCE: Singleton? = null @Synchronized fun getInstance(): Singleton { return INSTANCE ?: Singleton().apply { INSTANCE = this } } } }
3.4 double checked lock
thread safe한 lazy initializatioin방식은 매번 synchronized가 붙어있는 getInstance()를 호출하기때문에 성능이 좋지 않다. 그래서 최초 초기화에서만 synchronized를 적용하고 이미 만들어진 인스턴스를 반환할때는 synchronized를 사용하지 않도록 하는 방식이다.
구현해보면 이런 형태의 코드인데, 여기서
if(INSTANCE == null)
부분에서 동시성 문제가 발생한다.class Singleton { private static Singleton INSTANCE; private Singleton() {}; public static Singleton getInstance() { if(INSTANCE == null) { //1 synchronized(Singleton.class) {//2 if(INSTANCE == null) { //3 INSTANCE = new Singleton(); //4 } } } return INSTANCE; //5 } }
INSTANCE = new Singleton()
은 아래와 같은 형태로 진행되는데- 객체를 할당할 메모리 공간 확보
- 객체 초기화
- 변수에 메모리 공간 연결
jvm에 의해서 최적화 과정을 거치면서 2번과 3번의 순서가 변경되는 경우가 있다고 한다. 만약 스레드1이 변수에 메모리 공간 연결까지 끝내고 객체를 초기화 하기 전인데 스레드2가 getInstance()를 호출하면
if(INSTANCE == null)
코드에 도달했다고 가정하면 스레드2의 getInstance()결과값은 null을 받아서 문제가 발생한다. 이를 해결하기 위해서는 INSTANCE에 volatile키워드를 붙여주면 가시성 문제를 해결할 수 있다.*
가시성 문제
: A스레드가 값 변경 메인 메모리에 기록하지 않아서 B스레드에서는 변수의 최신정보를 보지 못하는 문제class Singleton { private static volatile Singleton INSTANCE; private Singleton() {}; public static Singleton getInstance() { if(INSTANCE == null) { //1 synchronized(Singleton.class) {//2 if(INSTANCE == null) { //3 INSTANCE = new Singleton(); //4 } } } return INSTANCE; //5 } }
3.5 lazy holder
eager initialization방식을 응용한 버전이다. 싱글톤 객체를 처음부터 로드하지 않고
getInstance()
를 호출하는 시점에 로드한다. Singleton클래스가 로드될 때는 LazyHolder클래스는 로드되지 않는다. JVM에서는 직접 참조되지 않는 클래스는 로드하지 않고 필요할 때 JVM에 의해 로드한다.class Singleton { private Singleton() {}; public static Singleton getInstance() { return LazyHolder.INSTANCE; } private static class LazyHolder { private static final Singleton INSTANCE = new Singleton(); } }
class Singleton private constructor() { companion object { class LazyHolder private constructor() { companion object { var INSTANCE = Singleton() } } fun getInstance() = LazyHolder.INSTANCE } }
3.6 enum
매우 간단하게 thread safe한 singleton class를 생성할 수 있다. 컴파일하면 자동으로 INSTANCE는 SingletonEnum를 타입으로 가지는 static final필드로 변경시킨다. 또한, 컴파일러가 static block을 통해서 상수 필드를 자동으로 초기화 시켜준다. 컴파일시점에 딱 한번만 인스턴스화 시키는걸 보장하므로 싱글톤으로 매우 편하게 사용할 수 있다.
enum SingletonEnum { INSTANCE; }
4. 적절한 싱글톤 패턴 구현 방식은?
Java로 싱글톤을 구현하는 방법에는 여러 가지가 있는데, 어떤 방식을 채택할지는
객체 생성 비용
을 기준으로 결정하는 것이 적절하다고 생각한다.객체 생성 비용이 크지 않은 경우: Eager Initialization
객체 생성 비용이 크지 않다면,
Eager Initialization
방식을 채택할 것이다. 이 방식은 JVM 클래스 로딩 시점에 객체를 생성하며, 별도의 동시성 제어 없이도 JVM이 스레드 안전성을 보장한다. 초기화 코드가 간단하고 유지보수가 쉬우므로, 생성 비용이 적고 항상 필요한 객체에 사용하기 좋은 방식이다.객체 생성 비용이 큰 경우: Lazy Holder
Lazy Holder
방식은 객체 초기화를 지연함으로써 불필요한 자원 점유를 방지할 수 있다. 이는 객체가 처음 요청될 때만 초기화되므로, 필요 없는 시점부터 메모리를 점유하는 문제를 해결한다. JVM의 클래스 로딩시점에 스레드 안전성을 제공하므로, 추가적인 동기화 코드나 복잡한 구현 없이 효율적으로 사용할 수 있다.따라서, 생성 비용이 크거나 사용 빈도가 낮은 객체라면 Lazy Holder 방식이 더 적절하다.5. 코틀린에서의 싱글톤 구현
이때까지 java에서 singleton을 어떻게 구현하는지 알아보고 각 방식별로 장단점을 알아봤다. 그렇다면 코틀린은....? 어떻게 싱글톤을 구현할까?
object
키워드를 사용하면 내부적으로 클래스 정의와 해당 클래스의 단일 인스턴스를 생성한다.object로 선언된 클래스는 JVM의 클래스 로딩 과정에 초기화시킨다.
또한,코틀린에서는 lazy를 통해서 필요시 지연초기화 시키는게 가능하므로 정말 쉽게 싱글톤을 구현할 수 있다.object Singleton { }
아래와 같이 static 블록에서 singleton객체를 미리 생성시켜버린다.
public final class Singleton { // 단일 인스턴스를 생성 public static final Singleton INSTANCE; // private 생성자 private Singleton() {} static { Singleton singleton = new Singleton(); INSTANCE = singleton; } }
'Java' 카테고리의 다른 글
자바 객체 생성되는 과정에 대해서 (0) 2021.09.02 Java 배열에 대해서 (0) 2021.08.27 자바 String 클래스에 대해서 (1) 2021.08.27 자바 - enum (0) 2020.09.01