1️⃣ 정의 및 기본 구조
▶︎ 정의
⇒ 클래스의 인스턴스를 하나만 생성하여 전역적으로 사용하는 디자인 패턴
▶︎ 기본 구조
class MySingleton {
// 1. static 키워드 사용하여 전역 인스턴스 생성
static let shared = MySingleton()
// 2. 다른 곳에서 추가 인스턴스 생성 방지
private init() {}
// 3. 기타 코드 작성
// 예시)
let myName: String = "유림"
}
class Test {
// 다른 클래스에서 MySingleton에 접근 가능함
//let mySingleton = MySingleton()
//mySingleton.myName
print(MySingleton.shared.myName) // "유림"
}
2️⃣ static 키워드: 인스턴스와 타입
static 키워드는 Swift에서 클래스나 구조체 내부에서 특정 프로퍼티나 메서드가 인스턴스가 아닌 타입에 속하도록 할 때 사용됩니다.
그럼 인스턴스와 타입이 각각 무엇인지 알아봅시다.
인스턴스 프로퍼티 & 메서드
- 각각의 인스턴스가 고유하게 가질 수 있는 속성
- 우리가 평소에 사용하는 프로퍼티/메소드
타입 프로퍼티 & 메서드
- 클래스나 구조체 전체에서 공유되는 속성.
▶︎ 인스턴스 프로퍼티 & 메서드
Menus 라는 클래스를 임의로 생성했습니다.
이 클래스 안에서 선언된 프로퍼티와 메서드는 모두 인스턴스입니다.
class Menus {
// 인스턴스 프로퍼티
let menus = ["스시", "돈가스", "우동"]
// 인스턴스 메서드
func printMenus() {
print(menus)
}
}
Menus 클래스 인스턴스를 생성하여 내부 요소에 접근해보겠습니다.
- Menus 타입을 갖고 있는 myMenus 라는 인스턴스를 생성
-> 인스턴스가 생성되면 프로퍼티와 메서드가 메모리에 올라감let myMenus = Menus() // Menu 인스턴스 생성
- . 을 써서 Menus의 프로퍼티와 메서드에 접근
myMenus.menus[0] // 스시
myMenus.printMenus() // ["스시", "돈가스", "우동"]
위의 예시에서 menus와 printMenus()는 인스턴스를 생성해야만 접근할 수 있는 프로퍼티와 함수입니다.
아래처럼 클래스에 바로 접근 시 오류 메시지가 뜹니다.
따라서 menus는 인스턴스 프로퍼티, printMenus() 는 인스턴스 메서드라고 할 수 있습니다.
▶︎ 타입 프로퍼티 & 메서드
이번에는 Menus 클래스의 프로퍼티와 메서드를 **‘타입’**으로 선언해 보겠습니다.
여기에서 타입으로 선언하고 싶을 때 쓰는 키워드가 static 입니다.
+) class 키워드를 써도 타입으로 선언할 수 있습니다.
💡 Tip
인스턴스 메서드가 "인스턴스에 걸려있는" 메서드였다면
타입 메서드는 "타입에 걸려있는 메서드”라고 생각하면 됩니다.
class Menus {
// 타입 프로퍼티
static let menus = ["스시", "돈가스", "우동"]
// 타입 메서드
static func printMenus() {
print(menus)
}
}
이번에는 클래스 인스턴스를 만들지 않고 바로 Menus에 접근했는데 오류가 나지 않습니다.
Menus.menus[0] // 스시
Menus.printMenus() // ["스시", "돈가스", "우동"]
반대로 클래스 인스턴스를 생성하여 접근하면 오류가 납니다.
💡 Tip) static vs class
아까 class 키워드로도 타입 프로퍼티를 만들 수 있다고 했는데, class와 static은 다음과 같은 차이점이 있습니다.
1. 오버라이딩 가능 여부
- class 타입 메소드는 오버라이딩 가능
- static 타입 메서드는 오버라이딩 불가능
2. 프로퍼티 선언 가능 여부
저장 프로퍼티 | 연산 프로퍼티 | 메서드 | |
static | ✅ | ❌ | ✅ |
class | ❌ | ❌ | ✅ |
3️⃣ 특징
▶︎ 장점
1. 인스턴스가 하나만 생성되므로 메모리 낭비를 방지할 수 있다.
2. 전역 인스턴스이기 때문에 다른 클래스들과 자원 공유가 쉽다.
class Menus {
static let shared = Menus()
init() { }
var menus = ["스시", "돈가스", "우동"]
}
class MainViewController: UIViewController {
let lunchLabel: UILabel = {
let label = UILabel()
label.text = Menus.shared.menus.first
return label
}()
}
class DetailViewController: UIViewController {
let dinnerLabel: UILabel = {
let label = UILabel()
label.text = Menus.shared.menus[1]
return label
}()
}
3. DBCP(DataBase Connection Pool)처럼 공통된 객체를 여러개 생성해서 사용해야 하는 상황에서 많이 쓰임
ex) 쓰레드풀, 캐시, 대화상자, 사용자 설정, 레지스트리 설정, 로그 기록 객체 등
▶︎ 주의점
1. 전역 인스턴스이기 때문에 상태 관리가 어려울 수 있다.
1-1. 예시 >
class Menus {
static let shared = Menus()
init() { }
var menus = ["스시", "돈가스", "우동"]
}
// 애플리케이션의 여러 곳에서 Menus에 접근하여 값 변경 가능
class MainViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
Menus.shared.menus.append("떡볶이")
}
}
class DetailViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
Menus.shared.menus = []
}
}
1-2. 해결> 코드를 캡슐화하여 외부에서 쉽게 변경하지 못하도록 한다.
(캡슐화: 객체 내부의 세부 구현을 숨기고, 필요한 기능만 외부에 노출하는 것)
class Menus {
static let shared = Menus()
init() { }
private var menus = ["스시", "돈가스", "우동"] // private 접근제어자로 외부에서 접근 못 하도록 함
func addMenu(_ menu: String) { // addMenu 메소드 생성
menus.append(menu)
}
}
class MainViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
Menus.shared.addMenu("떡볶이") // addMenu 메소드로 메뉴 추가 가능
}
}
2. Singleton Instance가 너무 많은 일을 하거나, 많은 데이터를 공유시킬 경우,
다른 클래스의 Instance들 간 결합도가 높아져 객체 지향 설계 원칙인 ‘개방-폐쇄’ 원칙을 위배함.
그 결과, 수정과 테스트가 어려워짐.
2-1. 예시 (by GPT) >
어떤 앱에서 AppManager라는 싱글톤 클래스가 있다고 가정합시다.
이 클래스는 앱의 상태를 관리하고, 네트워크 요청을 처리하며, 사용자 데이터를 저장하는 등 다양한 기능을 담당합니다.
class AppManager {
static let shared = AppManager()
var userData: UserData?
var appState: AppState = .inactive
func fetchUserData() {
// 네트워크 요청을 보내 사용자 데이터를 가져옴
}
func saveUserData() {
// 사용자 데이터를 저장
}
func changeAppState(to state: AppState) {
// 앱의 상태를 변경
appState = state
}
}
이때 다음과 같은 문제가 발생할 수 있습니다.
- 결합도 증가: 모든 뷰 컨트롤러가 AppManager의 userData에 접근하여 데이터를 직접 수정하거나 사용하면, 클래스들 간의 결합도가 높아집니다. 만약 userData의 구조나 동작 방식이 변경되면, 이를 사용하는 모든 클래스들이 영향을 받습니다.
- 개방-폐쇄 원칙 위배: 새로운 뷰 컨트롤러가 추가되거나, userData의 데이터 구조가 변경되면 AppManager를 수정해야 합니다. 이는 기존 코드를 확장할 때 기존 클래스 수정이 필요하게 되어 개방-폐쇄 원칙에 위배됩니다.
- 테스트 어려움: 테스트할 때 AppManager가 모든 책임을 갖고 있기 때문에, 각각의 기능을 독립적으로 테스트하기 어렵습니다. AppManager는 네트워크 요청, 상태 관리 등 다양한 기능을 동시에 다루므로, 이 클래스에 대해 의존성이 높아져 단위 테스트 작성이 복잡해집니다.
2-2> 해결 (by GPT)
2-2-1. 책임 분리 (SRP 원칙 준수):
AppManager가 너무 많은 책임을 지고 있는 경우, 이를 기능별로 분리해야 합니다. 예를 들어, UserManager는 사용자 데이터를, NetworkManager는 네트워크 요청을, AppStateManager는 앱 상태를 관리하도록 설계할 수 있습니다.
class UserManager {
static let shared = UserManager()
var userData: UserData?
}
class NetworkManager {
static let shared = NetworkManager()
func fetchUserData(completion: (UserData) -> Void) {
// 네트워크 요청 처리
}
}
class AppStateManager {
static let shared = AppStateManager()
var appState: AppState = .inactive
}
2-2-2. 의존성 주입(DI, Dependency Injection) 활용:
싱글톤에 대한 의존성을 줄이기 위해 의존성 주입을 사용하면 클래스 간 결합도를 낮출 수 있습니다. 필요한 객체를 외부에서 주입받는 방식으로, 테스트 시에는 모의 객체(Mock Object)를 사용해 유연하게 대응할 수 있습니다.
[의존성 주입 없이 결합된 경우]
아래는 UserService 클래스가 네트워크 요청을 위해 NetworkManager 싱글톤 인스턴스에 직접 의존하는 예시입니다.
class NetworkManager {
static let shared = NetworkManager()
func fetchUserData(completion: (UserData) -> Void) {
// 네트워크에서 사용자 데이터를 가져옴
}
}
class UserService {
func getUserData() {
NetworkManager.shared.fetchUserData { userData in
print("User data: \(userData)")
}
}
}
<문제점>
- UserService는 NetworkManager의 구체적인 구현에 강하게 의존하고 있기 때문에, 나중에 네트워크 로직을 수정하거나 테스트할 때 NetworkManager를 교체하기 어려움
- 테스트 환경에서 네트워크를 사용하지 않고 UserService를 테스트하려면, NetworkManager를 모의 객체(Mock)로 바꿔야 하는데, 지금 구조에서는 불가능
[의존성 주입을 사용한 코드]
의존성 주입을 사용하면, UserService는 NetworkManager와 같은 구체적인 클래스가 아닌 **추상적인 프로토콜(인터페이스)**에 의존하게 됩니다.
외부에서 어떤 구현체를 주입할지 결정하기 때문에, 코드가 더 유연하고 쉽게 테스트할 수 있습니다.
먼저, 네트워크 요청을 담당할 프로토콜을 정의합니다.
protocol NetworkService {
func fetchUserData(completion: (UserData) -> Void)
}
class NetworkManager: NetworkService {
static let shared = NetworkManager()
func fetchUserData(completion: (UserData) -> Void) {
// 네트워크에서 사용자 데이터를 가져옴
}
}
class MockNetworkManager: NetworkService {
func fetchUserData(completion: (UserData) -> Void) {
// 임의의 데이터 리턴
}
}
이제 UserService는 NetworkService 프로토콜에 의존하며, 구체적인 NetworkManager가 아닌 추상적인 프로토콜을 주입받아 사용합니다.
class UserService {
private let networkService: NetworkService
// 의존성 주입: 생성자를 통해 의존 객체 주입
init(networkService: NetworkService) {
self.networkService = networkService
}
func getUserData() {
networkService.fetchUserData { userData in
print("User data: \(userData)")
}
}
}
2-2-3. 캡슐화와 인터페이스 사용:
AppManager와 같은 싱글톤 클래스의 내부 구현을 외부에 노출시키지 말고, 인터페이스를 통해 제한된 기능만 제공하여 데이터를 직접 공유하지 않도록 설계할 수 있습니다.
4️⃣ 이럴 때 사용해요
▶︎ Swift 자체 싱글톤
let screen = UIScreen.main
let userDefault = UserDefaults.standard
let application = UIApplication.shared
let fileManager = FileManager.default
let notification = NotificationCenter.default
▶︎ 커스텀 싱글톤
아래처럼 매니저를 만들면 편합니다.
1) 네트워킹 (API 호출 관리)
네트워크 요청을 관리하는 객체는 여러 화면이나 기능에서 재사용되기 때문에 싱글톤 패턴이 적합합니다. 이렇게 하면 네트워크 세션을 전역에서 공유할 수 있고, 불필요한 인스턴스 생성을 막을 수 있습니다.
class NetworkManager {
static let shared = NetworkManager()
private init() { }
func fetchData() {
// 네트워크 요청 코드
}
}
2) 데이터베이스 액세스
Core Data나 UserDefaults와 같은 데이터베이스에 접근하는 경우에도 싱글톤을 자주 사용합니다.
import CoreData
import UIKit
class CoreDataManager {
static let shared = CoreDataManager()
private init() { }
// **CRUD 함수**
// Create
func createMenu(name: String, price: Double) {
// 코드
}
// Read
func fetchAllMenus() -> [Menu] {
// 코드
}
...
}
3) 재사용되는 컴포넌트의 Identifier
테이블뷰 셀과 같이 재사용되는 컴포넌트에 identifier를 아래처럼 설정해두면,
셀을 등록할 때 오타 없이 쉽게 코드를 작성할 수 있습니다.
📔 참고자료
https://varyeun.tistory.com/entry/스위프트에서-static-키워드란-static-in-swift
https://80000coding.oopy.io/68ee8d89-5d05-449d-87e2-5fba84d604ca
https://babbab2.tistory.com/66
'Swift > 문법' 카테고리의 다른 글
[Swift] 팀프로젝트 세팅부터 마무리까지 (깃헙 & 터미널 사용법) - git, branch, merge (0) | 2024.03.11 |
---|---|
[Swift] Git 브랜치 사용 방법 (0) | 2024.03.11 |
[Swift] Git 생성 및 Github 연결 방법 (0) | 2024.03.10 |
[Swift|문법] String 문자열 추출하기(자르기) - prefix(_:), suffix(_:), .index(.startIndex, offset:) (0) | 2024.02.21 |
[Swift|문법] 클로저(Closure) (1) | 2024.02.19 |