Singleton Pattern(싱글톤 패턴)

Factory Pattern

Flutter/Dart 클래스(객체) 이해하기

이번 글에서는 Singleton Pattern에 대해서 알아보도록 하겠다.

Singleton Pattern은 제가 주로 사용하는 디자인 패턴이다. 싱글톤 패턴의 정의는 오직 하나의 인스턴스만을 생성하는 디자인 패턴이다 라고 정의할 수 있는데, 저도 처음에 배울 때는 이해가 잘 되지 않았었다.

Clean Architecture 방법론 중 DDD(Domain Driven Design)를 사용할 때에 Lazy Singleton 패턴을 사용하게 되는데, 아키텍처 개발하면서 많이 배웠었다. 우선 싱글톤 패턴이 무엇인지에 대해서 부터 차근차근 배워보자.

위에서 간단하게 정의한 것처럼 하나의 인스턴스만 생성한다는 것이 무슨 의미를 가질까 ? 꼭 사용해야만 하는 패턴인가 ? 라고 생각할 수 있는데, 개발하던 프로젝트의 사이즈가 커지다 보면 메모리 사용 측면을 고려하지 않을 수가 없게된다. 그렇다면 싱글톤 패턴을 사용할 때에 장점과 단점은 무엇이 있는지 살펴보도록 하자.

먼저 싱글톤 패턴의 장점을 보면 최초 한 번 new 연산자를 통해서 인스턴스를 생성하게 되면 이 후에는 인스턴스가 생성되지 않고 최초 생성한 인스턴스를 리턴하여 사용하게 되면서 고정된 메모리 영역만을 사용하게 되어 메모리 측면의 효율성이 좋아진다.

또한 객체간의 데이터 공유가 쉽다는 장점이 있다. 싱글톤 패턴으로 생성한 인스턴스는 전역으로 사용되는 객체이기에 데이터를 공유하면서 사용할 수 있다. 단 한개의 인스턴스를 공유해서 사용하고 싶을 때 이를 보장할 수 있다라는 장점도 있다.

그렇다면 단점에는 어떤 문제들이 있을까 ?

우선 코드 작성이 복잡해진다. 객체를 만들 때와는 다르게 구현해야 하는 코드가 많아지고, 자식 객체를 가질 수도 없을 뿐만 아니라 내부 구조를 변경할 수도 없다.

정적 팩토리 메서드를 사용할 때 멀티스레딩 환경에서 발생할 수 있는 동시성 문제 해결을 위해 syncronized 키워드를 사용해야 하고, 구체 클래스 간의 의존 관계가 발생하여 SOLID 원칙 중 DIP를 위반하게 되고 OCP 원칙 또한 위반할 가능성이 높다는 단점이 있다. 이 외에도 테스트 역시 어렵다. 테스트를 하기 위해 매번 인스턴스를 초기화 하는 로직을 추가하여야 한다.

싱글톤 패턴을 복잡한 객체 구조로 사용하지 않고 있어서 아직 단점을 느낄 정도로 사용해보지는 못하였고, 메모리 사용 측면의 효율성을 높게 가져갈 수 있기에 싱글톤 패턴의 적절한 사용은 반드시 필요하다고 생각한다.

Flutter 개발을 하다보면 모든 어플리케이션에 등록된 라이브러리로 Firebase, SharedPreferences일 것이다. 해당 라이브러리를 사용할 때 보면 .instance로 인스턴스 과정을 거치는데, 여기서 .instance의 구조를 살펴보면 싱글톤으로 구현되어 있다는 것을 알 수 있다.

이제 Dart Pad로 싱글톤 패턴에 대한 개념을 이해해보고, Flutter에서 간단한 카운트 예제를 통해 싱글톤 패턴을 사용해보자.

Dart

우선 Dart pad를 사용해서 Dart언어로 Singleton Pattern에 대해서 알아보도록 하겠다.

class

싱글톤 패턴과 싱글톤 패턴을 사용하지 않은 경우 클래스의 차이점에 대해서 알아보기 위해 일반적인 클래스 생성법으로 GenralCount라는 객체를 생성해보자. count라는 변수를 선언해주자.

class GeneralCount {
  int count = 0;
}

main 함수에서 GeneralCount 객체를 count1으로 인스턴스를 거쳐 count 값을 출력해보자. 예상대로 0이 출력되어 있을 것이다.

void main() {
  GeneralCount count1 = GeneralCount();
  
  print(count1.count);
}

// 0

이번에는 count1의 count 변수를 출려가고 count 값을 1증가 시킨 후 다시 출력해보자. 예상했던 결과대로 0이 출력되고 이어서 1이 출력이 될것이다. 자 이제 count1의 객체의 count 값은 1이 된 것이다.

void main() {
  GeneralCount count1 = GeneralCount();
  
  print(count1.count);
  count1.count++;
  print(count1.count);
}

// 0
// 1

자 이번에는 여러 클래스를 인스턴스할 것이기에 아래와 같이 GeneralCount 객체와 문자형 title을 필수 파라미터로 제공 받는 increment 함수를 생성해보자.
함수의 기능은 바로 위에서 사용했던 기능과 동일한 기능을 갖추고 있다.

void increment(GeneralCount count, String title){
  print("$title : ${count.count}");
  count.count++;
  print("$title : ${count.count}");
}

다시 main 함수로 와서 이번에는 count1과 count2라는 객체를 각각 생성한 뒤 increment 함수를 실행해보자.

결과는 먼저 count1 객체의 count 값이 출력되고 1 증가한 값이 바로 출력이 된다. 이어서 count2 객체의 결과가 출력이 되고 있다.

예상했던 정상적인 결과대로 수행이 되고 있다.
지금까지 살펴본 클래스의 인스턴스 과정이었다. 해당 부분을 반드시 이해하고 있어야 싱글톤 패턴을 이해할 수 있다.

void main() {
  GeneralCount count1 = GeneralCount();
  GeneralCount count2 = GeneralCount();
  increment(count1, "Count 1");
  increment(count2, "Count 2");
}

// Count 1 : 0
// Count 1 : 1
// Count 2 : 0
// Count 2 : 1

Singleton

이제 드디어 싱글톤 패턴에 대해서 살펴보도록 하겠다.

위에서 일반적인 클래스로 생성한 GeneralCount 클래스와 동일한 기능을 가지는 객체를 이번에는 싱글톤 패턴으로 만들어 보자.

싱글톤 패턴은 아래와 같이 생성할 수 있다. 잠깐 살펴보면 static으로 instance라는 변수에 전역에서 접근할 수 있게 해주는데, 해당 instance 변수가 호출될 때가 바로 싱글톤 패턴이 인스턴스 과정을 거치는 과정이라고 볼 수 있다.

factory 키워드로 싱글톤 객체를 초기화 해주면 된다.

여기서 잠깐 살펴보고 지나가야 할 내용이 있는데, factory 부분은 없어도 문제가 없다. 만일 싱글톤 객체의 초기화 코드에 작성할 내용이 없을 때는 factory 부분을 제거하고 instance로 접근하여도 된다. 단 factory 키워드의 초기화 코드가 없는 경우에는 instance로만 접근하여야 한다.

class SingletonCount {
  static final SingletonCount instance = SingletonCount._internal();
  factory SingletonCount()=>instance;
  SingletonCount._internal();
  
  int count = 0;
}

초기화 코드에 따라 인스턴스 코드가 다르다.

// factory 키워드를 사용하지 않는 경우 (초기화 필요 없을시)
SingletonCount _count = SingletonCount.instance;

// factory 키워드를 사용하는 경우 (초기화 필요) 
SingletonCount _count = SingletonCount();

이번에는 위에서 생성한 SingletonCount와 문자열을 필수 파라미터로 받아오는 increment 함수를 만들어준다.

void increment(SingletonCount count, String title){
  print("$title : ${count.count}");
}

main 함수에서 아래와 같이 SingletonCount 클래스를 singleton1 객체로 인스턴스화 해보자.
결과는 당연히 0이 출력이 된다.

void main() {
  SingletonCount singleton1 = SingletonCount();
  increment(singleton1, "Singleton 1");
}

// Singleton 1 : 0

이번에는 SingletonCount 클래스를 singleton1 / singleton2로 각각 생성해 보자. 역시나 결과는 예상대로 0 / 0이 출력이 된다.

void main() {
  SingletonCount singleton1 = SingletonCount();
  SingletonCount singleton2 = SingletonCount();
  increment(singleton1, "Singleton 1");
  increment(singleton2, "Singleton 2");
}

// Singleton 1 : 0
// Singleton 2 : 0

increment 함수를 바꿔보자. 위에서 사용한 것과 동일하게 현재 count 값을 출력하고 count를 1 증가시킨 후 다시 count 값을 출력하는 기능으로 만들어 준다.

void increment(SingletonCount count, String title){
  print("$title : ${count.count}");
  count.count++;
  print("$title : ${count.count}");
}

main 함수를 다시 실행해보자. 과연 결과는 예상했던 결과가 같게 나올 것인가....

전혀 예상하지 않은 값이 나왔다. 왜 저런 출력 값이 나오게 된걸까 ? 여기서 싱글톤 패턴을 이해할 수 있게 될 것이다.

싱글톤 패턴은 하나의 인스턴스를 생성하는 디자인 패턴으로 인스턴스는 최초 한 번만 인스턴스 과정을 거치고 이 후 인스턴스를 생성하려고 할 때에는 기존 생성된 인스턴스를 리턴하여 인스턴스가 하나만 생성된다는 것을 절대 보장할 수 있는 디자인 패턴이라고 했었다.

이해가 쉽게 되지 않을 수 있는데, 말 그대로 인스턴스가 하나만 존재하여서 이런 황당한 출력 값이 나오게 된것이다. 사실 황당할 필요가 없다. 우리가 원하는 기능대로 작동 된 것이다.

SingletonCount 클래스를 singleton1으로 인스턴스 하였을 때의 SingletonCount는 singleton1의 객체이다. 그렇다면 singleton2는 어디서 나온 객체가 될까 ? .. singleton2 객체는 SingletonCount 클래스를 인스턴스 하려고 할 때에 이미 인스턴스 과정을 거쳤기에 기존에 먼저 생성된 singleton1 객체를 리턴하게 되어 선언된 것이기에 singleton1과 singleton2는 같은 객체가 되는 것이다.

void main() {
  SingletonCount singleton1 = SingletonCount();
  SingletonCount singleton2 = SingletonCount();
  increment(singleton1, "Singleton 1");
  increment(singleton2, "Singleton 2");
}

// Singleton 1 : 0
// Singleton 1 : 1
// Singleton 2 : 1
// Singleton 2 : 2

이번에는 count 값을 1증가 시킨 후 출력하게끔 increment 기능을 변경해보자.

void increment(SingletonCount count, String title){
  count.count++;
  print("$title : ${count.count}");
}

5개의 SingletonCount 객체를 increment 함수를 통해서 1씩 증가시킨 후 출력을 해보자. 아래와 출력 값과 같이 5개를 생성하든 100개를 생성하든 단 하나의 객체라는 것을 알 수 있다.

void main() {
  SingletonCount singleton1 = SingletonCount();
  SingletonCount singleton2 = SingletonCount();
  SingletonCount singleton3 = SingletonCount();
  SingletonCount singleton4 = SingletonCount();
  SingletonCount singleton5 = SingletonCount();
  increment(singleton1, "Singleton 1");
  increment(singleton2, "Singleton 2");
  increment(singleton3, "Singleton 3");
  increment(singleton4, "Singleton 4");
  increment(singleton5, "Singleton 5");
}

// Singleton 1 : 1
// Singleton 2 : 2
// Singleton 3 : 3
// Singleton 4 : 4
// Singleton 5 : 5

어느정도는 이해가 됬을 것이라고 생각한다. 인스턴스는 메모리의 한 영역을 차지하는 과정인데, 싱글톤으로 만든 클래스는 인스턴스를 할 때에 메모리 공간이 같게 된다. 정말로 메모리에 할당된 영역이 같은지를 비교하기 위해 hashcode 값을 통해서 한 번 비교해보자.

void main() {
  GeneralCount general1 = GeneralCount();
  GeneralCount general2 = GeneralCount();
  GeneralCount general3 = GeneralCount();
  GeneralCount general4 = GeneralCount();
  GeneralCount general5 = GeneralCount();
  SingletonCount single1 = SingletonCount.instance;
  SingletonCount single2 = SingletonCount.instance;
  SingletonCount single3 = SingletonCount.instance;
  SingletonCount single4 = SingletonCount.instance;
  SingletonCount single5 = SingletonCount.instance;
  print("General1 Hashcode : ${general1.hashCode}");
  print("General2 Hashcode : ${general2.hashCode}");
  print("General3 Hashcode : ${general3.hashCode}");
  print("General4 Hashcode : ${general4.hashCode}");
  print("General5 Hashcode : ${general5.hashCode}");
  print("Single1 Hashcode : ${single1.hashCode}");
  print("Single2 Hashcode : ${single2.hashCode}");
  print("Single3 Hashcode : ${single3.hashCode}");
  print("Single4 Hashcode : ${single4.hashCode}");
  print("Single5 Hashcode : ${single5.hashCode}");
}

일반적인 클래스는 인스턴스가 생성될 때마다 각기 다른 메모리 영역을 차지하고 있다. 하지만 싱글톤 패턴으로 생성한 클래스는 단 하나의 메모리 공간만 차지하고 있는 것을 확인할 수 있다.

// General1 Hashcode : 587853338
// General2 Hashcode : 1059523135
// General3 Hashcode : 1037171523
// General4 Hashcode : 974037617
// General5 Hashcode : 775661907
// Single1 Hashcode : 1026029905
// Single2 Hashcode : 1026029905
// Single3 Hashcode : 1026029905
// Single4 Hashcode : 1026029905
// Single5 Hashcode : 1026029905

Flutter

이번에는 Flutter로 카운트 앱 예제를 만들어서 각각 일반 클래스와 싱글톤 패턴으로 생성한 클래스의 카운트 값 변화를 좀 더 시각적으로 확인해 보도록 하자.

UI

먼저 UI 부분의 코드이다. 각 객체의 title과 현재 count 값, hashcode 값을 보여주고 아이콘을 클릭했을 때 count 변수를 올려줄 수 있도록 하는 UI 구조이다.

Padding(
      padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 24),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.spaceBetween,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            children: [
              Text(
                title,
                style:
                    const TextStyle(fontWeight: FontWeight.bold, fontSize: 18),
              ),
              const SizedBox(height: 4),
              Text(
                "$hashCode",
                style: const TextStyle(fontSize: 12, color: Colors.cyan),
              ),
            ],
          ),
          Text(
            "$count",
            style: const TextStyle(
                fontWeight: FontWeight.bold, fontSize: 18, color: Colors.amber),
          ),
          GestureDetector(
            onTap: onTap,
            child: const Icon(
              Icons.add_circle_outline,
              size: 35,
            ),
          ),
        ],
      ),
    );`

문자열 title과 정수형 count 값을 가지는 모델을 각각 생성하는데, 싱글톤으로 사용될 모델에는 위에서 배운 싱글톤 패턴 방법으로 클래스를 생성해보자.

class GeneralExampleModel {
  String title = "General";
  int count = 0;
}
class SingletonExampleModel {
  static final SingletonExampleModel instance =
      SingletonExampleModel._internal();
  factory SingletonExampleModel() => instance;
  SingletonExampleModel._internal();

  String title = "Singleton";
  int count = 0;
}

스크린 진입시 위에서 생성한 일반 클래스와 싱글톤 클래스를 각각 3개씩 인스턴스하자.

  GeneralExampleModel general1 = GeneralExampleModel();
  GeneralExampleModel general2 = GeneralExampleModel();
  GeneralExampleModel general3 = GeneralExampleModel();
  SingletonExampleModel singleton1 = SingletonExampleModel();
  SingletonExampleModel singleton2 = SingletonExampleModel();
  SingletonExampleModel singleton3 = SingletonExampleModel();

스크린 진입시 각 객체의 title 값을 순서에 맞게 변경해주자. initState가 실행되고 나면 역시나 당황스러운 값이 화면에 출력되고 있다.

singleton으로 생성한 객체는 title을 변경해 주어도 3개가 전부 "Singleton 3"로 변경되어 있다. 당연한 결과였다. 위에서 설명한 대로 싱글톤의 인스턴스 과정은 단 한번만 발생하기 때문에 최초 인스턴스한 singleton1 객체인 것이다.

hashcode 값도 전부 동일한 것을 확인할 수 있다.

 
  void initState() {
    setState(() {
      general2.title = "General 2";
      singleton2.title = "Singleton 2";
      general3.title = "General 3";
      singleton3.title = "Singleton 3";
    });
    super.initState();
  }

이번에는 Icon을 터치 했을 때 count 값을 1씩 증가시킬 수 있는 기능을 만들어 주자.

 onTap: () {
              setState(() {
                general1.count = general1.count + 1;
              });
            },

각각 이이콘을 터치해 보자.
일반적인 클래스로 생성한 객체는 각각 카운트 값이 바뀌는 반면에 싱글톤 패턴으로 생성한 객체는 데이터가 공유된다는 것을 알 수 있다.

자 이제 페이지를 나갔다가 다시 들어와 보자 역시나 count 값은 이전 값과 동일하다. 0으로 변경되어야 하는 것이 아닌가 ? 생각할 수 있지만, 이미 메모리 공간을 차지하고 있기에 싱글톤을 초기화 하고 싶다면 초기화를 시킬 수 있는 로직을 따로 구현해주어야 한다.

이번에는 새로운 페이지로 이동할 수 있는 버튼을 생성해보자.

 floatingActionButton: FloatingActionButton(
        onPressed: () => Navigator.of(context).push(MaterialPageRoute(
            builder: (_) => const DartPatternSecondScreenWithSingleton())),
        child: const Icon(Icons.keyboard_arrow_right_outlined),
      ),

새로운 페이지에서 싱글톤 객체를 하나 더 생성해보자.

SingletonExampleModel _singletonModel = SingletonExampleModel();

생성한 singletonModel을 보여줄 화면을 개발해보자.

   body: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          _form(context, _singletonModel.title, "Title : "),
          const SizedBox(height: 24),
          _form(context, "${_singletonModel.count}", "Count : "),
        ],
      ),
      
SizedBox _form(BuildContext context, String content, String title) {
    return SizedBox(
      width: MediaQuery.of(context).size.width,
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(
            title,
            style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
          ),
          Text(
            content,
            style: const TextStyle(
                fontWeight: FontWeight.bold, fontSize: 20, color: Colors.amber),
          ),
        ],
      ),
    );
  }

새로 만든 페이지로 이동을 해보면 이전 페이지에서 count 값을 올린 값과 동일하 값인 것을 확인할 수 있다.

싱글톤은 단 하나의 인스턴스만 생성한다는 것을 반드시 기억하자 !

Result

Git

https://github.com/boglbbogl/flutter_velog_sample/tree/main/lib/dart_lang/singleton

마무리

지금까지 싱글톤 패턴에 대해서 살펴보았는데, 어려운 패턴이라 생각할 수 있다. 이거는 직접 사용해 보면서 장단점을 살펴봐야 적절하게 사용할 수 있을 것이다.

Git Repository에서 코드를 내려 받아 직접 실행해보면 좋을 것 같다.

싱글톤 패턴은 개발 중 필수 디자인 패턴이라고는 볼 수 없지만 적절하게 사용하면 데이터 공유, 메모리 측면 등 장점이 매우 많은 패턴이라고 생각한다.

빈번하게 호출되고 사용하는 인스턴스를 싱글톤 패턴으로 관리하여 메모리 효율을 고려한 개발을 하도록 하자.

profile
Flutter Developer

0개의 댓글