Notice
Recent Posts
Recent Comments
«   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
Archives
Today
Total
관리 메뉴

DeFacto-Standard IT

Singleton Pattern 본문

Design Pattern/Creational Pattern

Singleton Pattern

defacto standard 2017. 9. 23. 04:37

1. 가정

 - 여러 사람이 동시에 프린터를 사용하는 경우를 구현한다.

 - 여러 사람은 스레드로서 구현한다.

 - 프린터는 1대밖에 존재하지 않는다.


2. Naive Code

 - NaivePrinter

public class NaivePrinter {
private static NaivePrinter naivePrinter;

private NaivePrinter(){
}

public static NaivePrinter getNaivePrinter(){
if(naivePrinter ==null)
naivePrinter = new NaivePrinter();
return naivePrinter;
}

public void print(String string) {
System.out.println(string);
}
}

프린터는 하나밖에 존재하지 않는다. 따라서, NaivePrinter naivePrinter = new NaivePrinter(); 와 같은 방식으로 Printer 객체를 얻는다면, 이미 여러 개의 NaivePrinter 객체가 만들어 질 수 있다는 의미이다. 따라서 생성자에 private 접근제어자를 적용하여 생성자를 통한 객체 생성을 방지하였다.


NaivePrinter를 통해 프린트를 하려면 어쨋든간에 NaivePrinter 객체를 할당받아야 한다. 

그러나, 생성자를 private로 지정하여 외부 클래스에서 호출할 수 없기 때문에, new 키워드를 통한 객체 생성은 불가능하다.


이 대안으로, static 키워드를 통해 NaivePrinter 클래스 내부에 NaivePrinter 타입의 클래스 변수(정적 변수, static 변수)를 선언하였다.

public static 메서드를 제공함으로써 클래스 메서드(정적 메서드, static 메서드)를 호출할 수 있게 하였다.


getNaivePrinter()가 처음 호출되는 시점에서 naivePrinter는 null이다. 따라서 첫 호출에서 NaivePrinter 객체를 생성하여 레퍼런스에 바인딩하고 이를 리턴한다.


따라서 메서드가 2번째 이상 부터 호출되더라도 같은 객체를 리턴할 것이다.


 - NaiveThread

public class NaiveThread extends Thread {
public NaiveThread(String name) { // 스레드 생성
super(name);
}

public void run() { // 현재 스레드 이름 출력
NaivePrinter naivePrinter = NaivePrinter.getNaivePrinter();
naivePrinter.print(this.getName() + " " + naivePrinter.hashCode());
}
}

동시에 여러사람이 접근한다는 것은 스레드로서 구현하였다. 스레드가 실행되면 NaivePrinter로부터 NaivePrinter객체를 받아 프린트를 하게된다.



 - NaiveClient

public class NaiveClient {
private static final int THREAD_NUM = 5;

public static void main(String[] args) {
NaiveThread[] user = new NaiveThread[THREAD_NUM];
for (int i = 0; i < THREAD_NUM; i++) {
user[i] = new NaiveThread((i + 1) + "-thread");
user[i].start();
}
}
}

Client에서는 쓰레드를 5개를 생성하고, 바로 프린트를 하도록 스레드를 실행시킨다.


3. 문제점

위 소스코드는 '단 한개의 프린터만을 사용한다'라는 요구사항을 만족할 수 없다.


그 이유는 Thread-Safe하지 못하기 때문이다.


아무리 static 변수와 static 메서드를 사용한다고 하더라도, 스레드는 결국 동시에 수행된다.



여기서부터는 OS에 대한 지식이 필요하다.


스레드는 프로세스의 일부로서 동작하게 된다.


요즘 대부분의 OS는 프로세스에 대하여 RR(Round Robin) 방식의 선점 스케쥴링을 사용하여 여러 프로세스에 대해 공평하게 시간을 분배하여 CPU를 사용하게 한다.


이렇게 프로세스에 CPU를 사용할 수 있도록 할당된 시간을 Time Slice라고 한다.


Time Slice동안 자신의 일을 수행하고 다른 프로세스에게도 CPU를 사용할 수 있도록 현재 CPU레지스터에 저장된 데이터(프로세스에 대한 데이터) 를 다른 곳에 저장시키고 다른 프로세스의 데이터를 로드해야 하는데, 이러한 일련의 작업들을 Context Switching 이라고 한다.


스레드도 프로세스의 일부이기 때문에, time slice가 제공되는 것은 마찬가지이다. 


예를 들어서, 쓰레드1, 2가 생성되고 나서 각각 실행된다고 해보자.


쓰레드1이 먼저 수행된다고 가정하겠다. 실제로는 스케쥴링 알고리즘에 의해 어떤 스레드가 먼저 실행될지는 알 수 없다.


쓰레드1은 NaivePrinter 객체를 얻기 위해 getNaivePrinter()를 호출 하였고, 현재 naivePrinter객체는 null 상태이므로 if문의 결과는 true가 된다. 그리고 if문 내부의 로직을 수행하려고 할 것이다.


이때, Thread1에 대한 time slice가 끝났고, if문 안의 로직은 수행하지 못했다. 즉, NaivePrinter객체를 만드는 코드는 실행되지 않은 것이다.

이제 Thread2가 CPU를 점유하게 되었다. 


쓰레드2는 NaivePrinter 객체를 얻기 위해 getNaivePrinter()를 호출 하였고, 현재 naivePrinter객체는 null 상태이므로 if문의 결과는 true가 된다. 그리고 if문 내부의 로직을 수행하려고 할 것이다.


Thread2에 대한 time slice가 끝나고, Thead1이 CPU를 점유한다. if문 내부에서 time slice가 끝났으므로, 그 지점부터 다시 시작할 것이다. 그 코드는 NaivePrinter 객체를 생성해서 리턴하는 부분이다.


Thread1에 대한 time slice가 끝나고, Thread2가 CPU를 점유한다. if문 내부에서 time slice가 끝났으므로, 그 지점부터 다시 시작할 것이다. 그 코드는 NaivePrinter 객체를 생성해서 리턴하는 부분이다.


분명히, 사용 가능한 NaivePrinter의 레퍼런스는 1개이다. 하지만, 객체는 2번 만들어졌다. 이것은 '프린터는 1개이다'라는 요구사항을 만족시킬 수 없다.


실제로 수행 결과는 다음과 같다.


OS에 대한 스케쥴링을 사람이 정확하게 알 수 없기에 몇개의 쓰레드가 만들어지는지는 예상할 수 없다. 5개의 Thread가 전부 다른 객체를 생성할 수도 있고, 타이밍이 좋아서 단 하나의 객체만을 생성하게 될 수도 있다.


따라서, 위 결과는 실행할 때 마다 다른 결과를 보인다. HashCode값은 계속해서 바뀌며, Thread의 실행 순서 또한 랜덤이다. 


자바에서 두 개 이상의 객체가 서로 다른 객체인지 아닌지를 구분하는 방법은, 객체 생성시에 할당되는 hash code 값으로 판단한다.

따라서 같은 객체인지를 판단하기위해 hashCode()를 사용하여 출력하도록 구현하였다.


4. 해결방안

현재 문제가 되는 부분은 객체를 만드는 부분이 Thread-Safe하지 못하다는 것이다. Thread-Safe하다는 것은 곧 그 부분이 '임계영역'이라는 것이다. 임계영역은 단 한개의 프로세스만 접근하여 처리가 가능한 로직이다.


위 예제에서는 NaivePrinter 객체를 얻어 반환하는 부분이 된다.


자바에서는 synchronized 키워드를 제공한다. 이 부분을 임계영역으로 설정함으로써, 여러 개의 Thread가 동시에 접속할 시 이를 제어하여 단 한개의 Thread만 접속하게 해야 한다.


그러나, synchronized 키워드를 사용하면 성능의 하락이 존재한다. 따라서, 이 키워드를 쓰는것 보다 더 효율적인 방법을 찾아야 한다.


이는 생각보다 쉬운데, 바로 클래스 변수를 선언과 동시에 new 키워드를 사용하여 할당하는 것이다.


5. Solution Code

 - Singleton

public class Singleton {
// 선언과 함께 객체를 할당
// getInstance()에서의 분기문을 근본적으로 없애, 성능의 향상과 소스코드의 간결함이라는 이득을 취함
// getInstance()에서 synchronized 키워드를 쓰지 않기 때문에 성능의 하락을 방지할 수 있다.
// static 함수에서 변수를 사용하는 경우, 해당 변수는 static변수여야 한다.
private static Singleton singleton = new Singleton();
int counter = 0;

private Singleton() {
}

// static : 전역에서 호출 가능
public static Singleton getInstance() {
return singleton;
}

// synchronized : Thread-Safe
public synchronized void threadSafedMethod(String string) {
counter++;
System.out.println(string + " " + counter);
}
}

단 한개만 생성되어야 하는 Singlton 객체를 생성하는 Singlton 클래스이다.


생성자는 private로 두어 new 연산자의 무분별한 사용을 방지하였다.

getInstance()라는 메서드를 통해 객체를 얻게 된다.


생성자가 private이기 때문에 Printer객체를 얻으려면 이 메서드는 public static메서드여야한다.


Printer 객체를 사용하려면 우선 객체를 얻어야 하는데, 일반적으로 new를 사용해서 얻으려니 생성자는 private로 막혀 있어서 사용할 수 없기 때문이다.


따라서 클래스 변수로 두어서 이 메서드를 통해 얻어야 한다.


클래스 변수는 static이라는 키워드가 붙는데, static 메서드 안에서 사용하는 모든 변수는 클래스 변수(static 변수)여야 한다.


static 키워드는 정적 메서드 혹은 정적 변수를 지정할 때 쓰이는데, 이는 프로그램이 실행될 때 메모리 상에 로드된다.


로컬필드나 로컬객체를 참고하는 경우, 객체가 만들어져야만 참조가 가능한데, 프로그램 실행 시에는 지역객체를 생성할 수 없기 때문에 static 메서드 내부에서 로컬 필드를 참조하려고 하면컴파일 에러가 난다.


객체를 생성하는 코드가 getNaivePrinter()에 존재하기 때문에, 이를 동기화 할 필요가 있었다.


static 키워드는 프로그램이 로드될 때 미리 메모리를 할당한다. 따라서, getNaivePrinter()에서는 분기문이 사라져 소스코드가 짧아지고, getNaivePrinter()를 호출 할 때 마다 분기문을 연산 하지 않아 성능이 향상되고, synchronized 키워드를 쓰지 않아 성능의 하락을 막을 수 있다.


하지만 아직 문제가 남아있다. 클래스 변수는 프로그램 실행 시에 무조건 메모리가 할당이 되므로, 사용하든 안하든 메모리를 점유한다.


사실 이정도 클래스 하나 올라와봤자 성능이 얼마나 차이가 나겠냐만은, 그래도 최대한 성능을 끌어올려야 하는 환경에서는 적절치 못하다.


이를 해결하기 위해서는 Enum 싱글톤이나 LazyHolder를 사용하면 된다.


간단한 소스 예를 보자면 다음과 같다.


 - Enum Singleton

public enum Singleton {
INSTANCE;
}

 - LazyHolder

public class Singleton {
private Singleton() {}

public static Singleton getInstance() {
return LazyHolder.INSTANCE;
}

private static class LazyHolder {
private static final Singleton INSTANCE = new Singleton();
}
}


위에서 Singleton 클래스는 static 클래스와 static 메서드를 가지고 있다.


자바에서 클래스 로드 방식은 2개가 있는데,

1개는 런타임 동적 로딩(Run-time Dynamic Loading)이라고 하며,

나머지는 로드타임 동적 로딩(Load-time Dynamic Loading이라고 한다.


로드 타임 로딩은 위에서 설명한 것처럼 특정 클래스 내부에 다른 클래스 정보가 있다면 모두 로드하는 방식이고,

런타임 로딩은 특정 클래스 내부에 다른 클래스가 있다고 하더라도, 실제 그 클래스 정보를 필요로 하는 코드가 실행될 때 로드되는 방식이다.


따라서 위에서 설명한 LazyHolder는 런타임 로드이기 때문에, static 클래스가 미리 올라오지 않는다.


만약 멀티 스레딩 환경에서 LazyHolder를 동시에 호출하는 경우, JVM이 알아서 하나만 올려준다.




객체는 단 하나만 생성되지만, threadSafeMethod 역시 순차적으로 출력하게 해야한다. 요구사항에서 프린터는 1대밖에 없는데, 여러 스레드가 한번에 사용하는 것은 맞지 않기 때문이다.


synchronized키워드를 사용하여 이를 만족시킨다.


 - UserThread

public class UserThread extends Thread {
public UserThread(String name) { // 스레드 생성
super(name);
}

public void run() { // 현재 스레드 이름 출력
Singleton singleton = Singleton.getInstance();
singleton.threadSafedMethod(this.getName() + " " + singleton.hashCode());
}
}

단 한개 존재하는 Singleton 객체를 할당 받아서 이를 사용하는 클래스이다.


여러 개의 스레드를 돌리기 위한 클래스이다.


역시 다른 객체가 할당되었는지를 확인하기 위해 해시코드를 출력한다.


 - Client

public class Client {
private static final int THREAD_NUM = 5;

public static void main(String[] args) {
UserThread[] user = new UserThread[THREAD_NUM];
for (int i = 0; i < THREAD_NUM; i++) {
user[i] = new UserThread((i + 1) + "-thread");
user[i].start();
}
}
}

스레드를 만들어서 싱글톤 객체를 통해 출력을 한다.


출력 결과는 다음과 같다.



쓰레드의 경우는 OS의 스케쥴링에 따라 순서가 결정되므로, 쓰레드의 실행 순서는 알 수 없다. 단, 쓰레드 실행 순서 제어에 대한 상황은 고려하지 않겠다.


6. Singleton Pattern


단 한 개의 인스턴스만을 생성하고, 어디에서든 해당 객체를 불러서 사용할 수 있는 패턴.


만들어진 객체는 전체 어플리케이션 안에서 공유된다. 따라서 멀티 쓰레드 환경에서는 특히 조심하여야 한다.


instance와 getInstance()를 static으로 선언하여 전역에서 부를 수 있다.


그리고 생성자를 private로 제한하여 여러 개의 instance를 생성하는 것을 방지한다.


따라서 instance를 얻는 작업은 Singleton 클래스의 static 메서드인 getInstance로만 얻을 수 있다.


게임 프로그래밍과 같은 환경에서 이런저런 경우의 수 때문에, 프로그램 전역에 결쳐 하나의 클래스를 호출 할 경우에 유용하게 쓰인다.


- Class Diagram



7. 장단점

장점

 - 단 하나의 인스턴스 생성해야 하면서, 여러 스레드에서 이 객체를 사용할 때 유용하다. 전역에서 해당 클래스를 불러서 쉽게 사용할 수 있다.


단점

 - 전역에서 사용하다보니 복잡도가 증가하고, 결합도가 증가할 수 있다.

 - 멀티쓰레드 환경에서는 Thread-Safe에 신경써서 작성을 해야한다.

 - 어떤 인터페이스를 구현하는 것이 아니면, 가짜 구현으로 대체할 수 없기 때문에 테스트하기가 어렵다.

Comments