Definition of Singleton Pattern and its needs.
싱글턴 패턴의 정의 : 인스턴스를 오직 한개만 제공하는 클래스를 의미한다
싱글턴 패턴으로 작성된 객체는 Global Scope에서 접근이 가능해야하고
접근 가능한 객체는 전 런타임에 걸쳐 오직 하나여야 한다.
시스템 런타임, 환경 설정에 대한 정보 등 인스턴스가 여러개일때 모호할 수 있는 경우가 있는데
이런 경우에 인스턴스는 오직 하나만 만들어서 제공하는 클래스가 필요하다.
이를 위해 고안된 패턴이 싱글턴 패턴에 해당한다.
Singleton Pattern Implementation
Private Constructor와 Static Method
public class Settings {
private static Settings instance;
private Settings() {}
public static Settings getInstance(){
if(instance == null){
instance = new Settings();
}
return instance;
}
}
싱글턴 패턴의 두 가지 제약사항
- 전 런타임에 걸쳐 객체는 한가지만 존재해야 함.(클래스 외부에서 생성 불가)
생성자의 접근 제어자를 private으로 설정하여new
를 이용한 객체 생성을 불가하게 만들었다. - 전 런타임에서 글로벌하게 접근할 수 있는 메서드가 필요함
Settings
인스턴스가 생성되지 않은 상태에서 getInstance()
메서드를 호출하려면 이 메서드는 static
초기화를 통해 애플리케이션 로딩 시점에 미리 로드되어야 한다.
다만 위의 구현 방법에는 문제점이 존재한다 -> 멀티스레드 환경에서 동시성 문제 발생
- 동시성 문제?
- Web App들은 대부분 Multi-Thread로 동작한다
- A라는 스레드와 B라는 스레드가 있다고 가정하자
- 스레드 A는
if
문에서instance
가 존재하는지에 대한 condition을 검사했다고 가정하고 아직new Settings
에 도달하지 않았다고 생각해보자. - 이때 스레드 B는 스레드 A에서 아직
new Settings
에 도달하지 않았기 때문에, 그 순간에if
문의 조건 검사에 접근하게 되면 아직instance
가 생성되지 않은것으로 파악할 것이다. - 순간의 일이지만 두 스레드가 모두
if
의 조건문을 통과했기 때문에,new
연산자를 통한 객체의 생성이 2번 일어나게 되고 이는 싱글턴 패턴을 위반하는 사례가 된다.
- 스레드 A는
따라서 위의 방법은 동시성 문제로부터 싱글턴 패턴의 Integrity를 보장하지 않는다.
Synchronized를 이용, 멀티쓰레드 환경에 안전하게 싱글턴 패턴 구현하기
위의 private
접근 제어자와 static
메서드를 이용한 싱글턴 패턴의 구현은 thread-safe하지 않다.
우리는 지금부터 Java의 Synchronized
제어자를 이용하여 thread-safe한 싱글턴 패턴을 구현해보겠다.
public class Settings {
private static Settings instance;
private Settings() {}
public static synchronized Settings getInstance(){
if(instance == null){
instance = new Settings();
}
return instance;
}
}
synchronized
가 메서드 선언부에 추가됨으로써 이 메서드에 특정 스레드가 접근하게 되면
그 스레드는 이 메서드에 Lock을 걸게 됩니다. 사용이 끝나면 Lock을 반환하게 되는데, 이 Lock을 가진 시점에서 다른 스레드는 이 메서드에 접근할 수 없게 됩니다.
따라서 동시에 두 스레드가 절묘한 시점에 if
문에 접근하여 인스턴스가 2개 생성되는 위에서 기술한 문제점을 미연에 방지할 수 있습니다.
다만 Synchronized
를 사용하는 경우 한 메서드가 getInstance()
에 접근했을 때, 객체를 생성하는 시간 또는 존재하는 객체를 반환하는 시간 비용에 따라 다른 메서드가 동시에 접근할 수 없어 multi-threaded
하게 처리가 불가하게 되고 이는 성능 저하를 야기합니다.
Eager Initialization을 이용한 Singleton Pattern 구현
public class Settings {
private static final Settings INSTANCE = new Settings();
private Settings() {}
public static Settings getInstance(){
return INSTANCE;
}
}
이 방법은 new
연산자를 통해 Settings
객체를 애플리케이션 로딩 시점에 생성합니다.
애플리케이션 로딩 시점에 static
을 통해 final
즉, 상수로 선언하기 때문에, 메서드를 통한 객체 생성이 이루어지지 않으며 모든 생성된 객체의 반환은 static
메서드인 getInstance
에 의해 이루어집니다.
따라서, 멀티스레드 환경에서의 메서드 동시 진입에 의한 싱글턴 패턴 위배의 위험은 면할 수 있습니다.
그러나, 여전히 trade-off는 존재하는데 애플리케이션 로딩 시점에 객체를 "무조건" 생성하기 때문에, 객체 사용의 빈도가 낮다거나 객체 생성의 비용이 높은 경우 다소 쓰기 어려운 방법이 될 수 있겠습니다.(메모리 낭비)
생성자에서 Checked Exception
을 throw
해야 하는 경우 호출하는 쪽에서 try-catch문으로 감싸야 합니다
예제와 같이 변수를 초기화하는 과정에서는 try-catch문을 사용할 수 없기 때문에static {}
블록을 이용해서 instance
를 초기화하면 되는데 이 경우 final
키워드를 사용할 수 없습니다.
public class Settings{
private static Settings instance = null;
static {
try{
instance = new Settings();
} catch (Exception e){
e.printStackTrace();
}
}
private Settings() throws Exception{
throw new Exception();
}
public static Settings getInstance(){
return instance;
}
Double Checked Locking을 통한 Singleton Pattern
애플리케이션 로딩 시점에 Eager Initialization이 아닌 Lazy Initialization을 통한 메서드 전체에 Synchronized
를 거는 것 보다는 매우 더 효율적인 Synchronized
를 통한 Singleton Pattern의 구현 방법은 다음과 같습니다.
public static volatile Settings instance;
public class Settings {
private Settings() {}
public sttic Settings getInstance(){
if(instance == null){
synchronized(Settings.class){
if(instance == null){
instance = new Settings();
}
}
}
return instance;
}
}
위 코드는 Double Checked Locking이라는 기법을 사용하여 싱글턴 패턴을 구현하였습니다.
위와 같은 기법을 사용하려면, java의 volatile
키워드를 참조형 변수 선언에 추가해야합니다.
이 Double Checked Locking 기법은 과거의 static
메서드를 통한 getInstance()
의 사용과는 달리 if
문을 2중 중첩의 이중 중첩과 Synchronized
를 통한 멀티스레드 환경에서의 메서드 lock
을 지원합니다.
volatile
키워드는 Java의 변수를 Main Memory 상에 저장하겠다는 의미를 가집니다.
volatile
키워드를 지정하지 않은 경우, MultiThread 애플리케이션에서는 성능 향상을 위해 불러온 변수 자료를 Cache에 저장하게 되는데, 이 때 불일치 문제가 발생할 수 있습니다.
스택 오버플로우의 답변에 따르면 volatile
키워드는 메모리 쓰기가 re-ordered
됨으로써 발생하는 null 접근을 차단합니다.
어떤 의미냐 하면, 스레드 A가 있다고 가정합시다. 정상적으로 Synchronized Block까지 접근하여 new
키워드를 이용해 Settings
객체를 생성했습니다. 그리고 lock
을 반환하고 스레드 B가 앞에서 생성한 Settings
객체에 접근합니다. 스레드 B가 생각했을 때 이 객체는 이미 초기화되었다고 생각했을 수 있겠으나, CPU가 쓰기 작업 순서를 재정렬(Re-Order)하였기 때문에 객체에 생성된 데이터는 아직 초기화되지 않은 Value를 가르킬 수 있게 되고 이는 Unexpected Event를 생성할 수 있습니다.
따라서 Volatile
키워드를 통해 Memory Barrier를 삽입하게 되면, Settings
객체에 대한 모든 쓰기 작업은 Settings
객체에 대한 수정(modification)이 일어나기 전에 완료됩니다. 이는 이러한 쓰기 작업의 재정렬(CPU에 의한)을 방지하는 것입니다.
이로써 첫번째 if
문 분기를 통해 운 좋게 두 스레드가 동시에 조건 분기를 통과하였다고 하더라도, 초기 객체 생성이 필요한 경우라면, 두 스레드 중 한 스레드만 synchronized
블럭 내로 접근할 수 있게 되고 한 스레드가 먼저 들어왔을 때 현존하는 객체가 없다면 2번째 if
문 분기를 통과하여 새로운 객체를 생성할 것이나, synchrnonized
에 의해 대기중이던 다른 스레드가 if
문 분기를 통과하게 되는 경우, 이미 첫번째 스레드에서 객체 Settings
를 생성한 상태일 것이기 때문에 if
문 분기에서 instance
조건을 통과하지 못하게 됩니다.
Double Checked Locking은 이전의 단순한 Synchronized
를 통한 메서드 동시 접근 방지와는 다르게, 모든 메서드 접근 시도에 대해 synchronized
를 통한 동시 접근을 차단하지 않습니다.
따라서 Synchronized
에 접근하게 되는 경우는 객체를 처음 생성해야 할 때 두 개 이상의 스레드가 동시에 접근하는 경우로 제한됩니다.
따라서 이전의 방법과는 다르게 Synchronized
가 걸리는 경우가 초기 객체 생성 시점을 제외하고는 없습니다.
따라서 Synchronized
의 사용으로 받게되는 성능상의 제약이 상당 부분 완화되는 이점이 있습니다.
이 코드는 모든 스레드를 Synchronized
에 접근시키도록 하는 과거의 방법보다는 성능상의 이점이 있고 필요로 하는 시점에 생성이 가능하지만 코드의 복잡도가 다소 높다는 단점이 있습니다(가독성에 있어)
Static Inner 클래스를 통한 Singleton Pattern의 구현
public class Settings {
private Settings() {}
private static class SettingsHolder{
private static final Settings SETTINGS = new Settings();
}
public static Settings getInstance(){
return SettingsHolder.SETTINGS;
}
}
위 코드는 static
inner class를 이용한 Singleton Pattern을 구현한 것입니다
이 코드는 volatile
키워드의 필요가 없고 Lazy init + Thread-Safe하다는 장점이 있습니다
Settings
를 생성하려면 static
메서드인 SettingsHolder
를 호출해야하기 때문에, 해당 메서드를 생성하기 전까지는 전역으로 클래스가 생성되지 않습니다. + 상수이기 때문
클래스 생성 메서드가 private
접근 제어자를 통해 막혀있고, 이 메서드는 .getInstance
를 통해 호출되기 전까지는 생성되지 않으며, static
으로 선언되어 있기 때문에, .getInstance()
에 처음 접근하는게 아닌 이상 new
연산자를 통해 객체 생성을 하지 않고 static
영역에 생성된 SETTINGS
를 반환합니다.
해당 방법을 통한 Singleton Pattern의 구현은 일반적인 경우에 Thread-Safe하다고 말할 수 있겠으나, 다음과 같이 클라이언트쪽에서 이상한 방법으로 객체를 사용하는 경우에 문제가 발생할 수 있습니다.
Static Inner 클래스에서 싱글턴 제약이 깨지는 경우
Java Reflection을 사용하는 경우
Reflection enables Java code to discover information about the fields, methods and constructors of loaded classes, and to use reflected fields, methods, and constructors to operate on their underlying counterparts, within security restrictions. The API accommodates applications that need access to either the public members of a target object (based on its runtime class) or the members declared by a given class. It also allows programs to suppress default reflective access control. - Core Java Reflection API Documentation
리플렉션은 자바 코드가 필드와 메서드, 로드된 클래스들의 생성자들을 확인(discover)할 수 있도록 하고, reflected된 필드, 메서드 그리고 생성자들을 보안에 관한 제한범위 내에서 사용(operate)할 수 있도록 합니다. (Underlying Counterparts가 지칭하는 바는 모르겠습니다)
Reflection API(This API)는 애플리케이션이 런타임 클래스를 기반으로 한 타킷 객체의 public 멤버들이나 주어진 클래스에 의해 선언된 멤버변수들에 엑세스 가능하게 해줍니다.
또한 프로그램이 기본 Reflective Access Control을 무시/억제하도록 해줍니다
코드를 작성할 시점에는 어떤 타입의 클래스를 사용할지 모르지만 런타임 시점에 지금 실행되고 있는 클래스를 로드해서 실행해야하는 경우에 해당.
제너릭은 컴파일 타임 매커니즘에 해당, 리플렉션은 런타임 메커니즘이다.
객체에 Class
인스턴스를 생성한다, Class
는 생성자가 없고 JVM에 의해 클래스로 로딩되며 classLoader
의 defineClass
메소드를 호출하여 생성.
Settings settings = Settings.getInstance();
Constructor<Settings> declaredConstructor = Settings.class.getDeclaredConstructor();
declaredConstructor.setAccessible(true); // Private 접근제어자에 접근을 가능하게 해주는 기능
Settings settings1 = declaredConstructor.newInstance();
System.out.println(settings == settings1);
Settings settings1 = declaredConstructor.newInstance()
로 객체를 만들어버리면 용빼는 재주가 있어도 객체 생성을 방지할 수 없다(기존것과는 다른 객체가 생성된다)
이 방법에 대해 대응할 수 있는 방법은 없다. 따라서 Holder
와 static
Inner Class를 이용한 싱글톤 패턴의 구현은 리플렉션 앞에서 패턴이 깨질 수 있는 위험성이 존재한다.
직렬화 / 역직렬화를 사용하는 경우
- 직렬화 : 객체(Object)를 파일로 저장하는 것
- 역직렬화 : 파일로 저장되어있던 객체를 다시 객체로 불러오는 것
객체에 Serializable
을 implements
함으로써 객체의 역직렬화/직렬화를 사용할 수 있다.
Settings settings = Settings.getInstance();
Settings settings1 = null;
try(ObjectOutput out = new ObjectOutputStream(new FileOutputStream("settings.obj"))){
out.writeObject(settings);
} // 객체를 file에 write하는 것
try(ObjectInput in = new ObjectInputStream(new FileInputStream("settings.obj"))){
settings1 = (Settings) in.readObject();
}
System.out.println(settings == settings1);
// -> 결과는 False가 나온다.
분명히 싱글턴 패턴을 통해 구현했음에도 싱글턴이 깨지는 결과가 나타난다.
이것은 Serializable
을 통한 직렬화된 객체를 역직렬화하는 과정에서 Serializable
이 생성자를 활용해서 반드시 다른 객체로 만드는 것을 "강제하기" 때문이다.
기본값으로 설정되어있지만, 이는 readResolve()
라는 Serializable
클래스의 메서드를 @Override
함으로써 생성자의 호출을 방지하고, .getInstance()
를 통해 현존하는 클래스를 가져오거나 다른 로직을 사용하도록 설정할 수 있는 해결방법이 존재한다.
enum을 사용하여 Reflection의 한계 돌파
public enum Settings{
INSTANCE;
}
enum
에서 선언하는 멤버변수는 기본적으로 private
접근 제어자를 가진다.enum
은 자동으로 Serializable
, DeSerializable
을 구현하고 있으며 이는 enum
객체를 직접 들어가보면 알 수 있다.
역직렬화를 해도 안전하게 동일성을 보장한다
메서드와 프로퍼티를 정의할 수 있으나, Reflection
을 통한 임의의 클래스 생성이 불가능하다. 바이트코드를 살펴보면 알 수 있는데, enum
은 기본적으로 String
을 인수로 갖는 생성자를 기본으로 가진다. 그런데 Reflection
의 경우 기본 생성자를 필요로 하고 (JPA에서 Entity에 기본 생성자를 쓰는 이유 + 프록시 객체와 연관) enum
은 기본 생성자가 없다.
Settings.class.getDeclaredConstructors("Instance")
를 iteration
돌려서 찾아도 생성이 안된다.
그러나 enum
의 경우 상속이 안된다, enum
은 enum
만 컴파일 시점에 상속이 가능하다.
클래스 로딩 시점에 미리 만들어진다는 단점이 있다. (Eager Initialization은 아니다.)
'전공 > Design Pattern' 카테고리의 다른 글
Design Pattern - Prototype Pattern (2) | 2023.10.10 |
---|---|
Design Pattern - Builder Pattern (0) | 2023.10.09 |
Design Pattern - Abstract Factory Pattern (0) | 2023.10.07 |
Design Pattern - Factory Method Pattern (0) | 2023.10.07 |