본문 바로가기

Dot Programming/Spring

싱글톤(Singletone) 패턴 - 안티 패턴? 스프링 싱글톤 레지스트리?

    싱글톤(Singleton) 패턴

    싱글톤 디자인 패턴은 GoF가 소개한 디자인 패턴 중 하나이다. 반복적인 디자인 문제를 해결하여 유연하고 재사용 가능한 객체 지향 소프트웨어를 설계한다. 객체를 최초 한 번만 메모리에 할당해두고 그 다음부터는 생성해둔 객체를 참조해서 사용하는 것을 말한다. 

     

    이는 객체 생성 비용을 줄여주어 하나의 객체로도 여러 곳에 공유할 수 있을 때 주로 사용된다. 예로, 붕어빵 틀을 생각해보자. 붕어빵을 구울 때마다 매번 새롭게 틀까지 만들어야 한다면 굉장히 비용적으로 손실이 클 것이다. 이를 시스템으로 살펴보면 부하를 일으키고 서비스 장애까지 이어질 수 있다. 스프링의 빈 스코프의 디폴트가 Singleton인 이유도 이러한 비용적인 측면이 크다.

     

     

     

    자바에서의 싱글톤

    1. 이른 초기화 방법

    자바는 생성자를 private으로 선언하여 상속이 불가능하게 지정한다. 그래서 다음과 같이 싱글턴 패턴을 만들 수 있다.

    • 해당 방식은 이른 초기화 방법으로 static 변수 선언과 함께 싱글톤 인스턴스를 생성한다.
    • 이는 Thread-Safe하다는 장점이 있지만 해당 인스턴스를 사용하지 않는 경우에도 생성되기 때문에 리소스 낭비가 있다.
    /**
    *  이른 초기화 방법 
    *    장점 - Thread-Safe 하다
    *    단점 - 리소스 낭비
    */
    public class Coin {
    
    	private static final int ADD_MORE_COIN = 10;
    	private int coin;
    	private static Coin instance = new Coin(); // static 변수 선언과 함께 생성
    
    	private Coin(){
    		// private 생성자로 선언하여 상속을 방지한다.
    	}
    
    	public static Coin getInstance(){
    		return instance;
    	}
        
    }

     

    2. Lazy 초기화 방법

    여기서 좀 더 효율적으로 바꿔보면 getInstance()를 호출할 때 싱글톤 인스턴스가 없을 경우 동적으로 instance를 생성해주고 이미 존재하는 경우 해당 인스턴스를 반환해주는 것이 좋다.

    • 사용하지 않을 경우 불필요한 인스턴스 낭비가 없다.
    • 그러나 Thread-Unsafe하다.
    public class Coin {
    
    	private static final int ADD_MORE_COIN = 10;
    	private int coin;
    	private static Coin instance;
    
    	private Coin(){
    		// private 생성자로 선언하여 상속을 방지한다.
    	}
    
    	public static Coin getInstance(){
    		if(instance == null){
    			instance = new Coin();
    		}
    		return instance;
    	}
    }

     

    할당받은 두 Coin 인스턴스는 같은 상태를 지니고 있음을 확인할 수 있다. 여기서 Thread-Unsafe 문제가 발생하는 것이다. 

    public class CoinTester {
    
    	@Test
    	void coinSingletonInstance_test(){
    		Coin coin1 = Coin.getInstance();
    		Coin coin2 = Coin.getInstance();
    
    		assertEquals(coin1, coin2);
    		assertEquals(coin1.getCoin(), coin2.getCoin());
    
    		// coin1 객체의 coin 수 증가
    		coin1.addMoreCoin();
    
    		assertEquals(coin1.getCoin(), coin2.getCoin());
    	}
    }

     

    3. 멀티 쓰레드 환경에서의 문제

    같은 상태를 지녔다는 뜻은 JVM의 Heap의 환경과 같이 여러 쓰레드가 Coin 인스턴스에 동시에 접근할 경우 취약점을 드러내게 된다. 다음 getInstance() 메서드에 부하가 걸렸다는 가정하에 sleep(1000)을 추가하였다.

    public class Coin {
    
    	private static final int ADD_MORE_COIN = 10;
    	private int coin;
    	private static Coin instance = new Coin();
    
    	private Coin(){
    		// private 생성자로 선언하여 상속을 방지한다.
    	}
    
    	// 정적 블럭 초기화
    	public static Coin getInstance(){
    		try{
    			Thread.sleep(1000);
    		}catch (InterruptedException ex){
    		}
    		if(instance == null){
    			instance = new Coin();
    		}
    		return instance;
    	}
    
    }

     

    그러고 나서 멀티 쓰레드로 Coin 싱글톤 인스턴스를 가져온다.

    package singleton;
    
    public class Attacker implements Runnable{
    
    	@Override
    	public void run(){
    		Coin coin = Coin.getInstance();
    		System.out.println("[" + Thread.currentThread().getName() + "] get Coin Instance : " + coin);
    	}
    
    	/**
    	 * 실행하면 Coin 싱글턴 인스턴스가 여러개 생성된다.
    	 * 이는 여러 개의 쓰레드가 Coin 싱글턴 인스턴스 생성 메서드에 즉, 하나의 자원에 접근하기 때문이다.
    	 * 이를 해결하기 위해서는 싱글턴 인스턴스에 동기화를 걸어줘야 한다.
    	 */
    	public static void main(String[] args) {
    		Runnable r = new Attacker();
    
    		int t = 10;
    		while(t-- > 0){
    			new Thread(r).start();
    		}
    	}
    }

     

    실행 결과를 보면  Coin 인스턴스가 여러 개 생성된 것을 볼 수 있다. 이유는 한 쓰레드가 if문의 조건식을 통과하고 coin을 생성하기 바로 직전에 다른 쓰레드가 끼어들었기 때문이다. 이러한 상황을 race condition이라고도 한다.

     

    race condition

     

    4. 싱글톤 인스턴스 동기화 

    이러한 문제를 해결하기 위해서는 싱글톤 인스턴스에게 동기화를 걸어줘야 한다. 방법은 다양하다.

    1. 다음과 같이 메서드의 경쟁상태만 해결하면 될 경우 해당 메서드에만 synchronized 키워드를 붙인다.
    2. 좀 더 미세하게 나누려면 인스턴스 자체에 volatile 변수를 걸어주고 if문 내부에 synchronized 블록을 형성한다. (dobule locking)
    3. 클래스 자체를 enum으로 바꿔준다.

     

    왜 동기화를 하는 데에 있어서 여러 방법이 존재하냐면 이를 걸어주는 것 자체가 해당 부분에 Lock을 거는 행동으로 상당한 비용을 필요로 하기 때문이다. 이는 DB 트랜잭션 고립 레벨(Isolation Level)이 존재하는 이유이기도 하고 OS 멀티 쓰레드 환경에서도 마찬가지이다. 게임 개발과 같이 고성능을 필요로 할 때에는 이러한 비용조차 없애기 위해서 Lock Free 알고리즘을 사용하여 Lock 자체를 없애버린다.

     

    어떻게 성능이 차이나는지 하나씩 다뤄보자.

     

    4-1. 메서드에 synchronized 키워드 붙이기 (Lazy Initialization with synchronized)

    가장 간단한 방법은 메서드에 synchronized 키워드를 붙이는 것이다. 한 쓰레드가 해당 메서드에 접근하게 되면 다른 쓰레드들은 메서드에 접근하지 못하고 기다리게 된다.

     

    총 실행 시간은 10049ms이다. 

    public static synchronized Coin getInstance(){
        try{
            Thread.sleep(1000);
        }catch (InterruptedException ex){
        }
        if(instance == null){
            instance = new Coin();
        }
        return instance;
    }

     

    4-2. volatile 인스턴스와 double locking 기법 (Lazy Initialization, Double Checking Locking)

    이는 4-1 방법보다 lock의 범위를 더 줄여준 것이다. volatile을 선언하더라도 여러 쓰레드가 동시에 if문에 접근할 수 있기 때문에 double check lock으로 if문을 2개 선언하고 중간에 synchronized 블록을 선언하였다. 그래서 만약 첫 번째 if문에 여러 쓰레드가 접근하더라도 가장 먼저 도착한 쓰레드가 synchronized 블록을 점유하기 때문에 하나의 인스턴스만 생성된다.

    • 4-1)은 모든 쓰레드가 sleep(1000)씩 기다린 반면, 해당 4-2) 방식은 모든 쓰레드가 기다림없이 sleep(1000)을 실행하게 되고 if문 앞에서 대기를 하게 되기 때문에 시간이 훨씬 짧아진다.

    총 실행 시간은 1041ms이다.

    public class Coin {
    
        private volatile static Coin instance;
    
        public static Coin getInstance(){
            try{
                Thread.sleep(1000);
            }catch (InterruptedException ex){}
    
           // Double Check Locking
            if(instance == null) {
                synchronized (Coin.class) {
                    if (instance == null) {
                        instance = new Coin();
                    }
                }
            }
            return instance;
        }
    }

     

    4-3. Enum 클래스 사용하기

    싱글톤 인스턴스를 쓰기 가장 빠르고 쉬운 방법은 사실 Enum 클래스를 사용하는 것이다. 이는 애초에 인스턴스가 단 하나만 존재하기 때문에 따로 lock을 걸어주지 않아도 된다. 이는 간결하고 직렬화도 쉽다. 리플렉션 공격에도 제 2의 인스턴스가 생기는 일을 막아준다.

     

    총 실행시간은 40ms이다.

    public enum EnumCoin {
    	ENUM_COIN;
    
    	private static final int ADD_MORE_COIN = 10;
    	private int coin;
    
    	public int getCoin() {
    		return coin;
    	}
    
    	public void addMoreCoin(){
    		coin += ADD_MORE_COIN;
    	}
    }

     

    싱글톤은 안티패턴인가?

    간단하게 싱글톤에 대해 다뤄보면서 싱글톤 패턴은 전역 상태(Global state)를 갖기 때문에 사용하기 편리하고 하나의 인스턴스로만 관리되어 비용 절약을 할 수 있다는 장점이 있는 것을 알 수 있었다. 하지만 이는 단점 또한 존재한다. 객체지향 모델에서는 안티 패턴이라고 불리고 있는데, 이유는 다음과 같다.

    1. private 생성자 혹은 enum 클래스는 상속이 불가능하다.
    2. 싱글톤 객체는 테스트하기 힘들다. (mock 생성 불가)
    3. 전역 상태는 유연한 설계에 어긋난다. (독립된 모듈! 높은 응집! 낮은 결합!)
    인용) 토비의 스프링 1장
    싱글톤은 만들어지는 방식이 제한적이기 때문에 테스트에서 사용될 때 mock 오브젝트 등으로 대체하기가 힘들다

    인용) 이펙티브 자바 Item3. private 생성자나 열거 타입으로 싱글턴임을 보장하라
    클래스를 싱글턴으로 만들면 이를 사용하는 클라이언트를 테스트하기가 어려워질 수 있다. 타입을 인터페이스로 정의한 다음 그 인터페이스를 구현해서 만든 싱글턴이 아니라면 싱글턴 인스턴스를 가짜 구현(mock object)으로 대체할 수 없기 때문이다.

     

    1. private 생성자 혹은 enum 클래스는 상속이 불가능하다.

    정적 메서드로 객체를 생성할 수 있도록 생성자를 private으로 제한하거나 enum 클래스로 만든다. 그러나 상속을 통해 다형성을 적용하기 위해서는 기본 생성자가 필수이다. 또한 싱글톤을 구현하기 위해서는 객체지향적이지 못한 static 필드와 static 메소드를 사용해야 한다. 이러한 점들은 객체지향과 멀어지는 요소이다.

     

    2. 싱글톤 인스턴스는 테스트하기 힘들다.

    싱글톤은 생성 방식이 제한적이기 때문에 Mock 객체로 대체하기가 어려우며, 동적으로 객체를 주입하기도 힘들다. 테스트 작성은 리팩토링, 확장에 유연한 객체지향 설계의 핵심적인 요소이다. 이 부분 또한 객체지향과 멀어진다.

     

    3. 전역 상태는 유연한 설계에 어긋난다.

    static 메서드는 전역 상태(Global State)로 어디서든지 해당 객체를 사용되기 쉽다. 코드는 한 사람에 의해 작성되고 수정되지 않는다. 이러한 부분은 컴파일에서 잡기 어려워서 항상 위험이 존재한다. 코드의 표현이 모호하기 때문에 사용법을 주석을 잘 작성하는 수 밖에 없다.

    • 보통 코드로 표현을 명확하게 한다고 하면, 수정이 일어나지 않는 파라미터는 'final'로 선언하고 상속을 하지 않는 코드는 'private static final'로 선언하기도 한다.

     

    스프링 싱글톤 레지스트리

    스프링의 Bean Scope의 디폴트는 Singleton이다. 그러나 이는 위에서 다뤘던 싱글톤 패턴과는 다르다. 스프링은 직접 빈을 싱글톤 레지스트리에서 관리하여 자원을 효율적으로 관리해준다. 이는 정적 메서드나 private 생성자, enum 클래스를 사용하지 않고 기본 클래스로 싱글톤을 활용할 수 있게 해준다. 

    • 스프링 싱글톤 레지스트리에 등록된 빈은 상속이 가능하다.
    • 스프링에서 빈을 직접 관리하여 주입(DI)해주기 때문에 테스트하기 편리하다.
    • 스프링이 제어권(IOC)을 지니고 관리하기 때문에 싱글톤을 보장받는다.
    • 객체지향적이다.