[디자인 패턴] 싱글톤 패턴
책 ‘면접을 위한 CS 전공노트’ 중에서 공부하고 싶었던 부분에 대해 요약 정리한 글이다. 이번에는 디자인 패턴 중에 DB 모듈 클래스에 많이 쓰이는 싱글톤 패턴을 깊게 보고, 라이브러리, 프레임워크에 대한 용어 정리를 간단하게 하고자 한다.
0. 사전지식
라이브러리
- 공통으로 사용될 수 있는 특정한 기능들을 모듈화한 것.
- 폴더명, 파일명과 같은 규칙이 적고, 직접 컨트롤이 가능하다.
- 프레임워크에 비해 자유롭다.
- ex) 종이를 자를 때, 가위를 이용해 ‘자유롭게’ 종이를 원하는 모양대로 자를 수 있는 느낌과 비슷하다.
프레임워크
- 공통으로 사용될 수 있는 특정한 기능들을 모듈화한 것.
- 폴더명, 파일명과 같은 규칙이 세세하게 정해져있고, 라이브러리에 비해 엄격하다.
- ex) 여행할 때 비행기가 갈 곳까지 알아서 데려다주고 나는 ‘이용’하는 느낌과 비슷하다.
디자인패턴
프로그램을 설계할 때 발생했던 문제점을 객체 간의 상호 관계 등을 이용하여 해결할 수 있도록 ‘규약’ 형태로 만들어 놓은 것.
1.1 싱글톤 패턴
1.1.1 소개
싱글톤 패턴은 하나의 클래스에 대해 오직 하나의 인스턴스만 가지는 디자인 패턴이다.
커넥션 풀이나 스레드 풀과 같이 객체 생성 자체에 비용이 많이 드는 객체들을 여러 개 생성하게 되면 불필요한 자원 낭비로 이루어질 수 있기 때문에 이와 같은 경우에 싱글톤 패턴을 사용한다.
싱글톤 패턴으로 객체를 설계할 때에 지켜야할 규칙이 있다.
- 생성자를
private
접근자로 선언해야한다. - 유일함이 보장된 단일 객체를 반환하기 위해
static
메소드가 필요하다. - 유일한 단일 객체를 참조할 수 있는
static
참조 변수가 필요하다.
그리고 싱글톤 패턴의 특징으로는
- 일반적으로 DB 연결 모듈에서 사용된다.
- 하나의 클래스 기준으로 단 하나의 인스턴스만 생성되므로 인스턴스 생성 시에 드는 비용이 감소되는 장점이 있다.
- 하지만 클래스 별로 의존성이 높아지는 단점이 있다.
- 독립적인 인스턴스를 만들기 어렵다.
- 독립적인 인스턴스를 만들기 어려우므로 TDD 시에 걸림돌이 된다.
- 독립적인 인스턴스를 만들기 어렵다.
싱글톤 패턴의 예시 실행 코드이다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
class Singleton{
private static Singleton singletonInstance;
private Singleton(){}
public static Singleton getInstance(){
if(singletonInstance == null){
singletonInstance = new Singleton();
}
return singletonInstance;
}
}
class Main{
public static void main(String [] args){
Singleton instance1 = Singleton.getInstance();
Singleton instance2 = Singleton.getInstance();
System.out.println(instance1 == instance2); //true
System.out.println(instance1.hashCode() == instance2.hashCode()); //true
}
}
생성자 private
접근자, getInstance()
정적 메소드를 통해 기존에 생성된 인스턴스를 반환하는 형태이고, Singleton 객체가 하나의 인스턴스임이 보장되는 것 같지만, 멀티 스레드 환경일 때 보장이 되지 않는다.
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
class Singleton{
private static Singleton singletonInstance;
private Singleton(){}
public static Singleton getInstance(){
if(singletonInstance == null){
singletonInstance = new Singleton();
}
return singletonInstance;
}
}
class Main{
public static void main(String [] args){
Runnable task = () -> {
Singleton instance = Singleton.getInstance();
System.out.println("instance hashCode: " + instance.hashCode());
};
Thread thread1 = new Thread(task);
Thread thread2 = new Thread(task);
thread1.start();
thread2.start();
}
}
출력
instance hashCode: 814045373
instance hashCode: 1714102148
위와 같이 멀티 스레드 환경일 때 if(singletonInstance == null)
구문이 동시에 통과하게 되면 단 하나의 인스턴스가 보장되지 않는 문제점이 있다. 그러므로 thread-safe한 싱글톤 패턴을 구현해야한다.
1.2 싱글톤 패턴 객체 초기화와 문제점 보완 방법
객체의 초기화 시기는 Eager Initialization(즉시 초기화)와 Lazy Initialization(지연 초기화)로 나뉜다.
Eager Initialization은 어플리케이션 시작과 동시에 JVM에 의해 객체가 생성되는 것을 의미하고, Lazy Initialization은 싱글톤 객체가 필요한 경우에 객체 생성을 진행한다. 차이점은 객체 생성(초기화) 시점을 즉시로 하느냐, 최대한 미루느냐이다.
1.2.1 Eager Initialization
1
2
3
4
5
6
7
8
9
class Singleton{
private static final Singleton singletonInstance = new Singleton();
private Singleton(){}
public static Singleton getInstance(){
return singletonInstance;
}
}
클래스 내에서 즉시 new
연산자를 통해 즉시 인스턴스를 생성하고 있다.
getInstance()
메소드는 단순히 인스턴스를 리턴해주는 역할만 한다.
이와 같은 즉시 초기화의 장단점은
- 장점
- 구현이 간단하다.
- 단점
- 클래스의 인스턴스가 사용되지 않을 경우에는 자원 낭비로 이어진다.
- 이미 생성된 인스턴스에 대해 예외 처리가 불가능하다.
1.2.2 Lazy Initialization
1
2
3
4
5
6
7
8
9
10
11
12
class Singleton{
private static Singleton singletonInstance;
private Singleton(){}
public static Singleton getInstance(){
if(singletonInstance == null){
singletonInstance = new Singleton();
}
return singletonInstance;
}
}
맨 위의 예시 코드와 같다.
지연 초기화의 장단점은
- 장점
- 필요한 경우에만 객체가 생성되므로 자원 낭비가 없다.
getInstance()
메소드에서 객체 생성 전에 예외 처리를 할 수 있다.
- 단점
- null 체크를 반드시 해줘야한다.
- 인스턴스에 직접 접근이 불가능하다.
- 멀티 스레드 환경에서는 싱글톤 속성을 깨뜨릴 수 있다.
자원 낭비의 단점을 보완하기 위해 Lazy Initialization 방법을 채택했지만 위 예시에서 멀티 스레드 환경에서의 싱글톤 패턴의 문제점을 알 수 있었다. 단 하나의 인스턴스가 보장이 되지 않는다는 문제점이 있었다. 이를 개선하기 위한 방법을 단계적으로 적용해보고자 한다.
1.2.3 synchronized Singleton
1
2
3
4
5
6
7
8
9
10
11
12
class Singleton{
private static Singleton singletonInstance;
private Singleton(){}
synchronized public static Singleton getInstance(){
if(singletonInstance == null){
singletonInstance = new Singleton();
}
return singletonInstance;
}
}
synchronized
키워드를 통해 getInstance()
메소드의 다른 스레드 접근을 막을 수 있었다. 하지만 synchronized
키워드를 전체 메소드 앞에 붙인다면 간단히 문제점을 해결할 수 있지만 객체 생성 부분만 다른 스레드 접근을 막으면 되는데 반환하는 부분까지 동기화가 되는 성능의 문제가 발생할 수 있다.
이 방법을 해결하기 위해 synchronized
범위를 최소한으로 줄이고 싱글톤 instance의 null 검사를 두 번하는 Double Checked Locking 방법이 이번 문제점의 해결 방법이다.
1.2.4 Double Checked Locking Singleton
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Singleton{
private static Singleton singletonInstance;
private Singleton(){}
public static Singleton getInstance(){
if(singletonInstance == null){ //1
synchronized(Singleton.class){ //2
if(singletonInstance == null){
singletonInstance = new Singleton();
}
}
}
return singletonInstance;
}
}
Double Checked Locking(DCL) 방식은 이중으로 null 체크를 하면서 최적화가 잘 되어있는 것처럼 보이지만, 멀티 스레드 환경에서 단 하나의 인스턴스를 보장받지 못한다.
getInstance()
메소드는 아래와 같은 과정으로 이루어져있다.
- 메모리를 할당한다.
- singletonInstance에 참조 객체를 할당한다.
- 생성자를 호출해서 객체를 생성하고 참조한다.
한 스레드에서 이미 코드의 주석 2번 부분에 접근하고 있다고 가정하고 아직 Singleton 객체가 생성되지 않았을 때, 다른 스레드에서 인스턴스가 null이 아닌 것을 보고 객체가 생성되기 전에 return
문으로 향해 생성자가 호출되지 않은 객체를 반환해버릴 수 있기 때문에 불안정하다.
1.2.5 DCL에 volatile 선언
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class Singleton{
private static volatile Singleton singletonInstance;
private Singleton(){}
public static Singleton getInstance(){
if(singletonInstance == null){
synchronized(Singleton.class){
if(singletonInstance == null){
singletonInstance = new Singleton();
}
}
}
return singletonInstance;
}
}
volatile
키워드 선언을 통해 모든 스레드가 항상 같은 공유 변수의 값을 읽어올 수 있도록 보장하고, 그 뒤에 getInstance()
메소드를 통해 공유 변수에 인스턴스를 할당하면 volatile
키워드에 의해 CPU 캐시가 아닌 메인 메모리에 해당 인스턴스의 값이 갱신되고 이로 인해 다른 스레드들은 null이 아닌 공유 변수에 할당된 인스턴스를 메인 메모리로부터 바로 읽어올 수 있게 된다.
따라서, 공유 변수의 값에 불일치가 일어나지 않기 때문에 다른 스레드가 또 다시 getInstance()
의 if문 블록에 진입하는 경우는 발생하지 않게 된다.
이제 데이터를 쓰고 읽음에 있어 항상 메인 메모리에서 인스턴스를 가져오는 것을 보장해준다. 하지만 객체 내에 변수의 개수가 많아질 수록 volatile
키워드를 남발하면 메인 메모리의 접근을 높이기 때문에 성능이 상당히 떨어질 수 있다는 문제점이 있다.
이렇기 때문에 결국 DCL의 문제를 해결하려면 돌고 돌아 Lazy Initialization을 사용하지 않고 Eager Initialization을 사용하거나, 전체 메소드에 synchronized
키워드를 적용해서 동시에 다른 스레드의 접근을 막는 방법이 있다. 하지만 각각 다 문제점이 있었다.
1.3 대안
자원을 효율적으로 사용하며, thread-safe하고 생성자 문제 없는 싱글톤 패턴은 없을까? 있었다. LazyHolder 싱글톤 패턴이다.
1.3.1 LazyHolder Singleton
1
2
3
4
5
6
7
8
9
10
11
class Singleton{
private Singleton(){}
private static class LazyHolder{
private static final Singleton INSTANCE = new Singleton();
}
public static Singleton getInstance(){
return LazyHolder.INSTANCE;
}
}
Eager Initialization 처럼 간단해 보이지만 Lazy Initialization이다. Java 어플리케이션에서는 클래스를 로딩할 때 static 중첩 클래스는 바로 생성하지 않고, getInstance()
메소드가 호출됐을 때에 호출된다. 그래서 Lazy Initialization과 똑같은 초기화 시점을 가지게 된다. 다른 점은 동기화 작업을 JVM에 위임해서 만약 다른 스레드가 getInstance()
메소드를 호출할 때 static final
로 선언된 인스턴스가 이미 JVM 메모리에 올라와있기 때문에 싱글톤 객체를 유일하게 하나만 생성할 것이라고 JVM이 보장해준다. 그리고 코드도 DCL에 비하면 간단하다.
1.4 싱글톤 패턴의 단점
싱글톤 패턴은 TDD를 할 때에 걸림돌이 생긴다. TDD에서 단위 테스트를 진행할 때 테스트 별로 서로 독립적이어야하는데 싱글톤 패턴으로 설계된 객체는 테스트마다 독립적인 인스턴스를 만들기가 어렵다.
1.4.1 의존성 주입
싱글톤 패턴은 사용하기가 쉽고 굉장히 실용적이지만 싱글톤 패턴의 객체가 변경되면 상태 변화에 예상치 못한 부분에 영향을 끼칠 수 있기 때문에 모듈 간의 결합을 강하게 만들 수 있다는 단점이 있다. 그래서 메인 모듈(싱글톤 패턴 객체)가 ‘직접’ 다른 하위 모듈에 대한 의존성을 주기보다 중간에 의존성 주입자(dependency injector)가 이 부분을 가로채서 메인 모듈이 ‘간접’적으로 의존성을 주입하는 방식이다. 예를 들어 스프링 프레임워크의 외부 컨테이너에서 관리되어질 수 있다.
의존성 주입(DI)의 장점으로는
- 모듈들을 쉽게 교체할 수 있는 구조가 되어 테스트하기 쉽고, 마이그레이션하기도 용이하다.
- 구현할 때 추상화된 클래스를 넣고, 이를 기반으로 컨테이너에서 구현체를 넣어주기 때문에 어플리케이션 의존성 방향이 일관된다.
- 결과적으로 모듈 간의 관계들이 조금 더 명확해진다.
하지만 단점으로는 모듈들이 더욱 더 분리되므로 클래스의 수가 늘어나 복잡성이 생기고, 약간의 런타임 패널티가 생길 수 있다.
1.4.2 의존성 주입 원칙 (DIP)
객체 지향의 원칙 SOLID 중 D인 DIP 원칙은 “상위 모듈은 하위 모듈에서 어떠한 것도 가져오지 말아야 한다. 또한, 둘 다 추상화에 의존해야하며, 이 때 추상화는 세부 사항에 의존하지 말아야 한다.”의 원칙으로 DIP를 지키면서 만들어야한다.
추후에 DIP에 대해서도 깊게 다뤄봐야겠다.
1.5 결론
가장 구현이 쉬운 Eager Initialization부터 LazyHolder Singleton까지 싱글톤 패턴에서 생길 수 있는 문제점을 파악해보고, 그 문제점을 어떻게 해결했는 지 봤다. 학부 시절에 LazyHolder Singleton 방식을 배웠는데, 어떤 문제점으로 인해 이 방법이 나오게 되었는 지 놓친 부분이 있어 깊게 공부할 수 있는 기회가 되었다.
thread-safe하고 Lazy Initialization을 통해 싱글톤 패턴으로 클래스를 설계함으로써 단 하나의 인스턴스를 보장받을 수 있는 객체를 구현할 수 있게 되었다.
의존성 주입(DI)와 의존성 주입 원칙(DIP)이 Spring 내에서 중요한 개념인데 추후에 개념 정리를 해야겠다.
출처