싱글톤 (Singleton) 패턴
인스턴스를 오직 한개만 제공하는 클래스
싱글톤(Singleton) 패턴은 특정 클래스의 인스턴스가 오직 하나만 생성되도록 보장하고, 그 인스턴스에 대한 전역 접근을 제공하는 디자인 패턴이다. 이 패턴은 주로 시스템의 전역 상태나 설정 정보를 관리하는 데 사용되며, 인스턴스가 여러 개 존재할 때 발생할 수 있는 문제를 방지한다.
시스템 런타임, 환경 세팅에 대한 정보 등, 인스턴스가 여러개 일 때 문제가 생길 수 있는 경우가 있다.
인스턴스를 오직 한개만 만들어 제공하는 클래스가 필요하다.
싱글톤 패턴을 가장 단순히 구현하는 방법
public class Settings {
}
public class App {
public static void main(String[] args) {
Settings settings1 = new Settings();
Settings settings2 = new Settings();
System.out.println(settings1 != settings2); // -> true
}
}
현재 Settings 클래스는 인스턴스 생성에 제한이 없다. (new 키워드로 생성 가능)
또한 인스턴스 생성 시 매번 새로운 인스턴스가 반환된다.
public class Settings {
private Settings () {}
public static Settings getInstance(){
return new Settings();
}
}
public class App {
public static void main(String[] args) {
Settings settings1 = Settings.getInstance();
Settings settings2 = Settings.getInstance();
System.out.println(settings1 != settings2); // -> true
}
}
외부에서 생성자 접근을 막고 전역적으로 접근이 가능한 getInstance() 메소드를 통해서
인스턴스를 생성하여 반환하고 있다.
하지만 여전히 매번 새로운 인스턴스가 반환된다.
public class Settings {
private static Settings instance;
private Settings () {}
public static Settings getInstance(){
if(instance == null){
instance = new Settings();
}
return instance;
}
}
public class App {
public static void main(String[] args) {
Settings settings = Settings.getInstance();
System.out.println(settings == Settings.getInstance()); // -> true
}
}
이제 getInstance()를 호출하면 매번 같은 인스턴스를 반환한다.
이번 방식은 private 생성자와 static 메소드를 사용하는 간단한 싱글톤 구현 방식이었다.
하지만 해당 방식에는 아주 큰 문제점이 있다.
싱글톤 패턴을 멀티 쓰레드 환경에서 안전하게 구현하는 방법
public class Settings {
private static Settings instance;
private Settings () {}
public static Settings getInstance(){
if(instance == null){
instance = new Settings();
}
return instance;
}
}
왜 현재 코드는 쓰레드 세이프 하지 않을까?
시나리오
스레드 A / 스레드 B 존재
1. 스레드 A: getInstance() 메소드 if 조건부 true로 통과
2. 스레드 A 조건부에 통과 했는데 스레드 B로 컨텍스트 스위칭
3. 스레드 B: getInstance() 메소드 if 조건부 true로 통과
4. 스레드 B: 인스턴스 생성 이후 instance 반환 & 스레드 A로 컨텍스트 스위칭
5. 스레드 A: 인스턴스 생성 이후 instance 반환
그럼 해당 getInstance()를 호출했던 곳은 서로 다른 인스턴스를 가리키게 된다.
→ 싱글톤이 아니다.
쓰레드 세이프 하게 만들어 보자!
synchronized 키워드 사용하기
public class Settings {
private static Settings instance;
private Settings () {}
public static synchronized Settings getInstance(){
if(instance == null){
instance = new Settings();
}
return instance;
}
}
synchronized 키워드를 사용해서 getInstance() 메소드를 호출할 때 락을 가지고 있는 스레드만 호출할 수 있도록 하였다.
하지만 성능 이슈가 발생할 수 있다.
자바의 동기화 블럭 처리 방법
Java에서 동기화는 synchronized 키워드를 사용하여 수행된다. 동기화는 특정 코드 블록에 대한 접근을 제어하여 하나의 스레드만 그 블록을 실행할 수 있도록 한다.
Object Level Lock
객체에 대한 락을 사용하는 경우
synchronized 블록을 사용하여 특정 객체에 대한 접근을 제어할 수 있다.
public void synchronizedMethod() {
synchronized (this) {
// synchronized 블록
}
}
Object Level Lock은 모니터 락(Monitor Lock)을 의미한다.
자바에는 모든 객체가 모니터를 가지고 있으며, synchronized 키워드를 사용할 때 이 모니터 락을 활용하여 동기화한다.
public class Example {
private int count = 0;
// Object level lock
public synchronized void increment() {
count++;
}
}
Class Level Lock
클래스 자체에 대한 락을 사용하는 경우.
synchronized 메서드나 synchronized 블록을 클래스 레벨에서 정의할 수 있다.
public static synchronized void synchronizedStaticMethod() {
// 해당 메소드는 클래스에 대한 락을 사용
}
동기화된 메서드 또는 블록이 호출될 때, 해당 메서드나 블록에 대한 락을 소유한 스레드만 그 코드를 실행할 수 있다.
다른 스레드는 락이 해제될 때까지 대기해야한다.
public class Settings {
private static Settings instance;
private Settings () {}
public static synchronized Settings getInstance(){
if(instance == null){
instance = new Settings();
}
return instance;
}
}
getInstance() 메서드 동기화 시 사용하는 락(lock)은 인스턴스의 락인가 클래스의 락인가?
이제는 답할 수 있다.
getInstance() 메서드에 synchronized 키워드를 사용하면, 클래스의 락을 사용하게 된다.
이 방식은 getInstance() 메서드가 호출될 때마다 Class Level 락을 걸기 때문에, 인스턴스가 생성된 후에도 매번 동기화 비용이 발생한다.
이른 초기화(eager initialization) 사용하기
public class Settings {
private static final Settings INSTANCE = new Settings();
private Settings () {}
public static Settings getInstance(){
return INSTANCE;
}
}
이번 방식인 이른 초기화는 스레드 세이프 하다.
클래스 로딩 시점에 이미 초기화되어있기 때문에 그렇다.
단순히 getInstance() 메소드는 인스턴스를 반환하기만 한다.
리소스 낭비
애플리케이션에서 해당 인스턴스를 사용하지 않을 수도 있는데, 클래스 로딩 시점에 이미 인스턴스가 생성.
만약 인스턴스 생성에 큰 비용이 든다면 리소스 낭비가 될 수 있다.
Settings가 사용될지 여부와 관계없이 항상 인스턴스가 생성되기 때문에, 애플리케이션의 메모리 및 초기화 시간을 필요 이상으로 소비할 수 있다.
만약 생성자에서 checked 예외를 던진다면?
이른 초기화 방식에서는 해당 예외를 처리할 수 없다.
그 이유는 클래스가 로딩될 때 이미 생성자가 호출되기 때문이다.
public class Settings {
private static volatile Settings instance;
private Settings () throws IOException{ // -> checked Exception throw
throw new IOException();
}
public static Settings getInstance() throws IOException{
if(instance == null){
synchronized (Settings.class){
if(instance == null){
try {
instance = new Settings();
}
catch (IOException e){
throw new IOException(e); // 인스턴스 생성은 중요 -> custom exception 고려
}
}
}
}
return instance;
}
}
이런 식으로 try catch 블록으로 감싸고 다시 IoException을 던진다.
인스턴스 생성은 중요하니 custom exception을 고려할듯하다.
Double Checked Locking
public class Settings {
private static volatile Settings instance;
private Settings () {}
public static Settings getInstance(){
if(instance == null){
synchronized (Settings.class){
if(instance == null){
instance = new Settings();
}
}
}
return instance;
}
}
double checked locking으로 효율적인 동기화 블럭 만들기
이미 instance가 있는 경우에는 synchronized 블록을 pass 하기 때문에 성능 이슈가 거의 없다.
또한 인스턴스가 필요로 하는 시점에 생성할 수 있기 때문에 이른 초기화 방식에 비해서 리소스 낭비도 적다.
volatile
volatile 키워드는 Java에서 멀티스레드 환경에서 변수의 가시성을 보장하기 위해 사용된다.
자바는 JVM에서 각 스레드마다 독립적인 스택 메모리를 사용한다.
따라서 한 스레드가 변경한 변수는 다른 스레드에서 즉시 볼 수 없다.
volatile로 선언된 변수는 메인 메모리(JVM 힙 메모리)에서 직접 읽고 쓰기를 수행한다.
이는 스레드가 변수를 읽거나 쓸 때 항상 메인 메모리에서 작업하게 하여, 다른 스레드가 그 변화를 감지할 수 있도록 한다.
메모리 배리어(Memory Barrier)
volatile 변수에 대한 읽기와 쓰기 작업은 메모리 배리어를 생성한다. 이 배리어는 JVM에게 해당 변수에 대한 접근을 최적화하지 않도록 지시한다. 즉, 컴파일러와 CPU는 volatile 변수를 사용할 때 최적화를 수행하지 않고, 항상 메인 메모리에서 값을 읽고 쓴다는 보장을 제공한다.
쓰기 연산: volatile 변수를 쓰는 연산 전에 모든 이전 쓰기 작업이 완료되도록 보장한다.
읽기 연산: volatile 변수를 읽는 연산 후에는 모든 이후의 읽기 및 쓰기 작업이 그 변수의 최신 값에 대해 수행되도록 보장한다.
참고: 여기서 말하는 최적화를 수행하지 않는다의 의미는 CPU 캐시에서 읽는게 아닌 메인 메모리(JVM 힙)에서 읽기를 의미함
하지만 원자성을 보장하지 않는다.
volatile 변수에 대한 복합 연산(예: increment)은 원자적이지 않기 때문에 여러 스레드에서 동시에 접근할 경우 race condition이 발생할 수 있다.
즉 가시성만 보장, 원자성은 보장하지 않는다.
if( instance == null )
이중 검사(double-checked locking) 방식에서는 instance가 이미 초기화된 경우, 성능 최적화를 위해 동기화 블록에 들어가지 않고 빠르게 반환하고자 한다. 하지만 만약 instance가 volatile로 선언되지 않았다면, 한 스레드에서 instance가 초기화되었더라도 다른 스레드는 여전히 null로 인식할 수 있다. (가시성 x)
그래서 volatile을 사용하여 가시성을 보장하는 것
volatile을 사용하는 이유 간단 정리
이중 검사에서 synchroized 키워드로 원자성을 확보하고 성능 이슈가 없게 하려고 맨 앞에 if(instance == null) 코드를 사용하는 건데 instance 변수에 대한 가시성이 확보되지 않아 이미 초기화되었는데도 synchroized 블록에 진입하여 성능 이슈가 생길까봐 volatile 키워드를 붙여 가시성을 확보하는 것
static inner 클래스 사용하기 (권장하는 방식)
public class Settings {
private Settings () {}
static class SettingsHolder {
private static final Settings INSTANCE = new Settings();
}
public static Settings getInstance(){
return SettingsHolder.INSTANCE;
}
}
Lazy Initialization (지연 초기화)
Settings 클래스의 인스턴스는 클래스 로딩 시점이 아니라 정적 내부 클래스인 SettingsHolder가 로드될 때 생성된다.
이로 인해 사용할 때까지 인스턴스가 생성되지 않으므로 지연 초기화가 이루어진다.
Thread Safety (스레드 안전성)
이 방식은 JVM의 클래스 로딩 메커니즘에 의해 스레드 안전성이 보장된다.
클래스 로딩 시점에 JVM은 해당 클래스를 단일 스레드 환경에서 초기화하므로 동기화(synchronized)나 volatile 키워드를 사용할 필요 없이 자동으로 스레드 안전한 인스턴스 생성이 이루어진다.
성능 최적화
Double-Checked Locking 방식에서는 첫 번째 if와 동기화 블록을 이용한 추가 검사가 필요하지만, 이 방식에서는 클래스 로딩 메커니즘만을 활용하여 동기화 없이도 지연 초기화와 스레드 안전성을 확보한다. 이는 성능적으로 매우 효율적
클래스 메모리에 로딩 시점 → 최초 사용 시점
싱글톤 패턴 구현 방법을 깨트리는 방법
리플렉션 사용
리플렉션이란?
자바에서 클래스, 메소드, 필드 등의 메타데이터에 접근하고, 실행 및 수정할 수 있는 강력한 기능
이 기능을 사용하면 컴파일 타임에 알 수 없는 클래스나 객체의 정보에 동적으로 접근할 수 있다.
https://20240228.tistory.com/323
public class App {
public static void main(String[] args) throws Exception{
Settings settings1 = Settings.getInstance();
Constructor<Settings> constructor = Settings.class.getDeclaredConstructor();
constructor.setAccessible(true);
Settings settings2 = constructor.newInstance();
System.out.println(settings1 != settings2); // -> true
}
}
getDeclaredConstructor()를 사용해서 생성자를 가져오고
private 생성자지만 setAccessible(true)를 통해서 접근 가능하게 만든 이후
newInstance()를 통해서 객체를 생성하였다.
이렇게 리플렉션을 활용해서 싱글톤을 깨트렸다.
직렬화 & 역직렬화 사용하기
직렬화 정의 (Serialization)
객체를 바이트 스트림으로 변환하는 과정
직렬화를 통해서 객체의 상태를 저장하고 이후에 객체를 재구성할 수 있다.
직렬화 사용 목적
객체의 상태를 파일에 저장하거나 데이터베이스에 저장
네트워크를 통해 객체를 전송
직렬화 가능한 객체
직렬화할 클래스는 java.io.Serializable 인터페이스를 구현해야 한다.
해당 인터페이스는 아무런 메소드가 없는 마커 인터페이스이다.
마커 인터페이스(Marker Interface)
메소드나 필드가 없는 빈 인터페이스이다.
이 인터페이스는 특정 기능이나 속성을 가진 객체를 식별하기 위해 사용
마커 인터페이스는 주로 런타임에 객체의 타입을 확인하고, 특정 기능을 제공하기 위해 사용
대표적으로 Serialzable & Cloneable이 있다.
이 두 인터페이스는 각각 객체의 직렬화 가능성과 복제 가능성을 나타낸다.
역직렬화(Deserialization)
바이트 스트림을 통해 객체를 재구성하는 과정
즉 저장된 객체의 상태를 읽어와 원래의 객체로 복원한다.
Transient 키워드
해당 키워드가 붙은 필드는 직렬화 과정에서 제외된다.
즉, 해당 필드는 직렬화 시 저장되지 않고, 역직렬화 시 기본값으로 초기화
serialVersionUID
자바에서 직렬화된 객체의 버전을 식별하는 고유한 ID이다.
이는 직렬화된 객체와 해당 객체를 역직렬화할 때 일치하는지 확인하는데 사용된다.
serialVersionUID를 명시적으로 선언하지 않으면 자바 컴파일러가 자동으로 생성하지만, 이 경우 클래스 구조가 변경될 때마다 ID가 변경될 수 있어 역직렬화 시 InvalidClassException이 발생할 수 있다.
따라서, serialVersionUID를 명시적으로 선언함으로써 클래스가 변경되더라도 안정성을 유지하고, 이전에 직렬화된 객체를 안전하게 역직렬화할 수 있다.
try-with-resources
자원을 자동으로 해제하기 위한 구조
이 블럭 안에 선언된 자원은 try 블럭이 종료될 때 자동으로 닫힌다.
try-with-resources는 AutoCloseable 또는 Closeable 인터페이스를 구현하는 객체에 대해 사용할 수 있다.
일반적으로 파일 입출력, 네트워크 연결, 데이터베이스 연결 등에서 사용된다.
이 구조는 코드의 가독성을 높이고, 자원 해제를 누락할 가능성을 줄여준다.
public class Settings implements Serializable {
private Settings () {}
static class SettingsHolder{
private static final Settings INSTANCE = new Settings();
}
public static Settings getInstance(){
return SettingsHolder.INSTANCE;
}
}
public class App {
public static void main(String[] args) throws Exception{
Settings settings1 = Settings.getInstance();
// 직렬화 (try-with-resources)
try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("settings.obj"))){
out.writeObject(settings1);
}
Settings settings2 = null;
// 역직렬화
try (ObjectInput in = new ObjectInputStream(new FileInputStream("settings.obj"))){
settings2 = (Settings) in.readObject();
}
System.out.println(settings1 != settings2); // true
}
}
마찬가지로 싱글톤이 깨졌다.
settings.obj
역직렬화 대응법
public class Settings implements Serializable {
private Settings () {}
static class SettingsHolder{
private static final Settings INSTANCE = new Settings();
}
public static Settings getInstance(){
return SettingsHolder.INSTANCE;
}
protected Object readResolve(){
return getInstance();
}
}
protected Object readResolve() 메소드를 추가하였다.
본래는 해당 메소드에서 생성자를 사용해서 인스턴스를 생성하여 반환하지만
싱글톤 유지를 위해 getInstance() 반환값을 return
이전 코드(App)을 다시 실행하면 이제 false가 나온다.
→ 싱글톤 유지
안전하고 단순하게 구현하는 방법 (enum)
이전까지 멀티 쓰레드에서 안전한 싱글톤 구현 방법을 알아봤다.
또한 멀티 쓰레드에는 안전하지만 리플렉션 / 직렬화 & 역직렬화를 통해서 싱글톤이 깨지는 케이스도 확인하였다.
직렬화 & 역직렬화 방법은 막는 방법이 있었지만 리플렉션은 막을 수 있는 방법이 없었다.
먼저 클래스 로딩 순서에 대해서 정리하고 넘어가자
public enum Color{
RED
}
public class App{
}
public class App2{
}
public class Main{
public static void main(String[] args) {
App app new App ();
}
}
JVM 로딩 순서
1. 애플리케이션 시작 지점에 Color enum 클래스 로드
2. Main 클래스가 애플리케이션의 진입접이기 때문에, JVM이 Main 클래스 로드
3. main 메소드 내에서 App 클래스의 인스턴스를 생성하는 코드가 존재하므로, JVM은 App 클래스 로드
4. App2 클래스는 애플리케이션이 실행되는 동안 참조되지 않기 때문에 메모리에 올라가지 않는다.
Enum을 사용한 싱글톤 구현
public enum Settings {
INSTANCE;
Settings(){} // default -> private not allowed public
private int number = 10;
public int getNumber(){
return number;
}
}
public class App {
public static void main(String[] args) throws Exception{
Settings instance = Settings.INSTANCE;
System.out.println(instance.getNumber());
Settings instance2 = Settings.INSTANCE;
System.out.println(instance == instance2);
}
}
출력
10
true
public enum Settings {
INSTANCE;
}
이렇게만 해도 싱글톤이다.
리플렉션을 활용해서 새로운 객체 생성 시도
public class App {
public static void main(String[] args) throws Exception{
Settings settings1 = Settings.INSTANCE;
Constructor<Settings> constructor = Settings.class.getDeclaredConstructor();
constructor.setAccessible(true);
Settings settings2 = constructor.newInstance();
System.out.println(settings1 == settings2);
}
}
결과
Exception in thread "main" java.lang.NoSuchMethodException: com.example.desginpatternstudy.Settings.<init>()
at java.base/java.lang.Class.getConstructor0(Class.java:3585)
at java.base/java.lang.Class.getDeclaredConstructor(Class.java:2754)
at com.example.desginpatternstudy.App.main(App.java:9)
해당 메소드가 없다고 나온다. NoSuchMethodException
바이트 코드를 확인해 보자.
인텔리제이: view → Show Bytecode
<init>은 생성자 메소드를 의미한다.
보면 생성자가 String 매개변수를 받는다.
다시 리플렉션을 사용해서 생성 시도
public class App {
public static void main(String[] args) throws Exception{
Settings settings1 = Settings.INSTANCE;
Settings settings2 = null;
Constructor<?>[] constructors = Settings.class.getDeclaredConstructors();
for (Constructor<?> constructor : constructors) {
constructor.setAccessible(true);
settings2 = (Settings) constructor.newInstance("INSTANCE");
}
}
}
결과
Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects
at java.base/java.lang.reflect.Constructor.newInstanceWithCaller(Constructor.java:492)
at java.base/java.lang.reflect.Constructor.newInstance(Constructor.java:480)
at com.example.desginpatternstudy.App.main(App.java:13)
IllegalArgumentException이 발생한다. Cannot relectively create enum objects
enum은 reflection을 통한 인스턴스 생성을 막아놨다.
→ 리플렉션에 인한 인스턴스 생성을 막음으로서 싱글톤을 보장한다.
enum을 사용하면 지연 초기화(lazy initialization)가 불가능하다.
맨 처음 클래스 로딩 순서에서 보여줬듯이 애플리케이션이 시작하면 enum 타입은 바로 메모리에 로딩된다.
물론 해당 인스턴스를 생성해 놓고 사용을 안 하는 일은 거의 없겠지만..
초기화 시점에 미리 리소스를 할당하게 된다.
enum은 직렬화 & 역직렬화 / 리플렉션 모두에 안전한다.
리플렉션에 대한 안전은 이미 설명했다.
enum은 Enum 클래스를 상속해서 구현하였다.
Enum
public abstract class Enum<E extends Enum<E>>
implements Constable, Comparable<E>, Serializable {
// 생략 ...
}
보면 Serializable 마커 인터페이스를 구현하고 있다.
enum의 직렬화 / 역직렬화는 별도의 장치(readResolve())가 없어도 안전하게(싱글톤 유지) 동작한다.
public enum Settings { // extends Enum implments Serializable
INSTANCE;
}
public class App {
public static void main(String[] args) throws Exception{
Settings settings1 = Settings.INSTANCE;
Settings settings2 = null;
try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("settings.obj"))){
out.writeObject(settings1);
}
try (ObjectInput in = new ObjectInputStream(new FileInputStream("settings.obj"))){
settings2 = (Settings) in.readObject();
}
System.out.println(settings1 == settings2); // -> true
}
}
보다시피 직렬화 역직렬화 이후에도 같은 인스턴스이다.
싱글톤 패턴(Singleton) 패턴 복습
static inner 클래스를 사용해 싱글톤 패턴을 구현하라
public class SingleTon{
private SingleTon () {}
static class SingleTonHolder{
private static final SingleTon INSTANCE = new SingleTon();
}
public static SingleTon getInstance(){
return SingleTonHolder.INSTANCE;
}
}
장점: 스레드 세이프 & 지연 초기화
단점: 리플렉션을 통한 방식은 방어 불가능
enum을 사용해 싱글톤 패턴의 구현하는 방법의 장점과 단점은?
장점
리플렉션에도 안전한 유일한 방식
직렬화 & 역직렬화를 통한 인스턴스 생성 방식에 대한 안전
단점
상속이 불가능하다.
지연 초기화가 불가능하다.
private 생성자와 static 메소드만을 사용해서 싱글톤 구현의 단점은?
→ 스레드 not safe
싱글톤 (Singleton) 패턴 실무에서는 어떻게 쓰이나?
자바 java.lang.Runtime
public class App {
public static void main(String[] args) {
Runtime runtime = Runtime.getRuntime();
// new Runtime(); private 생성자
System.out.println(runtime.maxMemory());
System.out.println(runtime.freeMemory());
}
}
스프링에서 빈의 스코프 중에 하나로 싱글톤 스코프
@Configuration
public class AppConfig {
@Bean
public String hello(){
return "hello";
}
}
public class App {
public static void main(String[] args) {
ApplicationContext applicationContext = new AnnotationConfigApplicationContext(AppConfig.class);
String hello1 = applicationContext.getBean("hello", String.class);
String hello2 = applicationContext.getBean("hello", String.class);
System.out.println(hello1 == hello2); // true
}
}
싱글톤 패턴을 적용하는건 아니지만
싱글톤 스코프면 ApplicationContext 내에서 단 하나의 인스턴스만 생성하고, 이 인스턴스를 모든 요청에 대해 공유한다.
다른 디자인 패턴(빌더, 퍼사드, 추상 팩토리 등) 구현체의 일부로 쓰이기도 한다
참고자료
https://www.inflearn.com/course/%EB%94%94%EC%9E%90%EC%9D%B8-%ED%8C%A8%ED%84%B4
'Computer Sience > Desgin Pattern' 카테고리의 다른 글
[Design Pattern] 어댑터 (Adapter) 패턴 (2) | 2024.10.22 |
---|---|
[Design Pattern] 프로토타입 (Prototype) 패턴 (2) | 2024.10.20 |
[Design Pattern] 빌더(Builder) 패턴 (6) | 2024.10.19 |
[Design Pattern] 추상 팩토리(Abstract Factory) 패턴 (0) | 2024.10.18 |
[Design Pattern] 팩토리 메소드(Factory Method) 패턴 (1) | 2024.10.18 |