강의를 보며 싱글톤 패턴 코드를 따라서 작성하고 있었는데 에러가 발생했다.
Static property 'shared' is not concurrency-safe because non-'Sendable' type 'Pms' may have shared mutable state
'정적 프로퍼티 'shared'는 동시성 안전하지 않습니다. 이는 'Sendable'이 아닌 타입 'Pms'이 공유된 가변 상태를 가질 수 있기 때문입니다.'
라고 번역이 되는데, 동시성 문제
가 있다는 것이다.
동시성 문제는 여러 스레드나 비동기 작업이 동시에 동일한 리소스(메모리, 파일, 네트워크 등)에 접근하거나 변경하려고 할 때 발생할 수 있는 문제를 말한다.
예를 들어, 싱글톤 클래스처럼 공유된 상태를 가지는 객체에 여러 스레드가 동시에 접근하고 값을 읽거나 쓸 때, 데이터 불일치나 충돌이 발생할 가능성이 있다.
Pms
클래스는 싱글톤 패턴을 사용하며, mbti
와 age
같은 변경 가능한(shared mutable) 속성을 가진다.
예를 들어, 다음과 같은 상황에서 문제가 발생할 수 있다.
Pms.shared.mbti
값을 읽는 동안 스레드 B가 값을 변경한다면, 스레드 A는 중간 상태의 값을 읽게 될 가능성이 있다.Pms.shared.age
를 변경하면 예기치 않은 결과가 나올 수 있다.즉, 앱 전역에서 모두 접근할 수 있는 싱글톤 패턴의 특성이 동시성 문제를 가져오는 것이다.
Swift에서는 동시성을 지원하기 위해 객체가 스레드 간에 안전하게 공유될 수 있는지를 보장하는Sendable
프로토콜을 도입했다.
위의 코드에서 Pms
클래스는 Sendable
을 따르지 않기 때문에 동시성 안전성 문제가 발생할 수 있는 것이고, 이를 경고한 것이다.
@MainActor
사용싱글톤을 메인 스레드에서만 접근 가능하도록 제한할 수 있다.
@MainActor
는 모든 속성과 메서드가 메인 스레드에서 동작하도록 보장한다.
@MainActor
class Pms {
static let shared = Pms()
var mbti: String = "INFP"
var age: Int = 29
private init() {}
func printInfo() {
print("[Pms Info]")
print("mbti = \(mbti)")
print("age = \(age)")
}
}
actor
사용actor
내부의 모든 상태에 대한 접근은 직렬화된다.
즉, actor
내부의 변수를 읽거나 쓸 때 한 번에 하나의 작업만 실행되도록 보장하는 것이다.
따라서 여러 스레드가 동시에 actor
의 상태에 접근하더라도 작업 충돌이나 Race Condition(경쟁 상태)이 발생하지 않는다.
actor Pms: Sendable {
static let shared = Pms()
var mbti = "INFP"
var age = 29
private init() {}
func printInfo() {
print("[Pms Info]")
print("mbti = \(mbti)")
print("age = \(age)")
}
}
Task {
await Pms.shared.printInfo()
}
actor
는 기본적으로 Sendable
을 따르고, 내부 상태는 스레드 안전을 보장한다.
actor
의 속성은 외부에서 직접 접근할 수 없고, 반드시 동시성 제약을 만족하는 메서드를 통해서만 접근 가능하다. 격리된 상태 덕분에 동시성 문제를 피할 수 있다.
actor
는 내부 상태에 대해 하나의 작업만 실행할 수 있도록 보장한다. 따라서 동시에 여러 스레드가 Pms.shared
의 상태를 읽거나 쓰려고 해도, 내부적으로 직렬화된 접근을 보장하기 때문에 경쟁 상태가 발생하지 않는다.
actor를 써서 동시성 문제를 해결한 건 좋은데,
Task {
await Pms.shared.printInfo()
}
이 코드는 또 뭘까.
async/await
는 Swift의 비동기 프로그래밍을 지원하기 위해 도입된 키워드이다. 비동기 코드를 더 읽기 쉽고 직관적으로 작성할 수 있도록 설계되었다.
async
:await
키워드로 결과를 기다려야 함await
:예시
func fetchData() async -> String {
// 비동기 작업을 시뮬레이션 (2초 대기)
try? await Task.sleep(nanoseconds: 2_000_000_000)
return "Data fetched!"
}
Task {
let result = await fetchData()
print(result) // "Data fetched!" (2초 후 출력)
}
async/await
의 장점콜백 방식
fetchData { result in
process(result) { processedResult in
display(processedResult)
}
}
async/await 방식
let result = await fetchData()
let processedResult = process(result)
display(processedResult)
전에 포켓몬 이미지를 사용하는 연락처 앱 과제를 할 떄 이런 메서드가 있었다.
class NetworkManager {
// 서버 데이터를 불러오는 일반적인 형태의 메서드
func fetchData<T: Decodable>(url: URL, completion: @escaping (T?) -> Void) {
let session = URLSession(configuration: .default)
session.dataTask(with: URLRequest(url: url)) { data, response, error in
guard let data, error == nil else {
print("데이터 로드 실패")
completion(nil)
return
}
let suceessRange = 200..<300
if let response = response as? HTTPURLResponse, suceessRange.contains(response.statusCode) {
guard let decodeData = try? JSONDecoder().decode(T.self, from: data) else {
print("JSON 디코딩 실패")
completion(nil)
return
}
completion(decodeData)
}
else {
print("응답 오류")
completion(nil)
}
}.resume()
}
}
서버에서 데이터를 받아오기 위한 콜백 형태의 메서드였다.
특정 api를 처리하기 위한 메서드라기 보단 다양한 api를 처리할 수 있는 일반적인 형태의 메서드였다.
포켓몬 이미지만 받아오면 돼서 조금 더 간단히 작성할 수도 있었지만, 확장성을 생각해 이렇게 작성했었다.
이걸 다음과 같이 호출하여 사용했었다.
/// 이미지 생성 버튼 이벤트 처리 메서드
func didTapCreateImageButton() {
let randomNumber = Int.random(in: 1...1000)
let urlComponents = URLComponents(string: "https://pokeapi.co/api/v2/pokemon/"+"\(randomNumber)")
guard let url = urlComponents?.url else {
print("잘못된 URL")
return
}
networkManager.fetchData(url: url) { [weak self] (result: PokemonData?) in
guard let self, let result else { return }
guard let imageUrl = URL(string: result.sprites.front_default) else { return }
if let imageData = try? Data(contentsOf: imageUrl) {
if let image = UIImage(data: imageData) {
DispatchQueue.main.async {
self.editView.profileImage.image = image
}
}
}
}
}
콜백 형태로 호출하다보니 중첩된 콜백이 많아지면서 이른바 콜백 지옥이 생기려 하고 있는 것이다.
일반적인 방식이라고 하니 익숙해지면 괜찮겠지 하며 열심히 눈에 익혔는데,async/await
라는 게 생긴 거 보면 다른 개발자들도 이런 중첩된 형태를 참을 수 없는가보다.
이걸 async/await
로 바꿔보았다.
이걸 async/await
방식으로 다시 작성해보면,
class NetworkManager {
// 서버 데이터를 비동기로 불러오는 메서드
func fetchData<T: Decodable>(url: URL) async -> T? {
do {
let (data, response) = try await URLSession.shared.data(for: URLRequest(url: url))
guard let httpResponse = response as? HTTPURLResponse, (200..<300).contains(httpResponse.statusCode) else {
print("응답 오류")
return nil
}
return try JSONDecoder().decode(T.self, from: data)
} catch {
print("데이터 로드 실패 또는 JSON 디코딩 실패: \(error)")
return nil
}
}
}
이런 식으로fetchData
메서드를 async
함수로 변경할 수 있다.
이제 이걸 호출하는 쪽에서 await
로 처리할 수 있다.
func didTapCreateImageButton() {
Task {
let randomNumber = Int.random(in: 1...1000)
let urlComponents = URLComponents(string: "https://pokeapi.co/api/v2/pokemon/\(randomNumber)")
guard let url = urlComponents?.url else {
print("잘못된 URL")
return
}
// `await`로 데이터를 불러오기
guard let result: PokemonData = await networkManager.fetchData(url: url) else {
print("포켓몬 데이터를 불러오지 못했습니다.")
return
}
// 이미지 URL 처리
guard let imageUrl = URL(string: result.sprites.front_default) else {
print("이미지 URL 생성 실패")
return
}
// 이미지 로드
do {
let imageData = try await URLSession.shared.data(from: imageUrl).0
if let image = UIImage(data: imageData) {
DispatchQueue.main.async {
self.editView.profileImage.image = image
}
}
} catch {
print("이미지 로드 실패: \(error)")
}
}
}
fetchData
메서드가 비동기 함수로 변경되었기 때문에, 콜백 대신 await
로 처리할 수 있다.
기존 코드에서 중첩된 클로저 구조를 제거하여 코드가 더 간결하고 읽기 쉬워졌다.
코드가 중첩된 구조 없이, 순차적으로 실행되는 동기적인 흐름처럼 작성된 것이다.
또, 기존 코드는 각 단계에서 개별적으로 에러를 확인하고 처리해야 해서 코드 중간중간 에러 처리를 했어야 했는데,
async/await
로 바꾸면서 try
와 do-catch
를 활용하여 한 곳에서 에러를 처리할 수 있게 되었다.
싱글톤 패턴은 그냥 냅다 쓸 게 아니라, 싱글톤 패턴의 특성상 동시성 문제를 늘 생각 해야겠구나 싶었다.
그리고 이를 해결하기 위해 actor
와 async/await
라는 방법으로 코드를 작성하는 법을 공부하게 되었다.
그러다가 전에 작성했던 fetchData까지 다시 보게 되었는데, 어렵게 느껴졌던 api 통신 코드가 조금은 눈에 익기 시작한 것 같다..
곧바로 async/await을 사용하는 것 뿐 아니라 기존의 completion handler를 사용하는 메서드를 호환가능하도록 async를 사용한 메서드를 작성하는 법도 알아두면 좋을 거 같아요
어제 챌린저 수업 시간에 어쨌든 기존 방식으로 작성된 코드가 많고 모두 다 마이그레이션 된 건 아니라고 하시더라구요