Petnow LogoPetnow
iOS SDKUI 모듈

기본 사용법

CameraView를 앱에 통합하고 생체 데이터를 캡처하는 단계별 가이드.


시작하기 전에

이 가이드를 시작하기 전에 시작하기를 완료하세요. Petnow API 키 발급 및 SPM 패키지 설치가 필수입니다.

이 가이드에서는 PetnowUI 모듈의 핵심 컴포넌트인 CameraViewCameraViewModel을 사용하여 반려동물 코무늬/얼굴 촬영 기능을 앱에 통합하는 방법을 단계별로 안내합니다.

CameraViewModel 생성

종류와 촬영 목적을 설정하여 ViewModel을 생성합니다.

카메라 초기화

라이선스 검증 및 캡처 세션을 연결합니다.

촬영 결과 처리

성공/실패 콜백을 처리합니다.

에러 처리

초기화 에러를 올바르게 처리합니다.

추가 기능

진행률/상태 관찰, 카메라 전환, Detection 재실행을 사용합니다.


Step 1: CameraViewModel 생성

CameraViewModel은 카메라 세션과 탐지 로직을 관리하는 핵심 객체입니다. SwiftUI에서는 @StateObject로 생성합니다.

import SwiftUI
import PetnowUI

struct PetCameraView: View {
    @StateObject private var cameraViewModel: CameraViewModel 
    
    init(species: Species) {
        _cameraViewModel = StateObject(wrappedValue: CameraViewModel( 
            species: species,                       // .dog 또는 .cat
            cameraPurpose: .forRegisterFromProfile  // 촬영 목적
        )) 
    }
    
    var body: some View {
        // TODO: 카메라 화면으로의 네비게이션 구현
    }
}

파라미터 이해하기

species - 반려동물 종류

public enum Species {
    case dog  // 강아지 코무늬 촬영
    case cat  // 고양이 얼굴 촬영
}

cameraPurpose - 촬영 목적

public enum CameraPurpose {
    case forRegisterFromProfile  // 프로필 등록용
    case appendNose              // 기존 프로필에 비문 추가 (추후 지원 예정)
    case forSearch               // 검색/식별용
    case forWitness              // 제보/검증용
}

촬영 목적에 따른 차이

목적에 따라 필요한 이미지 수가 다릅니다:

  • forRegisterFromProfile: 여러 장의 이미지 필요
  • forSearch, forWitness: 빠른 검색을 위한 최소 장 수 이미지만 사용

appendNose에 대해

appendNose는 기존 등록된 반려동물에 추가 비문을 등록하는 용도입니다. 현재 API에서는 지원하지 않으며, 추후 지원 예정입니다.


Step 2: 카메라 초기화

captureSessionId 전달 필수

카메라 초기화 시 서버에서 발급받은 captureSessionId를 전달해야 합니다. 시작하기에서 세션 생성 방법을 확인하세요.

initializeCamera() 메서드를 호출하여 카메라를 초기화하고 라이선스를 검증합니다. 초기화가 완료될 때까지 로딩 화면을 표시하는 것이 좋습니다.

import SwiftUI
import PetnowUI

struct CameraScreenView: View {
    @ObservedObject var cameraViewModel: CameraViewModel
    @Environment(\.dismiss) private var dismiss
    @State private var captureSessionId: UUID?
    
    var body: some View {
        ZStack {
            // 초기화 중일 때 로딩 화면 표시
            if cameraViewModel.isInitialized {
                CameraView(viewModel: cameraViewModel)
            } else {
                CameraLoadingView()
            }
        }
        .task {
            await initializeCamera()
        }
    }
    
    private func initializeCamera() async {
        do {
            // 1. 서버에서 캡처 세션 생성
            captureSessionId = try await createCaptureSessionFromServer()
            
            guard let sessionId = captureSessionId else {
                print("세션 ID 생성 실패")
                dismiss()
                return
            }

            // 2. 카메라 초기화
            try await cameraViewModel.initializeCamera( 
                licenseInfo: LicenseInfo( 
                    apiKey: "YOUR_API_KEY", 
                    isDebugMode: false  // deprecated: 항상 false
                ), 
                initialPosition: .back,  // 후면 카메라 사용
                captureSessionId: sessionId  // 서버에서 생성된 세션 ID
            ) { result in
                // 촬영 결과를 처리하는 콜백 (Step 3에서 구현)
                print("촬영 완료: \(result)")
            }
            // await가 반환되면 isInitialized가 true가 되어 로딩 화면이 사라집니다
        } catch {
            print("카메라 초기화 실패: \(error.localizedDescription)")
            dismiss()
        }
    }
    
    // 서버에서 캡처 세션 생성
    private func createCaptureSessionFromServer() async throws -> UUID {
        // TODO: 실제 서버 API 호출 구현
        return UUID()
    }
}
    

initializeCamera 파라미터

licenseInfo - API 키와 환경 설정

public struct LicenseInfo {
    let apiKey: String      // Petnow API 키
    let isDebugMode: Bool   // deprecated: 항상 false
}

initialPosition - 초기 카메라 위치

.back   // 후면 카메라 (권장)
.front  // 전면 카메라

captureSessionId - 캡처 세션 ID

let captureSessionId: UUID  // 서버 API로 생성된 세션 ID

callback - 촬영 결과를 받는 콜백 함수

typealias CameraResultCallback = (_ result: CameraResult) -> Void

initializeCamera()는 비동기 함수이므로 반드시 await와 함께 호출해야 합니다.

SwiftUI에서는 .task modifier를 사용하는 것이 권장됩니다.

함수가 끝나면 카메라 초기화가 완료된 것이며, CameraViewModelisInitialized 프로퍼티도 true로 설정됩니다.


Step 3: 촬영 결과 처리

Step 2에서 initializeCamera()의 콜백으로 CameraResult가 전달됩니다. 이 콜백 내부를 구현합니다.

try await cameraViewModel.initializeCamera(
    licenseInfo: licenseInfo,
    initialPosition: .back,
    captureSessionId: sessionId
) { result in
    switch result { 
    case .success(let fingerprintImages, let appearanceImages):
        // 성공: 이미지 URL 배열 사용
        print("코무늬: \(fingerprintImages.count)장")
        print("외형: \(appearanceImages.count)장")
        
        // 서버에 업로드하거나 다음 화면으로 이동
        uploadImages(fingerprintImages, appearanceImages)
        
    case .fail:
        // 실패: 재시도하거나 화면 dismiss
        showRetryOrCancelAlert() 
    }
}

실패 시 서버 세션 관리

CameraResult.fail을 수신하면 서버의 캡처 세션은 아직 열린 상태입니다.

  • 재시도: cameraViewModel.startDetection()을 호출하여 동일 세션에서 촬영을 다시 시작
  • 종료: 카메라 화면을 닫고 이전 화면으로 돌아감

재시도하지 않고 화면을 닫으면, Petify Console에서 해당 세션이 "처리 중"으로 표시될 수 있습니다. 서버가 약 5분 후 자동으로 세션을 종료(ABORTED) 처리합니다.

// Fail 시 재시도/종료 처리 예시
private func showRetryOrCancelAlert() {
    // SwiftUI Alert 또는 UIKit AlertController
    // "재시도" → cameraViewModel.startDetection()
    // "종료"  → dismiss()
}

CameraResult 타입

public enum CameraResult {
    case success(
        fingerprintImages: [URL],  // 코무늬/지문 이미지 (파일 URL)
        appearanceImages: [URL]    // 외형 이미지 (파일 URL)
    )
    case fail  // 촬영 실패
}

이미지 URL 사용하기

촬영된 이미지는 앱의 임시 디렉토리에 저장되며, 다음과 같이 사용할 수 있습니다:

// 이미지를 UIImage로 변환
if let image = UIImage(contentsOfFile: fingerprintImages[0].path) {
    imageView.image = image
}

// Data로 변환하여 서버에 업로드
if let imageData = try? Data(contentsOf: fingerprintImages[0]) {
    await uploadToServer(imageData)
}

이미지 저장 위치

촬영된 이미지는 앱의 임시 디렉토리에 저장됩니다. 필요시 다른 위치로 복사하거나 서버에 업로드한 후 삭제하세요.


Step 4: 에러 처리

initializeCamera()는 다양한 에러를 throw할 수 있습니다. 각 에러에 맞는 처리를 해주세요.

private func initializeCamera() async {
    do {
        try await cameraViewModel.initializeCamera( /* ... */ ) { /* ... */ }
    } catch PetnowUIError.invalidLicense(let underlyingError) {
        // 라이선스 검증 실패
        showError("유효하지 않은 API 키입니다.\n\(underlyingError.localizedDescription)")
        
    } catch PetnowUIError.permissionDenied(let message) {
        // 카메라 권한 거부
        showError("카메라 권한이 필요합니다.\n설정에서 권한을 허용해주세요.")
        showSettingsAlert()
        
    } catch {
        // 기타 에러
        showError("카메라 초기화 실패: \(error.localizedDescription)")
    }
    
    showCamera = false
}

private func showError(_ message: String) {
    // 에러 메시지 표시 로직
    print("❌ \(message)")
}

private func showSettingsAlert() {
    // 설정 앱으로 이동하는 Alert 표시
    if let url = URL(string: UIApplication.openSettingsURLString) {
        UIApplication.shared.open(url)
    }
}

PetnowUIError 타입

public enum PetnowUIError: Error {
    case invalidLicense(underlyingError: Error)  // API 키 오류
    case permissionDenied(message: String)       // 권한 거부
}

권한 에러 처리 필수

permissionDenied 에러가 발생하면 사용자를 설정 앱으로 안내해야 합니다. 그렇지 않으면 사용자가 카메라를 사용할 수 없습니다.


Step 5: 추가 기능

CameraViewModel은 실시간 촬영 상태를 @Published 프로퍼티로 제공합니다. 이를 활용하여 커스텀 UI를 만들 수 있습니다.

주요 @Published 프로퍼티

// 탐지 상태 (1초마다 업데이트)
@Published public var detectionStatus: DetectionStatus

// 진행률 (0-100)
@Published public var currentDetectionProgress: Int

// 카메라 권한 상태
@Published public var cameraPermissionStatus: AVAuthorizationStatus

// 탐지된 영역 (정규화된 좌표 0.0-1.0)
@Published public var detectedObjectNormalizedRect: CGRect?

// 현재 카메라 방향(전면, 후면)
@Published public var currentCameraPosition: AVCaptureDevice.Position

// 카메라 전환 버튼 활성화 여부
@Published public var isSwitchButtonEnabled: Bool

DetectionStatus 타입

public enum DetectionStatus {
    case noObject                           // 대상 미탐지
    case processing                         // 탐지 진행 중
    case detected                          // 탐지 완료
    case failed(DetectionFailureReason)    // 실패 (사유 포함)
}

public enum DetectionFailureReason {
    case error                      // 오류
    case tooBright                  // 너무 밝음
    case tooDark                    // 너무 어두움
    case noseNotFound               // 코 탐지 실패
    case notFrontFace               // 정면이 아님
    case notFrontCatFaceHor         // 고양이 얼굴 수평 방향 불일치
    case notFrontCatFaceTop         // 고양이 얼굴이 너무 위로 향함
    case notFrontCatFaceBottom      // 고양이 얼굴이 너무 아래로 향함
    case tooFarAway                 // 너무 멀음
    case tooClose                   // 너무 가까움
    case notFrontNoseTop            // 코가 너무 위로 향함
    case tooBlurred                 // 흔들림
    case shadowDetected             // 그림자 감지
    case glareDetected              // 빛 반사 감지
    case motionBlurDetected         // 움직임 흔들림 감지
    case defocusedBlurDetected      // 초점 불일치 감지
    case notFrontNose               // 코가 정면이 아님
    case furDetected                // 털 감지
    case humanFaceDetected          // 사람 얼굴 감지
    case fakeDetected               // 가짜 사진 감지 (예: 모니터 화면)
    case unexpected                 // 예상치 못한 오류
}

전/후면 카메라 전환

if cameraViewModel.isSwitchButtonEnabled {
    cameraViewModel.switchCamera() 
}

isSwitchButtonEnabledfalse인 경우 해당 기기에서 전면 카메라를 지원하지 않거나, 전환 준비가 안 된 상태입니다.

Detection 재실행

현재 세션의 Detection을 처음부터 다시 시작할 수 있습니다.

cameraViewModel.startDetection() 

이 메서드를 호출하면 탐지가 처음부터 재개됩니다.


UIKit에서 사용하기

UIKit 앱에서는 UIHostingController를 사용하여 CameraView를 통합합니다.

class PetCameraViewController: UIViewController {
    private var cameraViewModel: CameraViewModel!
    private var hostingController: UIHostingController<CameraView>?
    
    override func viewDidLoad() {
        super.viewDidLoad()
        
        // CameraViewModel 생성
        cameraViewModel = CameraViewModel(
            species: .dog,
            cameraPurpose: .forRegisterFromProfile
        )
        
        // CameraView를 UIHostingController로 래핑
        let cameraView = CameraView(viewModel: cameraViewModel)
        hostingController = UIHostingController(rootView: cameraView)
        
        // Child View Controller로 추가
        guard let hostingController = hostingController else { return }
        addChild(hostingController)
        view.addSubview(hostingController.view)
        hostingController.view.frame = view.bounds
        hostingController.didMove(toParent: self)
    }
    
    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
        initializeCamera()
    }
    
    private func initializeCamera() {
        Task {
            do {
                let sessionId = try await createCaptureSessionFromServer()
                
                try await cameraViewModel.initializeCamera(
                    licenseInfo: LicenseInfo(apiKey: "YOUR_API_KEY", isDebugMode: false),
                    initialPosition: .back,
                    captureSessionId: sessionId
                ) { [weak self] result in
                    DispatchQueue.main.async {
                        self?.handleCameraResult(result)
                    }
                }
            } catch {
                print("초기화 실패: \(error)")
            }
        }
    }
    
    private func handleCameraResult(_ result: CameraResult) {
        switch result {
        case .success(let fingerprints, let appearances):
            print("촬영 성공: \(fingerprints.count)장")
        case .fail:
            print("촬영 실패")
        }
    }
    
    deinit {
        cameraViewModel?.stopDetection()
    }
}

UIKit 사용 시 주의사항

콜백은 백그라운드 스레드에서 호출될 수 있으므로 UI 업데이트는 반드시 메인 스레드에서 수행하세요.


모범 사례

1. API 키 안전하게 관리하기

// 나쁜 예: 코드에 하드코딩
let apiKey = "sk_live_abc123..."

// 좋은 예: Info.plist 또는 환경변수 사용
extension Bundle {
    var petnowAPIKey: String {
        guard let key = infoDictionary?["PETNOW_API_KEY"] as? String,
              !key.isEmpty else {
            fatalError("PETNOW_API_KEY not configured in Info.plist")
        }
        return key
    }
}

// 사용
LicenseInfo(apiKey: Bundle.main.petnowAPIKey, isDebugMode: false)

2. 리소스 정리하기

// SwiftUI
struct PetCameraView: View {
    @StateObject private var cameraViewModel: CameraViewModel
    
    var body: some View {
        CameraView(viewModel: cameraViewModel)
            .onDisappear {
                // 화면이 사라질 때 리소스 정리
                cameraViewModel.stopDetection()
            }
    }
}

// UIKit
deinit {
    cameraViewModel?.stopDetection()
}

3. 권한 사전 확인하기 (선택사항)

import AVFoundation

func checkCameraPermission() async -> Bool {
    let status = AVCaptureDevice.authorizationStatus(for: .video)
    
    switch status {
    case .authorized:
        return true
        
    case .notDetermined:
        // 권한 요청
        return await AVCaptureDevice.requestAccess(for: .video)
        
    case .denied, .restricted:
        // 설정 앱으로 안내
        showPermissionAlert()
        return false
        
    @unknown default:
        return false
    }
}

// 카메라 표시 전에 확인
if await checkCameraPermission() {
    showCamera = true
} else {
    showError("카메라 권한이 필요합니다")
}

4. isDebugMode (Deprecated)

isDebugMode는 deprecated되었습니다. 항상 false를 전달하세요.


문제 해결

Q. 카메라가 초기화되지 않아요

A. 다음을 확인하세요:

  1. API 키가 올바른지 확인
  2. Info.plistNSCameraUsageDescription 추가 여부
  3. 실제 디바이스에서 테스트 (시뮬레이터는 카메라 미지원)

Q. 촬영이 완료되지 않아요

A. 다음을 시도하세요:

  1. 밝은 곳에서 촬영
  2. 카메라와 반려동물 사이 적정 거리 유지 (30-50cm)
  3. 반려동물이 움직이지 않도록 안정적으로 촬영

Q. 촬영 실패 후 화면이 멈춤 상태로 남아요

A. CameraResult.fail을 수신한 후 UI 처리를 하지 않으면 카메라 화면이 멈춤 상태로 남습니다. 반드시 startDetection()으로 재시도하거나, 화면을 dismiss()하여 이전 화면으로 돌아가는 처리를 구현하세요. 자세한 내용은 Step 3를 참고하세요.

Q. 이미지 URL을 어떻게 사용하나요?

A. 촬영된 이미지는 임시 디렉토리에 저장됩니다:

case .success(let fingerprintImages, let appearanceImages):
    // 이미지 읽기
    if let firstImage = UIImage(contentsOfFile: fingerprintImages[0].path) {
        // 이미지 사용
    }
    
    // 또는 Data로 변환
    if let imageData = try? Data(contentsOf: fingerprintImages[0]) {
        // Data 사용 (서버 업로드 등)
    }

더 많은 문제 해결 방법은 트러블슈팅 문서를 참고하세요.

다음 단계

기본 사용법을 익혔다면 다음 문서를 참고하세요:

On this page