티스토리 뷰

Java

DesignPattern#04. Singleton Pattern

LichKing 2019. 10. 19. 16:01

면접관으로 들어가서 알고있는 디자인 패턴이 어떤게 있는지를 질문해보면 가장 많이 나오는 대답이 MVC패턴과 싱글톤 패턴이다. 커뮤니티를 봐도 디자인패턴에 대한 질문은 싱글톤의 비중이 압도적이다. 개인적으로 싱글톤이 왜 이렇게 인기가 많을까 생각해봤는데 패턴이 추구하는 바가 비교적 명확하고, 처음 패턴을 공부하는 초보 개발자들이 적용하기에도 가장 만만해서 그런게 아닐까싶다. 그럼 이제 싱글턴 패턴에 대해 알아보자.

 

싱글턴 패턴은 애플리케이션 내에서 하나의 클래스가 하나의 인스턴스만 생성하는걸 강제하는 패턴이다. 자바에서 객체를 생성하는 메커니즘은 생성자를 이용해서 객체를 생성하는 것이다.

public class Singleton {
    public Singleton() {
        
    }
}

Singleton singleton = new Singleton();

이 메커니즘을 그대로 이용하면 싱글턴 패턴을 구현할수 없다. 생성자가 호출된다는것 그 자체가 이미 객체를 새로 생성한다는 의미이기 때문이다. 저 짧은 코드에서 주목해야할 점은 생성자에 붙어있는 접근제어자다. 생성자에도 접근제어자를 사용할 수 있다는점은 아래와같은 코드를 가능케한다.

public class Singleton {
    private Singleton() {

    }
}

private 접근제어자가 뭔지 아는 사람이라면 이 코드를 처음봤을때 황당할 수 있다. private은 클래스 내에서만 접근할 수 있는 접근제어자다. 생성자가 막혀있으므로 외부 클래스에선 new Singleton(); 이라는 문장을 작성할 수 없다. 그럼 1개의 인스턴스를 생성해야하는데 이상태로는 1개도 만들수가 없는것 아닌가? 클래스 내에서만 접근할 수 있다는 표현에 주목하자. 클래스 내에서 생성해서 아래와같이 표현할 수 있다.

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

    }
}

Singleton.getInstance();

클래스 내부에서 인스턴스를 만들고, 그 인스턴스를 반환하는 형태로 작성한 것이다. 사실 이렇게만써도 별 문제는 없다. 이 다음부터는 (개인적인 생각에) 필요 이상의 최적화가 들어가면서 코드가 복잡해지기 시작한다. 지금의 코드는 싱글턴을 구현한건 맞으나 인스턴스가 JVM이 구동되는 시점에 생성된다는 점이있다. 객체 생성에 비용이 크고, 특정 조건에 따라 해당 객체를 만들지 않아도 되는 경우가있다면 JVM이 구동되는 시점에서부터 객체를 만들어 놓는건 상당히 비효율적이다. 필요할때 만들고, 혹여라도 애플리케이션 라이프사이클 사이에 필요한 경우가 없다면 굳이 만들지않아도 될것이다.

public class Singleton {
    private static Singleton singleton;

    public static Singleton getInstance() {
        if(singleton == null) {
            singleton = new Singleton();
        }
        return singleton;
    }

    private Singleton() {

    }
}

getInstance() 메서드 내에서 null 체크를 하도록 변경했다. 이렇게 만들면 JVM이 구동되는 시점이 아니라 정말 해당 객체가 필요할때 객체를 생성하게되고, 그 이후 2, 3번째 요청땐 이미 생성된 객체를 반환하게 된다. 나름 잘 최적화가 된것 같으나 이 코드는 멀티 스레드 환경에서 문제를 일으킨다.

public class Singleton {
    private static int num = 0;
    private static Singleton singleton;

    public static Singleton getInstance() {
        if(singleton == null) {
            System.out.println(num++);
            singleton = new Singleton();
        }
        return singleton;
    }

    private Singleton() {

    }
}


public static void main(String[] args) {                                      
    ExecutorService service = Executors.newFixedThreadPool(10);               
                                                                              
    for(int i = 0; i < 100; i++) {                                            
        service.execute(() -> {                                               
            Singleton singleton = Singleton.getInstance();                    
        });                                                                   
    }                                                                         
                                                                              
    service.shutdown();                                                       
}                                                                             

Singleton 클래스 내에 생성자가 몇번 호출되는지 확인하기위해 static 필드로 num을 추가했다. 싱글턴이 정말 의도한대로 실행된다면 num은 0을 1번만 출력해야될것이다. (이후엔 객체가 존재하므로 if문 내에 들어올일이 없을것이므로)

 

하지만 코드를 돌려보면 그렇지않다. 멀티 스레드에 안전하지않은 코드를 보호하기위해 동기화 처리를 해보자.

public static synchronized Singleton getInstance() {    
    if(singleton == null) {                             
        System.out.println(num++);                      
        singleton = new Singleton();                    
    }                                                   
    return singleton;                                   
}                                                       

getInstance() 메서드에 synchronized 키워드를 붙여 동기화 처리를 해줬다. 이후 코드를 실행하면 의도한대로 0이 1번만 출력된다. 하지만 동기화 처리가 필요한 케이스는 객체가 생성되지않은 시점 딱 1번이다. 그때만 정상적으로 동기화처리가되어 객체 생성이 1번만 됨을 보장하고나면 이후엔 불필요한 동기화 처리가 필요하지않다. 이부분을 추가적으로 최적화해보자.

public static Singleton getInstance() {     
    if(singleton == null) {                 
        synchronized (Singleton.class) {    
            if(singleton == null) {         
                System.out.println(num++);  
                singleton = new Singleton();
            }                               
        }                                   
    }                                       
    return singleton;                       
}                                           

이 기법은 Double Checked Locking 이라는 기법이다. 말 그대로 두번 체크한다. 처음엔 동기화되지않은 상태로, 두번째는 동기화하여 처리한다. 처음 객체가 생성되기전에만 동기화 처리가되고 이후에 객체 생성이후엔 동기화 코드가 실행되지않으므로 이전코드에 비해 매우 효율적이다. 출력되는 문구도 우리가 원하는대로 0이 1번만 출력된다.

 

이런 동기화까지도 없애는 표현이 하나 더 있다. Initialization-on-demand holder idiom 이라고 하는 관용구이다.

public class Singleton {
    public static Singleton getInstance() {
        return Holder.singleton;
    }

    private static class Holder {
        static final Singleton singleton = new Singleton();
    }

    private Singleton() {
    }
}

이전 코드에 비해 훨씬 간결해졌다. 코드를 설명하자면 내부 클래스의 static 필드에다가 싱글턴으로 만들고자하는 객체의 레퍼런스를 생성하는 형태다. 이 코드는 지금까지 추구했던 lazy initialize(지연 초기) 와 multi thread safe(멀티 스레드 안전) 를 모두 만족하면서 코드도 간결해진 형태다. 일단 JVM은 클래스를 사용할때 로딩하게 되므로 getInstance() 메서드가 호출되지않는다면 Singleton 클래스가 로드 되더라도 (Singleton은 로드되어도 Holder 클래스가 로드되지 않았으므로) 객체를 생성하지않는다. 그리고 비로소 getInstance() 메서드가 호출될때 클래스를 로드하고, 정적 필드를 초기화하게되는 셈이다. 이 과정은 java 스펙으로 멀티스레드에 안전함을 보장하므로 개발자가 따로 동기화 처리를 해줄필요가 없는 것이다.

 

다만 이미 말했디만 이런 복잡한 최적화가 정말 필요한지는 의문이다. 정말 필요한 경우가 아니라면 그냥 static 필드에 초기화하는 방법을 사용하거나 enum을 이용해서 구현하는 방법이 나아보인다.

 

이펙티브자바 3판 item 3. private 생성자나 열거타입으로 싱글턴임을 보장하라

이펙티브자바 3판 item 67. 최적화는 신중히 하라

 

Anti Pattern

싱글턴 패턴에 대해 공부하는 사람들이 생각보다 많은데 싱글턴을 그렇게 깊이 이해할 필요가 있는지는 의문이다. 실제로 구현해서 쓸일이 거의 없으며(자바 엔터프라이즈 환경은 거의 기본으로 스프링을 깔고 들어가고있는데 굳이 싱글턴이 필요하다면 직접 싱글턴을 구현하기보다 spring bean을 이용하는 경우가 대다수다. 참고로 난 5년 넘게 자바개발하면서 싱글턴은 1번도 써본적 없다.) 안티 패턴으로 거론되고있기때문이다.

 

안티패턴으로 거론되는 이유를 몇가지 알아보자.

 

- 싱글턴은 결과적으로 전역 변수에 해당한다. 전역 변수를 사용하게되면 고려해야할 점들이 많아진다. 간단하게 위 포스팅에서 멀티 스레드 상황에서 안전한 싱글턴 생성에 대해 알아봤지만 생성 이후에도 멀티 스레드에 안전하게 사용하기위한 점들을 고려해야한다. (당연히 싱글턴 객체의 상태를 조작하거나 하는 행동이 있다면 치명적이다.)

 

- 생성자를 막아놓고 스스로 생성하기때문에 테스트코드를 작성하기가 어렵다. DI를 받는 형태로 아니기때문에 mocking을 하기에도 어렵다.

 

- 싱글턴으로 생성되는 객체는 의존성을 내부에서 해결하므로 구현 클래스들간의 강결합이 생길수밖에 없다. (테스트하기 어렵다는 이유와 비슷하다.)

 

- 싱글턴으로 작성된 클래스를 확장하는것도 불가능하다. (생성자가 private 으로 되어있으므로 확장할 수 없다.)

 

- Serializable 인터페이스를 구현하고있다면 직렬화/역직렬화에 대해서도 싱글턴 보장이 쉽지않다.

  - 이펙티브자바 3판 item 89. 인스턴스 수를 통제해야 한다면 readResolve보다는 열거 타입을 사용하라 

 

싱글턴 안티패턴에 대한 내용은 https://dzone.com/articles/singleton-anti-pattern 을 참고했으며, 100%는 아니더라도 내가 조금이나마 이해한(간단한;;) 내용에 대해서만 몇가지 언급했다.

댓글
공지사항
최근에 올라온 글
최근에 달린 댓글
Total
Today
Yesterday
링크
«   2024/03   »
1 2
3 4 5 6 7 8 9
10 11 12 13 14 15 16
17 18 19 20 21 22 23
24 25 26 27 28 29 30
31
글 보관함