동시성 문제

maxminseok·2024년 12월 23일
1
post-thumbnail

강의를 보며 싱글톤 패턴 코드를 따라서 작성하고 있었는데 에러가 발생했다.

Static property 'shared' is not concurrency-safe because non-'Sendable' type 'Pms' may have shared mutable state

'정적 프로퍼티 'shared'는 동시성 안전하지 않습니다. 이는 'Sendable'이 아닌 타입 'Pms'이 공유된 가변 상태를 가질 수 있기 때문입니다.'

라고 번역이 되는데, 동시성 문제가 있다는 것이다.


동시성 문제라는 게 뭘까

동시성 문제는 여러 스레드나 비동기 작업이 동시에 동일한 리소스(메모리, 파일, 네트워크 등)에 접근하거나 변경하려고 할 때 발생할 수 있는 문제를 말한다.

예를 들어, 싱글톤 클래스처럼 공유된 상태를 가지는 객체에 여러 스레드가 동시에 접근하고 값을 읽거나 쓸 때, 데이터 불일치나 충돌이 발생할 가능성이 있다.

주된 발생 원인

  1. 공유 리소스 접근
    • 한 객체가 여러 스레드에 의해 동시에 수정될 때, 업데이트된 값이 덮어쓰이거나 중간 상태를 읽게 될 수 있다.
  2. 작업 순서 비결정성
    • 동시적으로 실행되는 작업의 실행 순서는 보장되지 않는다. 이로 인해 실행 결과가 예측할 수 없거나 일관되지 않을 수 있다.
  3. Race Condition(경쟁 상태)
    • 두 개 이상의 스레드가 특정 자원을 경쟁적으로 접근할 때 발생하며, 이로 인해 데이터 손상이나 예기치 않은 동작이 발생할 수 있다.

이 코드의 동시성 안전 문제

Pms 클래스는 싱글톤 패턴을 사용하며, mbtiage 같은 변경 가능한(shared mutable) 속성을 가진다.

예를 들어, 다음과 같은 상황에서 문제가 발생할 수 있다.

  1. 스레드 A가 Pms.shared.mbti 값을 읽는 동안 스레드 B가 값을 변경한다면, 스레드 A는 중간 상태의 값을 읽게 될 가능성이 있다.
  2. 여러 스레드가 동시에 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의 상태를 읽거나 쓰려고 해도, 내부적으로 직렬화된 접근을 보장하기 때문에 경쟁 상태가 발생하지 않는다.


await는 또 뭘까

actor를 써서 동시성 문제를 해결한 건 좋은데,

Task {
    await Pms.shared.printInfo()
}

이 코드는 또 뭘까.

async/await

async/await는 Swift의 비동기 프로그래밍을 지원하기 위해 도입된 키워드이다. 비동기 코드를 더 읽기 쉽고 직관적으로 작성할 수 있도록 설계되었다.

  • async:
    • 비동기 작업을 수행하는 함수나 메서드를 정의할 때 사용
    • 호출 시 await 키워드로 결과를 기다려야 함
  • await:
    • 비동기 작업의 완료를 기다릴 때 사용
    • 비동기 작업이 완료될 때까지 일시 중단(suspend)
    • 결과가 준비되면 중단된 이후의 코드를 계속 실행

예시

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의 장점

  • 콜백(closure)을 사용하는 비동기 코드보다 훨씬 더 읽기 쉽다.
  • 기존의 복잡한 중첩된 콜백 지옥을 피할 수 있다.

콜백 방식

fetchData { result in
    process(result) { processedResult in
        display(processedResult)
    }
}

async/await 방식

let result = await fetchData()
let processedResult = process(result)
display(processedResult)

fetchData 메서드가 생각난다..

전에 포켓몬 이미지를 사용하는 연락처 앱 과제를 할 떄 이런 메서드가 있었다.

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 형태로 변환

이걸 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 로 바꾸면서 trydo-catch를 활용하여 한 곳에서 에러를 처리할 수 있게 되었다.


느낀점

싱글톤 패턴은 그냥 냅다 쓸 게 아니라, 싱글톤 패턴의 특성상 동시성 문제를 늘 생각 해야겠구나 싶었다.

그리고 이를 해결하기 위해 actorasync/await라는 방법으로 코드를 작성하는 법을 공부하게 되었다.

그러다가 전에 작성했던 fetchData까지 다시 보게 되었는데, 어렵게 느껴졌던 api 통신 코드가 조금은 눈에 익기 시작한 것 같다..

1개의 댓글

comment-user-thumbnail
2024년 12월 24일

곧바로 async/await을 사용하는 것 뿐 아니라 기존의 completion handler를 사용하는 메서드를 호환가능하도록 async를 사용한 메서드를 작성하는 법도 알아두면 좋을 거 같아요
어제 챌린저 수업 시간에 어쨌든 기존 방식으로 작성된 코드가 많고 모두 다 마이그레이션 된 건 아니라고 하시더라구요

답글 달기