싱글턴 패턴이란?
특정 클래스의 인스턴스가 하나만 만들어지도록 해 주는 패턴
이러한 싱글턴 패턴을 사용하면, 하나의 인스턴스만 생성되도록 할 수 있습니다. 설정 객체, Connection Pool, Thread Pool 등과 같이 하나의 인스턴스만 필요한 경우, 여러 개의 인스턴스가 생성되지 않도록 해서 자원이 불필요하게 사용되지 않도록 할 수 있는거죠.
고전적 싱글턴 패턴
고전적인 싱글턴 구현은 간단합니다. 생성자의 접근 제어자(Access Modifier)를 private로 하여, 외부에서 접근하지 못하도록 하고, getInstance()와 같은 메서드를 통해 정해진 인스턴스를 반환하도록 하면 돼요.
public class Singleton {
// 하나뿐인 인스턴스를 저장하는 static 변수
private static Singleton uniqueInstance;
// private 생성자, 클래스 내부에서만 접근 가능하다.
private Singleton() {
}
public static Singleton getInstance(){
if(Objects.isNull(uniqueInstance)){
uniqueInstance = new Singleton();
}
return uniqueInstance;
}
}
중요한 점은 아래와 같습니다.
- 생성자의 접근 제어자는
private, 따라서 클래스 내부에서만 인스턴스를 생성할 수 있음 - 인스턴스를 저장하는 레퍼런스 변수와 하나뿐인 인스턴스를 반환하는 메서드는
static- 이 메서드를 통해서만 인스턴스를 생성할 수 있으며, 여러 번 호출하더라도 같은 인스턴스를 반환
- 이 메서드를 호출할 때 인스턴스가 생성되도록 하여, 불필요한 자원 낭비 방지
class Test{
@Test
@DisplayName("싱글스레드 환경에서 싱글턴 인스턴스는 하나만 생성되어야 한다.")
void test1(){
// Given
Set<Singleton> singletonSet = new HashSet<>();
// When
IntStream.range(0, 1000)
.forEach((index) -> singletonSet.add(Singleton.getInstance()));
// Then
Assertions.assertEquals(1, singletonSet.size());
}
}
위 테스트 코드는, 싱글스레드 환경에서 싱글턴 인스턴스를 1000번 생성했을 때, 몇 개의 인스턴스가 생성되는지 확인하는 테스트입니다. 테스트 결과는 성공으로, 예상대로 1개의 인스턴스만 생성되어요.
고전적 싱글턴 패턴의 문제
고전적 싱글턴 패턴의 문제는 멀티스레드 환경에서 여러 개의 인스턴스가 생성될 수 있다는 것입니다.
class Test{
@Test
@DisplayName("멀티스레드 환경에서 싱글턴 인스턴스는 하나만 생성되어야 한다.")
void test1() throws Exception{
// Given
Map<String, Singleton> singletonHashMap = new ConcurrentHashMap<>();
var executorService = Executors.newFixedThreadPool(10);
// When
IntStream.range(0, 3).forEach((index) -> executorService.submit(() ->{
Singleton instance = Singleton.getInstance();
singletonHashMap.put(instance.toString(), instance);
}));
executorService.awaitTermination(500, TimeUnit.MICROSECONDS);
// Then
Assertions.assertEquals(1, singletonHashMap.size());
}
}
위 코드는 크기가 멀티스레드 환경에서 Singleton 인스턴스를 3번 생성해서 ConcurrentHashMap에 삽입하는 코드입니다.
실행 결과, 2개의 싱글턴 인스턴스가 생성됩니다.
getInstance() 메서드에 진입하여 경합을 벌이는 과정에서 서로 다른 두 개의 인스턴스가 만들어질 수 있는거죠.
expected: <1> but was: <2>
Expected :1
Actual :2
💡 스레드의 동작에 따라 결과가 달라집니다. 하나의 인스턴스만 생성되는 경우도 있습니다.
Thread-Safe Singleton
그럼 멀티스레드 환경에서 어떻게 하나의 인스턴스만 생성되도록 할 수 있을까요?
스레드가 경합을 벌였을 때, 문제가 발생하는 메서드를 동기화하면 멀티스레딩과 관련된 문제가 해결됩니다.
public class Singleton{
...
public static synchronized Singleton getInstance(){
if(Objects.isNull(uniqueInstance)){
uniqueInstance = new Singleton();
}
return uniqueInstance;
}
}
위 코드에서는 getInstance() 메서드에 synchronized 키워드를 붙여주었는데요,
이 키워드를 메서드에 붙여주면 이 메서드는 임계 영역(Critical Section)이 되어서, 한 번에 하나의 스레드만 실행할 수 있습니다.
하지만, 동기화로 인해 성능이 저하되며, 심지어 인스턴스가 생성된 후부터는 동기화가 필요하지 않으므로, 불필요한 오버헤드가 증가합니다. 따라서 getInstance()가 병목으로 작용할 수도 있어요.
또한, DCL 방식에서 살펴보겠지만, 이 방식도 완전히 Thread-safe 하지는 않아요.
Eager Initialization (Early Loading)
public class Singleton{
private static final Singleton uniqueInstance = new Singleton()
public static Singleton getInstance(){
return uniqueInstance;
}
}
정적변수의 경우 클래스가 로딩될 때 초기화 되며, 정적변수가 초기화되기 전까지 어떤 스레드도 정적 변수에 접근할 수 없습니다.
따라서 멀티스레드 환경이라도 서로 다른 싱글턴 인스턴스가 생성되지 않습니다.
하지만, 클래스가 로딩될 때 초기화되므로, 인스턴스가 필요하지 않은 경우에도 미리 인스턴스를 생성하기 때문에 메모리를 낭비할 수 있습니다.
그렇다면, 정적변수는 Application이 실행될 때 초기화 된다는 걸까요?
public class Singleton {
private static final Singleton uniqueInstance;
static {
uniqueInstance = new Singleton()
System.out.println("정적 변수 초기화");
}
private Singleton() {
}
public static Singleton getInstance(){
return uniqueInstance;
}
}
public class Application {
public static void main(String[] args) {
IntStream.range(0, 5)
.forEach(System.out::println);
}
}
이 Application의 출력은 어떨까요? “정적 변수 초기화”라는 문자열이 출력될까요?
0
1
2
3
4
Singleton 클래스의 static block에서 싱글턴 인스턴스를 초기화하고 있음에도, “정적 변수 초기화”라는 문자열이 출력되지 않았습니다. 즉, 클래스는 인스턴스 생성, static method 호출, 변수 접근, Class.forName() 등으로 인해 클래스가 로딩되는데, 현재는 Singleton 클래스에 대한 접근이 없기 때문에 클래스가 로딩되지 않는거고, 그에 따라 uniqueInstance가 초기화 되지 않는겁니다.
그럼, 인스턴스가 필요하지 않는 경우에도 인스턴스가 미리 생성된다는 게 무슨 말이야..?
public class Singleton {
private final static Singleton uniqueInstance;
static {
System.out.println("정적 변수 초기화");
uniqueInstance = new Singleton();
}
private Singleton() {
}
public static void noOperation(){
}
public static Singleton getInstance(){
return uniqueInstance;
}
}
public class Application {
public static void main(String[] args) {
Singleton.noOperation();
}
}
정적 변수 초기화
이번에는 Singleton 클래스의 static block이 실행되었습니다. 이유가 뭘까요??
Application의 main()에서 Singleton의 정적 메서드 noOperation()을 호출하고 있기 때문에, Singleton 클래스가 로드되면서 uniqueInstance까지 초기화가 되는거죠. 따라서 싱글턴 인스턴스가 사용되지 않음에도 자원을 낭비하게 됩니다.
Bill Pugh Solution (Initialization-on-demand holder idiom)
이 방식은 holder를 이용하여 싱글턴 인스턴스를 초기화하는 방법이에요.
public class Singleton {
private Singleton() {
}
private static Singleton getIntance(){
return SingletonHolder.INSTANCE;
}
public static void noOperation(){
System.out.println("noOperation call");
}
private static class SingletonHolder {
private static final Singleton INSTANCE;
static {
INSTANCE = new Singleton();
System.out.println("정적 객체 초기화");
}
}
}
public class Application {
public static void main(String[] args) {
Singleton.noOperation();
}
}
noOperation call
중요한 점은 세 가지에요.
- Singleton 클래스 내부의
SingletonHolder(private static class) - SingletonHolder의 INSTANCE 정적 변수
- getInstance()의 return SingletonHolder.INSTANCE
이 방식의 특징은 아래와 같습니다.
Lazy Loading이 가능하다.- 위 출력을 보면, Singleton의 noOperation()을 호출했음에도 싱글턴 인스턴스가 생성되지 않았어요. 오직 getInstance()를 통해서만
SingletonHolder클래스가 로드되고, 싱글턴 인스턴스가 생성됩니다. - 따라서, 인스턴스가 불필요하게 생성되는 것을 방지할 수 있어요.
- 위 출력을 보면, Singleton의 noOperation()을 호출했음에도 싱글턴 인스턴스가 생성되지 않았어요. 오직 getInstance()를 통해서만
- 클래스가 로드될 때 싱글턴 인스턴스가 생성되므로,
Thread-safe합니다.
DCL(Double-Checked Locking)을 이용한 싱글턴 패턴
⚠️ 자바5 이전 버전에서는 DCL이 제대로 동작하지 않습니다.
이 패턴은 instance가 null인지 두 번 check 하기 때문에 Double Checked Locking이라고 해요.
public class Singleton {
private volatile static Singleton instance;
private Singleton() {
}
public static Singleton getInstance(){
if(Objects.isNull(instance)){ // Single Checked
synchronized (Singleton.class){
if(Objects.isNull(instance)){ // Double Checked
instance = new Singleton();
}
}
}
return instance;
}
}
중요하게 볼 부분은 세 가지 입니다.
- getInstance()에서 두 번의 instance 정적 변수 null check
- 안쪽 null check 코드의
synchronized 블록 - instance 정적 변수의
volatile키워드
왜 두 번의 null check이 필요할까요?
public static Singleton getInstance(){
if(Objects.isNull(instance)){ // Single Checked
instance = new Singleton();
}
return instance;
}
예를 들어 위 코드에서 스레드1과 스레드2가 동시에 첫 번째 null check을 수행하는 경우, 두 스레드 모두 if문을 통과하고, 각각의 스레드가 Singleton 인스턴스를 생성하는 것이죠.
그럼 아래 코드는?
public static Singleton getInstance(){
synchronized(Singleton.class){
if(Objects.isNull(instance)){ // Single Checked
instance = new Singleton();
}
}
return instance;
}
이 경우, getInstance()를 호출하는 모든 스레드가 synchronized 블록을 실행해야하기 때문에 성능이 저하됩니다.
싱글턴 인스턴스가 생성된 이후 또 다른 스레드가 getInstance()를 호출했을 때, synchronized 블록을 수행할 필요 없이 빠르게 instance를 반환 받도록 하기 위해 Double Check을 하는 것이죠.
그럼에도 getInstance() 메서드는 instance 정적 변수를 volatile로 만들지 않으면 제대로 동작하지 않아요.
그렇다면, 왜 싱글턴 인스턴스를 volatile로 선언해야할까요?
위처럼, 스레드는 working 메모리를 가지고 있어요.
💡 여기서 working memory는 각 스레드마다 독립적으로 할당되는 메모리 영역으로, Cpu Cache 메모리와는 다름.
따라서, main 메모리와 working 메모리 간의 데이터 이동이 있기 때문에 메모리 간에 동기화가 진행되는 동안 빈틈이 생겨요. 그래서 volatile을 쓰지 않는 Double Checked Locking 방식은 아래와 같은 문제가 발생할 수 있습니다.
- 스레드 1이 instance를 생성하고 synchronized 블록을 벗어남.
- 스레드2가 synchronized 블록을 null check을 수행하는 시점에 스레드1이 생성한 instance가 working 메모리에만 존재
- 스레드2가 인스턴스를 생성
volatile 키워드가 선언된 변수는 캐시 메모리를 사용하지 않고, 항상 main 메모리에서 직접 읽고 쓰기 때문에 싱글턴 인스턴스에 volatile 키워드를 선언해주어야 해요. 하지만, volatile 키워드로 선언하면 항상 메인 메모리에서 읽거나 쓰기 때문에 성능 저하가 발생할 수 있습니다.
Enum을 이용한 방식
Enum을 이용하면, 싱글턴 구현이 굉장히 간단합니다.
또한, Java에서 모든 enum은 프로그램에서 한 번만 인스턴스 화되도록 보장하기 때문에 Thread-safe 합니다.
class Status{
private final int value;
Status(int value) {
this.value = value;
}
}
public enum EnumSingleton {
INSTANCE(1, 2);
private final Status state1;
private final Status state2;
// enum 생성자의 기본 접근 제어자(Access Modifier)는 private
EnumSingleton(int statusValue1, int statusValue2){
state1 = new Status(statusValue1);
state2 = new Status(statusValue2);
}
public void doSomething(){
System.out.println("doSomething");
}
}
class Application{
public static void main(String[] args) {
EnumSingleton instance = EnumSingleton.INSTANCE;
instance.doSomething();
}
}
doSomething
이 방식의 장/단점은 아래와 같습니다.
Pros
- 구현 쉬움
- Thread-safe 하다.
직렬화/역직렬화에 대한 처리가 필요없다.
Cons
lazy Loading이 아니다.
직렬화/역직렬화를 왜 처리해주어야 하나에 대해 살펴보겠습니다.
먼저 Early Loading 방식의 Singleton 클래스가 Serializable 인터페이스를 구현하도록 하고, serialVersionUID(직렬화 프로토콜의 고유 식별자)를 추가해주었습니다.
public class Singleton implements Serializable {
private static final long serialVersionUID = 123456789L;
private static Singleton INSTANCE = new Singleton();
private Singleton() {
}
public static Singleton getInstance(){
return INSTANCE;
}
}
class Test{
@Test
@DisplayName("싱글턴 클래스에서 readResolve()를 정의하지 않으면, 싱글턴 인스턴스를 역직렬화할 때 새로운 인스턴스가 생성된다.")
void test4() throws IOException, ClassNotFoundException {
// Given
Singleton serializedInstance = Singleton.getInstance();
final String fileName = "output.txt";
// When
// 직렬화
try(var out = new ObjectOutputStream(new FileOutputStream(fileName))){
out.writeObject(serializedInstance);
}
// 역직렬화
Singleton deSerializedInstance = null;
try(var in = new ObjectInputStream(new FileInputStream(fileName))){
deSerializedInstance = (Singleton) in.readObject();
}
// Then
Assertions.assertNotEquals(serializedInstance.hashCode(), deSerializedInstance.hashCode());
}
}
위 테스트 코드는 이 고전적인 싱글턴 패턴을 구현한 클래스의 인스턴스를 파일에 저장하고, 다시 읽어오는 코드에요.
테스트 결과, 성공했어요. 즉, 싱글턴 인스턴스와 역직렬화된 인스턴스가 다른 hashCode 값을 갖고있어요.
이것을 해결하려면, Singleton 클래스에 readResolve()를 추가해주면 돼요. 그럼 인스턴스를 역직렬화해도 새로운 인스턴스가 생성되지 않아요.
public class Singleton implements Serializable {
private static final long serialVersionUID = 123456789L;
private static Singleton INSTANCE = new Singleton();
private Singleton() {
}
public static Singleton getInstance(){
return INSTANCE;
}
public Object readResolve(){
return getInstance();
}
}
그렇다면, enum은 어떨까요?
class Test{
@Test
@DisplayName("enum은 싱글턴 인스턴스를 역직렬화해도 새로운 인스턴스가 생성되지 않는다.")
void test5() throws IOException, ClassNotFoundException {
// Given
EnumSingleton serializedInstance = EnumSingleton.INSTANCE;
final String fileName = "output.txt";
// When
// 직렬화
try(var out = new ObjectOutputStream(new FileOutputStream(fileName))){
out.writeObject(serializedInstance);
}
// 역직렬화
EnumSingleton deSerializedInstance = null;
try(var in = new ObjectInputStream(new FileInputStream(fileName))){
deSerializedInstance = (EnumSingleton) in.readObject();
}
// Then
Assertions.assertEquals(serializedInstance.hashCode(), deSerializedInstance.hashCode());
}
}
테스트 결과는 성공으로, 역직렬화 해도 새로운 인스턴스가 생성되지 않아요.
싱글턴 패턴의 장점
- 한 개의 인스턴스만 생성되도록 할 수 있다.
- 한 개의 인스턴스만 생성되기 때문에, 메모리 공간을 절약할 수 있다.
- 전역적으로 접근할 수 있다.
싱글턴 패턴의 문제점
Reflection을 통해 인스턴스 생성이 가능하다.
class Test{
@Test
@DisplayName("Reflection을 이용하여 싱글턴 클래스의 인스턴스를 직접 생성할 수 있다.")
void test3() throws Exception {
Singleton instance = Singleton.getInstance();
Constructor[] constructors = Singleton.class.getDeclaredConstructors();
for (Constructor constructor : constructors) {
constructor.setAccessible(true); // singleton breaker
Assertions.assertNotEquals(instance, constructor.newInstance());
}
}
}
위 코드는 Reflection을 이용하여 싱글턴 클래스의 인스턴스를 직접 생성하는 코드입니다.
이렇게 싱글턴 클래스의 접근 제어자(Access Modifier)가 private라도, Reflection을 이용해서 Singleton 패턴을 무력화시킬 수 있습니다.
인스턴스 생성
Thread-Safe와는 별개로, 클래스 로더를 어떻게 구성하고 있느냐에 따라서 싱글턴 클래스임에도 하나 이상의 오브젝트가 만들어질 수 있습니다. 따라서 자바 언어를 이용한 싱글턴 패턴 기법은 서버 환경에서는 싱글톤이 꼭 보장된다고 볼 수 없습니다. 또한 여러 개의 JVM에 분산되어 설치되는 경우에도 각각 독집적으로 오브젝트가 생기기 때문에 싱글턴으로서의 가치가 떨어집니다.
동기화
싱글턴 인스턴스의 상태가 변할 수 있는 경우, 동시에 접근하는 스레드 간 상호작용에 의해 예기치 않은 결과가 발생할 수 있습니다.
class Test{
@Test
@DisplayName("가변 Singleton 인스턴스를 여러 스레드가 이용하는 경우, 동기화 이슈가 발생할 수 있다.")
void test6() throws Exception{
// Given
var executorService = Executors.newFixedThreadPool(10);
// When
IntStream.range(0, 1000).forEach((index) -> executorService.submit(() ->{
Count.getInstance().increase();
}));
executorService.awaitTermination(500, TimeUnit.MICROSECONDS);
// Then
int resultCount = Count.getInstance().getCount();
System.out.println(resultCount);
Assertions.assertNotEquals(1000, resultCount);
}
}
resultCount : 522
위 코드는 멀티스레드 환경에서 Count 싱글턴 인스턴스의 값을 1000번 증가시키는 코드입니다.
하지만, 결과 값은 522로 훨씬 낮은 값이 출력되는 것을 볼 수 있어요.
따라서 싱글턴이 멀티스레드 환경에서 사용되는 경우, 불변 객체로 만들어져야합니다.
테스트가 어렵다.
싱글톤 객체는 프로그램 전역에서 유일하게 하나의 객체만 존재하기 때문에 Mock Object로 대체하기 어렵습니다. Mock Object는 보통 실제 객체와 같은 인터페이스를 구현하며, 테스트 시에는 Mock Object를 실제 객체 대신 사용하여 테스트를 수행합니다.
하지만 싱글톤 객체는 유일하게 하나만 존재해야 하기 때문에 Mock Object로 대체할 수 없습니다. Mock Object로 대체하면 다른 객체에서 해당 싱글톤 객체를 참조하면서 예상치 못한 동작을 할 수 있기 때문입니다. 따라서, 싱글톤 객체를 사용하는 코드를 테스트하기 위해서는 다른 방법을 사용해야 합니다. 예를 들어, 의존성 주입(Dependency Injection) 등을 활용하여 싱글톤 객체에 대한 의존성을 주입하는 방법이 있습니다.
유연성이 떨어진다.
싱글턴 인스턴스는 정적 필드로 선언되어 있기 때문에 런타임 시에 동적으로 변경되지 않습니다. 이로 인해 싱글턴 클래스를 다른 클래스로 교체하거나, 여러 개의 인스턴스를 생성하는 것과 같은 일이 어렵습니다.
객체지향적이지 않다.
아무 객체나 자유롭게 접근하고 수정하고 공유할 수 있는 전역 상태를 갖는 것은 객체지향 프로그래밍에서는 지양되어야 할 모델이며, Java에서 Singleton은 private 생성자를 가지므로 상속할 수 없습니다. 따라서 다형성과 같은 객체지향의 특징을 적용하기 어렵습니다. 또한, 상속과 다형성 같은 객체지향의 특징이 적용되지 않는 스태틱 필드와 메소드를 사용해야 합니다.
전역적 사용으로 인해, 프로그램 전체에 영향을 미친다.
싱글턴 클래스의 인스턴스는 전역 상태를 유지하기 때문에 다른 객체와의 의존성이 높아집니다. 이는 코드의 유지보수를 어렵게 만들 수 있으며, 특히 테스트 코드 작성을 어렵게 만들 수 있습니다.
안티패턴으로 여겨지는가?
싱글턴 패턴은 반드시 안티패턴이라고 할 수는 없지만, 앞서 살펴본 문제점 등으로 인해 잘못 사용할 경우 문제가 발생할 수 있는 패턴 중 하나입니다.
Spring에서는 이러한 싱글턴의 단점을 보완해서 사용하고 있습니다.
Spring의 어플리케이션 컨텍스트는 싱글톤을 저장하고 관리하는 싱글톤 레지스트리이기도 한데요, 스프링은 기본적으로 별다른 설정을 하지 않으면 내부에서 생성하는 빈(Bean) 오브젝트를 모두 싱글톤으로 만듭니다. 하지만, 우리가 Spring에서 Service 빈을 싱글톤으로 작성했던 적이 있었나요? 없죠. 여기서 싱글턴이라는 것은 디자인 패턴의 싱글턴과 비슷한 개념이지만, 그 구현 방법이 다르기 때문이에요.
싱글턴 레지스트리의 장점은 스태틱 메서드와 private 생성자를 사용해야 하는 비정상적인 클래스가 아니라 평범한 자바 클래스를 싱글턴으로 활용하게 해줍니다. 평범한 자바 클래스라도 IoC 방식의 컨테이너를 사용해서 생성과 관계 설정, 사용 등에 대한 제어권을 컨테이너에게 넘기면 손십게 싱글턴 방식으로 만들어져 관리되게 할 수 있습니다. 오브젝트 생성에 관한 모든 권한은 IoC 기능을 제공하는 애플리케이션 컨텍스트에게 있기 때문입니다. 이 경우, 테스트 환경에서 자유롭게 오브젝트를 만들 수 있고, 테스트를 위한 Mock 오브젝트로 대체하는 것도 간단합니다. 또한, 생성자 파라미터를 이용해서 사용할 오브젝트를 넣어주게 할 수도 있습니다.
출처
- Head First Design Pattern Chapter 5. 싱글턴 패턴
- 토비의 3.1 VOL.1, 스프링 1.6 싱글턴 레지스트리와 오브젝트 스코프
- 디자인패턴 - 싱글톤 패턴
- Double Checked Locking on Singleton Class in Java - Example
- Thread and Locks
- Double-checked Locking Pattern (DCLP) 을 쓰지 말아야 하는 이유
- Initialization-on-demand holder idiom
- All About the Singleton
- [Structure] 싱글톤 패턴과 문제점
'스터디' 카테고리의 다른 글
데코레이터 패턴(Decorator Pattern)이란? (0) | 2023.04.04 |
---|---|
22.11.15 SSAFY 스터디 CS 발표 - 디자인 패턴 (전략 패턴) (0) | 2022.12.01 |
22.11.8 SSAFY 스터디 CS 발표 - 디자인 패턴 (템플릿 메서드 패턴) (0) | 2022.11.07 |
22.10.11 SSAFY 스터디 CS 발표 - 컴퓨터 구조, 기억장치 (0) | 2022.10.11 |
22.09.22 SSAFY 스터디 CS 발표 - 컴퓨터 구조 (0) | 2022.09.22 |