티스토리 뷰

CS/Design Pattern

Singleton Pattern

eello 2024. 12. 10. 15:46

스프링을 배웠다면 수도 없이 많이 들었던 개념 중 하나가 싱글턴 패턴일 것이다. 스프링은 빈으로 등록된 객체들은 기본적으로 싱글턴으로 관리되기 때문이다.

 

객체들 중에는 사실 단 하나만 존재해도 되는 것들이 있다. 스레드 풀이나 로그 기록용 객체 등이 이에 해당한다. 예를 들어 스레드 풀 객체가 두 개가 존재한다고 생각해 보자. 어떤 객체는 스레드 풀 A에서, 또 어떤 객체는 스레드 풀 B에서 스레드를 가져와 사용했을 때, 스레드 풀 A에서 가져다 쓰는 객체의 호출 빈도가 높다면 A 스레드 풀 내의 스레드는 쉴 틈 없이 사용되고 있을 것이고 이에 비해 B 스레드 풀의 스레드들은 놀고 있는 상황이 되어 모든 자원을 효율적으로 사용하지 못해 성능을 온전히 낼 수 없게 된다.

 

즉, 하나만 존재해도 되는 객체가 둘 이상 존재하게 되면 자원을 효율적으로 사용하지 못할 수도 있고, 뿐만 아니라 프로그램이 이상하게 돌아가거나 불필요한 자원을 잡아먹는 등의 단점이 존재하게 된다.

 

그렇다면 이러한 객체들을 어떻게 하나만 존재하도록 할 수 있을까? 간단하게 하려면 팀 내 규칙으로 지정해 하나만 생성하도록 하던가, 정적 변수를 쓰고 애플리케이션이 실행될 때 초기화한 후 필요한 곳에서 정적 변수를 가져다 사용하면 된다. 하지만 팀 내 규칙으로 정했다면 혹시 모를 휴면 에러로 더 생성될 가능성이 존재하며 정적 변수로 애플리케이션이 실행될 때 초기화했을 때, 이 객체를 어디에서도 사용하지 않으면 단순히 메모리만 잡아먹는 쓸모없는 객체가 될 것이다.(정적 변수가 이 객체를 참조하고 있기 때문에 가비지 컬렉터도 메모리를 회수하지 않는다.)

 

싱글턴 패턴은 이런 걸 방지해 애플리케이션 내에 단 하나의 객체만 생성될 수 있도록 보장할 수 있는 패턴이다.

 

싱글턴 패턴의 구현

싱글턴 패턴의 구현의 시작은 `private` 생성자로부터 시작한다. 생성자를 `private`으로 선언하면 해당 클래스 내에서만 인스턴스를 생성할 수 있게 된다. 싱글턴 패턴은 이러한 특성을 이용해 외부에서 새로운 객체 생성을 방지한다. 기본적인 싱글턴 구현 방식은 다음과 같다.

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

외부에서 `getInstance()` 메서드를 호출했을 때 전역 변수인 instance가 null이 아니면 해당 클래스 내에서 새로운 인스턴스를 생성해 초기화하고 이 인스턴스를 반환한다. 이후에 다시 외부에서 `getInstance()`를 호출하더라도 이미 instance에는 인스턴스가 할당되었기 때문에 더 이상 새로운 인스턴스를 생성하지 않기 때문에 모두 하나의 동일한 인스턴스를 사용하게 된다.

이 역시 static 변수를 사용하지만 단순히 정적 변수를 사용했을 때와의 차이점은 lazy Initialize의 이점이 존재한다는 것이다.

 

멀티 스레드 환경에서 문제점

위와 같은 구현에서 2개 이상의 스레드가 동시에 아직 한 번도 호출되지 않은 getInstance() 메서드를 호출한다고 해보자. 그럼 동시에 접근한 여러 스레드에서 새로운 인스턴스를 생성을 하게 될 것이다. 물론, 이후 호출에서는 모두 같은 인스턴스를 반환하겠지만 이 순간에서는 대부분의 스레드들은 서로 다른 인스턴스를 반환받아 사용하게 될 것이다.

 

아래는 인스턴스 생성에 1ms가 걸린다고 가정하고 10개의 스레드를 실행시켰을 때의 결과이다.

 

10개의 스레드에서 모두 서로 다른 인스턴스를 반환받은 것을 알 수 있다. 즉, 이러한 기본 구현은 멀티 스레드 환경에서 문제가 발생할 수 있음을 알 수 있다.

 

이러한 문제를 해결하기 위해 `synchronized` 블록을 사용할 수 있다. 단순히 `getInstance()` 메서드에 동기화 블록을 사용하면 한 번에 하나의 스레드만 접근해 인스턴스를 생성할 수 있기 때문에 모든 스레드가 동일한 하나의 인스턴스를 반환받아 사용할 수 있게 될 것이다.

 

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

 

동일하게 인스턴스 생성에 1ms가 걸린다고 가정하고 10개의 스레드를 실행시켰을 때의 결과이다.

 

모두 동일한 인스턴스를 반환받는 것을 알 수 있다.

 

하지만 `getInstance()`를 호출할 때마다 동기화 과정을 거치기 때문에 성능적으로 좋지 않다. 일반적으로 동기화 블록을 메서드에 설정하면 100배 정도의 성능 손해를 보게 된다고 한다. 때문에 메서드에 거는 것이 아니라 `DCL(Double-checking Lock)`을 사용해 인스턴스 생성 부분에 부분적으로 동기화 블록을 설정함으로써 성능을 높일 수 있다.

DCL을 사용하면 인스턴스가 생성되어 있는지 확인한 후, 생성되지 않았을 때만 동기화할 수 있어 생성되어 있는 경우에는 동기화하지 않아도 돼 성능을 높일 수 있다.

 

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

 

 

volatile을 사용해야 하는 이유

일반적으로 Singleton instance = new Singleton()을 한다면

1. 메모리 할당 : Singleton 인스턴스를 위한 메모리 할당
2. 생성자 호출 : Singleton 생성자 호출해 인스턴스 초기화
3. 참조 저장 : 할당된 메모리 주소를 instance 변수에 저장

위 단계로 진행된다. 하지만 JVM의 최적화와 명령어 재배치(reordering)로 인해 2번과 3번 과정의 순서가 바뀔 수 있다. 즉, 아직 인스턴스 초기화가 되지 않은 (단순히 할당된) 메모리 주소를 instance 변수에 저장하게 된다는 것이다. 아직 인스턴스가 만들어지지 않은 상황에서 다른 스레드가 instance에 접근한다면 이미 instance는 null이 아니기 때문에 초기화되지 않은 객체에 접근해 `NPE` 같은 에러가 발생할 수 있게 된다.

volatile은 이러한 명령어 재배치를 사용하지 않고 정확한 순서로 실행되도록 보장한다.

 

Static Holder 방식

위와 같이 동기화 블록을 사용하지 않고도 Lazy Initailze이 가능하면서 멀티 스레드 환경에서 문제없는 방식이 존재한다. 바로 `ClassLoader`를 이용하는 방식이다. `ClassLoader`는 클래스를 메모리 내로 로딩하는 역할을 수행하며 해당 클래스가 필요한 순간에 동적으로 로딩한다. Static Holder 방식은 클래스를 동적으로 로딩하는 `ClassLoader`의 방식을 이용한 방식이다.

 

public class Singleton {
    
    private Singleton() {}

    public static Singleton getInstance() {
        return SingletonHolder.instance;
    }

    private static class SingletonHolder {
        private static Singleton instance = new Singleton();
    }
}

 

`getInstance()`가 호출되기 전까지 `SingletonHolder` 클래스는 JVM 내에 로딩되지 않는다. 처음 `getInstance()`가 호출되는 순간 내부 클래스 `SingletonHolder`가 클래스 로더에 의해 로딩되면서 정적 변수 instance에 새로운 싱글턴 인스턴스로 초기화한 다음 반환하게 된다. 클래스가 동적으로 로딩되면서 초기화되는 과정은 Thread-safe 하게 진행된다. 때문에 모든 스레드는 동일한 인스턴스를 사용할 수 있게 된다.

 

Static Holder 방식이 가장 안전하고 뛰어난 성능으로 가장 추천되는 방식이다.

 

스프링에서의 싱글턴 객체

싱글턴 패턴의 구현 방법을 알아봤지만 우리가 스프링에서 빈으로 등록하는 객체들에 대해서는 이러한 구현이 적용되지 않는다. 스프링 IoC 컨테이너는 애플리케이션 컨텍스트가 초기화될 때 `@Component`, `@Bean` 등의 어노테이션이 적용된 클래스들을 빈으로 생성하고 이를 싱글턴 레지스트리에 등록해 캐싱한다. 이후 동일한 이름으로 빈을 요청하면 새로운 인스턴스를 생성하지 않고 캐싱되어 있는 빈을 반환한다.

 

즉, 스프링에서의 빈들은 실제 싱글턴 패턴의 구현으로 보장되는 것이 아닌 스프링 프레임워크의 IoC 컨테이너가 직접 빈들의 생명주기를 관리함으로써 보장된다.

'CS > Design Pattern' 카테고리의 다른 글

Strategy Pattern  (1) 2024.12.17
Observer Pattern  (0) 2024.12.10
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
TAG
more
«   2025/10   »
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
글 보관함