싱글톤 패턴은 굉장히 많이 사용되는 패턴이다.
나도 많이 사용하지만, 개념을 정리하고 싶어서 글을 쓰게 되었다.

싱글톤 패턴이란

  • 전역 변수를 사용하지 않고 객체를 하나만 생성하도록 하며, 생성된 객체를 어디에서든지 참조할 수 있도록 하는 패턴이다.
    • ‘생성 패턴’ 중 하나이다.
  • Singleton의 역할
    • 하나의 인스턴스만을 생성하는 책임이 있으며 getInstance() 메소드를 통해 모든 클라이언트에게 동일한 인스턴스를 반환하는 작업을 수행한다.

예시

프린트 관리자 만들기

  • 프린트 하나를 10명이 공유해서 사용한다고 가정하자.
1
2
3
4
5
public class Printer {
public Printer(){ }

public void print(String str){...}
}
  • 그러나 Printer 클래스를 사용해 프린터를 이용하려면 Client 프로그램에서 new Printer()가 반드시 한 번만 호출되도록 주의해야 한다. 왜냐하면 프린터는 하나이기 때문에!
  • 이를 해소하는 방법 중 하나는 생성자를 외부에서 호출할 수 없도록 하는 것이다.
    • Printer 클래스의 생성자를 private으로 선언
1
2
3
4
5
6
public class Printer {
// Printer 생성자를 외부에서 사용하지 못함.
private Printer(){ }

public void print(String str){...}
}
  • 자기 자신 프린터에 대한 인스턴스를 하나 만들어 외부에 제공해줄 메소드가 필요하다.
  • static 메소드, static 변수
    • 구체적인 인스턴스에 속하는 영역이 아니고, 클래스 자체에 속한다.
    • 클래스의 인스턴스를 생성하지 않고도 메소드를 실행할 수 있고 변수를 참조할 수 있다.
    • 객체가 저장되는 힙 영역이 아닌 클래스(메소드) 영역에 저장되어 모든 객체가 공유하는 메모리가 된다.
  • 만약 new Printer()가 호출되기 전이면 인스턴스 메소드인 print() 메소드는 호출할 수 없다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public class Printer {
// 외부에 제공할 자기 자신의 인스턴스
// static 하기 때문에 해당 클래스의 객체들이 공유하낟.
private static Printer printer = null;

private Printer() {
}

// 자기 자신의 인스턴스를 외부에 제공하는 메소드.
public static Printer getPrinter() {
if (printer == null) {
// printer 인스턴스 생성.
printer = new Printer();
}
return printer;
}

public void print(String str) {
System.out.println(str);
}
}

Client에서 사용

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class PrinterClient {
private static final int USER_NUMBER = 5;

public static void main(String[] args) {
User[] users = new User[USER_NUMBER];
for (int i = 0; i < USER_NUMBER; i++) {
users[i] = new User(String.valueOf(i + 1));
users[i].print();
}
}

public static class User {
private String name;

public User(String name) {
this.name = name;
}

public void print() {
Printer printer = Printer.getPrinter();
printer.print(this.name + " print using " + printer.toString());
}
}
}
// printer.toString()을 통해서 하나의 printer 객체를 사용하는 것을 확인할 수 있다.

// 결과
1 print using Singleton.Printer@60e53b93
2 print using Singleton.Printer@60e53b93
3 print using Singleton.Printer@60e53b93
4 print using Singleton.Printer@60e53b93
5 print using Singleton.Printer@60e53b93

문제점

다중 스레드 환경에서 Printer 클래스를 이용할 때 인스턴스가 1개 이상 생성되는 경우가 발생할 수 있다.

  • 경합 조건(Race Condition)을 발생시키는 경우
  1. Printer 인스턴스가 아직 생성되지 않았을 때 스레드 1이 getPrinter() 메소드의 if문을 실행해 이미 인스턴스가 생서되었는지 확인한다. 현재 printer 변수는 null인 상태이다.
  2. 만약 스레드 1이 생성자를 호출해 인스턴스를 만들기 전에 스레드 1의 CPU 이용시간이 끝나 스레드 2가 CPU를 획득해 스레드 2가 if문을 실행해 printer 변수가 null인지 확인한다. 현재 printer 변수는 null이므로 인스턴스를 생성하는 생성자를 호출하는 코드를 실행하게 된다.
  3. 스레드 1도 스레드 2와 마찬가지로 인스턴스를 생성하는 코드를 실행하게 되면 결과적으로 Printer 클래스의 인스턴스가 2개 생성되는 문제가 발생한다.
  • race Condition이란?
    • 메모리와 같은 한정적인 자원을 2개 이상의 스레드가 이용하려고 경합하는 현상을 말한다.

해결책

프린터 관리자(Lazy Initialization)는 사실 다중 스레드 환경의 애플리케이션이 아닌 경우에는 아무런 문제가 되지 않는다.

  • 다중 스레드 환경에서 발생하는 문제를 해결하는 방법
    1. 정젹 변수에 인스턴스를 만들어서 바로 초기화 하는 방법(Eager Initialization)
    2. 인스턴스를 만드는 메소드에 동기화 하는 방법(Thread-Safe Initialization)
    3. Enum 클래스 사용.
    4. Holder에 의한 초기화

1. 정적 변수에 인스턴스를 만들어서 바로 초기화 하는 방법

이른 초기화(Eager Initialization)라고도 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
public class Printer {
// static 변수에 외부에 제공할 자기 자신의 인스턴스를 만들어 초기화한다.
private static Printer printer = new Printer();

private Printer() { }

// 자기 자신의 인스턴스를 외부에 제공.
private Printer getPrinter() {
return printer;
}

public void print(String str) {
System.out.println(str);
}
}

static 변수는 객체가 생성되기 전 클래스 로더에 의해 클래스가 메모리에 로딩될 때 만들어지기 때문에 초기화가 한 번만 실행된다. 또, 클래스 로더에 의해 클래스가 최초 로딩될 때 객체가 생성됨으로 Thread-Safe 하다.

프로그램 시작부터 종료까지 없어지지 않고 메모리에 계속 상주하며 클래스에서 생성된 모든 객체에서 참조할 수 있다.

하지만, 싱글톤 객체의 사용 유무와 상관 없이 클래스가 로딩되는 시점에 항상 싱글톤 객체가 생성되고 메모리를 점유하고 있기 때문에 비효율적인 부분이 있다.

2-1. 인스턴스를 만드는 메소드에 동기화 하는 방법

늦은 초기화(Lazy Initializaion)라고 한다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class Printer {

// 외부에 제공할 자기 자신의 인스턴스
private static Printer printer = null;
private int count = 0;

private Printer() {

}
// 인스턴스를 생성하는 메소드 동기화. (임계 구역)
public synchronized static Printer getInstance() {
if (printer == null) {
// printer 인스턴스 생성
printer = new Printer();
}
return printer;
}

public void print(String str) {
// 오직 하나의 스레드 접근만을 허용한다. (임계 구역)
// 성능을 위해 필요한 부분만을 임계 구역으로 설정한다.
synchronized (this) {
count++;
System.out.println(str);
}
}
}

임계 구역

  • 인스턴스를 만드는 메소드를 임계 구역으로 변경했다.
    • 임계 구역 : 둘 이상의 스레드가 동시에 접근해서는 안되는 공유 자원을 접근하는 코드의 일부를 말한다.
    • 다중 스레드 환경에서 동시에 여러 스레드가 getPrinter() 메소드를 소유하는 객체에 접근하는 것을 방지한다.
  • 공유 변수에 접근하는 부분을 임계 구역으로 변경
    • 여러 개의 스레드가 하나뿐인 count 변수 값에 동시에 접근해서 갱신하는 것을 방지하기 위함이다.

synchronized 키워드를 사용해서 getInstance() 메소드를 lock, unlock 처리하기 때문에 많은 비용이 발생한다. 대략 100배 정도 비효율적이라고 한다. 따라서 많은 쓰레드가 getInstance() 메소드를 호출하게 되면 프로그램의 전반적인 성능 저하가 발생한다.

2-2. Thread Safe Lazy Initialization + Double - Checked locking

2-1에서 언급한 인스턴스를 만드는 메소드에 동기화를 하는 방법에서 조금 바꾸면 된다. 2-1의 문제는 많은 쓰레드들이 동시에 synchronized 처리된 getInstance() 메소드를 접근하면 성능저하가 발생한다. 이를 완화하기 위해서 Double-Checked locking 기법을 사용한다. DCL이라고도 부른다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public Printer{
private volatile static Printer printer;
private Printer(){ }

public static Printer getInstance(){
if(printer == null){
synchronized(Printer.class){
if(printer == null){
printer = new Printer();
}
}
}

return printer;
}
}

메소드에 synchronized를 제거하면서 동기화 오버헤드를 줄여보고자 하는 의도로 설계되었다. printer가 null인지 체크하고 null일 경우 동기화 블록에 진입하게 된다. 그래서 최초 객체가 생성된 이후로는 동기화 블록에 진입하지 않기 때문에 효율적이다.

하지만, 아주 안 좋은 케이스로 정상 동작하지 않을 수도 있다. 그래서 권고하지 않는 방법 중 하나이다.

3. Enum 클래스

간단하게 class가 아닌 enum으로 정의하는 것이다.

1
2
3
4
5
6
7
public enum Printer{
INSTANCE;

public static Printer getInstance(){
return INSTANCE;
}
}

Enum은 인스턴스가 여러 개 생기지 않도록 확실하게 보장해준다.(Thread-Safe) 또한, 복잡한 직렬화나 리플렉션 상황에서도 직렬화가 자동으로 지원된다는 이점이 있다.

하지만, Enum에도 한계라는 것이 있다. 보통 Android 개발을 하게 될 경우 Singleton의 초기화 과정에 Context라는 의존성이 끼어들 가능성이 노다. Enum의 초기화는 컴파일 타임에 결정되므로 매번 메소드 등을 호출할 때 Context 정보를 넘겨야 하는 비효율적인 상황이 발생할 수 있다.

결론적으로 Enum은 효율적인 방법이지만, 상황에 따라 사용이 어려울 수도 있다는 점이다.

4. Holder에 의한 초기화

지금까지 나온 방법 중 가장 완벽하다고 평가 받는 방법이다.
코드를 먼저 확인해보자.

1
2
3
4
5
6
7
8
9
10
11
12
public class Printer {

private Printer() { }

private static Printer getInstance() {
return LazyHolder.INSTANCE;
}

private static class LazyHolder {
private static final Printer INSTANCE = new Printer();
}
}

한마디로 객체가 필요할 때로 초기화를 미루는 것이다. 따라서 Lazy Initialization이라고도 한다. Printer 클래스에는 LazyHolder 클래스의 변수가 없기 때문에 Printer 클래스 로딩 시 LazyHolder 클래스를 초기화하지 않는다.

LazyHolder 클래스는 Printer 클래스의 getInstance() 메소드에서 LazyHolder.INSTANCE를 참조하는 순간 클래스 로더에 의해 Class가 로딩되며 초기화가 진행된다.

클래스 로더가 Class를 로딩하고 초기화하는 시점은 Thread-Safe를 보장하기 때문에 volatile이나 synchronized 같은 키워드가 없어도 thread-safe 하면서 성능도 보장하는 아주 훌륭한 방법이다.

참고