목차
- 소개
- MVVM 패턴이란?
- 프로젝트 구조
- 구현 단계
- 앱 설정
- 모델 구현
- API 서비스
- ViewModel
- UI 컴포넌트
- 핵심 기능 분석
- 성능 최적화
- 마무리 및 다음 단계
1. 소개
안녕하세요!
이번 글에서는 SwiftUI와 MVVM 아키텍처 패턴을 활용하여 네이버 API를 이용한 책 검색 앱을 만드는 방법을 알아보겠습니다. 단순히 작동하는 앱을 만드는 것을 넘어, 유지보수가 용이하고 확장성 있는 코드 구조를 만드는데 초점을 맞출 것입니다.
이 프로젝트는 백엔드 개발자인 제가 SwiftUI를 학습하며, 실제 API를 사용하는 앱을 만드는 과정과 MVVM 패턴의 실전 적용 방법을 보여드리기 위해 작성하였습니다.
2. MVVM 패턴이란?
MVVM(Model - View - ViewModel)은 사용자 인터페이스와 비즈니스 로직을 분리하는 소프트웨어 아키텍처 패턴입니다.
특히 SwiftUI와 같은 선언적 UI 프레임워크와 궁합이 좋습니다.
MVVM 주요 구성 요소
- Model : 앱의 데이터와 비즈니스 로직을 담당합니다.
- View : 사용자에게 보여지는 UI 요소를 정의합니다.
- ViewModel : View와 Model 사이에 중개자 역할을 하며, View에 필요한 데이터를 가공합니다.
SwiftUI에서의 MVVM 장점
- 코드 재사용성 향상 : 각 컴포넌트가 독립적으로 작동하므로 다른 프로젝트에서도 쉽게 재사용할 수 있습니다.
- 테스트 용이성 : 비즈니스 로직이 UI와 분리되어 있어서 단위 테스트가 용이합니다.
- 유지보수 향상 : 각 계층이 분리되어 있어 특정 부분만 수정할 수 있습니다.
- SwiftUI의 상태 관리와 조화 : @Published, @ObservableObject 등 SwiftUI의 상태 관리 기능이 자연스럽게 연결됩니다.
3. 프로젝트 구조
폴더명 | 파일명 | 비고 |
App | bookApp.swift | 앱의 진입점 |
AppConstants.swift | 앱 전체의 사용되는 상수값 | |
Models | BookModel.swift | 데이터 모델 정의 |
Views | BookSearchView.swift | 메인 검색 화면 |
BookListView.swift | 책 목록 컴포넌트 | |
BookListItemView.swift | 개별 책 항목 컴포넌트 | |
ViewModels | BookSearchViewModel.swift | 검색 화면의 뷰모델 |
Services | NaverBookAPIService.swift | 네이버 API 통신 관련 로직 |
Utilities | StringExtension.swift | 문자열 확장 기능 |
4. 구현 단계
앱 설정
먼저 앱의 진입점과 전역 상수를 설정합니다.
bookApp.swift
import SwiftUI
@main
struct bookApp: App {
var body: some Scene {
WindowGroup {
BookSearchView()
}
}
}
- @main 속성은 앱의 진입점을 표시합니다. SwiftUI 앱에서는 main 함수를 직접 작성할 필요가 없습니다.
- App 프로토콜을 준수하는 구조체가 앱의 생명주기를 관리합니다.
- WindowGroup은 앱의 창을 나타내며, 여기서는 BookSearchView를 초기 화면으로 설정합니다.
AppConstants.swift
import Foundation
struct AppConstants {
struct NaverAPI {
static let baseURL = "https://openapi.naver.com/v1/search/book.json"
static let clientID = "네이버 API에서 등록한 ClientId"
static let clientSecret = "네이버 API에서 등록한 clientSecret"
static let defaultDisplay = 20
}
struct UI {
static let appTitle = "도서 검색"
static let searchPlaceholder = "책 제목 또는 저자 검색"
static let emptySearchAlertMessage = "검색어를 입력해주세요"
}
struct ErrorMessages {
static let apiError = "API 요청 중 오류가 발생하였습니다."
static let noDataReceived = "데이터를 받지 못했습니다."
static let decodingError = "데이터 처리 중 오류가 발생하였습니다."
}
}
- 상수를 중앙화하며 관리하면 나중에 변경이 필요할 때 한 곳에서만 수정하면 됩니다.
- 네임스페이스를 이용해 논리적으로 그룹화하여 코드 가독성을 높였습니다.
- API 키는 실제 사용시 별도 환경 설정 파일이나 안전한 저장소에 보관하는 것이 좋습니다.
- 네이버 API 는 아래의 링크를 참조하시면 됩니다.
- https://developers.naver.com/docs/serviceapi/search/book/book.md#%EC%B1%85
검색 > 책 - Search API
검색 > 책 책 검색 개요 개요 검색 API와 책 검색 개요 검색 API는 네이버 검색 결과를 뉴스, 백과사전, 블로그, 쇼핑, 웹 문서, 전문정보, 지식iN, 책, 카페글 등 분야별로 볼 수 있는 API입니다. 그 외
developers.naver.com
모델 구현
BookModel.swift
import Foundation
struct NaNaverBookResponse: Codable {
let lastBuildDate: String
let total: Int
let start: Int
let display: Int
let items: [BookModel]
}
struct BookModel: Codable, Identifiable {
// Identifiable 프로토콜을 준수한 id
var id: String{ isbn }
let title: String
let link: String
let image: String
let author: String
// let price: String
let discount: String
let publisher: String
let pubdate :String
let isbn: String
let description: String
// html 태그 제거
var cleanTitle: String {
return title.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
}
var cleanAuthor: String {
return author.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
}
}
- Codable 프로토콜을 채택하여 JSON 파싱을 쉽게 처리합니다.
- Identifiable 프로토콜을 채택하여 List 에서 각 항목을 고유하게 식별할 수 있게 합니다.
- HTML 태그를 제거하는 계산 속성을 추가했습니다. 네이버 API는 종종 결과에 HTML 태그를 포함시키기 때문입니다.
- 책의 ISBN을 고유 식별자(id)로 사용했습니다. ISBN은 책마다 고유한 값을 가지므로 이상적인 식별자입니다.
API 서비스
NaverBookAPIService.swift
import Foundation
class NaverBookAPIService {
// 싱글톤 패턴 적용
static let shared = NaverBookAPIService()
private init() {}
// 결과를 받아올 때 성공/실패를 함께 처리할 수 있는 Result 타입 적용
func searchBooks(query: String, display: Int = AppConstants.NaverAPI.defaultDisplay, completion: @escaping (Result<[BookModel], Error>) -> Void) {
// 검색어 인코딩
guard let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) else {
completion(.failure(APIError.invalidQuery))
return
}
// URL 생성
let urlString = "\(AppConstants.NaverAPI.baseURL)?query=\(encodedQuery)&display=\(display)"
guard let url = URL(string: urlString) else {
completion(.failure(APIError.invalidURL))
return
}
// 요청 생성
var request = URLRequest(url: url)
request.httpMethod = "GET"
request.addValue(AppConstants.NaverAPI.clientID, forHTTPHeaderField: "X-Naver-Client-Id")
request.addValue(AppConstants.NaverAPI.clientSecret, forHTTPHeaderField: "X-Naver-Client-Secret")
// 요청 실행
URLSession.shared.dataTask(with: request) { data, response, error in
// 에러 체크
if let error = error {
completion(.failure(error))
return
}
// 데이터 체크
guard let data = data else {
completion(.failure(APIError.noData))
return
}
// 디버깅을 위한 로그
#if DEBUG
if let jsonString = String(data: data, encoding: .utf8) {
print("API 응답 데이터 : \(jsonString)")
}
#endif
// 디코딩
do {
let bookResponse = try JSONDecoder().decode(NaNaverBookResponse.self, from: data)
completion(.success(bookResponse.items))
}
catch {
print("디코딩 오류 : \(error)")
completion(.failure(error))
}
}.resume()
}
}
// API 관련 오류 정의
enum APIError : Error {
case invalidQuery
case invalidURL
case noData
case decodingError
}
extension APIError : LocalizedError {
var errorDescription: String? {
switch self {
case .invalidQuery:
return "검색어가 유효하지 않습니다."
case .invalidURL:
return "API URL이 유효하지 않습니다."
case .noData:
return "데이터를 받지 못했습니다."
case .decodingError:
return "데이터 변환 중 오류가 발생했습니다."
}
}
}
- 싱글톤 패턴 : shared 인스턴스를 통해 앱 전체에서 하나의 API 서비스 인스턴스만 사용하도록 합니다.
- Result 타입 : Swift의 Result 타입을 사용하여 성광과 실패 케이스를 깔끔하게 처리합니다.
- 안전한 옵셔널 처리 : guard let 을 사용하여 조기에 실패 케이스를 처리합니다.
- 커스텀 에러 타입 : APIError 를 정의하여 API 호출과 관련된 특정 오류를 명확히 표현합니다.
- LocalizedError 확장 : 에러 메시지를 사용자 친화적으로 제공합니다.
- 디버깅 모드 로깅 : 디버그 모드에서만 로그를 출력하여 개발 중에만 필요한 정보를 보여줍니다.
ViewModel
BookSearchViewModel.swift
import Foundation
import Combine
class BookSearchViewModel : ObservableObject {
@Published var searchText = ""
@Published var books: [BookModel] = []
@Published var isLoading = false
@Published var showAlert = false
@Published var errorMessage: String?
// API 서비스
private let apiService = NaverBookAPIService.shared
// 검색 기능
func searchBooks() {
// 검색어 검증
let query = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
if query.isEmpty {
showAlert = true
return
}
// 로딩 상태 설정
isLoading = true
books = []
// API 호출
apiService.searchBooks(query: query) { [weak self] result in
DispatchQueue.main.async {
self?.isLoading = false
switch result {
case .success(let books):
self?.books = books
// 결과 로깅
print("검색 결과 : \(books.count)권의 책을 찾았습니다.")
case .failure(let error):
self?.errorMessage = error.localizedDescription
print("검색 오류 : \(error.localizedDescription)")
}
}
}
}
}
- ObservableObject 프로토콜 : SwiftUI의 View가 ViewModel의 변경 사항을 관찰할 수 있게 합니다.
- @Published 속성 래퍼 : 값이 변경될 때마다 View에 자동으로 알림을 보냅니다.
- 메모리 관리 : API 콜백에서 [weak self]를 사용하여 순환 참조를 방지합니다.
- 메인 스레드 처리 : UI 업데이트는 DispatchQueue.main.async 를 통해 메인 스레드에서 수행합니다.
- 상태 관리 : 로딩 상태, 오류 메시지 등을 관리하여 View가 적절하게 반응할 수 있게 합니다.
- 관심사 분리 : ViewModel은 View에 필요한 데이터와 상태만 관리하며, 실제 API 호출은 service에 위임합니다.
UI 컴포넌트
BookListItemView.swift
import SwiftUI
struct BookListItemView: View {
let book: BookModel
var body: some View {
VStack(alignment: .leading, spacing: 5) {
Text(book.cleanTitle)
.font(.title3)
.lineLimit(2)
HStack{
Text(book.cleanAuthor)
.font(.subheadline)
.foregroundStyle(.secondary)
Spacer()
Text(book.publisher)
.font(.caption)
.foregroundStyle(.gray)
}
Text(book.description)
.lineLimit(4)
.font(.subheadline)
.padding(10)
.foregroundColor(.blue)
.frame(maxWidth: .infinity, alignment: .leading)
.background(Color(.systemGray6))
.cornerRadius(10)
}
.padding(.vertical, 4)
}
}
- 컴포넌트 분리 : 개별 책 항목 UI를 별도 컴포넌트로 분리하여 재사용성을 높였습니다.
- 레이아웃 구성 : VStack 과 HStack 을 적절히 조합하여 가독성 좋은 UI를 구성했습니다.
- 정렬 및 간격 : alignment 와 spacing 매개변수를 활용하여 세련된 레이아웃을 만들었습니다.
- 데이터 가공 : 모델의 cleanTitle, cleanAuthor 계산 속성을 사용하여 HTML 태그 없는 텍스트를 표시합니다.
BookListView.swift
import SwiftUI
struct BookListView: View {
let books: [BookModel]
let isLoading: Bool
var body: some View {
Group {
if isLoading {
ProgressView()
.progressViewStyle(CircularProgressViewStyle())
.scaleEffect(1.5)
.padding()
}
else if books.isEmpty {
VStack(spacing: 10){
Image(systemName: "book.closed")
.font(.system(size: 50))
.foregroundColor(.gray)
.padding()
Text("검색 결과가 없습니다.")
.font(.headline)
.foregroundStyle(.gray)
}
}
else{
List(books) { book in
BookListItemView(book: book)
}
.listStyle(.plain)
}
}
}
}
- 조건부 뷰 : 로딩 상태와 빈 결과에 따라 다른 UI를 표시합니다.
- SF Symbols 활용 : Apple의 SF Symbols(book.closed)을 사용하여 시각적 일관성을 유지합니다.
- 리스트 자동 식별 : BookModel 이 Identifiable 을 구현하므로 List 에서 id 매개변수 없이 사용할 수 있습니다.
- 컴포넌트 재사용 : 각 책 항목에 BookListItemView 를 활용하여 코드를 깔끔하게 유지합니다.
- 상태 분리 : 실제 데이터와 상태는 외부에서 주입받아 단방향 데이터 흐름을 유지합니다.
BookSearchView.swift
import SwiftUI
struct BookSearchView: View {
@StateObject private var viewModel = BookSearchViewModel()
var body: some View {
NavigationView {
VStack{
// 검색 바
HStack {
TextField(AppConstants.UI.searchPlaceholder, text: $viewModel.searchText)
.padding(10)
.background(Color(.systemGray6))
.cornerRadius(8)
.padding(.leading)
.onSubmit {
viewModel.searchBooks()
}
Button(action: {
viewModel.searchBooks()
}) {
Image(systemName: "magnifyingglass")
.foregroundColor(.white)
.padding(10)
.background(Color.blue)
.cornerRadius(8)
}
.padding(.trailing)
}
.padding(.bottom)
// 책 목록 뷰
BookListView(books: viewModel.books, isLoading: viewModel.isLoading)
Spacer()
}
.navigationTitle(AppConstants.UI.appTitle)
.alert(AppConstants.UI.emptySearchAlertMessage, isPresented: $viewModel.showAlert) {
Button("확인", role: .cancel){}
}
.alert(isPresented: .init(
get: {viewModel.errorMessage != nil},
set: { if !$0 {viewModel.errorMessage = nil}}
)){
Alert(title: Text("오류"),
message: Text(viewModel.errorMessage ?? "알 수 없는 오류가 발생했습니다."),
dismissButton: .default(Text("확인")))
}
}
}
}
- @StateObject : ViewModel을 생성하고 View의 생명주기 동안 유지합니다.
- NavigationView : 탐색 기능을 위한 컨테이너를 제공합니다.
- 양방향 바인딩 : $viewModel.searchText로 텍스트 필드와 ViewModel 상태를 연결합니다.
- onSubmit : iOS 15부터 제공되는 기능으로 키보드의 검색 버튼을 눌렀을 때 실행됩니다.
- 여러 알림 처리 : 빈 검색어와 API 오류에 대한 별도의 알림을 설정합니다.
- Binding 커스터마이징 : errorMessage를 바인딩으로 변환하여 alert과 연결합니다.
- 컴포넌트 주입 : 하위 컴포넌트에 필요한 데이터만 전달하여 의존성을 제한합니다.
핵심 기능 분석
네이버 API 호출 과정
네이버 API 호출은 앱의 핵심 기능입니다. 이 과정을 단계별로 분석해 보겠습니다.
- 사용자 입력 : BookSearchView에서 사용자가 검색어를 입력하고 검색 버튼을 누릅니다.
- ViewModel 처리 : BookSearchViewModel의 searchBooks() 메서드가 호출됩니다.
- 입력 검증 : 검색어가 비어있는지 확인하고, 비어있다면 알림을 표시합니다.
- 로딩 상태 설정 : isLoading을 true로 설정하여 로딩 인디케이터를 표시합니다.
- 서비스 호출 : NaverBookAPIService의 searchBooks(query:) 메서드를 호출합니다.
- API 요청 준비 : 검색어 인코딩, URL 생성, 요청 헤더 설정 등을 수행합니다.
- 네트워크 요청 : URLSession을 사용하여 실제 네트워크 요청을 보냅니다.
- 응답 처리 : 응답을 받아 NaverBookResponse 모델로 디코딩합니다.
- 결과 반환 : 성공 또는 실패 결과를 ViewModel에게 전달합니다.
- UI 업데이트 : ViewModel은 메인 스레드에서 상태를 업데이트하고, SwiftUI는 자동으로 UI를 업데이트합니다.
MVVM 데이터 흐름
MVVM 패턴에서 데이터는 다음과 같이 흐릅니다.
- 사용자 동작: View에서 사용자 동작이 발생합니다. (검색어 입력, 버튼 탭)
- ViewModel 호출: View는 ViewModel의 메서드를 호출합니다.
- 서비스 호출: ViewModel은 필요한 경우 Service 계층의 메서드를 호출합니다.
- 모델 업데이트: Service는 API 호출 결과로 받은
- 상태 변경: API 응답으로 ViewModel의 상태(@Published 속성)가 업데이트됩니다.
- UI 업데이트: SwiftUI는 ViewModel의 상태 변화를 감지하고 자동으로 UI를 업데이트합니다.
성능 최적화
메모리 관리
- 클로저에서의 [weak self]: API 콜백에서 [weak self]를 사용하여 메모리 누수를 방지합니다.
- 싱글톤 패턴: NaverBookAPIService를 싱글톤으로 구현하여 메모리 사용을 최적화합니다.
효율적인 UI 업데이트
- 지연 로딩: 필요한 데이터만 로드하여 네트워크 및 메모리 사용을 최소화합니다.
- 상태 기반 UI: 로딩 상태에 따라 다른 UI를 표시하여 사용자 경험을 향상시킵니다.
- 컴포넌트 분리: UI 컴포넌트를 작은 단위로 분리하여 불필요한 리렌더링을 방지합니다.
고려해 볼 추가 최적화
- 디바운싱 적용: 검색어 입력마다 API를 호출하지 않고, 일정 시간 후 최종 입력값으로 호출하도록 합니다.
- 페이지네이션: 한 번에 모든 결과를 로드하지 않고, 사용자가 스크롤할 때 추가 결과를 로드합니다.
- 이미지 캐싱: 책 표지 이미지를 캐싱하여 중복 다운로드를 방지합니다.
- 오프라인 지원: 검색 결과를 로컬에 저장하여 오프라인 상태에서도 이전 결과를 볼 수 있게 합니다.
마무리
이번 포스트에서는 SwiftUI와 MVVM 패턴을 활용하여 네이버 책 검색 앱을 구현해 보았습니다. 이 프로젝트는 단순하면서도 실용적인 앱을 만드는 과정을 보여주며, MVVM 아키텍처의 장점을 실제로 경험할 수 있게 해 줍니다.
배운 점
- SwiftUI와 MVVM 패턴의 효과적인 결합 방법
- API 서비스 구현 및 비동기 데이터 처리
- 상태 관리와 UI 업데이트의 자동화
- 컴포넌트 기반 UI 설계
'SwiftUI' 카테고리의 다른 글
iOS 개발자라면 반드시 알아야 할 UserDefaults 완전 정복! (0) | 2025.05.24 |
---|---|
Immutable value 'error' was never used; consider replacing with '_' or removing it 메시지는 무엇일까? (1) | 2025.05.24 |
FCM에서 iOS 앱 푸시알림을 위한 APN 키 등록하기 (0) | 2025.05.20 |
SwiftUI에서 웹뷰 구현하기 (웹사이트 표시부터 인터랙션까지) (0) | 2025.05.11 |
SwiftUI의 프로퍼티 래퍼 완벽 가이드 (0) | 2025.04.25 |