싱글톤 패턴은 의존성 주입에 비해 너무나 간편하고 간결해서 자꾸만 중독적으로 쓰게 된다.
하지만 정확히 어디서 봤는지 모르는 어떤 글이 계속 생각나 사용할 때 고민을 하게 해준다.
파일 10개 중 단 2~3곳에서만 사용되는 객체를 앱의 시작부터 종료시점까지 메모리에 올려두어야 할 필요가 정말 있는지 확인해라.
이번 프로젝트에서 로그인과 관련된 객체를 만들며 이러한 문제에 대해 팀원들과 고민했었다.
로그인과 관련하여 로그인 페이지를 모달로 띄워줄 객체인 LoginManager와
로그인 페이지에 미리 아이디와 비밀번호가 입력되어있도록 도와줄 UserDefaultsManager가 필요했다.
이 두 객체를 처음엔 별 생각없이 싱글톤으로 구현했으나 곰곰히 생각해보니 로그인 페이지가 나타나는 곳은 딱 3곳이었다.
앱을 처음 실행할 때 1번만 메인 탭바 뷰,
예매하기 버튼을 누르는 디테일뷰,
회원정보를 확인하는 마이페이지 뷰.
여러 화면 중에서 딱 3곳에서만 필요하고 로그인 이후 로그인에 대한 정보면 몰라도 로그인 화면에 대해서는 동일한 상태를 유지할 필요도 없다고 판단하여 의존성 주입 방식으로 사용하기로 변경했다.
대신 UserDefaultsManager에는 현재 로그인 상태에 대한 정보도 담아두었는데,
현재 로그인 상태를 앱의 실행 중 계속해서 동일하게 유지해야 한다는 점과 로그인 매니저, 로그인 창을 열지 결정해야 하는 뷰 등 다양한 곳에서 해당 객체를 필요로 하여 싱글톤으로 구현하기로 결정했다.
이번 프로젝트의 첫 글 하단에서 sending을 알아보며 잠시 나왔던 Sendable과 관련하여 팀원이 경고를 발견한 부분에 대해 추가로 적으려 한다.
static을 이용해 싱글톤 패턴을 구현하는 중에 발견된 경고인데 동시성 안전성이 보장되지 않는다는 뜻같아보인다.
그 아래로 Sendable 프로토콜을 준수하지 않는다.
@MainActor를 사용해 메인스레드에서만 동작하는 것을 보장해봐라
동시성 안전성 체크하는 기능을 끄라는 등의 추천 동작이 이어졌다.
게다가 Swift6에서는 경고가 아닌 에러로 간주된다고 한다.
실제로 이 경고는 내가 BuildSetting에서 Strict Concurrency Checking의 옵션을 complete로 바꾸고 나서야 발생했다.
아무튼 해당 경고는 결국 동시성 안전성이 보장되지 않는다는 말인데 그렇다면 Concurrency-safe가 뭘까? Thread-safe와 비슷하게 생겼는데 비교하면서 알아보겠다.
일단 동시성이란 CPU의 성능을 최대화 하기 위해 여러 작업을 동시에 실행하는 것을 말한다.
하지만 동시에 같은 자원에 접근하여 읽고 쓰는 경우 실행 순서에 따라 결과가 달라지는 일이 발생한다.(Race Condition)
또는 서로 자원을 기다리기만 하고 쓰는 스레드가 없어 영원히 멈추는 경우(Deadlock)나 데이터가 불일치하는 상황이 발생할 수 있다.
이를 해결하기 위해 Swift에는 몇 가지 방법이 존재한다.
1. Lock
2. Queue-Based Concurrency(GCD나 OperationQueue)
3. Immutability
4. Actor
5. Sendable
이러한 방법을 통해 Concurrency-safe를 보장받을 수 있다.
그렇다면 Thread-safe와는 무엇이 다를까?
먼저 스레드 안전이란 공유 자원이 여러 스레드에서 동시에 접근되더라도 데이터 무결성이 보장되는 상태를 의미한다.
이 또한 경쟁조건이나 데이터 손상, 데드락을 방지하기 위해 필요하다.
스레드 안전성을 보장하기 위해선 다음과 같은 방법을 사용한다.
1. Lock
2. GCD(Grand Central Dispatch)
3. Immutability
4. Actor
5. Atomic Operation
뭔가 방지하고자 하는 목적도 비슷하고 해결법도 굉장히 비슷하다.
그렇다면 정확히 어느 지점에서 구분이 되는 걸까??
스레드 안전성은 공유 자원 자체에 초점이 맞춰져있다.
동시성 안전성은 작업의 단위와 시스템 전체 동작의 안전성에 초점이 맞춰져있다.
결국 데이터를 보호하는 것이 핵심이고 여러 작업이 충돌하지 않도록 방지하기 위함이기에 위에서처럼 해결방법이 대부분 겹치는 것이다.
내가 이애한 바로는 Thread-safe가 좀 더 작은 단위(변수나 객체)를 보호하기 위함이라면 Concurrency-safe는 좀 더 큰 단위(시스템이나 작업)를 보호하는 수준이라고 생각된다.
동시성 안전성을 해결한다면 스레드 안전성도 어느정도 따라오는 느낌이다.
결국 static이 붙어있는 공유객체는 여러 작업, 스레드에서 동시에 접근할 가능성이 존재하므로 이를 좀 더 안전하게 사용할 수 있도록 보장하라는 의미의 경고로 보인다.
이를 위한 해결법을 아주 간단히 하나씩 알아보겠다.
그래야 강의를 들을 수 있다..
lock은 이름 그대로 자원에 락을 걸어두어 해제 전까지 다른 스레드에서의 접근을 막아버리는 방법이다.
그렇다면? 당연히 관리를 잘못했을 때 Deadlock에 걸릴 것이다.
또 경합(contention)이 발생하여 대기 시간이 길어지고 성능이 저하될 가능성도 존재한다.
때문에 아래에서 알아볼 다른 방법을 더 적극적으로 사용하자.
1편에서 알아봤던 Actor다.
복습을 하자면 actor 내부에 정의된 변수와 메서드는 기본적으로 Thread-safe하다.
또 상태에 접근하기 위해 반드시 await이 필요하고 격리되었음을 더 확실히 보증하기 위해 isolated라는 키워드를 사용한다.
그리고 내부 상태에 대한 접근이 모두 직렬화 되기에 동시성 안전성이 보장된다!
새벽에 밤새가며 공부한 보람이 있다.
이건 1편에서 그냥 넘겼던 부분이다..
가장 일반적으로 사용되는 DispatchQueue를 활용한 직렬화, 동시 실행 방법이다.
let serialQueue = DispatchQueue(label: "com.example.serialQueue")
serialQueue.async {
print("작업 1 시작")
print("작업 1 완료")
}
serialQueue.async {
print("작업 2 시작")
print("작업 2 완료")
}
// 작업 프린트문이 순서대로 출력된다.
직렬 큐를 사용하면 순차적으로 작업이 실행되니 자연스레 동시성 문제가 발생하지 않는다.
또 동시 실행도 지원하는데 대신 적절한 동기화 처리를 해주어야 문제가 발생하지 않는다.
그러나 동시 작업이 복잡할 경우 코드가 어려워질 수 있다.
Operation Queue는 GCD보다 상위 API로 작업의 우선순위와 의존성 그리고 취소 가능성까지 추가로 관리 가능하다.
대신 그만큼 약간의 오버헤드가 따를 수 있다.
사용 예시는 다음과 같다.
let operationQueue = OperationQueue()
operationQueue.maxConcurrentOperationCount = 1 // 직렬 실행
// 숫자를 늘리면 그만큼 동시 실행이 가능해짐
operationQueue.addOperation {
print("Task 1 실행")
}
operationQueue.addOperation {
print("Task 2 실행")
}
의존성 주입의 예시는 다음과 같다.
let operationQueue = OperationQueue()
let task1 = BlockOperation {
print("Task 1 실행")
}
// BlockOperation은 클로저 기반의 작업을 생성하고 실행할 수 있게 돕는 서브클래스.
let task2 = BlockOperation {
print("Task 2 실행")
}
task2.addDependency(task1) // 의존성을 설정하여 Task 1이 완료된 후에 Task 2 실행되도록 함
operationQueue.addOperations([task1, task2], waitUntilFinished: false)// 작업을 추가하고 작업 완료를 기다리지 않고 즉시 반환하도록 설정함. 즉 비동기적으로 실행되도록 함.
가장 간단하고 안전하지만 수정이 잦을 경우 성능저하가 발생하는 불변성 활용이다.
모든 데이터가 불변이라면 동시성 문제가 원칙적으로 발생하지 않을 것이다.
대신 새로운 상태를 설정할 때마다 값을 복사하여 새로 넣어주어야 하니 성능저하가 생길 수 있다.
struct Immutability {
let value = 1
func addValue(_ num: Int) -> Immutability {
return Immutability(value = self.value + num)
}
마지막으로 알아볼 것은 Sendable이다.
이건 애초에 Concurrency-safe를 보장하는 프로토콜로 객체나 값이 여러 스레드 간에 안전하게 전달될 수 있는지를 컴파일 타임에 검사받는다.
Sendable은 자동으로 준수하게 되는 경우가 있다.
즉 명시하지 않더라도 암묵적으로 준수하는 코드를 작성할 수 있는데 그 예가 위와 같다.
이 프로토콜에는 필수 메서드느 프로퍼티는 없으나 컴파일 타임에 검증되는 의미적 요구사항이 존재한다.
내가 느끼기엔 순서의 차이일 뿐 Sendable을 준수한다 = 동시성 안전성을 보장한다.
동시성 안전을 보장하는 코드 = Sendable을 준수하는 코드 같다.
구현은 개발자가 도구를 선택하여 직접 구현하고 구현했을을 컴파일러에게 알리는 도구가 Sendable인 느낌.
Sendable의 요구사항을 충족하려면 열거형이나 구조체는 Sednable멤버와 관련된 값만 가져야 한다.
위 두 가지 경우엔 Sendable을 명시적으로 선언할 필요가 없다.
그 외의 경우엔 Sendable을 명시적으로 선언해야 하고,
코드 작성 시 컴파일러는 동시성 안전성을 확인하지 못하지만 개발자가 동시성 안전성을 보장하려면 @unchecked Sendable
을 사용하여
컴파일러에게 안전하니까 보장해달라고 하는 방법이 있다.
Frozen은 아마 첫 글에서 잠깐 봤던 @frozen을 붙여 확장이 불가능함을 명시적으로 지정한 구조체나 열거형을 뜻하는 게 아닐까 싶다.
(원문: Frozen structures and enumerations)
그리고 @usableFromInline은 internal로 선언된 심볼을 모듈외부에서도 인라인될 수 있도록 허용하여 성능 최적화를 하는 속성이라고 하는데
아마 내가 쓸 일은 없지 않을까.. 그냥 쓰게 되면 명시적으로 Sendable을 준수하자.
class Foo: Sendable {
let value: Int = 0
} // Int는 Sendable을 준수하므로 Bar도 Sendable을 자동으로 준수한다.
struct Bar: Sendable {
let bar: Int = 0
} // Int는 Sendable을 준수하므로 Bar도 Sendable을 자동으로 준수한다.
struct Baz: Sendable {
let userDefaults = UserDefalts
} // UserDefaults는 Sendable을 준수하지 않기 때문에 수동으로 준수하도록 구현해야 함.
모든 actor는 암시적으로 Sendable을 준수한다. 모든 접근을 순차적으로 처리하니까.
Sendable을 준수하는 클래스는 다음 사항을 만족해야 한다.
그리고 @MainActor는 암시적으로 Sendable을 준수한다.
메인스레드에서만 동작한다고 보장받으니 가변 프로퍼티나 Sendable하지 않은 프로퍼티도 가질 수 있다.
함수와 클로저는 프로토콜 대신 @Sendable attribute로 표시한다.
특히 @Sendable Closure는 다음을 충족해야 한다.
만약 Sendable 클로저를 요구하는 컨텍스트에서 위 요구사항을 충족한다면 암시적으로 Sendable로 간주해준다.
명시적으로 작성하고 싶다면 다음과 같이 작성하면 된다.
let closure = { @Sendable (num: Int) -> Int in
return num * 2
}
앞서 살펴본 Sendable이 자동으로 준수되는 경우가 아닌 경우 수동으로 동시성 안전성을 보장해줘야 하한다.
그 방법은 다시 앞서 알아본 NSLock, DispatchQueue, actor 등의 동기화 도구 활용으로 이어진다.
이번에 만난 싱글톤 객체의 경고를 해결해보자.
일단 class에 Sendable을 사용해보자.
그러니 내부 프로퍼티 중 하나인 userDefaults의 타입이 Sendable을 준수하지 않는 타입이라고 경고가 떴다.
어떻게 해결할까?
actor는 class처럼 사용할 수 있는 키워드인데
앞서 말했듯 actor는 내부 상태에 대한 모든 접근을 직렬화 하고 동시성 안전을 보장하기 때문에 UserDefaults같은 스레드안전하지 않은 객체도 안전하게 관리할 수 있다.
대신 비동기적으로 호출되니 final class에서 actor로 변경 시 호출되는 부분을 모두 await을 사용하도록 변경해주어야 한다.
UserDefaults에 대한 경고가 사라졌다!
대신 에러를 받았다!!
일단 actor로만 바꾸고 함수에 대해 async 처리를 안 해줘서 난 오류였다.
함수에 async를 붙이고 오니 다른 에러가 발생했다.
'async' call in a function that does not support concurrency
이 에러는 직역하면 동시성을 지원하지 않는 함수에서 호출되었다는 건데
즉 비동기 함수(async)로 선언되지 않은 함수에서 호출받았다는 뜻이다.
동기함수에서는 비동기 호출이 허용되지 않기 때문이며 함수 자체를 다시 async로 정의하거나 get async로 속성을 비동기로 정의하여 반환해버릴 수도 있다.
함수 자체가 비동기일 필요가 아닌 반환 속성이 비동기이길 원하기 때문에 get async{}로 묶어주어서 반환을 해줬다.
오늘도 글을 끝내고 싶은데 끝낼 수가 없다.
왜냐면 다시 해당 함수를 호출하는 곳을 비동기처리해야 하니까..
아 근데 이렇게 하나가 비동기가 되면서 비동기가 비동기를 낳고 비동기를 낳는 이런 전파가 맞는 걸까..?
actor로 바꿀 문제가 아닌 걸까 흑흑
async/await 를 쓰기위해 함수 호출 계층이 전부 수정 될 이유가 없다면 Task를 통해 비동기 실행 컨텍스트를 만들어서 사용해보는 것은 어떨까요?