싱글톤 패턴(Singleton Pattern)이란?
싱글톤 패턴이란 단 하나의 유일한 객체를 만들기 위한 코드 패턴이다.
쉽게 말하자면 메모리 절약을 위해, 인스턴스가 필요할 때 똑같은 인스턴스를 새로 만들지 않고 기존의 인스턴스를 가져와 활용하는 기법을 말한다.
즉, 프로그램 시작부터 종료 시점까지 어떤 클래스의 인스턴스가 메모리 상에 단 하나만 존재할 수 있게 하고 이 인스턴스에 대해 어디에서나 접근할 수 있도록 하는 패턴이다.
우리가 전역 변수라는 걸 만들어 이용하는 이유는, 똑같은 데이터를 메서드마다 지역 변수로 선언해서 사용하면 무의미하기도 않고 낭비이기 때문에, 전역에서 한번만 데이터를 선언하고 가져와 사용하면 효율적이기 때문이다.
이러한 개념을 그대로 클래스에 대입한 것이 싱글톤 패턴이라고 이해하면 된다.
따라서 보통 싱글톤 패턴이 적용된 객체가 필요한 경우는 그 객체가 리소스를 많이 차지하는 역할을 하는 무거운 클래스일때 적합하다.
대표적으로 데이터베이스 연결 모듈을 예로 들 수 있는데, 데이터베이스에 접속하는 작업(I/O 바운드)은 그 자체로 무거운 작업에 속하며 또한 한번만 객체를 생성하고 돌려쓰면 되지 굳이 여러번 생성할 필요가 없기 때문이다.
이밖에도 디스크 연결, 네트워크 통신, DBCP 커넥션풀, 스레드풀, 캐시, 로그 기록 객체 등에 이용된다.
이러한 객체들은 또 새로 만들어서 사용될 일도 없거니와 사용해도 리소스 낭비일 뿐이다. 따라서 어플리케이션에서 유일해야 하며 유일한 것이 좋은 것을 싱글톤 객체로 만들면 된다고 보면 된다.
💡TIP
실제로 안드로이드 스튜디오 자바 SDK에서 각 액티비티 들이나, 클래스마다 주요 클래스들을 하나하나 전달하는게 번거롭기 때문에 싱글톤 클래스를 만들어 어디서든 접근하도록 설계되었다.
싱글톤은 생성 패턴(Creational Pattern) 중 하나다.
- 생성 패턴은 인스턴스를 만드는 절차를 추상화하는 패턴이다.
- 생성 패턴에 속하는 패턴들은 객체를 생성, 합성하는 방법이나 객체의 표현 방법을 시스템과 분리시켜준다.
- 생성 패턴은 시스템이 상속(inheritance) 보다 복합(composite) 방법을 사용하는 방향으로 진화되어 가면서 더 중요해지고 있다.
생성 패턴에서는 중요한 이슈가 두 가지 있다.
- 생성 패턴은 시스템이 어떤 Concrete Class를 사용하는지에 대한 정보를 캡슐화한다.
- 생성 패턴은 이들 클래스의 인스턴스들이 어떻게 만들고 어떻게 결합하는지에 대한 부분을 완전히 가려준다.
쉬운 말로 정리 하자면, 생성 패턴을 이용하면 무엇이 생성되고, 누가 이것을 생성하며, 이것이 어떻게 생성되는지, 언제 생성할 것인지 결정하는 데 유연성을 확보할 수 있게 된다.
싱글톤 패턴 구현 원리
클래스에 싱글톤 패턴을 적용하는 것은 간단하다
싱글톤으로 이용할 클래스를 외부에서 new 생성자를 통해 인스턴스화 하는 것을 제한하기 위해 클래스 생성자 메서드에 private 키워드를 붙여주면 된다.
그리고 위 그림에서 볼 수 있듯이 getInstance() 라는 메서드에 생성자 초기화를 해주어, 만일 클라이언트가 싱글톤 클래스를 생성해서 사용하려면 getInstance() 라는 메서드 실행을 통해 instance 필드 변수가 null 일경우 초기화를 진행하고 null이 아닐경우 이미 생성된 객체를 반환하는 식으로 구성하면 된다.
다음은 싱글톤으로 구성된 클래스를 외부에서 불러오는 예제이다.
정적 메소드로 getInstance()를 통해 객체를 불러와 변수에 저장하고 이를 출력해보면 똑같은 객체 주소를 가지고 있는 걸 볼 수 있다.
즉, 객체 하나만 생성하고 여러 변수에 불러와도 돌려쓰기를 한 것이다.
public class Main {
public static void main(String[] args) {
// Singleton.getInstance() 를 통해 싱글톤 객체를 각기 변수마다 받아와도 똑같은 객체 주소를 가리킴
Singleton i1 = Singleton.getInstance();
Singleton i2 = Singleton.getInstance();
Singleton i3 = Singleton.getInstance();
System.out.println(i1.toString()); // Singleton@1b6d3586
System.out.println(i2.toString()); // Singleton@1b6d3586
System.out.println(i3.toString()); // Singleton@1b6d3586
System.out.println(i1 == i2); // true
}
}
💡TIP
그렇다면 그냥 평범한 클래스 만들고 한번만 인스턴화화 한뒤 사용안하면 싱글톤 패턴과 무슨 차이가 있느냐 싶겠지만, 본래 개발자는 사람이고 사람은 항상 실수를 번복하는 생물이라, 이러한 문법적인 법률을 통해 구조적으로 제한하는 것으로 이해하면 된다.
싱글톤 패턴 구현 기법 종류
지금부터 자바 코드를 예제로 들어 싱글톤 패턴 구현 기법에 대해 알아보는 것이다.
어떠한 목적을 구현하기 위해 코드 패턴이라는 것이 꼭 한가지만 있는 것은 아니다. 여러가지 코드 기법들이 존재하여 이들 중 가장 최적화된 패턴을 상황에 맞게 사용하는 것이 핵심이다.
다음은 싱글톤 패턴을 구현하는 코드 기법들이다.
총 7가지가 있으며 이들은 모두 싱글톤을 지향한다는 점에서는 같지만 각기 코드 패턴마다 장단점이 존재한다. 각 순서마다 1번 부터 조금씩 단점을 보완하는 식으로 흘러가는 것으로 보면 된다.
- Eager Initialization
- Static block initialization
- Lazy initialization
- Thread safe initialization
- Double-Checked Locking
- Bill Pugh Solution
- Enum 이용
만일 지금 당장 사용할 검증된 싱글톤 코드 패턴을 봐야한다면, Bill Pugh Solution과 Enum 이용 예제만 보면 된다.
다만 우리가 전공에서 컴퓨터의 역사부터 차근차근 배우는 이유와 같이, 하나의 목적을 이루고자 탄생한 다양한 자바의 코드 패턴들을 보며 순서대로 코드들이 최적화되고 발전되어져 가는 과정을 차근차근 학습한다면, 나중에 코드를 설계할때 이러한 경험들이 분명 도움이 될 것이다.
1. Eager Initialization (단일 쓰레드)
- 한번만 미리 만들어두는, 가장 직관적이면서도 심플한 기법, 이는 싱글톤 클래스의 인스턴스를 클래스 로딩 단계에서 생성하는 방법이다.
- static final 이라 멀티 쓰레드 환경에서도 안전함
- 그러나 static 멤버는 당장 객체를 사용하지 않더라도 메모리에 적재하기 때문에 만일 리소스가 큰 객체일 경우, 공간 자원 낭비가 발생함
- 예외 처리를 할 수 없음
💡TIP
만일 싱글톤을 적용한 객체가 그리 크지 않은 객체라면 이 기법으로 적용해도 무리는 없다.
class Singleton {
// 싱글톤 클래스 객체를 담을 인스턴스 변수
private static final Singleton INSTANCE = new Singleton();
// 생성자를 private로 선언 (외부에서 new 사용 X)
private Singleton() {}
public static Singleton getInstance() {
return INSTANCE;
}
}
이 방법을 사용할 때는 싱글톤 클래스가 다소 적은 리소스를 다룰 때여야 한다.
File System, Database Connection 등 큰 리소스들을 다루는 싱글톤을 구현할 때는 위와 같은 방식보다는 getInstance() 메소드가 호출될 때까지 싱글톤 인스턴스를 생성하지 않는 것이 더 좋다.
게다가 Eager Initializaion은 Exception에 대한 Handling도 제공하지 않는다.
2. Static Block Initialization
- Static Block Initialization은 1번에서 살펴본 Eager Initialization과 유사함.
- static block을 통해서 Exception Handling에 대한 옵션 제공이 가능
- 그러나 여전히 static 의 특성으로 인해 사용하지도 않는데도 공간을 차지함
※ 참고
static block : 클래스가 로딩되고 클래스 변수가 준비된 후 자동으로 실행되는 블록
class Singleton {
// 싱글톤 클래스 객체를 담을 인스턴스 변수
private static Singleton instance;
// 생성자를 private로 선언 (외부에서 new 사용 X)
private Singleton() {}
// static 블록을 이용해 예외 처리
static {
try {
instance = new Singleton();
} catch (Exception e) {
throw new RuntimeException("싱글톤 객체 생성 오류");
}
}
public static Singleton getInstance() {
return instance;
}
}
위와 같이 구현할 경우 싱글톤 클래스의 인스턴스를 생성할 때 발생할 수 있는 예외에 대한 처리를 할 수 있지만, Eager Initialization과 마찬가지로 클래스 로딩 단계에서 인스턴스를 생성하기 때문에 여전히 큰 리소스를 다루는 경우에는 적합하지 않게 된다.
💡TIP
방법 1, 2번은 정적(static) 멤버나 블록을 사용하는 방법이다. 이는 런타임이 아니라 최초에 JVM이 클래스를 로딩할 때 모든 클래스들을 로드하면서 미리 인스턴스를 생성하는데 사용되는 방법이다.
클래스 로딩과 동시에 싱글톤 인스턴스를 만들기 때문에 모듈들은 싱글톤 인스턴스를 요청할 때 그냥 만들어둔 인스턴스를 반환받으면 되는 것이다.
이는 불필요한 자원낭비라는 문제점이 있다. 싱글톤 인스턴스가 필요없는 경우도 무조건 싱글톤 클래스를 호출해 인스턴스를 만들어야 하기 때문이다.
3. Lazy Initialization
- 객체 생성에 대한 관리를 내부적으로 처리
- 메서드를 호출했을 때 인스턴스 변수의 null 유무에 따라 초기화 하거나 있는 걸 반환하는 기법(나중에 초기화)
이는 global access 한 getInstance() 메소드를 호출할 때에 인스턴스가 없다면 생성한다. - 위의 미사용 고정 메모리 차지의 한계점을 극복
- 그러나 쓰레드 세이프(Thread Safe) 하지 않는 치명적인 단점을 가지고 있음 (아래 설명)
class Singleton {
// 싱글톤 클래스 객체를 담을 인스턴스 변수
private static Singleton instance;
// 생성자를 private로 선언 (외부에서 new 사용 X)
private Singleton() {}
// 외부에서 정적 메서드를 호출하면 그제서야 초기화 진행 (lazy)
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton(); // 오직 1개의 객체만 생성
}
return instance;
}
}
⚠️ 멀티 쓰레드 환경에서의 치명적인 문제점
이 방식으로 구현할 경우 1, 2번에서 안고 있던 문제(사용하지 않았을 경우에는 인스턴스가 낭비)에 대해 어느 정도 해결책이 된다.
그러나 이 경우에는 치명적인 문제점을 가지고 있다. 자바는 멀티 쓰레드 언어인데, 이 멀티 쓰레드 환경에서 쓰레드 세이프 하지 않다는 것이다.
즉, 원자성이 결여되어 있기 때문에 multi-thread 환경에서의 동기화 문제다.
각 스레드는 자신의 실행단위를 기억하면서 코드를 위에서 아래로 읽어간다. 따라서 다음과 같은 동시성으로 인한 코드 실행 문제점이 발생 할 수 있게 된다.
- 스레드 A, 스레드 B 가 존재한다고 가정한다.
- 스레드 A가 if문을 평가하고 인스턴스 생성 코드로 진입하였다. (아직 초기화 진행 X)
- 그런데 그때 스레드 B가 if문을 평가한다. 아직 스레드 A가 인스턴스화 코드를 실행을 안시켰기 때문에 이 if문도 참(True)이 되게 된다.
- 그러면 결과적으로 스레드 A와 B가 인스턴스 초기화 코드를 두번 실행하는 꼴이 된다. (원자성이 결여)
이론만 보는 것보다 직접 코드를 실행하는 것이 머릿속에 더 잘 들어온다.
다음은 여러개의 쓰레드를 생성하고 싱글톤 클래스를 get하여 정말로 이 객체가 유일한 객체인지 해시코드로 판별하는 코드이다.
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
// 싱글톤 객체
class Singleton {
private static Singleton instance;
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton(); // 오직 1개의 객체만 생성
}
return instance;
}
}
public class Main {
public static void main(String[] args) {
// 1. 싱글톤 객체를 담을 배열
Singleton[] singleton = new Singleton[10];
// 2. 스레드 풀 생성
ExecutorService service = Executors.newCachedThreadPool();
// 3. 반복문을 통해 10개의 스레드가 동시에 인스턴스 생성
for (int i = 0; i < 10; i++) {
final int num = i;
service.submit(() -> {
singleton[num] = Singleton.getInstance();
});
}
// 4. 종료
service.shutdown();
// 5. 싱글톤 객체 주소 출력
for(Singleton s : singleton) {
System.out.println(s.toString());
}
}
}
위의 결과 처럼 싱글톤 클래스인데 객체 두개가 만들어져 버리게 된다.
이처럼 인스턴스가 생성되지 않은 시점에서 여러 쓰레드가 동시에 getInstance()를 호출한다면 예상치 못한 결과를 얻을 수 있을뿐더러, 단 하나의 인스턴스를 생성한다는 싱글톤 패턴에 위반하는 문제점이 야기될 수 있다.
그렇기에 이 방법으로 구현을 해도 괜찮은 경우는 single-thread 환경이 보장됐을 때이다.
4. Thread Safe Singleton
Thread Safe Singleton은 3번의 문제를 해결하기 위한 방법으로, getInstance() 메소드에 synchronized를 걸어두는 방식이다.
- synchronized 키워드를 통해 메서드에 쓰레드들을 하나하나씩 접근하게 하도록 설정한다. (동기화)
- 하지만 여러개의 모듈들이 매번 객체를 가져올 때 synchronized 메서드를 매번 호출하여 동기화 처리 작업에 overhead가 발생해 성능 하락이 발생한다.
synchronized
synchronized 키워드는 멀티 쓰레드 환경에서 두개 이상의 쓰레드가 하나의 변수에 동시에 접근을 할 때 Race condition(경쟁상태)이 발생하지 않도록 한다.
한마디로 쓰레드가 해당 메서드를 실행하는 동안 다른 쓰레드가 접근하지 못하도록 잠금(lock)을 거는 것으로 보면 된다.
아래 그림 처럼 thread-1이 메서드에 진입하는 순간 나머지 thread-2 ~ 4의 접근을 제한하고, thread-1이 완료가 되면 다음 스레드를 접근시킨다.
synchronized 키워드에 대해 더 자세히 알고 싶다면 해당 블로그 참고.
코드는 아래와 같다.
class Singleton {
private static Singleton instance;
private Singleton() {}
// synchronized 메서드
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
위와 같은 방식으로 구현한다면 getInstance() 메소드 내에 진입하는 쓰레드가 하나로 보장받기 때문에 멀티 쓰레드 환경에서도 정상 동작하게 된다. 그러나 synchronized 키워드 자체에 대한 비용이 크기 때문에 싱글톤 인스턴스 호출이 잦은 어플리케이션에서는 성능이 떨어지게 된다. 즉, getInstance() 메서드를 호출할 때마다 lock이 걸려 성능 저하가 된다는 것이다.
5. Double Checked Locking(DCL, 이중 확인 잠금)
그래서 고안된 방식이 double checked locking 이다.
이는 인스턴스 생성 여부를 싱글톤 패턴 잠금 전에 한번, 객체를 생성하기 전에 한번 총 2번 체크하면서 getInstance() 메소드 수준에 lock을 걸지 않고 instance가 null일 경우에만 synchronized가 동작하도록 한다.
- 매번 synchronized 동기화를 실행하는 것이 문제라면, 최초 초기화할때만 적용하고 이미 만들어진 인스턴스를 반환할때는 사용하지 않도록 하는 기법
- 이때 인스턴스 필드에 volatile 키워드를 붙여주어야 I/O 불일치 문제를 해결 할 수 있다.
- 그러나 volatile 키워드를 이용하기위해선 JVM 1.5이상이어야 되고, JVM에 대한 심층적인 이해가 필요하여, JVM에 따라서 여전히 쓰레드 세이프 하지 않는 경우가 발생하기 때문에 사용하기를 지양하는 편이다.
하지만 이는 앞서 생겼던 문제점인 lock이 지속적으로 발생하여 성능 저하 시킨다는 문제점을 해결해준다.
코드는 아래와 같다.
class Singleton {
private static volatile Singleton instance; // volatile 키워드 적용
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
// 메서드에 동기화 거는게 아닌, Singleton 클래스 자체를 동기화 걸어버림
synchronized (Singleton.class) {
if(instance == null) {
instance = new Singleton(); // 최초 초기화만 동기화 작업이 일어나서 리소스 낭비를 최소화
}
}
}
return instance; // 최초 초기화가 되면 앞으로 생성된 인스턴스만 반환
}
}
[ volatile 키워드 ]
근데 여기서 instance라는 변수에 volatile 키워드를 사용한 것을 확인할 수 있다.
아래 글을 확인하자.
volatile에 대해 알기 위해서는 메모리 구조를 살펴볼 필요가 있다.
메모리 구조는 다음과 같다. 메인 메모리(RAM) 위에 CPU 캐시 메모리라고 불리는 L1, L2, L3 캐시가 있다.
(L4도 드물긴 하지만 L4까지 CPU 캐시 메모리라고 부른다.)
Java에서는 쓰레드를 여러개 사용할 경우, 성능을 위해서 각각의 쓰레드들은 변수를 메인 메모리(RAM)으로부터 가져오는 것이 아니라 캐시(Cache) 메모리에서 가져오게 된다.
문제는 비동기로 변수값을 캐시에 저장하다가, 각 쓰레드마다 할당되어있는 캐시 메모리의 변수값이 일치하지 않을수 있다는 점이다.
그래서 volatile 키워드를 통해 이 변수는 캐시에서 읽지 말고 메인 메모리에서 읽어오도록 지정해주는 것이다.
public class Test {
boolean flag = true;
public void test() {
new Thread(()->{
int cnt = 0;
while (flag) {
cnt++;
}
System.out.println("Thread1 finished\n");
}
).start();
new Thread(()-> {
try {
Thread.sleep(100);
} catch (InterruptedException ignored) {
}
System.out.println("flag to false");
flag = false;
}
).start();
}
public static void main(String[] args) {
new Test().test();
}
}
위 코드 예시를 보면 flag 을 각 쓰레드가 공유해서 사용될거 같지만 사실상 각각의 캐시 메모리에서 가져오기 때문에 공유해서 사용하지 않는다.
근데 여기서 volatile 키워드를 추가하게 되면 메인 메모리(RAM)를 기반으로 저장하고 읽어오기 때문에 이 문제를 해결할 수 있다.
6. Bill Pugh Singleton Implementaion
- 권장되는 두가지 방법중 하나
- 멀티쓰레드 환경에서 안전하고 Lazy Loading(나중에 객체 생성) 도 가능한 완벽한 싱글톤 기법
- 클래스 안에 내부 클래스(holder)를 두어 JVM의 클래스 로더 매커니즘과 클래스가 로드되는 시점을 이용한 방법 (스레드 세이프함)
- static 메소드에서는 static 멤버만을 호출할 수 있기 때문에 내부 클래스를 static으로 설정
이밖에도 내부 클래스의 치명적인 문제점인 메모리 누수 문제를 해결하기 위하여 내부 클래스를 static으로 설정 - 다만 클라이언트가 임의로 싱글톤을 파괴할 수 있다는 단점을 지님 (Reflection API, 직렬화/역직렬화를 통해)
class Singleton {
private Singleton() {}
// static 내부 클래스를 이용
// Holder로 만들어, 클래스가 메모리에 로드되지 않고 getInstance 메서드가 호출되어야 로드됨
private static class SingleInstanceHolder {
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance() {
return SingleInstanceHolder.INSTANCE;
}
}
- 우선 내부클래스를 static으로 선언하였기 때문에, 싱글톤 클래스가 초기화되어도 SingleInstanceHolder 내부 클래스는 메모리에 로드되지 않음
- 어떠한 모듈에서 getInstance() 메서드를 호출할 때, SingleInstanceHolder 내부 클래스의 static 멤버를 가져와 리턴하게 되는데, 이때 내부 클래스가 한번만 초기화되면서 싱글톤 객체를 최초로 생성 및 리턴하게 된다.
- 마지막으로 final 로 지정함으로서 다시 값이 할당되지 않도록 방지한다.
만일 위의 코드 흐름 패턴에 대해 전혀 감을 못잡는 것이라면, JVM의 Class Loader의 클래스 로딩 및 초기화 과정을 아직 잘 모르는 것이다.
자바 코드를 실행하면 클래스가 어떠한 순서대로 로드되고 초기화 되는지, 그리고 내부 클래스를 쓰는데 왜 하필 static 키워드를 붙인 내부 클래스여야 하는지, 그 원리에 대해서 깊이 알고 싶다면 다음 포스팅들을 참고하길 바란다.
7. Enum Singleton
- 권장되는 두가지 방법중 하나
- enum은 애초에 멤버를 만들때 private로 만들고 한번만 초기화 하기 때문에 thread safe함.
- enum 내에서 상수 뿐만 아니라, 변수나 메서드를 선언해 사용이 가능하기 때문에, 이를 이용해 싱글톤 클래스 처럼 응용이 가능
- 위의 Bill Pugh Solution 기법과 달리, 클라이언트에서 Reflection을 통한 공격에도 안전
- 하지만 만일 싱글톤 클래스를 멀티톤(일반적인 클래스)로 마이그레이션 해야할때 처음부터 코드를 다시 짜야 되는 단점이 존재한다. (개발 스펙은 언제어디서 변경 될수 있기 때문에)
- 클래스 상속이 필요할때, enum 외의 클래스 상속은 불가능하다.
enum SingletonEnum {
INSTANCE;
private final Client dbClient;
SingletonEnum() {
dbClient = Database.getClient();
}
public static SingletonEnum getInstance() {
return INSTANCE;
}
public Client getClient() {
return dbClient;
}
}
public class Main {
public static void main(String[] args) {
SingletonEnum singleton = SingletonEnum.getInstance();
singleton.getClient();
}
}
마지막 7번 방법은 이펙티브 자바를 쓴 조슈아 블로크(Joshua Bloch)가 추천한 방법이다.
A single-element enum type is the best way to implement a singleton
- Joshua Bloch, Effective Java 2nd Edition p.18 -
최종 정리하자면, 싱글톤 패턴 클래스를 만들기 위해서는 Bill Pugh Solution 기법을 사용하거나 Enum으로 만들어 사용하면 된다.
다만, 이 둘의 사용 선택은 자신의 싱글톤 클래스의 목적에 따라 갈리게 된다고 보면 된다.
- LaszHolder : 성능이 중요시 되는 환경
- Enum : 직렬화, 안정성 중요시 되는 환경
싱글톤 패턴은 안티 패턴?
이처럼 싱글톤 클래스는 고정된 메모리 영역을 가지고 하나의 인스턴스만 사용하기 때문에 메모리 낭비 방지할 수 있으며, DBCP(DataBase Connection Pool)처럼 공통된 객체를 여러개 생성해야 하는 상황에서 많이 사용된다.
하지만 싱글톤 패턴은 얻는 이점과 더불어 많은 문제점들을 수반하기 때문에 trade-off를 잘 고려해야 한다.
싱글톤의 문제점
1. 모듈간 의존성이 높아진다.
대부분의 싱글톤을 이용하는 경우 인터페이스가 아닌 클래스의 객체를 미리 생성하고 정적 메소드를 이용해 사용하기 때문에 클래스 사이에 강한 의존성과 높은 결합이 생기게 된다.
즉, 하나의 싱글톤 클래스를 여러 모듈들이 공유를 하니까, 만일 싱글톤의 인스턴스가 변경되면 이를 참조하는 모듈들도 수정이 필요하게 된다.
또한 클라이언트 코드에서 너무 많은 곳에서 사용하면 클래스간의 결합도가 높아져 오히려 패턴을 사용 안하느니만 못하게 될 수도 있다.
2. S.O.L.I.D 원칙에 위배되는 사례가 많다.
우선 싱글톤 인스턴스 자체가 하나만 생성하기 때문에 여러가지 책임을 지니게 되는 경우가 많아 단일 책임 원칙(SRP)를 위반하기도 하고, 싱글톤 인스턴스가 혼자 너무 많은 일을 하거나, 많은 데이터를 공유시키면 다른 클래스들 간의 결합도가 높아지게 되어 개방-폐쇄 원칙(OCP)에도 위배된다.
그리고 의존 관계상 클라이언트가 인터페이스와 같은 추상화가 아닌, 구체 클래스에 의존하게 되어 의존 역전 원칙(DIP)도 위반하게 된다.
따라서 싱글톤 인스턴스를 너무 많은 곳에서 사용할 경우 잘못된 디자인 형태가 될 수도 있다.
그래서 싱글톤 패턴을 객제 지향 프로그래밍의 안티 패턴이라고 불리기도 한다.
3. TDD 단위 테스트에 애로사항이 있음
마지막으로 싱글 클래스를 사용하는 모듈을 테스트하기 어렵다는 것이다.
단위 테스트를 할때, 단위 테스트는 테스트가 서로 독립적이어야 하며 테스트를 어떤 순서로든 실행 할 수 있어야 하는데, 싱글톤 인스턴스는 자원을 공유하고 있기 때문에, 테스트가 결함없이 수행되려면 매번 인스턴스의 상태를 초기화시켜주어야 한다. 그렇지 않으면 어플리케이션 전역에서 상태를 공유하기 때문에 테스트가 온전하게 수행되지 못할 수도 있다.
많은 테스트 프레임워크가 Mock 객체를 생성할 때 상속에 의존하기 때문에 싱글의 클라이언트 코드를 테스트하기 어렵다.
이처럼 싱글톤 기법은 오직 한 개의 인스턴스 생성을 보증하여 효율을 찾을 수 있지만, 그에 못지많게 수반되는 문제점도 많다.
결과적으로 이러한 문제들을 안고있는 싱글톤 패턴은 유연성이 많이 떨어지는 패턴이라고 할 수 있다.
그래서 직접 유저가 만들어 사용하는 것 보다는, 스프링 컨테이너 같은 프레임워크의 도움을 받으면 싱글톤 패턴의 문제점들을 보완하면서 장점의 혜택을 누릴 수 있다.
스프링 프레임워크에서는 싱글톤 패턴이란게 없고 내부적으로 클래스의 제어를 IoC(Inversion Of Control) 방식의 컨테이너에게 넘겨 컨테이너가 관리하기 때문에, 이를 통해 평범한 객체도 하나의 인스턴스 뿐인 싱글턴으로 존재가 가능하기 때문에 싱글톤 단점이 없다.
따라서 만일 프레임워크 도움 없이 싱글톤 패턴을 적용하고 싶다면, 위에서 살펴본 장단점을 잘 고려하여 사용하는 것이 좋을 것이다.
Singleton 패턴의 장점
- 다른 모든 클래스에서 접근 할 수 있다.
- 인스턴스가 하나만 생성됨이 보장된다.
- 메모리 낭비를 방지할 수 있다.
- Lazy initialization(게으르게 생성) 하여 구현 될 수 있다.
- 인스턴스의 개수를 변경하기가 자유롭다.
위의 2가지 장점이 Singleton 패턴을 사용하는 주된 이유이자 Singleton 패턴 적용시 꼭 지켜야 할 점이다.
게으르게 생성할 수 있다는 것은, 프로그램 실행 중에 인스턴스를 생성 할 수 있다는 것이다.
이는 Singleton 패턴을 적용하지 않고 전역변수를 사용 했을때에 비해서 장점인데, 전역변수를 사용하면 프로그램 실행시에 인스턴스를 바로 생성해야한다.
이렇게 할 때 문제점은, 생성된 인스턴스가 프로그램이 실행 되는 동안 한번도 사용되지 않을 경우 자원이 낭비된다.
그렇기 때문에 게으르게 생성되는 것이 Singleton의 장점이라고 할 수 있다.
인스턴스의 개수를 변경하기가 자유롭다는 말이 참 혼란스럽다. 싱글톤 패턴은 클래스의 인스턴스가 유일함을 보장 할 때만 사용해야 하는데, GoF의 디자인 패턴 책에서는 인스턴스의 갯수 변경이 자유롭다는 것을 장점이라고 설명한다.
물론 싱글톤 인스턴스에 대한 레퍼런스를 배열이나 링크드 리스트 등을 사용해서 구현하면 여러개의 인스턴스를 가지는 싱글톤 클래스를 구현 할 수는 있다.
싱글톤 패턴은, 여러개의 인스턴스를 가지는 싱글톤 클래스를 구현 할 수 있긴 하지만, 기본적으로는 한개의 인스턴스만이 존재해야 할 때 적용해야한다고 생각하면 될 듯하다.
Singleton 패턴의 단점
- 단일 책임의 원칙을 어긴다.
- 전역변수보다 사용하기가 불편하다.
- Singleton 클래스에 대한 의존도가 높아진다. 이 때문에 객체 지향 설계 원칙에 어긋날 수 있다.
- 싱글톤 클래스에 대한 서브클래스를 만들기 어려워진다.
- 멀티 스레드 적용 시 동기화 문제가 생길 수 있다.
- 객체의 파괴 시점을 컨트롤하기 어려울 수 있다.
단일 책임의 원칙은 모든 클래스가 하나의 책임만을 가져야 한다는 원칙이다.
싱글톤 클래스는 클래스의 작업을 처리하는 역할 뿐 아니라 자신의 인스턴스에 대한 접근을 관리하는 역할에 대해서도 책임을 져야한다.
이렇게 하는게 전체적인 설계를 간단하게 만들 수 있어서 장점이기도 하지만, 단일 책임의 원칙을 어긴다는 점도 고려해 봐야한다.
싱글톤 패턴을 적용하면 여러 클래스나 컴포넌트가 싱글톤 클래스에 의존하게 된다. 이는 시스템의 Coupling(결합도)를 높이는 결과를 가져오기 때문에 주의해야한다.
싱글톤 클래스는 생성자가 private으로 지정되기 때문에 상속이 불가능 한데, 이를 protected나 public으로 고치면 다른 클래스에서 인스턴스를 만들 수 있게 되서 더 이상 싱글톤이라고 할 수 없게 된다.
생성자 문제 뿐만 아니라, 싱글톤 클래스는 static 변수를 바탕으로 하기 때문에 모든 서브클래스들이 그 변수를 공유하게 된다.
이를 해결하기 위해서는 '레지스트리'를 구현해두어야 한다.
멀티 스레드를 적용하면 여러 스레드에서 한개의 싱글톤 클래스에 접근하게 된다. 이는 동기화 문제를 발생시킬 수 있으니 volatile 키워드를 통해 Double Checking Locking(DCL)을 적용하거나 getInstance() 메소드에 synchronized 키워드를 적용하여 동기화 해야한다.
그런데 synchronized 키워드를 적용하면 스레드에서 getInstance() 할 때마다 속도가 느려지는 문제가 발생한다.
다른 패턴과의 관계
- 대부분의 경우 하나의 퍼사드 객체만 있어도 충분하므로 퍼사드 패턴의 클래스는 종종 싱글턴으로 변환될 수 있다.
- 만약 객체들의 공유된 상태들을 단 하나의 플라이웨이트 객체로 줄일 수 있다면 플라이웨이트는 싱글턴과 유사해질 수 있다. 그러나 이 패턴들에는 두 가지 근본적인 차이점이 있다
- 싱글턴은 인스턴스가 하나만 있어야 한다. 반면에 플라이웨이트 클래스는 여러 고유한 상태를 가진 여러 인스턴스를 포함할 수 있다.
- 싱글턴 객체는 변할 수 있다 (mutable). 플라이웨이트 객체들은 변할 수 없다 (immutable).
- 추상 팩토리, 빌더 및 프로토타입은 모두 싱글턴으로 구현할 수 있습니다.
참고
- https://invincibletyphoon.tistory.com/12
- https://refactoring.guru/ko/design-patterns/singleton
- https://coding-factory.tistory.com/709
- https://readystory.tistory.com/116
- https://refactoring.guru/ko/design-patterns/singleton
- http://www.btechsmartclass.com/java/java-threads-synchronization.html
- https://www.youtube.com/watch?v=5oUdqn7WeP0
- https://www.youtube.com/watch?v=bHRETd1rFfc
- https://youtu.be/3rfbnQYOCFA