본문 바로가기
SwiftUI

SwiftUI와 MVVM 패턴으로 구현한 네이버 책 검색 앱 만들기

by 향테크 2025. 5. 18.

SwiftUI 책 검색 앱 만들기

 

목차

  1. 소개
  2. MVVM 패턴이란?
  3. 프로젝트 구조
  4. 구현 단계
    1. 앱 설정
    2. 모델 구현
    3. API 서비스
    4. ViewModel
    5. UI 컴포넌트
  5. 핵심 기능 분석
  6. 성능 최적화
  7. 마무리 및 다음 단계

 

1. 소개

안녕하세요!

이번 글에서는 SwiftUI와 MVVM 아키텍처 패턴을 활용하여 네이버 API를 이용한 책 검색 앱을 만드는 방법을 알아보겠습니다. 단순히 작동하는 앱을 만드는 것을 넘어, 유지보수가 용이하고 확장성 있는 코드 구조를 만드는데 초점을 맞출 것입니다.

 

이 프로젝트는 백엔드 개발자인 제가 SwiftUI를 학습하며, 실제 API를 사용하는 앱을 만드는 과정과 MVVM 패턴의 실전 적용 방법을 보여드리기 위해 작성하였습니다.

 

앱 처음 접근했을때 화면
도서 검색시 화면

 

2. MVVM 패턴이란?

MVVM(Model - View - ViewModel)은 사용자 인터페이스와 비즈니스 로직을 분리하는 소프트웨어 아키텍처 패턴입니다.

특히 SwiftUI와 같은 선언적 UI 프레임워크와 궁합이 좋습니다.

 

MVVM 주요 구성 요소

  1. Model : 앱의 데이터와 비즈니스 로직을 담당합니다.
  2. View : 사용자에게 보여지는 UI 요소를 정의합니다.
  3. 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 태그 없는 텍스트를 표시합니다.

BookListItemView Preview

 

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 를 활용하여 코드를 깔끔하게 유지합니다.
  • 상태 분리 : 실제 데이터와 상태는 외부에서 주입받아 단방향 데이터 흐름을 유지합니다.

로딩 중
결과 없음 UI

 

결과 있음 UI

 

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과 연결합니다.
  • 컴포넌트 주입 : 하위 컴포넌트에 필요한 데이터만 전달하여 의존성을 제한합니다.

BookSearchView

 

도서 검색 결과 화면

 

핵심 기능 분석

네이버 API 호출 과정

네이버 API 호출은 앱의 핵심 기능입니다. 이 과정을 단계별로 분석해 보겠습니다.

  1. 사용자 입력 : BookSearchView에서 사용자가 검색어를 입력하고 검색 버튼을 누릅니다.
  2. ViewModel 처리 : BookSearchViewModel의 searchBooks() 메서드가 호출됩니다.
  3. 입력 검증 : 검색어가 비어있는지 확인하고, 비어있다면 알림을 표시합니다.
  4. 로딩 상태 설정 : isLoading을 true로 설정하여 로딩 인디케이터를 표시합니다.
  5. 서비스 호출 : NaverBookAPIService의 searchBooks(query:) 메서드를 호출합니다.
  6. API 요청 준비 : 검색어 인코딩, URL 생성, 요청 헤더 설정 등을 수행합니다.
  7. 네트워크 요청 : URLSession을 사용하여 실제 네트워크 요청을 보냅니다.
  8. 응답 처리 : 응답을 받아 NaverBookResponse 모델로 디코딩합니다.
  9. 결과 반환 : 성공 또는 실패 결과를 ViewModel에게 전달합니다.
  10. UI 업데이트 : ViewModel은 메인 스레드에서 상태를 업데이트하고, SwiftUI는 자동으로 UI를 업데이트합니다.

MVVM 데이터 흐름

MVVM 패턴에서 데이터는 다음과 같이 흐릅니다.

  1. 사용자 동작: View에서 사용자 동작이 발생합니다. (검색어 입력, 버튼 탭)
  2. ViewModel 호출: View는 ViewModel의 메서드를 호출합니다.
  3. 서비스 호출: ViewModel은 필요한 경우 Service 계층의 메서드를 호출합니다.
  4. 모델 업데이트: Service는 API 호출 결과로 받은
  5. 상태 변경: API 응답으로 ViewModel의 상태(@Published 속성)가 업데이트됩니다.
  6. UI 업데이트: SwiftUI는 ViewModel의 상태 변화를 감지하고 자동으로 UI를 업데이트합니다.

 

성능 최적화

메모리 관리

  • 클로저에서의 [weak self]: API 콜백에서 [weak self]를 사용하여 메모리 누수를 방지합니다.
  • 싱글톤 패턴: NaverBookAPIService를 싱글톤으로 구현하여 메모리 사용을 최적화합니다.

효율적인 UI 업데이트

  • 지연 로딩: 필요한 데이터만 로드하여 네트워크 및 메모리 사용을 최소화합니다.
  • 상태 기반 UI: 로딩 상태에 따라 다른 UI를 표시하여 사용자 경험을 향상시킵니다.
  • 컴포넌트 분리: UI 컴포넌트를 작은 단위로 분리하여 불필요한 리렌더링을 방지합니다.

고려해 볼 추가 최적화

  • 디바운싱 적용: 검색어 입력마다 API를 호출하지 않고, 일정 시간 후 최종 입력값으로 호출하도록 합니다.
  • 페이지네이션: 한 번에 모든 결과를 로드하지 않고, 사용자가 스크롤할 때 추가 결과를 로드합니다.
  • 이미지 캐싱: 책 표지 이미지를 캐싱하여 중복 다운로드를 방지합니다.
  • 오프라인 지원: 검색 결과를 로컬에 저장하여 오프라인 상태에서도 이전 결과를 볼 수 있게 합니다.

 

마무리 

이번 포스트에서는 SwiftUI와 MVVM 패턴을 활용하여 네이버 책 검색 앱을 구현해 보았습니다. 이 프로젝트는 단순하면서도 실용적인 앱을 만드는 과정을 보여주며, MVVM 아키텍처의 장점을 실제로 경험할 수 있게 해 줍니다.

 

배운 점

 

  • SwiftUI와 MVVM 패턴의 효과적인 결합 방법
  • API 서비스 구현 및 비동기 데이터 처리
  • 상태 관리와 UI 업데이트의 자동화
  • 컴포넌트 기반 UI 설계

 

 

 

 

 

반응형