TIL 7 | 싱글톤 패턴 알아보기

dereck·2024년 11월 27일

TIL

목록 보기
7/21

싱글톤 패턴이란?

싱글톤 패턴이란 특정 클래스에 객체 인스턴스가 하나만 만들어지도록 해 주는 패턴이다. 클래스 인스턴스를 하나만 만들고, 그 인스턴스로의 전역 접근을 제공한다. 싱글톤 패턴을 사용하면 전역 변수를 사용할 때와 마찬가지로 객체 인스턴스를 어디서든지 액세스 할 수 있게 만들 수 있으며, 전역 변수를 쓸 때처럼 여러 단점을 감수할 필요도 없다.

이런 싱글톤 패턴은 객체를 쓸 때 인스턴스가 2개 이상이면 프로그램이 이상하게 돌아간다든가, 자원을 불필요하게 잡아먹는다든가, 결과에 일관성이 없어진다든가 하는 문제가 있을 경우에 사용된다. 예시로는 스레드 풀, 캐시, 대화상자, 사용자 설정, 레지스트리 설정을 처리하는 객체 등에서 사용된다.

고전적인 싱글톤 패턴 구현하기

public class Singleton {
	// Singleton 클래스의 하나뿐인 인스턴스를 저장하는 정적 변수
	private static Singleton instance;
    
    // 외부에서 객체 생성을 막음
    private Singleton() { }
    
    public static Singleton getInstance() {
    	if (instance == null) {
        	instance = new Singleton();
        }
        return instance;
    }
}

위의 코드를 살펴보면 외부에서 객체를 직접 생성하지 못하게 생성자를 private로 막아두었고, 객체의 생성 여부에 따라 새로운 객체를 생성하거나 기존 객체를 반환한다.

이제 이 코드에서 다른 객체를 생성할 수 있는 방법(=문제점)에 대해 알아보자.

문제점 살펴보기

문제점 1. 멀티스레드 환경

아까 코드를 다시 한 번 살펴보자.

public class Singleton {
	private static Singleton instance;
    
    private Singleton() { }
    
    public static Singleton getInstance() {
    	if (instance == null) {
        	instance = new Singleton();
        }
        return instance;
    }
}

2개의 스레드에서 동시에 Singleton.getInstance()를 실행하면 어떻게 될까? 그러니까 최초 A라는 스레드가 getInstance()를 호출하고, if문에 들어갔을 때 B라는 스레드가 getInstance()를 호출하면 어떻게 될까?

A 스레드에서 호출했을 땐 Singleton 객체가 없기 때문에 확실히 객체가 생성될 것이다. 그리고 우리는 제목에서 알 수 있듯 B에서도 새로운 객체가 생기는 문제가 생긴다는 것을 알 수 있다.

이처럼 기존 코드에서는 멀티스레드 환경에서 안전하지 못하다는 문제가 있다. 그렇다면 어떻게 이 문제를 해결할 수 있을까?

해결 방법 1. synchronized 추가

가장 간단한 방법으로는 기존 코드에 synchronized 를 추가하는 것이다.

public class Singleton {
	private static Singleton instance;
    
    private Singleton() { }
    
    public static synchronized Singleton getInstance() {
    	if (instance == null) {
        	instance = new Singleton();
        }
        return instance;
    }
}

synchronized를 추가하면 한 스레드가 메서드 사용을 끝내기 전까지 Lock이 걸리게 된다. 다른 스레드는 사용이 끝나기 전까지 기다려야 한다. 즉, 2개의 스레드가 동시에 getInstance()를 실행하는 일은 일어나지 않게 되는 것이다.

하지만 이 방법은 동기화할 때 속도 문제가 발생한다. 그리고 동기화가 꼭 필요한 시점은 getInstance()가 시작되는 때뿐이라는 사실도 알 수 있다. 바꿔 말하면, 일단 instanceSingleton 인스턴스를 대입하면 굳이 getInstance()를 동기화된 상태로 유지할 필요가 없다는 뜻이 되고, 불필요한 오버헤드만 증가시키게 된다.

만약 getInstance()의 속도가 그렇게 중요하지 않다면 그냥 둬도 괜찮다. 하지만 메서드를 동기화하면 성능이 100배 정도 저하된다고 하니 알아두자. 만약 getInstance()가 애플리케이션에서 병목으로 작용한다면 다른 방법을 생각해봐야 한다.

그렇다면 더 효율적으로 멀티스레딩 문제를 해결할 수 있는 방법에는 어떤 것이 있을까?

해결 방법 2. 인스턴스를 처음부터 만들기

애플리케이션에서 Singleton의 인스턴스를 생성하고 계속 사용하거나 인스턴스를 실행 중에 수시로 만들고 관리하기가 성가시다면 아예 처음부터 Singleton 인스턴스를 만들어도 된다.

public class Singleton {
	private static Singleton instance = new Singleton();
    
	private Singleton() { }
    
    public static Singleton getInstance() {
        return instance;
    }
}

이 방법을 사용하면 클래스가 로딩될 때 JVM에서 Singleton의 하나뿐인 인스턴스를 생성해 준다. JVM에서 하나뿐인 인스턴스를 생성하기 전까지 그 어떤 스레드도 정적 변수 instance에 접근할 수 없다.

하지만 이 방법은 즉시 로딩이라는 것 자체가 단점이다. 새로운 객체를 생성하는 것은 자원이 많이 드는 일이다. 만약 기껏 생성해놓고 사용하지 않는다면 생성을 했다는 그 자체가 단점이 되는 것이다.

그렇다면 멀티스레딩 문제도 해결하면서 지연 로딩까지 만족하는 방법은 없을까?

해결 방법 3. DCL을 사용

먼저 DCL이란 Double-Checked Locking의 줄임말이다. DCL을 사용하면 인스턴스가 생성되어 있는지 확인한 다음 생성되어 있지 않았을 때만 동기화할 수 있다. 이러면 처음 생성할 때만 동기화하고 나중에는 동기화하지 않아도 되는 것이다.

public class Singleton {
	// volatile = Main Memory에 읽기와 쓰기를 보장하는 키워드
	private volatile static Singleton instance = new Singleton();
    
	private Singleton() { }
    
    public static Singleton getInstance() {
        if (instance == null) {  // 인스턴스가 있는지 확인하고 없을 경우 동기화 블록으로 들어감
        	synchronized (Singleton.class) {  // 처음에만 동기화
        		if (instance == null) {  // 다시 한 번 변수가 null인지 확인
                	instance = new Singleton();
                }
        	}
        }
        return instance;
    }
}

이 방법 역시 단점이 존재한다. 바로 자바 1.4 이전 버전에서는 쓸 수 없다는 것이다. 잘못 본 것이 아니다 자바 14가 아닌 자바 1.4이다.

해결 방법 4. static inner class

정적 내부 클래스를 사용하면 멀티스레드 환경에서도 안전하고, 지연 로딩도 가능하게 된다.

public class Singleton {
	
    private Singleton() {}

	private static class SingletonHolder {
    	private static final Singleton INSTANCE = new Singleton();
    }
    
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

자, 이렇게 다양한 방법을 통해 멀티스레드 환경에서 안전한 방법으로 하나뿐인 객체를 생성하는 방법에 대해 알아봤다. 이제 이 방법들을 전부 무시하고 새로운 객체를 만들 수 있는 방법을 알아보자.

문제점 2. 직렬화/역직렬화 사용

자바는 Object를 파일 형태로 저장(직렬화)했다가 다시 읽어들일(역직렬화) 수 있다. 해당 방법을 사용하려면 클래스가 Serializable을 구현하고 있어야 한다.

public class Singleton implements Serializable {
	
    private Singleton() {}

	private static class SingletonHolder {
    	private static final Singleton INSTANCE = new Singleton();
    }
    
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

파일을 역직렬화를 하면 반드시 생성자를 사용해서 다시 한 번 인스턴스를 만들어 준다.

public class Main {
	public static void main(String[] args) {
    	Singleton singleton = Singleton.getInstance();
        Singleton singleton1 = null;
        
        try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("singleton.obj"))) {
        	out.writeObject(singleton);  // 실행 시 파일 생성됨
        }
        
        try (ObjectInput in = new ObjectInputStream(new FileInputStream("singleton.obj"))) {
        	singleton1 = (Singleton) in.readObject();  // 파일 역직렬화 (생성자로 인스턴스 생성됨)
        }
    }
}

해결 방법 1. readResolve() 사용

이것은 명시적으로 재정의할 수 있는 것은 아닌데 Object 타입의 readResolve() 메서드를 만들어 놓으면 역직렬화 시 사용된다.

public class Singleton implements Serializable {
	
    private Singleton() {}

	private static class SingletonHolder {
    	private static final Singleton INSTANCE = new Singleton();
    }
    
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
    
    protected Object readResolve() {
    	return getInstance();
    }
}

이렇게 만든 뒤 위의 코드를 재실행하면 역직렬화 시 getInstance()가 실행되기 때문에 동일한 객체를 얻을 수 있게 된다.

진부하지만 다시 한 번 위의 방법을 전부 무시하면서 새로운 객체를 만드는 방법을 하나만 더 알아보자.

문제점 3. 리플렉션 사용

리플렉션을 사용하면 객체가 생성되어 있어도 새로운 객체를 생성할 수 있다.

public class Main {
	public static void main(String[] args) {
    	Singleton singleton = Singleton.getInstance();
        
        Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor();
        constructor.setAccessible(true);
        Singleton singleton1 = constructor.newInstance();
    }
}

여기서 getInstance()를 사용한 것은 우리가 마지막에 알아본 정적 내부 클래스를 통해서 만든 것과 같은 방식이고, 리플렉션을 이용하여 newInstance()를 사용한 것은 new를 사용해서 만든 것과 같다고 생각해도 된다.

두 객체를 비교하면 false가 나오게 된다.

지금까지 알아본 방법으로는 리플렉션으로 생성하는 방법을 막을 수 없다. 사실 이 경우는 그냥 싱글톤으로 사용하길 거부한게 아닌가 싶지만 리플렉션을 사용하는 것까지 막고 싶다면 enum 을 사용하면 된다.

해결 방법 1. Enum 사용

자바에서 제공하는 enum을 사용하면 길었던 코드를 간단하게 만듦과 동시에 리플렉션을 통한 새로운 객체 생성 역시 막을 수 있다.

public enum Singleton {
	INSTANCE
}

public class Main {
	public static void main(String[] args) {
    	Singleton singleton = Singleton.getInstance();
        Singleton singleton1 = null;
        
        Constructor<?>[] constructors = Singleton.class.getDeclaredConstructors();
        
        for (Constructor<?> constructor : constructors) {
        	constructor.setAccessible(true);
        	singleton1 = constructor.newInstance("INSTANCE");
        }
        
    }
}

자세한 확인은 자바 바이트 코드를 확인하면 되는데, 리플렉션을 사용해서 기본 생성자를 찾으려고 해도 런타임 에러가 발생하고, 모든 생성자를 전부 가져와서 새로운 인스턴스를 생성하려고 해도 enum에서 리플렉션에서 newInstance()를 사용해서 인스턴스를 생성하는 것을 막아놨기 때문에 역시 에러가 발생한다. 결론적으로 이런 방법으로 인스턴스를 만들 수 없기 때문에 유일한 인스턴스가 보장된다.

또한 enum은 자체적으로 Serializable을 구현하고 있고, 별 다른 장치를 만들지 않아도 역직렬화 시 동일한 객체를 반환하기 때문에 직렬화/역직렬화의 해결 방법이기도 하다.

하지만 enum은 클래스를 쓰는 순간(로딩하는 순간) 인스턴스 자체가 만들어진다는 단점이 있다.


References

0개의 댓글