CoreML (1)
Machine Learning이란
머신러닝은 새로운 개념이 아니다. 1959년 인공지능 초기 선구자인 Arthur Samuel이 처음 소개한 용어로, “컴퓨터가 명시적으로 프로그래밍되지 않고도 학습할 수 있는 능력을 부여하는 학문 분야”라고 정의했다. 계속 코드를 다시 써줘서가 아니라, 경험을 통해 스스로 작업을 더 잘하게 되는 것을 학습이라고 본 것이다.
기존 프로그래밍으로 풀 수 없는 문제
식당에 차를 몰고 간다고 해보자. 지역을 알고 있어서 30분쯤 걸릴 거라 예상하고 안전하게 40분 일찍 출발했는데, 교통 체증에 갇혀 결국 예약을 놓친다.
이런 예측 문제는 기존 프로그래밍으로 풀 수 없다. 가능한 모든 교통 상황에 대한 규칙을 일일이 쓸 수는 없기 때문이다. 대신 Apple Maps나 Waze 같은 앱은 수백만 건의 과거 이동 기록, 현재 교통 데이터, 실시간 사용자 제보를 통해 학습한다. 데이터를 많이 처리할수록 예측 정확도가 올라가지만, 100% 정확하지는 않다. 어디까지나 패턴을 기반으로 한 추측이라, 아직 보고되지 않은 사고나 도로 폐쇄는 인지하지 못해 예측이 빗나간다.
결정론적 vs 확률론적
- 기존 프로그래밍은 결정론적(deterministic)이다. “x가 있으면 y가 나온다”
- 머신러닝은 확률론적(probabilistic)이다. “x가 주어지면 y일 가능성이 92% 신뢰도로 높다”
이메일 스팸 필터가 좋은 예다. “제목에 ‘나이지리아 왕자’가 포함되면 스팸으로 표시” 같은 규칙을 수백만 개 손으로 짠 사람은 없다. 대신 수백만 개의 스팸/정상 이메일 사례로 학습해서, 스팸에 자주 나타나는 패턴(특정 단어, 발신자 행동, 서식 스타일)을 스스로 식별한다. 그래서 학습 당시엔 존재하지도 않았던 새로운 스팸도 잡아낼 수 있다.
머신러닝이 여는 영역
규칙 기반으로는 불가능하거나 비현실적인 문제들이 있다. 사진 속 얼굴을 인식하는 규칙, 어떤 고객이 구독을 취소할지 예측하는 규칙, 신용카드 거래가 사기인지 판단하는 규칙을 어떻게 프로그래밍할 것인가. 전부 규칙으로 짤 수 없지만, 알고리즘에 수천 개의 예제를 보여주고 패턴을 찾게 할 수는 있다.
기계가 “학습한다”는 건 실제로는 최적화다. 알고리즘이 내부 parameter를 조정해가면서 학습 데이터에 대한 예측 오차를 최소화한다. 모든 규칙을 일일이 프로그래밍하지 않고도 학습 데이터로부터 수학적 모델을 구축하고, 그 모델로 예측이나 의사결정을 내리는 것. 명시적인 지침이 아니라 예제를 기반으로 스스로 작성되는 코드라고 보면 된다.
실제 활용 사례와 한계
- 은행: 과거 데이터 패턴으로 대출 부실 위험 예측
- 의료: X-ray나 MRI에서 질병을 시사하는 패턴 식별
- 스트리밍: 시청 습관 패턴을 학습해 다음 콘텐츠 추천
- 공장: 센서 데이터로 장비 고장 예측
- Uber: 실시간 수요/공급에 따른 가격 조정
한계도 분명하다. 과거 패턴을 기반으로 예측하기 때문에 상황이 갑자기 바뀌면 예측이 실패한다. 그래서 새로운 데이터로 모델을 정기적으로 재학습시켜야 한다.
이제는 모든 개발자의 도구
- iPhone의 Neural Engine은 예전에 슈퍼컴퓨터가 필요했던 모델을 실행할 수 있다
- 앱 상호작용, 센서 값, 거래 기록 등 모든 것이 학습에 쓸 수 있는 데이터가 된다
- Core ML, Create ML 같은 framework 덕분에 박사 학위 없이도 앱에 모델을 붙일 수 있다
머신러닝은 더 이상 거대 기술 기업만의 영역이 아니다.
세 가지 학습 방식
머신러닝은 지도 학습(supervised learning), 비지도 학습(unsupervised learning), 강화 학습(reinforcement learning), 이렇게 세 가지 방식으로 나뉜다.
지도 학습
레이블이 붙은 예시로 모델을 학습시켜서, 새로운 데이터에 대한 결과를 예측하게 만드는 방식이다. 입력과 예상 출력이 모두 알려진 학습 데이터에 의존한다. 레이블 붙은 샘플을 입력하면, 알고리즘이 데이터와 정답 사이를 연결하는 방법을 스스로 학습하고, 학습이 끝난 모델(predictor)은 처음 보는 새로운 데이터에 대해서도 예측을 수행한다.
개 품종 분류기가 좋은 예다. 학습 데이터는 비글, 래브라도, 저먼 셰퍼드 등으로 레이블이 붙은 개 사진 폴더들로 구성된다. 각 폴더에는 다양한 포즈, 조명, 배경의 이미지가 들어있어야 하고, 품종별로 사진 수가 비슷해야 모델 균형이 맞는다. 학습 데이터가 더 우수하고 다양할수록 예측 정확도도 올라간다.
비지도 학습
입력 데이터 자체에서 숨겨진 구조나 패턴을 찾는 방식이다. 레이블이 지정된 예제에서 시작하지 않는다. 대규모 원시 데이터를 그대로 제공하면 시스템이 스스로 패턴이나 클러스터, 상관관계를 발견한다.
예를 들어 웹사이트들을 과학, 엔터테인먼트, 스포츠, 기술 같은 주제별로 그룹화하는 알고리즘은 그 주제가 뭔지에 대한 정보를 전혀 받지 않는다. 데이터에서 유사점을 찾아 그에 따라 스스로 정리할 뿐이다.
강화 학습
시행착오를 통해 모델을 훈련하는 방식이다. 레이블이 지정된 데이터에서 배우는 게 아니라, 환경과 직접 상호작용하면서 좋은 행동엔 보상을, 나쁜 행동엔 벌칙을 받는 식으로 시간이 지나면서 어떤 행동이 최선의 결과를 가져오는지 파악해간다.
체스나 바둑을 마스터하는 게임 플레이 AI가 대표적인 사례다. 수백만 번 스스로 게임을 플레이하면서 승패로부터 학습한다. 자율주행이나 로보틱스도 같은 원리로, 에이전트가 피드백을 받으면서 더 나은 결정을 내리는 법을 배워간다.
정리
지도 학습은 레이블이 붙은 예제로부터 배우는 방식, 비지도 학습은 레이블 없는 데이터에서 숨겨진 패턴을 발견하는 방식, 강화 학습은 피드백과 경험을 통해 배우는 방식이다. 이 중 지도 학습을 중점적으로 다룬다.
지도 학습 워크플로우
세 단계 워크플로우
앱에서 지도 학습을 쓰려면 다음 세 단계를 따른다.
- 학습 예제를 수집하고 레이블을 지정한다
- 머신러닝 모델을 학습시킨다
- 학습된 모델로 새로운 데이터에 대한 예측을 수행한다
Apple이나 오픈소스 커뮤니티가 제공하는 사전 학습된(pre-trained) 모델을 쓰면 1, 2단계를 완전히 건너뛸 수 있다. 이미 완전히 훈련된 비서가 앱에 바로 투입될 준비가 되어 있는 셈이다.
학습이란 무엇인가
머신러닝 모델은 본질적으로 예측 엔진이지만, 유용한 예측을 하려면 먼저 학습을 거쳐야 한다.
아이에게 글씨 쓰는 법을 가르치는 것과 비슷하다. “A는 이렇게 쓴다”, “이건 B다”처럼 예시를 보여주면, 충분한 연습 끝에 곡선, 직선, 비율 같은 패턴을 스스로 인식하기 시작한다. 결국 한 번도 본 적 없는, 연습한 예시와 완전히 똑같지도 않은 지저분한 손글씨까지 읽을 수 있게 된다. 모델을 학습시키는 것도 이와 같은 원리다.
성공적인 학습에는 양질의 학습 예제와 학습 시스템 둘 다 필요하다. Create ML은 데이터의 패턴과 관계를 감지하는 정교한 알고리즘, 즉 학습 시스템을 제공한다. 이 알고리즘이 예제를 분석해서 정확한 예측을 위한 규칙을 자동으로 발견한다. “이게 있으면 저거”라는 로직을 수동으로 코딩할 필요 없이, 시스템이 스스로 알아낸다.
좋은 데이터의 조건
모델의 품질은 전적으로 학습 데이터에 달려있다. 편향되거나 불완전하거나 부족한 예시를 주면 잘못된 예측을 하게 된다. 동화책만 읽고 언어를 배우려는 것과 비슷해서, 어휘가 제한되고 표현이 어색해질 수밖에 없다.
학습 데이터셋이 갖춰야 할 조건은 세 가지다.
- 충분히 커야 한다: 예시가 많을수록 패턴 인식이 좋아진다
- 다양해야 한다: 실제로 마주칠 법한 edge case와 변형까지 포함해야 한다
- 균형이 맞아야 한다: 골든 리트리버 1000장, 치와와 10장으로 개 품종 분류기를 학습시키면 안 된다
사례: 스팸 탐지의 진화
초창기 이메일 프로그램은 “‘나이지리아 왕자’가 포함되면 스팸으로 표시” 같은 단순한 규칙을 썼다. 역사 수업에서 나이지리아 왕족을 논하는 정상적인 이메일은 여기 걸려버린다. 이런 규칙은 금방 너무 경직된 것으로 드러났다.
그다음엔 “발신자를 모르고 의심스러운 링크가 있으면 스팸”이라는 조금 더 스마트한 규칙이 도입됐지만, 잠재 고객이 보낸 자기 회사 웹사이트 링크가 담긴 첫 이메일 같은 경우엔 여전히 문제가 됐다.
최신 스팸 필터는 다른 방식으로 접근한다. 작성 패턴과 문법, 발신자 평판, 시간대 패턴처럼 수천 가지 feature를 동시에 분석한다. 스팸은 종종 이상한 시간에 도착하고, 대문자를 과도하게 쓰거나 느낌표를 남발하는 경향도 있다. 사용자가 이메일을 스팸으로 표시하거나 스팸 폴더에서 다시 꺼내는 행위 자체가 새로운 학습 예시가 되어, 나중에 모델을 재학습시켜 새로운 스팸 패턴과 개인별 취향에 적응하는 데 쓰인다.
Create ML과 Core ML로 할 수 있는 것
이미지 분류, object detection, 텍스트 감정 분석, Vision Pro의 공간 컴퓨팅 환경에서 실제 물체를 추적하는 것까지, “좋은 데이터 + 스마트한 학습 = 정확한 예측”이라는 원칙은 동일하게 적용된다.
Create ML이 복잡한 수학과 모델 학습 과정을 알아서 처리해준다. 할 일은 좋은 학습 데이터를 제공하고 모델이 무엇을 학습할지 정의하는 것뿐이다. 특별한 하드웨어 없이 Mac의 프로세서만으로 모델을 효율적으로 훈련할 수 있다.
Core ML이란
Apple은 iOS 11에서 Core ML을 도입하면서, 개발자가 앱에 머신러닝을 통합하는 방식을 근본적으로 바꿨다.
고수준 프레임워크로서의 Core ML
Core ML 이전에도 머신러닝 라이브러리는 있었지만, 깊은 기술 전문성과 방대한 설정·최적화 작업이 필요했다. Core ML은 고수준 프레임워크다. 저수준 라이브러리를 직접 다루지 않고도 머신러닝 모델을 앱에 통합할 수 있는 간단한 기본 인터페이스를 제공하며, 복잡한 수학과 최적화는 전부 Core ML이 처리해줘서 머신러닝이 어떻게 동작하는지보다 앱의 기능 자체에 집중할 수 있다.
모델 로딩, 최적화, 실행 같은 무거운 작업을 전부 처리하면서도 앱을 빠르고 메모리 효율적이고 배터리 친화적으로 유지하고, Apple Silicon의 CPU, GPU, Neural Engine 중 그 작업에 가장 적합한 하드웨어를 자동으로 골라 쓴다.
On-Device와 프라이버시
모든 예측은 사진, 텍스트, 음성 녹음 같은 디바이스 상의 사용자 데이터를 기반으로 이루어지며, 그 데이터는 기기를 절대 벗어나지 않는다. 신뢰를 유지하고 최신 개인정보 보호 표준을 충족하는 핵심 요소다.
메모리와 배터리 소모 측면에서도 효율적이라, 머신러닝 기능 때문에 배터리가 급격히 닳거나 기기가 과열되는 일이 없다.
Core ML 기반의 도메인별 프레임워크
Apple은 내부적으로 Core ML을 사용하는 여러 도메인별 프레임워크를 함께 제공한다.
- Vision: 이미지 분류, object detection, 얼굴 추적, 텍스트 인식
- Natural Language: 텍스트 분석, tokenization, 감정 감지
- Speech: 음성 인식 및 전사
- Sound Analysis: 웃음 같은 환경음이나 박수, 음악처럼 사람 또는 주변에서 나는 소리 식별
이 프레임워크들은 자유롭게 조합해서 쓸 수 있다. 사진 속 사물을 식별하고 그에 대한 설명 문장을 생성하려면 Vision과 Natural Language를, 음성을 텍스트로 바꾸고 그 감정을 분석하려면 Speech와 Natural Language를 함께 쓰면 된다.
Apple Intelligence와 Core ML의 관계
iOS 18에서 Apple은 생성형 AI 기능을 기기에 직접 제공하는 개인정보 보호 중심 시스템인 Apple Intelligence를 도입했다. 글쓰기 지원, 이미지 생성, 지능형 요약 같은 생성형 AI 작업을 위한 강력한 foundation model이 여기 포함된다.
Apple Intelligence가 시스템 레벨에서 많은 AI 기능을 처리해주긴 하지만, 여전히 자체적인 Core ML 모델을 학습시키고 사용해서 고유한 AI 기능을 제공할 수 있다.
Core ML 도입 이후 모델 성능이 계속 향상되어 온 것과 더불어, Create ML 앱 덕분에 모델 학습에 대한 접근성도 훨씬 쉬워졌고 통합 과정도 더 매끄러워졌다.
개 품종 분류기 만들기
Apple의 시각적 도구인 Create ML로 이미지 분류기를 직접 훈련해본다. Mac에서 코딩 없이, 잘 정리된 학습 데이터와 몇 번의 클릭, 약간의 인내심만으로 모델을 훈련할 수 있다는 게 Create ML의 핵심이다. 목표는 사진 속 개 품종을 인식하는 분류기를 만들고, 완성된 모델을 Vision framework로 SwiftUI 앱에 통합하는 것이다.
학습 데이터 준비하기
학습 데이터는 일정한 구조로 정리해야 한다. 여기서는 이미지가 폴더별로 그룹화되는 디렉토리 구조 기반 형식을 쓴다.
Kaggle에서 개 품종 이미지 데이터셋을 받아 썼다. 17,000장이 넘는 다양한 견종 이미지가 들어있다. 압축을 풀면 “개 품종 이미지 데이터셋”이라는 폴더가 있고, 그 안에 견종별로 레이블이 붙은 하위 폴더들이 있다.
폴더 안 이미지 파일의 이름 자체는 특별한 규칙을 따를 필요가 없다. 중요한 건 내용물이다. 예를 들어 보더콜리 폴더를 만들려면 폴더 이름을 그렇게 짓고, 그 안에 보더콜리 사진을 10장 이상 넣으면 된다. 물론 10장은 최소치일 뿐이고, 이미지가 많을수록 좋다.
이미지 크기는 다양해도 되지만 299×299 픽셀보다 큰 게 좋다. 정확도를 높이려면 동일하거나 거의 같은 이미지는 피해야 하고, 다양한 각도와 조명 조건에서 찍힌 사진들로 구성해야 한다. 마지막으로, 레이블 붙은 폴더마다 이미지 수가 거의 비슷해야 한다.
테스트 데이터는 따로 준비하기
모델을 검증할 테스트 이미지도 필요하다. 학습 데이터에서 일부를 떼어 테스트셋을 만들 수도 있지만, 테스트 이미지와 학습에 쓴 이미지가 겹치지 않도록 아예 다른 데이터셋을 쓰는 게 더 안전한 전략이다.
그래서 Kaggle의 다른 개 품종 데이터셋을 테스트용으로 따로 받았다. 500장이 넘는 이미지와 8개 견종으로 구성되어 있다. 압축을 풀고 최상위 폴더 이름을 “테스트 데이터”로 바꿨다. 혼동을 피하기 위해 테스트 데이터셋도 학습 데이터와 마찬가지로, 견종별로 이름 붙은 폴더가 최소 2개 이상 있고 각 폴더에 100장 이상의 이미지가 들어있어야 한다. 이 데이터셋이 목적에 딱 맞았다.
이제 학습 데이터셋과 테스트 데이터셋이 모두 준비됐으니, 다음은 실제로 개 품종 분류기 모델을 훈련하는 단계다.
Create ML 앱으로 모델 학습시키기
Apple의 Create ML 앱으로 이미지 분류기를 훈련한다. 코드 한 줄 없이, 시각적인 인터페이스만으로 머신러닝 모델을 학습시킬 수 있는 독립 애플리케이션이다.
Create ML 실행하기
Xcode와 함께 번들로 제공되기 때문에 별도로 설치할 필요가 없다. 실행하는 방법은 여러 가지다.
Cmd + Space로 Spotlight를 띄운 뒤 “Create ML” 입력 후 Enter- Dock의 Xcode 아이콘을 Control-클릭 → “Open Developer Tool” → “Create ML”
- Xcode가 실행 중이면 상단 메뉴의 Xcode → “Open Developer Tool” → “Create ML”
프로젝트 생성하기
시작 화면에서 “New Project”를 선택하고, 템플릿 목록에서 “Image Classification”을 골라 Next를 누른다. 프로젝트 이름은 공백 없이 DogBreedClassifier로 짓고, 설명은 “개 품종을 식별하는 모델” 정도로 적었다. 저장 위치를 지정하고 Create를 누르면 프로젝트가 만들어진다.
학습/테스트 데이터 추가하기
인터페이스에서 Training Data 영역에 앞서 준비한 학습 데이터 폴더를 추가한다.
Create ML이 폴더 구조를 자동으로 분석해서 하위 폴더를 기준으로 이미지 카테고리를 인식한다. 이번 경우 157개 클래스(견종), 총 17,498장의 이미지가 감지됐다.
이어서 Testing Data 영역에 테스트 데이터 폴더도 추가한다. 8개 클래스, 541장이 인식됐다.
이 테스트 데이터는 학습 중에 한 번도 보지 못한 이미지에서 모델이 얼마나 잘 작동하는지 평가하는 용도다.
학습 옵션 설정하기
Training 버튼을 누르기 전에 몇 가지 옵션을 설정할 수 있다.
- Feature Extractor: 이미지에서 특징을 추출할 때 쓰는 방식이다. Image Feature Print V1은 구형 OS까지 지원하지만 추출된 특징의 크기가 크고, 학습이 느리고, 메모리 사용량도 더 높다. iOS/macOS 사용자는 보통 시스템을 최신으로 유지하니 V2를 선택했다.
- Augmentations: flip, rotation, blur 같은 이미지 변환을 적용하면 모델이 다양한 각도와 조건에서도 견종을 더 잘 인식하게 된다. noise, crop, exposure도 실험해볼 수 있지만, augmentation을 많이 켤수록 학습 시간이 늘어난다. 대신 모델 정확도를 높이는 데 도움이 되기도 한다.
- Iterations: 기본값 25는 대부분의 프로젝트에 잘 맞는다. 반복 횟수가 적으면 정확도가 떨어질 수 있으니, 기본값으로 원하는 결과가 안 나오면 늘려볼 수 있다. 여기서는 기본값 25를 그대로 뒀다.
학습 진행 과정
Train 버튼을 누르면 학습이 시작된다. 데이터셋 크기와 Mac 성능에 따라 몇 분에서 그 이상까지 걸릴 수 있다. Activity Monitor를 열어보면 CPU 부하와 메모리 사용량이 확 올라가는 게 보인다. 모델 학습은 최신 프로세서에도 부담이 큰 작업이다.
진행 상황은 두 단계로 표시된다.
- Feature Extraction: 각 이미지를 분석해서 중요한 시각적 특징을 식별하는 단계
- Training: 그 특징을 바탕으로 모델이 여러 견종을 구별하는 법을 학습하는 단계
Training 단계에서는 training accuracy와 validation accuracy를 보여주는 실시간 그래프를 볼 수 있다.
결과 확인하기
학습이 끝나면 Create ML이 테스트 데이터로 모델을 자동 평가해서, 견종별 전체 정확도, precision, recall을 보여주는 상세 지표 표와 confusion matrix를 제공한다.
이번 결과는 정확도 40%로 그다지 좋지 않았다. 114장이 정확히 분류됐고 169장은 오류였다. (강의와는 다르다.(강의는 38%, 107,176))
precision과 recall의 개념을 정리하면 이렇다.
- True Positive: 모델이 어떤 이미지를 특정 품종이라고 예측했는데 실제로 그 품종인 경우 (예: 골든 리트리버라고 예측했고 실제로도 골든 리트리버)
- False Positive: 모델이 특정 품종이라고 예측했지만 실제로는 아닌 경우 (예: 골든 리트리버라고 예측했는데 실제로는 래브라도 → 골든 리트리버 카테고리 입장에서 false positive)
- False Negative: 실제로 그 품종인데 모델이 아니라고 예측한 경우 (예: 실제 골든 리트리버인데 모델이 골든 리트리버가 아니라고 예측 → 골든 리트리버를 “놓친” 셈)
Precision은 true positive를 (true positive + false positive)로 나눈 값이다. 즉 모델이 “이거다”라고 예측했을 때 그 예측이 얼마나 자주 맞는지를 나타낸다.
Recall은 true positive를 (true positive + false negative)로 나눈 값으로, 실제로 그 품종에 해당하는 이미지들을 모델이 얼마나 놓치지 않고 찾아내는지를 나타낸다. 둘 다 중요한 지표인 이유가 여기 있다.
“Top Confusions” 드롭다운을 보면 모델이 특히 헷갈려하는 견종 쌍이 드러난다. 비글은 잉글리시 하운드로, 달마시안은 그레이트 데인으로 자주 오인됐다. 정확도를 높이려면 이렇게 자주 혼동되는 견종들의 이미지를 더 크고 다양하게 채워서 다시 학습시켜야 한다.
Preview 탭으로 테스트하기
Preview 탭으로 전환해서 개별 강아지 이미지를 드래그해 넣어보면, Create ML이 각 이미지에 대한 예측 카테고리와 confidence 수준을 보여준다. 분류기가 Create ML 안에서는 잘 동작하는 걸 확인할 수 있지만, 진짜 재미는 이걸 iOS 앱에 붙였을 때 시작된다.
모델 내보내기
Output 탭에서 학습된 모델 파일과 레이블을 확인하고, Get 버튼으로 원하는 위치에 저장한다. DogBreedClassifier라는 이름으로 저장했다.
결과물인 Core ML 모델 파일은 놀랍게도 1MB가 채 안 된다. 학습에 쓴 이미지 총량이 거의 800MB였던 걸 생각하면 꽤 인상적인 압축률이다.
iOS 앱에 모델 통합하기
학습시킨 개 품종 분류기 모델을 실제 iOS 앱에서 써본다. 정적인 이미지에서 견종을 감지하는 앱을 만드는 게 목표다. 나중에 기능을 확장하고 싶으면 학습 데이터에 품종 이미지를 더 추가하고 모델을 재학습시키면 된다. 그러면 지금 인식하는 품종 외에 다른 품종도 추가로 인식할 수 있게 된다.
새 SwiftUI 프로젝트 만들기
프로젝트 이름은 DogBreedClassifier로 했다. 인터페이스는 SwiftUI로 한다.
모델 세부 정보 확인하기
좀전에 만든 모델파일을 프로젝트에 복사해주고, 프로젝트 내비게이터에서 모델 파일을 클릭하면 모델에 대한 세부 정보가 표시된다.
Xcode가 자동으로 이 모델용 Swift 클래스를 생성해주는데, 이 클래스 이름은 나중에 코드에서 그대로 쓰게 되니 기억해둬야 한다.
- Predictions 탭: 입력 요구사항(이미지 크기, 포맷)과 출력 세부사항(예측된 품종, confidence 점수)을 확인할 수 있다
- Performance 탭: 실제 기기에서 모델이 어떻게 동작하는지 테스트할 수 있다.
+버튼으로 테스트할 기기(iPhone, iPad Pro 등)를 추가하면 된다
ImageClassifierViewModel 만들기
이제 분류 로직을 처리할 ViewModel을 만든다. 프로젝트 내비게이터를 우클릭해서 “New File”을 선택하고 ImageClassifierViewModel.swift라는 이름으로 새 파일을 만든다. 이 클래스가 모델 로딩, 이미지 처리, SwiftUI 뷰로의 결과 전달까지 이미지 분류와 관련된 모든 작업을 담당한다.
import 및 ObservableObject 선언
1
2
3
4
5
6
7
8
import SwiftUI
import Combine
import Vision
class ImageClassifierViewModel: ObservableObject {
@Published var classificationLabel: String = ""
@Published var confidence: String = ""
}
ObservableObject를 채택하면 분류 결과가 바뀔 때 SwiftUI가 자동으로 UI를 업데이트할 수 있다. @Published가 붙은 프로퍼티는 값이 바뀔 때마다 뷰에 변경을 알려주는 특별한 SwiftUI 프로퍼티다. classificationLabel(예측된 품종 문자열)이나 confidence(신뢰도)가 업데이트되면, 그걸 구독 중인 인터페이스가 새 값으로 다시 렌더링된다.
classificationRequest lazy 프로퍼티
1
2
3
4
5
6
7
8
9
10
private lazy var classificationRequest: VNCoreMLRequest = {
do {
let model = try VNCoreMLModel(for: DogBreedClassifier().model)
let request = VNCoreMLRequest(model: model)
request.imageCropAndScaleOption = .centerCrop
return request
} catch {
fatalError("Failed to load Core ML model: \(error)")
}
}()
몇 가지 짚을 부분이 있다.
lazy로 선언한 이유는 이 프로퍼티에 처음 접근할 때만 생성되게 하기 위해서다. 머신러닝 모델 로딩처럼 비용이 큰 작업에 적합한 방식이다DogBreedClassifier는 Xcode가 모델 파일로부터 자동 생성해준 클래스 이름이다. 프로젝트 내비게이터에서.mlmodel파일을 클릭하면 이 클래스 이름을 확인할 수 있다VNCoreMLModel(for:)는 실패할 수 있는 작업이라try가 붙고, 그래서 전체를do-catch로 감싼다. 모델이 이미지를 입력으로 받지 않는 등 문제가 생기면fatalError로 즉시 실행을 멈추고 에러 설명을 출력하게 했다. 개발 중에 문제를 빠르게 발견하는 데 유용하다VNCoreMLRequest는 위에서 만든VNCoreMLModel을 인자로 받아 생성한다imageCropAndScaleOption을.centerCrop으로 설정하면, 모델이 기대하는 입력 크기와 맞지 않는 이미지가 들어왔을 때 Vision이 이미지 중앙을 기준으로 잘라서 처리한다. 개 사진은 보통 피사체가 사진 중앙에 있는 경우가 많아서, 이 옵션이 개 품종 분류기와 잘 맞는다
classify(_:) 메서드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public func classify(image: UIImage) {
guard let ciImage = CIImage(image: image) else {
classificationLabel = "Unable to process image."
return
}
Task {
let handler = VNImageRequestHandler(ciImage: ciImage, orientation: .up)
do {
try handler.perform([classificationRequest])
processClassifications(request: classificationRequest, error: nil)
} catch {
processClassifications(request: classificationRequest, error: error)
}
}
}
몇 가지 짚을 부분이 있다.
- Vision framework는
CIImage를 다루지만, 이미지 선택기나 카메라는UIImage를 준다. 그래서 입력받은UIImage를CIImage로 변환하는 과정이 필요하다. 변환이 실패할 가능성은 드물지만 있을 수 있어서guard let으로 조기 종료하고 에러 메시지를 표시한다 VNImageRequestHandler를 만들고, 이 처리 과정을Task로 감싸서 비동기 컨텍스트를 만든다. 이렇게 하면 이미지 분류가 백그라운드에서 이루어져서 UI가 멈추지 않는다. 특히 구형 기기에서는 Vision 처리에 시간이 걸릴 수 있어서, 메인 스레드를 막지 않는 게 중요하다orientation은.up으로 지정해서 이미지가 똑바로 서 있는 상태라고 Vision에 알려준다handler.perform(_:)은 이미지가 손상되었거나 모델에 문제가 있을 때 에러를 던질 수 있어서try로 감싸고do-catch로 처리한다. 이 메서드는 여러 요청을 동시에 처리할 수 있어서 배열을 받는데, 여기서는 분류 요청 하나만 필요하므로 단일 항목 배열을 전달한다- 성공하면
processClassifications(request:error:)를error: nil로 호출하고, 실패하면catch블록에서 같은 메서드를 에러와 함께 호출해서 사용자에게 표시할 수 있게 한다
processClassifications(request:error:) 메서드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
@MainActor
class ImageClassifierViewModel: ObservableObject {
private func processClassifications(request: VNRequest, error: Error?) {
guard let results = request.results else {
self.classificationLabel = "Could not classify the image.\n\(error?.localizedDescription ?? "Unknown error")"
self.confidence = "-"
return
}
let classifications = results as! [VNClassificationObservation]
if classifications.isEmpty {
classificationLabel = "Nothing recognized"
confidence = ""
} else {
let topClassifications = classifications.prefix(2)
let descriptions = topClassifications.map { classification in
"\(classification.identifier): \(Int(classification.confidence * 100))%"
}
self.classificationLabel = descriptions.joined(separator: "\n")
}
}
}
이 메서드는 UI를 안전하게 업데이트해야 하니 class 전체에 @MainActor를 붙여서 항상 메인 스레드에서 실행되게 한다.
- 먼저
request.results가nil인지 확인한다.nil이면 모델에 문제가 생긴 것이니 에러 메시지로classificationLabel을 채우고,confidence도"-"로 표시한 뒤 조기 종료한다 results를[VNClassificationObservation]으로 강제 캐스팅(as!)한다.VNClassificationObservation은 각각identifier(예: “골든 리트리버” 같은 품종 이름)와 0~1 사이의confidence점수를 담고 있다. 0.85면 85% 신뢰도라는 뜻이다. 이 배열은 이미 신뢰도 순으로 정렬되어 있어서, 가장 확신도 높은 예측이 맨 앞에 온다- 결과가 비어있으면
classificationLabel을 “Nothing recognized”로,confidence는 빈 문자열로 설정한다 - 결과가 있으면
prefix(2)로 상위 두 예측만 가져온다. 더 많이 가져올 수도 있지만, 보통 상위 두 개만으로도 모델이 얼마나 확신하는지 판단할 수 있다. 예를 들어 골든 리트리버 85%, 래브라도 10%라면 모델이 꽤 확신하고 있다는 뜻이고, 골든 리트리버 40%, 래브라도 38%처럼 비슷하다면 이미지가 애매하거나 혼종일 가능성을 시사한다 map으로 각 결과를 “품종명: 신뢰도%” 형태의 문자열로 변환하고, 줄바꿈으로 이어붙여서classificationLabel에 담는다. 이 케이스에서는confidence를 따로 설정하지 않는다
이렇게 해서 ViewModel이 완성됐다. Core ML 모델을 로드하고, Vision으로 이미지를 처리하고, 보기 좋게 정리된 결과를 만들어내는 역할을 전부 담당한다.
SwiftUI 인터페이스 만들기
ViewModel이 준비됐으니 이제 사용자 인터페이스를 만든다. SwiftUI 덕분에 이 부분은 비교적 간단하다. 카메라로 사진을 찍거나 사진 라이브러리에서 이미지를 선택할 수 있는 인터페이스를 ContentView에 구성한다.
기본 구조: ViewModel과 State
1
2
3
4
5
6
7
struct ContentView: View {
@StateObject private var viewModel = ImageClassifierViewModel()
@State private var selectedImage: UIImage?
@State private var showingImagePicker: Bool = false
@State private var showingCamera: Bool = false
@State private var sourceType: UIImagePickerController.SourceType = .photoLibrary
}
@StateObject는 뷰의 전체 생명주기 동안 유지되는 프로퍼티에 붙인다. 이렇게 하면 ImageClassifierViewModel이 이 뷰의 단일한 source of truth가 된다.
나머지는 @State로 선언한 프로퍼티들인데, 이미지 선택기나 카메라를 보여줄지 같은 일시적인 UI 상태를 추적하는 용도다. sourceType은 UIImagePickerController.SourceType 타입으로, 이미지 선택기를 카메라로 띄울지 사진 라이브러리로 띄울지를 결정한다. 기본값은 .photoLibrary로 뒀다.
이미지 표시 영역
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if let image = selectedImage {
Image(uiImage: image)
.resizable()
.scaledToFit()
.frame(maxHeight: 400)
.cornerRadius(12)
.shadow(radius: 5)
} else {
ZStack {
RoundedRectangle(cornerRadius: 12)
.fill(Color.gray.opacity(0.2))
.frame(height: 400)
VStack(spacing: 10) {
Image(systemName: "photo")
.font(.system(size: 60))
.foregroundColor(.gray)
Text("Select or capture an image")
.foregroundColor(.secondary)
}
}
}
if let으로 이미지 선택 여부를 확인한다. 이미지가 있으면 둥근 모서리와 은은한 그림자를 준 Image view로 보여주고, 아직 선택 전이라면 회색 배경의 RoundedRectangle 위에 사진 아이콘과 안내 문구를 올린 placeholder를 보여준다.
1
2
3
4
5
6
7
8
9
10
11
if !viewModel.classificationLabel.isEmpty {
VStack(alignment: .leading, spacing: 8) {
Text(viewModel.classificationLabel)
.font(.system(.body, design: .monospaced))
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color.blue.opacity(0.1))
.cornerRadius(8)
}
.padding(.horizontal)
}
분류 결과는 ViewModel의 classificationLabel이 비어있지 않을 때만 표시된다. @Published 프로퍼티가 채워지는 순간 이 뷰가 자동으로 나타난다. monospace 폰트로 텍스트를 표시해서 가독성을 높이고, 옅은 파란색 배경으로 결과 영역을 눈에 띄게 만들었다.
카메라/사진 라이브러리 버튼과 시트
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
HStack(spacing: 20) {
Button(action: {
showingCamera = true
}) {
Label("Camera", systemImage: "camera.fill")
.frame(maxWidth: .infinity)
.padding()
.background(Color.blue)
.foregroundColor(.white)
.cornerRadius(10)
}
Button(action: {
showingImagePicker = true
}) {
Label("Photo Library", systemImage: "photo.on.rectangle")
.frame(maxWidth: .infinity)
.padding()
.background(Color.green)
.foregroundColor(.white)
.cornerRadius(10)
}
}
.padding(.horizontal)
.padding(.bottom)
파란색 카메라 버튼과 초록색 사진 라이브러리 버튼, 두 가지 액션 버튼을 둔다. 각 버튼은 일관된 아이콘을 위해 SF Symbols를 쓴다. 카메라 버튼은 showingCamera를, 사진 라이브러리 버튼은 showingImagePicker를 각각 true로 바꾼다.
1
2
3
4
5
6
.sheet(isPresented: $showingImagePicker) {
ImagePicker(image: $selectedImage, sourceType: .photoLibrary)
}
.sheet(isPresented: $showingCamera) {
ImagePicker(image: $selectedImage, sourceType: .camera)
}
.sheet(isPresented:) modifier로 이미지 선택기를 모달 시트로 띄운다. 사진 라이브러리용, 카메라용 두 개의 시트가 따로 있고, 각각의 isPresented 바인딩(showingImagePicker, showingCamera)이 시트가 언제 뜰지 결정한다. sourceType은 각 시트에서 .photoLibrary, .camera로 직접 지정해서 ImagePicker로 전달한다.
onChange로 자동 분류 트리거
1
2
3
4
5
.onChange(of: selectedImage) { oldValue, newValue in
if let image = newValue {
viewModel.classify(image: image)
}
}
onChange modifier가 selectedImage의 변경을 감시한다. 사용자가 이미지를 선택하거나 촬영하는 즉시 이 클로저가 자동으로 실행되면서 viewModel.classify(image:)가 호출된다. 버튼을 따로 눌러야 하는 수고 없이, 이미지가 정해지는 순간 바로 분류가 시작되는 구조다.
여기까지 작성하면 ImagePicker가 아직 정의되지 않아서 컴파일 에러가 몇 개 뜨는데, 카메라와 사진 라이브러리 접근을 감싸는 UIViewControllerRepresentable 래퍼를 별도로 구현해야 한다.
UIViewControllerRepresentable로 UIKit 감싸기
ImagePicker.swift 파일을 새로 만든다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import SwiftUI
import UIKit
import UniformTypeIdentifiers
struct ImagePicker: UIViewControllerRepresentable {
@Binding var image: UIImage?
@Environment(\.dismiss) private var dismiss
let sourceType: UIImagePickerController.SourceType
var allowsEditing: Bool = false
func makeUIViewController(context: Context) -> UIImagePickerController {
let picker = UIImagePickerController()
if UIImagePickerController.isSourceTypeAvailable(sourceType) {
picker.sourceType = sourceType
} else {
picker.sourceType = .photoLibrary
}
picker.mediaTypes = [UTType.image.identifier]
picker.allowsEditing = allowsEditing
picker.delegate = context.coordinator
return picker
}
func updateUIViewController(_ uiViewController: UIImagePickerController, context: Context) {}
func makeCoordinator() -> Coordinator {
Coordinator(self)
}
@MainActor
class Coordinator: NSObject, UIImagePickerControllerDelegate, UINavigationControllerDelegate {
let parent: ImagePicker
init(_ parent: ImagePicker) {
self.parent = parent
}
func imagePickerController(_ picker: UIImagePickerController, didFinishPickingMediaWithInfo info: [UIImagePickerController.InfoKey: Any]) {
if parent.allowsEditing, let edited = info[.editedImage] as? UIImage {
parent.image = edited
} else if let original = info[.originalImage] as? UIImage {
parent.image = original
}
picker.dismiss(animated: true)
parent.dismiss()
}
func imagePickerControllerDidCancel(_ picker: UIImagePickerController) {
picker.dismiss(animated: true)
parent.dismiss()
}
}
}
UIViewControllerRepresentable은 UIKit 컴포넌트를 SwiftUI에서 쓸 수 있게 감싸는 프로토콜이다. ImagePicker 구조체가 이걸 채택함으로써, SwiftUI 앱 안에서 UIKit의 UIImagePickerController를 그대로 활용할 수 있다.
몇 가지 방어적인 처리가 들어가 있다.
UIImagePickerController.isSourceTypeAvailable(sourceType)로 요청한 source(카메라 등)를 실제로 쓸 수 있는지 먼저 확인한다. 시뮬레이터에는 카메라가 없는 등, 기기에 따라 특정 source가 없을 수 있어서 이런 경우.photoLibrary로 대체한다mediaTypes를[UTType.image.identifier]로 제한해서 동영상이 선택 목록에 뜨지 않게 한다allowsEditing옵션으로 사용자가 사진을 자르는 편집 UI를 쓸지 말지 선택할 수 있게 해뒀다
Coordinator 클래스는 UIImagePickerController의 delegate 역할을 한다. UI를 다루는 콜백이라 class 전체에 @MainActor를 붙여서 메인 스레드에서 실행되도록 보장한다. 사용자가 이미지를 선택하면 imagePickerController(_:didFinishPickingMediaWithInfo:)가 호출되는데, allowsEditing이 켜져있고 편집된 이미지가 있으면 그걸, 아니면 원본 이미지를 가져와서 parent.image를 업데이트한다. 사용자가 취소하면 imagePickerControllerDidCancel(_:)이 호출되어 그냥 picker를 닫는다.
@Environment(\.dismiss)로 가져온 dismiss를 통해, picker 자체의 dismiss(animated:)뿐 아니라 이 뷰를 present한 SwiftUI 쪽 sheet도 함께 닫아준다.
개인정보 보호 설명 추가하기
카메라나 사진 라이브러리에 접근하려면 Info 설정에 privacy usage description을 추가해야 한다. 이게 없으면 접근 시도 시 앱이 바로 crash한다.
프로젝트 내비게이터에서 프로젝트를 선택하고, 앱 타겟을 고른 뒤 Info 탭으로 이동한다. + 버튼으로 새 항목을 추가한다.
- Privacy - Camera Usage Description: “강아지 사진을 찍으려면 카메라 액세스가 필요합니다.”
- Privacy - Photo Library Usage Description: “개 이미지를 선택하려면 사진 라이브러리 액세스 권한이 필요합니다.”
이 문구들은 앱이 카메라나 사진 라이브러리 접근을 처음 요청할 때 iOS가 사용자에게 보여주는 권한 요청 알림에 그대로 나타나는 사용자 대상 메시지다.
실제로 테스트해보기
시뮬레이터나 실제 기기에서 앱을 실행할 수 있다. 시뮬레이터를 쓴다면 먼저 사진 라이브러리에 개 사진들을 채워둬야 한다. 이때 학습 데이터셋에 포함되지 않은 이미지를 쓰는 게 중요하다. 학습 단계에서 한 번도 보지 못한 이미지를 모델이 얼마나 잘 분류하는지 확인하는 게 목적이기 때문이다.
이미지를 선택하면 분류 결과가 이미지 아래에 자동으로 표시되고, 상위 두 개의 예측이 각각의 confidence 점수와 함께 나타난다.
겪었던 문제: “Failed to create espresso context”
시뮬레이터에서 실행했더니 이런 에러가 났다.
“Espresso”는 Core ML/Vision이 내부적으로 쓰는 신경망 추론 엔진 이름이다. 이 엔진이 모델을 실행할 context(연산 그래프)를 만들려면 Neural Engine이나 특정 GPU 연산 경로가 필요한 경우가 있는데, 시뮬레이터는 실제 기기의 Neural Engine을 갖고 있지 않아서 이 context 생성 자체가 실패하는 것이었다. 특히 .mlpackage(mlprogram) 형식으로 export된 모델을 쓰는 Vision 요청에서 자주 보고되는 문제이고, Apple 엔지니어도 “시뮬레이터가 이 요청을 실행할 능력이 없는 게 정상 동작이며, 실제 기기에서는 해결된다”고 공식적으로 확인해준 사례가 있다.
해결 방법은 두 가지다.
- 실제 기기에서 테스트하기: 가장 확실한 방법이다.
computeUnits을 CPU 전용으로 강제하기: 모델 로딩 시 Neural Engine/GPU를 거치지 않고 CPU로만 실행하도록 설정하면 우회되는 경우가 있다.
1
2
3
4
let config = MLModelConfiguration()
config.computeUnits = .cpuOnly
let model = try VNCoreMLModel(for: DogBreedClassifier(configuration: config).model)
기본값인 .all 대신 .cpuOnly로 바꾸면, 시뮬레이터에서도 Espresso가 CPU 경로로만 context를 만들려고 시도해서 이 에러가 사라지는 경우가 많다. 다만 실기기보다 속도는 느려질 수 있다.
그래도 안되어서 실기기에서 해보았다.
확실히 정확도가 낮은만큼 사진에따라 견종에 대해서 분석을 잘될때도 있고 안될때도 있었다.



















