2025. 8. 25. 18:49ㆍSwift Architecture
0. 들어가기에 앞서
MV, MVC, MVVM, TCA, VIPER, Clean Architecture 등등 iOS 개발에 사용될 수 있는 아키텍처 패턴은 정말 많습니다.
하지만 중요한 것은 "어떤 아키텍처를 사용해봤냐?"가 아니라 "왜 그 아키텍처를 선택했냐?"에 대한 대답입니다.
소프트웨어에서 사용하는 패턴 (디자인 패턴, 아키텍처 패턴 포함)은 모두 특정한 문제를 해결해주기 위한 방법임에는 틀림없지만, 현 프로젝트의 볼륨이나 방향에 따라 때로는 오히려 코드를 복잡하고 / 진입 장벽을 높게만 만들 수 있기 때문입니다.
즉, 아키텍처 사용에는 이유가 필요하다는 것입니다.
그 이유에는 현재 프로젝트의 규모, 동료 개발자들의 역량, 개발기간과 이후 유지보수 가능성 등이 종합적으로 고려되어야 하죠.
예를 들어, 작은 규모의 프로젝트에 있어 TCA 같은 복잡한 아키텍처를 적용한다면 오히려 개발 속도가 느려지고 진입 장벽만 높게 만들 것입니다.
반대로 장기적으로 확장 가능성이 있거나 다수 인원이 협업하는 프로젝트에서 단순 MV, MVC로만 진행된다면 코드 의존성이 서로 얽히고 테스트하기 어려워지는 문제가 발생할 수 있습니다.
결국 중요한 것은 "현재 개발로 직면한 문제를 어떤 방식을 적용했을 때 가장 효율적으로 해결할 수 있는가"에 대한 고민입니다.
아키텍처는 그 해결 방법을 제시해주는 수단일 뿐, 목적 그 자체가 아니므로.
지난 챌린지 프로덕트 FF!p에서는 이런 고민을 담아 우리 팀만의 아키텍처 패턴을 설계했습니다.
카메라를 사용하는 서비스라는 점,
네트워크 통신은 수행하지 않고 애플 내장 인공지능 프레임워크를 사용한다는 점,
기능의 확장은 카메라 기능 쪽에서 발생할 수 있고 / 30일 간 챌린지가 끝나면 그 이후에도 유지가 될 수 있는 구조화된 아키텍처를 설계하고 싶다는 점.
그리고 iOS 17+ 부터 새로워진 SwiftUI의 상태관리 매크로 (Observation)를 활용하고 싶다는 점 등이 아키텍처 설계 시에 고려했습니다.
특히, 애플 내부에 있는 인공지능 프레임워크 Vision이나 Speech를 활용한다고 했을 때.
호출하는 API는 전부 Swift Concurrecny 기반의 비동기 메서드로 이루어지고 있다는 특징이 있어, 데이터 레이스 (Data Race) 문제를 방지하기 위한 최신 Swift의 노력도 함께 고려하고자 했습니다. -> 액터 (Actor), 메인 액터 (MainActor), Swift 6 사용 등 !
오늘 글에서는 FF!p의 아키텍처가 만들어진 과정을 차근차근 훑어가보도록 하겠습니다.
[iOS] FF!p (Fast-Find item picker) 삡 - 단어로 위치 탐색
FF!p 삡 - 단어로 위치 탐색Fast-Find item picker, FF!p 눈 앞에서 찾아 헤메던 단어들, 빠르고 정확하게 삡! Command+F 기능을 실생활에 적용해보세요. AI가 모든 일을 대신 해준다는데 귀찮은 도서관
mini-min-dev.tistory.com
1. FF!p에서 사용하는 핵심 기능을 actor 타입의 Service 파일로 분리하기
우선 FF!p 앱에서 사용하는 핵심 비즈니스 로직을 ~Service라는 이름으로 분리해서 구현했습니다.
🧐 Service? Manager? Controller? Coordinator? Provider? 더 명확한 네이밍에 대한 고민 포인트
"Service"라는 네이밍은 외부에서 불러와 특정 기능을 제공하는 컴포넌트에게 가장 범용적/추상적으로 활용할 수 있는 이름입니다.
이는 장단점이 동시에 될 수 있는 것이 어느 곳에서나 만능적으로 활용할 수 있는 이름이기에,
어디에 갖다붙여도 크게 문제가 되지 않지만 / 이름만으로 구체적인 기능을 알지 못한채 만능의 역할로만 인지될 수 있다는 한계가 될 수 있죠.
-
Service라는 이름 외에도 선택할 수 있는 다른 선택지도 있었습니다.
✔️ Manager : "관리자"의 책임이 명확할 때. -> 부수적인 효과를 직접 수행하기보다, 상위에서 큰 관리의 책임만 질 때 적합합니다.
✔️ Controller : "컨트롤"하는 책임이 명확할 때. -> 특정 리소스의 생명주기 제어/설정/명령 책임이 명확할 때 적합합니다.
✔️ Coordinator/Mediator : 두 개 이상의 모듈간 "상호작용, 연결"에 대한 책임을 질 때 적합한 이름입니다.
✔️ Provider : 상태나 값을 제공하는 역할 (권한이나 환경, 설정 등)을 갖고 있을 때 적합합니다.
이 앱의 비즈니스 로직이라고 하면 카메라를 다루기 위해 필요한 AVFoundation 모듈과 의존성을 맺고 있는 부분.
그리고 이미지로부터 텍스트를 추출하는 TextRecognition 기능을 위해 Vision 모듈과 의존성을 맺고 있는 부분. 크게 두 개로 나눠볼 수 있습니다.
단, 세부적으로 카메라 기능은 많은 기능을 포함하고 있으므로 아래와 같이 Service를 나누어서 SOLID 원칙 중 SRP (Single Responsibility Principle)에 따른 책임을 나눠볼 수 있었습니다. 아래 Service 구조를 확인해봅시다.
- PrivacyService : 카메라 기능을 제공하기 위해 사용자로부터 권한 요청/상태를 체크하는 책임
- VideoCaptureService : 카메라 캡처 세션 (AVCaptureSession)을 끊기지 않게 안정적으로 실행, 중단하기 위한 책임
- VideoDeviceService : 촬영 장치 (AVCaptureDevice)의 품질을 설정하고, 촬영 장치에서 제공하는 기능 (줌, 토치, 포커스 등)을 제공하는 책임
- VisionService : 이미지로부터 텍스트 추출 (RecognizeTextRequest) 기능을 제공하기 위한 인공지능 객체의 책임
import AVFoundation
actor PrivacyService {
private(set) var isCameraAuthorized: Bool = false
func fetchCameraAuthorization() async {
// isCameraAuthorized 권한 요청과 권한 조회 관련
}
}
@preconcurrency import AVFoundation
actor VideoCaptureService {
private var captureSession: AVCaptureSession?
private let sampleBufferQueue = DispatchQueue(label: "sampleBufferQueue")
func configureSession(
device: AVCaptureDevice,
delegate:
AVCaptureVideoDataOutputSampleBufferDelegate
) {
// captureSession 할당 및 입출력 장치 설정
// AVCaptureVideoDataOutput delegate 연결
}
func stopSession() {
// captureSession 정지 및 메모리 해제
}
}
import AVFoundation
actor VideoDeviceService {
private(set) var videoDevice: AVCaptureDevice?
func fetchVideoDevice() {
// 비디오 카메라 디바이스 후면 설정
}
func zoom(to factor: CGFloat) -> CGFloat {
// 카메라 줌 수치 (videoZoomFactor) 설정
}
func toggleTorch() -> Bool {
// 카메라 플래시 (토치) on/off 설정
}
func setAutoFocusMode() {
// 자동 초점 모드 설정
}
}
import Vision
actor VisionService {
private var recognizeTextRequest = RecognizeTextRequest()
func prepareTextRecognition(searchKeyword: String) {
// recognizeTextRequest 설정
}
func performTextRecognition(
image: CVImageBuffer
) async throws -> [RecognizedTextObservation] {
// 전달받은 CVImageBuffer에 대해 OCR (TextRecognition) 수행
}
}
앱 전체 흐름에서는 이 Service 객체를 적절한 상황에 맞게 호출해서 사용하게 됩니다. (어디서 호출할지는 뒤에 이어지는 고민 포인트!)
이 Service 구조에서 또 하나 주요하게 볼 포인트는 모두 각 파일은 액터 (actor)로 선언되었다는 점입니다.
액터는 동일한 인스턴스 상태에 한 번에 하나의 태스크만 접근할 수 있도록 해 데이터 레이스 (Data Race) 문제를 해결할 수 있고,
순서가 보장되어 있기 때문에 (권한을 받거나 / 세션을 구성하거나 / 시작 정지 / 텍스트 인식 객체와 인식 자체 등) 순서가 중요한 작업을 직렬화할 수 있다는 특징이 있죠.
그리고 UI 작업과 격리된 상태로 작업을 수행하기 때문에 (@MainActor(View/Model) ↔ 일반 actor(Services) 간 분리)
무거운 작업을 수행하는 Service에서의 일과 메인 스레드에서의 일을 명확하게 분리할 수 있습니다. -> 즉, 메인 스레드 지연 혹은 블로킹 이슈를 최소화할 수 있었습니다.
이어가봅시다!
지금부터는 View와 이 Service 객체들을 어떻게 연결해줄 수 있을지에 대한 고민 포인트가 글로 이어집니다.
2. View-Model-Service: UI와 비즈니스 로직을 이어주는 매게체 역할의 Model
이제 View와 Service 객체를 연결해줘야 합니다.
그 책임을 FF!p에서는 Model이라는 객체가 갖는 그림을 생각하게 되었죠.
SwiftUI UI Framework 시대에서 Apple은 "State (상태) -> View (화면)"의 단방향 흐름을 제시합니다.
구체적으로, 이 상태 (State)를 담고 있는 녀석이 SwiftUI에서 Model로 구현되고 / 이 Model은 View에서 관찰가능한 스트림으로 값을 방출하는 형태입니다.
*WWDC가 진행될 때마다 업데이트되는 Apple의 카메라 대표 예시 코드인 AVCam: Building a camera app의 아키텍처 구조를 참고했습니다. 이 외에도 View-Model 구조는 Apple이 제시하고 있는 가장 일반적인 구조이기도 하죠.
구체적으로 우리 앱의 Model이 가져야하는 책임은 아래와 같이 정리해볼 수 있었습니다.
- Model은 UI 프레임워크에 의존하지 않고 있으며 (import SwiftUI 삭제), 하위 Service 모듈들을 의존하고 있어야 한다.
- UI를 그리기 위해 필요한 데이터들 (카메라 프레임, 텍스트 매칭 결과, 기타 카메라 기능 데이터..)을 관찰 가능한 객체 (Observable) 타입으로 제공해야 한다.
- Service 객체들을 올바른 순서대로 호출해 카메라 사용~텍스트 인식 까지의 파이프라인을 작동해야한다.
이 책임이 코드에 어떻게 포함되어있는지 아래 코드를 가볍게 확인해봐도 좋겠습니다.
@preconcurrency import AVFoundation
import Vision
@MainActor
@Observable
final class CameraModel: NSObject {
private(set) var frame: CVImageBuffer?
private(set) var recognizedTextObservations = [RecognizedTextObservation]()
private(set) var matchedObservations = [RecognizedTextObservation]()
private var framesToDisplayStream: AsyncStream<CVImageBuffer>?
private var framesToAnalyzeStream: AsyncStream<CVImageBuffer>?
private var framesToDisplayContinuation: AsyncStream<CVImageBuffer>.Continuation?
private var framesToAnalyzeContinuation: AsyncStream<CVImageBuffer>.Continuation?
private(set) var searchKeyword: String
private(set) var zoomFactor: CGFloat = 2.0 // 현재 줌 배율
private(set) var isCameraPaused: Bool = false // 카메라 멈춤 상태
private(set) var isTorchOn: Bool = false // 토치 (플래시) 상태
private let privacyService: PrivacyService
private let captureService: VideoCaptureService
private let deviceService: VideoDeviceService
private let visionService: VisionService
func start() async {
await privacyService.fetchCameraAuthorization()
await deviceService.fetchVideoDevice()
guard let videoDevice = await deviceService.currentDevice else { return }
setupStream()
await captureService.configureSession(device: videoDevice, delegate: self)
await visionService.prepareTextRecognition(searchKeyword: searchKeyword)
await deviceService.setAutoFocusMode()
await setDefaultZoom()
}
func stop() async {
await captureService.stopSession()
framesToDisplayContinuation?.finish()
framesToAnalyzeContinuation?.finish()
}
}
// MARK: - CameraModel Extension Method
extension CameraModel {
func distributeDisplayFrames() async {
// 표시 스트림에서 프레임을 for-await로 읽어 frame을 갱신 → 화면이 즉시 최신 프레임을 그림.
}
func distributeAnalyzeFrames() async {
// 분석 스트림에서 프레임을 for-await으로 읽어 analyze 작업을 수행 -> matchedObservations 값 산출.
}
func setDefaultZoom() async {
// 초기 줌 값 세팅
}
func handleZoomButtonTapped() async {
// 줌 버튼 탭으로 인한 동작 분기
}
func zoom(to factor: CGFloat) async {
// zoomFactor = await deviceService.zoom(to: factor)
}
func toggleTorch() async {
// isTorchOn = await deviceService.toggleTorch
}
func toggleCamera() {
// isCameraPaused 여부 분기처리
}
}
// MARK: - CameraModel Private Extension Method
private extension CameraModel {
func setupStream() {
// View에 보여주기 위한 frame 스트림과 Continuation 생성
// 텍스트 인식 분석을 위한 frame 스트림과 Continuation 생성
}
func analyze(_ buffer: CVImageBuffer) async {
// Vision에 이미지 버퍼를 전달해 텍스트 관찰 배열을 얻고 recognizedTextObservations 갱신
}
func filterMatchedObservations() -> [RecognizedTextObservation] {
// 이미지로부터 인식된 텍스트를 searchKeyword와 비교해 필터링 (View에 표시할 바운딩 박스 반환)
}
}
// MARK: - Camera Model AVCaptureVideoDataOutputSampleBufferDelegate
extension CameraModel: AVCaptureVideoDataOutputSampleBufferDelegate {
nonisolated func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
guard sampleBuffer.isValid, let imageBuffer = sampleBuffer.imageBuffer else { return }
Task { @MainActor in
guard !isCameraPaused else { return }
framesToDisplayContinuation?.yield(imageBuffer)
framesToAnalyzeContinuation?.yield(imageBuffer)
}
}
}
중요하게 위 코드에서 봐야하는 포인트가 있다면, FF!p에서 사용한 Model은 @Observable (#1)과 @MainActor (#2)로 선언되었습니다.
그 이유를 살펴보자면요 !
#1.
View에서 보여지는 카메라 프레임을 담고 있는 frame,
이미지로부터 인식된 텍스트와 그 위치, 그리고 searchKeyword와 동일하다고 판별된 필터링된 항목 matchedObservations,
그리고 기타 카메라 설정과 관련된 프로퍼티들 (searchKeyword, zoomFactor, isCameraPaused, isTorchOn)은 모두 View에서 관찰 가능한 속성이어야 합니다.
-> Model에 속해있는 프로퍼티의 값이 변경되면 -> View는 변경을 인지하고 -> 해당하는 부분의 UI를 리렌더링하는 과정을 수행하기 위해 Model은 @Observable 타입으로 생성되었습니다.
#2.
위와 같은 맥락으로 Model에서 수행하는 작업은 모두 UI 작업과 직접적으로 연결되어 있기 때문에,
Model의 모든 저장 프로퍼티 접근/변경 작업과 메서드 호출을 메인 스레드로 고정해주기 위해 @MainActor를 붙여주게 되었습니다.
*무거운 Vision 작업은 위에서 선언했던 actor ~Service에서 수행되고, Model은 그 요청에 대한 결과를 받았을 때만 Main에서 상태를 갱신하므로 @MainActor라도 UI가 막히지 않은채 동작을 유도할 수 있습니다.
**SwiftUI의 UI 작업이 메인 스레드에서 왜 동작해야하는지 궁금하다면 해당 글 링크를 참조하길 권장드립니다 ^__^
View는 이 Model을 의존하고 있습니다.
더 자세하게, View는 Model을 @Bindable로 선언해 뷰와 모델 간 바인딩을 진행하고, Model의 속성을 관찰해 View에 반영하고 있는 모습이죠.
Model이 제공하는 파이프라인은 View의 .task { ... } 구문에서 실행합니다.
*.task { ... } 구문은 View가 실행되는 시점에 SwiftUI가 비동기로 실행하는 블록입니다.
- cameraModel.start() : 앱의 핵심 기술을 수행하기 위해 필요한 사전 세팅
- cameraModel.distributeFrames() 두 작업 : 뷰를 그리기 위한 스트림 + 분석을 위한 스트림 소비하면서 카메라 frame 소비
import SwiftUI
struct CameraView: View {
@Bindable var cameraModel: CameraModel
var body: some View {
ZStack {
// 카메라 기능과 관련된 UI 어쩌구 코드 ....
FrameView(image: cameraModel.frame)
// 프레임에서 인식된 텍스트에 박스 UI를 그리는 어쩌구 코드 ...
ForEach(cameraModel.matchedObservations, id: \.self) { observation in
Box(observation: observation)
}
// 나머지 UI 어쩌구 코드 ...
}
.task {
await cameraModel.start()
Task { await cameraModel.distributeDisplayFrames() }
Task { await cameraModel.distributeAnalyzeFrames() }
}
.onDisappear {
Task { await cameraModel.stop() }
}
}
}
지금과 같은 코드 구조로 (View-Model-Service) 앱의 핵심 기능을 구현할 수 있습니다.
하지만, CameraModel 코드를 보면 개선할 수 있는 지점이 보였죠.
지금 Model이 갖고 있는 책임을 크게 두 가지로 나눠볼 수 있었습니다.
심지어 카메라와 관련된 작업은 UI와 직접 연결되어 메인 스레드 (@MainActor)에서 수행해야하지만 / Vision 작업은 백그라운드에서 수행해도 상관이 없는 작업이죠.
쉽게 말해 SOLID 중 S, 단일 책임 원칙에 부합하도록 코드를 리팩토링할 필요가 있다고 판단하게 됩니다.
- 하나는 AVFoundation에 의존하는 카메라 세션 처리 / 스트림 생성 / 카메라 세부 기능 (플래시, 줌, 포커스)과 관련된 카메라 책임.
- 또 다른 하나는 Vision 프레임워크에 의존하는 텍스트 인식 (TextRecognition)과 관련된 머신러닝 모델 관련 책임.
3. CameraModel의 분리: Camera의 책임과 Vision의 책임을 하나의 Model이 갖는게 맞을까?
단일 책임 원칙 (Single Responsibility Principle)에 부합하도록 Model을 두개로 분리해봅시다.
우선, 기존 CameraModel에 들어가 있는 모든 프로퍼티와 메서드들을 정리해보고
AVFoundation을 의존해 카메라 책임을 갖는 것이 아니라 Vision에 의존성을 가져 VisionModel을 분리할 수 있는 부분을 볼드로 표시해봤습니다.
*start() 메서드에는 Camera와 Vision의 작업이 중첩되어있어 별도로 분리할 필요성이 존재했습니다. 아래 표에는 표시하지 않습니다.
의존성 | privacyService, captureService, deviceService | 카메라 권한 / 카메라 캡처 세션 / 카메라 디바이스에 대한 책임을 갖는 하위 모듈 |
visionService | 텍스트 인식을 수행하는 ML 프레임워크 Vision을 의존하는 하위 모듈 | |
상태 | frame | 최신 카메라 프레임을 저장하며, FrameView(image:)로 UI를 나타내는 원천 |
searchKeyword | 텍스트 매칭 기준이 되는 키워드 문자열 값 | |
recognizedObservations | 이미지 버퍼로부터 추출된 모든 TextObservation을 담고 있는 배열 | |
matchedObservations | 위 배열에서 searchKeyword로 필터링된 TextObservation을 담고 있는 배열 | |
framesTo~Stream | 각각 View/Analyze 용도 프레임 비동기 스트림 (AsyncStream) | |
framesTo~Continuation | 각각 View/Analyze 용도 비동기 스트림의 입구 (Continuation) | |
zoomFactor, isCameraPaused, isTorchOn | 각각 현재 줌 배율값, 일시정지 플래그, 토치 (플래시) 상태를 나타내는 값 | |
메서드 | distributeDisplayFrames() | View 용도 비동기 스트림 소비 루프. 프레임을 UI 상태에 반영하는 용도 |
distributeAnalyzeFrames() | 분석 용도 비동기 스트림 소비 루프. 이미지를 분석 메서드에 전달하는 용도 | |
setupStream() | 두 개의 AsyncStream 생성. bufferingPolicy도 지정하고 Continuation에 보관 용도 | |
analyze(_ buffer:) | VisionService에 있는 TextRecognition 수행 메서드 호출 용도 | |
filterMatchedObservations() | searchKeyword 기반 필터링 처리 용도 | |
기타 카메라 관련 메서드들 | 초기 줌, 줌 변경, 토치 (플래시) on/off 처리, 카메라 멈춤 토글 처리 등등... |
그리고 위 표에 담긴 구분에 의해 파일을 (VisionModel-CameraModel 관계로) 쪼개볼 수 있었습니다.
@preconcurrency import AVFoundation
@Observable
final class CameraModel: NSObject {
private(set) var frame: CVImageBuffer?
private(set) var framesToDisplayStream: AsyncStream<CVImageBuffer>?
private(set) var framesToAnalyzeStream: AsyncStream<CVImageBuffer>?
private var framesToDisplayContinuation: AsyncStream<CVImageBuffer>.Continuation?
private var framesToAnalyzeContinuation: AsyncStream<CVImageBuffer>.Continuation?
private(set) var zoomFactor: CGFloat = 2.0
private(set) var isCameraPaused: Bool = false
private(set) var isTorchOn: Bool = false
private let privacyService: PrivacyService
private let captureService: VideoCaptureService
private let deviceService: VideoDeviceService
init(
privacyService: PrivacyService,
captureService: VideoCaptureService,
deviceService: VideoDeviceService,
) {
self.privacyService = privacyService
self.captureService = captureService
self.deviceService = deviceService
}
func start() async {
await privacyService.fetchCameraAuthorization()
await deviceService.fetchVideoDevice()
guard let videoDevice = await deviceService.currentDevice else { return }
setupStream()
await captureService.configureSession(device: videoDevice, delegate: self)
await deviceService.setAutoFocusMode()
await setDefaultZoom()
}
func stop() async {
await captureService.stopSession()
framesToDisplayContinuation?.finish()
framesToAnalyzeContinuation?.finish()
}
}
// MARK: - CameraModel Extension Method
extension CameraModel {
func distributeDisplayFrames() async {
// 표시 스트림에서 프레임을 for-await로 읽어 frame을 갱신 → 화면이 즉시 최신 프레임을 그림.
}
// 기타 카메라 관련 View 접근 가능 메서드들....
}
// MARK: - CameraModel Private Extension Method
private extension CameraModel {
func setupStream() {
// View에 보여주기 위한 frame 스트림과 Continuation 생성
// 텍스트 인식 분석을 위한 frame 스트림과 Continuation 생성
}
}
// MARK: - Camera Model AVCaptureVideoDataOutputSampleBufferDelegate
extension CameraModel: AVCaptureVideoDataOutputSampleBufferDelegate {
nonisolated func captureOutput(
_ output: AVCaptureOutput,
didOutput sampleBuffer: CMSampleBuffer,
from connection: AVCaptureConnection
) {
guard sampleBuffer.isValid, let imageBuffer = sampleBuffer.imageBuffer else { return }
Task { @MainActor in
guard !isCameraPaused else { return }
framesToDisplayContinuation?.yield(imageBuffer)
framesToAnalyzeContinuation?.yield(imageBuffer)
}
}
}
import Vision
@Observable
final class VisionModel: NSObject {
private let visionService: VisionService
private(set) var searchKeyword: String
private(set) var recognizedTextObservations = [RecognizedTextObservation]()
private(set) var matchedObservations = [RecognizedTextObservation]()
init(
searchKeyword: String,
visionService: VisionService
) {
self.searchKeyword = searchKeyword
self.visionService = visionService
}
func distributeAnalyzeFrames(_ stream: AsyncStream<CVImageBuffer>?) async {
// 분석 스트림에서 프레임을 for-await으로 읽어 analyze 작업을 수행 -> matchedObservations 값 산출.
}
}
private extension VisionModel {
func filterMatchedObservations() -> [RecognizedTextObservation] {
// 이미지로부터 인식된 텍스트를 searchKeyword와 비교해 필터링 (View에 표시할 바운딩 박스 반환)
}
}
CameraModel은 카메라 세팅과 동작에 관련한 책임 / VisionModel은 모델 요청과 응답 처리에 대한 책임으로 각각 분리되었습니다.
이제 View의 .task { ... } 블록 내의 코드가 바뀐 부분만 확인해보면 됩니다.
표시를 위한 비동기 스트림 소비 루프 (cameraModel)와 분석을 위한 비동기 소비 루프 (visionModel)를 하나의 TaskGroup으로 묶어줄 수 있었습니다.
🔗 비구조적 (unstructured) 동시성과 구조적 (structured) 동시성에 대해
이전 .task 블록 안에서 두 개의 Task { ... }를 호출하는 방식은 애플의 표현 방식대로라면 비구조적 태스크에 해당합니다.
이런 경우 부모와 별도로 따로 떨어져 선언된 태스크이기 때문에 / 부모가 사라졌다하더라도 외부에서 계속 돌아갈 수 있다는 문제가 있죠.
*View는 이미 disappear되었는데 Task 내부의 작업이 지연되어 View의 frame을 갱신하려는 작업이 진행될 수 있다는 내용입니다.
반면, withTaskGroup을 사용해서 각각의 작업이 하나의 구조에 묶는 경우는 구조적 태스크라 표현합니다.
이런 경우 부모의 범위 (.task -> View)에 수명이 묶이게 되어 명시적인 작업의 완료/정리가 보장되도록 강제할 수 있습니다.
자세한 내용은 나중에 Task를 깊게 다루는 별도의 글로 다뤄보도록 하겠습니다!
또한, cameraModel.start()에서 한번에 수행하던 Vision Model의 준비 작업 (prepare)도 이제는 task에서 명시적으로 호출해줘야 합니다.
두 개의 Model로 명확하게 나뉘어져서 첫 문제 상황보다는 나아진 것 같지만, 아직도 제 눈에는 개선할 포인트가 보였네요.
글을 이어가보도록 하겠습니다 !
import SwiftUI
struct CameraView: View {
@Bindable var cameraModel: CameraModel
@Bindable var visionModel: VisionModel
var body: some View {
ZStack { ... }
.task {
await cameraModel.start()
await visionModel.prepare()
await withTaskGroup(of: Void.self) { group in
group.addTask {
await cameraModel.distributeDisplayFrames()
}
group.addTask {
await visionModel.distributeAnalyzeFrames(cameraModel.framesToAnalyzeStream)
}
await group.waitForAll()
}
}
.onDisappear {
Task { await cameraModel.stop() }
}
}
}
4. 내가 보려고 정리하는 중재자 패턴 (Mediator Pattern)
잠깐 여기서 중재자 패턴 (Mediator Pattern)이라는 디자인 패턴의 개념을 짚어보고자 합니다.
중재자 패턴 (Mediator Pattern)이란 "중재자 (Mediator)"라는 객체를 통해서만 객체 간 상호작용을 가능하게 함으로써 / 객체 간 직접적인 연관성을 낮추기 위해 활용할 수 있는 디자인 패턴입니다.
여러 객체 간 상호작용은 반드시 Mediator를 통해서만 이루어지록 구성되므로,
객체 간 상호 의존성은 줄어들고 / 상호작용은 중앙에 집중되고 / 통신 과정은 독립적으로 동작할 수 있게 이루어진다는 장점이 있는 패턴이죠.
중재자 패턴의 이론대로라면 크게 3가지로 주요 파일 구조를 나눠볼 수 있습니다.
- Mediator (중재자 프로토콜) : Colleague 객체간 통신 규칙 (규약, 방법)을 정의하는 곳이며, 프로토콜 (인터페이스)로 선언합니다.
- Concrete Mediator (중재자 구현부) : Mediator 프로토콜에서 정의했던 객체간 통신 규칙의 구현부를 정의하는 곳입니다. 구체적으로는 Colleague 객체간 인터랙션이 이 부분에서 코드로 들어가게 될 것입니다.
- Colleague (동료 객체) : Mediator에 의존하고 있으며, Colleague 상호 간은 서로 알지 못하는 상태입니다. (= 의존성 x)
그러므로 Colleague 간 상호작용을 수행하고자 할 때는 반드시 Mediator를 통해서만 이루어져야 합니다.
예시를 들어보겠습니다.
여러 대의 비행기 (객체)가 공항에 착륙하거나 이륙하려고 함
만약 비행기들이 서로 직접 통신하면서 이착륙을 조율한다면? -> “나 먼저 갈게!”, “안 돼, 나 연료 없어!”와 같은 혼란이 발생하겠죠?
그래서 공항에는 관제탑이 중간에 있어 모든 비행기를 조율할 수 있는 역할을 수행하게 됩니다.
직접 서로 통신하지 않고, 관제탑을 통해서만 의사소통을 주고받는 "비행기" -> "Colleague" 가 해당
비행기들의 요청을 받아 이착률을 조율하는 "공항 관제탑" -> "Mediator" 가 해당
// 공항 관제탑
protocol AirTrafficControl {
func requestTakeOff(from airplane: Airplane) async
}
// 공항 관제탑 구현부
final class ControlTower: AirTrafficControl {
private var queue = [Airplane]()
private var isRunwayAvailable = true
func requestTakeOff(from airplane: Airplane) async {
if isRunwayAvailable {
// 착륙 Action
isRunwayAvailable = false
print("🛫 \(airplane.name) is taking off!")
try? await Task.sleep(for: for: .seconds(duration))
self.isRunwayAvailable = true
print("✅ Runway is now available.")
await processNextInQueue()
} else {
print("🕓 Runway busy. \(airplane.name) added to queue.")
queue.append(airplane)
}
}
private func processNextInQueue() {
guard !queue.isEmpty else { return }
let next = queue.removeFirst()
await requestTakeOff(from: next)
}
}
final class Airplane {
let name: String
let controlTower: AirTrafficControl
init(
name: String,
controlTower: AirTrafficControl
) {
self.name = name
self.controlTower = controlTower
}
func requestTakeOff() {
print("📡 \(name) requesting takeoff.")
Task {
await controlTower.requestTakeOff(from: self)
}
}
}
간단하죠?
중재자 패턴 (Mediator Pattern)의 핵심은 Collegue간의 상호작용은 직접 Collegue가 하는 것이 아니라 Mediator가 담당한다.
위에서 봤던 코드에도 상호작용이 이루어지는 Collegue 객체들이 존재했기 때문에, 이 중재자 패턴 (Mediator Pattern)을 코드 구조에도 적용할 수 있지 않을까라는 생각을 하게 되었습니다.
어느 부분에 중재자 패턴을 적용했는지 마지막 부분으로 넘어가보죠 !
- UI를 그리는데 있어 CameraModel과 VisionModel 간 상호작용
- CameraModel의 프로세스를 처리하는데 있어 PrivacyService, VideoCaptureService, VideoDeviceService 간 상호작용
5. Mediator 계층이 객체 간 상호작용을 담당하도록 하자
커밍쑨 ~!
GitHub - DeveloperAcademy-POSTECH/2025-C4-M13-Visionable: 이 레포 ⭐ 안누르면 모기 물림🦟 - 라고 후랑크가
이 레포 ⭐ 안누르면 모기 물림🦟 - 라고 후랑크가 씀. Contribute to DeveloperAcademy-POSTECH/2025-C4-M13-Visionable development by creating an account on GitHub.
github.com