싱글톤 패턴이란?
클래스의 인스턴스를 오직 하나만 생성하도록 하며, 어디서든지 그 인스턴스에 접근할 수 있도록 하기 위한 패턴
싱글톤 패턴은 간단히 말해 단 하나의 인스턴스만 생성해서 사용할 수 있어야 한다는 것이다.
왜 필요할까?
쉽게 생각하자면 게임의 설정 화면같은 경우는 오직 하나만 존재해야 할 것이다.
만약 설정 화면이 여러 인스턴스가 존재해서 A라는 설정화면에서는 Q라는 버튼을 눌렀을 때 공격을 하게끔 설정하였는 데 B라는 설정화면에서는 마우스 왼쪽 클릭을 통해 공격을 하게끔 설정해놓는다면 매우 헷갈릴 것이다.
이런 경우 설정 화면을 단 하나의 인스턴스에서만 설정할 수 있게끔 제공을 해야 한다.
또한, 하나의 인스턴스만 사용하니 메모리 측면에서 이점을 가져갈 수 있고 인스턴스가 전역으로 사용되는 인스턴스이기때문에 다른 클래스의 인스턴스 간에 데이터 공유가 쉽다는 이점에서 사용할 수 있다.
그래서 DBCP(DataBase Connection Pool)처럼 공통된 객체를 여러개 생성해서 사용해야 하는 상황에서 많이 사용한다. (쓰레드풀, 캐시, 설정 등)
문제점은 없을까?
싱글톤은 앞써 말한 것과 같은 이점을 얻을 수 있지만 다음과 같은 많은 문제점도 가지고 있다.
먼저 싱글톤 패턴을 구현하는 코드 자체가 많이 필요하다. 아래에서도 볼 수 있겠지만 적지 않은 코드를 사용해야 싱글톤 패턴을 사용할 수 있다.
두번째는 테스트하기 어렵다.싱글톤 인스턴스는 오직 하나만 존재해야하기 때문에 테스트가 격리된 환경에서 수행되게 하려면 매번 인스턴스의 상태를 초기화 시켜주어야 할 것이다.
세 번째는 의존 관계상 클라이언트가 구체 클래스에 의존하게 된다. new
키워드를 통해 클래스 안에서 객체를 생성하고 있으므로, 이는 SOLID 원칙 중 DIP를 위반하게 되고 OCP원칙 또한 위반할 가능성이 높다.
어떻게 만들까?
고전적인 싱글톤 패턴
앞써 알아봤듯이 싱글톤은 두 가지 조건을 만족해야한다.
첫번째로는 인스턴스가 단 하나만 생성되어야 한다는 점이고 두 번째로는 어디서든지 그 인스턴스에 접근할 수 있어야 한다는 점이다.
첫번째를 먼저 생각해보자.
public class Main {
public static void main(String[] args) {
String str1 = new String("a");
String str2 = new String("a");
System.out.println(str1 == str2); // false
}
}
우리는 인스턴스를 생성할 때 new
키워드로 생성한다. 이 때 생성되어지는 인스턴들은 모두 다른 주소값을 가지고 있다. 이는 우리가 앞써 공부해보았던 String
을 통해 확인할 수 있다.
그렇다면 new
키워드를 사용하지 못하게 해야한다는 것인데 어떻게 할 수 있을까?
바로 생성자를 private
하게 만드는 것이다. 간단히 생각하면 new
키워드는 생성자를 호출하도록 되어 있으니 생성자를 클라이언트 쪽에서 호출할 수 없도록 막아버리는 것이다.
public class Singleton {
private Singleton() {
}
}
new
키워드를 사용하지 못하면 인스턴스를 어떻게 생성해야할까? 이를 두번째 조건도 만족하면 해결하는 방법을 알아보자. 클라이언트쪽에서 인스턴스를 만들 수 없으니 결국 Singleton라는 객체 안에서 인스턴스를 생성해야할 것이고 어디서든 접근할 수 있도록 해야할 것이다.
이는 static
메서드를 통해 해결할 수 있다.
public class Singleton {
private Singleton() {
}
public static Singleton getInstance() {
return new Singleton();
}
}
public class Main {
public static void main(String[] args) {
Singleton singleton = Singleton.getInstance();
Singleton singleton2 = Singleton.getInstance();
System.out.println(singleton == singleton2); // false
}
}
static
메서드를 통해 어디서든 접근하여 인스턴스를 반환할 수 있게 되었다. 하지만 아직까진 하나의 인스턴스만 생성하는 조건을 만족하지 못했다.
이를 만족하려면 항상 인스턴스를 생성하여 반환해주지 말고 인스턴스가 만들어 있지 않으면 만들어서 반환해주고 인스턴스가 만들어져 있다면 그 인스턴스을 반환하면 된다.
이를 private static
필드를 통해서 구현할 수 있다.
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
public class SingleThread {
public static void main(String[] args) {
Singleton singleton1 = Singleton.getInstance();
Singleton singleton2 = Singleton.getInstance();
System.out.println(singleton1);
System.out.println(singleton2);
System.out.println(singleton1 == singleton2);
}
}
결과를 보면 getInstance
메서드를 두 번 사용해서 인스턴스를 만들었지만 같은 인스턴스가 반환된 것을 볼 수 있다.
해당 구현방법을 Lazy Initialization라고 한다.
Multi Thread 환경에서의 문제
우리는 어플리케이션을 만들 때 대부분 Multi Thread를 사용하게 된다.
이러한 멀티 스레드 환경에서 위에서 구현한 싱글톤은 안전할까? 답은 아니다.
getInstance 메서드를 Multi Thread 환경에서 사용하게 되면 위와 같은 상황이 벌어질 수 있다.
Thread A에서 instance == null
구문을 통과하여 조건문 안으로 들어가 instance 변수에 인스턴스 할당 전인 상태에서 동시에 Thread B가 instance == null
구문을 확인한다면 아직 instance에 null
이 들어있으니 통과하게 되고 결국 Thread A,B 둘다 new Singleton()
을 호출하게 된다.
위의 상황을 코드로 확인해보자.
public class Singleton {
private static Singleton instance;
private Singleton() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
public class MultiThread {
public static void main(String[] args) {
new Thread(() -> {
Singleton singleton = Singleton.getInstance();
System.out.println(singleton);
}).start();
new Thread(() -> {
Singleton singleton = Singleton.getInstance();
System.out.println(singleton);
}).start();
}
}
위의 상황을 구현하기 위해 Singleton
의 생성자에 Thread.sleep(1000)
을 실행해 1초정도 멈추게 하였다.
그랬더니 아까와는 다르게 다른 인스턴스가 생성되는 것을 볼 수 있다.
Multi Thread 환경에서의 문제 해결법
(1) Sychronized 키워드 사용
첫번째로는 Sychronized 키워드를 사용해 동기화 시켜주는 것이다.
스레드의 동기화(synchronization)란, 한 스레드가 진행 중인 작업을 다른 스레드가 간섭하지 못하도록 막는 것이다. 여기서 도입된 개념이 임계 영역(critical section)과 잠금(lock)이다.
공유 데이터 즉, 공유 객체가 가지고 있는 lock을 획득한 단 하나의 스레드만 임계 영역 내의 코드를 수행할 수 있게 한다. 이후 해당 스레드가 임계 영역을 모두 수행하고 벗어났다면 lock을 반납해서 다른 스레드도 lock을 얻어 임계 영역의 코드를 수행할 수 있도록 한다.
추가적으로 static synchronized
메서드는 클래스 단위로 lock을 공유한다.
이를 싱글톤에 사용해보자.
public class Singleton {
private static Singleton instance;
private Singleton() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
아까는 다른 인스턴스가 생성되었지만 이번에는 같은 인스턴스가 생성된다.
하지만 동기화를 처리하는 과정이 추가되기때문에 성능 저하가 생길 여지가 있다. ( 거의 100배 정도 저하 ).
(2) 이른 초기화 ( eager initialization ) 사용
만약 해당 인스턴스를 만드는 비용이 그렇게 비싸지 않다면 미리 만들어 문제를 해결할 수 있다.
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static Singleton getInstance() {
return INSTANCE;
}
}
이런식으로 구현하게 된다면 클래스가 로딩될 때 JVM에서 Singleton의 하나뿐인 인스턴스를 생성해 준다. JVM에 클래스가 로딩되고 초기화될 때는 Multi Thread 환경이라도 순차적으로 동작하며 오직 한 개의 클래스만 로딩됨을 보장한다.
따라서 인스턴스는 미리 생성하였고 그저 반환만 해주면 되니 Thread-Safe하다.
하지만 해당 클래스가 로딩되는 시점에 항상 인스턴스가 생성되고 메모리를 차지 하고 있으니 사용하지 않는 다면 비효율적인 방법이 될 수 있다.
(클래스가 로딩되는 시점은 클래스의 인스턴스 생성, 클래스의 정적 변수 사용 - final X
, 클래스의 정적 메서드 호출할 때이다.)
(3) Double-checked-Locking (DCL)
이른 초기화의 단점을 회피 즉, 인스턴스를 사용이 될 때 만들고 싶지만 synchronized
를 사용하기에는 성능이 걱정될 때 사용할 수 있다.
synchronized 블럭
을 사용하면 내가 원하는 부분만 동기화 시켜줄 수 있다. ( static synchronized 블럭
의 경우 클래스 단위로 lock 공유 )
이렇게 만들어주면 인스턴스를 생성되어 있는 지 확인한 다음 생성되어 있지 않았을 때만 동기화할 수 있다. 즉, 처음 인스턴스를 만들때만 동기화하고 그 이후로는 동기화하지 않고 그저 생성한 인스턴스를 반환해주면 된다.
고로, 동기화로 인한 성능 저하를 줄일 수 있다.
public class Singleton {
private static volatile Singleton instance;
private Singleton() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
getInstance()
메서드가 바뀐 것 말고도 필드에 volatile
키워드가 추가된 것을 볼 수 있다.
volatile
스레드를 이용하게 되면, 각각의 스레드는 성능을 끌어올리기 위해 Cache Memory
를 사용한다.
메인 메모리에서 스레드로 값을 가져와 사용할 때는 Registers
→ Cache Memory
→ Main Memory
순서로 진행되고, 스레드에 있는 값을 메인 메모리로 가져올 때는 반대의 순서로 진행하게 된다.
문제는 메인메모리와 스레드의 Registers
간에 데이터의 이동이 있기 때문에 그 이동이 진행되는 동안 빈틈이 생기게 된다.
Thread A에 의해 인스턴스가 생성되었지만 Registers
에서 Main Memory
로 가져가는 도중 즉, 아직 인스턴스가 저장되지 않았을 때 만약, Thread B에서 instance를 조회하게 되면 null값이 나와 인스턴스를 또 생성하게 된다.
이러한 문제를 해결해주는 것이 volatile
키워드이다.
volatile
는 각 스레드가 해당 변수를 Cache Memory
에서 읽는 것이 아닌, Main Memory
에서만 읽도록 보장하는 것이다.
이렇게 되면 위에서 발생한 문제는 발생하지 않지만 자바 버전 1.5 이상에서만 유효하다.
(4) Initialization-on-demand holder idiom (holer에 의한 초기화 방식) - 권장하는 방식
public class Singleton {
private static class singletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton() {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
}
public static Singleton getInstance() {
return singletonHolder.INSTANCE;
}
}
코드를 보며 static inner class
를 사용하여 구현하고 있다.
외부 클래스는 내부클래스를 멤버 변수처럼 사용하니 singletonHolder.INSTANCE
로 인스턴스를 사용할 수 있다.
그렇다면 왜 이게 Thread-Safe
할까? 이유는 클래스 로더에 있다.
클래스 로더는 외부 클래스를 로딩할 때 내부 클래스는 로딩하지 않는다. 즉, 위의 코드에서는 getInstance()를 호출하여 singletonHolder
클래스가 로딩되기 전까지는 안에 있는 인스턴스를 생성하지않는다. ( Lazy Loading
)
또한, 위에서 말했듯이 클래스의 로딩은 단 한번만 수행되니 Multi Thread환경에서 getInstance()를 동시에 호출하여도 singletonHolder
는 한번만 로딩되어 인스턴스를 생성하여 가지고 있으니 그저 값만 반환해주면 된다.
따라서, 구현도 간편하고 Thread-Safe, Lazy Loading
까지 가능하여 해당 방식은 권장하는 방식 중 하나이다.
싱글톤 패턴 깨는 방법
사실 앞서 살펴본 싱글톤 방식은 완전히 안전하다고 보장하지 못한다. 아래 예제를 통해 살펴보자.
(1) Reflection
Reflection은 간단하게 구체적인 클래스 타입을 알지 못해도, 그 클래스의 메서드, 타입, 변수들에 접근할 수 있도록 해주는 자바 API이다.
public class Main {
public static void main(String[] args) throws
NoSuchMethodException,
InvocationTargetException,
InstantiationException,
IllegalAccessException {
Singleton singleton1 = Singleton.getInstance();
Constructor<Singleton> singletonConstructor = Singleton.class.getDeclaredConstructor();
singletonConstructor.setAccessible(true);
Singleton singleton2 = singletonConstructor.newInstance();
System.out.println(singleton1);
System.out.println(singleton2);
System.out.println(singleton1 == singleton2);
}
}
Reflection의 getDeclaredConstructors
메서드를 통해 생성자 정보를 가져온다.
가져온 생성자 정보를 setAccessible
메서드를 통해 접근 가능하도록 바꾸고 newInstance
메서드를 호출하면 생성자가 호출되어 new
를 통한 인스턴스 생성과 동일하게 된다.
그래서 두 인스턴스의 값이 다르게 나오는 것이다.
(2) 직렬화 / 역직렬화
직렬화는 메모리를 디스크에 저장하거나, 네트워크 통신에 사용하기 위한 형식으로 변환하는 것이다.
Java 직렬화는 자바 시스템 내부에서 사용되는 객체 또는 데이터를 외부의 자바 시스템에도 사용할 수 있도록 바이트 형태로 데이터를 변환한다. ( Serializable
인터페이스를 구현해야 한다.)
이를 싱글톤에 적용하면 싱글톤이 깨지게 된다.
public class Main {
public static void main(String[] args) throws IOException, ClassNotFoundException {
Singleton singleton1 = Singleton.getInstance();
Singleton singleton2 = null;
try (ObjectOutput objectOutput = new ObjectOutputStream(new FileOutputStream("singleton1.obj"))) {
objectOutput.writeObject(singleton1);
}
try (ObjectInput objectInput = new ObjectInputStream(new FileInputStream("singleton1.obj"))) {
singleton2 = (Singleton)objectInput.readObject();
}
System.out.println(singleton1);
System.out.println(singleton2);
System.out.println(singleton1 == singleton2);
}
}
Serializable
인터페이스를 구현한 클래스는 역직렬화가 진행될때 readObject
를 호출하면서 새로운 인스턴스를 생성하기 때문이다.
(2) - 1. 역직렬화 대응 방안
역직렬화를 진행할 때 readResolve
메서드를 호출하여 객체를 생성한다.
그러니 해당 메서드를 직접 정의하여 역직렬화 과정에서 만들어진 인스턴스 대신에 기존에 생성된 인스턴스를 반환하도록 하면 된다. readObject
메서드가 있더라도 readResolve
메서드에서 반환한 인스턴스로 대체된다.
public class Singleton implements Serializable {
private Singleton() {
}
public static Singleton getInstance() {
return singletonHolder.INSTANCE;
}
private Object readResolve() {
return getInstance();
}
private static class singletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
}
가장 안전하게 싱글톤을 만드는 방법
문제를 해결했더니 그것을 모두 깨는 방법이 존재하여 문제가 또 생겨버렸다.
하지만 이는 enum타입을 사용하면 쉽게 해결된다.
public enum EnumSingleton {
INSTANCE;
public static void doSomething() {
// to do
}
}
enum 타입은 기본적으로 직렬화가 가능하므로 Serializable
인터페이스를 구현할 필요도 없고, Reflection
을 이용한 enum의 인스턴스화를 금지하기 때문에 두 문제가 해결된다. 인스턴스가 JVM 내에 하나만 존재한다는 것이 보장되므로, Java에서 싱글톤을 가장 안전하게 만드는 방법이다.
하지만 선언과 동시에 초기화되는 eager initialization의 단점이 존재한다.
실무에서는 어떻게 쓰이나?
(1) 스프링 컨테이너
@Configuration
public class SpringConfig {
@Bean
public String hello() {
return "hello";
}
}
public class Main {
public static void main(String[] args) {
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(SpringConfig.class);
String hello = applicationContext.getBean("hello", String.class);
String hello2 = applicationContext.getBean("hello", String.class);
System.out.println(hello == hello2); // true
}
}
스프링 컨테이너는 싱글톤 패턴을 적용하지 않아도 객체 인스턴스를 싱글톤으로 관리하며 싱글톤 컨테이너라고 불린다.
이렇게 싱글톤 객체를 생성하고 관리하는 기능을 싱글톤 레지스트리라고 하며 이 기능 덕분에 싱글톤 패턴의 모든 단점을 해결하면서 객체를 싱글톤으로 유지할 수 있다.
이를 통해 고객의 요청이 올 때마다 객체를 생성하는 것이 아닌, 이미 만들어진 객체를 공유해서 효율적으로 재사용할 수 있다.
(2) 자바 java.lang.Runtime
Every Java application has a single instance of class
Runtime
that allows the application to interface with the environment in which the application is running. The current runtime can be obtained from thegetRuntime
method.An application cannot create its own instance of this class.
Java docs : class Runtime
이른 초기화로 구현되어 있다.
Runtime 인스턴스는 애플리케이션이 실행되고 있는 환경에 대한 정보를 가지고 있다.
public class Main {
public static void main(String[] args) {
Runtime runtime = Runtime.getRuntime();
System.out.println(runtime.maxMemory());
System.out.println(runtime.freeMemory());
}
}
댓글