[Design Pattern] 싱글톤 패턴 (w. Java, Kotlin)
들어가기 전에
개발자라면 한 번쯤 들어봤을 법한 싱글톤 패턴이란 무엇일까?
기술면접을 가면 꼭 한 번씩 물어보는 질문일 것이다.
면접관 : 디자인 패턴에 대해서 알고 있나요?
나 : 네, 깊게는 아니지만 간단한 개념정도는 알고 있습니다.
면접관 : 그럼 싱글톤 패턴이 뭐죠?
나 : 인스턴스를 오직 한 개만 제공하도록 하는 디자인 패턴입니다.
면접관 : 이걸 왜 쓸까요?
위와 같은 면접 흐름에서 많이도 뒤통수를 맞아본 기억이 많다.
Spring 에서는 가장 핵심적인 원리라고 하는데 나는 그 부분이 잘 와닿지 않았다.
개발자로서 늘 왜? 라는 물음과 함께 지내는데 공식문서를 열심히 찾아봐도 블로그를 열심히 뒤져도 그런 내부 코드는 확인할 수 없었다.
실제로 코드로 봐야 아, 하고 와닿는 부분이 있을 텐데 그 부분을 깊게 파보다가 결국엔 일단 개념을 외우고 나중에 천천히 뜯어보자라는 생각을 품었다.(이때 뜯어봤어야 했는데....) 그래서 지금이라도 이렇게 뜯어보려고 한다.
각설하고 이 포스팅에서는 아래와 같은 내용을 다룰 것이다.
싱글톤 패턴은 하나의 객체만 생성해서 해당 객체만을 공유할 수 있도록 하는 디자인 패턴이다.
하지만 모든 클래스나 구조체마다 싱글톤 패턴을 적용하기엔 무리가 있을 수 있다.
따라서 Java 와 Kotlin 에서 싱글톤 패턴을 어떻게 구현하는지 알아보고 상황에 맞게 적절하게 활용하도록 하자.그리고 Spring 에서는 어떻게 활용되는지까지 고민한 흔적을 기록한다.
언제 싱글톤 패턴이 필요할까?
백기선님이 강의 중에 싱글톤 패턴에 대해서 이렇게 말씀하셨다.
시스템 런타임, 환경 세팅에 대한 정보 등 인스턴스가 여러 개일 때 문제가 생길 수 있는 경우가 있다.
이때 인스턴스를 오직 한 개만 만들어서 제공하는 클래스가 필요하다.
싱글톤 패턴은 간단하게는 인스턴스를 오직 한 개만 제공하는 클래스이다.
그렇다면 대체 인스턴스가 한 개 필요한 순간이 언제일까?
나는 이 질문에 대한 대답을 Spring을 기준으로 먼저 생각했다.
우선 @Entity, @Service, @Repository, @Controller 와 같은 어노테이션이 붙은 클래스는 싱글톤 패턴이어야 할 것이다. 멀티스레드 환경에서 사용자의 숫자마다 각자 제공하는 DB 데이터 환경이나 서비스 로직이 다르다면 그것도 말이 안 되지 않을까?
단순하게 언제 어디서나 사용자가 수십, 수백만이 되어도 같은 것을 제공해야만 하는 클래스에 적용해야만 하는 것이다.
이게 아마 Spring 에서는 대표적으로 @Bean, @Component 이 붙은 클래스나 메소드라면 전부 적용될 것이다.
Spring 프로젝트가 실행될 때 스프링 컨테이너에 해당 어노테이션이 붙은 클래스나 메소드의 구현체가 등록되며 해당 빈에 대한 요청이 있을 때마다 스프링 컨테이너는 이 인스턴스를 제공하는 방식이기 때문이다.
| Singleton |
| - instance : Singleton |
| + getInstanace() : Singleton |
Java에서 구현하는 법
Java 코드는 수업 자료 중 하나이므로 공개하지는 않겠다. 대신 아래에 kotlin 으로 바꾼 코드를 제시한다.
가장 단순히 구현하는 법
클래스 안에서 private 한 인스턴스 변수를 생성한 후 static 메소드를 통해 객체를 생성하려는 순간, 객체가 비어있다면 새로 생성하고 그렇지 않다면 클래스 안에 있는 인스턴스 변수를 전달한다.
단점은 thread safe 하지 않는다는 점이다.
동시에 다른 thread 에서 빈 인스턴스 변수에 접근하려고 했을 때 어떻게 될 지 고민해보자.
/**
* [가장 단순히 구현하는 법]
* - 클래스 안에서 private 한 인스턴스 변수를 생성한 후 static 메소드를 통해 객체를 생성하려는 순간, 객체가 비어있다면
* 새로 생성하고 그렇지 않다면 클래스 안에 있는 인스턴스 변수를 전달한다.
* - thread safe 하지 않다.
* - 동시에 다른 thread 에서 빈 인스턴스 변수에 접근하려고 했을 때 어떻게 될지?
*/
class Settings2 private constructor() {
companion object {
private var instance: Settings2? = null
fun getInstance(): Settings2 {
if (instance == null) {
instance = Settings2()
}
return instance!!
}
}
}
@Test
@DisplayName("싱글톤 패턴이 적용되지 않아 같은 클래스로 만든 인스턴스는 서로 다르다")
fun singletonBySettingsTest() {
val basicSettings = BasicSettings()
val basicSettings2 = BasicSettings()
assertTrue(basicSettings != basicSettings2)
}
@Test
@DisplayName("private 생성자에 static 메소드로 생성자를 전달해줘도 인스턴스는 서로 다르다")
fun singletonBySettings1Test() {
val settings1 = Settings1.getInstance()
val settings2 = Settings1.getInstance()
assertTrue(settings1 != settings2)
}
@Test
@DisplayName("static 인스턴스를 생성해서 객체가 null 이면 한번 생성하고 그 이후엔 그 객체를 유지해서 싱글톤 패턴을 유지한다")
fun singletonBySettings2Test() {
val settings1 = Settings2.getInstance()
val settings2 = Settings2.getInstance()
assertTrue(settings1 === settings2)
}
멀티 쓰레드 환경에서 안전하게 구현하는 방법
- synchoronized 키워드를 사용한다. 그러나 동기화 처리 작업 때문에 늦어질 수 있다.
/**
* [멀티 쓰레드 환경에서 안전하게 구현하는 방법]
* - synchronized 사용하기
*/
class Settings3 private constructor() {
companion object {
private var instance: Settings3? = null
@Synchronized
fun getInstance(): Settings3 {
return instance ?: Settings3().also { instance = it }
}
}
}
@Test
@DisplayName("Settings3 클래스에서 멀티 스레드 환경일 때 객체가 일치하는지 확인한다")
fun singletonBySettings3Test() {
val instances: MutableSet<Settings3> = ConcurrentHashMap.newKeySet()
val threadCount = 100
val threads = mutableListOf<Thread>()
for (i in 1..threadCount) {
val thread = Thread {
val instance = Settings3.getInstance()
instances.add(instance)
}
threads.add(thread)
thread.start()
}
for (thread in threads) {
thread.join()
}
assertEquals(1, instances.size)
}
- 이른 초기화(eager initialization)
/**
* [멀티 쓰레드 환경에서 안전하게 구현하는 방법]
* - 이른 초기화 사용
*/
class Settings4 private constructor() {
companion object {
private val instance: Settings4 = Settings4()
fun getInstance(): Settings4 = instance
}
}
@Test
@DisplayName("이른 초기화(eager initialization) 사용하기")
fun singletonBySettings4() {
val settings = Settings4.getInstance()
assertTrue(settings === Settings4.getInstance())
}
- doublue checked locking
class Settings5 private constructor() {
companion object {
@Volatile
private var instance: Settings5? = null
fun getInstance(): Settings5 {
return instance ?: synchronized(this) {
instance ?: Settings5().also { instance = it }
}
}
}
}
@Test
@DisplayName("double checked locking 사용하기")
fun singletonBySettings5Test() {
val settings1 = Settings5.getInstance()
assertTrue(settings1 === Settings5.getInstance())
}
- static inner 로 구현하기
/**
* [멀티 쓰레드 환경에서 안전하게 구현하는 방법]
* - static inner 클래스 사용하기
* (권장하는 방법)
*/
class Settings6 private constructor() {
companion object {
private object SettingsHolder {
val instance = Settings6()
}
fun getInstance(): Settings6 = SettingsHolder.instance
}
}
@Test
@DisplayName("static inner 클래스 사용하기")
fun singletonBySetting6Test() {
val settings1 = Settings6.getInstance()
assertTrue(settings1 === Settings6.getInstance())
}
자, 그러면 이 싱글톤 패턴 구현 방법을 깨뜨리려면 어떻게 해야할까?
reflection 을 사용하면 된다!
@Test
@DisplayName("싱글톤 패턴 구현 방법을 깨트리는 방법(리플렉션)")
fun breakSingleton() {
// given
val settings = Settings6.getInstance()
val constructor = Settings6::class.primaryConstructor!!
constructor.isAccessible = true
// when
val settings2 = constructor.call()
// then
assertTrue(settings != settings2)
}
그럼 가장 찬란하고 아름다우며 단순하고 안전한 싱글톤 패턴 구현 방법은 뭘까?
바로 enum 이다.
enum class Settings {
INSTANCE,
}
@Test
@DisplayName("싱글톤 패턴 구현(ENUM 과 reflection)")
fun singletonByEnumTest() {
// given
val settings = Settings.INSTANCE
// when
val enumClass = Settings::class
val enumProperties = enumClass.declaredMemberProperties
// then
enumProperties.forEach { property ->
property.isAccessible = true
val settings1: Any? = property.get(settings) // Any? 타입으로 선언
assertTrue(settings === settings1)
}
}
Kotlin 으로 싱글톤 패턴 구현하기
객체 선언(Object Declaration)
/**
* [코틀린에서 싱글톤 패턴 구현하기]
* [ V ] 객체 선언(Object Declaration)
*
* 가장 권장하는 방법
*
* 클래스 정의와 동시에 초기화되는 단일 인스턴스로 동작한다.
* 이 객체 선언을 통해 싱글톤 패턴을 쉽고 간결하게 구현할 수 있다.
*
* object 는 싱글톤 객체 선언을 위한 전용 키워드
*
* 생성자 생성 불가능, 상속 불가능, 추상화 불가능, 인터페이스 구현 불가능
*/
object ObjectDeclarationSingletonUser
@Test
@DisplayName("객체 선언으로 싱글톤 패턴을 구현할 수 있다")
fun objectDeclarationTest() {
val user = ObjectDeclarationSingletonUser
val user2 = ObjectDeclarationSingletonUser
assertTrue(user === user2)
}
패키지 수준 함수(Package-Level Function)
/**
* [코틀린에서 싱글톤 패턴 구현하기]
* [ V ] 패키지 수준 함수(Package-Level Function)
*/
private var instance: PackageLevelSingletonUser? = null
fun getPackageLevelSingletonInstance(): PackageLevelSingletonUser {
return instance ?: synchronized(PackageLevelSingletonUser::class) {
instance ?: PackageLevelSingletonUser().also { instance = it }
}
}
class PackageLevelSingletonUser() {
// 클래스의 내용
}
@Test
@DisplayName("패키지 수준 함수로 싱글톤 패턴을 구현할 수 있다")
fun packageLevelFunctionTest() {
val user = getPackageLevelSingletonInstance()
assertTrue(user === getPackageLevelSingletonInstance())
}
동반 객체(Companion Object)
/**
* [코틀린에서 싱글톤 패턴 구현하기]
* [ V ] 동반 객체(Companion Object)
*/
class CompanionObjectSingletonUser private constructor() {
companion object {
private val instance = CompanionObjectSingletonUser()
fun getInstance(): CompanionObjectSingletonUser = instance
}
}
@Test
@DisplayName("동반 객체로 싱글톤 패턴을 구현할 수 있다")
fun companionObjectTest() {
val user = CompanionObjectSingletonUser.getInstance()
assertTrue(user === CompanionObjectSingletonUser.getInstance())
}
lazy 초기화
/**
* [코틀린에서 싱글톤 패턴 구현하기]
* [ V ] lazy 초기화
* lazy 를 이용해서 instance 생성을 지연 초기화하며 처음 접근되는 시점에서 초기화 된다
* 그 이후에는 초기화된 값을 재사용한다
*/
class LazyInitializationSingletonUser private constructor(
var firstName: String? = null,
var lastName: String? = null,
) {
companion object {
private val singletonUser by lazy(LazyThreadSafetyMode.PUBLICATION) { LazyInitializationSingletonUser() }
fun getInstance(): LazyInitializationSingletonUser = singletonUser
}
}
fun main(args: Array<String>) {
with(LazyInitializationSingletonUser.getInstance()) {
firstName = "Ihor"
lastName = "Kuchen"
}
}
@Test
@DisplayName("lazy 초기화를 사용한 싱글톤 패턴 테스트")
fun lazyInitializationTest() {
val user = LazyInitializationSingletonUser.getInstance()
assertTrue(user === LazyInitializationSingletonUser.getInstance())
}
더 고민해야할 점
싱글톤 패턴 복습
자바 enum 을 사용하지 않고 싱글톤 패턴을 구현하는 방법은?
private 생성자와 static 메소드를 사용하는 방법의 단점은?
enum을 사용해 싱글톤 패턴을 구현하는 방법의 장점과 단점은?
static inner 클래스를 사용해 싱글톤 패턴을 구현해보자
실무에서는 어떻게 쓰이나?
스프링에서 빈의 스코프 중 하나로 싱글톤 스코프
자바 java.lang.Runtime
다른 디자인 패턴(빌더, 퍼사드, 추상 팩토리 등) 구현체의 일부로 쓰이기도 한다