본문 바로가기

SW 설계/Design Pattern

[Design Pattern] Creational - 싱글톤 패턴 (Singleton Pattern)

728x90

싱글톤 패턴은 매우 유명한 패턴이며, Spring 개발 경험이 있다면 매우 익숙한 디자인 패턴이다. Spring 이 자체적으로 객체를 Singleton 패턴으로 관리하는데, 이를 Bean 이라고 부르며 Bean 들을 보관하는 공간을 Singleton Container (Spring Container) 라고 부르기 때문이다. 

 

 

싱글톤 패턴은 애플리케이션 내에서 단 하나의 객체만 존재하도록 보장하는 패턴이다 (사실 원하는 갯수만큼 보장이다). 따라서 인 앱 로직 객체보단, Thread Pool Manager, Caches, Logging, Factory 등 관리 측에서 여러 개가 있으면 오히려 더 SW 관리가 어려워지는 객체들에 한해서 많이 사용한다. 하지만 다음과 같은 상황들에 대해서 생각해볼 필요가 있다. 

 

 

global variable 로 선언하면 되는 것 아닌가?

multi-threading 환경에서는 별 문제 없는가?

 

 

public class Hello {

    private static Hello instance; // static 으로 GC가 관리하지 않는 Stack 영역에 인스턴스 값을 저장한다

    private Hello() {} // private 을 통한 외부 생성 제어, 1개 생성을 보장

    public static Hello getInstance() { // 인스턴스가 없이 불러올 수 있도록 static 으로 선언
        if (instance == null) {
            instance = new Hello(); // 인스턴스 첫 생성
        }
        return instance; // 이미 있다면 기존의 것을 반환
    }
}

 

 

싱글톤의 기본적인 형태는 상단과 같다. 생성자를 private 으로 선언함으로써 외부의 사용자가 new operator 를 통해 새로운 객체를 생성하는 것을 제한한다. 하지만 이렇게 단순한 싱글톤 클래스는 Multi-Threading 환경에서는 잘못된 동작을 할 수 있다.

 

 

단순 싱글톤의 멀티 스레딩 문제

 

 

상단 그림과 같이 null 상태인 singleton instance 를 동시에 호출하여 호출하면서도 지속적인 Context-switch 가 발생한다면, 서로 다른 두 object 가 생성될 가능성이 충분히 있다 (이는 실패한 싱글톤이다).

 

 

따라서 생성을 진행하는 getInstance 에 concurrency 를 방지할 필요성이 있다 (Lock & Unlock). 이와 같은 멀티 쓰레딩 환겨에서의 싱글톤 문제를 해결할 수 있는 옵션들을 살펴보자. 

 

 

1_

 

public class Hello {

    private static Hello instance; 

    private Hello() {} 

    // synchronized = critical-section화
    // 한 thread 가 접근시 탈출전까지 타 thread 가 접근할 수 없다
    public static synchronized Hello getInstance() {
        if (instance == null) {
            instance = new Hello(); 
        }
        return instance;
    }
}

 

 

Synchronized 특성을 추가해줌으로써 getInstance 함수는 한 개의 thread 만 접근할 수 있도록 critical-section 화 (병행 제어에 대한 포스트) 시켜주었다. 이렇게 해주면 multi-thread 에서도 한 개의 인스턴스만의 생성을 보장해준다. 하지만 이는 효율적이지 못하다.

 

 

만약 A Thread 가 처음 getInstance 에 진입함으로써 B,C,D Thread 가 진입하지 못하고 있다면, 이미 생성할 가능성이 없는 Thread 들이 빨리 instance 를 사용하지 못하고 굳이 instance == null 체킹을 하기 위해 기다리고 있는 꼴이기 때문이다 (맞지만, 성능 측면에서 아쉬움이 있음).

 

 

2_

 

public class Hello {

    private static Hello instance = new Hello(); 

    private Hello() {} 

    public static Hello getInstance() {
        return instance;
    }
}

 

 

이와 같이 Hello Class 가 Memory 영역에 로드되는 순간 전역 static variable 을 생성시켜 놓으면, Lock 이 없기 때문에 여러 쓰레드가 getInstance() 를 다같이 반환 받을 수도 있고, instance 가 한 번 이상 생성될 걱정도 없다. 

 

 

하지만 이 상황 역시 필요하지 않을 수 있는 인스턴스를 미리 생성해서 메모리를 미리 잡아먹어 놓는다는 측면에서 아쉬움이 있다 (global variable 의 단점을 같이 가져간다). 하지만 이번 옵션은 큰 문제점인 상황은 아니기 때문에 (차피 인 앱에 있으면 필요한 인스턴스라는 측면에서), 현업에서도 많이 사용하는 옵션이다. 

 

 

3_ 해결책이 안되는 옵션

 

public class Hello {

    private static Hello instance; 

    private Hello() {} 

    public static Hello getInstance() {
        if (instance == null) {
            synchronized(Singleton.class){
                instance = new Hello(); 
            }
        }
        return instance;
    }
}

 

 

이와 같이 instance null checking 을 넘어선 이후에 synchronize 를 거는 방법을 생각해볼 수도 있다. 하지만 이는 해결책이 안되는 솔루션이다. 아까 Thread 들이 있는 그림을 살펴보면 instance 가 null 인 상황에서 if 문을 통과한 이후에 context-switch 가 발생했기 때문에, A Thread 가 먼저 synchronize 를 탈출했어도, B Thread 가 바로 진행하면 이미 null checking 을 통과한 상태이다. 따라서 Hello Instance 가 두 번 생성된다. 

 

 

4_ Double Check Locking 적용

 

public class Hello {

    private static Hello instance; 

    private Hello() {} 

    public static Hello getInstance() {
        if (instance == null) {
            synchronized(Singleton.class){
                if(instance == null){
                    instance = new Hello(); 
                }
            }
        }
        return instance;
    }
}

 

 

위와 같이 synchronize 이후에 null 체킹을 한 번 다시 해준다면, 상황은 해결이 된다. B,C,D Thread 는 이제 기다릴 필요도 없고, 만약 null 조건을 통과한 이후여도 한 번 다시 해주기 때문에 instance 생성이 되었다면 재생성을 하지 않을 것이다. 하지만 이 충분해 보이는 로직에도 개발자들은 문제점을 발견했다 (아주 드문 문제긴 하다). 

 

 

Thread 는 CPU 활동을 지속적으로 캐싱하는 캐시 메모리에서 변수를 읽어온다

 

 

null 로 보이지 않는 B가 sync 를 확인하지 않는다

 

 

위처럼 B Thread 가 A Thread 의 생성을 기다리지 않고 null 이 아닌것으로 착각하고 바로 instance 반환을 시도한다. 이는 런타임 예외를 발생시키게 된다. 아주 드물게 나타나지만, 그래도 확실한 보장을 위해 다음과 같은 방안을 제안한다. 

 

 

5_ volatile 적용 

 

public class Hello {

    private volatile static Hello instance = null; 

    private Hello() {} 

    public static Hello getInstance() {
        if (instance == null) {
            synchronized(Singleton.class){
                if(instance == null){
                    instance = new Hello(); 
                }
            }
        }
        return instance;
    }
}

 

 

volatile 이란 4번의 상황처럼 Thread 들이 값을 읽을 때 항상 Main Memory 에 직접 가서 확인하라는 뜻이다. 따라서 A Thread 가 생성중인 상황일 때 B Thread 가 instance 에 값이 할당되었다고 착각할 일이 사라지게 된다. 

 

 

위 상황은 싱글톤뿐만 아니라 Thread Pool 사용시 나타날 수 있는 흔한 문제이므로, 이는 확실하게 알아두는 것을 추천한다. 싱글톤, static 등은 공유되기 때문에, Thread 들 사이에서 확실한 보장이 필요하다 (volatile & synchronize 는 성능에 어느정도 영향이 있으므로, 돈 혹은 인원수와 같은 Critical 한 정보일 경우!)

 

 

 

 

정리하며

 

이번 디자인패턴 포스트에서는 Singleton 이 실질적으로 사용되는 사항들보단, Singleton 의 정확한 구현에 집중하였다. 싱글톤 패턴 자체는 목적이 크게 어려운게 아니기 때문에 사용해야할 상황이 비교적 확실하게 보이기 때문이다. 다만 싱글톤은 인 앱 로직에서는 안티 패턴으로 여겨지기 때문에, 사용이 필요하다고 판단시 충분히 고려한 이후에 진행해야할 것이다. 

 

 

싱글톤 자체보다 중요했던 키워드는 static instance, concurrency, synchronize & volatile 인 것 같다. 이 상황에 관해서는 JVM 메모리 구조와 함께 정리하며 정확한 Flow 를 알아두고 완벽하게 필요한 상황을 판별할 수 있는 것이 비즈니스에 매우 중요할 것 같다 (추후에 정리하면 묶어두도록 하겠다...). 

 

 

외국에서도 많이 헷갈리나보다

 

 

 

출처

 

 

1) 에릭 프리먼, Head First Design Patterns』, O'Reilly Media (2004)

 

 

2) 중앙대학교 소프트웨어학부 이찬근 교수님 디자인 패턴 수업자료 중

 

 

 

728x90