Skip to content

swift6 도입기 ‐ @unchecked Sendable을 사용해야만 했던 이유

Tltlbo edited this page Dec 4, 2024 · 1 revision

Swift6 도입기

우선적으로 Swift6를 경험해보자가 저희의 목표였습니다. Swift6에서는 Data-race safety 를 중요시하기 때문에 데이터 레이스가 생길 가능성이 있는 부분에 대해서 컴파일러가 오류를 내고, 저희가 그것을 해결해보는 경험을 가지고자 도입하게 되었습니다.

Data race

Swift6에서는 클래스 내부의 프로퍼티가 외부에서 변경, 접근할 가능성이 있으면 바로 컴파일러가 오류를 내뿜거나 경고를 발생시킵니다.

하지만 어디까지나 가능성입니다. 동시에 수정이 이루어지거나 접근할 가능성을 없애주면 됩니다.

@unchecked Sendable

기본적으로 Swift6Sendable하면 오류를 내뿜지 않습니다. Sendable하다는 것은 Data race에 대해 방지가 잘되어 있음을 의미하는 것이니까요.

하지만 Sendable을 준수하기 위해서는 꽤나 번거로운 작업이 동반됩니다. 데이터에 접근을 위해서는 비동기적으로 프로퍼티에 접근해야 하고 함수 작성에도 여러 사항을 준수하면서 작성되어야 합니다.

하지만 위에서 말씀드렸듯이 Data-race가 일어날 가능성이 있다! 가능성이 있다는 것이지 안 일어날 수도 있음을 의미합니다.

그럼 개발자가 Sendable을 준수하진 않지만 data-race가 일어나지 않음을 명시해주어야 컴파일러가 오류를 내지 않습니다.

이때 사용되는 것이 @unchecked Sendable 입니다.

final class HummingViewModel: @unchecked Sendable {
	...
}

이런 식으로 해당 키워드를 입력해주면 컴파일러는 해당 클래스에 대해 Sendable 한지 확인하지 않습니다.

그럼 왜 사용하는가?

data race 는 꽤나 중요한 문제입니다. 수정을 진행하는 2개의 작업이 순서가 중요한데 마지막에 되었어야 할 작업이 먼저 되었다면 그 값을 읽는 부분에서 잘못된 정보를 읽은 것이 되어버리기 때문입니다.

하지만 ViewModel 이라면?

대부분의 ViewModel은 하나의 VC 혹은 하나의 View에 1:1의 관계를 가지게 됩니다.

RehummingViewModel과 RehummingVC를 예로 들어보겠습니다.

final class RehummingViewController: UIViewController {
    private var progressBar = ProgressBar()
    private var musicPanel = MusicPanel()
    private var hummingPanel = RecordingPanel(.asMint)
    private var recordButton = ASButton()
    private var submitButton = ASButton()
    private var submissionStatus = SubmissionStatusView()
    private var buttonStack = UIStackView()
    private let viewModel: RehummingViewModel

    init(viewModel: RehummingViewModel) {
        self.viewModel = viewModel
        super.init(nibName: nil, bundle: nil)
    }

    @available(*, unavailable)
    required init?(coder _: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }

    override func viewDidLoad() {
        setupUI()
        setupLayout()
        setAction()
        bindToComponents()
    }
    
    override func viewDidDisappear(_ animated: Bool) {
        viewModel.cancelSubscriptions()
    }

    private func bindToComponents() {
        submissionStatus.bind(to: viewModel.$submissionStatus)
        progressBar.bind(to: viewModel.$dueTime)
        musicPanel.bind(to: viewModel.$music)
        hummingPanel.bind(to: viewModel.$isRecording)
        hummingPanel.onRecordingFinished = { [weak self] recordedData in
            self?.recordButton.updateButton(.reRecord)
            self?.viewModel.updateRecordedData(with: recordedData)
        }
        submitButton.bind(to: viewModel.$recordedData)
    }

    private func setupUI() {
        recordButton.updateButton(.idle("녹음하기", .systemRed))
        submitButton.updateButton(.submit)
        submitButton.updateButton(.disabled)
        buttonStack.axis = .horizontal
        buttonStack.spacing = 16
        buttonStack.addArrangedSubview(recordButton)
        buttonStack.addArrangedSubview(submitButton)
        view.backgroundColor = .asLightGray
        view.addSubview(progressBar)
        view.addSubview(musicPanel)
        view.addSubview(hummingPanel)
        view.addSubview(buttonStack)
        view.addSubview(submissionStatus)
    }
    
    ...
}

VC 그 어디에도 VM의 데이터에 수정을 일으키는 부분은 존재하지 않습니다. 오직 bind를 통해서 값의 변화만 지켜보고 있어 data-race 를 일으키지 않습니다.

final class RehummingViewModel: @unchecked Sendable {
    @Published public private(set) var dueTime: Date?
    @Published public private(set) var recordOrder: UInt8?
    @Published public private(set) var status: Status?
    @Published public private(set) var submissionStatus: (submits: String, total: String) = ("0", "0")
    @Published public private(set) var music: Music?
    @Published public private(set) var recordedData: Data?
    @Published public private(set) var isRecording: Bool = false

    private let gameStatusRepository: GameStatusRepositoryProtocol
    private let playersRepository: PlayersRepositoryProtocol
    private let recordsRepository: RecordsRepositoryProtocol
    private var cancellables: Set<AnyCancellable> = []

    public init(
        gameStatusRepository: GameStatusRepositoryProtocol,
        playersRepository: PlayersRepositoryProtocol,
        recordsRepository: RecordsRepositoryProtocol
    ) {
        self.gameStatusRepository = gameStatusRepository
        self.playersRepository = playersRepository
        self.recordsRepository = recordsRepository
        bindGameStatus()
    }

    func submitHumming() async throws {
        guard let recordedData else { return }
        do {
            let result = try await recordsRepository.uploadRecording(recordedData)
            if result {
                // 전송됨
            } else {
                // 전송 안됨, 오류 alert
            }
        } catch {
            throw error
        }
    }

    func startRecording() {
        if !isRecording {
            isRecording = true
        }
    }

    func updateRecordedData(with data: Data) {
        // TODO: - data가 empty일 때(녹음이 제대로 되지 않았을 때 사용자 오류처리 필요
        guard !data.isEmpty else { return }
        recordedData = data
        isRecording = false
    }

    private func bindRecord(on recordOrder: UInt8) {
        recordsRepository.getHumming(on: recordOrder)
            .sink { [weak self] record in
                guard let record else { return }
                self?.music = Music(record)
            }
            .store(in: &cancellables)
    }

    private func bindGameStatus() {
        gameStatusRepository.getDueTime()
            .sink { [weak self] newDueTime in
                self?.dueTime = newDueTime
            }
            .store(in: &cancellables)
        gameStatusRepository.getRecordOrder()
            .sink { [weak self] newRecordOrder in
                self?.recordOrder = newRecordOrder
                self?.bindRecord(on: newRecordOrder)
                self?.bindSubmissionStatus(with: newRecordOrder)
            }
            .store(in: &cancellables)
        gameStatusRepository.getStatus()
            .sink { [weak self] newStatus in
                self?.status = newStatus
            }
            .store(in: &cancellables)
    }

    private func bindSubmissionStatus(with recordOrder: UInt8) {
        let playerPublisher = playersRepository.getPlayersCount()
        let recordsPublisher = recordsRepository.getRecordsCount(on: recordOrder)

        playerPublisher.combineLatest(recordsPublisher)
            .sink { [weak self] playersCount, recordsCount in
                let submitStatus = (submits: String(recordsCount), total: String(playersCount))
                self?.submissionStatus = submitStatus
            }
            .store(in: &cancellables)
    }
    ...

VM에서만 VM 내부의 프로퍼티를 변경할 수 있는 함수가 존재합니다. 이렇게 되면 VM 내부에서만 데이터의 변경이 일어납니다.

이렇게 되면 오로지 데이터를 변경하는 곳은 VM이므로, 동시에 프로퍼티가 변경될 일이 사라지기 때문에 암시적으로 Sendable하게 만들 수 있습니다.

그럼 CacheManager들은 왜 @unchecked Sendable 할까요?

이 부분도 동일합니다. 각 CacheManager들은 ASCacheManager라는 하나의 큰 CacheManager와 1:1 연관관계로 연결되어 있습니다.

다른 곳에서는 각 CacheManager를 참조하지 않기에 오로지 ASCacheManager에서만 변화를 일으키고, 각 CacheManager 내부에서 Manager 프로퍼티에 변화를 시키기에 data-race 가 일어나지 않음을 보장할 수 있습니다.

iOS07 프로젝트 일지

📚 문서

🫶🏻 팀 기록

🎤 프로젝트

💡 핵심 경험

🚨 트러블 슈팅

📔 학습 정리

🪄 QA

Clone this wiki locally