본문 바로가기

웹개발 - Back 관련/Architecture

[ GOF ] 싱글톤 패턴, object - Singleton Pattern with Kotlin

Topic =  싱글톤 패턴 개념과 정적 매소드 - Kotlin object, companion object 싱글톤 구현 방식 정리

 

🔑 Singleton Purpose

클래스에 인스턴스가 하나만 있도록하여서 생성된 하나의 인스턴스에 대한 전역 접근을 제공하는 것.

 


 

싱글톤 패턴 개념

 

싱글톤 패턴은 여러 클래스에서 특정 클래스 A 객체를 생성할 때마다 새로운 A클래스의 인스턴스를 생성하는 것이 아닌, 이미 생성된 하나의 클래스 A 인스턴스를 여러 클래스에서 접근하여 사용하도록 하는 것으로 전역 접근을 통해 메모리 효율성, 클래스 메서드 사용의 편의성을 제공한다.

 

[ 정적 메서드와 싱글톤은 다르다. ]

정적 메서드

  • 정적 메서드는 클래스에 속하고, 인스턴스를 생성하지 않고도 호출할 수 있는 메서드이다.
  • 정적 메서드는 일반적으로 상태를 가지지 않으며, 클래스의 속성이나 인스턴스 상태에 의존하지 않는다. 때문에 수학 계산, 문자열 처리 등의 기능을 구현하는 데 주로 사용된다.
  • ➡️ 동일한 데이터나 메서드를 각 클래스 별로 생성하는 것은 효율적이지 못하기 때문에 전역적으로 접근할 수 있는 데이터를 만드는 것.

 

싱글톤 객체

  • 싱글톤 객체를 사용하면 객체 상태를 유지할 수 있게된다. 즉 객체 설정 값을 저장하고 관리할 수 있게 되는 것이다.
  • 하나의 생성된 객체에 여러 클래스가 접근하므로, 객체 생성 시 발생하는 자원낭비가 적어지게 된다.
  • ➡️ 정적 메서드는 상태를 갖지 않는(변하지 않는) 데이터나, 상태를 유지할 필요가 없는 경우에 사용하게 되고, 싱글턴 객체는 전역 상태 유지하여 관리가 필요하거나, 의존성을 주입 받아서 사용할 때(정적 메서드는 변수 사용 불가) 사용할 수 있으며 리소스를 많이 차지하는 등, 객체를 사용하는 클래스 마다 새로운 객체를 생성하는 것이 메모리 자원 소모가 비효율적인 경우에 싱글톤 객체를 사용하는 것이 바람직하다.

 

 

정적 메서드

Utils 클래스의  를 사용하기 위해서 매번 Utils 클래스의 객체를 사용하게 되는데, 객체를 생성함에 따른 메모리 사용량이 증가함으로 객체 생성 비용이 발생하게 된다. 정적 메서드는 클래스의 인스턴스가 없어도 접근할 수 있는 속성과 메서드를 포함할 수 있으므로 객체 생성 자원을 소모하지 않게 된다.

 

[ Kotlin 정적 메서드 사용 예시 ]

class Utils {
    companion object {
        fun strPrettier(message: String) {
            // 문자열 가공 로직
        }
    }
}

class MyService {
    fun doSomething(): String {
        val myStr = "Test"
        return Utils.strPrettier(myStr)
    }
}

 

 

[ Kotlin Singleton 북마크 객체 예시 ]

 

1. object class 로 싱글톤 객체 정의

class MyBookmarkFactory private constructor() {
    
    companion object {
        val instance : MyBookmark = MyBookmark()
    }
}

class MyBookmark {

    private val myBookmarkMap = LinkedHashMap<String, String>()

    fun addBookmark(url: String, desc: String) {
        myBookmarkMap[url] = desc
    }

    fun removeBookmark(url: String) {
        myBookmarkMap.remove(url)
    }

    fun readAllBookmark(): Map<String, String> {
        return myBookmarkMap
    }

    fun clearBookmark() {
        myBookmarkMap.clear()
    }
}

 

🌟 싱글톤 패턴은 무조건 단일 책임 원칙을 지키지 않는다? == ❌

위 예시 코드에서 object 내에서 전역 변수로 myBookmarkMap, 과 상태관리 로직이 들어가지 않는 이유는, MyBookmarkFactory 에서 객체 생성과 객체 상태 관리라는 두가지 책임을 동시에 수행하게 되므로 이를 분리 시킨 것이다. 이와 같이 분리를 통해 하나의 클래스가 변경될 이유를 하나로 제한할 수 있게 되어 SRP를 지킬 수 있게 된다.

 

🌟Kotlin object를 사용하게 된다면 객체 생성 로직을 개발자가 관리하지 않으며, object 내부에는 상태 관리 로직만을 정의하여 사용하기에 SRP를 위반하지 않게 된다. 아래에서 싱글톤 패턴의 주의점을 다루면서 Kotlin object class가 JVM으로 컴파일 될 시에 어떤식으로 코드가 변환되는지를 확인 해보며 object에 대해 자세하게 이해 해볼 것이다.

 

1 - 1 싱글톤 객체 생성 및 검증

private val logger = KotlinLogging.logger {  }
class SingletonTest {

    @BeforeEach
    fun bookmarkTest(){
        addBookmark("naver.com","네이버")
        addBookmark("google.com","구글")
        addBookmark("daum.com","다음")
        addBookmark("facebook.com","페이스북")
        addBookmark("amazon.com","아마존")
    }

    @Test
    fun readAllBookmarkTest() {
        // given
        val myBookmark = MyBookmarkFactory.instance

        // when
        val result = myBookmark.readAllBookmark()

        // then
        logger.info { result }
        result.size shouldBe 5
    }

    private fun addBookmark(url: String, desc: String){
        val myBookmark = MyBookmarkFactory.instance

        myBookmark.addBookmark(url, desc)
    }
}
  • 예시에서는 하나의 객체 내부에서 사용하는 것이기에 전역 변수로 MyBookmark 객체를 생성해도 되지만, class 간의 격리를 표현하기 위해, addBookmark 메서드가 실행될 때 마다 MyBookmark 에 접근하여 북마크를 추가하고, 모든 북마크 리스트를 가져오는 readAllBookmarkTest 메서드 에서도 MyBookmark 객체에 접근해서 생성하도록 했다.

 

싱글톤 패턴의 주의점 + Kotlin object class 작동 원리

테스트가 어렵다.

  • 변경 가능한 상태를 가진 싱글톤 객체는 여러 클래스에서 공유되므로, 한 클래스에서 상태를 변경하면 다른 클래스에도 영향을 받는데. 이를 예측하는 것은 어렵다. 이러한 문제로 인해 싱글톤 객체를 독립적으로 테스트하기 위해서 기존 상태를 유지해야 하는 작업이 필요할 수도 있다. 싱글톤 객체 자체가 일련의 과정이 끝나면 상태가 초기 상태로 유지되는 경우에는 테스트에 큰 영향이 없겠지만, 상태가 지속적으로 변하는 경우 테스트를 위해 별도로 상태를 초기화 해야하는 로직이 추가된다면, 테스트를 위한 책임이 생기는 것이다.

쓰레드 안정성 vs 객체 접근 비용의 trade - off 가 존재한다.

  • 싱글톤 객체는 한번 생성되면 상태를 유지하며 전역적으로 접근할 수 있게 한다. 때문에 한번 생성이 되면 상태를 유지하는 동안에는 계속 객체가 생성된 상태이다. 이는 자원을 지속적으로 차지하는 것으로 싱글톤 객체를 사용하지 않음에도 객체가 계속 생성되어있는 문제가 발생한다.
  • 이 문제를 해결하기 위한 다양한 Singleton 객체 초기화 방식이 존재한다. 

🟦[ 지연 초기화 ] 

  • 위와 같은 문제로 싱글톤 객체를 사용하는 시점에 객체를 초기화 하는 지연 초기화 방법을 사용하기도 했는데, 이는 멀티 쓰레드 환경에서 한 시점에 여러 쓰레드가 객체 생성에 접근할 경우 객체가 여러개가 생성되는 문제를 발생 시킬 수 있다.
class Singleton private constructor(){
    val instace : Singleton by lazy { Singleton() } 
}

 

 

 

🟫[ Double - Checked Locking ] 

  • 일반적인 지연 초기화의 멀티 쓰레드 환경에서 접근 시 발생할 수 있는 문제를 해결하기 위해 객체 생성 시점에 쓰레드가 접근하게 된다면 Lock을 걸어 다른 쓰레드가 객체 생성에 관여할 수 없도록 하는 방식이 사용되게 되었지만, 이 방법 또한 아쉬운 점이 존재한다.
  • Singleton 객체를 초기화 하는 부분에 @Volatile 키워드가 존재한다. 간단하게 이 키워드를 살펴보자면 @Volatile이 붙은 변수는 변수이 값이 메인 메모리에만 저장되며, 멀티 쓰레드 환경에서도 CPU 캐시를 참조하지 않고, 메인 메모리의 값을 참조하게 되므로, 변수 값 불일치 문제가 발생하지 않게 된다. 메인 메모리를 참조하도록 하는 것은 캐시된 값을 사용하지 않게 되는 것이므로, 캐시 접근에 비해 성능이 떨어지게 된다.
class Singleton private constructor() {
    @Volatile private var instance: Singleton? = null

    companion object
    fun getInstance(): Singleton {
        return instance ?: synchronized(this) {
            instance ?: Singleton().also { instance = it }
        }
    }
}

 

 

🟩 [ object class 사용 ( java - static inner class ) ]

  • Kotlin 에서는 자체적으로 Thread - safe 한 싱글톤 class 키워드를 제공한다. Kotlin 코드와 JVM 컴파일 단계에 어떤 java 코드로 변환되는지 확인 해보자.

[ Kotlin object 객체 선언 ]

object Singleton {
    var count: Int = 0
    
    fun addCount() {
        count++
    }
}

 

[ Kotlin object → 컴파일 시 코드 ] 

public final class Singleton {
    public static final Singleton INSTANCE;

    private int count;

    private Singleton() {
        count = 0;
    }

    public final void addCount() {
        count++;
        System.out.println("addCount(). Count: " + count);
    }

    static {
        INSTANCE = new Singleton();
    }
}

 

➡️ [ object의 동작 방식 → java의 static inner class 방식과 유사하다 ]

  • object class 는 쓰레드 안정성이 높지만 앱이 시작될 때, 클래스가 JVM을 통해 로드될 때 인스턴스가 생성되는  Eager Initialization ( 이른 초기화 ) 방식이다. 
  • JVM은 클래스 초기화를 위한 내부적인 락을 사용하기에, 여러 스레드가 동시에 접근하더라도 단 하나의 인스턴스만 생성되도록 한다.
  • SRP를 위반하지 않는다.
    • object class 싱글톤 객체 내부에 개발자가 객체 생성을 직접 관리하지 않는다. 때문에 object 내부에서 상태 관리 로직을 작성하여도 SRP를 위반하지 않게 된다.
  • 🚨주의! - Reflection 을 통해 접근할 경우, 싱글톤 보장이 깨질 수 있다.
  • 특별한 상황 외에서는 크게 성능 차이를 보이지 않고 간결하게 싱글톤 패턴을 적용할 수 있는 권장되는 싱글톤 객체 초기화 방식이다.

 


🧹정리

- 싱글톤 패턴은 인스턴스 생성을 하나로 제한하며, 생성된 하나의 인스턴스에 전역적으로 접근 하는 모든 객체가 동일한 객체를 사용할 수 있게 한다.

- 하나의 객체로 전역 접근하는 것이기에 테스트가 어렵다. ( 쓰레드 안정성의 문제는 object로 해결할 수 있다, )

- object 를 통해 싱글톤 패턴을 사용하게 되면 싱글톤 패턴 적용으로 인해 발생할 수 있는 SRP, Thread - safe 등의 문제점들을 해결할 수 있다.

 

 

 

 

 

 

 

 

 

 


 

[ A. 오늘 복습한 내용 / B. 다음에 학습할 내용 ]

A. object 클래스 작동원리 자세하게 확인해보기.

 

B. 프로토 타입 패턴

 

 

 

 


[Reference]

 

싱글턴 패턴

/ 디자인 패턴들 / 생성 패턴 싱글턴 패턴 다음 이름으로도 불립니다: Singleton 의도 싱글턴은 클래스에 인스턴스가 하나만 있도록 하면서 이 인스턴스에 대한 전역 접근​(액세스) 지점을 제공하

refactoring.guru

 

https://www.youtube.com/watch?v=bAYGNP-FevQ&list=PLfI752FpVCS_v_sc8Q6V9QQN7GhoyktKD&index=3