스파르타 코딩 클럽 - iOS 스타터 6기/본 캠프

49. 스파르타 코딩 클럽 - BookSearchApp #1

seongpil Heo 2025. 5. 15. 23:00

  📚  BookSearchApp

 

Kakao API 를 이용하여 책을 검색하고, 정보를 받아온 뒤 선택한 책을 CoreData를 사용하여 내부저장소에 저장하는 앱을 만들어보자!

ViewController 2개와 Modal을 사용하는 ViewController 1개까지 총 3개의 화면으로 구성되어 있다.

 

KAKAO REST API를 사용하여 SearchBar에 사용자가 검색한 내용이 있는 책을 받아오고,

그 중 하나를 선택하면 Modal을 사용하여 화면에 선택한 책의 정보를 보여준다.

 

그리고 담기 버튼을 누르면 CoreData를 사용하여 휴대폰 내부 저장소에 저장하고 담은 책 리스트 탭에서 저장된 책들을 보여준다.


 ✓ 현재까지 작업 내용

1. ViewController UI 작업

 

 

2. Kakao REST API 사용하여 책 검색 후 리스트로 보여주기

// AlamoFire를 이용한 기본 통신 구조
private func fetchData<T: Decodable>(
    url: URL,
    headers: HTTPHeaders,
    completion: @escaping (Result<T, AFError>) -> Void
) {
    AF.request(url, headers: headers)
        .validate()
        .responseDecodable(of: T.self) { response in
            completion(response.result)
        }
}

// kakao REST API 사용하여 데이터 통신 함수
func fetchBooksFromKakaoAPI() {

	// xcconfig에 숨긴 API키를 사용할 수 있게 가져오는 코드
    guard let filePath = Bundle.main.path(forResource: "Info", ofType: "plist") else {
          return
        }
        let plist = NSDictionary(contentsOfFile: filePath)
        guard let apiKey = plist?.object(forKey: "KakaoApiKey") as? String else {
          return
        }

    // 사용자가 searchBar에 입력한 text를 쿼리로 추가
    let query = "\(searchBar.text ?? "")"
    let encodedQuery = query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed)!
    let urlString = "https://dapi.kakao.com/v3/search/book?query=\(encodedQuery)"

    guard let url = URL(string: urlString) else { return }

    // KAKAO REST API 사용시 Header를 추가해줘야 함
    let headers: HTTPHeaders = [
        "Authorization": "KakaoAK \(apiKey)"
    ]
    
    // AlamoFire 이용
    fetchData(url: url, headers: headers) { (result: Result<BookResponse, AFError>) in
        switch result {
        case .success(let response):
            print("도서 수: \(response.documents.count)")

	    //bookData에 받아온 데이터 저장
            self.bookData = response.documents

	    // 메인 쓰레드에서 콜렉션뷰 reload
            DispatchQueue.main.async {
                self.collectionView.reloadData()
            }

        case .failure(let error):
            print("에러 발생: \(error.localizedDescription)")
        }
    }
}

 

 

3. 책을 하나 선택하면 Modal-Present를 사용하여 화면에 정보 표시

// cell 클릭 이벤트
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
    if indexPath.section == 1 {
        let selectedBook = bookData[indexPath.item]

        let modalVC = ModalViewController(bookData: selectedBook)
        modalVC.modalPresentationStyle = .automatic

        present(modalVC, animated: true, completion: nil)

    }
}

 

 

4. gitignore 작성 (API키 숨기기)

 

48. 스파르타 코딩 클럽 - xcconfig 파일을 사용하여 Github로 부터 API 키 감추기

xcconfig 파일을 통해 Github ignore 설정하기[ 1. xcconfig 파일 생성 ] [ New File from Templete... 클릭 ] → [Other 부분에 Configuration Settings File 클릭 ] [ 2. API 키 작성하기 ]만든 xcconfig 파일에 사용하는 API 키를

coding-pill.tistory.com

이 부분은 내 블로그의 글을 참고

 

 

5. Modal ViewController에 ScrollView 추가

// scrollView를 하나 생성하고, 그 안에 들어갈 요소들을 담을 innerView를 생성
private let scrollView = UIScrollView()
private let innerView = UIView()

// view에 scrollView 추가
view.addSubview(scrollView)

// scrollView에 innerView 추가
scrollView.addSubview(innerView)

// innerView안에 들어갈 요소들 추가
[titleLabel, authorLabel, imageView, priceLabel, contentsLabel].forEach {
        innerView.addSubview($0)
    }
    
// 버튼들은 스크롤 영역에 관계없이 사용하기 위해서 플로팅 형식으로 view에 추가
[cancelButton, cartButton].forEach {
        view.addSubview($0)
    }

 

 

6. CartViewController에 CoreData 불러와서 Data 표시하기

// 싱글톤 패턴의 coredata를 사용하기 위한 코드 작성
var coredata = CoreDataManager.shared
var container: NSPersistentContainer!


// view가 willAppear 될 때 coreData에서 저장된 bookData를 불러오고 tableView에 표시
override func viewWillAppear(_ animated: Bool) {
    super.viewWillAppear(animated)

    readData()
    tableView.reloadData()
}


private func readData() {
    // bookData를 빈 배열로 초기화
    bookData = []
    let book = coredata.readAllData()

    // 불러온 책 정보들을 bookData 배열에 append
    for data in book {
        let price = data.price
        guard let title = data.title,let author = data.author, let thumbnail = data.thumbnail, let contents = data.contents, let isbn = data.isbn else { return }
        bookData.append(Book(title: title, contents: contents, authors: author.components(separatedBy: ", "), price: Int(price), thumbnail: thumbnail, isbn: isbn))
    }
}

 

 

7. CartViewController에서 전체 삭제 버튼을 클릭시 CoreData 전체 삭제 및 tableView.reloadData()로 갱신

// 전체 삭제 버튼
@objc private func deleteAllButtonTapped() {
    print("전체 삭제 버튼 클릭")
    // coredata에서 데이터 전체 삭제
    coredata.deleteAllData()
    
    // 테이블 뷰의 data로 사용되는 bookData 배열을 빈 배열로 초기화
    bookData.removeAll()
    
    // 삭제 후 테이블 뷰 reload
    tableView.reloadData()
}


// CoreDataManager.deleteAllData()
// CoreData 저장된 데이터 전체 삭제
    func deleteAllData() {
        let fetchRequest = BookData.fetchRequest()

        do {
            let result = try self.container.viewContext.fetch(fetchRequest)
            
            for data in result as [NSManagedObject] {
                self.container.viewContext.delete(data)
                print("삭제된 데이터: \(data)")
            }
            
            try self.container.viewContext.save()
            print("모든 데이터 삭제 완료")
            
        } catch {
            print("모든 데이터 삭제 실패: \(error)")
        }
    }

 

 

8. 스와이프 방식을 통하여 담은 책 개별삭제 구현 및 추가 버튼 클릭 시 검색 탭으로 이동

// 왼쪽으로 스와이프 했을 때 데이터 삭제
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
    let deleteAction = UIContextualAction(style: .destructive, title: "삭제") { [weak self] (_, _, completionHandler) in
        guard let self = self else { return }

        let book = self.bookData[indexPath.item]
        self.coredata.deleteData(isbn: book.isbn)
        self.bookData.remove(at: indexPath.item)
        tableView.deleteSections(IndexSet(integer: indexPath.section), with: .automatic)

        completionHandler(true)
    }

    return UISwipeActionsConfiguration(actions: [deleteAction])
}

 

// 추가 버튼
@objc private func addButtonTapped() {
    print("추가 버튼 클릭")

    // 첫번째 탭으로 이동 하기
    self.tabBarController?.selectedIndex = 0
}

  🎯 Trouble Shooting

[ 1. 전체 삭제를 하고 tableView.reloadData()를 수행했지만 콜렉션 뷰가 새로고침 되지 않음 ]

// 전체 삭제 버튼
@objc private func deleteAllButtonTapped() {
    coredata.deleteAllData()
    bookData.removeAll()
    tableView.reloadData()
}

 

이유 : coredata 에서는 데이터가 삭제 완료 되었지만

tableView는 UITableViewDataSource의 데이터를 기반으로 그리므로,

UITableViewDataSource의 데이터로 사용중인 bookData의 배열을 비우거나 coreData에서 새로 불러오지 않으면

화면에는 이전 데이터가 보이게 됨

 

// 전체 삭제 버튼
@objc private func deleteAllButtonTapped() {
    coredata.deleteAllData()
    bookData.removeAll()  // 해결 방법
    tableView.reloadData()
}

 

해결 방법 : bookData를 removeAll() 메서드를 이용해서 빈 배열로 비워줌

 

[ 2. CollectionView에서 trailingSwipeActionsConfigurationForRowAt 메서드의 부재 ]

나는 담은 책 리스트 탭 화면에서 검색탭에서 만든 CollectionView Compositional layout을 재사용하여 화면을 구현했었다.

그러나 Step3-2의 요구사항 중 스와이프 등의 방식을 통하여 담은 책 개별 삭제를 구현하기 위해 방법을 찾아보다가

trailingSwipeActionsConfigurationForRowAt 라는 메서드를 사용해서 Swipe Action을 구현할 수 있는 방법을 찾게되었다.

 

이유 : trailingSwipeActionsConfigurationForRowAt 메서드는 CollectionView에는 없고 TableView에 있는 메서드이다.

 

해결 방법 : 담은 책 리스트 탭을 TableView로 새로 구현하고, TableView의 trailingSwipeActionsConfigurationForRowAt 메서드를 이용해서 왼쪽으로 스와이프 했을 때 해당 책이 삭제되게 구현하였다.

// 왼쪽으로 스와이프
func tableView(_ tableView: UITableView, trailingSwipeActionsConfigurationForRowAt indexPath: IndexPath) -> UISwipeActionsConfiguration? {
    let deleteAction = UIContextualAction(style: .destructive, title: "삭제") { [weak self] (_, _, completionHandler) in
        guard let self = self else { return }

        let book = self.bookData[indexPath.item]
        self.coredata.deleteData(isbn: book.isbn)
        self.bookData.remove(at: indexPath.item)
        tableView.deleteSections(IndexSet(integer: indexPath.section), with: .automatic)

        completionHandler(true)
    }

    return UISwipeActionsConfiguration(actions: [deleteAction])
}