본문 바로가기

Design Pattern

Singleton

헤드 퍼스트 디자인 패턴 5장 싱글턴 패턴을 정리한 글이다.
 
처음 목차를 봤을 때 솔직히 의아했다. 객체 하나만 만드는 방법을 한 장 전체에 걸쳐서 배운다고?

책 속 등장인물도 똑같은 질문을 한다. 그런데 다 읽고 나니 이해가 됐다. 만드는 건 쉬운데, '정말 하나만' 만들어지게 보장하는 게 생각보다 까다로웠다.
 

 


 
■ 싱글턴 패턴이 뭔데?
 
한 줄로 말하면, 클래스의 인스턴스가 딱 하나만 만들어지도록 보장하고, 어디서든 그 하나에 접근할 수 있게 해 주는 패턴이다.
 
회사의 대표 전화번호를 생각하면 쉽다. 직원이 몇 명이든 회사 대표 번호는 하나다. 누가 어디서 전화하든 같은 번호로 연결된다. 부서마다 대표 번호를 따로 만들어 버리면 고객은 어디로 전화해야 할지 혼란스러워진다.
 
 
■ 1. 왜 하나만 있어야 할까
 
실제로 하나만 있어야 제대로 돌아가는 객체는 꽤 많다.
 
· 스레드 풀, 연결 풀
· 캐시
· 사용자 설정, 레지스트리 설정 객체
· 로그 기록용 객체
· 디바이스 드라이버
 
이런 객체가 2개 이상 생기면 프로그램이 이상하게 돌아가거나, 자원을 쓸데없이 잡아먹거나, 결과가 일관되지 않게 된다. 예를 들어 설정 객체가 2개면 설정 내용도 2벌이 생기는 셈이니, 어느 쪽이 진짜인지 알 수 없다.
 
▶ 그냥 전역 변수 쓰면 안 되나?
 
책 속 등장인물도 이 질문을 한다. 자바라면 정적 변수에 객체를 넣어 두면 어디서든 쓸 수 있지 않냐는 것이다.
 
전역 변수의 문제는 두 가지다.
 
· 애플리케이션이 시작될 때 무조건 객체가 만들어진다. 무거운 객체인데 끝까지 한 번도 안 쓰면 자원만 낭비한다
· 어디서든 접근은 되지만, 인스턴스가 하나만 만들어진다는 보장은 못 한다
 
싱글턴의 목표는 '하나만 만들기'와 '어디서든 접근하기' 두 가지인데, 전역 변수로는 두 번째만 달성할 수 있다. 게다가 전역 변수를 자꾸 만들다 보면 네임스페이스도 지저분해진다.
 
 
■ 2. 하나만 만들게 하려면 어떻게 해야 할까
 
책에서는 이 부분을 문답 형식으로 한 단계씩 풀어 가는데, 흐름이 재미있어서 정리해 봤다.
 
① 클래스가 public이면 누구나 new로 인스턴스를 몇 개든 만들 수 있다.
 
② 그럼 생성자를 private으로 막으면?
 

public class MyClass {
    private MyClass() {}
  }


 
이제 다른 클래스에서는 new MyClass()를 쓸 수 없다. 그런데 private 생성자는 MyClass 안에서만 부를 수 있으니, 결국 아무도 인스턴스를 못 만든다. 닭이 먼저냐 달걀이 먼저냐 같은 상황이다.
 
③ 여기서 정적 메소드를 쓰면 문제가 풀린다. 정적 메소드는 인스턴스 없이 클래스 이름으로 바로 호출할 수 있고, 클래스 안에 있으니 private 생성자도 부를 수 있다.

 public class MyClass {
   private MyClass() {}
 
   public static MyClass getInstance() {
     return new MyClass();
   }
 }


 
④ 이제 남은 건 getInstance()가 매번 새로 만들지 않고, 처음 만든 하나를 계속 돌려주게 하는 것이다.
 
'생성자는 막고, 대신 만들어 주는 창구 하나만 열어 둔다.' 싱글턴의 기본 아이디어는 이게 전부다.
 
 
■ 3. 고전적인 싱글턴 구현
 

  public class Singleton {
    private static Singleton uniqueInstance;   // 하나뿐인 인스턴스를 저장
 
    private Singleton() {}   // 외부에서 new 금지
 
    public static Singleton getInstance() {
      if (uniqueInstance == null) {
        uniqueInstance = new Singleton();
      }
      return uniqueInstance;
    }
  }


 
동작은 단순하다.
 
· uniqueInstance가 null이면 아직 만들어진 적이 없으니 새로 만든다
· null이 아니면 이미 있으니 그걸 돌려준다
 
이렇게 실제로 필요한 순간이 올 때까지 인스턴스를 만들지 않는 방식을 '게으른 인스턴스 생성(lazy instantiation)'이라고 한다.

전역 변수의 첫 번째 문제(안 써도 무조건 생성)가 여기서 해결된다.
 
참고로 싱글턴도 보통 클래스라서, 다른 필드나 메소드를 얼마든지 가질 수 있다.
 
▶ 정의
 
"싱글턴 패턴은 클래스 인스턴스를 하나만 만들고, 그 인스턴스로의 전역 접근을 제공한다."
 
· 클래스가 자기 인스턴스를 직접 관리하고, 다른 클래스는 추가로 만들 수 없게 한다
· getInstance()라는 전역 접근 지점을 제공해서, 필요할 때 언제든 요청할 수 있게 한다
 
책에서 주의하라고 하는 게 하나 있다. 이 코드를 그대로 쓰면 안 된다는 것. 곧 문제가 드러난다.
 
 
■ 4. 초콜릿 보일러 사건
 
책의 예제는 초콜릿 공장의 보일러 제어 코드다. 보일러는 재료 넣기(fill) → 끓이기(boil) → 비우기(drain) 순서로 움직이고, 코드에는 실수를 막는 조건이 꼼꼼하게 들어 있다.
 
· 비어 있을 때만 재료를 넣는다
· 가득 차 있고 아직 안 끓었을 때만 끓인다
· 가득 차 있고 다 끓었을 때만 비운다
 
그런데 이 보일러 객체가 2개 만들어지면? 각 객체가 자기 상태(empty, boiled)만 보고 움직이기 때문에, 한쪽은 "비어 있다"고 생각하고 재료를 붓는데 실제 보일러는 끓고 있는 상황이 생길 수 있다. 그래서 보일러를 싱글턴으로 바꿨다.
 
그런데도 사고가 났다. 아직 끓고 있는 보일러에 새 재료를 넣어서 500갤런(약 1,900리터)의 초콜릿이 넘쳐 버린 것이다. 바뀐 거라고는 보일러 컨트롤러를 멀티스레드로 최적화한 것뿐이었다.
 
 
■ 5. 멀티스레드에서 싱글턴이 깨지는 이유
 
두 스레드가 거의 동시에 getInstance()를 호출하면 이런 일이 생길 수 있다.

 


 
① 1번 스레드가 null 검사를 한다 → 아직 null이니 통과
② 객체를 만들기 직전에 2번 스레드도 null 검사를 한다 → 아직 null이니 통과
③ 1번 스레드가 객체를 만든다
④ 2번 스레드도 객체를 만든다
 
결국 두 스레드가 서로 다른 보일러 객체를 받게 된다. 싱글턴이 깨진 것이다.
 
문제는 "null인지 확인하고 → 만든다"는 두 동작 사이에 다른 스레드가 끼어들 수 있다는 점이다. 화장실 문에 '사용 중' 표시가 없어서, 둘이 동시에 "비어 있네" 하고 문을 연 상황과 비슷하다.
 
 
■ 6. 해결 방법 세 가지
 
▶ 방법 1 : getInstance()를 동기화한다
 

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


 
synchronized를 붙이면 한 스레드가 메소드를 다 쓸 때까지 다른 스레드는 기다려야 한다. 두 스레드가 동시에 들어오는 일 자체가 없어진다.
 
간단하지만 아쉬운 점이 있다. 사실 동기화가 필요한 건 처음 인스턴스를 만드는 순간뿐이다. 한 번 만들어진 뒤에는 매번 줄을 세울 필요가 없는데, 이 방식은 호출할 때마다 동기화 비용을 낸다.

책에서는 메소드를 동기화하면 성능이 100배 정도 떨어질 수 있다고 하면서, getInstance()가 병목이 아니라면 그냥 써도 괜찮다고 말한다.
 
(이 '100배'는 책이 처음 쓰였을 때 기준의 얘기라, 요즘 JVM에서는 차이가 훨씬 줄었다고 알려져 있다. 그래도 '매번 동기화하는 건 낭비'라는 요점은 그대로다)
 
▶ 방법 2 : 처음부터 만들어 둔다
 

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


 
클래스가 로딩될 때 JVM이 인스턴스를 만들어 준다. JVM이 인스턴스를 다 만들기 전에는 어떤 스레드도 이 변수에 접근할 수 없으니, 스레드 문제가 원천적으로 생기지 않는다.
 
대신 게으른 생성을 포기하는 셈이다. 그래서 어차피 항상 쓰는 객체이거나, 만드는 비용이 크지 않을 때 적합하다.
 
▶ 방법 3 : DCL(Double-Checked Locking)
 

  public class Singleton {
    private volatile static Singleton uniqueInstance;
 
    private Singleton() {}
 
    public static Singleton getInstance() {
      if (uniqueInstance == null) {   // 1차 확인 (동기화 없이)
        synchronized (Singleton.class) {
          if (uniqueInstance == null) {   // 2차 확인 (동기화 안에서)
            uniqueInstance = new Singleton();
          }
        }
      }
      return uniqueInstance;
    }
  }


 
인스턴스가 없을 때만 동기화 블록에 들어간다. 그래서 처음 한 번만 동기화하고, 그 뒤로는 동기화 없이 바로 돌려준다.
 
처음 봤을 때 "왜 null 검사를 두 번이나 하지?"가 궁금했다. 1차 확인을 통과한 두 스레드가 동기화 블록 앞에서 줄을 섰다고 해 보자. 먼저 들어간 스레드가 객체를 만들고 나오면, 뒤에 들어간 스레드는 2차 확인에서 "이미 있네" 하고 그냥 나온다. 2차 확인이 없으면 결국 두 번 만들어진다.
 
volatile도 꼭 필요하다. 이게 없으면 다른 스레드가 '아직 초기화가 덜 끝난 객체'를 보게 될 수 있다. 책에서도 자바 5 이전 버전에서는 volatile을 써도 DCL이 제대로 동작하지 않을 수 있으니 주의하라고 한다.
 
▶ 초콜릿 보일러에는 어떤 방법이 맞을까?
 
책의 정답은 이렇다. 보일러는 속도가 중요한 상황이 아니니 방법 1도 충분하고, 어차피 보일러 인스턴스는 항상 필요하니 방법 2도 괜찮다. DCL은 굳이 쓸 필요까지는 없다.
 
결국 '어떤 게 제일 좋은가'보다 '이 상황에서 속도와 자원이 얼마나 중요한가'를 보고 고르는 문제였다.
 
 
■ 7. 책에 없는 방법 : 홀더 방식
 
책에는 안 나오지만, 실무와 면접에서 자주 언급되는 방법이 하나 더 있다. 클래스 안에 정적 중첩 클래스를 두는 방식이다.
 

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


 
Holder 클래스는 getInstance()가 처음 호출될 때 비로소 로딩된다. 그래서 방법 2처럼 JVM이 스레드 안전을 보장해 주면서도, 방법 1이나 3처럼 필요할 때 만드는 게으른 생성까지 챙길 수 있다. 동기화 코드도 없다.
 
 
■ 8. 책의 Q&A에서 기억할 만한 것
 
▶ 모든 메소드와 변수를 static으로 만들면 똑같지 않나?
 
복잡한 초기화가 필요 없을 때는 가능하다. 하지만 자바의 정적 초기화 순서 때문에 여러 클래스가 얽히면 찾기 어려운 버그가 생길 수 있다. 특별한 이유가 없다면 쓰지 말라고 한다.
 
▶ 클래스 로더가 여러 개면?
 
클래스 로더마다 같은 클래스를 따로 로딩할 수 있어서, 싱글턴 인스턴스가 여러 개 생길 수 있다. 리플렉션, 직렬화와 역직렬화로도 싱글턴이 깨질 수 있다.
 
▶ 싱글턴은 느슨한 결합 원칙에 어긋나지 않나?
 
맞다. 싱글턴에 의존하는 객체들은 전부 그 하나의 객체에 단단히 묶인다. 싱글턴을 바꾸면 연결된 객체도 다 바꿔야 할 가능성이 높다.
 
▶ 한 클래스가 두 가지 일을 하는 것 아닌가?
 
맞다. 싱글턴은 자기 인스턴스를 관리하는 일과, 원래 맡은 일을 둘 다 책임진다. 단일 책임 원칙 관점에서 보면 문제가 될 수 있지만, 디자인을 단순하게 만들어 주기 때문에 실제로는 많이 쓴다고 한다.
 
▶ 싱글턴의 서브클래스를 만들 수 있나?
 
생성자가 private이라 상속 자체가 막혀 있다. 생성자를 열면 더 이상 싱글턴이 아니다. 게다가 정적 변수를 쓰기 때문에 서브클래스들이 같은 인스턴스를 공유하게 된다. 책은 오히려 이렇게 말한다. 애플리케이션에서 싱글턴을 꽤 많이 쓰고 있다면 전체 디자인을 다시 생각해 봐야 한다.

 

싱글턴은 특수한 상황에서 제한적으로 쓰라고 만든 것이다.

 
 
■ 9. 결론 : enum을 쓰자
 
지금까지 본 동기화 문제, 클래스 로더 문제, 리플렉션, 직렬화 문제를 한 번에 해결하는 방법이 있다.
  

public enum Singleton {
    UNIQUE_INSTANCE;
    // 필요한 필드와 메소드
  }
 
  Singleton singleton = Singleton.UNIQUE_INSTANCE;


 
이게 끝이다. 자바의 enum은 JVM이 인스턴스가 하나만 만들어지도록 보장해 준다.
 
그럼 지금까지 한 건 다 뭐였나 싶은데, 책의 답이 마음에 들었다. 싱글턴이 어떻게 작동하는지 제대로 이해하려고 원리를 하나하나 따라가 본 것이고, 이제 실제로 필요할 때는 enum을 쓰면 된다. 그리고 면접에서 "enum 없이 싱글턴을 구현해 보세요"라는 질문이 나오면 자신 있게 대답할 수 있다.
 
 
■ 10. 실무에서 보는 싱글턴
 
Spring을 쓰다 보면 "Spring 빈은 기본적으로 싱글턴"이라는 말을 듣게 된다. 이름은 같지만 이 글의 싱글턴 패턴과는 조금 다르다.
 
· 싱글턴 패턴 : 클래스가 스스로 private 생성자와 getInstance()로 하나만 만들어지게 막는다
· Spring 싱글턴 : 클래스는 평범하게 두고, 컨테이너가 하나만 만들어서 필요한 곳에 넣어 준다
 
Spring 방식은 클래스 자체에 싱글턴 코드가 없어서, 앞에서 본 단점들(강한 결합, 단일 책임 위반, 테스트할 때 가짜 객체로 바꾸기 어려운 점)이 많이 줄어든다. 요즘 직접 싱글턴 패턴을 구현할 일이 예전보다 적은 이유이기도 하다.
 
 
■ 셀프 체크
 
Q1. 생성자를 private으로 만들면 인스턴스를 어떻게 얻을 수 있을까?
→ 클래스 안의 정적 메소드(getInstance())를 통해서. 정적 메소드는 인스턴스 없이 호출할 수 있고, 같은 클래스 안이라 private 생성자도 부를 수 있다.
 
Q2. 고전적인 싱글턴이 멀티스레드에서 깨지는 이유는?
→ null 검사와 객체 생성 사이에 다른 스레드가 끼어들 수 있어서. 두 스레드가 동시에 null 검사를 통과하면 인스턴스가 2개 생긴다.
 
Q3. DCL에서 null 검사를 두 번 하는 이유는?
→ 첫 번째는 이미 만들어졌으면 동기화 없이 바로 돌려주려고, 두 번째는 동기화 블록 앞에서 기다리던 다른 스레드가 또 만들지 않게 하려고.
 
Q4. 요즘 자바에서 싱글턴이 필요하다면 가장 간단한 방법은?
→ enum. 동기화, 리플렉션, 직렬화 문제까지 JVM이 해결해 준다.
 
 
■ 마무리
 
싱글턴은 클래스 다이어그램에 클래스가 딱 하나뿐인, 가장 간단해 보이는 패턴이다. 그런데 '정말 하나만'을 보장하려고 하니 멀티스레드, 클래스 로더, 직렬화까지 신경 쓸 게 계속 나왔다.
 
정리하면서 느낀 건, 싱글턴은 쓰는 방법보다 언제 쓰지 말아야 하는지를 아는 게 더 중요하다는 점이었다. 편해서 여기저기 쓰다 보면 어느새 모든 객체가 하나의 객체에 묶여 버린다. 책에서도 말하듯, 정말 하나만 있어야 하는 특수한 경우에만 제한적으로 쓰는 게 맞는 것 같다.