싱글톤 패턴에 대해 좋은 포스팅이 있어 다시 한번 정리하려고 하고 개인적으로 필요한 부분이라고 생각되는 부분들은 추가적으로 작성하였습니다.
애플리케이션이 시작될 때 어떤 클래스가 최초 한번만 메모리를 할당하고(static) 그 메모리에 인스턴스를 만들어 사용하는 디자인 패턴입니다.
생성자가 여러 차례 호출되더라도 실제로 생성되는 객체는 하나고 최초 생성 이후에 호출된 생성자는 최초에 생성한 객체를 반환합니다.
하나의 인스턴스를 메모리에 등록해서 여러 스레드가 동시에 해당 인스턴스를 공유하여 사용하게끔 할 수 있으므로, 요청이 많은 곳에서 사용하면 효율적일 수 있습니다.
즉, 싱글톤 패턴은 단 하나의 인스턴스를 생성해 사용하는 디자인 패턴입니다.
싱글톤 패턴의 공통적인 특징은 private constructor를 가진다는 것과, static method를 사용합니다.
public class Singleton{
// Eager Initialization
private static Singleton uniqueInstance = new Singleton();
private Singleton() {}
public static Singleton getInstance(){
return uniqueInstance;
}
}
이른 초기화 방식은 static 키워드의 특징을 이용해서 클래스 로더가 초기화 하는 시점에서 정적 바인딩(static binding) (컴파일 시점에서 성격이 결정됨)을 통해 해당 인스턴스를 메모리에 등록해서 사용하는 것입니다.
이른 초기화 방식은 클래스 로더에 의해 클래스가 최초로 로딩 될 때 객체가 생성되기 때문에 Thread-safe 합니다.
싱글톤 구현 시 중요한 점이, 멀티 스레딩 환경에서도 동작 가능하게끔 구현해야 한다는 것입니다.
즉, Thread-safe가 보장되어야 합니다.
synchronized 키워드를 이용한 지연 초기화 방식인데, 메소드에 동기화 블럭을 지정해서 Thread-safe를 보장합니다.
지연 초기화 방식이란 컴파일 시점에 인스턴스를 생성하는 것이 아닌, 인스턴스가 필요한 시점에 요청하여 동적 바인딩(dynamic binding)(런타임 시에 성격이 결정됨)을 통해 인스턴스를 생성하는 방식을 말합니다.
public class Singleton{
private static Singleton uniqueInstance;
private Singleton() {}
//Lazy initailization
public static synchronized Singleton getInstance(){
if(uniqueInstance == null){
uniqueInstance = new Singleton();
}
return uniqueInstance;
}
동기화 블럭을 지정한 게으른 초기화 방식은 Thread-safe 합니다. 하지만, 인스턴스가 생성되었든 혹은 안되었든 무조건 동기화 블록을 거치게 되었다는 것입니다.
synchronized 키워드를 사용하면 성능이 약 100배 가량 떨어집니다.
만약, getInstance 메소드의 속도가 중요하지 않은 경우라면 그냥 둬도 상관은 없지만 아래에 나오는 방식들을 확인하고 나서는 위 방식을 사용하면 안됩니다.
위 동기화 블럭 방식을 개선한 방식이 DCL 방식입니다.
이 방식은, 인스턴스가 생성되지 않은 경우에만 동기화 블럭이 실행되게끔 구현하는 방식입니다.
public class Singleton{
private volatile static Singleton uniqueInstance;
private Singleton() {}
//Lazy Initialization. DCL
public Singleton getInstance(){
if(uniqueInstance == null){
synchronized(Singleton.class){
if(uniqueInstance == null){
uniqueInstance == new Singleton();
}
}
}
return uniqueInstance;
}
}
위 코드에서 volatile 키워드가 등장하는데, volatile 키워드를 사용하면 멀티 스레딩을 쓰더라도 uniqueInstance 변수가 Singleton 인스턴스로 초기화되는 과정이 올바르게 진행되도록 할 수 있습니다.
volatile 변수를 사용하고 있지 않는 멀티 스레드 어플리케이션에서는 작업(Task)을 수행하는 동안 성능 향상을 위해 Main Memory에서 읽은 변수 값을 CPU Cache에 저장하게 됩니다.
만약에 멀티 스레드 환경에서 스레드가 변수 값을 읽어올 때 각각의 CPU Cache에 저장된 값이 다르기 때문에 변수 값 불일치 문제가 발생하게 되는데, volatile 키워드가 이런 문제를 해결 해줍니다.
즉, volatile 변수는 Main Memory에 값을 저장하고 읽어오기 때문에(read and write) 변수 값 불일치 문제가 생기지 않습니다.
Enum 인스턴스의 생성은 기본적으로 Thread-safe 합니다.
따라서 스레드 관련된 코드가 없어져서 코드가 간단해집니다.
하지만, Enum 내의 다른 메소드가 있는 경우 해당 메소드가 Thread-safe 한지는 개발자가 책임져야 합니다.
public enum Singleton{
INSTANCE;
}
Enum 방식을 사용한 장점은 아주 복잡한 직렬화 상황이나, 리플렉션 공격에도 제 2의 인스턴스가 생성되는 것을 막아줍니다. 단, 만들려는 싱글톤이 Enum외의 클래스를 상속해야 하는 경우에는 사용할 수 없습니다. 또한, Android 같이 Context 의존성이 있는 환경일 경우, 싱글턴의 초기화 과정에 Context라는 의존성이 끼어들 가능성이 있습니다.
Lazy Holder 방식은 가장 많이 사용되는 싱글턴 구현 방식입니다.
volatile이나 synchronized 키워드 없이도 동시성 문제를 해결하기 때문에 성능이 뛰어납니다.
public class Singleton {
private Singleton() {}
/**
* static member class
* 내부클래스에서 static변수를 선언해야하는 경우 static 내부 클래스를 선언해야만 한다.
* static 멤버, 특히 static 메서드에서 사용될 목적으로 선언
*/
private static class InnerInstanceClazz() {
// 클래스 로딩 시점에서 생성
private static final Singleton uniqueInstance = new Singleton();
}
public static Singleton getInstance() {
return InnerInstanceClazz.instance;
}
}
싱글톤 클래스에는 InnerInstanceClazz클래스의 변수가 없기 때문에, static 멤버 클래스더라도, 클래스 로더가 초기화 과정을 진행할 때 InnerInstanceClazz 메소드를 초기화 하지 않고, getInstance() 메소드를 호출할 때 초기화 됩니다. 즉, 동적바인딩(Dynamic Binding)(런타입시에 성격이 결정)의 특징을 이용하여 Thread-safe 하면서 성능이 뛰어납니다.
InnerInstanceClazz 내부 인스턴스는 static 이기 때문에 클래스 로딩 시점에 한번만 호출된다는 점을 이용한 것이며, final을 써서 다시 값이 할당되지 않도록 합니다.
클래스 로더를 2개 이상 사용하는 경우, 인스턴스가 2개 이상 생성될 수 있습니다. 이런 경우에는 클래스 로더를 지정해야 합니다.
자바와 스프링의 싱글톤 차이점은, 싱글톤 객체의 생명주기가 다릅니다. 또한 자바에서 범위는 클래스 로더가 기준이지만, 스프링에서는 어플리케이션 컨텍스트가 기준이 됩니다.
클래스 로더 기준이라는 것은 톰캣이 WAR 파일을 만들게 되면, WAR 파일 하나 당 클래스 로더 하나 1:1 식으로 배치가 되기 때문에 다른 WAR 파일은 참조가 불가능합니다.
반면, ApplicationContext 기준이라는 것은 web.xml에서 root context 하나와 servlet context 여러 개를 등록할 수 있는데, 각각의 context들이 싱글톤 범위가 됩니다.
스프링은 빈을 등록할 때 범위(scope)를 지정할 수 있는데 디폴트가 싱글톤입니다. 그 외에도 prototype, request, session이 있습니다.
스프링에서 핵심 컨테이너의 빈 관리를 담당하는 BeanFactory의 핵심 구현 클래스는 DefaultListableBeanFactory입니다. 대부분의 애플리케이션 컨텍스트는 바로 이 클래스를 BeanFactory로 사용하는데, 핵심 구현 클래스인 DefaultListableBeanFactory가 구현하고 있는 인터페이스의 한가지가 바로 SingletonRegistry입니다.
스프링에서 하나의 요청을 처리하기 위해서는 Presentation Layer, Business Layer, Data Access Layer 등 다양한 기능을 담당하는 객체들이 계층형을 이루고 있는데, 클라이언트 요청마다 각 로직을 담당하는 객체를 만들어 사용한다면, GC가 있더라도 메모리 부하가 올 수 있습니다.
이 때문에 엔터프라이즈 분야에서는 서비스 오브젝트(service object)라는 개념을 사용해 왔는데 서블릿은 Java 엔터프라이즈 기술의 가장 기본이 되는 서비스 오브젝트라고 할 수 있습니다.
서블릿은 대부분의 멀티 스레딩 환경에서 싱글턴을 동작하며, 서블릿 클래스 하나 당 하나의 객체를 생성하여, 클라이언트 요청 처리를 담당하는 스레드들이 해당 객체를 공유해서 사용합니다.
우리가 Bean을 어떻게 만드는지 생각하시면 됩니다.
스프링은 어노테이션 설정만으로 IOC 컨테이너 (Application Context)에 제어권을 넘겨줌으로써 손쉽게 빈을 싱글톤으로 생성하여 사용할 수 있습니다.
Component-scan 대상이 되는 어노테이션들 @Repository, @service, @Controller, @Component 등을 사용하면 됩니다.
private 생성자를 가진 클래스도 스프링의 빈으로 등록해서 사용이 가능합니다.
스프링은 리플렉션을 통해서 인스턴스를 만들고, 리플렉션을 통해서라면 private 생성자를 호출해서 인스턴스를 만드는것이 가능합니다.
하지만 추천하는 방법은 아니며, 가능하면 클래스 설계자의 의도를 존중하고, 접근방법을 지키도록 하는 것이 바람직합니다.
싱글턴의 중요한 특징 중 하나가 멀티스레딩 환경에서도 동작 가능하게 구현해야 한다고 했습니다. 즉, Thread-safe를 보장해야 한다고 했습니다.
따라서 Thread-safe를 보장하려면, 무상태성(stateless) 을 지켜야 합니다.
즉, 상태 정보를 클래스 내부에 가지고 있으면 안됩니다.
@RequiredArgsConstructor
public class EventService {
private final ApplyRepository; // (1)
private List<Stirng> applicants; // (2)
private ApplyVo apply; // (2)
public void createEvent() {
// 생략
}
}
무상태성을 지키기 위해서는 클래스 내부에 상태 정보를 가지고 있으면 안된다고 했는데, 예외가 있습니다.
(1)번 같은 경우는 자신의 클래스 내부에서 다른 싱글턴 빈(Singleton Bean)을 저장하려는 용도이면 사용 가능합니다.
(2)번 같은 전역 변수는 메모리의 메소드 영역(Static Area)에 저장됩니다. 메소드 영역은 스레드가 공유 가능한 영역이므로, 여러 개의 스레드가 접근하려는 경우 값이 변경될 위험이 있기 때문에 Thread-safe 하지 않습니다.
따라서 (2)번 같은 경우는 지역변수나, 메소드의 매개변수로 이용하여 스레드가 공유 불가능한 스택 영역(Stack Area)에 저장되도록하여 Thread-safe를 보장하게끔 만들어 줘야 합니다.
즉, 싱글턴이라해도 메소드 파라미터나, 메소드 안에서 생성되는 로컬 변수는 메소드가 호출 될 때마다 매번 새로 할당 되므로, 여러 스레드가 변수의 값을 덮어쓸 일은 없습니다.
추가적으로 Multi-thread 환경에서 간략한 싱글톤 테스트를 해보겠습니다.
public class Singleton{
private static Singleton singleton = new Singleton();
private Singleton(){}
public static Singleton getInstance(){
return singleton;
}
public int add(int num){
return ++num;
}
}
class AddTest{
public static void main(String[] args){
Singleton singleton = Singleton.getInstance();
int[] arr= {0,1,2,3,4,56,7,8,9,10,11,12,13,14,15,16,17,18,19};
for(int i : arr){
new Thread(() -> {
System.out.println(singleton.add(i));
}).start();
}
}
}
위와 같은 경우 순서보장은 못하지만, arr 각 값에 1을 더한 값이 하나도 빠짐 없이 1~20까지 출력이 됩니다.
그럼 상태를 가지는(전역변수가 있는) 싱글톤 객체를 만들어 보겠습니다.
public class Singleton{
int num;
private static Singleton singleton = new Singleton();
private Singleton(){}
public static Singleton getInstance(){
return singleton;
}
public int add(){
return ++num;
}
}
}
class AddTest{
public static void main(String[] args){
Singleton singleton = Singleton.getInstance();
int[] arr= {0,1,2,3,4,56,7,8,9,10,11,12,13,14,15,16,17,18,19};
for(int i : arr){
new Thread(() -> {
System.out.println(singleton.add(i));
}).start();
}
}
}
위와 같이 실행할 경우 예를 들어 어느 값이 두번 나오고 19에서 1을 더한 20이 나오지 않는다거나 등 thread-safe 하지 못한 결과 값을 출력되는 것을 확인할 수 있습니다.
즉, 하나의 공유 자원을 놓고 여러 개의 스레드가 읽기/쓰기를 하면서 데이터 조작 중 문제가 발생한 것 입니다.
jvm에서 각각의 스레드는 자신의 stack 영역을 가지고 있지만 heap영역은 스레드 간에 공유를 하고 있습니다. 그래서 상태를 가지는 가변 객체의 경우 thread-safe 하지 못한 것 입니다.
싱글톤 스코프의 스프링 빈도 결국 위의 싱글톤 예제와 동일하기 때문에 멀티 스레드 환경에서의 가변 객체일 경우에는 thread-safe 하지 못합니다.
보통 MVC 패턴을 이용하여 프로젝트를 진행할 때, serviceImpl 클래스에 @Service 어노테이션을 붙여 싱글톤으로 등록해서 사용하는데, 비즈니스 로직을 처리하다 보면 여러 스레드가 접근할 때 DB에 값이 여러번 쓰여지지 않게 해야하는 경우가 있습니다.
PK는 다르더라도 검증이 필요한 필수값(unique key로 지정 될만한 값)에 대해서 DB에서 테이블 락을 건다던지, 오라클의 select for update 같은 쿼리로 동시성 문제를 해결하지 않는 경우에는 synchronized로 동시성 문제를 해결해야 합니다.