[디자인 패턴/Java] 01. 싱글턴 패턴(Singleton Pattern)

JJ·2023년 9월 30일

Design Pattern

목록 보기
2/4
post-thumbnail

이 글은 헤드 퍼스트 디자인 패턴(개정판) - 에릭 프릭먼 외 4 | 한빛미디어 를 참고하여 작성되었습니다.



정의

싱글턴 패턴(Singleton Pattern)을 한 문장으로 정리하면, 클래스 인스턴스를 하나만 만들고 그 인스턴스로의 전역 접근을 제공하는 패턴이라고 할 수 있다.

좀 더 쉽게 말하면, 클래스에서 인스턴스를 딱 하나만 만들어서 관리하는 형태를 말한다. 때문에 다른 클래스에서는 자신의 인스턴스를 추가로 만들 수 없게 된다.
만약 인스턴스가 필요하다면 반드시 클래스 자신을 거쳐야 한다. 즉, 어디서든 인스턴스에 접근할 수 있도록 전역 접근 지점을 제공해준다.

만약 자원을 많이 잡아먹는 인스턴스가 있다면, 이렇게 하나만 만들어두고 공유하는게 훨씬 유용하겠죠?


싱글턴 패턴은 다양한 분야에서 사용할 수 있다. 스레드 풀, 캐시, 사용자 설정, 레지스트리 설정 처리 객체, 로그 기록 객체, 디바이스 드라이버 등 인스턴스가 2개 이상일 때 문제가 발생하는 경우에는 특히 자주 사용된다! 이런 경우에 인스턴스가 여러 개일 경우 에러가 발생하거나 실행 결과의 일관성을 침해하는 등 문제가 발생할 수 있기 때문이다.

💡Java에서는 싱글턴 패턴을 private 생성자와 정적 메소드, 정적 변수를 사용해 구현할 수 있다.



고전적인 싱글턴 패턴 구현 방법

우선 싱글턴 패턴 구현의 뼈대를 만들어보자. 결론부터 말하자면 아래 코드를 그대로 사용하기에는 문제점이 있긴 하지만, 앞서 살펴본 정의 내용을 그대로 코드로 옮기면 아마 아래와 같은 형태가 될 것이다.

public class Singleton {
	private static Singleton uniqueInstance;

	//기타 인스턴스 변수

	private Singleton() {}

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

	//기타 메소드
}

고전적인 싱글턴 패턴은 위와 같이 객체를 필드로 갖고, 생성자를 private 으로 선언한다. 때문에 해당 클래스에서만 인스턴스를 생성할 수 있다. 이 하나뿐인 인스턴스를 정적 변수(위 코드에선 uniqueInstance 가 이에 해당)에 저장해두고 getInstance() 메소드를 이용해 접근할 수 있도록 한다.



고전적인 구현 방법의 문제

앞서 살펴본 고전적인 구현 방법은 멀티스레딩 환경에서 문제가 발생할 수 있다고 했는데, 정확히 말하면 getInstance() 메소드 내부의 조건문을 통해 여러 개의 객체가 생성될 수 있기 때문에 싱글턴 패턴의 원칙이 깨지게 되는 것이다.

예시를 통해 살펴보자.

위 예시를 보면, 두 스레드가 getInstance() 메소드를 호출하는 시점이 약간 어긋났을 뿐인데 두 개의 인스턴스 객체가 생성되었다.

즉, 조건문에서 객체의 존재 유무를 참조하는 시점으로 인해 객체를 하나만 만드는 싱글턴 패턴의 대전제가 깨지게 된 것이다. 심지어 두 스레드는 전혀 다른 객체를 참조하기까지 한다. 그렇다면 이런 문제를 해결하려면 어떻게 해야 할까?



멀티스레딩 문제 해결하기


💡 방법 1. 메소드 동기화 하기

대부분의 Java 어플리케이션을 지원하기 위해선 싱글턴 패턴이 멀티스레딩 환경에서도 정상적으로 작동해야 한다. 때문에 이를 해결하는 것도 필수라고 할 수 있는데, 가장 쉬운 방법은 메소드를 "동기화"하는 것이다.

코드를 먼저 살펴보자.

public class Singleton {
	private static Singleton uniqueInstance;

	//기타 인스턴스 변수

	private Singleton() {}

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

	//기타 메소드
}

위 코드와 같이 문제가 되었던 getInstance() 메소드에 synchronized 를 붙여서 메소드를 동기화하는 방식이다. 이렇게 메소드를 동기화하면, 한 스레드가 메소드 사용을 끝내기 전까지 다른 스레드는 기다리게 되므로 동시 접근을 막을 수 있다.

🚨 물론 이 방법은 해당 메소드가 애플리케이션에 큰 부담을 주지 않을 경우 사용 가능한 방식이다. 사실 따지고 보면 동기화가 꼭 필요한 시점은 메소드가 시작될 때 뿐이니까, 계속해서 메소드를 동기화된 상태로 유지하는 것은 오히려 불필요한 오버헤드를 증가시키는 원인이 될 수 있다.

즉, 동기화시킨 메소드가 애플리케이션에서 병목으로 작용하는 경우, 메소드를 동기화시키는 것이 오히려 애플리케이션의 성능을 저하시킬 수 있게 된다. 해당 메소드가 애플리케이션에 큰 부담이 되지 않는다면 그냥 동기화시키고 두는 것이 편하겠지만, 그렇지 않은 경우에는 다른 방법을 찾아야한다.


💡 방법2. 인스턴스를 필요할 때 생성하지 않고 처음부터 만들어두기

동기화를 사용하지 않고 멀티스레딩 문제를 해결할 수 있는 방법 중 하나는 처음부터 인스턴스를 무조건 만들어두는 것이다. 아래 코드를 살펴보자.

public class Singleton {
	private static Singleton uniqueInstance = new Singleton(); //인스턴스 생성

	private Singleton() {}

	public static synchronized Singleton getInstance() {
		return uniqueInstance;
	}
}

위와 같이 정적 초기화 부분에서 인스턴스를 생성해버리는 방법이 있다. 이렇게 해두면 클래스가 로딩될 때 JVM에서 Singleton 클래스의 하나뿐인 인스턴스를 생성하게 되고, JVM이 인스턴스를 생성하기 전까진 어떤 스레드도 이 정적 변수에 접근할 수 없게 된다.

가장 단순하지만 어떻게 보면 가장 정확한 방법이라고 할 수 있다.


💡 방법3. DCL(Double-Checked Locking) 사용하기

DCL을 사용하면 인스턴스가 생성되어 있는지 확인하고, 생성되어 있지 않을 때만 동기화를 하거나, 처음에만 동기화하고 나중에는 동기화하지 않는 작업을 수행할 수 있다. 선택적으로 동기화를 하면 동기화했을 때의 장점은 그대로 가져가고, 성능이 느려진다는 단점은 가져가지 않아도 된다는 사실!

public class Singleton {
	private volatile static Singleton uniqueInstance;

	private Singleton() {}

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

위 코드처럼 synchronized 를 이용하여 메소드를 동기화해두고, 변수에 volatile 키워드를 붙여주면 된다. 이렇게 해두면 멀티 스레딩 환경에서도 해당 변수가 인스턴스로 초기화되는 과정이 올바르게 진행된다.

🚨 다만 이 방식은 Java 1.4 이전 버전에서는 사용할 수 없다. 이보다 낮은 버전에서는 volatile 키워드를 사용해도 DCL에서 동기화가 제대로 수행되지 않을 가능성이 높다! 때문에 Java 5 보다 낮은 버전의 JVM을 써야 한다면 다른 방식으로 싱글턴을 구현해야 한다.


💡 방법4. LazyInitialization.LazyHolder(게으른 홀더, Thread-safe) 사용하기

DCL을 사용할 수 없다면 DCL보다 단순하고 안전하다는 장점을 갖는 홀더 패턴, 즉 Holder 클래스를 선언하여 사용하는 방법도 있다. 아래 코드를 살펴보자.

public class Singleton {  

    private Singleton() { }
    
    //Holder 클래스 선언
    private static final class Holder {  
        private static final Singleton INSTANCE = new Singleton();  
    }  
    
    public static Singleton getInstance() {  
        return Holder.INSTANCE;  
    }

	...
}

위 코드처럼 Holder 클래스를 선언하면, 해당 클래스 내에 있는 정적 필드인 INSTANCE 가 사용될 때 Holder 클래스의 초기화가 일어나게 된다.

때문에 런타임에 Singleton.getInstance() 가 호출되면, Holder.INSTANCE 를 사용하기 전에 클래스 로더를 통해 Holder 클래스의 초기화가 일어나게 된다. 그리고 이 초기화 단계에서 정적 필드 INSTANCE 의 초기화가 단 한 번만 일어나게 되는 것이다.

이 방식은 synchronizedvolatile 키워드를 사용하지 않기 때문에 성능 면에서도 뛰어나다는 장점을 갖고 있다.

📌 추가로 클래스나 인터페이스 타입 T의 초기화 시점은 다음 중 하나가 처음 일어나기 직전이 될 수 있다.

  • T는 클래스이며 T의 인스턴스가 생성된다.
  • T에 선언된 정적 메서드가 호출된다.
  • T에 선언된 정적 필드가 할당된다. (e.g., 외부에서 공개된 정적 필드에 값을 할당하는 등)
  • T에 선언된 정적 필드가 사용되며 이때 이 필드는 상수 변수가 아니다.
    (상수 변수 = 상수 표현식으로 초기화된 기본 타입 or String 타입의 final 변수)

💡 방법5. enum 사용하기

사실 Java에는 이러한 동기화 문제나 클래스 로딩 문제 외 리플렉션, 직렬화, 역직렬화 등의 문제도 해결할 수 있는 강력한 기능을 제공한다다. 바로 enum 이다!

public enum Singleton {
	UNIQUE_INSTANCE;
	//기타 필요한 필드
}

public class SingletonClient {
	public static void main(String[] args) {
		Singleton singleton = Singleton.UNIQUE_INSTANCE;
		//여기서 싱글턴 사용
	}
}

위 코드처럼 enum 을 이용해 싱글턴을 구현하는 방법인데, 인스턴스가 JVM 내에 하나만 존재함을 보장하고 있기 때문에 객체가 여러 개 생성되는 등의 문제를 고민할 필요가 없다.

또한 enum 을 사용하면 기본적으로 직렬화가 가능하기 때문에 Serializable 인터페이스를 구현할 필요가 없다. 즉, 리플렉션 문제도 발생하지 않게 되는 것이다.



그 외 발생할 수 있는 문제들

지금까지 온전한 싱글턴 패턴을 구현하기 위한 여러 방법들을 살펴봤는데, 안타깝게도 디자인 패턴은 만능이 아니기 때문에 그에 따르는 부수적인 문제들이 몇 가지 더 있다.


❗️ 문제1. 클래스 로더

JVM의 클래스 로더는 런타임 중 JVM의 메소드 영역에 동적으로 Java 클래스를 로드하는 역할을 한다. 이 때 클래스 로더마다 서로 다른 네임스페이스를 정의하는데, 이런 클래스 로더가 여러 개 있으면 멀티 스레딩 문제처럼 여러 개의 인스턴스가 생길 수 있다.

따라서 클래스 로더가 여러 개라면 싱글턴을 사용할 때 이 부분을 잘 살펴봐야 한다. 따라서 클래스 로더를 여러 개 사용하게 된다면 직접 클래스 로더를 지정해줘야 한다.

🚨 다만 자바와 스프링은 싱글턴 객체의 생명주기가 다르고, 자바의 경우엔 클래스 로더가 기준이 되지만 스프링에서는 ApplicationContext가 기준이 되기 때문에 주의해서 사용해야 한다.
예를 들어, 톰캣이 WAR 파일을 만들게 되면 이 파일 하나에 클래스 로더가 1:1로 배치된다. 즉, 하나의 WAR 파일에 하나의 클래스로더가 매핑되는 것이다. 이런 경우에는 다른 WAR 파일은 참조가 불가능하게 된다.
반면 스프링의 경우, web.xml에서 root context 하나에 여러 개의 servlet context를 등록할 수 있게 된다. 이 경우에는 각각의 context 들이 싱글턴의 범위가 되는 것이다.


❗️ 문제2. 느슨한 결합 원칙 위배

이 문제는 싱글턴 패턴의 고질적인 문제인데, 우선 느슨한 결합 원칙부터 살펴보자.

💡 느슨한 결합 원칙 | ”상호작용하는 객체 사이에서 최대한 느슨한 결합을 추구해야 한다.”

만약 싱글턴을 사용할 때 이 싱글턴 객체를 바꾸게 되면 연결된 모든 객체를 바꿔야 하는 상황이 발생할 수 있다. 즉, 싱글턴 패턴을 사용할 때 가장 위배하기 위운 원칙이 바로 느슨한 결합 원칙인 것이다. 둘 중 어떤 것을 선택하느냐가 바로 개발자의 몫이 될 것이다.


❗️ 문제3. 객체지향 원칙 위배

객체지향을 공부하신 분이라면 글을 읽는 내내 뭔가 걸리는 것이 있었을 것 같은데요. 바로 이 객체지향 원칙이다.

싱글턴은 총 두 가지를 책임지게 되는데, 이는 다음과 같다.

1. 자신의 인스턴스를 관리하는 일 
2. 원래 그 인스턴스를 사용하고자 하는 목적에 부합하는 작업

즉, 이는 한 클래스가 한 가지만 책임지도록 하는 객체지향 관점의 디자인에서 벗어나는 경향을 갖게 되는 것이다.

하지만 전체적인 디자인을 간단하게 만들기 위해 클래스 내에 인스턴스 관리 기능을 포함시키는 경우도 심심치 않게 찾아볼 수 있다. 또 객체지향 개발을 하면서도 싱글턴을 사용하는 개발자도 많다! 이런 상황 때문에 싱글턴 기능을 별도로 뽑아내야 한다는 의견도 존재한다고 한다.


❗️ 문제4. 서브 클래스 생성 & 과도한 싱글턴 사용

싱글턴의 서브 클래스를 만들어야 한다면 가장 먼저 마주치는 문제는, 생성자가 private 으로 선언되어 있다는 점 이다. 즉, 클래스를 확장할 수 없는 상태를 의미하게 되는 것이다. 때문에 생성자를 public 이나 protected 로 선언해야 한다. 그렇게 되면 다른 클래스에서 인스턴스를 만들 수 있게 되니까, 싱글턴의 본래 목적을 잃어버리는 셈이 될 수 있다.

그 외에도 부수적인 문제들이 존재하지만... 만약 싱글턴의 서브클래스를 만들어야 한다면, 싱글턴을 확장해서 어떤 일을 할지를 잘 생각해봐야 한다. 애초에 싱글턴은 하나의 인스턴스를 공유하여 사용하려는 목적이니까! 원래의 목적성에 부합하는 디자인인지 다시 한 번 고려해봐 한다.

또한 하나의 애플리케이션에 과도하게 많은 싱글턴이 사용되는 경우에도 적절한 디자인인지 검토해야 한다. 디자인 패턴은 만능이 아니고, 싱글턴 또한 어떤 특수한 문제를 해결하기 위해 사용되는 도구에 불과하기 때문이다!


❗️ 그 외 여러 문제들...

그 외에도 리플렉션, 직렬화, 역직렬화 등 Java의 고급 기능을 사용하는 경우 파생되는 문제들도 다양하다. 이러한 경우들을 모두 세세하게 살펴보기엔 내용이 너무 길어질 것 같아 따로 정리해두진 않았지만, 앞서 말했듯이 Java 1.5 이상을 사용한다면 enum 을 사용해 이런 문제를 피할 수 있다.



마무리

싱글턴 패턴을 처음 접하게 되면 "전역 변수랑 뭐가 다른거지?" 라는 생각이 들 수 있다. 짧게 정리해보면 전역 변수는 객체의 정적 레퍼런스를 의미하기 때문에, 게으른 인스턴스 생성을 할 수 없고 처음부터 끝까지 인스턴스를 가지고 있어야 한다. 또한 간단한 객체의 전역 레퍼런스를 계속해서 만들기 때문에 네임스페이스가 지저분해진다.

싱글턴도 과도하게 사용한다면 네임스페이스를 어지럽힐 가능성이 있지만, 앞서 말했듯이 싱글턴을 과도하게 사용하는 것은 문제가 있는 디자인일 가능성이 높을 뿐더러 그럴 상황은 거의 없다고 볼 수 있다.


디자인 패턴이 만능은 아니지만, 잘 활용하면 효율성을 증가시키거나 코드를 깔끔하게 유지하는 것도 가능하고, 개발 생산성 자체를 높이는 방법도 될 수 있다. 개발자가 할 일이 바로 이 적재적소를 판단하고 능숙하게 사용하는 일이 되겠다!

0개의 댓글