소프트웨어 엔지니어링에서 의존성 주입(Dependency Injection, DI)은 하나의 객체가 다른 객체의 의존성을 제공하는 테크닉이다. "의존성"은 예를 들어 서비스로 사용할 수 있는 객체이다. 또한 공유 변수를 이용한 구성이 필요할 때 추후 유지보수와 메모리 효율 등을 위해 상태 관리(State Management)가 필요하다. 이와 관련된 수단으로 Singleton, Provider 등이 있다. 이 중 싱글톤 패턴(Singleton Pattern)을 알아보려 한다.
싱글톤 패턴(Singleton Pattern)
싱글톤 패턴(Singleton Pattern)은 객체의 인스턴스가 오직 1개만 생성하여 사용하는 패턴을 의미한다. 생성자를 여러 번 호출하더라도 실제 생성되는 객체는 하나이며 최초로 생성된 이후에 호출된 생성자는 이미 생성한 객체를 반환시키도록 만드는 것이다. 즉, 어플리케이션이 시작될 때 어떤 클래스가 최초 한 번만 메모리를 할당하고(static) 그 메모리에 인스턴스를 만들어 사용하는 디자인 패턴이다.
main() {
Singleton s1 = Singleton();
Singleton s2 = Singleton();
print(identical(s1, s2)); // true
print(s1 == s2); // true
}
장점과 특징
원래 객체를 생성할 때마다 메모리 영역을 할당받아야 하지만 싱글톤을 이용하게 되면 때에 따라 메모리 효율과 데이터 공유가 쉽기 때문에 이를 위해 주로 이용된다. 장점을 나열하자면 아래와 같다.
- 고정된 메모리 영역을 얻으면서 한 번의 new로 인스턴스를 사용하기 때문에 메모리 낭비를 방지할 수 있다.
- 싱글톤으로 만들어진 클래스의 인스턴스는 전역이기 때문에 다른 클래스의 인스턴스들이 데이터를 공유하기 쉽다.
- 인스턴스가 절대적으로 한 개만 존재하는 것을 보증하고 싶을 경우 사용한다.
- 두 번째 이용부터 객체 로딩 시간이 줄어 성능이 좋아지는 장점이 있다.
싱글톤 패턴은 주로 공통 객체를 여러 번 생성해야 하는 경우에 주로 사용이 되며 이는 아래와 같은 경우를 예로 들 수 있다.
- DB 커넥션 풀
- 스레드 풀
- 캐시
- 로그 기록 객체
- 앱: 각 액티비티 클래스마다 주요 클래스를 각각 전달하는 것이 번거롭기 때문에 싱글톤 클래스를 생성해 어디서든 접근 가능하도록 설계
단점
하지만 단점도 있다. 객체 지향 설계 원칙 SOLID에 개방-폐쇄 원칙이란 것이 존재한다.
만약 싱글톤 인스턴스가 혼자 너무 많은 일을 하거나, 많은 데이터를 공유시키면 다른 클래스들 간의 결합도가 높아지게 되는데, 이때 개방-폐쇄 원칙이 위배된다. 따라서 결합도(Coupling)가 높아지게 되면, 유지보수가 힘들고 테스트도 원활하게 진행할 수 없는 문제점이 발생한다.
또한 멀티 스레드 환경에서 동기화 처리를 하지 않았을 때, 인스턴스가 2개가 생성되는 문제도 발생할 수 있다. 위와 같은 이유로 동시성 프로그래밍에 있어서 같은 데이터를 동시에 접근하게 되면 문제가 발생할 수 있으므로 설계시 이 점에 유의해야 한다.
이외에도 자식 클래스를 만들 수 없다는 점과, 내부 상태를 변경하기 어렵다는 점 등 여러 가지 문제들이 존재한다. 결과적으로 이러한 문제들을 안고 있는 싱글톤 패턴은 유연성이 많이 떨어지는 패턴이라고 할 수 있다.
Dart 예시
이제 Flutter의 Dart에서 싱글톤 패턴의 예시를 살펴보자.
class Singleton {
/// Private한 생성자 생성
Singleton._privateConstructor();
/// 생성자를 호출하고 반환된 Singleton 인스턴스를 _instance 변수에 할당
static final Singleton _instance = Singleton._privateConstructor();
/// Singleton() 호출시에 _instance 변수를 반환
factory Singleton() {
return _instance;
}
}
위의 키워드를 정리하자면 아래와 같다.
- static: 변수 또는 메소드가 인스턴스에 귀속되는 것이 아닌 클래스에 귀속되는 것
- factory: Dart의 생성자 키워드이지만 기존 생성자와 달리 반환 값에 새로운 인스턴스의 생성이 가능해 보다 더 유연한 프로그래밍을 가능하게 함. 즉 데이터 타입을 가지면 해당 인스턴스 return 가능
가장 처음 언급했던 부분을 가져오자면 Dart에서 static은 가장 최초 한 번만 메모리를 할당하는 부분과 연관되고, factory는 메모리에 인스턴스를 만들어 사용하는 부분과 관련이 있다고 볼 수 있다.
위 코드 라인 중 "Singleton._privateConstructor();" 부분은 호출이 아니라 생성자를 만드는 선언부이다. 즉, 해당 라인은 빈 생성자를 만드는 선언으로 이를 풀어서 표현하면 아래와 같다.
SingletonOne._privateConstructor() {
}
클래스 이름 뒤에 생성자 이름은 사용자가 원하는 대로 만들면 된다. 예를 들어 "Singleton.something();"처럼 사용도 가능하다. 이처럼 굳이 빈 생성자를 생성하는 이유는 Dart에선 생성자가 없을 경우 자동으로 Public한 생성자를 만들어 버린다. 이를 막기 위해 Private한 생성자를 만들어줘서(언더바(‘_’)를 붙이면 Private을 의미) 자동으로 만들어주는 생성자가 생성되지 않도록 방지하는 것이다.
factory에 대해 덧붙이자면 선언된 클래스를 상속받은 자식 클래스의 인스턴스 또한 return이 가능하다. 아래는 예시이다.
...
// named generative
// delegates to the default generative constructor
Person.profile(String name) : this(name, "John Doe");
// named factory
factory Person.profile(String name) {
return Profile(name);
}
}
class Profile extends Person {
Profile(String name) : super(name, "John Doe");
}
결과적으로 Singleton을 호출할 때마다 factory에서 같은 인스턴스가 반환되므로 싱글톤 패턴이 만들어진다.
추가적으로 가장 처음에 언급했던 Provider 역시 Singleton과 같은 이유인 의존성 주입(DI) 및 상태 관리(State Management) 수단으로 Flutter에서 사용될 수 있다. 이 둘은 매우 유사한 방식으로 이용되고 있다. 다만 Provider는 Singleton과 달리 불필요한 새 instance를 생성하지 않고, 해당 instance가 disposed되어야 할 때 과감하게 dispose한다. 따라서 만약 앱 프로세스가 처음 시작될 때 top level에서 Provider를 제공하고 프로세스 종료시 이 instance를 dispose한다면 Singleton과 Provider는 큰 차이가 없게 될 것이다.
관련 포스트
2022.02.18 - [Computer Science/Software Engineering] - [Design Pattern] SOLID (객체 지향 설계)란?
참고 자료
https://devmoony.tistory.com/43
https://gyoogle.dev/blog/design-pattern/Singleton%20Pattern.html
https://tecoble.techcourse.co.kr/post/2020-11-07-singleton/
https://www.robertlarsononline.com/2017/03/13/singleton-pattern-using-c/
https://www.reddit.com/r/FlutterDev/comments/d4txb6/is_provider_at_the_top_level_a_singleton/