싱글톤 패턴(Singletone Pattern)은 언제나 옳다?

박경현·2023년 5월 19일
0
post-thumbnail

개요

A:'싱글톤 패턴은 무엇인가요?'
B: '객체를 단 하나의 인스턴스로 생성하여 하나의 인스턴스를 프로세스가 돌아가는 동안 전역에서 접근 할 수 있는 디자인 패턴입니다.'
A: '이 패턴의 위험성에 대해 말씀해주세요.'
B: '...'

개발자라면 싱글톤 패턴(Singletone Pattern)을 사용해봤는지 물어봤을 때 '네'라는 말을 하겠지만,
싱글톤 패턴의 위험성을 고민해봤는지 물어봤을 때 모두 '네'라는 말을 하진 않습니다.

해당 글은 싱글톤에 대한 개인적인 고찰을 기록하기 위해 남겨둡니다.


싱글톤이란?

싱글톤 패턴은 언어별 구현 형태가 다르지만서도 정의는 동일합니다.

'Head First:Design Pattern'에서는 싱글톤을 아래와 같이 정의를 합니다.

싱글톤 패턴(Singletone pattern)은 해당 클래스의 인스턴스가 하나만 만들어지고, 어디서든 그 인스턴스에 접근할 수 있도록 하기 위한 패턴이다.

text 싱글톤 패턴의 다이어 그램


개발자는 왜 싱글톤 패턴을 사용할까?

위 내용에서 정의한대로 싱글톤 패턴은 전역 상태로 메모리(Data) 영역에 저장되어 있는 객체의 인스턴스입니다.

** 왜 개발자들은 싱글톤 패턴을 쓰는걸까요?

  1. 생성 코스트 절감

    객체를 생성하게 되면 메모리(Heap) 영역에서 리소스를 할당합니다. 동일 리소스를 반복적으로 여러 객체에서 접근해서 사용해야 하는 경우나 한번 생성할 때 발생하는 코스트가 높은 경우, 싱글톤 패턴을 이용해서 필요한 공유 객체를 메모리(Data) 영역에 한번만 생성하여 성능상 이점을 얻을 수 있습니다.

  2. 공유 리소스 관리

    싱글톤 패턴은 여러 객체가 공유하는 리소스를 효율적으로 관리할 수 있습니다. 데이터베이스 연결, 로깅, 설정 등 전역으로 선언하여 여러 객체에서 접근이 가능하고 일관된 상태를 유지할 수 있습니다.

  3. 공유 리소스 접근

    여러 객체에서 동시에 접근하여 사용할 때, 실제 인스턴스화된 객체는 하나이기 때문에 별다른 동기화 작업 없이 데이터의 무결성을 유지할 수 있습니다.(첫 생성 경우 제외)

싱글톤 패턴은 위와 같은 장점이 있지만, 과도한 사용은 의존성과 결합도를 높일 수 있고 테스트하기 어려워질 수 있습니다. 아래에서 싱글톤의 단점과 위험성에 대해 이야기를 하겠습니다.


문제점

싱글톤 패턴은 언뜻 사용하기에는 편리성이 좋습니다. 그렇지만 사용하는 방법과 호출하는 방법 등에서 오남용으로 발생할 수 있는 문제들이 있습니다.

다른 클래스에서 전역 객체를 호출한다는 것은 클래스의 인스턴스 간 결합도가 높아져서 '개발-폐쇄 원칙(OCP, Open Close Principle)'에 위배가 됩니다.

그리고, 멀티 스레드 환경에서 동기화 처리가 되지 않을 때, 싱글톤 패턴의 정의와 같이 하나가 아닌 n개의 인스턴스가 생성될 수 있습니다. 그렇게 되었을 때 데이터의 무결성이 무너지고 메모리 관리에도 치명적인 이슈가 발생하게 됩니다.

class Singleton private constructor() {
    init {
        println("Singleton 인스턴스 생성")
    }

    companion object {
    	// getInstance를 호출 할 때마다 새로운 객체를 인스턴스화함.
        fun getInstance(): Singleton {
            return Singleton()
        }
    }
}

fun main() {
	// 신규 인스턴스가 호출하는 만큼 생성 
    val singleton1 = Singleton.getInstance()
    val singleton2 = Singleton.getInstance()

    if (singleton1 == singleton2) {
        println("같은 인스턴스입니다.")
    } else {
        println("다른 인스턴스입니다.")
    }
}

반영 방법

  • synchronized 키워드를 이용해서 thread-safety를 보장합니다.
    단점) 키워드 자체가 많은 cost를 필요하기 때문에 성능 저하로 이어질 수 있습니다.
    그리고 인스턴스의 null 상태를 더블 체크하는 로직은 호출마다 호출되기 때문에 위와 마찬가지로 성능 저하로 이어질 수 있습니다.
class SynchonizedSingleton private constructor() {
    init {
        println("SynchonizedSingleton 인스턴스 생성")
    }

    companion object {
        @Volatile
        private var instance: SynchonizedSingleton? = null

        @Synchronized
        fun getInstance(): SynchonizedSingleton {
            if (instance == null) {
                instance = Singleton()
            }
            return instance!!
        }
    }
}
  • lazy 기법을 통해 처음 호출할 때 인스턴스화하고 조건문으로 인스턴스화가 제어됩니다.
    단점) Multi-Thread 상황에서 동시 호출 처리가 되면 n개의 인스턴스가 생성될 수 있습니다.
class LazySingleton private constructor() {
    init {
        println("LazySingleton 인스턴스 생성")
    }

    companion object {
        val instance: LazySingleton by lazy {
            LazySingleton()
        }
    }
}
  • Holder를 통해 Class가 로드되는 시점을 이용하는 방법입니다. JVM의 특성을 최대한 활용한 기법으로 JVM이 클래스 초기화 과정에서 원자성을 보장하게 됩니다.
class HolderSingleton private constructor() {
    init {
        println("HolderSingleton 인스턴스 생성")
    }

    companion object {
        private val instanceHolder = Holder.instance

        val instance: HolderSingleton
            get() = instanceHolder
    }

    private object Holder {
        val instance = HolderSingleton()
    }
}
profile
#모바일 #Android #Flutter #개발문화

0개의 댓글