Project/개발 업무

클로저 안에 락을 두어 생기는 데드락 (Apple 시스템 API)

lgvv 2026. 3. 26. 17:02

클로저 안에 락을 두어 생기는 데드락 (Apple 시스템 API)

 

클로저 안에 락이 들어 있으면 위험함. 획득이든 해제든 락의 수명이 클로저 실행에 묶이는데, 클로저가 언제 불릴지, 혹은 불리지 않을지 앱이 통제하지 못하기 때문.

 

애플 시스템 API를 쓰는 영역에서 이 형태로 데드락이 발생. lock을 잡고 시스템 API를 호출한 뒤 완료 클로저 안에서 unlock을 하던 기존 코드였음. 그 클로저가 끝내 호출되지 않으면 lock이 풀리지 않아 무한 대기에 빠짐.

 

완료 클로저 안의 unlock이 실행되지 않는 경우

락을 잡은 채로 완료 클로저 안에서만 unlock을 하는 형태로 실제 코드를 단순화하면 아래와 같음.

 

func activateAudioSession() {
    lock.lock()
    // Apple 오디오 세션 관련 시스템 API 호출
    // 완료가 비동기 클로저로 전달됨
    audioSession.requestRecordPermission { [weak self] granted in
        // 응답이 와야 여기서 unlock
        self?.lock.unlock()
    }
    // 함수는 여기서 반환. lock은 아직 잡힌 상태
}

 

정상 흐름에서는 완료 클로저가 불리면서 unlock까지 이어짐. 문제는 시스템 API가 응답을 주지 않는 경우였음. 콜백이 오지 않으면 클로저 안의 unlock도 실행되지 않고, lock은 잡힌 채로 남음. 이후 같은 lock을 잡으려는 호출은 전부 lock.lock()에서 멈춤.

 

근본 원인은 unlock을 클로저 안에 둔 것 자체가 아니라, lock을 푸는 시점을 외부 시스템의 응답에 통째로 맡긴 것. 응답이 없으면 해제 시점도 같이 사라짐.

 

클로저에 콜백이 오지 않을 수 있는 Apple 시스템 API

 

실제로 발생한 영역에 대한 정리.

 

모두 클로저나 delegate 콜백이 불릴지 여부가 앱이 아니라 시스템 상태에 달려 있음.

  • 오디오 권한: AVAudioSession의 마이크 권한 요청(requestRecordPermission)
    • 콜백이 오지 않아 데드락이 발생했고, 해당 경우에 30초 정도 지나면 콜백이 내려옴.
  • 페이지 전환: UIPageViewControllersetViewControllers(_:direction:animated:completion:)로 앞선 전환 애니메이션이 끝나기 전에 다시 호출하면 completion이 불리지 않고 유실되는 경우가 발생.
    • 해결 방법으로는 셋업 단계에서 isEnabledUserInteraction = false로 설정하고 클로저를 받은 후에 다시 활성화
  • 알림 권한: UNUserNotificationCenterrequestAuthorization(options:completionHandler:)getNotificationSettings(completionHandler:). 문서에도 적혀 있듯 메인 스레드를 보장하지 않으면 특정 Xcode 버전에서 배포한 특정 iOS 버전에서만 발생

 

보완 방법

 

lock 해제와 흐름 진행을 완료 클로저 하나에만 의존하지 않도록 변경

 

  1. lock은 공유 상태를 읽고 쓰는 최소 구간에만 사용함. 외부 응답을 기다리는 작업은 lock 밖으로 빼서, lock을 잡은 채 콜백을 기다리지 않게 함.
  2. 애플의 시스템 API도 클로저로 콜백이 오지 않을 수 있어서 타임아웃을 함께 두어 예방할 수 있음. 정해진 시간 안에 콜백이 없으면 실패로 처리하고 상태를 원래대로 되돌림.
  3. 완료 처리가 한 번만 실행되도록 보호함. 타임아웃과 실제 콜백이 둘 다 도착해도 unlock이나 상태 변경이 중복되지 않게 함.

 

샘플 코드

func requestMicPermission() {
    // lock은 상태 변경 구간에만 사용
    lock.lock()
    markRequesting()
    lock.unlock()

    audioSession.requestRecordPermission { [weak self] granted in
        self?.finishPermission(with: granted ? .granted : .denied)
    }

    // 콜백이 오지 않는 경우를 대비한 타임아웃
    scheduleTimeout { [weak self] in
        self?.finishPermission(with: .timedOut)
    }
}

 

finishPermission(with:)을 한 번만 동작하도록 막아 두면, 콜백과 타임아웃 중 먼저 도착한 쪽만 반영됨. 시스템이 응답을 주지 않아도 앱은 정해진 시간 뒤에 실패로 흐름을 정리하고 멈추지 않음.

 


(참고)