Ver3.5 iOS 카메라와 AVFoundation을 완전 정복하는 사진·비디오 처리 가이드

iOS 카메라와 AVFoundation을 완전 정복하는 사진·비디오 처리 가이드
🎬 카메라 앱 개발의 A to Z, 실전 코드와 함께 친절하게 파헤쳐보자!
안녕! 오늘은 iOS 개발자라면 한 번쯤은 꼭 마주치게 되는 카메라와 AVFoundation 세계로 같이 뛰어들어볼 거야. 📸
솔직히 말하면, 처음 AVFoundation을 접했을 때 "이게 뭐야... 클래스가 왜 이렇게 많아?" 싶었던 사람 손 들어봐. 🙋
근데 한번 제대로 이해하고 나면 진짜 강력하고 재미있는 프레임워크야. 사진 찍고, 영상 녹화하고, 필터 입히고, 실시간 처리까지 — 이 모든 게 다 AVFoundation 안에 있거든.
이 글에서는 AVFoundation의 핵심 구조부터 시작해서, 실제 카메라 앱을 만들 때 필요한 사진 촬영, 비디오 녹화, 실시간 프레임 처리까지 단계별로 쭉 훑어볼 거야. 코드도 실전 위주로 넣을 테니까 따라오면서 직접 해봐! 🚀
🎯 AVFoundation이 뭔데? 왜 써야 해?
iOS에서 카메라를 다루는 방법은 크게 두 가지야.
하나는 UIImagePickerController처럼 시스템이 제공하는 UI를 그냥 쓰는 방법이고,
다른 하나는 AVFoundation을 직접 써서 카메라를 완전히 내 손으로 제어하는 방법이야.
UIImagePickerController는 진짜 간단해. 3줄이면 카메라 열 수 있어. 근데 문제가 있어 — 커스터마이징이 거의 불가능해. 내가 원하는 UI를 만들 수도 없고, 실시간으로 프레임을 처리할 수도 없어.
그래서 진짜 카메라 앱을 만들려면 AVFoundation을 써야 해. 인스타그램, 스냅챗, 틱톡 같은 앱들이 다 이걸 기반으로 만들어진 거야. 🔥
위 그림처럼 AVFoundation은 크게 세 가지 영역으로 나눌 수 있어.
캡처(Capture) — 카메라로 사진·영상 찍기
재생(Playback) — 비디오 파일 재생하기
편집(Edit) — 영상 자르고 합치고 내보내기
오늘 우리가 집중할 건 주로 캡처 레이어야. 여기서 카메라 제어의 핵심이 다 일어나거든!
🏗️ AVCaptureSession — 카메라의 심장
AVFoundation 카메라 구현의 핵심은 AVCaptureSession이야.
이걸 중심으로 입력(Input)과 출력(Output)이 연결되는 구조야. 마치 전기 회로처럼 생각하면 돼.
카메라 하드웨어 → AVCaptureDeviceInput → AVCaptureSession → AVCaptureOutput → 화면/파일
이 흐름을 이해하면 AVFoundation의 절반은 이해한 거야! 😄
기본 세션 설정하기
먼저 프로젝트에 카메라 권한 설정부터 해야 해. Info.plist에 이걸 추가해줘:
<key>NSCameraUsageDescription</key>
<string>카메라를 사용하여 사진과 영상을 촬영합니다.</string>
<key>NSMicrophoneUsageDescription</key>
<string>영상 녹화 시 마이크를 사용합니다.</string>
<key>NSPhotoLibraryUsageDescription</key>
<string>촬영한 사진을 앨범에 저장합니다.</string>
이제 Swift 코드로 기본 세션을 만들어보자:
import AVFoundation
import UIKit
class CameraViewController: UIViewController {
// 1. 핵심 객체들 선언
var captureSession: AVCaptureSession!
var previewLayer: AVCaptureVideoPreviewLayer!
var currentDevice: AVCaptureDevice?
override func viewDidLoad() {
super.viewDidLoad()
checkPermissionAndSetup()
}
func checkPermissionAndSetup() {
switch AVCaptureDevice.authorizationStatus(for: .video) {
case .authorized:
setupCaptureSession()
case .notDetermined:
AVCaptureDevice.requestAccess(for: .video) { granted in
if granted {
DispatchQueue.main.async {
self.setupCaptureSession()
}
}
}
default:
print("카메라 권한이 없습니다.")
}
}
func setupCaptureSession() {
captureSession = AVCaptureSession()
// 세션 품질 설정 (photo / high / medium / low)
captureSession.sessionPreset = .photo
// 입력 장치 설정
guard let device = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: .back
) else { return }
currentDevice = device
do {
let input = try AVCaptureDeviceInput(device: device)
if captureSession.canAddInput(input) {
captureSession.addInput(input)
}
} catch {
print("입력 장치 설정 실패: \(error)")
return
}
// 미리보기 레이어 설정
previewLayer = AVCaptureVideoPreviewLayer(session: captureSession)
previewLayer.videoGravity = .resizeAspectFill
previewLayer.frame = view.bounds
view.layer.insertSublayer(previewLayer, at: 0)
// 세션 시작 (백그라운드 스레드에서!)
DispatchQueue.global(qos: .userInitiated).async {
self.captureSession.startRunning()
}
}
}
captureSession.startRunning()은 반드시 백그라운드 스레드에서 실행해야 해. 메인 스레드에서 실행하면 UI가 블로킹되어서 앱이 버벅거려. 이건 진짜 자주 하는 실수야!
세션 프리셋(Preset) 선택하기
sessionPreset은 카메라의 출력 품질을 결정해. 용도에 맞게 골라야 해:
| 프리셋 | 해상도 | 용도 |
|---|---|---|
.photo |
최대 해상도 | 사진 촬영 (가장 고품질) |
.hd4K3840x2160 |
4K (3840×2160) | 4K 영상 녹화 |
.hd1920x1080 |
1080p | Full HD 영상 |
.hd1280x720 |
720p | HD 영상 |
.medium |
480p 수준 | 실시간 처리, 스트리밍 |
.low |
저해상도 | QR코드 스캔, 바코드 |
📸 사진 촬영 구현하기
세션 설정이 끝났으면 이제 실제로 사진을 찍어보자!
사진 촬영에는 AVCapturePhotoOutput을 사용해. iOS 10 이후로 기존의 AVCaptureStillImageOutput은 deprecated 됐으니까 꼭 AVCapturePhotoOutput을 써야 해.
AVCapturePhotoOutput 설정
// CameraViewController에 추가
var photoOutput: AVCapturePhotoOutput!
// setupCaptureSession() 안에 추가
func setupPhotoOutput() {
photoOutput = AVCapturePhotoOutput()
// HEIF 포맷 지원 여부 확인 후 설정
if photoOutput.availablePhotoCodecTypes.contains(.hevc) {
// HEIF 포맷 지원 (iOS 11+, iPhone 7 이상)
print("HEIF 지원됨")
}
if captureSession.canAddOutput(photoOutput) {
captureSession.addOutput(photoOutput)
}
}
실제 사진 촬영 코드
// 셔터 버튼 탭 시 호출
@objc func capturePhoto() {
let settings = AVCapturePhotoSettings()
// 플래시 설정
if currentDevice?.hasFlash == true {
settings.flashMode = .auto // .on / .off / .auto
}
// 고해상도 사진 활성화
settings.isHighResolutionPhotoEnabled = true
// HEIF 포맷으로 촬영 (용량 절약)
if let codecType = photoOutput.availablePhotoCodecTypes.first(where: { $0 == .hevc }) {
let heifSettings = AVCapturePhotoSettings(format: [AVVideoCodecKey: codecType])
photoOutput.capturePhoto(with: heifSettings, delegate: self)
} else {
// JPEG 폴백
photoOutput.capturePhoto(with: settings, delegate: self)
}
}
// AVCapturePhotoCaptureDelegate 구현
extension CameraViewController: AVCapturePhotoCaptureDelegate {
// 촬영 완료 시 호출
func photoOutput(_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?) {
if let error = error {
print("촬영 실패: \(error)")
return
}
// 이미지 데이터 추출
guard let imageData = photo.fileDataRepresentation() else { return }
guard let image = UIImage(data: imageData) else { return }
// 사진 앨범에 저장
UIImageWriteToSavedPhotosAlbum(image, self, #selector(imageSaved(_:didFinishSavingWithError:contextInfo:)), nil)
// 또는 메모리에서 바로 사용
DispatchQueue.main.async {
self.showPreview(image: image)
}
}
@objc func imageSaved(_ image: UIImage,
didFinishSavingWithError error: Error?,
contextInfo: UnsafeRawPointer) {
if let error = error {
print("저장 실패: \(error)")
} else {
print("✅ 사진 저장 완료!")
}
}
}
촬영 타이밍 콜백 이해하기
AVCapturePhotoCaptureDelegate에는 여러 콜백이 있어. 촬영 과정의 각 단계를 알 수 있어:
// 셔터 애니메이션 효과 추가
func photoOutput(_ output: AVCapturePhotoOutput,
willCapturePhotoFor resolvedSettings: AVCaptureResolvedPhotoSettings) {
// 화면 깜빡임 효과
DispatchQueue.main.async {
self.previewLayer.opacity = 0
UIView.animate(withDuration: 0.25) {
self.previewLayer.opacity = 1
}
}
}
🎥 비디오 녹화 구현하기
이번엔 영상 녹화야! 비디오 녹화에는 AVCaptureMovieFileOutput을 사용해.
사진보다 조금 더 복잡하지만, 원리는 같아. 파일로 직접 저장하는 방식이라 메모리 걱정이 없어서 좋아. 😊
AVCaptureMovieFileOutput 설정
var movieOutput: AVCaptureMovieFileOutput!
var isRecording = false
func setupMovieOutput() {
movieOutput = AVCaptureMovieFileOutput()
// 최대 녹화 시간 설정 (선택사항)
movieOutput.maxRecordedDuration = CMTime(seconds: 60, preferredTimescale: 600)
if captureSession.canAddOutput(movieOutput) {
captureSession.addOutput(movieOutput)
}
// 비디오 안정화 설정
if let connection = movieOutput.connection(with: .video) {
if connection.isVideoStabilizationSupported {
connection.preferredVideoStabilizationMode = .auto
}
}
}
// 마이크 입력 추가 (영상에 소리 포함)
func addAudioInput() {
guard let audioDevice = AVCaptureDevice.default(for: .audio) else { return }
do {
let audioInput = try AVCaptureDeviceInput(device: audioDevice)
if captureSession.canAddInput(audioInput) {
captureSession.addInput(audioInput)
}
} catch {
print("마이크 설정 실패: \(error)")
}
}
녹화 시작 / 중지
@objc func toggleRecording() {
if isRecording {
stopRecording()
} else {
startRecording()
}
}
func startRecording() {
guard !movieOutput.isRecording else { return }
// 임시 파일 경로 생성
let tempDir = FileManager.default.temporaryDirectory
let fileName = "video_\(Date().timeIntervalSince1970).mov"
let outputURL = tempDir.appendingPathComponent(fileName)
// 기존 파일 삭제
try? FileManager.default.removeItem(at: outputURL)
movieOutput.startRecording(to: outputURL, recordingDelegate: self)
isRecording = true
print("🔴 녹화 시작!")
}
func stopRecording() {
guard movieOutput.isRecording else { return }
movieOutput.stopRecording()
isRecording = false
print("⏹ 녹화 중지")
}
// AVCaptureFileOutputRecordingDelegate 구현
extension CameraViewController: AVCaptureFileOutputRecordingDelegate {
func fileOutput(_ output: AVCaptureFileOutput,
didFinishRecordingTo outputFileURL: URL,
from connections: [AVCaptureConnection],
error: Error?) {
if let error = error {
print("녹화 실패: \(error)")
return
}
// 사진 앨범에 저장
UISaveVideoAtPathToSavedPhotosAlbum(
outputFileURL.path,
self,
#selector(videoSaved(_:didFinishSavingWithError:contextInfo:)),
nil
)
}
@objc func videoSaved(_ videoPath: String,
didFinishSavingWithError error: Error?,
contextInfo: UnsafeRawPointer) {
if error == nil {
print("✅ 영상 저장 완료!")
}
}
}
AVCapturePhotoOutput과 AVCaptureMovieFileOutput을 동시에 세션에 추가하면 일부 기기에서 충돌이 발생할 수 있어. 사진 모드와 영상 모드를 전환할 때는 세션을 beginConfiguration() / commitConfiguration()으로 감싸서 안전하게 전환해야 해!
사진 ↔ 영상 모드 전환
enum CaptureMode {
case photo
case video
}
func switchCaptureMode(to mode: CaptureMode) {
captureSession.beginConfiguration()
switch mode {
case .photo:
// 영상 출력 제거, 사진 출력 추가
if captureSession.outputs.contains(movieOutput) {
captureSession.removeOutput(movieOutput)
}
if captureSession.canAddOutput(photoOutput) {
captureSession.addOutput(photoOutput)
}
captureSession.sessionPreset = .photo
case .video:
// 사진 출력 제거, 영상 출력 추가
if captureSession.outputs.contains(photoOutput) {
captureSession.removeOutput(photoOutput)
}
if captureSession.canAddOutput(movieOutput) {
captureSession.addOutput(movieOutput)
}
captureSession.sessionPreset = .hd1920x1080
}
captureSession.commitConfiguration()
}
🔄 카메라 전환 & 고급 제어
이제 좀 더 실용적인 기능들을 알아보자. 전면/후면 카메라 전환, 줌, 포커스, 노출 제어 같은 거야.
이런 기능들이 있어야 진짜 카메라 앱처럼 느껴지거든! 🎛️
전면 / 후면 카메라 전환
func switchCamera() {
guard let currentInput = captureSession.inputs.first as? AVCaptureDeviceInput else { return }
let currentPosition = currentInput.device.position
let newPosition: AVCaptureDevice.Position = currentPosition == .back ? .front : .back
guard let newDevice = AVCaptureDevice.default(
.builtInWideAngleCamera,
for: .video,
position: newPosition
) else { return }
do {
let newInput = try AVCaptureDeviceInput(device: newDevice)
captureSession.beginConfiguration()
captureSession.removeInput(currentInput)
if captureSession.canAddInput(newInput) {
captureSession.addInput(newInput)
currentDevice = newDevice
} else {
captureSession.addInput(currentInput) // 실패 시 원복
}
captureSession.commitConfiguration()
// 전환 애니메이션
animateCameraSwitch()
} catch {
print("카메라 전환 실패: \(error)")
}
}
func animateCameraSwitch() {
let transition = CATransition()
transition.duration = 0.3
transition.timingFunction = CAMediaTimingFunction(name: .easeInEaseOut)
transition.type = .fade
previewLayer.add(transition, forKey: nil)
}
줌(Zoom) 제어
// 핀치 제스처로 줌 제어
var lastZoomFactor: CGFloat = 1.0
@objc func handlePinch(_ gesture: UIPinchGestureRecognizer) {
guard let device = currentDevice else { return }
let maxZoom = min(device.activeFormat.videoMaxZoomFactor, 10.0)
let minZoom: CGFloat = 1.0
func update(scale factor: CGFloat) {
do {
try device.lockForConfiguration()
defer { device.unlockForConfiguration() }
device.videoZoomFactor = factor
} catch {
print("줌 설정 실패: \(error)")
}
}
let newScaleFactor = minMaxZoom(lastZoomFactor * gesture.scale,
min: minZoom, max: maxZoom)
switch gesture.state {
case .began: fallthrough
case .changed:
update(scale: newScaleFactor)
case .ended:
lastZoomFactor = newScaleFactor
update(scale: newScaleFactor)
default: break
}
}
func minMaxZoom(_ factor: CGFloat, min: CGFloat, max: CGFloat) -> CGFloat {
return Swift.min(Swift.max(factor, min), max)
}
탭으로 포커스 & 노출 설정
@objc func handleTap(_ gesture: UITapGestureRecognizer) {
let touchPoint = gesture.location(in: view)
// 화면 좌표 → 카메라 좌표 변환
let devicePoint = previewLayer.captureDevicePointConverted(fromLayerPoint: touchPoint)
focusAndExpose(at: devicePoint)
showFocusIndicator(at: touchPoint)
}
func focusAndExpose(at point: CGPoint) {
guard let device = currentDevice else { return }
do {
try device.lockForConfiguration()
// 포커스 설정
if device.isFocusPointOfInterestSupported &&
device.isFocusModeSupported(.autoFocus) {
device.focusPointOfInterest = point
device.focusMode = .autoFocus
}
// 노출 설정
if device.isExposurePointOfInterestSupported &&
device.isExposureModeSupported(.autoExpose) {
device.exposurePointOfInterest = point
device.exposureMode = .autoExpose
}
// 화이트밸런스 자동
if device.isWhiteBalanceModeSupported(.autoWhiteBalance) {
device.whiteBalanceMode = .autoWhiteBalance
}
device.unlockForConfiguration()
} catch {
print("포커스 설정 실패: \(error)")
}
}
// 포커스 인디케이터 UI
func showFocusIndicator(at point: CGPoint) {
let focusView = UIView(frame: CGRect(x: 0, y: 0, width: 70, height: 70))
focusView.center = point
focusView.layer.borderColor = UIColor.yellow.cgColor
focusView.layer.borderWidth = 2
focusView.backgroundColor = .clear
view.addSubview(focusView)
UIView.animate(withDuration: 0.3, animations: {
focusView.transform = CGAffineTransform(scaleX: 0.7, y: 0.7)
}) { _ in
UIView.animate(withDuration: 0.2, delay: 0.5) {
focusView.alpha = 0
} completion: { _ in
focusView.removeFromSuperview()
}
}
}
🎨 실시간 비디오 프레임 처리 (AVCaptureVideoDataOutput)
이제 진짜 재미있는 부분이야! 🔥
AVCaptureVideoDataOutput을 사용하면 카메라에서 나오는 모든 프레임을 실시간으로 받아서 처리할 수 있어.
필터 적용, 얼굴 인식, AR 효과, QR 코드 스캔 같은 기능들이 다 이걸 기반으로 해.
단, 이 방식은 성능이 매우 중요해. 60fps로 들어오는 프레임을 16ms 안에 처리해야 하거든. 그래서 Metal이나 Core Image를 함께 쓰는 경우가 많아.
VideoDataOutput 설정
var videoDataOutput: AVCaptureVideoDataOutput!
let videoDataOutputQueue = DispatchQueue(
label: "com.myapp.videoDataOutput",
qos: .userInitiated,
attributes: [],
autoreleaseFrequency: .workItem
)
func setupVideoDataOutput() {
videoDataOutput = AVCaptureVideoDataOutput()
// 픽셀 포맷 설정
videoDataOutput.videoSettings = [
kCVPixelBufferPixelFormatTypeKey as String:
kCVPixelFormatType_32BGRA // BGRA 형식 (Metal과 호환성 좋음)
]
// 처리가 늦으면 프레임 드롭 (메모리 보호)
videoDataOutput.alwaysDiscardsLateVideoFrames = true
// 델리게이트 설정 (전용 큐에서 처리)
videoDataOutput.setSampleBufferDelegate(self, queue: videoDataOutputQueue)
if captureSession.canAddOutput(videoDataOutput) {
captureSession.addOutput(videoDataOutput)
}
// 방향 설정
if let connection = videoDataOutput.connection(with: .video) {
connection.videoOrientation = .portrait
// 전면 카메라 미러링
if currentDevice?.position == .front {
connection.isVideoMirrored = true
}
}
}
프레임 처리 델리게이트
extension CameraViewController: AVCaptureVideoDataOutputSampleBufferDelegate {
func captureOutput(_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection) {
// CMSampleBuffer → CVPixelBuffer 변환
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
// Core Image로 필터 적용
let ciImage = CIImage(cvPixelBuffer: pixelBuffer)
let filteredImage = applyFilter(to: ciImage)
// 메인 스레드에서 UI 업데이트
DispatchQueue.main.async {
self.filteredImageView.image = UIImage(ciImage: filteredImage)
}
}
// 프레임 드롭 감지
func captureOutput(_ output: AVCaptureOutput,
didDrop sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection) {
print("⚠️ 프레임 드롭 발생 — 처리 속도 최적화 필요")
}
}
// Core Image 필터 적용 예시
func applyFilter(to image: CIImage) -> CIImage {
let context = CIContext()
// 흑백 필터
guard let filter = CIFilter(name: "CIPhotoEffectMono") else { return image }
filter.setValue(image, forKey: kCIInputImageKey)
return filter.outputImage ?? image
}
다양한 Core Image 필터들
Core Image에는 200개 이상의 내장 필터가 있어. 자주 쓰는 것들만 정리해봤어:
| 필터 이름 | 효과 | 주요 파라미터 |
|---|---|---|
CIPhotoEffectMono |
흑백 변환 | 없음 |
CIPhotoEffectVivid |
선명도 강화 | 없음 |
CIColorControls |
밝기/대비/채도 | brightness, contrast, saturation |
CIGaussianBlur |
가우시안 블러 | inputRadius |
CIVignette |
비네트 효과 | inputIntensity, inputRadius |
CISepiaTone |
세피아 톤 | inputIntensity |
CIPixellate |
픽셀화 | inputScale |
CIEdges |
엣지 검출 | inputIntensity |
// 여러 필터 체이닝 예시
func applyMultipleFilters(to image: CIImage) -> CIImage {
var result = image
// 1. 채도 조절
if let colorFilter = CIFilter(name: "CIColorControls") {
colorFilter.setValue(result, forKey: kCIInputImageKey)
colorFilter.setValue(1.5, forKey: "inputSaturation") // 채도 1.5배
colorFilter.setValue(0.1, forKey: "inputBrightness") // 밝기 +0.1
result = colorFilter.outputImage ?? result
}
// 2. 비네트 효과
if let vignetteFilter = CIFilter(name: "CIVignette") {
vignetteFilter.setValue(result, forKey: kCIInputImageKey)
vignetteFilter.setValue(0.8, forKey: "inputIntensity")
vignetteFilter.setValue(1.5, forKey: "inputRadius")
result = vignetteFilter.outputImage ?? result
}
return result
}
CIContext는 매 프레임마다 새로 만들면 안 돼. 한 번 만들어서 재사용해야 해. 특히 Metal 기반 컨텍스트를 쓰면 GPU 가속으로 훨씬 빨라져:let context = CIContext(options: [.useSoftwareRenderer: false])
🔍 Vision Framework와 연동 — 얼굴 인식, QR 코드
AVFoundation으로 프레임을 받아오면, Apple의 Vision Framework와 연동해서 강력한 AI 기능을 추가할 수 있어.
얼굴 인식, 텍스트 인식, QR/바코드 스캔, 물체 감지 같은 것들이 다 가능해. 그것도 실시간으로! 🤖
실시간 얼굴 인식
import Vision
class FaceDetectionViewController: UIViewController {
var faceDetectionRequest: VNDetectFaceRectanglesRequest!
var overlayLayer: CALayer!
override func viewDidLoad() {
super.viewDidLoad()
setupFaceDetection()
}
func setupFaceDetection() {
faceDetectionRequest = VNDetectFaceRectanglesRequest { [weak self] request, error in
guard let self = self else { return }
if let error = error {
print("얼굴 인식 오류: \(error)")
return
}
guard let results = request.results as? [VNFaceObservation] else { return }
DispatchQueue.main.async {
self.drawFaceBoxes(faces: results)
}
}
}
// VideoDataOutput 델리게이트에서 호출
func detectFaces(in sampleBuffer: CMSampleBuffer) {
guard let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
let requestHandler = VNImageRequestHandler(
cvPixelBuffer: pixelBuffer,
orientation: .up,
options: [:]
)
do {
try requestHandler.perform([faceDetectionRequest])
} catch {
print("Vision 요청 실패: \(error)")
}
}
func drawFaceBoxes(faces: [VNFaceObservation]) {
// 기존 레이어 제거
overlayLayer.sublayers?.forEach { $0.removeFromSuperlayer() }
for face in faces {
// Vision 좌표 → 화면 좌표 변환
let boundingBox = face.boundingBox
let size = CGSize(
width: boundingBox.width * view.bounds.width,
height: boundingBox.height * view.bounds.height
)
let origin = CGPoint(
x: boundingBox.minX * view.bounds.width,
y: (1 - boundingBox.maxY) * view.bounds.height // Y축 반전!
)
let faceLayer = CALayer()
faceLayer.frame = CGRect(origin: origin, size: size)
faceLayer.borderColor = UIColor.green.cgColor
faceLayer.borderWidth = 2
faceLayer.cornerRadius = 4
overlayLayer.addSublayer(faceLayer)
}
}
}
Vision Framework는 좌하단이 원점(0,0)인 좌표계를 사용해. 반면 UIKit은 좌상단이 원점이야. 그래서 Y축을 반전시켜야 해:
y = (1 - boundingBox.maxY) * height
QR 코드 / 바코드 스캔
QR 코드 스캔은 AVCaptureMetadataOutput을 사용하는 게 더 간단하고 빨라:
var metadataOutput: AVCaptureMetadataOutput!
func setupQRScanner() {
metadataOutput = AVCaptureMetadataOutput()
if captureSession.canAddOutput(metadataOutput) {
captureSession.addOutput(metadataOutput)
metadataOutput.setMetadataObjectsDelegate(self, queue: .main)
// 지원하는 코드 타입 설정
metadataOutput.metadataObjectTypes = [
.qr, // QR 코드
.ean8, // EAN-8 바코드
.ean13, // EAN-13 바코드
.code128, // Code 128
.pdf417, // PDF417
.aztec // Aztec 코드
]
// 스캔 영역 제한 (선택사항 — 성능 향상)
metadataOutput.rectOfInterest = CGRect(x: 0.2, y: 0.2, width: 0.6, height: 0.6)
}
}
extension CameraViewController: AVCaptureMetadataOutputObjectsDelegate {
func metadataOutput(_ output: AVCaptureMetadataOutput,
didOutput metadataObjects: [AVMetadataObject],
from connection: AVCaptureConnection) {
guard let metadataObject = metadataObjects.first,
let readableObject = metadataObject as? AVMetadataMachineReadableCodeObject,
let stringValue = readableObject.stringValue else { return }
// 진동 피드백
AudioServicesPlaySystemSound(SystemSoundID(kSystemSoundID_Vibrate))
print("✅ QR 코드 인식: \(stringValue)")
handleQRCode(stringValue)
}
}
🎬 AVAsset으로 비디오 편집하기
촬영한 영상을 편집하고 싶다면 AVAsset과 AVMutableComposition을 사용해.
영상 자르기, 합치기, 오디오 추가, 트랜지션 효과까지 모두 가능해. 마치 코드로 영상 편집 프로그램을 만드는 느낌이야! ✂️
영상 자르기 (Trim)
func trimVideo(inputURL: URL,
startTime: Double,
endTime: Double,
completion: @escaping (URL?) -> Void) {
let asset = AVAsset(url: inputURL)
// 내보내기 세션 생성
guard let exportSession = AVAssetExportSession(
asset: asset,
presetName: AVAssetExportPresetHighestQuality
) else {
completion(nil)
return
}
// 출력 파일 경로
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent("trimmed_\(Date().timeIntervalSince1970).mp4")
exportSession.outputURL = outputURL
exportSession.outputFileType = .mp4
exportSession.shouldOptimizeForNetworkUse = true
// 시간 범위 설정
let start = CMTime(seconds: startTime, preferredTimescale: 600)
let end = CMTime(seconds: endTime, preferredTimescale: 600)
exportSession.timeRange = CMTimeRange(start: start, end: end)
// 내보내기 실행
exportSession.exportAsynchronously {
switch exportSession.status {
case .completed:
print("✅ 영상 자르기 완료")
completion(outputURL)
case .failed:
print("❌ 실패: \(exportSession.error?.localizedDescription ?? "")")
completion(nil)
case .cancelled:
print("취소됨")
completion(nil)
default: break
}
}
}
여러 영상 합치기 (Merge)
func mergeVideos(urls: [URL], completion: @escaping (URL?) -> Void) {
let composition = AVMutableComposition()
guard let videoTrack = composition.addMutableTrack(
withMediaType: .video,
preferredTrackID: kCMPersistentTrackID_Invalid
),
let audioTrack = composition.addMutableTrack(
withMediaType: .audio,
preferredTrackID: kCMPersistentTrackID_Invalid
) else { return }
var currentTime = CMTime.zero
for url in urls {
let asset = AVAsset(url: url)
let duration = asset.duration
let timeRange = CMTimeRange(start: .zero, duration: duration)
// 비디오 트랙 추가
if let assetVideoTrack = asset.tracks(withMediaType: .video).first {
try? videoTrack.insertTimeRange(timeRange,
of: assetVideoTrack,
at: currentTime)
}
// 오디오 트랙 추가
if let assetAudioTrack = asset.tracks(withMediaType: .audio).first {
try? audioTrack.insertTimeRange(timeRange,
of: assetAudioTrack,
at: currentTime)
}
currentTime = CMTimeAdd(currentTime, duration)
}
// 내보내기
let outputURL = FileManager.default.temporaryDirectory
.appendingPathComponent("merged_\(Date().timeIntervalSince1970).mp4")
guard let exportSession = AVAssetExportSession(
asset: composition,
presetName: AVAssetExportPresetHighestQuality
) else { return }
exportSession.outputURL = outputURL
exportSession.outputFileType = .mp4
exportSession.exportAsynchronously {
if exportSession.status == .completed {
completion(outputURL)
} else {
completion(nil)
}
}
}
영상에 텍스트/이미지 오버레이 추가
func addTextOverlay(to asset: AVAsset,
text: String,
completion: @escaping (URL?) -> Void) {
let composition = AVMutableComposition()
let videoSize = asset.tracks(withMediaType: .video).first?.naturalSize ?? CGSize(width: 1920, height: 1080)
// 비디오 컴포지션 설정
let videoComposition = AVMutableVideoComposition()
videoComposition.renderSize = videoSize
videoComposition.frameDuration = CMTime(value: 1, timescale: 30)
// CALayer로 텍스트 레이어 생성
let textLayer = CATextLayer()
textLayer.string = text
textLayer.fontSize = 60
textLayer.foregroundColor = UIColor.white.cgColor
textLayer.alignmentMode = .center
textLayer.frame = CGRect(x: 0, y: 50, width: videoSize.width, height: 100)
// 배경 레이어
let backgroundLayer = CALayer()
backgroundLayer.frame = CGRect(origin: .zero, size: videoSize)
// 비디오 레이어
let videoLayer = CALayer()
videoLayer.frame = CGRect(origin: .zero, size: videoSize)
// 레이어 합성
let parentLayer = CALayer()
parentLayer.frame = CGRect(origin: .zero, size: videoSize)
parentLayer.addSublayer(videoLayer)
parentLayer.addSublayer(backgroundLayer)
parentLayer.addSublayer(textLayer)
videoComposition.animationTool = AVVideoCompositionCoreAnimationTool(
postProcessingAsVideoLayer: videoLayer,
in: parentLayer
)
// ... (내보내기 코드 동일)
}
⚡ 성능 최적화 & 실전 팁
카메라 앱은 성능이 생명이야. 프레임 드롭이 생기거나 배터리를 너무 많이 먹으면 사용자가 바로 삭제해버리거든. 😅
실전에서 꼭 알아야 할 최적화 팁들을 정리해봤어.
앱 생명주기와 세션 관리
// 앱 생명주기에 맞춰 세션 관리
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
DispatchQueue.global(qos: .userInitiated).async {
if !self.captureSession.isRunning {
self.captureSession.startRunning()
}
}
}
override func viewWillDisappear(_ animated: Bool) {
super.viewWillDisappear(animated)
DispatchQueue.global(qos: .userInitiated).async {
if self.captureSession.isRunning {
self.captureSession.stopRunning()
}
}
}
// 앱 백그라운드 진입 시
override func viewDidLoad() {
super.viewDidLoad()
NotificationCenter.default.addObserver(
self,
selector: #selector(appDidEnterBackground),
name: UIApplication.didEnterBackgroundNotification,
object: nil
)
NotificationCenter.default.addObserver(
self,
selector: #selector(appWillEnterForeground),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
}
@objc func appDidEnterBackground() {
DispatchQueue.global().async {
self.captureSession.stopRunning()
}
}
@objc func appWillEnterForeground() {
DispatchQueue.global().async {
self.captureSession.startRunning()
}
}
카메라 인터럽트 처리
전화가 오거나 다른 앱이 카메라를 사용하면 세션이 인터럽트될 수 있어. 이걸 처리해줘야 해:
// 세션 인터럽트 감지
NotificationCenter.default.addObserver(
self,
selector: #selector(sessionWasInterrupted),
name: .AVCaptureSessionWasInterrupted,
object: captureSession
)
NotificationCenter.default.addObserver(
self,
selector: #selector(sessionInterruptionEnded),
name: .AVCaptureSessionInterruptionEnded,
object: captureSession
)
@objc func sessionWasInterrupted(notification: NSNotification) {
guard let userInfoValue = notification.userInfo?[AVCaptureSessionInterruptionReasonKey] as AnyObject?,
let reasonIntegerValue = userInfoValue.integerValue,
let reason = AVCaptureSession.InterruptionReason(rawValue: reasonIntegerValue) else { return }
switch reason {
case .videoDeviceNotAvailableInBackground:
print("백그라운드에서 카메라 사용 불가")
case .audioDeviceInUseByAnotherClient:
print("다른 앱이 마이크 사용 중")
case .videoDeviceInUseByAnotherClient:
print("다른 앱이 카메라 사용 중")
case .videoDeviceNotAvailableWithMultipleForegroundApps:
print("멀티태스킹 환경에서 카메라 사용 불가")
default: break
}
}
@objc func sessionInterruptionEnded(notification: NSNotification) {
print("카메라 인터럽트 종료 — 재개 가능")
DispatchQueue.global().async {
self.captureSession.startRunning()
}
}
📱 멀티 카메라 & 최신 기능들 (iOS 13+)
iPhone 11 이후로 멀티 카메라 시스템이 도입됐어. AVCaptureMultiCamSession을 사용하면 전면과 후면 카메라를 동시에 사용할 수 있어. 유튜버들이 자주 쓰는 PIP(Picture-in-Picture) 촬영 같은 거 말이야! 🎥
멀티 카메라 세션
// iOS 13+ 멀티 카메라
import AVFoundation
class MultiCamViewController: UIViewController {
var multiCamSession: AVCaptureMultiCamSession!
func setupMultiCam() {
// 멀티 카메라 지원 여부 확인
guard AVCaptureMultiCamSession.isMultiCamSupported else {
print("이 기기는 멀티 카메라를 지원하지 않습니다.")
return
}
multiCamSession = AVCaptureMultiCamSession()
multiCamSession.beginConfiguration()
// 후면 카메라 추가
if let backCamera = AVCaptureDevice.default(
.builtInWideAngleCamera, for: .video, position: .back
),
let backInput = try? AVCaptureDeviceInput(device: backCamera),
multiCamSession.canAddInput(backInput) {
multiCamSession.addInputWithNoConnections(backInput)
}
// 전면 카메라 추가
if let frontCamera = AVCaptureDevice.default(
.builtInWideAngleCamera, for: .video, position: .front
),
let frontInput = try? AVCaptureDeviceInput(device: frontCamera),
multiCamSession.canAddInput(frontInput) {
multiCamSession.addInputWithNoConnections(frontInput)
}
multiCamSession.commitConfiguration()
DispatchQueue.global(qos: .userInitiated).async {
self.multiCamSession.startRunning()
}
}
}
최신 카메라 기능들 (iOS 16+)
🔭 Depth Data (깊이 데이터)
TrueDepth 카메라나 LiDAR 스캐너가 있는 기기에서 깊이 정보를 함께 캡처할 수 있어. 포트레이트 모드, AR 기능에 활용돼.
📸 RAW 포맷 촬영
AVCapturePhotoSettings에서 RAW 포맷을 지원하면 DNG 파일로 저장 가능. 전문 사진 앱에서 사용해.
🎬 ProRes 영상 녹화
iPhone 13 Pro 이상에서 Apple ProRes 코덱으로 영상 녹화 가능. 영상 편집 전문가용.
🌙 야간 모드 (Night Mode)
AVCapturePhotoSettings.photoQualityPrioritization으로 야간 모드 제어 가능.
// 깊이 데이터 캡처 설정
func setupDepthCapture() {
let photoSettings = AVCapturePhotoSettings()
// 깊이 데이터 포함 여부
if photoOutput.isDepthDataDeliverySupported {
photoOutput.isDepthDataDeliveryEnabled = true
photoSettings.isDepthDataDeliveryEnabled = true
}
// 포트레이트 효과 매트 (배경 분리)
if photoOutput.isPortraitEffectsMatteDeliverySupported {
photoOutput.isPortraitEffectsMatteDeliveryEnabled = true
photoSettings.isPortraitEffectsMatteDeliveryEnabled = true
}
// 품질 우선순위 설정
photoSettings.photoQualityPrioritization = .quality // .speed / .balanced / .quality
photoOutput.capturePhoto(with: photoSettings, delegate: self)
}
// 깊이 데이터 처리
func photoOutput(_ output: AVCapturePhotoOutput,
didFinishProcessingPhoto photo: AVCapturePhoto,
error: Error?) {
// 깊이 데이터 추출
if let depthData = photo.depthData {
let depthMap = depthData.converting(toDepthDataType:
kCVPixelFormatType_DepthFloat32)
let depthPixelBuffer = depthMap.depthDataMap
print("깊이 데이터 크기: \(CVPixelBufferGetWidth(depthPixelBuffer))x\(CVPixelBufferGetHeight(depthPixelBuffer))")
}
}
🛠️ 실전 프로젝트 — 인스타그램 스타일 카메라 앱 구조
지금까지 배운 내용을 종합해서 실제 앱 구조를 잡아보자.
재능넷 같은 플랫폼에서 재능을 공유할 때 포트폴리오로 카메라 앱을 만들어 보여주면 정말 임팩트 있어! 💪
아래는 인스타그램 스타일 카메라 앱의 전체 아키텍처야:
CameraManager 싱글톤 패턴
// 카메라 관련 로직을 분리한 Manager 클래스
class CameraManager: NSObject {
static let shared = CameraManager()
private override init() {}
// 세션
let session = AVCaptureSession()
private let sessionQueue = DispatchQueue(label: "com.app.cameraSession")
// 출력
private(set) var photoOutput = AVCapturePhotoOutput()
private(set) var videoDataOutput = AVCaptureVideoDataOutput()
// 현재 장치
private(set) var currentDevice: AVCaptureDevice?
// 상태
var isRunning: Bool { session.isRunning }
// 콜백
var onPhotoCapture: ((UIImage) -> Void)?
var onVideoFrame: ((CVPixelBuffer) -> Void)?
func configure(completion: @escaping (Bool) -> Void) {
sessionQueue.async { [weak self] in
guard let self = self else { return }
self.session.beginConfiguration()
let success = self.setupInputs() && self.setupOutputs()
self.session.commitConfiguration()
DispatchQueue.main.async { completion(success) }
}
}
func start() {
sessionQueue.async {
if !self.session.isRunning {
self.session.startRunning()
}
}
}
func stop() {
sessionQueue.async {
if self.session.isRunning {
self.session.stopRunning()
}
}
}
private func setupInputs() -> Bool {
guard let device = AVCaptureDevice.default(
.builtInWideAngleCamera, for: .video, position: .back
),
let input = try? AVCaptureDeviceInput(device: device),
session.canAddInput(input) else { return false }
session.addInput(input)
currentDevice = device
return true
}
private func setupOutputs() -> Bool {
guard session.canAddOutput(photoOutput),
session.canAddOutput(videoDataOutput) else { return false }
session.addOutput(photoOutput)
session.addOutput(videoDataOutput)
videoDataOutput.videoSettings = [
kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
]
videoDataOutput.setSampleBufferDelegate(self, queue: sessionQueue)
return true
}
}
🧪 테스트 & 디버깅 팁
카메라 앱 개발에서 테스트는 좀 특별해. 시뮬레이터에서는 실제 카메라를 사용할 수 없거든.
그래서 실기기 테스트가 필수야. 몇 가지 유용한 팁을 알려줄게! 🔧
시뮬레이터 대응 코드
// 시뮬레이터와 실기기 분기 처리
#if targetEnvironment(simulator)
// 시뮬레이터: 더미 이미지 사용
func setupForSimulator() {
let placeholderImage = UIImage(systemName: "camera.fill")
previewImageView.image = placeholderImage
previewImageView.tintColor = .systemGray
print("⚠️ 시뮬레이터 환경 — 카메라 기능 제한됨")
}
#else
// 실기기: 실제 카메라 설정
func setupCamera() {
setupCaptureSession()
}
#endif
자주 발생하는 오류와 해결법
| 오류 | 원인 | 해결법 |
|---|---|---|
| 세션이 시작 안 됨 | 권한 미설정 또는 메인 스레드 실행 | 권한 확인 + 백그라운드 스레드 사용 |
| 미리보기 검은 화면 | previewLayer 프레임 미설정 | viewDidLayoutSubviews에서 frame 업데이트 |
| 사진이 회전됨 | EXIF 방향 정보 미처리 | imageOrientation 처리 또는 fixOrientation 함수 사용 |
| 프레임 드롭 발생 | 처리 시간이 너무 길거나 메인 스레드 블로킹 | alwaysDiscardsLateVideoFrames = true + 전용 큐 사용 |
| 메모리 누수 | 델리게이트 강한 참조 | [weak self] 캡처 리스트 사용 |
| canAddOutput이 false | 세션 설정 충돌 | beginConfiguration/commitConfiguration 사용 |
// 사진 방향 수정 유틸리티
extension UIImage {
func fixOrientation() -> UIImage {
if imageOrientation == .up { return self }
UIGraphicsBeginImageContextWithOptions(size, false, scale)
draw(in: CGRect(origin: .zero, size: size))
let normalizedImage = UIGraphicsGetImageFromCurrentImageContext()
UIGraphicsEndImageContext()
return normalizedImage ?? self
}
}
// 메모리 경고 처리
override func didReceiveMemoryWarning() {
super.didReceiveMemoryWarning()
// 캐시된 이미지 해제
imageCache.removeAllObjects()
print("⚠️ 메모리 경고 — 캐시 해제")
}
🚀 마무리 — 더 나아가기 위한 로드맵
와, 여기까지 따라왔다면 진짜 대단한 거야! 👏
AVFoundation은 정말 깊고 넓은 프레임워크야. 오늘 다룬 내용만 해도 꽤 많지만, 사실 이게 시작이야.
더 깊이 파고들고 싶다면 아래 로드맵을 참고해봐:
iOS 카메라 개발은 진짜 재미있는 분야야. 사용자가 직접 눈으로 보고 느끼는 기능이라서 완성했을 때 성취감도 엄청나거든. 🎉
만약 카메라 앱 개발 관련 도움이 필요하거나, 실력 있는 iOS 개발자를 찾고 있다면 재능넷에서 다양한 개발 재능을 가진 분들을 만나볼 수 있어. 실제로 AVFoundation 전문가들이 프리랜서로 활동하고 있으니까 참고해봐! 😊
오늘 배운 내용을 정리하면:
✅ AVCaptureSession — 카메라 파이프라인의 핵심
✅ AVCapturePhotoOutput — 고품질 사진 촬영
✅ AVCaptureMovieFileOutput — 비디오 파일 녹화
✅ AVCaptureVideoDataOutput — 실시간 프레임 처리
✅ Core Image — 필터 적용
✅ Vision Framework — AI 기반 인식
✅ AVAsset / AVComposition — 영상 편집
✅ 성능 최적화 — 스레드 관리, Metal GPU 활용
✅ 생명주기 관리 — 세션 시작/중지, 인터럽트 처리
코드 한 줄 한 줄 직접 쳐보면서 익히는 게 최고야. 처음엔 복잡해 보여도 패턴이 보이기 시작하면 금방 익숙해져. 화이팅! 💪🎬
관련 키워드
댓글 작성
이 글에 대한 여러분의 생각을 들려주세요
로그인이 필요합니다
댓글을 작성하려면 먼저 로그인해주세요.
댓글 0
지식인의 숲 - 지적 재산권 보호 고지
지적 재산권 보호 고지
- 저작권 및 소유권: 본 컨텐츠는 재능넷의 독점 AI 기술로 생성되었으며, 대한민국 저작권법 및 국제 저작권 협약에 의해 보호됩니다.
- AI 생성 컨텐츠의 법적 지위: 본 AI 생성 컨텐츠는 재능넷의 지적 창작물로 인정되며, 관련 법규에 따라 저작권 보호를 받습니다.
- 사용 제한: 재능넷의 명시적 서면 동의 없이 본 컨텐츠를 복제, 수정, 배포, 또는 상업적으로 활용하는 행위는 엄격히 금지됩니다.
- 데이터 수집 금지: 본 컨텐츠에 대한 무단 스크래핑, 크롤링, 및 자동화된 데이터 수집은 법적 제재의 대상이 됩니다.
- AI 학습 제한: 재능넷의 AI 생성 컨텐츠를 타 AI 모델 학습에 무단 사용하는 행위는 금지되며, 이는 지적 재산권 침해로 간주됩니다.
아직 댓글이 없습니다.