6. 싱글턴 패턴

디자인 패턴은 무조건 아래 두 글을 선행해야합니다. 짧으니 간단히 읽고 오시면 이해가 쉽습니다.

설명에 사용할 코드는 Java-like Pseudo Code 입니다.


‘정적 변수’ 및 ‘정적 메서드’

코드를 작성하다보면 변수메서드단 하나만 생성하여 모든 곳에서 공유하여 사용할 때가 있습니다. 정적 변수정적 메서드에 해당하는 개념입니다. Java 와 같이 객체지향 프로그램에서는 변수, 메서드 모두 클래스 내 존재해야하는 제약사항 때문에 공유하려는 전역 변수, 메서드를 클래스에 담아서 공유해야합니다. 재미있는 점은 정적 클래스라는 개념은 없기 때문에 클래스 안의 정적 변수나 메서드는 따로 객체 초기화 할 필요없이 바로 접근이 가능함과 동시에 원한다면 이 클래스를 객체로 초기화해서도 사용가능하다는 것입니다. 객체 초기화 없이 해당 클래스의 정적 변수와 정적 메서드를 사용한다니 이게 어떻게 가능한걸까요?

Java, JVM 메모리

Java 는 JVM 위에서 프로그램을 동작시키는데요. JVM 의 M, Machine 이 뜻하는대로 작은 OS 라고 보시면 됩니다. JVM 에서 돌리는 모든 프로그램의 자원을 JVM 이 관리합니다. 이런 이유로 Java 에 대한 이해는 JVM 메모리 관리에 대한 이해와 1:1의 관계에 놓여있습니다. 싱글턴 패턴을 배우기에 앞서 정적 변수, 메서드를 이해하기 위해 클래스, 변수, 메서드가 메모리에 어떤 JVM 메모리 영역에 할당이 되고 어떻게 정리가 되는지 간단하게 살펴보겠습니다. JVM 메모리 영역은 다음 세 영역으로 나뉩니다.

  • 변하지 않는 값을 담는 Static 영역 (이를 칭하는 용어는 아래 총 3가지가 있습니다)
    • 변하지 않는 값을 담는다는 의미에서 Static 영역이라 부르기도 하고
    • 객체화 되기 전 Class 그 자체를 담는다는 의미에서 Class 영역이라 부르기도 하고 (Class Loading)
    • 객체화 되기 전 Class 의 함수를 담는다는 의미에서 Method 영역이라 부르기도 합니다.
  • 변하는 값을 담는 Heap 영역Stack 영역으로 나뉩니다.
    • Stack 영역: 함수 내 ‘파라미터’나 ‘로컬변수’와 같이 그 함수 블록 내에만 생존하는 변수들을 저장
    • Heap 영역: 객체들을 저장

객체 생성의 가장 근간이 되는 Class 는 바이트코드 형태로 Static 영역에 적재됩니다. 그 Class 를 객체화할때마다 그 객체와 객체의 변수, 메서드는 위 클래스 바이트코드를 참조하여 생성된 뒤에 Heap 영역에 적재됩니다. 정적 변수, 메서드는 객체없이 Class 에 존재하는것이므로 Static 영역에 저장되겠군요. Static 영역에 Class 적재 및 객체 생성을 담당하는 것을 Classloader(클래스로더)**라고 부르며 이 로더는 커스텀하게 바꾸지 않았다면 일반적으로 JVM 위에 하나만 존재합니다. 만약 두 개의 클래스로더가 있다면 같은 정적 변수라 할지라도 각자 다른 Static 영역에 적재됩니다. **정적 변수, 메서드일반 객체의 변수, 메서드는 적재된 영역이 다르기 때문에 서로 참조하지 못하는 특징을 갖습니다.

싱글턴 패턴

정적 변수, 메소드

정적 변수, 메서드는 ‘클래스로더’ 내 단 하나만 존재하는 **유일무이한 “클래스”**의 변수, 메서드입니다.
(클래스로더는 한 프로그램에 다수 개일 수 있습니다.)

  • Static 영역에 생성되는 클래스 변수, 메서드입니다.
  • 프로그램의 시작과 동시에 클래스로더 에 의해 바이트코드형태로 Static 영역 메모리에 바로 적재됩니다.
1
2
3
4
5
6
7
8
class Calculator {
// * Public: Can be initialized from outer
public Caculator() {}
// * Static: sum(a, b)
public static sum(Integer a, Integer a) {
return a + b;
}
}

싱글턴 변수, 메소드

싱글턴 패턴의 정의는 ‘클래스로더’ 내 단 하나만 존재하는 **유일무이한 “객체”**의 변수, 메서드입니다.
(클래스로더는 한 프로그램에 다수 개일 수 있습니다.)

  • Heap 영역에 생성되는 객체 변수, 메서드입니다.
  • 프로그램 실행 도중 필요한 그 시점에 객체로 Heap 영역에 적재됩니다. 그리곤 오랜기간 사용되지 않는다면 GC 됩니다.
    (필요한 시점에 객체 생성하는것을 Lazy Loading 이라고 합니다. 싱글턴 패턴의 존재 의의기도 합니다.)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class Calculator {
// * Priavte: Cannot be initialized from outer
private Caculator() {}
// * Non-Static: sum(a, b)
public sum(Integer a, Integer a) {
return a + b;
}

// * Singleton: Can be initialized only once using getInstance()
private static Calculator uniqueInstance;
public static Calculator getInstance() {
if (uniqueInstance == null) {
uniqueInstance = new Calculator();
}
return uniqueInstance;
}
}

차이를 아시겠나요? 하지만 유일한 클래스의 정적 변수, 메서드든 유일한 객체인 싱글턴 패턴이든 진입점이 한 곳인 만큼 다중 스레드가 한번에 진입점에 들어올 때 서로를 어떻게 독립적으로 수행할 수 있게 보장할지가 문제가 됩니다. 이를 유식한 말로는 다수의 스레드가 모두 이 클래스 내지는 객체에 접근을 하려 경쟁한다는 의미로 **Race Condition(경쟁 상태)**라고 합니다.

Race Condition

Java 에서 다중 스레드를 사용할지라도 JVM 메모리에서는 따로 스레드별로 영역들을 지정해주지 않기 때문에 프로그래머가 접근제어를 해주지 않는다면 하나의 클래스 혹은 객체를 두 스레드에서 접근할 수 있습니다. Java 의 객체, 변수, 메서드 모두 기본적으로 non-blocking 이므로 여러 스레드에서 하나의 클래스 혹은 객체 접근을 동시에 할 경우 함수, 변수를 중복 호출/사용하는 문제가 발생합니다. 이를 해결하기 위해 가장 단순하게 생각할 수 있는것은 함수 단위로 blocking 하는것입니다.

위 싱글턴 패턴의 예로 사용한 Caculator 클래스의 getInstance() 함수를 두 개의 스레드에서 동시에 진입했다고 가정합시다. 동시에 if (uniqueInstance == null) 구문에 진입했을때 어느 스레드도 그 다음 라인인 new Caculator() 를 수행하지 않았다고 가정한다면 두 스레드 모두 uniqueInstance 가 null 인것으로 판단할것입니다. 그리고 그 다음 라인에 두 스레드 각각 새 객체를 생성하게 되고, 이렇게 된다면 두 스레드는 하나의 객체 함수가 아닌 각자의 객체 함수를 보게됩니다. 단순 계산 객체라면 큰 영향은 없겠지만 만약 하나의 상태를 공유하려는 객체라면 두 스레드가 서로 다른 상태를 보고있는 끔찍한 상황이 연출됩니다.

1
2
3
Thread1: getInstance() 
if (uniqueInstance == null) { // 2019-03-03 00:00:01
uniqueInstance = new Calculator(); // 2019-03-03 00:00:03 - Calculator 객체 1 생성 (Thread1)
1
2
3
Thread2: getInstance() 
if (uniqueInstance == null) { // 2019-03-03 00:00:02
uniqueInstance = new Calculator(); // 2019-03-03 00:00:04 - Calculator 객체 2 생성 (Thread2)

함수 단위 Blocking - Synchronized

다수의 스레드가 한 함수에 접근하려 한다면, 하나의 스레드가 해당 함수를 수행하는 동안에는 기다리도록 blocking 합니다. Java 가 제공하는 synchronized 키워드를 사용하면 손쉽게 해당 함수 호출을 blocking 할 수 있습니다. 이젠 Thread 1 이 해당 함수를 호출하고 끝날때까지 Thread 2 는 해당 함수 호출을 계속 기다려야합니다. 두 스레드가 한 함수를 동시에 호출할일은 없어졌습니다.

1
2
3
4
5
6
7
8
9
10
class Calculator {
...

public static synchronized Calculator getInstance() {
if (uniqueInstance == null) {
uniqueInstance = new Calculator();
}
return uniqueInstance;
}
}

하지만 싱글턴 함수가 위에 로직보다 더 복잡하고 수행시간이 길다면 다른 스레드들은 한 스레드가 해당 함수 호출을 완료하는 그 긴 시간동안 멈춰있어야하는 성능의 이슈가 있습니다. 이런 경우에는 함수 단위의 blocking 이 아니라 함수 내 blocking 해야하는 딱 그 변수만 집어서 blocking 하는게 좋겠지요.

변수 생성 단위 Blocking - DCL (Double Checked Locking)

우리의 원래 목적은 “변수”의 스레드간 공유인데, 굳이 함수 단위의 blocking 을 해서 변수 외 나머지 로직 수행의 시간까지 손만 빨며 성능 이슈까지 발생시킬 이유는 없습니다. 똑똑한 프로그래머들의 고민 결과 “함수”가 아닌 “변수” 단위의 Blocking 을 고안해내었고 이를 **DCL (Double Checked Locking)**이라고 명명합니다. 왜 Double Checked 일까요? 아래 코드를 보시면 객체 생성 로직 진입 전과 진입 후 생성하기전에 한번 더 null 여부를 검사하기 때문인것으로 알 수 있습니다.

  • 함수 단위 Blocking - 함수에 synchronized 추가
    1
    2
    3
    4
    5
    6
    7
    private static Calculator uniqueInstance;
    public static synchronized Calculator getInstance() {
    if (uniqueInstance == null) {
    uniqueInstance = new Calculator();
    }
    return uniqueInstance;
    }
  • 변수 생성 단위 Blocking - 변수에 volatile 추가, 함수 내 해당 변수에 synchronized 추가
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    private volatile static Calculator uniqueInstance;
    public static Calculator getInstance() {
    if (uniqueInstance == null) {
    synchronized (Calculator.class) {
    if (uniqueInstance == null) {
    uniqueInstance = new Calculator();
    }
    }
    }
    return uniqueInstance;
    }
    기존 방식은 getInstance() 함수에 synchronized 가 붙어있는 반면, 변수 생성 단위 Blocking 에서는 변수에 volatile 이 추가되었고, 해당 함수 내 아까 volatile 을 추가한 변수에 대해서 synchronized 붙여준걸 알 수 있습니다. 여기서 유념히 보셔야할것은 변수를 사용하는 부분이 아닌 변수를 생성하는 부분에 synchronized 를 붙여줬음을 꼭 기억하시기 바랍니다.

DCL (Double Checked Locking) 의 의미

모든 프로그램 및 스레드는 CPU 을 통해 연산들을 수행하고, 연산을 위한 변수값들은 “메인 메모리”로부터 CPU 바로 옆 “캐시”로 가져와 사용하게 됩니다. 만약 두 스레드가 각자 다른 CPU (멀티코어 환경) 에서 하나의 싱글턴 혹은 정적 변수를 공유하여 작동한다면 어떤 일이 발생할까요?

두 스레드가 공유하는 하나의 변수는 기본적으로 “메인 메모리”에 적재되어 있습니다. 각 스레드가 각 CPU 에서 값을 변경하는 경우 1) 먼저 메인 메모리로부터 캐시로 변수값을 가져오고, 2) CPU 가 해당 캐시의 값을 변경하고, 3) 캐시에 변경된 값을 메인 메모리에 작성(동기화)하는 과정을 거칩니다. 두 스레드가 동시에 변수의 값에 접근할 경우, 첫번째 스레드가 자신이 할당된 CPU 내 캐시의 변수값을 먼저 바꿨음에도 불구하고 아직 메인 메모리에 쓰지 않아 두번째 스레드는 변경된 값을 모른채 자신의 CPU 에서 독립적으로 값 변경을 수행하는 문제가 발생합니다.

그렇다고 다수 스레드가 하나의 CPU 에서 수행된다고 하더라도 아예 문제가 없는것은 아닙니다. JIT 컴파일러에 의해 어셈블리 레벨 코드 재배열(Reorder)이 발생하여 스레드 간 참조하는 변수값이 달라질 수 있기 때문입니다.

위에서 설명한 스레드간 변수 동기화 내지는 불일치 문제를 한 스레드의 값 업데이트를 다른 스레드에서는 볼 수 없다는 의미의 가시성(Visibility) 문제라고 일컫습니다. 볼 수 있다면 Visible 하다. 라고 표현합니다.

가시성 문제를 해결하기 위해 “캐시”와 “메인 메모리” 간 읽은(READ) 값이 일치하도록 강제하는 것이 volatile 키워드 입니다. 변수에 volatile 키워드를 추가하면 해당 변수는 CPU 에서 “캐시”의 값을 읽을때 동시에 “메인 메모리”의 값을 Read 함을 보장합니다. 한 스레드에서 값을 변경한다면 바로 메인 메모리에 적용되고 다른 스레드가 값을 읽을 때 최신의 값을 읽을 수 있습니다.

하지만 두 스레드가 같은 메인 메모리 값을 가져다가 변경할 경우는 여전히 문제입니다. 값을 쓰는것은 어쩔 수 없이 blocking 을 걸어두어야합니다. 한 스레드가 작성하고있다면 다른 스레드는 기다렸다가 앞 스레드가 작성을 마치면 바로 메인 메모리로부터 값을 읽어서 그 다음 쓰기를 진행하면 됩니다. 이를 위해 값을 변경(WRITE)할때는 해당 클래스에 blocking 을 거는 synchronized 키워드를 함께 사용하면 됩니다.

변수 사용 단위 Blocking - Lazy Holder

애석하게도 변수 생성 단위의 Blocking 으로 단일 생성이 완벽히 보장되진 않았습니다. 세상에, CPU 캐시까지 고려했는데 무엇을 또 놓쳤다는걸까요? 트랜지스터 레벨이라도 봐야하는 것일까요? DCL 을 통해 변수의 단일 생성 자체는 보장되었습니다. 다만 단일 생성 바로 직후에 다른 스레드에서 해당 변수를 바로 사용하려 한다면, 아직 채 완전히 생성되지 못한 변수를 사용하게 될 수 있다는 것입니다. 단일 생성을 시작하면 해당 클래스의 new ..를 통해 생성자를 수행하게 될 것입니다. 생성자가 조금이라도 복잡하다면 온전한 객체가 만들어지기 까지는 조금의 시간이 걸릴 것입니다. 하지만 해당 객체를 접근하는 다른 스레드는 그 라인의 끝마침을 기다려주지 않습니다. 이때 미처 다 온전하게 생성되지 않은 불완전한 객체를 다른 스레드에서 가져다가 사용하게 되는것입니다. 이를 out-of-order write 문제라고 명명합니다.

해결은 해당 객체가 단순히 생성되었다 여부가 아닌 완벽히 생성되었다는걸 보장하면 됩니다. 이를 보장하는 방식은 더 똑똑한 프로그래머들에 의해 정말 다양하게 제시되었는데요. 기발한것들도 있지만 그 중에 가장 이해가 쉬운것은 아래와 같습니다.

1
2
3
4
5
6
7
8
9
public class Calculator {
...
private static class LazyHolder {
private static final Calculator UNIQUE_INSTANCE = new Calculator();
}
public static Calculator getInstance() {
return LazyHolder.UNIQUE_INSTANCE;
}
}
  • static final 로 정의된 UNIQUE_INSTANCE는 클래스로더에 의해 프로그램 시작 시 가장 먼저 Static 영역에 바로 적재됩니다. 이를 통해 getInstance() 호출되기 이전에 UNIQUE_INSTANCE = new Calculator(); 가 무조건 존재함을 보장합니다.

사실상 위에서 배운 모든 것을 활용한 해결책에 해당합니다. 개인적으로 이 해결책이 기억에 남는 이유가 C# 에서 LazyHolder 형식을 기본적으로 제공해주기 때문에 싱글턴 패턴 사용이 아래와 같이 매우 쉽게 해결했던 기억이 있습니다.

1
2
3
4
5
6
public sealed class Singleton
{
private static readonly Lazy<Singleton> lazy = new Lazy<Singleton>(() => new Singleton());
public static Singleton Instance { get { return lazy.Value; } }
private Singleton() {}
}

  1. https://gampol.tistory.com/entry/Double-checked-locking%EA%B3%BC-Singleton-%ED%8C%A8%ED%84%B4
  2. http://thswave.github.io/java/2015/03/08/java-volatile.html
Author

Aaron Ryu

Posted on

2019-03-02

Updated on

2019-05-25

Licensed under

Comments