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

마시마로·2023년 5월 19일
post-thumbnail

1. 개요

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

개발자라면 누구나 싱글톤 패턴을 다뤄봤거나 알고 있을 것입니다.

하지만 "싱글톤 패턴의 위험성을 충분히 고민하고 쓰고 계시나요?"라는 질문에는 선뜻 답하지 못하는 경우가 많습니다. 편리함 속에 가려진 싱글톤의 이면과, 올바른 구현 방식에 대한 고민을 기록으로 남겨둡니다.


2. 싱글톤 패턴이란?

전 세계 개발자들의 교과서인 'Head First: Design Pattern'에서는 싱글톤을 다음과 같이 정의합니다.

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

왜 사용할까? (장점)

  • 생성 코스트 절감: 객체를 생성할 때마다 힙(Heap) 영역에 리소스를 할당해야 합니다. 인스턴스 생성 비용이 크거나 공유해야 하는 리소스라면, 데이터(Data) 영역에 단 한 번만 할당하여 성능상 이점을 얻을 수 있습니다.
  • 공유 리소스 관리 및 접근: 데이터베이스 연결 pool, 로깅, 전역 설정 정보 등 앱 전반에서 일관된 상태를 유지해야 하는 시스템을 효율적으로 관리할 수 있습니다.

3. 싱글톤 패턴의 단점과 위험성

사용하기엔 참 편리하지만, 오남용 시 객체지향 아키텍처를 망치기도 합니다.

  1. 높은 결합도와 OCP 위배: 다른 클래스들이 이 전역 객체를 직접 참조하게 되면서 클래스 간 결합도가 극도로 높아집니다. 이는 시스템 확장을 어렵게 만들어 개방-폐쇄 원칙(OCP, Open-Closed Principle)을 위배하는 원인이 됩니다.
  2. 테스트의 어려움: 전역 상태를 공유하기 때문에 독립적인 단위 테스트(Unit Test)를 수행하기 어렵습니다. 매 테스트마다 상태를 초기화해 주어야 하는 번거로움이 생깁니다.
  3. 멀티스레드 환경에서의 원자성 붕괴: 동기화 처리가 제대로 되지 않으면, 인스턴스가 단 하나만 존재해야 한다는 대전제가 깨지고 n개의 인스턴스가 생성되는 치명적인 결함이 발생할 수 있습니다.

4. Kotlin에서의 싱글톤 구현과 한계 분석

멀티스레드 환경(Thread-Safe)을 고려하며 발전해 온 싱글톤 구현 기법들의 장단점을 짚어보겠습니다.

① 무늬만 싱글톤 (잘못된 예시)

companion object 내에서 호출할 때마다 새로운 객체를 반환한다면 싱글톤의 정의가 무너집니다.

class FakeSingleton private constructor() {
    companion object {
        // 호출할 때마다 새로운 객체를 인스턴스화함
        fun getInstance(): FakeSingleton = FakeSingleton()
    }
}

② Synchronized (Thread-Safe 하나 성능 저하)

@Synchronized 키워드나 Double-Checked Locking을 사용하여 스레드 안전성을 보장하는 방식입니다.

class SynchronizedSingleton private constructor() {
    companion object {
        @Volatile
        private var instance: SynchronizedSingleton? = null

        @Synchronized
        fun getInstance(): SynchronizedSingleton {
            if (instance == null) {
                instance = SynchronizedSingleton()
            }
            return instance!!
        }
    }
}

단점: Synchronized 블록은 동기화 락을 획득하는 과정에서 많은 비용(Cost)을 소모하므로 성능 저하의 주원인이 됩니다.

③ Kotlin by lazy 기법 (위임 활용)

호출되는 시점에 초기화(Lazy Initialization)되며, 코틀린 내부적으로 스레드 동기화(Thread-safe)를 기본 보장합니다.

class LazySingleton private constructor() {
    companion object {
        val instance: LazySingleton by lazy { LazySingleton() }
    }
}

특징: 기본적으로 LazyThreadSafetyMode.SYNCHRONIZED로 동작하므로 멀티스레드 환경에서도 안전합니다. (다만 모드를 명시적으로 변경 시 원자성이 깨질 수 있습니다.)

④ Bill Pugh Solution (Initialization-on-demand holder 기법)

JVM의 클래스 로딩 시점을 이용한 전통적이고 가장 우수한 기법입니다. JVM이 클래스를 초기화하는 과정에서 원자성을 보장하는 특성을 활용합니다.

class HolderSingleton private constructor() {
    companion object {
        val instance: HolderSingleton
            get() = Holder.INSTANCE
    }

    private object Holder {
        val INSTANCE = HolderSingleton()
    }
}

장점: HolderSingleton 클래스가 로드될 때는 Holder가 모르는 상태였다가, instance가 최초로 호출되는 시점에만 내부 Holder 클래스가 로드되며 인스턴스가 생성됩니다. 구조적으로 Thread-safe 하며 성능 저하가 없습니다.

5. 결론: 어떻게 써야 할까?

가장 좋은 방법은 코틀린이 언어 차원에서 제공하는 object 키워드를 사용하거나, 수동 싱글톤보다는 Hilt, Koin 같은 DI(의존성 주입) 프레임워크를 활용하여 객체의 생명주기를 컨테이너가 싱글톤으로 관리하도록 위임하는 것입니다.

객체를 싱글톤으로 설계하기 전, "이 객체가 정말 전역 상태를 가져야만 하는가?"를 스스로에게 꼭 질문해 보아야 합니다. 구조에 대한 깊은 고민 없는 싱글톤의 무분별한 사용은 결국 거대한 기술 부채로 돌아온다는 점을 명심해야겠습니다.

profile
#Mobile #Android #iOS #Flutter #개발문화

0개의 댓글