본문 바로가기
프로그래밍/Java

[JAVA] Multi Thread 환경에서 Singleton 패턴을 Thread Safe 하게 만들기

by so5663 2022. 11. 19.

public class DBConnectionInfo {

    private String url = "";

    private String encoding = "";

    private String maxActive = "";

    private String maxIdle = "";

    private String minIdle = "";

// getter/setter 생략

}

DB정보를 가지고 있는 클래스

위 클래스를 다양한 방법의 Singleton Pattern으로 설계해보자.

 

1. Eager Initialization

private static DBConnectionInfo DBConnectionInfoInstance = new DBConnectionInfo();

private DBConnectionInfo() {}

public static DBConnectionInfo getInstance() {
    return DBConnectionInfoInstance;
}

첫 번째 방식은 Eager Initialization이다.

 

프로그램 실행 시, 클래스 로더에 의해 클래스가 로딩 될 때 static 제어자에 의해 미리 인스턴스가 생성되는 방식이다.

DBConnectionInfoInstance의 사용 유무와 관계없이 DBConnectionInfo 클래스가 로딩되는 시점에 객체가 생성되기 때문에 다른 클래스에서 getInstance 호출 시 언제나 같은 인스턴스만 반환하게 되어 Thread-safe하긴 하다.

하지만, 처음부터 메모리를 점유하고 있기 때문에 메모리 누수가 발생할 수 있는 등 비효율적인 부분이 있다.

제일 상단에서 미리 선언하기 때문에

 

2. Lazy Initialization

private static DBConnectionInfo DBConnectionInfoInstance;

private DBConnectionInfo() {}

public static DBConnectionInfo getInstance() {
    if(DBConnectionInfoInstance == null) {
        return DBConnectionInfoInstance = new DBConnectionInfo();
    }
    return DBConnectionInfoInstance;
}

두 번째 방식은 Lazy Initialization이다.

 

1번과는 다르게 처음 실행 시점부터 다르게 미리 메모리를 점유하지 않고, 인스턴스가 필요한 시점에서 인스턴스를 만들 수 있다.
그래서 느린 초기화라고 불린다.

1번의 단점을 보완했다고 생각할 수 있지만, 여러 Thread에서 동시에 getInstance를 호출 할 경우 Thread-safe하지 않을 수도 있다.
초기화 문제가 있을 수 있는 것이다.

예를 들어, 1번 Thread와 2번 Thread가 동시에 getInstance()를 호출한다면?

1번 Thread, 2번 Thread 모두 DBConnectionInfoInstance가 null이라고 판단하여 인스턴스를 각각 생성할 것이다.
결국 1번과 2번 Thread들은 모두 같은 객체를 바라보는 것이 아닌 다른 객체를 바라보게 된다.

 

3. Lazy Initialization + synchronized

private static DBConnectionInfo DBConnectionInfoInstance;

private DBConnectionInfo() {
}

public static synchronized DBConnectionInfo getInstance() {
    if(DBConnectionInfoInstance == null) {
        return DBConnectionInfoInstance = new DBConnectionInfo();
    }
    return DBConnectionInfoInstance;
}

2번 느린 초기화를 해결하는 방법 중에 하나는 getInstance 메서드에 synchronized 키워드를 추가하는 것이다.

그러나 이 방법을 사용하면 Thread-safe함을 유지하려는 목적에 비해 동기화 오버헤드가 심하다. 

그래서 성능저하가 일어난다.

확실한 방법이지만 좋은 방법은 아니다.

 

4. Double Checked Locking

private static DBConnectionInfo DBConnectionInfoInstance;

private DBConnectionInfo() {}

public static DBConnectionInfo getInstance() {
    if (DBConnectionInfoInstance == null) {
        synchronized (DBConnectionInfo.class) {
            if (Objects.isNull(DBConnectionInfoInstance))
                    return DBConnectionInfoInstance = new DBConnectionInfo();
        }
    }
    return DBConnectionInfoInstance;
}

4번째는 Double Checked Locking으로 말 그대로 두 번 체크하는 방법이다.

이 방법의 의도는 3번 getInstance() 메서드에 추가한 synchronized를 빼면서 오버헤드를 줄여보자는 것이다.

instance의 null 체크를 synchroinzed 블록 밖에서 하고, 안에서도 한번 더 null 체크를 한다.

밖에서 체크하여 이미 instance가 생성된 경우 빠르게 return 하고, 안에서 한번 더 체크하여 단 한개의 instance만 생성되도록 보장하기 위함이다.

안에서 체크하는 부분이 없으면 여러 Thread가 동시에 접근할 때 그냥 순차적으로 인스턴스를 생성하도록 하는 수준 밖에 되지 않기 때문에, synchronized 블록의 안팎으로 null 체크를 해줘야한다.

그러나 이 방법도 최악의 경우 정상 동작하지 않을 수 있다.

예를 들어, 1번 Thread가 인스턴스의 생성을 완료하기 전에 메모리 공간에 할당이 가능하다. 2번 Thread는 1번 Thread가 할당한 것을 보고 인스턴스를 사용하려고 하나, 생성 과정이 모두 끝난 상태가 아니기 때문에 오동작할 수 있다.

5. Lazy Initialization + holder

private DBConnectionInfo() {}

public static DBConnectionInfo getInstance() {
    return Holder.instance;
}

private static class Holder {
    private static final DBConnectionInfo instance= new DBConnectionInfo();
}

2번 Lazy Initialization의 단점을 보완하며 Thread-safe함도 보장하는 방법이다.

프로그램 시작할 때 클래스 로딩 단계에서 인스턴스를 생성하지 않아 메모리를 미리 점유하고 있지 않는다.

getInstance() 메소드 호출하며 Holder.instance를 참조하는 순간! Holder 클래스가 로딩되며 인스턴스 생성이 진행된다.

클래스를 로딩하고 초기화하는 시점은 Thread-safe가 보장되며, Holder 클래스 안에 선언된 instance는 static이어서 클래스 로딩 시점에 한번만 호출된다.

 

가장 추천되는 방법으로 알고 있다.