[Effective Java] 아이템 3. private 생성자나 열거 타입으로 싱글턴임을 보증하라
예제 코드는 여기서 보실 수 있습니다.
0. 들어가며
이번 아이템은 싱글턴 패턴을 안정적으로 구현하기 위한 방법들에 대한 내용입니다.
각각의 방법들을 예제 코드와 함께 알아보겠습니다.
1. 싱글턴이란?
싱글턴(singleton)이란 인스턴스를 오직 하나만 생성할 수 있는 클래스를 말한다.
(Effective java 책 23p)
'오직 하나만 생성할 수 있다' 라는 말은 '인스턴스가 하나만 존재해야 한다' 라고 해석할 수도 있습니다.
즉, 싱글톤 구현을 위해서는 Java 의 런타임 환경인 JVM 에서 단 하나의 인스턴스만 생성되도록 구현해야 한다고 생각할 수 있습니다.
1.1 싱글턴의 특징
1. 메모리 절약
모든 객체는 생성되면 JVM 의 heap 메모리에 저장됩니다.
그러나 싱글턴 객체는 단 한번만 생성되기 때문에 최초에 생성된 단 한개의 객체만 heap 에 저장됩니다.
이후 모든 클라이언트는 해당 객체를 공유하기 때문에 메모리를 효율적으로 사용할 수 있다는 이점이 있습니다.
2. 데이터 공유
싱글톤으로 구현된 객체는 JVM heap 에 저장된 하나의 객체를 모든 클라이언트들이 공유하게 되는데요.
만약 싱글톤 객체에 공유할 정보를 넣어놓는다면 손쉽게 모든 클라이언트들이 공유할 수 있게 됩니다.
3. 멀티스레드 환경을 고려해야 함
다수의 클라이언트에서 하나의 객체, 즉 하나의 자원을 공유하는 상황이다 보니 자칫하면 치명적인 문제가 발생할 수 있습니다.
싱글톤 구현 시 멀티스레드를 고려하지 않으면 여러 클라이언트에서 하나의 자원에 접근하며 레이스 컨디션이 발생할 수 있습니다.
만약 싱글턴의 생성 로직에서 레이스 컨디션이 발생한다면, 여러개의 객체가 생성되는 일도 발생할 수 있습니다.
4. 싱글턴을 사용하는 클라이언트를 테스트하기 어려움
테스트 케이스들을 작성할 때 필연적으로 다양한 입력값, 출력값을 정의해야 합니다.
만약 싱글톤 클래스가 불변객체이고, 생성자를 통해서 값을 주입받는 다면 어떨까요?
그렇게 된다면 이 싱글톤 클래스를 사용하는 코드를 테스트할때 어려움이 생기게 됩니다.
이런 부분을 방지하고자 한다면 싱글톤 클래스가 특정 인터페이스를 상속하게 하는 방법이 있습니다.
interface Something {
void doSomething();
}
class SingletonSomething implements Something {
@Override
public void doSomething() {
// do something
}
}
2. 싱글턴을 구현하는 방법
- public static final 필드
- 정적 팩터리
- 열거 타입 활용(enum)
- LazyHolder 방식
1,2,3번 방식은 Effective java 에서 제안하는 방식이고, 4번은 추가로 소개하고싶은 방법입니다.
1번부터 차례대로 살펴보겠습니다.
2.1 public static final 필드
/**
* 1. public static final 필드 방식
*/
public class Singleton1 {
public static final Singleton1 instance = new Singleton1();
private Singleton1() { }
}
- static field 는 Class loader 의 초기화 단계에서 실제로 객체가 생성됨
- Class loader 의 초기화 단계는 JVM 내부적으로 thread-safe 하게 동작하기 때문에 멀티스레드 환경에서도 안전함
- 이 싱글톤을 사용하지 않더라도 heap 에 객체가 미리 생성되어있어 불필요하게 메모리를 사용하게 됨
2.2 정적 팩터리 방식
정적 팩터리 방식에서는 여러가지 방법을 사용할 수 있습니다.
이 방식들의 공통적인 특징은 다음과 같습니다.
- 통상적으로
getInstance
메소드는 싱글톤을 반환하는 기능을 한다고 알려져있음- 그래서 클라이언트 측에서 싱글톤을 사용한다고 인지하고 사용할 수 있어서 혼동 가능성이 적음
- 그리고 상황에 따라
getInstance
메소드만 수정하면Singleton
<-->Non Singleton
으로 변경할 수 있음
1) static final 필드 활용
/**
* 2. 정적 팩터리 방식
* 1) static final 필드 활용
*/
public class Singleton2_1 {
private static final Singleton2_1 instance = new Singleton2_1();
private Singleton2_1() { }
public static Singleton2_1 getInstance() {
return instance;
}
}
2.1 과 특징이 같습니다.
- static field 는 Class loader 의 초기화 단계에서 실제로 객체가 생성됨
- Class loader 의 초기화 단계는 JVM 내부적으로 thread-safe 하게 동작하기 때문에 멀티스레드 환경에서도 안전함
- 이 싱글톤을 사용하지 않더라도 heap 에 객체가 미리 생성되어있어 불필요하게 메모리를 사용하게 됨
2) Lazy Initialization(지연 초기화)
/**
* 2. 정적 팩터리 방식
* 2) Lazy Initialization(지연 초기화)
*/
public class Singleton2_2 {
private static Singleton2_2 instance;
private Singleton2_2() { }
public static Singleton2_2 getInstance() {
if (instance == null) {
instance = new Singleton2_2();
}
return instance;
}
}
1)번 방법은 사용하지 않아도 불필요하게 객체가 생성된다는 단점이 있었습니다.
하지만 지연 초기화 방법을 사용하면 그러한 점을 해결할 수 있습니다.
- 호출할 때 null 인 경우에 객체가 생성되므로 불필요한 메모리 사용을 줄일 수 있음
- 멀티스레드 환경에서 레이스 컨디션이 발생할 경우, 싱글톤을 만족하지 않을 수 있음
3) Lazy Initialization + Synchronization(지연 초기화 + 동기화)
/**
* 2. 정적 팩터리 방식
* 3) Lazy Initialization + Synchronization(지연 초기화 + 동기화)
*/
public class Singleton2_3 {
private static Singleton2_3 instance;
private Singleton2_3() { }
public static synchronized Singleton2_3 getInstance() {
if (instance == null) {
instance = new Singleton2_3();
}
return instance;
}
}
멀티스레드 환경에서 레이스 컨디션이 발생하는 경우를 방지하기 위해 동기화를 사용하는 방법입니다.
Java 의 synchronized 키워드를 이용해서 동기화 문제를 해결합니다.
- thread-safe 한 싱글톤임
- synchronized 키워드로 인해 성능이 저하될 수 있음
4) LazyHolder 방식
/**
* 4. LazyHolder 방식
*/
public class Singleton4 {
private Singleton4() { }
private static class LazyHolder {
private static final Singleton4 INSTANCE = new Singleton4();
}
public static Singleton4 getInstance() {
return LazyHolder.INSTANCE;
}
}
LazyHolder 방식은 지연 초기화 방식의 성능적 문제를 개선하기 위해 제안된 방법입니다.
synchronized 키워드를 사용하면 JVM 내부적으로 락을 획득하는 작업 때문에 성능 저하가 발생합니다.
하지만 이 방법은 Classloader 에 Singleton4 인스턴스 생성 역할을 맡기며 지연 초기화를 구현하는 방법인데요.
클래스 로드와 초기화 과정은 thread-safe 하게 동작하는데, 이는 synchronized 보다는 성능이 좋다고 합니다.
(참고한 글 : https://medium.com/@joongwon/multi-thread-환경에서의-올바른-singleton-578d9511fd42)
- 지연 초기화로 인해 미사용 객체를 생성하지 않아 메모리를 효율적으로 사용
- synchronized 키워드를 사용한 방식보다는 성능상 이점이 있음
개인적으로는 synchronized 키워드와 Classloader 의 thread-safe 한 동작에서 왜 성능차이가 있는건지 궁금한데요.
이 부분은 조금 더 알아봐야할 것 같습니다.
3. 조금 더 생각해볼 것들
- volatile 키워드를 활용한 방식(volatile singleton)
- 어플리케이션 서버를 수평확장했을 때의 싱글톤 객체