1. Singleton Pattern이란?
- Singleton의 뜻은 실제로 사전에 (단독)개체, 독신자, 외둥이 라는 뜻이다
- 단어의 뜻과 같이 오직 한개의 인스턴스만 생성하여 사용하는 디자인 패턴을 싱글톤 패턴이라고 한다.
- 프로그램에서 자주 사용되고 정해진 일정한 동작을 하는 객체들을 계속 인스턴스화 했다가 메모리 해제 해주며 사용하면 한번에 여러개를 만드는 경우 메모리가 비효율적으로 사용되기 때문에 하나의 메모리에 할당하여 생성하고 이걸 계속 돌려쓰면 보다 메모리를 효율적으로 사용할 수 있다..!
2. Singleton Pattern Code
싱글톤 패턴의 장단점을 알아보기 전에 먼저 코드를 보고 가자.
2-1. new를 통한 무분별한 객체 생성
new를 통해 인스턴스를 생성하게 되면 singleton1과 singleton2는 같은 Singleton타입의 변수는 맞지만 둘을 비교해보면 false를 출력한다. 그 이유는 new로 인스턴스로 생성하게 되면 참조하는 메모리 주소가 다르기 때문이다. 이렇게 new로 인스턴스를 여러번 호출하다 보면 메모리에 부하가 걸린다.
public class Main
{
public static class Singleton{
public Singleton() {};
}
public static void main(String[] args) {
Singleton singleton1 = new Singleton();
Singleton singleton2 = new Singleton();
System.out.println(singleton1 == singleton2); // false
}
}
2-2. Singleton
앞에서 본 것 처럼 new로 객체를 찍어내지 않기 위해 싱글톤 클래스에 자기 자신의 클래스를 필드로 만든다. 인스턴스 생성 요청이 처음이라면 새 인스턴스를 만들어서 반환해주고 만약 이전에 생성했다면 새로 만들지 않고 반환한다.
public class Main {
public static class Singleton {
private static Singleton instance;
private Singleton() {};
public static Singleton getInstace() {
if(instance == null) {
instance = new Singleton();
}
return instance;
}
}
public static void main(String[] args) {
Singleton singleton1 = Singleton.getInstace();
Singleton singleton2 = Singleton.getInstace();
System.out.println(singleton1 == singleton2); // true
}
}
실제로 주소를 찍어보면 같은것을 볼 수 있다.
2-3. 멀티 스레드 환경에서의 Singleton
위의 코드는 잘 짜여진 싱글톤 코드지만 멀티 쓰레드 환경에서는 싱글톤이 유지가 안된다. 그 이유는 7번째 라인에 if(instance == null)부분을 여러개의 쓰레드가 동시에 실행시키는 경우 객체가 여러개 만들어지기 때문에 싱글톤이 깨진다.
해결 방법은 동기화 처리를 해주는 synchronized를 getInstance에 붙혀줘서 동기화 처리를 해주면 된다.
public class Main {
public static class Singleton {
private static Singleton instance;
private Singleton() {};
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
public static void main(String[] args) {
// 다수의 스레드에서 동시에 호출되는 상황을 가정
Runnable runnable = () -> {
Singleton singleton = Singleton.getInstance();
System.out.println(singleton);
};
// 여러 스레드에서 동시에 호출
Thread thread1 = new Thread(runnable);
Thread thread2 = new Thread(runnable);
thread1.start();
thread2.start();
}
}
2-4. Double Checked Locking 패턴 적용
위의 코드처럼 getInstance에 synchronized를 붙혀서 동기적으로 스레드를 실행하게 되면 스레드 하나가 끝날 때 까지 나머지 스레드는 기다려야 하므로 성능 이슈가 생길 수 있다. 동기화와 비동기화에 대해 잘 모른다면 이 글을 참조하면 도움이 될 것이다.
성능 이슈를 해결하기 위해서는 Double checked Locking 패턴을 적용하면 된다. 여기서부터는 코드가 길어지기 때문에 메인 Singleton로직만 코드블럭에 넣을것이다. 메인부분은 동일하다
public class Main {
public static class Singleton {
private static volatile Singleton instance; //1
private Singleton() {}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) { //2
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
//1 :private static volatile Singleton instance;
멀티 스레드 환경에서 각각의 스레드는 RAM(Random Access Memory) - Cache - Core(CPU register) 순서로 데이터를 옮긴다. 메인 메모리는 코어 내 레지스터보다 상대적으로 속도가 느리기 때문에 속도차로 인한 병목현상이 생길 수 있다. 이 속도차를 극복하기 위해 캐시 메모리를 붙혀서 사용하는 것이다.
자바에서 변수를 volatile로 선언하게 되면 해당 변수는 캐시에 적재되지않고 바로 캐시 레벨을 바이패싱 후 메인 메모리에 직접 적재된다. 변수값을 가져올때도 당연히 메인메모리에서 가져온다.
스레드에서 volatile을 붙히지 않은 상태로 생성된 싱글톤 인스턴스의 값을 여러개의 멀티스레드가 가져올 때 각 CPU에 딸린 캐시에 있는 다른 주소값의 Singleton 인스턴스를 참조할수 있다.
반대로 생각하면 volatile을 붙이고 동기처리를 안해주면 race condition이 발생할 수 있어 꼭 synchronized와 같이 사용해야한다.
//2 : synchronized (Singleton.class)
if문 진입시, 즉 싱글톤 인스턴스가 생성되지 않은 시점에서 스레드가 동시에 if문에 들어왔을 상황에만 Singleton클래스에 한정해서 synchronize하게 처리해줘서 객체의 이중 생성을 막는 방법이다. 그래서 항상 2-3처럼 항상 동기화 처리를 하지 않아도 되서 성능 이슈를 줄일 수 있다.
요약하면 volatile로 싱글톤 객체를 선언 후 인스턴스를 메인 메모리에 생성해서 race condition을 예방하고 synchronized를 사용해서 싱글톤 인스턴스를 첫 생성시 클래스레벨에서 막아서 동기처리 간 성능 이슈도 막아줬다.
2-5. static 초기화
public static class Singleton {
private final static Singleton instance = new Singleton();
private Singleton() {};
public static Singleton getInstance() {
return instance;
}
}
클래스 내부에 있는 static 변수 (또는 static 블록)은 클래스가 로드되는 시점과 거의 동일하게 초기화 된다. 이러한 JVM의
클래스 로딩 방식을 활용해서 Singleton 객체를 클래스 로딩과 동시에 생성하고 반환하는 방식이다.
하지만 static 초기화 방식은 만약 싱글톤으로 만드는 객체가 무겁거나 사용하지 않게되는 경우 클래스 로드와 동시에 인스턴스가 생성되어 메모리를 불필요하게 잡아먹게된다.
2-6. static 초기화 - Inner static class를 활용한 Lazy Holder방식 [추천]
public static class Singleton {
private Singleton() {};
public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}
private static class LazyHolder {
private static final Singleton INSTANCE = new Singleton();
}
}
// JVM은 class를 인스턴스화 할 때 클래스를 로드하고 그 안의 정적으로 선언된 필드들을 초기화 한다. 인스턴스화 하는 클래스 안에 Inner Class가 있어도 Inner class를 사용하지 않으면 Inner class에 대해서는 로딩 및 초기화 하지 않는다. //
위의 설명과 같이 싱글톤 클래스 안에 static Inner Class를 만들어서 Singleton 클래스는 로딩해도 실제로 static Inner class안의 필드가 사용되지 않는다면 로딩하지 않는 JVM의 특성을 활용하여 실제로 사용할 때 만들어서 사용하는, 사용하지 않으면 만들지 않아서 메모리를 효율적으로 사용하는 방법이다.
.
JVM은 클래스 로딩과 함께 클래스 초기화 작업을 "단 한번만" 실행한다. 멀티 스레드 환경에서 여러 스레드가 동시에 클래스를 인스턴스화 해도 클래스 초기화는 단 한번만 진행되기 때문에 위의 코드가 동작하는 방식을 순서대로 보면
1. 10개의 스레드가 Singleton.getInstance를 호출한다.
2. 가장 처음에 들어온 스레드가 호출한 getInstance에 의해 LazyHolder.INSTANCE를 통해 LazyHolder클래스가 로딩과 동시에 초기화 되며 싱글톤 인스턴스를 "단 한번" 생성한다. [클래스 로딩과 동시에 static 필드와 static 블록이 선언 순서에 맞게 초기화 된다.
3. 이미 JVM에 의해 static Inner class의 static final 싱글톤 인스턴스가 초기화를 통해 생성되었으니 다른 스레드들은 처음에 생성된 싱글톤 인스턴스와 동일한 인스턴스를 받아간다.
2-7. 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();
}
}
enum 타입은 멤버 타입 자체가 private이고 단 한번만 초기화 된다는 특성이 있다. enum내에서 상수 이외에도 변수나 메서드를 선언하여 사용이 가능하기 때문에 이를 사용하여 싱글톤 클래스처럼 응용이 가능하다. enum 내부에서 메서드를 사용해서 싱글톤과 비슷하게 사용하는게 핵심인 것 같다.
이 부분에 대해서는 내가 공부할 때 주로 활용하는 Inpa Dev님의 블로그에서 코드를 가져왔다.
3. 싱글톤은 안티 패턴이라던데?
싱글톤은 많이 사용되는 패턴이지만 지양해야하는 패턴이라고들 한다. 그 이유를 알아보자.
3-1. SOLID 원칙에 위배됨 - 객체지향과 거리가 멀어짐
SRP위반 : 싱글톤 클래스에서 객체 생성, 관리, 전역 상태 유지 등 여러 책임을 담당하게 된다.
OCP : 하나의 인스턴스를 가지고 있기 때문에 싱글톤의 구현이 변경되면 싱글톤을 가져다가 쓴 클래스들의 코드도 변경해야 할 일이 생긴다.
LSP, ISP : 치환 원칙을 구현하기 위해서는 일단 인터페이스를 구현해야하는데 싱글톤은 인터페이스 타입이 아닌 구체 타입이다.ISP는 뭐 당연히,,,
DIP : 싱글톤이라는 객체를 생성해주는 구체 클래스에 의존하게 된다.
위와 같은 이유로 싱글톤은 객체 지향적이지 못한 패턴이다.
3-2. 단위 테스트에서의 모의 객체 사용 어려움
단위 테스트는 테스트 객체가 서로 독립적이어야 하며 테스트를 하는 순서에 상관없이 실행할 수 있어야 한다. 하지만 싱글톤 패턴은 미리 생성된 하나의 인스턴스를 기반으로 동작하기 때문에 각 테스트마다 독립적인 인스턴스를 만들어서 테스트하기 힘들다.
3-3. 멀티스레드 환경에서의 동시성 문제
2번에서 직접 코드로 구현한 것과 같이 여러 문제점이 있지만 volatile, synchronized, static inner class등 여러 방법으로 보완할 수 있다.
이처럼 싱글톤 패턴은 객체 지향의 목적과는 거리가 먼 단점들이 존재한다. 하지만 객체 생성, 생명주기 관리 등 싱글톤을 사용하는 방식을 프레임워크한테 위임해서 메모리 효율, 속도 등 싱글톤이 가진 장점을 활용하고 단점은 프레임워크가 보완해주도록 사용할 수 있다.
대표적으로 스프링 프레임워크는 스프링 IoC컨테이너가 Bean이라는 이름으로 객체들을 관리해주고 주입해주기 때문에 단점 없이 싱글톤과 같은 방식을 사용할 수 있다.
프레임워크 없이 싱글톤을 사용하려면 장단점을 잘 따져서 이익이 더 큰 쪽을 선택하면 될 것 같다.
참고 자료
https://letyarch.blogspot.com/2019/04/singleton-synchronized_8.html
https://dev-monkey-dugi.tistory.com/158
CS 지식을 공부하고 기록하는 개인 공부 블로그입니다.
내용 중 틀린 부분 피드백 혹은 궁금한 점이 있으면 댓글로 남겨주시면 참고 및 답변 달아드리겠습니다🧐
'Design Pattern' 카테고리의 다른 글
[Design Pattern] SOLID 원칙 (1) | 2023.12.10 |
---|