구조 패턴(Structural Pattern)
구조 패턴은 클래스나 객체들을 조합해 더 큰 구조로 만들 수 있게 해주는 패턴이다.
*구조 클래스 패턴은 상속(extends), 확장(implements)을 통해 클래스나 인터페이스를 합성한다. *
구조 객체 패턴은 객체를 합성(composition)하는 방법을 정의한다.
생성 패턴(Creational Patterns)
객체를 생성(클래스의 인스턴스를 만드는 절차), 합성(Compositon) 하는 방법을 기존 클래스에서 분리한다.
(SOLID, Single Responsibility Principle : 단일 책임 원칙)
필요시 클래스의 인스턴스(instance)를 만드는 절차, 과정을 추상화(Abstraciton) 한다.
객체의 표현 방법을 기존 클래스에서 분리 한다.
싱글톤 패턴(Singleton Pattern)
생성자가 여러 차례 호출되더라도 실제로 생성되는 객체는 하나이고, 최초 생성 이후에 호출된 생성자는 최초의 생성자가 생성한 객체를 리턴한다.
싱글톤 패턴은 단일 책임 원칙을 위반한다.
런타임중 인스턴스의 개수를 통제, 관리, 생성하는 책임
자신의 비즈니스 로직에 대한 책임
싱글톤 패턴은 한 클래스에 두 책임이 부여되었으므로, 단일 책임 원칙에 위반된다. 원칙대로라면 인스턴스의 통제, 관리, 생성하는 책임을 다른 클래스에 위임 한 팩토리 패턴으로 구현하는 것이 바람직하다.
몇몇 디자인 패턴은 객체지향 원칙을 전부 준수하지 않는다.
오히려 원칙을 어기고 더 큰 유연함과 유지보수성, 코드가독성을 얻는 경우도 많기 때문이다.
이른 초기화(Eager-Initialization) 방식으로 구현 한 싱글톤
@Deprecated
public class EagerInitializationSingleton {
private static EagerInitializationSingleton instance = new EagerInitializationSingleton();
private EagerInitializationSingleton() {}
public static EagerInitializationSingleton getInstance() {
return instance;
}
}
Method Area에 싱글톤 클래스가 로딩 될 때, 싱글톤 객체 인스턴스가 초기화 된다.
정적(static)으로 선언된 변수는 Class Loader에 의해 Method Area(Static Area)로 로드된다.
이는 개발자가 간섭 할 수 없는 JVM의 영역이므로 항상 Thread-safe 을 보장한다.
사용 유무와 상관없이, 클래스가 로딩되는 시점에 항상 싱글톤 객체 인스턴스가 생성된다.
싱글톤 패턴은 주로 Handler 클래스나 Manager 클래스, DAO 클래스처럼 복잡하고 무거운 클래스에 주로 구현되는데, 따라서 이른 초기화 방식으로 구현된 싱글톤은 성능 이슈에서 자유롭지 못한 경우가 많다.
늦은 초기화(Lazy-Initialization) 방식으로 구현 한 싱글톤
@Deprecated
public class LazyInitializationSingleton {
private static LazyInitializationSingleton instance;
private LazyInitializationSingleton() {}
public static LazyInitializationSingleton getInstance() {
// ex) 두 Thread가 동시에 if(instance == null)문을 통과 했다면
if(instance == null) {
// 두 개의 instance가 생성될 수 있다. -> Lock이 필요한 상황
instance = new LazyInitializationSingleton();
}
return instance;
}
}
런타임 중 동적으로 싱글톤 객체 인스턴스를 할당 하여 사용할 수 있다.
사용자가 getInstance 메서드를 처음 호출할 때, LazyInitializationSingleton 객체 인스턴스(instance)가 생성되기 때문 이다.
멀티 쓰레드 환경에서 Thread-safe을 보장할 수 없다.
아직 instance가 생성되지 않은 상태에서, 1번 쓰레드와 2번 쓰레드가 동시에 getInstance 정적 메서드를 호출 했다고 생각해보자.
1번 쓰레드와 if(instance == null)을 통과하자마자, 2번 쓰레드로 Context Switching 되어 instance 생성을 끝내버린다면, 어떤 일이 벌어질까?
1번 쓰레드가 다시 Context Switching 되었을때, instance가 2개 만들어져 싱글톤 패턴의 불변식이 무너지는 것은 기본이고, 만약 1번 쓰레드와 2번 쓰레드가 동시에 생성자 코드에서 Context Switching 되었다면, 싱글톤 객체 내부의 일관성이 무너져 내부적으로 망가진 instance가 만들어 질 수도 있다. 이 싱글톤 객체가 외부 자원에 의존하는 DAO 객체라면, 끔찍한 일이 벌어진다.
따라서 멀티 쓰레드 환경에서 Thread-safe을 보장할 수 없다.
LazyInitializationSingleton 클래스의 동시성 이슈 해결 1
@Deprecated
public class LazyInitializationSynchronizedSingleton {
private static LazyInitializationSynchronizedSingleton instance;
private LazyInitializationSynchronizedSingleton() {}
public static synchronized LazyInitializationSynchronizedSingleton getInstance() {
if(instance == null) {
instance = new LazyInitializationSynchronizedSingleton();
}
return instance;
}
}
싱글 코어 PC에서의 synchronized 키워드를 이용해 동시성 문제를 해결 했다.
synchronized 키워드를 getInstance() 메소드에 적용해, 하나의 Thread만이 getInstance() 메소드를 이용할 수 있도록 하였다.
synchronized 키워드는 해당 메소드나 블록을 한번에 한 스레드씩 수행하도록 보장한다.
" ... 한 객체가 일관된 상태를 가지고 생성되고, 이 객체에 접근하는 메서드는 그 객체에 락(lock)을 건다.
... 즉, 객체를 하나의 일관된 상태에서 다른 일관된 상태로 변화시킨다.
동기화를 제대로 사용하면 어떤 메소드도 이 객체의 상태가 일관되지 않은 순간을 볼 수 없을 것이다. "
Effective Java Item.78
성능 저하가 크게 발생 할 가능성이 높다.
synchronized 블록은, 해당 영역(scope)이나 메소드를 Lock-Unlock 처리하기 때문에 많은 비용이 발생한다.
대략 50~200배 느려진다고 알려져 있으며, 싱글톤 객체 처럼 복잡하고 무거운 객체에선 성능 저하가 크게 발생 할 가능성이 높다.
LazyInitializationSingleton 클래스의 동시성 이슈 해결 2
@Deprecated
public class DoubleCheckedLoadingSingleton {
private static DoubleCheckedLoadingSingleton instance;
private DoubleCheckedLoadingSingleton() {}
public DoubleCheckedLoadingSingleton getInstance() {
if(instance == null) { // (1)
synchronized(DoubleCheckedLoadingSingleton.class) {
if(instance == null) { // (2)
instance = new DoubleCheckedLoadingSingleton();
}
}
}
return instance;
}
}
synchronized 키워드의 성능 저하 이슈를 해결했다.
한번 이상 getInstance 메서드를 호출 하여 instance가 생성된 경우, if문(1) 에서 synchronized block 접근을 제한한다.
synchronized는 인스턴스, 클래스, 메서드 단위로 Lock-Unlock 하여 동시성을 보장하는데, 이 때 발생하는 context-switching이 성능 저하의 주요 원인이다.
런타임중 synchronized block에 접근하는 경우는 instance가 처음 초기화될 때 뿐이므로,
실제 런타임 성능이 크게 개선된다.
싱글코어 PC가 아닌 둘 이상의 CPU가 탑재된 PC에서 실행 시 가시성 이슈(Visibility Issue)가 발생한다.
싱글코어 PC가 아닌 둘 이상의 CPU가 탑재된 PC에서 실행 시, 한 CPU에서 다루는 자원은
Main Memory에만 존재하는 것이 아니라, CPU 캐시(cahce)라고 하는 영역에도 존재한다.
이는 CPU가 Main Memory에서 값을 읽고(read -> load -> use), 다시 쓰고(assign -> store -> write) 하는 시간을 아끼기 위함이다.
이제 DoubleCheckedLoadingSingleton 객체의 가시성 이슈가 왜 발생하는지 알아보자.
Thread 1이 먼저 synchronized block(2)에 진입 해, instance를 생성 했다.
Thread 1이 synchronized block(2)에서 벗어나 Unlock 한다.
자신의 Working Memory에서 Main Memory로 assign -> store -> write 을 하는 짧은 순간,
(synchronized 문법은 동기화 블록에서 나올 때, Working Memory 데이터를 Main Memory에 모두 flush 한다.)
Main Memory에 전부 Flush 되지 못한 instance는 null 이기 때문에,
Thread 2가 synchronized block(2)에 진입 한 것이다.
따라서 instance는 volatile 키워드를 통해 가시성(Visibility)을 확보해야 한다.
volatile 키워드는 원래 컴파일러의 재배치(reordering)를 막는 키워드 이다.
public static void main(String args[]){
int i = 0;
for(int j = 0; j < 1000; j++){
i = 10;
}
}
위 코드의 for문은 i 변수에 10을 대입하는 똑같은 일을 1000번이나 수행한다. 이런 의미없는 로직을 컴파일러는 컴파일 타임 때 최적화 하는데, 예시로 든 for문 블록을 전부 지워버린다.
또한 성능 최적화를 위해 코드의 순서 배치를 바꾸기도 하는데, volatile 키워드가 붙은 객체는 컴파일러가 절대 재배치 하지 않는다.
@Deprecated
public class DoubleCheckedLoadingSingleton {
// 이 객체는 Working Memory를 거치지 않고, Main Memory에 즉시 Flush 한다.
private static volatile DoubleCheckedLoadingSingleton instance;
private DoubleCheckedLoadingSingleton() {}
public DoubleCheckedLoadingSingleton getInstance() {
if(instance == null) { // (1)
synchronized(DoubleCheckedLoadingSingleton.class) {
if(instance == null) { // (2)
instance = new DoubleCheckedLoadingSingleton();
}
}
}
return instance;
}
}
그럼 정말 DoubleCheckedLoading 싱글톤 패턴은 멀티 쓰레드 환경에서 안전한가?
volatile 제어자(Modifier)는 가시성(Visibility)은 확보하지만, *원자성(Atomicity)은 확보하지 못한다. *
실제 Oracle 에서도 동시성 문제 해결을 위해 Double Checked Locking 방식으로 구현하는 것은 권장하지 않는다고 한다.
레이지 홀더(LazyHolder) 싱글톤
public class LazyHolderSingleton {
private LazyHolderSingleton() {};
public static LazyHolderSingleton getInstance() {
return LazyHolder.instance;
}
private static class LazyHolder{
private static final LazyHolderSingleton instance = new LazyHolderSingleton();
}
}
정적 멤버 클래스(Static Member Class)의 이점을 활용 할 수 있다.
getInstance 메서드를 클라이언트에서 사용하기 위해 호출하는 순간, LazyHolder 클래스가 클래스 로더(Class Loader)에 의해 메소드 영역(Method Area)에 올라가게 된다. LazyHolder 클래스는 정적 멤버 클래스이기 때문이다.
따라서 런타임 중 클라이언트의 의도대로 instance의 초기화를 제어할 수 있으므로, 메모리의 부담(Memory Leak)을 줄일 수 있다.
가장 확실한 Thread-safe을 보장한다.
클래스 로딩은 JVM의 클래스 로더(Class Loader)에 의해 이루어진다.
이는 개발자가 간섭 할 수 없는 영역이므로 항상 Thread-safe을 보장한다.
Android 환경에서의 개발과 같이 Context 의존성이 존재하는 경우 싱글톤 객체 초기화 과정 중 Context가 끼어들 가능성이 있다.
LazyHolder 싱글톤 패턴은 이런 상황에 대해 안전하다.
이하 내용은 모든 싱글톤 패턴이 공유하는 문제다.
역직렬화(Deserializable) 이슈
... 둘 중 하나의 방식 으로 만든 싱글턴 클래스를 직렬화하려면 단순히 Serializable을
구현한다고 선언하는 것만으로는 부족하다. 모든 인스턴스 필드를 일시적(transient)이라고 선언하고
readResolve 메서드를 제공해야 한다. 이렇게 하지 않으면 직렬화된 인스턴스를 역직렬화할 때마다
새로운 인스턴스가 만들어진다.
(Effective Java Item.3)
리플렉션(Reflection) 이슈
런타임 중 악의적인 리플렉션 코드가 불변식을 깨뜨리기 위한 공격에 대한 방어 코드가 필요하다.
Villain 클래스의 정적 메서드가 무슨 일을 벌이는지 살펴보자.
class Villain{
public static void ReflectionAttack() throws Exception{
LazyHolderSingleton lazyHolder1 = LazyHolderSingleton.getInstance();
Constructor<? extends LazyHolderSingleton> constructor = lazyHolder1.getClass().getConstructor(new Class[0]);
constructor.setAccessible(true);
//lazyHolder1이 있음에도 lazyHolder2가 새로 만들어졌다!
LazyHolderSingleton lazyHolder2 = constructor.newInstance();
}
아래 소스는 리플렉션 공격에 대비한 방어적 코드가 삽입된 수정본이다.
public class LazyHolderSingleton {
// 원자성(Atomicity)을 보장하는 Boolean 타입을 다루는 AtomicBoolean 클래스
// 이 객체의 상태(state)를 싱글톤 객체가 로딩 될 때 false로 초기화 한다.
private static final AtomicBoolean isCreated = new AtomicBoolean(false);
private LazyHolderSingleton() {
if(isCreated.get()) {
throw new IllegalStateException("이 객체는 싱글톤 객체입니다!");
}
//생성자가 처음 호출될 때 isCreated 객체의 상태를 true로 바꾼다.
isCreated.compareAndSet(false, true);
};
public static LazyHolderSingleton getInstance() {
return LazyHolder.instance;
}
private static class LazyHolder{
private static final LazyHolderSingleton instance = new LazyHolderSingleton();
}
}
Enum 으로 구현된 싱글톤
enum 문법이 제공하는 모든 장점을 누릴 수 있다.
자바의 enum은 C++에서의 EnumClass랑 비슷하지만, 최신 문법과 라이브러리가 자바의 enum을 더욱 강력하게 만들어 준다.
enum 객체 필드는 public static final로 선언된 자기 자신의 인스턴스 이므로,
enum 객체는 그 자체로 불변 객체(Immutable Object)라고 할 수 있다.
불변 클래스는 Thread-safe 하다.
" ...불변 객체는 근본적으로 스레드 안전하여 따로 동기화할 필요 없다.
여러 스레드가 동시에 사용해도 절대 훼손되지 않는다.
사실 클래스를 스레드 안전하게 만드는 가장 쉬운 방법이기도 하다. 불변 객체에 대해서는
그 어떤 스레드도 다른 스레드에 영향을 줄 수 없으니 불변 객체는 안심하고 공유할 수 있다. "
(Effective Java Item.17)
개발자가 직렬화(Serizalizable)를 구현하지 않더라도, enum 객체의 직렬화는 JVM이 내부적으로 안전하게 처리해준다.
위 문단에서 설명한 최신 문법과 라이브러리가 자바의 enum을 더욱 강력하게 만들어 준다는 것을 증명한다.
https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/lang/Enum.html
https://docs.oracle.com/javase/specs/jls/se7/html/jls-8.html#jls-8.9
리플랙션 공격에 안전하다.
java.lang.reflect.Constructor 클래스의 newInstance 메소드는 enum 타입의 아규먼트를
전달 할 경우 항상 예외를 던지도록 구현되었기 때문에, 리플렉션 공격에 대한 방어 코드 삽입이 없어도 리플랙션 공격에 안전함을 문법적으로 보장한다.
enum 문법의 한계가 EnumSingleton의 한계가 된다.
많은 비즈니스 로직을 보유한 클래스라면, Enum 문법을 이용한 싱글톤 클래스 설계가 어렵다.
Reference
이펙티브 자바 Effective Java 3/E
조슈아 블로크 저/개앞맵시 역 | 인사이트(insight) | 2018년 11월 01일
헤드 퍼스트 디자인 패턴
에릭 프리먼, 엘리자베스 롭슨 저/서환수 역 | 한빛미디어 | 2022년 03월 16일
GoF의 디자인 패턴
에릭 감마 저 / 김정아 역 | 프로텍미디어 | 2015년 03월 26일
YABOONG
https://yaboong.github.io/design-pattern/2018/09/28/thread-safe-singleton-patterns/
위키 피디아
https://ko.wikipedia.org/wiki/%EC%8B%B1%EA%B8%80%ED%84%B4_%ED%8C%A8%ED%84%B4
'디자인 패턴(design-pattern)' 카테고리의 다른 글
추상 팩토리 패턴(Abstract Factory Pattern) (0) | 2023.05.10 |
---|---|
빌더 패턴(Builder Pattern) (0) | 2023.05.10 |
데코레이터 패턴(Decorator Pattern) (0) | 2023.05.10 |
댓글