개발/Java

Java] Multi Thread 환경에서 Singleton 패턴을 Thread Safe하게 사용하기

펭귀니 :) 2021. 5. 18. 08:46

Multi Thread 환경에서 Singleton 패턴을 Thread Safe하게 사용하기

해당 글과 관련된 Singleton Pattern을 테스트 해볼 수 있는 git주소를 첨부합니다 🍗
https://github.com/SimYeJu/HelloSingletonPattern/tree/main/src

❓Singleton이란?

하나의 인스턴스만 존재해야 할 경우에 Singleton 패턴을 사용한다.

예를 들어, DBCP(DataBase Connection Pool)나 로그를 기록하는 객체 등 공통된 객체를 여러 다른 클래스에서 사용해야 할 때, 하나의 인스턴스만 생성할 수 있게끔 만드는 것이 좋다. 그 이유는 같은 역할을 하는 객체가 여러개 만들어지지 않는게 메모리 사용면에서 좋기 때문이다.

Singleton Pattern을 설계 할 때, Single Thread 환경에서는 문제가 되지 않지만, Multi Thread 환경에서는 주의해야 한다.

Multi Thread 환경에서 Singleton 객체에 접근 할 때 객체 초기화 과정에서 Thread-safe하지 않은 문제가 발생할 수 있기 때문이다.

❔어떻게 Thread-safe하게 작성할 수 있나?

검색해보면 굉장히 많은 Singleton Pattern 작성법들이 소개되고 있다.
이를 정리해서 내 언어로 바꿔 재작성해보려고 한다.

Database 커넥션 정보를 담고 있는 DBConnectionInfo 클래스가 있다.

public class DBConnectionInfo {

    private String url = "";

    private String encoding = "";

    private String maxActive = "";

    private String maxIdle = "";

    private String minIdle = "";

// getter/setter 생략

}

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

1. Eager Initialization

private static DBConnectionInfo DBConnectionInfoInstance = new DBConnectionInfo();

private DBConnectionInfo ( ) {}

public static DBConnectionInfo getInstance() {
    return DBConnectionInfoInstance;
}

첫 번째 방식은 Eager Initialization이다. (이른 초기화라고 하는데, 왜 Eager인지는 잘 모르겠다.)

프로그램 실행 시, 클래스 로더에 의해 클래스가 로딩 될 때 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들은 모두 같은 객체를 바라보는 것이 아닌 다른 객체를 바라보게 된다.

테스트를 위해 Multi Thread 환경을 만들어서 Singleton객체를 호출하여 주소값을 찍어봤다.
아래와 같이 여러개의 객체가 만들어진 것을 볼 수 있다.

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함을 유지하려는 목적에 비해 동기화 오버헤드가 심하다.

우리는 Multi Thread 환경에서도 Thread-safe하게 쓰자!가 목표이지,

Multi Thread에서 메소드 호출시마다 lock을 걸어서 성능 저하 시키려는 것이 아니기에

확실하지만 무식한 방법이라고 생각한다.

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이어서 클래스 로딩 시점에 한번만 호출된다.

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


👁‍🗨 추가로 공부할 Keyword

  • Enum Singleton
  • volatile