2022. 1. 28. 00:10ㆍDesignPattern
싱글톤은 디자인 패턴의 하나로, 공유자원을 여러 객체가 사용해야 하는 경우에 활용된다. 싱글톤이라는 것은 하나의 개념으로, 프로그래밍의 언어에 상관없이 구현이 가능하다. 이번 게시글에서는 Java를 기준으로 설명을 해보겠다.
Singleton pattern - Wikipedia
In software engineering, the singleton pattern is a software design pattern that restricts the instantiation of a class to one "single" instance. This is useful when exactly one object is needed to coordinate actions across the system. The term comes from
en.wikipedia.org
Basic 싱글톤
먼저 코드를 살펴보자. 다음은 가장 기본적인 싱글톤 패턴의 구현법이다.
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null){
instance = new Singleton();
}
return instance;
}
}
Singleton instance를
클래스 변수로 선언하고, 해당 인스턴스를 호출하는 메서드 역시 static, 클래스 단위로 선언한다. static으로 호출한 인스턴스는 JVM의 힙의 metaspace에 생성되어, 모든 스레드가 해당 자원을 공유할 수 있다. (java 1.8 이후의 기준)
그리고 (instance == null)
if 블록을 확인하고, true
일 경우 인스턴스를 한번 생성하고, 그 이후로는 이미 생성된, instance
만을 활용한다.
단일 스레드의 경우, race condition이 발생할 일이 없으니, 무조건 한 번에 하나의 스레드(정확히는 main 스레드)가 접근한다. 그러니, 위의 코드로 마무리해도 큰 문제는 없을 것 같다.
하지만, 멀티 스레드 환경의 경우, thread-safe 하지 못한 상황이 발생한다. 여러 스레드가 getInstance()
에 접근하던 중 두 개 이상의 스레드가 동시에 접근할 경우, 해당 스레드들은 인스턴스가 생성되지 않은 시점에서 메서드를 접근하기 때문에 race condition이 발생하여, 해당 스레드들의 수만큼 해당 인스턴스를 중복 생성하게 된다. 이렇게 멀티스레드 환경에서 race condition이 발생하는 영역을 임계 영역(critical section)이라 하는데, 경쟁조건과 임계영역를 모르는 사람은 이 문서를 살펴보자.
그런데, 싱글톤을 멀티 스레드 환경에서 임계영역에서 발생하는 경쟁조건을 신경 써야 할까?
싱글톤을 사용하는 이유는 상태 공유이다. 하나의 인스턴스의 상태를 다른 스레드에서도 같은 상태를 공유해야 하는 상황에서 많이 쓸 수 있는 디자인 패턴이다.
한 가지 예를 들자면, 만약 최초로, 애플리케이션이 실행한 이후에, 3개의 스레드가 (instance == null)
을 동시에 접근하면 어떻게 될까?
원래대로라면, 3개의 스레드가 전부 같은 인스턴스를 참조해야 하는데, 3개의 스레드가 전부 if 블록에 true로 통과하고, 각기 다른 인스턴스를 초기화하는 상황이 생기니, 결과적으로는 3개의 스레드가 각기 다른 종류의 인스턴스를 가진다. 따라서, 싱글톤을 사용하는 목적인 상태 공유를 위반하게 되니, 싱글톤을 쓰게 된 이유가 없어지게 된다.
이러한 이유로, 위의 싱글톤 패턴 코드는 이러한 임계 영역에서 발생하는 race condition을 관리하는 코드가 전혀 없기 때문에 멀티 스레드 환경에서는 그다지 효율적인 코드라 할 수 없다. 따라서 이 코드를 멀티 스레드 환경에 맞게 고치기 위한 가장 간단한 방법은 자바의 모니터를 활용하는 것이다.
Synchronized 싱글톤
public class Singleton {
private static Singleton instance;
private Singleton() {}
public static synchronized Singleton getInstance() {
if (instance == null){
instance = new Singleton();
}
return instance;
}
}
모니터는 논란의 여지가 있겠지만, 스레드 동기화 기법들 중 뮤텍스(mutex) 를 고수준 언어에서 관리하는 기법이라 할 수 있다. 자세한 내용은 이 문서를 참고해주기 바란다.
위의 코드와 같이 getInstance()
앞에 synchronized를 붙여서 모니터 락을 얻은 스레드만 접근을 허용하면, 어느 정도 thread-safe 하다고 할 수 있다. 그러나 이 방법도 최선은 아닌 게, 자바의 모니터 락은 thread-safe를 위해 성능을 포기한 락 관리기법이다. 따라서, synchronized 블록을 메서드를 통째로 묵는 것은 다소 비효율적이다.
최초로 구현했던 싱글톤 코드를 봤을 때, race condition이 발생하는 부분은 (instance == null)
이다. 메서드 시작되는 첫 번째 코드라는 점을 고려해본다면, 그냥 메서드 자체에 모니터 블록을 선언하는 게 문제가 되지 않을 것 같지만, 이 부분은 싱글톤 인스턴스가 초기화된 이후에는 무조건 false 이기 때문에 동시성을 신경 쓸 필요가 없다.
따라서, 그냥 일괄적으로 모니터 락을 걸어버리는 것은 다소 비효율 적이다. 즉, race condition이 발생했을 때만, 스레드의 락 체킹을 하여, 모니터 락을 좀 더 느슨하게 걸어준다면, 위의 코드보다는 성능이 향상될 수도 있을 것 같다. 이러한 방식을 구현하기 위해선 Double checked locking을 활용하면 될 것 같다. 코드로 표현해본다면, 다음과 같을 것이다.
Double checked loking 싱글톤
public class Singleton {
private volatile static Singleton instance;
private Sigleton() {}
public Singleton getInstance() {
if(instance == null) {
synchronized(Singleton.class) {
if(instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
설명을 해보자면, 최초로 인스턴스의 null 체크를 할 때는 락을 걸지 말고, 인스턴스가 null이었을 시점에만 스레드의 락을 체크하여, 동기화를 진행하여, 좀 더 성능 저하를 개선해보는 것이다.
Double checked라는 말은 (instance == null)
을 모니터 락을 걸기 전에 한번 하고, 그다음에 다시 (instance == null)
을 하여, 최초에 여러 스레드가 동시에 최초 조건문을 통과해도, 결과적으로는 하나의 스레드만 싱글톤 인스턴스를 초기화하게 된다.
이렇게 하면, 최초에 여러 인스턴스를 생성하게 되는 상황을 피할 수 있으면서, 이후에 해당 싱글톤 인스턴스의 상태를 가져올 때, null 체크를 할 때, 불필요한 락을 걸지 않아도 된다.(왜냐면, 최초에 하나의 인스턴스가 이미 있다면, 여러 스레드가 동시에 null 체크를 하는 건 문제가 없기 때문이다.)
그러나 이 방식을 사용하지 않고도 성능과 안정성을 확보할 수 있는 방법이 있다. 다른 프로그래밍 언어의 경우엔 적용되지 않을 수도 있다. 그러나, Java의 경우 JVM의 특성을 활용하면, 좀 더 최적화된 싱글톤 패턴을 구현할 수 있다.
LazyHolder를 활용한 싱글톤 패턴
public class Singleton {
private Singleton() {}
private static class LazyHolder() {
private static final Singleton instance = new Singleton();
}
public static Singleton getInstance() {
return LazyHolder.instance;
}
}
그동안 멀티 스레드 환경에서 싱글톤 패턴을 구현하면서 발생한 임계 영역은 인스턴스가 null 일 때, 새 인스턴스를 생성하는 부분이었다. 이 부분은 사실 해당 코드가 실행되고 최초로 인스턴스를 생성해야 할 때 말고는 race condition을 신경 쓸 필요가 없다. 그래서 어떠한 방식이든, 코드의 런타임 내내 모니터 락 체킹을 하는 것은 조금 비효율 적일 수 있다.
그런데, JVM의 특성을 활용하면, 모니터 락 체킹을 할 필요도 없다. 위키피디아의 내용을 살펴보면 다음과 같은 JVM의 특성을 알 수 있다.
Since the class initialization phase is guaranteed by the JLS to be sequential, i.e., non-concurrent, no further synchronization is required in the static
getInstance
method during loading and initialization.
위 내용을 해석해보자면, Java Language Specification의 내용에 따르면, 클래스의 초기화 과정은 원자성을 보장하므로, 추가적인 동기화 작업이 필요 없다. 정도로 받아들이면 될 것 같다. 위와 같은 내용을 서술한 이유는 JVM의 경우, 컴파일 후에 실행되는 시점에서 필요한 클래스를 한 번 loading, linking, initializing 하는데, JVM은 이 기능의 원자성을 보장하는 특성이 있기 때문인 듯하다.
즉 위의 코드를 해석해보면, JVM은 몇 개의 스레드가 접근하는지에 상관없이 Singleton class가 필요한 시점에서 한 번 초기화하고, 그다음의 내부의 LazyHolder 클래스가 필요한 시점에서 한번 초기화하는 것을 JVM 단위에서 이미 보장하고 있기 때문에, 별도로, 락을 체크하지 않아도 thread-safe를 보장한다는 것이다.
마무리
싱글톤을 학습할 때도 그렇고, 멀티 스레드에 대해서 공부할 때를 보면, 멀티 스레드 간의 락을 관리하는 기법으로 가장 기본적으로 synchronized를 소개하지만, 결과적으로는 성능 이슈로 사용을 권장하지 않으면서, 다른 concurrency 한 방식을 권유한다. 물론 웹 개발을 하면서, 멀티 스레드 환경을 관리하기 위해 직접 스레드를 관리하는 경우는 거의 없겠지만, 동시성 문제를 해결한답시고 synchronized를 사용해버린다면, 운영하는 서비스의 성능을 크게 떨어트릴 여지가 있을 것 같다. 때문에, 필요한 서비스를 구현하기 위해 여러 가지 기술을 구현해보는 것도 좋지만, 이러한 기본기를 항상 숙지하는 것이 중요하다는 것을 새삼 느낀다.
'DesignPattern' 카테고리의 다른 글
싱글톤 패턴 -1- 예외 상황과 해결법 (0) | 2022.10.11 |
---|