[SwiftUI] AVFoundation을 통한 카메라 기능 만들기

양재현·2025년 8월 11일

AVFoundation

  • AVFoundation은 사진, 동영상, 오디오 등을 재생/캡쳐/처리 하는데 있어 만능 도구라 볼 수 있다.

  • 그 중 카메라로 캡쳐하는 기능을 만들거라 AVFoundation Capture subsystem을 사용해보겠다.

Capture Architecture

캡쳐 아키텍처에 대한 이해가 있으면 좋기 때문에 간단히 설명을 해보자면,
캡쳐 아키텍처에서 중요한 부분은 Session, Input, Output이다.

AVCaptureSession : 하나 이상의 Input을 하나 이상의 output과 연결한다.
AVCaptureDeviceInput : iOS 기기나 Mac에 내장된 카메라/마이크와 같은 캡처 장치를 포함한 미디어 소스다.
AVCaptureOutput : Input에서 미디어를 수집하여 디스크에 기록된 동영상 파일이나 라이브 처리에 사용할 수 있는 원시 픽셀 버퍼와 같은 유용한 데이터를 생성한다.

비유하자면, Session은 빨대이며 Input과 Output은 각각의 구멍이다.

카메라 만들기

우선 폴더 형태는 CameraFeature + ContentView로 이루어져 있다.

Info.plist 설정

TARGETS -> Info 에 들어가서 Privacy - Camera Usage Description 을 추가해주고 카메라 권한 허용하라는 내용을 적어준다.

ContentView

import SwiftUI

struct ContentView: View {
    @State var isPresented: Bool = false
    
    var body: some View {
        VStack {
            Button("카메라 열기") {
                isPresented = true
            }
        }
        .sheet(isPresented: $isPresented) {
            CameraView()
        }
    }
}

우선 ContentView에서는 sheet를 통해 카메라 뷰를 띄울거라 아주 간단하게 작성해봤다.

CameraView

import SwiftUI

struct CameraView: View {
    @StateObject private var viewModel: CameraViewModel = CameraViewModel()
    @Environment(\.dismiss) private var dismiss
    
    var body: some View {
        ZStack {
            //A : 캡쳐된 이미지 (사진 찍혔을때)
            if let image = viewModel.capturedImage {
                Image(uiImage: image)
                    .resizable()
                    .scaledToFit()
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
                //다시 찍기 버튼
                    .overlay(alignment: .bottom) {
                        Button("다시 찍기") {
                            viewModel.retake()
                        }
                    }
            }
            //B : 카메라 프리뷰 (카메라 렌즈로 보이는 뷰)
            else {
                CameraPreviewView(session: viewModel.captureSession)
                //카메라 전환 버튼
                    .overlay(alignment: .bottomLeading) {
                        Button("카메라 전환") {
                            viewModel.switchCamera()
                        }
                    }
                //사진 촬영 버튼
                    .overlay(alignment: .bottom) {
                        Button("사진 촬영") {
                            viewModel.takePhoto()
                        }
                    }
            }
        }
        //카메라 권한 없을때 알럿
            .alert("카메라 권한이 필요해요", isPresented: $viewModel.isAlertPresented) {
                Button("취소") { dismiss() }
                Button("설정으로 이동") {
                    viewModel.goSetting()
                    dismiss()
                }
            }
            .onAppear{
                Task { await viewModel.checkCameraAuth() }
            }
    }
}

카메라가 띄워질 뷰다. 카메라로 찍힌 사진을 보여줄 A뷰와 카메라 렌즈로 실제 세상을 보여줄 B뷰로 나누었다.

그리고 사진을 찍었어도 다시 찍기 위한 버튼을 A뷰에 뒀고, 카메라 전환과 사진 촬영을 위한 버튼을 B뷰에 뒀다.

또한 카메라 권한이 없다면 카메라가 안켜지기에 그 부분을 처리할 alert을 뒀다.

마지막으로 카메라 뷰가 나타날때 권한 체크를 위한 함수까지 뒀다.

CameraViewModel

import SwiftUI
import AVFoundation

class CameraViewModel: NSObject, ObservableObject {
    @Published var capturedImage: UIImage? //캡쳐된 이미지
    @Published var isAlertPresented: Bool = false //카메라 권한 없을때 띄울 alert
    
    let captureSession = AVCaptureSession() //capture세션
    
    private let photoOutput = AVCapturePhotoOutput() //cature output
    private var currentPosition: AVCaptureDevice.Position = .back //카메라 전면 or 후면 위치
    private let discoverySession = AVCaptureDevice.DiscoverySession( // DiscoverySession: 기기 목록 필터링
        deviceTypes: [.builtInTrueDepthCamera, .builtInDualCamera, .builtInWideAngleCamera],
        mediaType: .video,
        position: .unspecified
    )
    
    var isAuthorized: Bool {  //카메라 권한 있는지 계산 프로퍼티의 (get은 읽어들일때마다 실행)
        get async {
            let status = AVCaptureDevice.authorizationStatus(for: .video)
            
            var isAuthorized = status == .authorized
            
            if status == .notDetermined {
                isAuthorized = await AVCaptureDevice.requestAccess(for: .video) //카메라 권한 요청
            }
            
            return isAuthorized
        }
    }
    
    //MARK: - 카메라 권한 확인
    func checkCameraAuth() async {
        //A : 카메라 권한이 없을시 alert 띄우기
        guard await isAuthorized else {
            await MainActor.run {
                isAlertPresented = true
            }
            return
        }
        //B : 카메라 권한이 있을시 캡쳐 세션 세팅
        setUpCaptureSession()
    }
    
    //MARK: - 권한 설정으로 이동
    func goSetting() {
        if let url = URL(string: UIApplication.openSettingsURLString) {
            UIApplication.shared.open(url)
        }
    }
    
    //MARK: - 사진 촬영
    func takePhoto() {
        let settings = AVCapturePhotoSettings()
        settings.flashMode = .auto
        photoOutput.capturePhoto(with: settings, delegate: self)
    }
    
    //MARK: - 다시 찍기
    func retake() {
        capturedImage = nil
        Task {
            captureSession.startRunning()
        }
    }
    
    //MARK: - 카메라 전환
    func switchCamera() {
        // <---- 설정변경 시작 ---->
        captureSession.beginConfiguration()
        
        if let currentInput = captureSession.inputs.first as? AVCaptureDeviceInput {
            captureSession.removeInput(currentInput)  // 현재 Input 제거
            currentPosition = (currentInput.device.position == .back) ? .front : .back // 반대쪽 카메라 위치 선택
            
            if let newInput = try? AVCaptureDeviceInput(device: bestDevice(in: currentPosition)),  // 새로운 Input 설정
               captureSession.canAddInput(newInput) {
                captureSession.addInput(newInput)
            }
        }
        captureSession.commitConfiguration()
        // <---- 설정변경 완료 ---->
    }
    
    //MARK: - 캡쳐 세션 세팅
    private func setUpCaptureSession() {
        // <---- 설정변경 시작 ---->
        captureSession.beginConfiguration()
        guard let videoDeviceInput = try? AVCaptureDeviceInput(device: bestDevice(in: currentPosition)),
              captureSession.canAddInput(videoDeviceInput)
        else { return }
        captureSession.addInput(videoDeviceInput)
        
        guard captureSession.canAddOutput(photoOutput) else { return }
        captureSession.sessionPreset = .photo
        captureSession.addOutput(photoOutput)
        captureSession.commitConfiguration()
        // <---- 설정변경 완료 ---->
        
        captureSession.startRunning()
    }
    
    // MARK: - 최적 카메라 선택
    private func bestDevice(in position: AVCaptureDevice.Position) -> AVCaptureDevice {
        let devices = self.discoverySession.devices
        guard !devices.isEmpty else { fatalError("Missing capture devices.")}
        return devices.first(where: { device in device.position == position })!
    }
}

// MARK: - AVCapturePhotoCaptureDelegate(사진 촬영 후 이미지화를 위한)
extension CameraViewModel: AVCapturePhotoCaptureDelegate {
    func photoOutput(_ output: AVCapturePhotoOutput,
                     didFinishProcessingPhoto photo: AVCapturePhoto,
                     error: Error?) {
        guard let imageData = photo.fileDataRepresentation(),
              let image = UIImage(data: imageData) else { return }
        
        DispatchQueue.main.async {
            self.capturedImage = image
        }
        captureSession.stopRunning()
    }
}

코드가 굉장히 많긴 하지만 차근차근 보자.
일단 CameraView에서 checkCameraAuth() 함수가 호출 될 때부터 시작된다.



//MARK: - 카메라 권한 확인
    func checkCameraAuth() async {
        //A : 카메라 권한이 없을시 alert 띄우기
        guard await isAuthorized else {
            await MainActor.run {
                isAlertPresented = true
            }
            return
        }
        //B : 카메라 권한이 있을시 캡쳐 세션 세팅
        setUpCaptureSession()
    }
  • 카메라 권한이 없다면 isAlertPresented를 true로 만들어 alert을 띄워준다.

  • 카메라 권한이 있다면 캡쳐 세션을 세팅해주는 setUpCaptureSession() 메서드를 호출한다.

  • isAuthorized 는 계산프로퍼티로 카메라 권한 상태를 관리하며 권한이 정해지지 않았으면 사용자에게 권한요청을 보낸다

다음으로 setUpCaptureSession() 메서드를 살펴보겠다.


  //MARK: - 캡쳐 세션 세팅
    private func setUpCaptureSession() {
        // <---- 설정변경 시작 ---->
        captureSession.beginConfiguration()
        guard let videoDeviceInput = try? AVCaptureDeviceInput(device: bestDevice(in: currentPosition)),
              captureSession.canAddInput(videoDeviceInput)
        else { return }
        captureSession.addInput(videoDeviceInput)

        guard captureSession.canAddOutput(photoOutput) else { return }
        captureSession.sessionPreset = .photo
        captureSession.addOutput(photoOutput)
        captureSession.commitConfiguration()
        // <---- 설정변경 완료 ---->
        
        captureSession.startRunning() 
    }

이 메서드는 앞서 말했던 Session을 설정하는 기능을 한다.

beginConfiguration()commitConfiguration()은 각각 세션의 설정변경 시작을 알리고 변경된 설정을 저장하는 역할을 해주며, 세션 설정을 변경해줄때는 꼭 이 두 가지 메서드 사이에서 해줘야한다!

  • 먼저 세션에 Input을 붙이기 위해서는 카메라 장치(AVCaptureDevice)를 가져와야하는데 나는 bestDevice(in: )라는 메서드를 구현해 사용했다.
    그후, 세션에 Input을 붙일 수 있는지 확인하고 붙이는 작업을 했다.

  • 그리고 세션에 Output을 붙이기 위해서는 사진을 찍어 내보내는 장치(AVCapturePhotoOutput의 인스턴스)가 필요해 넣어주었고, sessionPreset = .photo를 통해 사진 촬영에 적합한 화질을 설정해주었다. 그후, 세션에 Output을 붙이는 작업을 해주었다.

(빨대에 구멍이 하나만 있으면 안되니 Input, Output 모두 뚫어줘야 한다)

  • Input, Output을 모두 세션에 붙여 세션 설정이 완료 되었으므로, startRunning()을 통해 세션을 실행해준다.

여기까지만 해줘도 카메라를 시동시킬 수 있는 것이다 !!

다음으로는 카메라 전환 기능(전면/후면) switchCamera() 메서드를 보겠다.


//MARK: - 카메라 전환
    func switchCamera() {
        // <---- 설정변경 시작 ---->
        captureSession.beginConfiguration()

        if let currentInput = captureSession.inputs.first as? AVCaptureDeviceInput {
            captureSession.removeInput(currentInput)  // 현재 Input 제거
            currentPosition = (currentInput.device.position == .back) ? .front : .back // 반대쪽 카메라 위치 선택
            
            if let newInput = try? AVCaptureDeviceInput(device: bestDevice(in: currentPosition)),  // 새로운 Input 설정
               captureSession.canAddInput(newInput) {
                captureSession.addInput(newInput)
            }
        }
        captureSession.commitConfiguration()
        // <---- 설정변경 완료 ---->
    }

카메라를 전환한다는 것은, 세션 설정을 다시 해줘야 한다는 것이다.
그래서 처음 세션 설정을 해준것처럼 beginConfiguration() 과 commitConfiguration() 사이에서 세션 변경을 해준다.

  • 먼저 세션에서 붙여져 있는 Input 장치를 떼어준다.
  • 현재 카메라의 전면/후면 위치에 따라 반대로 바꿔준다.
  • 그 후, 아까처럼 미리 만들어둔 bestDevice(in: ) 메서드로 새로운 카메라 Input 장치를 세션에 붙여주고 세션 변경을 완료해준다.

이렇게 간단하게 카메라 전환기능도 만들 수 있다 !

자, 여기서 두 번이나 쓰인 bestDevice(in: ) 메서드와 discoverySession 을 살펴보자.


// DiscoverySession: 기기 목록 필터링
      private let discoverySession = AVCaptureDevice.DiscoverySession( 
        deviceTypes: [.builtInTrueDepthCamera, .builtInDualCamera, .builtInWideAngleCamera],
        mediaType: .video,
        position: .unspecified
    )
 // MARK: - 최적 카메라 선택
    private func bestDevice(in position: AVCaptureDevice.Position) -> AVCaptureDevice {
        let devices = self.discoverySession.devices
        guard !devices.isEmpty else { fatalError("Missing capture devices.")}
        return devices.first(where: { device in device.position == position })!
    }
  

먼저 AVCaptureDevice.DiscoverySession은 카메라 장치 목록을 가져오는 필터 같은 역할을 한다.

  • deviceTypes에 쓰이는 3가지는 각각 Face ID에서 쓰이는 전면 카메라, 듀얼 후면 카메라, 일반 광곽 카메라다.
  • mediaType: .video는 영상 촬영이 가능한 장치만 가져온다.
  • position: .unspecified는 전면/후면 특정되지 않게 가져온다.

bestDevice(in: ) 메서드는 카메라 위치(전면/후면)을 인자로 받는다.

  • 위에서 만든 discoverySession의 카메라 리스트를 가져오고
  • 전면 or 후면 조건에 맞는 카메라를 반환한다.

아까처럼 세션 Input에 카메라를 연결해주는 역할을 하는 것이다 !

다음으로는 사진 촬영 기능에 대해 볼 것이므로 takePhoto()AVCapturePhotoCaptureDelegate에 대해 보겠다.


    //MARK: - 사진 촬영
    func takePhoto() {
        let settings = AVCapturePhotoSettings()
        settings.flashMode = .auto
        photoOutput.capturePhoto(with: settings, delegate: self)
    }
    
    // MARK: - AVCapturePhotoCaptureDelegate(사진 촬영 후 이미지화를 위한)
    extension CameraViewModel: AVCapturePhotoCaptureDelegate {
        func photoOutput(_ output: AVCapturePhotoOutput,
                         didFinishProcessingPhoto photo: AVCapturePhoto,
                         error: Error?) {
            guard let imageData = photo.fileDataRepresentation(),
                  let image = UIImage(data: imageData) else { return }

            DispatchQueue.main.async {
                self.capturedImage = image
            }
            captureSession.stopRunning()
        }
    }

takePhoto() 메서드에서

  • AVCapturePhotoSettings()을 통해 촬영할때 필요한 설정(플래시 등)을 할 수 있다.
  • capturePhoto(with: settings, delegate: self) 메서드를 시행하게 되면 delegate 메서드 photoOutput(_:didFinishProcessingPhoto:...)가 자동으로 호출된다.

photoOutput(_:didFinishProcessingPhoto:...) 딜리게이트가 호출되면

  • 촬영결과를 fileDataRepresentation()을 통해 데이터로 변환한다.
  • 변환한 UIImage를 메인스레드에서 self.capturedImage에 넣어준다.
  • 사진 촬영 후 촬영된 이미지를 바로 보여줄거라 stopRunning()을 통해 세션을 종료시켜준다.

이렇게 delegate패턴을 통해 아주 쉽게 사진 촬영 기능도 만들 수 있다.

만약 사진 촬영 후 다시 찍고 싶다면,


 //MARK: - 다시 찍기
    func retake() {
        capturedImage = nil
        Task {
            captureSession.startRunning()
        }
    }

capturedImage를 nil로 만들어주고 세션을 startRunning()로 다시 실행시켜주면 된다 !

자 이제 카메라 기능들의 이야기가 끝났으니, 진짜로 카메라 프리뷰와 연결시켜주는 작업을 해줘야한다 !


CameraPreviewView

카메라 프리뷰 뷰는 카메라 렌즈를 통해 보이는 실제세상의 뷰다.

import SwiftUI
import AVFoundation

struct CameraPreviewView: UIViewRepresentable {
    let session: AVCaptureSession
    
    class PreviewView: UIView {
        override class var layerClass: AnyClass {
            return AVCaptureVideoPreviewLayer.self
        }

        var videoPreviewLayer: AVCaptureVideoPreviewLayer {
            return layer as! AVCaptureVideoPreviewLayer
        }
    }
    
    func makeUIView(context: Context) -> PreviewView {
        let view = PreviewView()
        view.videoPreviewLayer.session = session //세션 일치 시켜주기
        
        return view
    }
    
    func updateUIView(_ uiView: PreviewView, context: Context) {
        //업뎃 로직 필요시
    }
}

SwiftUI 자체에는 카메라 미리보기 기능이 없으니까, UIKit의 AVCaptureVideoPreviewLayer를 감싸서 써야한다.

여기서 주의할 점은 videoPreviewLayer의 세션에 우리가 만들어 둔 세션을 연결시켜줘야 카메라가 정상적으로 작동하고 전환/촬영 기능도 작동한다.

여기까지 했다면 나만의 카메라 만들기에 성공했을 것이다.


구현영상


🍎 참고한 자료

0개의 댓글