본문 바로가기

스타트업 생존기

[iOS] 백그라운드에서 Bluetooth 태그 스캔 기능을 구현하라구요? - 1탄

안녕하세요!

오늘은 제가 기능 구현하면서 공부하고 부딪혔던 부분에대해서 글을 적어보려고 합니다!ㅎㅎㅎ

백그라운드..... 힘들었어요... 기기마다 스캔 빈도수가 꽤나 달라서..ㅜㅜ

자! 그럼 시작해봅시다!


회사에서 아래와 같은 요구 사항을 받았었는데요!

"앱이 백그라운드에 있어도 Bluetooth 태그를 계속 찾을 수 있게 만들어주세요!"

처음에는 어렵지 않을꺼라고 예상했지만........

iOS에서 백그라운드 Bluetooth 스캔은 Apple의 까다로운 백그라운드 정책 때문에 다소 복잡했어요,,ㅜㅜ

 

제일 컸던 부분은 iOS 13부터 시스템이 "내가 알아서 판단해줄게"라며 개입하기 시작해서

메모리나 배터리 상태, 앱 접속 빈도수, 권한 이슈 등 다양한 상황으로 인해 기기마다 스캔 기능이 실행되는게 천차 만별이었는데요🥺

그래서

  • "개발할 때는 잘 됐는데 출시하니 안 된다고 난리예요"
  • "며칠 후에 갑자기 안 되기 시작했어요"
  • "같은 코드인데 어떤 사용자는 되고 어떤 사용자는 안 돼요"

이런 피드백을 받았었어요.. 처음에는 제 폰이랑 테스트 폰에서는 이상이 없었기에 당황했던 기억이 나네요...

이번 글에서는 이처럼 알아야하는 개념이랑 주의 사항, 설정등을 위주로 작성하고 다음 글에서는 구현 코드를 중심으로 알아보겠습니다ㅎㅎ

 


Life Cycle

안정적인 백그라운드 동작을 만들려면 App Life CycleView Life Cycle을 모두 고려해야 해요.

그렇다면 이 두가지 Life Cycle이 백그라운드 스캔에서 각 어떤 부분을 담당할까요?

이전글중 Life Cycle에대해 간단하게 정리해둔 글이 있는데, 참고해주세용

+ 참고 문서

Managing your app’s life cycle

Preparing your UI to run in the background

 

App Life Cycle의 역할

App Life Cycle은 시스템과의 소통을 담당해요. 사용자가 홈 버튼을 누르거나 다른 앱으로 전환할 때, iOS 시스템이 우리 앱에게 "야, 이제 백그라운드로 들어간다!" 라고 알려주는 거죠.

 

이때 우리는

  • 백그라운드 권한을 시스템에 요청하고
  • 백그라운드 모드를 활성화하고
  • "잠깐, 아직 중요한 작업이 끝나지 않았어요!" 라고 시스템에 알려줘야 해요
// iOS 13+ Scene-based
func sceneDidEnterBackground(_ scene: UIScene) {
    // "시스템아, 이 Scene에서 블루투스 스캔 때문에 조금 더 시간이 필요해!"
    let taskId = UIApplication.shared.beginBackgroundTask(withName: "BluetoothScan") {
        // 시간 초과되면 정리 작업
    }
}

// iOS 12 이하 App-based  
func applicationDidEnterBackground(_ application: UIApplication) {
    // "시스템아, 블루투스 스캔 때문에 조금 더 시간이 필요해!"
    let taskId = UIApplication.shared.beginBackgroundTask(withName: "BluetoothScan") {
        // 시간 초과되면 정리 작업
    }
}

 

이를 고려하지 않으면, 앱이 백그라운드로 가면 스캔은 계속 되겠지만 특정 화면에서만 필요한 스캔인지, 전체 앱에서 필요한 스캔인지 구분할 수 없고 메모리 관리가 어려워져요!


View  Life Cycle의 역할

View  Life Cycle은 사용자의 화면 이동을 관리해요.

만약, 사용자가 태그 찾기 화면에서 스캔을 시작했는데, 갑자기 전화가 와서 통화 앱으로 넘어가거나, 카톡 메시지를 확인하러 다른 화면으로 이동할 수 있잖아요?

 

이때

  • 태그 찾기 화면(TagScanViewController)은 viewDidDisappear를 호출하고
  • 사용자는 여전히 앱에서 태그를 찾고 있기를 원할겁니다.

그럼 앱은 백그라운드에서 계속 스캔을 해야 하고 태그를 찾으면 사용자에게 알림으로 알려줘야합니다.

class TagScanViewController: UIViewController {
    override func viewDidDisappear(_ animated: Bool) {
        super.viewDidDisappear(animated)
        // 화면은 사라졌지만 스캔은 계속!
        // 백그라운드에서도 태그를 찾을 수 있도록 설정
        continueBluetoothScanInBackground()
    }
}

 

이를 고려하지 않으면, 카톡으로 넘어가는 순간 스캔이 멈추고 사용자가 앱으로 돌아와야만 스캔이 재개되게 되기때문에 사용자는 불편함을 겪게될 확률이 높습니다.

 

이렇게 두 라이프사이클을 함께 고려하면, 사용자가 어떤 상황에 있든 자연스럽고 끊김없는 경험을 제공할 수 있어요.

특히 Bluetooth 같은 하드웨어 기능은 시스템 리소스를 많이 사용하기 때문에, 언제 시작하고 언제 멈출지를 더 정확히 알아야 합니다!

때문에 두 Life Cycle 모두가 중요한 거죠! 

이 두 Life Cycle을 고려했으니, 백그라운드 기능 구현을 할때 꼭 알아야하는  Life Cycle상태들에대해서도 알아보겠습니다!


Baskground와 Suspended 상태

백그라운드 기능 구현에서는 BaskgroundSuspended 상태의 차이점을 아는 것이 중요합니다.

우선 상태에대한 개념을 한 번 쭉 훑어봅시다!

 

Background 상태

  • 앱이 백그라운드에서 실행 중인 상태
  • 제한된 시간 동안 코드 실행이 가능 (일반적으로 30초 이내)
  • 특별한 권한이 있으면 더 오래 실행 가능

Suspended 상태

  • 앱이 메모리에는 있지만 코드가 실행되지 않는 상태
  • 메모리 부족 시 시스템이 임의로 종료시킬 수 있음
  • CPU 사용량 0%

그렇다면 앱 전환 흐름은 어떻게 될까요?

사용자가 홈으로 나감 → Background (최대 30초) → Suspended (자동 전환)

위처럼 사용자가 홈으로 나가면 백그라운드 상태에서 30초 후 Suspended 모드로 자동으로 전환됩니다.

 


🤔 그러면 백그라운드 작업은 단 한 번만 작동하는 걸까요?

특별한 처리를 하지 않은 일반적인 상황이라면 맞아요. 하지만 Background 상태를 지속적으로 유지할 수 있는 방법이 있습니다.

아래에서 자세히 알아봅시다!


백그라운드 작업을 지속하는 방법

1. Background App Refresh

  • 시스템이 자동으로 앱을 깨워서 콘텐츠를 미리 업데이트
  • 사용자가 설정에서 끌 수 있음 (통제 불가능)
  • 로드할 데이터가 많을 때 유용

2. 특별한 백그라운드 권한 (UIBackgroundModes)

위 Background Modes에 없는 작업은 백그라운드에서 할 수 없고 전부 강제 중단됩니다.

각각의 옵션을 좀 더 자세히 살펴보겠습니다


 

Acts as a Bluetooth LE accessory: 내 앱을 BLE(Peripheral)처럼 광고해서 다른 기기에서 스캔/연결할 수 있음

  • 예)내 아이폰이 심박 센서처럼 광고해서 운동 기기(러닝머신, 스마트워치)가 실시간 심박수를 가져가도록 함.

Audio, AirPlay, and Picture in Picture: 오디오/ 영상 재상을 백그라운드에서도 유지

  • 예) Spotify, YouTube Music, 팟캐스트 앱

Background fetch: iOS가 주기적으로 앱을 깨워서 데이터 갱신(시간 보장 없음)

  • 예) 뉴스 앱에서 새 기사 받아오기, 이메일 앱이 새 메일 미리 가져오기

Background processing: BGTaskScheduler기반, 시스템이 적절할 때 무거운 작업 실행

  • 예) 사진 앱이 밤에 자동으로 얼굴 인식/ 정리, 건강앱이 걸음수 데이터 처리

External accessory communication: MFi 인증 기기(케이블/BT Classic)와 통신

  • 예) 신용카드 리더기 앱, 차량용 진단기와 연결하는 앱

Location updates: 백그라운드에서도 GPS/위치 이벤트 계속 수신

  • 예) 카카오맵 네비게이션, 러닝 기록 앱(Nike Run Club)

Push to Talk: 무전기처럼 양방향 음성 통신

  • 예) Zello 같은 위키토키 앱

Remote notifications: 푸시가 오면 앱을 깨워서 데이터 갱신 가능 (무음 푸시 포함)

  • 예) 메신저 앱이 푸시 오면 새 메시지 미리 다운로드

Uses Blutooth LE accessories: BLE Central 모드: 주변 기기 스캔/ 연결

  • 예) AirTag 탐지, 스마트 밴드 데이터 수집, 비콘 기반 체크인

Uses Nearby Interaction: UWB 기반 거리/ 방향 추적

  • 예) AirTag 근거리 위치 찾기("이 근처에 있습니다" 기능)

Voice over IP: VoIP 앱이 백그라운드에서 통화 수신 기능

  • 예) WhatsApp, FaceTime, Skype

제가 체크해 놓은 부분은 백그라운드에서 사용하는 옵션이에요!

옵션 선택은 간단하죠?

Background Modes에대한 설명은 Configuring background execution modes 공식 문서 참고해주세요!


3. Background Task 명시적 요청

beginBackgroundTask 메서드를 사용하여 "아직 중요한 작업이 끝나지 않았다"고 시스템에 알려주는 방법입니다.

let taskId = UIApplication.shared.beginBackgroundTask { 
    // 시간 초과 시 실행될 코드
}

🤔 beginBackgroundTask를 사용하면 백그라운드에서 무제한으로 작업 할 수 있나요??!

: beginBackgroundTask는 영구적인 백그라운드 실행을 위한 것이 아니라, 앱이 백그라운드로 전환되는 초기 몇 초 동안 안전하게 설정을 완료하기 위한 안전장치에요.

실제 지속적인 Bluetooth 스캔은 bluetooth-central 백그라운드 모드가 담당합니다!

정리해보자면, 특별한 백그라운드 권한이 없는데 백그라운드에서 작업을 실행해야한다면 beginBackgroundTask를 이용해서 안전하게 설정할 수 


 

beginBackgroundTask를 구현할때 주의할 점

beginBackgroundTask() 구현 타이밍과 endBackground() 호출이 정말 중요한데요!

func applicationDidEnterBackground(_ application: UIApplication) {
    var taskId: UIBackgroundTaskIdentifier = .invalid
    
    // 가능한 한 빨리 호출 (메서드 시작 부분)
    taskId = UIApplication.shared.beginBackgroundTask(withName: "BluetoothScan") {
        print("Background task expired, cleaning up...")
        // 반드시 정리 작업 필요
        UIApplication.shared.endBackgroundTask(taskId)
        taskId = .invalid
    }
    
    // Task 시작 실패 체크
    guard taskId != .invalid else {
        print("Failed to start background task")
        return
    }
    
    // 이후 실제 백그라운드 작업 수행
    startBluetoothScanning {
        // 작업 완료 시에도 Task 종료
        UIApplication.shared.endBackgroundTask(taskId)
        taskId = .invalid
    }
}

 

beginBackgroundTask()비동기적으로 처리되기때문에, 시스템이 권한을 부여하기 전에 앱이 suspended될 수 있고

늦게 호출하면 시스템이 권한 부여를 거부할 수도있기때문에 꼭 가능한 빨리 호출하는게 좋아요

endBackgroundTask()는 작업 완료시에 호출해주지 않으면 iOS가 계속 작업중이라고 생각해서 CPU 리소스를 계속 할당하게되고 이는 다른 앱들의 성능에 영향을 미치게 됩니다. 제대로 호출해주지 않으면 앱이 강제 종료까지 될 수 있어서 주의해야합니다!

 

자세한 내용은 링크 걸어둔 공식 문서를 참고해주세요!


🎧 음악 듣기나 게임에서 위치 정보를 사용할때는 지속적인 실행이 가능한건 왜 그런거야?

요약하자면 오디오와 위치(Always 권한) 정보는 백그라운드에서 지속적인 실행이 가능합니다.

위에서 백그라운드 작업을 지속하는 방법 중 특별한 백그라운드 권한이 있었죠? 그 부분이 여기에 해당됩니다.

이는 Apple에서 정한 정책인데, 오디오는 사용자가 음악 끊기는것을 싫어하기도 하고 보안 위험이 없고, 위치는 Always권한을 사용자가 주었을때는 지속적으로 실행되도록 하였습니다.

 

공식 문서도 살펴볼까요?

Enabling Background Sessions 문서 보면 아래와 같은 문구가 있습니다

오디오, 위치 업데이트 및 운동 처리 모드를 사용하면 앱에서 해당 백그라운드 세션을 실행할 수 있습니다. 앱은 세션을 포그라운드에서 시작해야 하지만 앱이 백그라운드로 전환하면 세션이 계속 실행됩니다. 

 

, 아래와 같은 UIBackgroundModes에서 오디오, 위치(Always 권한)등은 지속적인 실행이 가능합니다.

 

더 자세히 알고 싶다면 아래 공식 문서를 참고해주세요!

Configuring background execution modes

Configuring your app for media playback


 

그렇다면 블루투스 스캔 작업은 왜 지속적인 실행이 불가능 할까요?

블루투스 스캔 작업은 다음과 같은 이유로 제한적입니다

- 배터리 소모: 지속적인 스캔은 배터리를 많이 사용
- 보안 정책: 다른 기기들의 정보를 수집할 수 있어 까다로운 정책 적용
- 성능 최적화: 스캔 빈도를 시스템이 제어하여 전체 성능 향상

 

마지막으로 표로 간단히 정리해보겠습니다!

백그라운드 모드 지속 시간 실행 방식 조건 예시
audio 무제한 앱이 계속 실행 오디오 재생 중 스포티파이, 애플뮤직
location 무제한 앱이 계속 실행 Always 권한 + 위치 업데이트 구글맵, 피크민 블룸
voip 무제한 앱이 계속 실행 VoIP 소켓 연결 카카오톡 통화, 디스코드
bluetooth-central 제한적 무제한 시스템이 대신 처리 특정 서비스 스캔만 에어팟 연결, 태그 찾기

 


주요 제약 사항

백그라운드에서 Bluetooth 스캔을 구현할 때는 여러 제약사항들이 있는데요.

이런 제약들이 왜 있는지, 어떻게 해결해야 하는지 하나씩 자세히 알아보겠습니다!

서비스 UUID 필수

백그라운드에서는 배터리 절약을 위해 무차별적인 스캔이 불가능합니다. 

포그라운드에서는 아래와 같이 nil을 사용해서 모든 기기를 스캔할 수 있지만 백그라운드에서 nil을 사용하면 아무것도 찾을 수 없어요.

// 포그라운드: 모든 기기 스캔 가능
centralManager.scanForPeripherals(withServices: nil, options: nil)

 

아래와 같이 백그라운드에서는 반드시 특정 서비스 UUID를 지정해야 합니다.

// 실제 사용 예시들
struct BluetoothServices {
    // Apple의 표준 서비스들
    static let batteryService = CBUUID(string: "180F")
    static let deviceInformation = CBUUID(string: "180A") 
    
    // 커스텀 서비스 (실제 프로젝트에서)
    static let customTagService = CBUUID(string: "12345678-1234-5678-9ABC-DEF012345678")
    
    // 여러 서비스 동시 스캔
    static let scanServices = [batteryService, customTagService]
}

// 사용법
centralManager.scanForPeripherals(
    withServices: BluetoothServices.scanServices,
    options: [CBCentralManagerScanOptionAllowDuplicatesKey: false]
)

이처럼 해야하는 이유는 위에서 블루투스 스캔 작업이 제한된 이유와 동일합니다.

  • 배터리 보호: 모든 Bluetooth 기기를 찾으려면 엄청난 전력이 필요함. 주변에 있는 모든 기기를 계속 스캔하면 배터리가 금새 떨어짐
  • 개인정보 보호: 무차별 스캔으로 다른 사람들의 기기 정보를 수집하는 것을 방지. 악의적인 앱이 주변 기기들의 정보를 몰래 수집할 수 있음
  • 성능 최적화: 특정 서비스만 찾으면 CPU 사용량과 메모리 사용량을 크게 줄일 수 있음

스캔 빈도 제한

사실 시스템이 자동으로 스캔 간격을 조절하기때문에 개발자가 직접 제어할 수 없는 부분인데요.

하지만 완전히 제어할 수 없는 건 아닙니다! 몇 가지 방법으로 스캔 빈도에 영향을 줄 수 있는데, 자세한 관련 코드는 다음편에서 살펴보도록하고, 이번에는 포그라운드와 백그라운드의 스캔빈도가 어떻게 다른지만 알고 넘어가겠습니다ㅎㅎ

 

포그라운드 vs 백그라운드 스캔 빈도

포그라운드 상태에서는

  • 거의 실시간으로 스캔 결과를 받을 수 있음
  • 1초에 여러 번 스캔 결과가 올 수도 있음
  • 사용자가 앱을 적극적으로 사용하고 있다고 판단해서 높은 우선순위를 부여

백그라운드 상태에서는

  • 스캔 간격이 대폭 늘어남 (보통 수십 초에서 몇 분)
  • iOS가 배터리 상태, 다른 앱 사용량 등을 고려해서 동적으로 조절
  • 중요한 기기(이미 연결했던 기기 등)는 좀 더 자주 스캔

이는 단순한 제한이 아니라 전력 관리, 리소스 경쟁 방지, 사용자 경험 보호를 위한 시스템 차원의 정책이기때문에 스캔이 포그라운드 상태보단 덜 된다는 점을 사용자에게 잘 안내해야합니다.


연결 가능한 기기 수 제한

iOS 기기마다 동시에 연결할 수 있는 Bluetooth Peripheral 수에 한계가 있습니다.

  • iPhone/iPad: 일반적으로 7-8개의 기기와 동시 연결 가능
  • Apple Watch: 보통 2-3

이는 Bluetooth 칩셋의 하드웨어 한계로, 각 연결마다 칩셋 메모리에 연결 정보를 저장해야 하기 때문입니다.

 

제한된 연결 기기 문제를 개선하기 위해서는 연결 풀을 관리하여 중요한 기기(자주 사용하는 기기)는 계속 연결 유지하고, 덜 중요한 기기는 필요할 때만 연결하는 우선 순위 기반으로 관리하도록 구성하면 좋습니다!

class BluetoothConnectionManager {
    private let maxConnections = 7
    private var connectedDevices: [CBPeripheral] = []
    
    func connectToDevice(_ peripheral: CBPeripheral) {
        if connectedDevices.count >= maxConnections {
            disconnectOldestDevice()
        }
        centralManager.connect(peripheral, options: nil)
    }
    
    // CBCentralManagerDelegate에서 실제 연결 성공 시 배열 업데이트
    func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
        connectedDevices.append(peripheral)
    }
    
    func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
        connectedDevices.removeAll { $0 == peripheral }
    }
}

 

주의: 실제 연결 성공/실패는 CBCentralManagerDelegate에서 처리해야 배열과 실제 상태가 일치합니다!


배터리 최적화

저전력 모드(Low Power Mode) 또는 배터리 부족 상황에서는 iOS가 백그라운드 Bluetooth 동작을 더욱 강하게 제한합니다.

  • 80% 이상 → 정상 동작
  • 20% 이하 → 스캔 간격 늘어남
  • 5% 이하 → 대부분의 작업 중단

따라서 아래 예시들처럼 앱에서 배터리 상태를 감지하고, 전략을 조정하는 것이 좋습니다.

특히 저전력 모드일 때는 배터리 잔량과 관계없이 제한이 강화됩니다.

 

메모리 리크 방지

func monitorBatteryState() {
    UIDevice.current.isBatteryMonitoringEnabled = true
    
    NotificationCenter.default.addObserver(
        forName: UIDevice.batteryStateDidChangeNotification,
        object: nil,
        queue: .main
    ) { [weak self] _ in  // weak self 추가
        self?.adjustScanStrategy()
    }
}

 

저전력 모드 체크

private func adjustScanStrategy() {
    let batteryLevel = UIDevice.current.batteryLevel
    let isLowPowerMode = ProcessInfo.processInfo.isLowPowerModeEnabled
    
    if isLowPowerMode || batteryLevel < 0.2 {
        reduceBackgroundActivity()
    } else if batteryLevel > 0.8 {
        enableNormalActivity()
    }
}

 

Observer 해제 코드

BluetoothManager 객체 생성할때 등록한 Observer 해제

deinit {
    NotificationCenter.default.removeObserver(self)
    UIDevice.current.isBatteryMonitoringEnabled = false
}

 


주의할 점

1. Bluetooth 스캔하려면 무조건 Always 위치 권한이 있어야 할까?

네! 그렇습니다! 

Apple에서 Bluetooth 자체가 위치 좌표(GPS처럼 위도·경도)를 직접 제공하지는 않지만, 위치 추적에 활용될 수 있기 때문에 Apple이 위치 권한과 묶어두었습니다.

따라서 백그라운드에서 Bluetooth 스캔을 한다는건 위치 추적 가능성이 있다고 간주하기때문에 Always 권한이 있을때만 제대로 된 백그라운드 스캔을 할 수 있도록 허용합니다.

위치 권한 포그라운드 스캔 백그라운드 스캔
없음/거부 정상 동작 거의 안 됨
WhenInUse 정상 동작 매우 제한적
Always 정상 동작 그나마 동작

 


 2. 디버깅 시 주의

시뮬레이터 vs 실제 기기

  • 시뮬레이터에서는 거의 모든 것이 잘 동작함 (제약이 거의 없음)
  • 실제 기기에서는 완전히 다른 결과 나올 수 있음
  • 반드시 실제 iPhone으로 테스트 필요

기기별/iOS 버전별 차이

  • iPhone 12 vs iPhone 15: 칩셋 성능 차이로 스캔 빈도 다름
  • iOS 15 vs iOS 17: 백그라운드 정책 변화
  • 사용자마다 다른 설정: 저전력 모드, 백그라운드 앱 새로고림 등

이처럼 같은 코드라도 iOS 버전, 기종, 배터리 상태마다 동작이 달라지기때문에 꼭 아래와같이 다양한 방법으로 확인해보는게 좋습니다!

  • 여러 기기에서 테스트 (구형/신형)
  • 배터리 상태별 테스트 (100%, 50%, 20%, 저전력모드)
  • 시간대별 테스트 (하루 종일 방치 후 확인)
  • 다른 사용자 환경에서 테스트

마무리하며..

iOS 백그라운드 Bluetooth 스캔은 까다로운 제약이 많습니다. 하지만 이 제약들은 단순한 제한이 아니라, 사용자 배터리 보호, 개인정보 보안, 시스템 안정성을 위한 Apple의 설계 철학이라고 볼 수 있겠죠..?

 

요약해보면 아래와 같습니다.

  • Always 위치 권한은 사실상 필수
  • UUID 입력 필수
  • 백그라운드 스캔은 포그라운드보다 최대 55배 느림
  • 사용자 기대치 관리가 중요
  • 시뮬레이터가 아닌 실제 기기에서 장기 테스트 필수

다음 2탄에서는 이런 제약사항들을 고려한 실제 구현 코드를 친근하게 단계별로 알아보겠습니다. 함정을 피해서 안정적으로 동작하는 코드를 만들어봐요!


 

이외 참고 문서