[ Programming > Design Pattern ]
[디자인 패턴] 싱글턴 패턴
[디자인 패턴] 싱글턴 패턴
싱글턴 패턴은 디자인패턴의 생성패턴의 한 종류다. 개념은 엄청 간단하게 특정 클래스의 인스턴스가 오직 하나만 생성되도록 만드는 패턴이다. 핵심은 인스턴스를 여러개 생성하려고해도 오직 1개의 인스턴스만 만들어 지고 사용되도록 보장되어야 한다.
싱글톤 패턴은 보통 리소스를 많이 차지하는 역할을 하는 클래스에 적용한다. 예시로는 스레드풀, DB 연결 모듈과 같은 객체들이 한번 생성하고 계속 사용하는 객체인 싱글톤 객체로 만들어진다.
개념은 매우 간단하지만 실제 적용하기에는 고려할 것이 많은 패턴이다. "어떻게 1개만 생성될 수 있도록 만들것인가?", "언제 인스턴스가 생성되도록 할 것인가?", "멀티 쓰레딩 언어에서는 고려할 것이 없을까?", "그냥 전역 변수로 쓰면 안돼?"와 같은 의문들을 가져야한다. 또한 인터페이스에 맞춰 구현하는 것이 아니라, 정적 메서드와 정적으로 생성된 객체를 할당하면서 결합도가 높아져 SOLID 원칙에 위배되기도 한다.
1. 싱글톤 패턴 구현
싱글톤 패턴을 만드는 방법은 다양하다. 그리고 각 방법마다 "객체 생성 시기", "멀티 스레딩 가능 여부"와 같은 것들이 다르다. 이 방법중에서 사용이 권장되는 방법은 1.6과 1.7 방식이다. 중요해서 안전하게 사용하고 싶으면 enum을 활용하는 1.7 방식을 사용하고 유연하게 사용하고 싶으면 1.6방식을 사용하면 된다.
1.1 Eager Initialization(이른 초기화)
Eager Initialization은 클래스 로딩시 인스턴스를 미리 만드는 방법으로 2가지 특징이 있다.
클래스가 로딩될 때 바로 인스턴스를 생성되어 사용하지 않아도 인스턴스가 생성된다
단순하고 스레드 세이프하다
public class Singleton { private static final Singleton INSTANCE = new Singleton(); private Singleton() {} // 다른 클래스에서 생성자 호출이 불가함 => 단일 인스턴스 public static Singleton getInstance() { return INSTANCE; // 이미 생성된 인스턴스 반환 }}
1.2 Static Block Eager Initialization(이른 초기화2)
이 방식은 1.1 방식의 변형이다. class의 static 블록에서 인스턴스를 생성하며, 예외 처리까지 가능하다.
클래스 로딩 후에 인스턴스를 생성하여 사용하지 않아도 인스턴스가 생성된다. (static 블록은 클래스가 로딩되고, 1.1과 같이 초기화 과정에서 실행되는 블록이지만, 변수가 준비된 후 실행한다)
단순하고 스레드 세이프하다.
예외 처리가 가능하다.
public class Singleton { private static Singleton instance; private Singleton() {} static { try { instance = new Singleton(); } catch (Exception e) { throw new RuntimeException("싱글톤 인스턴스 생성 실패!", e); } } public static Singleton getInstance() { return instance; }}
1.3 Lazy Initialization (게으른 초기화)
Lazy Initialization은 필요할 때 처음으로 인스턴스를 생성하는 방법으로 2가지 특징이 있다.
최초 인스턴스 요청시 생성한다 (미사용 메모리 차지 문제점 해결.. 하지만 단점인지 장점인지는 상황에 따라 다를 것.)
단일 스레드에서는 안전하지만, 멀티 스레드 환경에서는 안전하지 않다
public class Singleton { private static Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; }}
이 코드가 왜 멀티 스레드 환경에서 안전하지 않을까?? JVM이 되었다고 생각해보면 아래 그림처럼 꼬이는 것을 알 수 있다.

이 방법을 해결하기 위해서 synchronized가 사용되기 시작했다.
1.4 Synchronized Lazy Initialization
1.3의 방식을 해결하기 위해 Synchronized 함수를 사용한 방법이다. 스레드 세이프함을 가졌지만 동시에 단점도 생겼다.
최초 인스턴스 요청시 생성한다
멀티 스레드 환경에서 안전하도록 synchronized 사용
매번 동기화 오버헤드가 발생 (한 스레드가 메소드 사용중이면 다른 스레드가 기다려야함)
public class Singleton { private static Singleton instance; private Singleton() {} public static synchronized Singleton getInstance() { if (instance == null) { instance = new Singleton(); } return instance; }}
이렇게 구현하면 멀티 스레드 환경에서 발생할 수 있는 문제는 해결하지만, 성능상 문제가 생길 것이다. 그래서 다음의 방법이 나왔다.
1.5 Double-Checked Locking (이중 검사 잠금)
synchronized 비용을 줄이기 위해 두 번 검사하는 방식이다. 인스턴스를 가져오는 메소드를 락을 걸지 않고, instance가 없을때(처음 불러올 때)만 Class에 동기화 락을 걸고 인스턴스를 생성하도록 하는 방법이다.
synchronized 비용을 줄이기 위해 2번 검사한다. (synchronized 외부에서만 검사하면 인스턴스 2번 만들 위험 존재)
volatile 키워드가 필요하다 (Java 1.4 이후 버전부터 가능, 캐시가 아닌 메인 메모리에서 변 읽어오도록 해서 I/O 불일치 해결, 캐시에 두고 쓰지않고 메인 메모리에 연산 결과를 반영하기 때문에 약간의 성능 희생은 있음)
성능과 안정성을 모두 보장한다.
public class Singleton { private static volatile Singleton instance; private Singleton() {} public static Singleton getInstance() { if (instance == null) { // 1번째 검사 synchronized (Singleton.class) { // 클래스 자체 동기화 if (instance == null) { // 2번째 검사 instance = new Singleton(); } } } return instance; }}
이 방법이 좋아보이지만, JVM에 따라서 여전히 쓰레드 세이프하지 않는 경우가 발생하기도 한다고 한다. 그래서 지양되는 방법이라고 소문이 있다.
1.6 Bill Pugh Singleton (정적 내부 클래스, LazyHolder)
이 방법이 싱글톤 패턴 구현에 권장되는 방법 중 하나다. 멀티쓰레드 환경에서 안전하고, Lazy Initialization도 가능한 싱글톤 기법이다.
클래스에 내부 클래스(holder)를 두어 JVM의 클래스 로더 매커니즘과 클래스가 로드되는 시점을 이용한다. (자바 한정...)
내부 클래스를 static 클래스로 설정하고 내부 클래스 필드들도 static하게 설정하여 내부 클래스가 로드되는 시점에 접근 가능하도록 만든다. (getInstance를 통해 내부 Static Class가 호출되면 그때 클래스 로더가 로딩을 하고 인스턴스 생성(초기화 수행))
단, 임의로 싱글톤이 파괴될 수 있다. (Reflection API, 직렬화/역직렬화를 통해)
public class Singleton { private Singleton() {} private static class Holder { // Holder가 호출되지 않으면, 클래스 로더에 로드 및 초기화 X. private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; // 이 코드가 호출될 시점에 클래스 로더에 Holder 클래스가 로드되고, 초기화를 수행. }}
그리고 어떻게 공격 당하는지 그 예시는 아래에 있다.
Reflection 공격
import java.lang.reflect.Constructor;public class ReflectionAttack { public static void main(String[] args) throws Exception { Singleton s1 = Singleton.getInstance(); Constructor<Singleton> constructor = Singleton.class.getDeclaredConstructor(); constructor.setAccessible(true); // private 무시 Singleton s2 = constructor.newInstance(); // 객체 1개 더 생성됨. System.out.println("s1 == s2 ? " + (s1 == s2)); // false }}
직렬화/역직렬화 공격
import java.io.*;public class SerializationAttack { public static void main(String[] args) throws Exception { Singleton s1 = Singleton.getInstance(); // 직렬화, s1 객체를 파일로 만들어버림. ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton.obj")); oos.writeObject(s1); oos.close(); // 역직렬화, 파일을 객체로 만들어 버림. ObjectInputStream ois = new ObjectInputStream(new FileInputStream("singleton.obj")); Singleton s2 = (Singleton) ois.readObject(); ois.close(); System.out.println("s1 == s2 ? " + (s1 == s2)); // false }}
그리고 아래가 그 방어 코드 예시다.
Reflection 공격 방어
public class Singleton { private Singleton() { if (Holder.INSTANCE != null) { throw new RuntimeException("이미 생성된 싱글톤이 있습니다!"); } } private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; }}
직렬화/역직렬화 공격 방어
import java.io.Serializable;// 마커 인터페이스 Serializablepublic class Singleton implements Serializable { // Serializable 인터페이스 구현 시 객체를 파일에 저장 후 부르기 가능 private Singleton() {} private static class Holder { private static final Singleton INSTANCE = new Singleton(); } public static Singleton getInstance() { return Holder.INSTANCE; } protected Object readResolve() { // 이 메서드 구현하면 역직렬화 시 JVM이 새 인스턴스 대신 기존 객체 반환 return getInstance(); }}
1.7 Enum Sington (Java 권장 방식)
이 방법도 Java에서 권장하는 싱글톤 구현 방식이다. 자바 언어 차원에서 보장(JVM 보장)되는 싱글톤 성질을 이용한 가장 간단하고 강력한 방법이라고 할 수 있다. 왜냐하면 1.6의 방식은 직렬화와 리플렉션 공격에 안전하지 않기 때문이다.
JVM에서 싱글톤을 보장한다 (자바는 enum의 인스턴스를 하나만 생성하도록 강제)
인스턴스 생성이 JVM 레벨에서 보장되어 인스턴스 생성은 스레드 세이프하다
직렬화, 리플렉션 공격에서 안전하다
가장 간단하고 강력한 방법이다 (Effective Java 저자가 말함)
enum이라서 상속이 불가능하다
일부 프레임워크에서는 사용이 불편하다(JPA Entity 같은 곳엔 적합하지 않다.)
public enum Singleton { INSTANCE; private Connection connection; Singleton() { // INSTANCE 생성자 try { // 예: JDBC 연결 connection = DriverManager.getConnection("jdbc:mysql://localhost:3306/mydb", "user", "pass"); } catch (SQLException e) { throw new RuntimeException("DB 연결 실패", e); } } public Connection getConnection() { return connection; } // 필요한 메서드 추가 가능 public void doSomething() { System.out.println("싱글톤 동작 중..."); }}// clientpublic class Main { public static void main(String[] args) { Singleton s1 = Singleton.INSTANCE; Singleton s2 = Singleton.INSTANCE; System.out.println(s1 == s2); // true, INSTANCE == Instance System.out.println(s1.getConnection() == s2.getConnection()); // true, Connection == Connection }}
2. 싱글톤 패턴 문제점
싱글톤 패턴은 사실 SOLID 원칙에 위배되는 패턴이라고들 한다. 당장 싱글톤 패턴의 역할만 봐도 자신의 인스턴스를 관리하는 기능과, 그 인스턴스를 사용하는 목적이 함께 공존한다. 한 클래스가 한 가지만을 나타내야 한다는 원칙에 대해서는 객체지향 관점에서는 좋지 않고, 개발자들도 이 문제를 인지하며 쓰고있는 것 같다.
싱글톤의 문제점은 다음과 같이 요약할 수 있을 것 같다.
인터페이스에 의존하지 않고, 미리 객체를 생성하고 정적 메소를 이용하여 클래스 사이에 강한 의존성과 결합이 생긴다.
싱글톤 인스턴스가 너무 많은 책임을 갖거나, 많은 데이터를 공유하면서 클래스간 결합도가 높아져 OCP에 위배된다.
코드 테스트가 필요할 경우 싱글턴의 상태에 따라 테스트가 달라진다면, 테스트가 힘들 수 있다.
3. 마무리
맨 처음 "어떻게 1개만 생성될 수 있도록 만들것인가?", "언제 인스턴스가 생성되도록 할 것인가?", "멀티 쓰레딩 언어에서는 고려할 것이 없을까?", "그냥 전역 변수로 쓰면 안돼?"라는 질문을 던져줬던 것 같다. 이중에서 "그냥 전역 변수로 쓰면 안돼?"라는 질문에 대해서는 아직 답이된 것 같지 않다. 전역 변수는 객체에 대한 정적 레퍼런스다. 그래서 객체의 Lazy Initialization이 불가능하며, 레퍼런스가 늘어날수록 네임스페이스도 지저분해진다. 즉, 싱글톤과 전역적인 접근은 제공할 수 있지만 생성 시기 조절을 못하고 네임스페이스가 늘어나며 지저분해진다는 단점이 있어서 전역 변수로 사용하기는 꺼려지는 것이다.
이렇게 모든 질문에 대해 답변이 된것 같아 만족스러운 것 같다. 다음 글은 커맨드 패턴이될 것 같다.~!