-
[디자인 패턴] 싱글톤 패턴프로그래밍/이론 2022. 1. 9. 02:06
디자인 패턴 중 생성 패턴의 종류인 싱글톤 패턴(Singleton Pattern)은 무엇일까?
아래 예제는 로그를 출력하는 간단한 클래스이다.
class Log { void info(String msg) { System.out.println("정보: " + msg); } } class One { Log log = new Log(); public One() { log.info("클래스 생성 - " + this.getClass()); } } class Two { Log log = new Log(); public Two() { log.info("클래스 생성 - " + this.getClass()); } } public class Main { public static void main(String[] args) { One one = new One(); Two two = new Two(); System.out.println(one.log == two.log); // 다른 객체 } }
더보기정보: 클래스 생성 - class One
정보: 클래스 생성 - class Two
falseOne 클래스와 Two 클래스에서 각각 Log 객체를 만들어 사용하고 있다. 객체 생성은 공짜가 아니라는 말이 있다. Log 클래스의 객체를 공유해서 써도 차이가 없을 것 같은데, 객체를 하나만 만들어서 사용하면 메모리 사용 측면에서 도움이 될 것 같다. 메서드에 static을 쓰지 않고 다른 방식으로 객체를 공유하는 방법은 없을까?
class Log { private static Log log = new Log(); private Log() {} public static Log getInstance() { return log; } void info(String msg) { System.out.println("정보: " + msg); } } class One { Log log = Log.getInstance(); public One() { log.info("클래스 생성 - " + this.getClass()); } } class Two { Log log = Log.getInstance(); public Two() { log.info("클래스 생성 - " + this.getClass()); } } public class Main { public static void main(String[] args) { One one = new One(); Two two = new Two(); System.out.println(one.log == two.log); // 같은 객체 } }
정보: 클래스 생성 - class One
정보: 클래스 생성 - class Two
trueLog 클래스의 생성자를 private로 하여 외부 클래스에서 new 연산자를 통한 객체 생성을 막고, 인스턴스를 담은 log 전역 변수를 만들어 getInstance 메서드에서 log 변수를 반환하게 만들었다. One 클래스와 Two 클래스에서 getInstance 메서드를 호출했을 땐 이미 만들어 놓았던 인스턴스를 받아 사용하였다. 객체 비교 결과 true 값이 나왔다.
싱글톤 패턴은 객체의 인스턴스가 한 개밖에 가질 수 없는 디자인 패턴을 말한다. 인스턴스는 두 개 이상 가질 수 없으며, 클래스 어디서든지 참조할 수 있어야 한다. 싱글톤 패턴을 사용하는 이유를 정리하면 객체 생성을 1회만 할 수 있게 하여 불필요한 메모리 사용의 부담을 덜 수 있다. 또한 어디서든 객체에 접근할 수 있기 때문에 전역 변수와 같은 특성을 가지고 있어 접근성이 좋다. 개발자 측면에서도 이 클래스가 싱글톤 패턴으로 작성되어있음을 쉽게 파악할 수 있다. 그리고 static과 달리 Lazy initialization(게으른 초기화)를 이용하여 객체 생성 시기를 개발자가 조절할 수 있다.
싱글톤 패턴의 구현 방식에도 여러 가지 방법이 있다.
class Singleton { private static Singleton singleton = new Singleton(); private Singleton() {} public static Singleton getInstance() { return singleton; } }
싱글톤 패턴의 기본 모양이라고 할 수 있다. getInstance 메서드는 인스턴스 없이 접근하여야 하기 때문에, 정적 메서드로 만들며 이에 맞춰 반환이 될 전역 변수인 singleton도 static으로 맞추어준다. 외부에서 new 연산자를 통한 객체 생성은 막아야 하기 때문에 private으로 생성자를 제한한다. 이러한 방식을 Eager initialization라고 한다. 클래스가 로딩될 때 객체가 생성된다. Eager initialization의 성능을 최적화 하고 싶다면 클래스가 언제 로딩되는지 파악하고 있을 필요가 있다.
클래스 로딩 시점에 대해 자세한 것은 다음에 포스팅하겠다.
class Singleton { private static Singleton singleton = null; private Singleton() {} public static Singleton getInstance() { if (singleton == null) { singleton = new Singleton(); } return singleton; } }
이 방법은 처음엔 객체를 생성하지 않았다가 getInstance 메서드가 호출이 됐을 때 안에서 new 연산자를 넣어 객체를 생성하는 방식이다. 또한 null 체크를 하여 객체가 이미 생성이 되었다면 더 이상 객체를 생성하지 않고 처음에 만들었던 인스턴스를 반환한다. 이러한 방법을 Lazy initialization라고 한다. 객체 생성 시점을 개발자가 조절할 수 있기 때문에 Eager initialization 방법의 상위 호환이 아니냐라고 생각되지만... 이 방식은 멀티스레드에 안전한 코드가 아니다.
지연된 초기화에 대한 자세한 것은 다음에 포스팅 하겠다.
public class Singleton { private static Singleton singleton = null; private Singleton() {} public static Singleton getInstance() { if (singleton == null) { for (int i = 0; i < 2147483647; i++) { } // 여기서 쓰레드가 지체되어 있다고 가정하는 동안 다른 쓰레드가 들어온다. System.out.println("객체 생성"); // 두 번 밟았다 singleton = new Singleton(); } return singleton; } public static void main(String[] args) { new Test(); } } class Test { Singleton sing1 = null; Singleton sing2 = null; public Test() { new Thread(() -> { sing1 = Singleton.getInstance(); }).start(); sing2 = Singleton.getInstance(); System.out.println(sing1 == sing2); // 다른 객체 } }
객체 생성
객체 생성
false스레드 하나가 메서드에서 많은 작업을 하면서 객체를 생성하는 시점이 늦춰졌고, 이때 다른 스레드가 접근했을 땐 객체가 생성된 시점이 아니니 if(singleton == null)을 통과해버렸고 결국 각 스레드가 서로 다른 객체를 만들었다. 이를 막기 위해선 synchronized를 이용해 동기화를 걸어주어야 한다.
public class Singleton { private static Singleton singleton = null; private Singleton() {} public static synchronized Singleton getInstance() { if (singleton == null) { for (int i = 0; i < 2147483647; i++) {} System.out.println("객체 생성"); singleton = new Singleton(); } return singleton; } public static void main(String[] args) { new Test(); } } class Test { Singleton sing1 = null; Singleton sing2 = null; public Test() { new Thread(() -> { sing1 = Singleton.getInstance(); }).start(); sing2 = Singleton.getInstance(); System.out.println(sing1 == sing2); // 같은 객체 } }
객체 생성
true멀티스레드에서도 객체가 하나만 만들어진 것을 확인할 수 있다. 하지만 이 방법에도 문제가 있다. synchronized는 사용하지 않는 것에 100배의 성능 저하가 나온다는 보고가 있다. 메모리 아끼려다가 성능이 느려지게 되었다. 그렇다면 객체를 생성할 때에만 synchronized를 사용하도록 객체 생성 부분에만 synchronized 블록을 사용 하는 방법은 어떨까?
synchronized에 대한 자세한 것은 다음에 포스팅하겠다.
public class Singleton { private static Singleton singleton = null; private Singleton() {} public static Singleton getInstance() { if (singleton == null) { synchronized (Singleton.class) { // 동기화 블럭 if (singleton == null) { System.out.println("객체 생성"); singleton = new Singleton(); } } } return singleton; } public static void main(String[] args) { new Test(); } } class Test { Singleton sing1 = null; Singleton sing2 = null; public Test() { new Thread(() -> { sing1 = Singleton.getInstance(); }).start(); sing2 = Singleton.getInstance(); System.out.println(sing1 == sing2); // 같은 객체 } }
객체 생성
true똑같이 성공적으로 객체가 하나만 생성이 되었다. 그리고 객체를 만들어야 할 최초로 호출될 때에만 synchronized을 통과할 것이다. 이러한 방식을 Double-Checked Locking이라고 한다. 하지만 이 패턴은 자바 메모리 구조상 먼저 들어간 스레드가 초기화 작업을 다 마치기 전에 또 다른 스레드가 객체를 사용한다면 각 스레드의 메모리 캐시의 값이 달라 프로그램이 충돌할 가능성이 있어 올바른 작동을 보장하기 어렵다. 심지어 이러한 캐시 불일치는 간혈적으로 나타나기 때문에 이러한 정보를 모르고 있다면 버그를 잡아내기 쉽지 않다. 다행히 J2SE 5 버전부턴 volatile 키워드를 사용하여 메모리 캐시를 같게 만들어 캐시 불일치를 방지할 수 있다. 코드가 조금 복잡해진 것 같다. 다른 간편한 방법은 없을까?
참고로 static 메서드이기 때문에 synchronized (this) 대신 synchronized (Singleton.class)으로 사용하여야 한다.
volatile에 대한 자세한 것은 다음에 포스팅하겠다.
public class Singleton { private Singleton() {} public static Singleton getInstance() { System.out.println("getInstance 실행"); return GetSingleton.INSTANCE; // 내부 클래스가 초기화된다 } private static class GetSingleton { private static final Singleton INSTANCE; static { System.out.println("객체 생성"); INSTANCE = new Singleton(); } } public static void main(String[] args) { new Test(); } } class Test { Singleton sing1 = null; Singleton sing2 = null; public Test() { new Thread(() -> { sing1 = Singleton.getInstance(); }).start(); sing2 = Singleton.getInstance(); System.out.println(sing1 == sing2); // 같은 객체 } }
getInstance 실행
객체 생성
getInstance 실행
true정적 내부 클래스를 이용하여 Lazy initialization를 하는 방법이다. 정적 내부 클래스는 내부 클래스의 변수가 외부 클래스에 사용될 때 정적 내부 클래스가 초기화되는데 이를 이용하여 getInstance 메서드에 들어갈 때 INSTANCE 변수를 호출하고 클래스가 실행이 되면서 객체를 생성하는 방식이다. 내부 클래스가 초기화되는 시점(객체가 생성되는 시점)을 보여주기 위해 초기화 블록을 사용했다. 이 방법을 Initialization on demand holder idiom, 또는 Bill Pugh Solution이라고 부른다. 이 방법은 synchronized을 사용하지 않으면서 멀티스레딩에 안전하고 캐시 불일치도 일어나지 않는다. 또한 변수에 final을 넣었기 때문에 객체가 한 번만 생긴다.
지금까지 설명한 싱글톤 생성 방법들은 작정하고 객체를 여러 개 만드려고 하면 그대로 뚫린다. 객체를 우회적으로 만드는 방법은 무엇일까?
내부 클래스에 대한 자세한 것은 다음에 포스팅하겠다.
import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.ObjectInput; import java.io.ObjectInputStream; import java.io.ObjectOutput; import java.io.ObjectOutputStream; import java.io.Serializable; public class Singleton implements Serializable { private static final long serialVersionUID = 1L; private Singleton() {} public static Singleton getInstance() { System.out.println("getInstance 실행"); return GetSingleton.INSTANCE; } private static class GetSingleton { private static final Singleton INSTANCE; static { System.out.println("객체 생성"); INSTANCE = new Singleton(); } } public static void main(String[] args) { new Test(); } } class Test { Singleton sing1 = null; Singleton sing2 = null; public Test() { sing1 = Singleton.getInstance(); try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("output.txt"))) { out.writeObject(sing1); } catch (Exception e) {} try (ObjectInput in = new ObjectInputStream(new FileInputStream("output.txt"))) { sing2 = (Singleton) in.readObject(); // 새로운 객체가 생성된다 } catch (Exception e) {} System.out.println(sing1 == sing2); // 다른 객체 } }
getInstance 실행
객체 생성
falseInitialization on demand holder idiom을 이용하여 싱글톤 패턴을 구현했다. 먼저 싱글톤 클래스를 직렬화 한 후 이를 다시 역직렬화를 할 경우 readObject 메소드에서 불러올 때 새로운 인스턴스가 생겨버린다. 이 경우에는 readResolve 메서드를 이용해 getInstance 메서드를 다시 실행시켜 인스턴스를 덮어씌워야한다.
import java.io.FileInputStream; import java.io.FileOutputStream; import java.io.ObjectInput; import java.io.ObjectInputStream; import java.io.ObjectOutput; import java.io.ObjectOutputStream; import java.io.Serializable; public class Singleton implements Serializable { private static final long serialVersionUID = 1L; private Singleton() {} public static Singleton getInstance() { System.out.println("getInstance 실행"); return GetSingleton.INSTANCE; } private static class GetSingleton { private static final Singleton INSTANCE; static { System.out.println("객체 생성"); INSTANCE = new Singleton(); } } private Object readResolve() { // 쉽게 말해 역직렬화의 생성자 같은 느낌 return getInstance(); } public static void main(String[] args) { new Test(); } } class Test { Singleton sing1 = null; Singleton sing2 = null; public Test() { sing1 = Singleton.getInstance(); try (ObjectOutput out = new ObjectOutputStream(new FileOutputStream("output.txt"))) { out.writeObject(sing1); } catch (Exception e) {} try (ObjectInput in = new ObjectInputStream(new FileInputStream("output.txt"))) { sing2 = (Singleton) in.readObject(); } catch (Exception e) {} System.out.println(sing1 == sing2); // 같은 객체 } }
getInstance 실행
객체 생성
getInstance 실행
truereadResolve 메서드 안에 getInstance 메서드를 실행하게 만들어 최종적으로는 GetSingleton.INSTANCE의 인스턴스를 받게끔 하였다. readResolve 메서드는 마치 역직렬화의 생성자처럼 역직렬화가 될 때 실행되며 readObject 메서드에서 생긴 객체는 가비지가 된다.
객체를 생성하는 방법엔 또 다른 방법이 있다.
직렬화에 대한 자세한 것은 다음에 포스팅하겠다.
import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; public class Singleton { private Singleton() {} public static Singleton getInstance() { System.out.println("getInstance 실행"); return GetSingleton.INSTANCE; } private static class GetSingleton { private static final Singleton INSTANCE; static { System.out.println("객체 생성"); INSTANCE = new Singleton(); } } public static void main(String[] args) { new Test(); } } class Test { Singleton sing1 = null; Singleton sing2 = null; public Test() { sing1 = Singleton.getInstance(); Constructor[] constructors = Singleton.class.getDeclaredConstructors(); // 생성자 정보를 가져온다 for (Constructor constructor : constructors) { constructor.setAccessible(true); // private 접근 허용 try { sing2 = (Singleton) constructor.newInstance(); // 객체 생성 } catch (InvocationTargetException e) { System.out.println(e.getMessage()); } catch (Exception e) {} } System.out.println(sing1 == sing2); // 다른 객체 } }
getInstance 실행
객체 생성
false리플렉션을 이용해 동적으로 객체 생성이 가능하다. getDeclaredConstructors 메서드는 클래스의 생성자 정보를 가져온다. private일 경우에는 외부에서 접근이 불가능하지만 setAccessible(true) 메서드를 이용하면 접근이 가능하다. 그리고 newInstance 메서드를 이용하면 동적으로 객체가 생성된다. 리플렉션은 GetSingleton 내부 클래스나 getInstance 메서드에 접근하는 것이 아닌 생성자에 직접 접근하기 때문에 생성자에 2번 이상 접근할 경우 Exception을 걸어주는 것으로 막을 수 있다.
리플렉션에 대한 자세한 것은 다음에 포스팅하겠다.
import java.lang.reflect.Constructor; import java.lang.reflect.InvocationTargetException; public class Singleton { private static int count = 0; private Singleton() { System.out.println("생성자 실행"); count++; if (count > 1) { // 2회 이상 접근시 Exception throw new RuntimeException("생성자가 2번 호출됨"); } } public static Singleton getInstance() { System.out.println("getInstance 실행"); return GetSingleton.INSTANCE; } private static class GetSingleton { private static final Singleton INSTANCE; static { System.out.println("객체 생성"); INSTANCE = new Singleton(); } } public static void main(String[] args) { new Test(); } } class Test { Singleton sing1 = null; Singleton sing2 = null; public Test() { sing1 = Singleton.getInstance(); Constructor[] constructors = Singleton.class.getDeclaredConstructors(); for (Constructor constructor : constructors) { constructor.setAccessible(true); try { sing2 = (Singleton) constructor.newInstance(); } catch (InvocationTargetException e) { System.out.println(e.toString()); } catch (Exception e) {} } System.out.println(sing1); System.out.println(sing2); // null } }
getInstance 실행
객체 생성
생성자 실행
생성자 실행
java.lang.reflect.InvocationTargetException
Singleton@379619aa
nullInstantiationException은 newInstance 메서드에서 인스턴스가 생성될 수 없을 때 발생한다. 따라서 sing2 변수는 객체를 받지 못하였다. 이렇게 해야 리플렉션에 안전한 싱글톤 패턴이 나온다. 그냥 쉽게 싱글톤을 만드는 방법은 없을까?
Exception에 대한 자세한 것은 다음에 포스팅하겠다.
enum Singleton { INSTANCE; private Singleton() { System.out.println("객체 생성"); } } public class Test { public static void main(String[] args) { System.out.println("main 실행"); Singleton sing1 = Singleton.INSTANCE; Singleton sing2 = Singleton.INSTANCE; System.out.println(sing1 == sing2); } }
main 실행
객체 생성
true이 방법은 enum의 특성을 이용해 싱글톤으로 구현한 것이다. enum은 기본적으로 생성자가 private 이기 때문에 밖에서 new 연산자를 이용한 객체 생성이 불가능하다. 또한 enum은 Eager initialization이다. JVM 자체적으로 직렬화를 할 때 인스턴스를 동일하게 맞춰주며 리플렉션을 통한 객체 생성이 막혀있다. 구현 방법은 쉽지만 enum 특성상 상속이 불가능하다.
enum에 대한 자세한 것은 다음에 포스팅하겠다.
그렇다면 싱글톤 패턴은 보통 어디에 쓰일까? 로그를 남기는 클래스를 다룰 때, 캐싱과 스레드 풀을 다룰 때, 데이터베이스 커넥션을 다룰 때, 드라이버 클래스를 다룰 때, 프린터와 같은 하드웨어를 직접 접근하는 클래스를 짤 때, 프로그램의 설정 파일을 기록하는 클래스를 짤 때(config) 보통 쓰인다. 실제로 쓰이는 예시들은 없을까?
package java.lang; import ... public class Runtime { private static final Runtime currentRuntime = new Runtime(); public static Runtime getRuntime() { return currentRuntime; } private Runtime() {} ... }
java.lang.Runtime 클래스에는 사용 가능한 메모리 값을 반환하는 메서드부터 외부 프로그램을 실행시킬 수 있는 exec 메서드, 가비지 컬렉터를 실행시키는 메서드와 JVM을 종료시켜버리는 exit메서드까지 Runtime 클래스에 들어있다.
package java.awt; import ... public class Desktop { private Desktop() { Toolkit defaultToolkit = Toolkit.getDefaultToolkit(); if (defaultToolkit instanceof SunToolkit) { peer = ((SunToolkit) defaultToolkit).createDesktopPeer(this); } } public static synchronized Desktop getDesktop() { if (GraphicsEnvironment.isHeadless()) throw new HeadlessException(); if (!Desktop.isDesktopSupported()) { throw new UnsupportedOperationException("Desktop API is not " + "supported on the current platform"); } sun.awt.AppContext context = sun.awt.AppContext.getAppContext(); Desktop desktop = (Desktop) context.get(Desktop.class); if (desktop == null) { desktop = new Desktop(); context.put(Desktop.class, desktop); } return desktop; } }
java.awt.Desktop 클래스에는 기본 설정된 브라우저로 URI을 띄우는 기능, 메일 클라이언트를 표시하는 기능, 파일을 여는 기능들이 들어있다. Desktop 클래스는 synchronized를 이용한 Lazy initialization 방법을 사용하였다.
사실 싱글톤 패턴은 전역 변수와 같은 접근성은 전역 변수와 같은 단점을 공유한다. 모든 클래스에서 접근 가능하다는 것은 결합도가 높기 때문에 객체 삭제 시점을 잡기 어렵고, 프로젝트가 거대해지면 코드 읽기가 어려워지니 테스트도 힘들어진다. 또한 객체 지향적인 코드라고 보기 어렵다. 결합도가 높아진다는 것은 코드를 수정하게 되면 이 코드를 사용하는 클래스도 맞춰 수정과 테스트를 해야 한다. 따라서 SOLID의 개방-폐쇄 원칙에 맞지 않으며 의존관계 역전 원칙에도 맞지 않는다. 또, 생성자가 private이니 상속이 불가능하여 부모 클래스가 될 수 없다. 위에 써놓은 것처럼 싱글톤 패턴이 주로 쓰이는 곳이라고 하더라도 주의해서 써야 한다. 클래스 사용 빈도가 높아지면 병목현상이 일어날 수 있다.
즉, 전역 변수와 같이 남용하지 말고 싱글톤 패턴을 썼을 때 성능 향상이 일어난다면 그때 쓰자.
SOLID에 대한 것은 이 포스팅에 써놓았다.
https://yaboong.github.io/design-pattern/2018/09/28/thread-safe-singleton-patterns/
https://www.geeksforgeeks.org/singleton-design-pattern-introduction/?ref=lbp
https://jobjava00.github.io/language/java/basic/singleton/
https://en.wikipedia.org/wiki/Double-checked_locking#cite_note-Boehm2005-5
http://jeremymanson.blogspot.com/2008/05/double-checked-locking.html
https://madplay.github.io/post/what-is-readresolve-method-and-writereplace-method
https://docs.oracle.com/javase/7/docs/api/java/lang/Runtime.html
http://cris.joongbu.ac.kr/course/2018-1/jcp/api/java/awt/Desktop.html
'프로그래밍 > 이론' 카테고리의 다른 글
[디자인 패턴] 의존성 주입 알아보기 (1) 2021.10.18 [디자인 패턴] 디자인 패턴의 정의 (0) 2021.10.14