싱글톤의 여러가지 구현 방식과 주의점을 알아보자.
싱글톤 패턴이란?
인스턴스를 오직 한 개만 제공하는 클래스입니다.
시스템 런타임, 환경 세팅에 대한 정보 등, 인스턴스가 여러 개 일 때 문제가 생길 수 있는 경우가 있다.
인스턴스를 오직 한개만 만들어 제공하는 클래스가 필요하다.
싱글톤 구현 방식 1. private 생성자 및 static 메서드 사용
public class Settings {
private static Settings instance;
// 클래스 밖에서 인스턴스 생성 방지
private Settings(){}
public static Settings getInstance(){
if(instance == null) {
return instance = new Settings();
}
return instance;
}
}
해당 방식은 멀티 쓰레드 환경에서 안전하지 않다.
동시에 두개의 스레드가 접근해 new를 동시에 실행한다면 두 스레드가 가진 인스턴스는 달라질 것이다.
싱글톤 구현 방식 2. private 생성자 및 static 메서드 사용 + synchronized
private static Settings instance;
// 클래스 밖에서 인스턴스 생성 방지
private Settings(){}
public static synchronized Settings getInstance(){
if(instance == null) {
return instance = new Settings();
}
return instance;
}
synchronized 키워드를 사용하면 멀티쓰레드 환경에서 안전하다.
하지만 쓰레드가 기다리게 되는 병목현상이 나타날 우려도 있다.
자세힌 알고 싶으면 synchronized에
싱글톤 구현 방식 3. 이른 초기화(eager initalization) 사용하기
public class Settings {
private static final Settings INSTANCE = new Settings();
// 클래스 밖에서 인스턴스 생성 방지
private Settings(){}
public static Settings getInstance(){
return INSTANCE;
}
}
이른 초기화는 클래스가 로딩되는 시점에 미리 만들어 놓은 객체를 리턴해주면 되기 때문에 멀티 스레드 환경에서 안전한다.
대부분의 상황에서는 일반적인 초기화가 지연 초기화보다 낫다. [effective java 참고]
만약 해당 인스턴스를 만드는 과정이 굉장히 길고 오래 걸리고 메모리를 많이 사용함에도 불구하고 해당 객체가 잘 쓰이지 않는 상황일 때 지연초기화를 쓰는 것이 좋다.
싱글톤 구현 방식 4. 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;
}
}
객체 생성을 나중에 하고 싶지만 Synchronized block 이 비용이 신경쓰인다면 다음과 같이 구현하면 된다.
인스턴스가 이미 있는 경우 Synchronized 를 skip 하게 되어 Synchronized block을
동시에 멀티스레드가 들어오는 경우에만 실행된다.
모든 스레드가 Synchronized에
그리고 인스턴스를 필요로 하는 시점에서만 만들 수 있다.
싱글톤 구현 방식 5. static inner 클래스 사용하기
public class Settings {
// 클래스 밖에서 인스턴스 생성 방지
private Settings(){}
static class SettingsHolder {
public static final Settings INSTANCE = new Settings();
}
public static Settings getInstance(){
return SettingsHolder.INSTANCE;
}
}
해당 코드는 멀티스레드 환경에서 안전하고 getInstance 호출 시 SettingsHolder 가 로딩이 되며 인스턴스를 생성하기 때문에 lazy loading 이 된다는 장점이 있다.
static inner 클래스 사용하기
이 방식으로 싱글턴 객체를 생성할 땐 어떻게 하나의 객체만 생성된다는걸 보장할 수 있을까?
클래스 참조시 클래스 로딩이 발생하며 클래스 초기화 과정이 이루어집니다.
(jvm 스펙에 따르면 ) 클래스는 로딩 및 초기화 과정이 딱 한번만 수행됩니다. 따라서 스레드 세이프하여 하나의 객체만 생성된다는 것을 보장할 수 있습니다.
또한 final 키워드가 있어 참조를 변경할 수 없기에 불변입니다.
싱글톤 패턴 구현을 깨트리는 방법
1. 리플렉션으로 싱글톤 깨뜨리기
package singleton;
import java.lang.reflect.Constructor;
import java.lang.reflect.InvocationTargetException;
public class App {
public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException {
Settings settings1 = Settings.getInstance();
Constructor<Settings> constructor = Settings.class.getDeclaredConstructor();
constructor.setAccessible(true);
Settings settings = constructor.newInstance();
System.out.println(settings1 == settings);
}
}
리플렉션을 통해 new을 활용한 것과 같은 인스턴스를 생성해 새로운 인스턴스를 만드는 것을 확인할 수 있다.
리플렉션으로 꺼내온 객체는 싱글톤이 깨지는 것을 확인할 수 있다.
2. 직렬화 , 역직렬화 활용
자바에는 오브젝트를 파일 형태로 디스크에 저장해 놨다가 다시 읽어드리는 직렬화, 역직렬화를 지원한다.
public class Settings implements Serializable { // 직렬화를 위해 Serializable 구현
package singleton;
import java.io.*;
public class App {
public static void main(String[] args) throws Exception {
Settings settings1 = Settings.getInstance();
Settings settings2 = null;
// 직렬화 작업
try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("setting.obj"))) {
out.writeObject(settings1);
}
// 역직렬과 작업
try (ObjectInput in = new ObjectInputStream(new FileInputStream("setting.obj"))) {
settings2 = (Settings)in.readObject();
}
System.out.println(settings1);
System.out.println(settings2);
System.out.println(settings1 == settings2);
}
}
직렬화 역직렬화를 통해서도 싱글톤을 깨뜨릴 수 있다.
역직렬화시 싱글톤을 보장하기 위해 역직렬화 시 쓰이는 readResolve 메서드를 다음과 같이 정의해 주면 싱글톤을 보장할 수 있다.
protected Object readResolve() {
return getInstance();
}
package generic.sigleton;
import java.io.Serializable;
public class Settings implements Serializable {
private static final Settings INSTANCE = new Settings();
private static class SettingsHoler {
private static final Settings INSTANCE = new Settings();
}
public static Settings getInstance(){
return SettingsHoler.INSTANCE;
}
protected Object readResolve() {
return getInstance();
}
}
안전하고 단순하게 싱글톤 구현하기
리플렉션을 통해 싱글톤을 깨지는 것을 방지하는 방법은 없을까?
enum 타입 방식의 싱글턴 - 바람직한 방법
package singleton;
public enum Settings {
INSTACE;
}
enum 클래스는 내부 코드적으로 리플렉션 생성을 막아놓았다.
단점은 클래스 로딩 순간 해당 인스턴스가 미리 만들어진다는 것이다. 객체 생성 비용이 비싸지 않는다면 가장 안전한 방법이다.
enum 은 기본적으로 Serializable 를 상속받고 있고 별다른 조치를 취하지 않아도 안전하게 동일한 인스턴스로 역직렬화가 된다.
- 자바에서 enum 을 사용하지 않고 싱글톤을 구현하는 방법은?
- private 생성자를 만들고 static 메서드를 사용하여 싱글톤을 구현한다.
- private 생성자와 static 메서드를 사용하는 방법의 단점은?
- 지연 로딩 시 멀티 스레드에 안전하기 위해 추가 작업이 필요하다.
- 리플렉션으로 싱글톤을 깨뜨릴 수 있다.
- enum을 사용해 싱글톤 패턴을 구현하는 방법의 장점과 단점은?
- 리플렉션, 역직렬화로 싱글톤을 파괴하는 객체 생성을 방지할 수 있다.
- 객체가 미리 생성된다.
- static inner 클래스를 사용해 싱글톤 패턴을 구현하라.
싱글톤 패턴 문제점
- 싱글톤 패턴을 구현하는 코드 자체가 많이 들어간다.
- 외존관계상 클라이언트가 구체 클래스에 의존한다 -> DIP 를 위반한다.
- 구체클래스.getInstance() 와 같이 꺼내야함
- 클라이언트가 구체클래스의 의존해서 OCP 원칙을 위반할 가능성이 높다.
- 테스트 하기 어렵다. (인스턴스를 미리 박아서 설정이 이미 코드에서 끝남.)
- private 생성자로 자식 클래스를 만들기 어렵다.
- 내부 속성을 변경하거나 초기화하기 어렵다.
- 결론적으로 유연성이 떨어진다. (구체클래스.getInstances 를 해줘야하기 때문에 유연성이 떨어짐, DI 불가)
그러나스프링에서는 싱글톤의 단점을 다 제거한 싱글톤 컨테이너를 사용할 수 있다.
싱글톤 방식의 주의점
- 싱글톤 패턴이든, 스프링 같은 싱글톤 컨테이너를 사용하든, 객체 인스턴스를 하나만 생성해서 공유하는 싱글톤 방식은 여러 클라이언트가 하나의 같은 객체 인스턴스를 공유하기 때문에 싱글톤 객체는 상태를 유지(stateful) 하게 설계하면 안된다.
- 무상태(stateless) 로 설계해야한다
- 특정 클라이언트에 의존적인 필드가 있으면 안된다.
- 특정 클라이언트가 값을 변경할 수 있는 필드가 있으면 안된다.
- 가급적 읽기만 가능해야 한다.
- 필드 대신에 자바에서 공유되지 않은, 지역변수, 파라미터, ThreadLocal 등을 사용해야한다.
- 스프링 빈의 필드에 공유 값을 설정하면 큰 장애로 이어질 수 있다.
public class StatefulService {
private int price;// 상태를 유지하는 필드
public void order(String name, int price) {
System.out.println("name = " + name + ", price = " + price);
this.price = price; // 여기가 문제
}
public int getPrice() {
return price;
}
}
import org.assertj.core.api.Assertions;
import org.junit.jupiter.api.Test;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
import org.springframework.context.annotation.Bean;
import static org.junit.jupiter.api.Assertions.*;
class StatefulServiceTest {
@Test
void statefulServiceSingleton() {
ApplicationContext ac = new AnnotationConfigApplicationContext(StatefulService.class);
StatefulService statefulService1 = ac.getBean(StatefulService.class);
StatefulService statefulService2 = ac.getBean(StatefulService.class);
// ThreadA : A 사용자가 10000 원 주문
statefulService1.order("userA", 10000);
// ThreadB : B 사용자가 10000 원 주문
statefulService1.order("userB", 20000);
// ThreadA : 사용자A 주문 금액 조회
int price = statefulService1.getPrice();
System.out.println("price = " + price);
Assertions.assertThat(statefulService1.getPrice()).isEqualTo(20000);
}
static class TestConfig{
@Bean
public StatefulService statefulService() {
return new StatefulService();
}
}
}
A 사용자가 만원을 주문하였지만 가격조회 결과 2만원으로 바뀐것을 볼 수 있다.
A 와 B 는 같은 객체를 사용하므로 A 사용자가 만원으로 셋팅하였어도 B 사용자가 2만원을 셋팅하였기 때문에
결과적으로 A,B 는 2만원의 결과를 갖는다.
현재 StatefulSercice 의 price 는 공유되는 필드이다. 특정 클라이언트가 값을 변경하였기 때문에 발생한 문제다.
공유 필드를 조심해야한다. 스프링은 항상 무상태로 (stateless) 로 설계하자.
public class StatefulService {
public int order(String name, int price) {
System.out.println("name = " + name + ", price = " + price);
return price;
}
}
공유 변수를 없애고 지역변수를 사용했다.
즉 공유 필드를 없애버렸다.
@Test
void statefulServiceSingleton() {
ApplicationContext ac = new AnnotationConfigApplicationContext(StatefulService.class);
StatefulService statefulService1 = ac.getBean(StatefulService.class);
StatefulService statefulService2 = ac.getBean(StatefulService.class);
// ThreadA : A 사용자가 10000 원 주문
int userAPrice = statefulService1.order("userA", 10000);
// ThreadB : B 사용자가 10000 원 주문
int userBPrice = statefulService1.order("userB", 20000);
// ThreadA : 사용자A 주문 금액 조회
System.out.println("price = " + userAPrice);
}
실행결과 의도한 A 가 주문한 만원이 잘 나온것을 확인 할 수 있다.
스레도별로 독립적인 지역변수를 가지기 때문에(공유 X ) B 의 실행결과가 A 의 변수의 영향을 주지 않는다.
즉 상태를 가지지 않게 설계하였다.
참고 자료
GoF 의 디자인 패턴, 이팩티브 자바
'JAVA' 카테고리의 다른 글
[JAVA] 자원 정리 (try-catch , try-with-resources) (0) | 2024.11.26 |
---|---|
[JAVA] Comparable ,Comparator (1) | 2024.11.23 |
[JAVA] iterator 순회 중 만난 ConcurrentModificationException (1) | 2024.11.20 |
[JAVA ]Set , Map 알아보기, 로드 팩터 (Load Factor)와 HashSet의 리해싱 (0) | 2024.11.19 |
유니코드, UTF-8, 직렬화 (1) | 2024.11.18 |