SSAC iOS 앱 개발자 데뷔과정 - 25

Sangwon Shin·2021년 11월 3일
1

SSAC

목록 보기
19/19

📑 Data Base

어제와 마찬가지로 Rleam을 이용한 다양한 예제들을 살펴보겠습니다.

어제 앱 내에서 바로 Realm 데이터에 접근 할 경우에 오류나 나는 것을 확인했습니다.

트랜잭션에 의해서 오류가 발생한다라고 이해했습니다. 트랜잭션에 대해 좀 더 정리가 되면 블로그에 정리하도록 하겠습니다.

만약, 사진을 앱내 메모리에 저장할 경우에는 어떻게 해야 할까요?

물론 사진을 데이터 형식으로 바꿔서 Realm 데이터 저장할 수 있지만 Realm 에 사진의 파일명만 저장하고 애플 내에서 제공하는 Document 에 사진을 저장하는 방식을 사용합니다.

우선, 각 레코드에 사진을 한장 저장하는 경우를 생각해보겠습니다.

사진의 파일명을 Realm 데이터에 저장해야 하기 때문에 위와 같이 테이블에 사진의 이름을 저장 할 Column 을 추가하면 됩니다.

하지만 각 레코드에 사진이 한장이라면 굳이 파일명을 저장하지 않더라도 해당 레코드임을 보장(?) 할 수 있도록 파일명을 설정할 수 있지 않을까요?

Primary Key 를 파일명으로 설정하면 됩니다❗️

기본키의 경우 테이블에서 레코드를 구분할 수 있는 고유한 값이기 때문에 기본키를 파일명으로 설정해 Document 에 저장하면 별도의 메모리를 사용하지 않고도 위의 테이블과 같은 기능을 할 수 있습니다.
물론 Date() 를 파일명으로 쓰거나 레코드의 값들을 조합해서도 가능합니다!


Primary Key 를 파일명으로 사진을 Document 에 저장하는 코드를 확인 해보겠습니다.

func saveImageToDocumentDirectory(imageName: String, image: UIImage) {
        
        //1. 이미지 저장할 경로 설정 : Document 폴더
        //Desktop/~~/~~/folder
        guard let documentDirectory = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first else { return }
        
        //2. 이미지 파일 이름 & 최종 경로 설정
        //Desktop/~~/~~/folder/222.png
        let imageURL = documentDirectory.appendingPathComponent(imageName)
        
        //3. 이미지 압축(optional) image.pngData()
        guard let data = image.jpegData(compressionQuality: 0.5) else { return }
        
        //4. 이미지 저장: 동일한 경로에 이미지를 저장하게 될 경우, 덮어쓰기
        //4-1. 이미지 경로 여부 확인 (만약 최종 경로에 동일한 파일이 있는 경우)
        if FileManager.default.fileExists(atPath: imageURL.path) {
            //4-2. 기존 경로에 있는 이미지 삭제
            do {
                try FileManager.default.removeItem(at: imageURL)
                print("이미지 삭제 완료")
            }
            catch {
                print("이미지 삭제하지 못했습니다.")
            }
        }
        
        //5. 이미지를 도큐먼트에 저장
        do {
            try data.write(to: imageURL)
        }
        catch {
            print("이미지 저장 실패")
        }
    }
//코드 주석만 봐도 이해되는 MAGIC...    

documentDirectory : **/Data/Application/~~~/Documents/
imageURL : **/Data/Application/~~~/Documents/imageName.(확장자)

@objc
    func saveButtonClicked() {
        
        // 비어있는 Record 를 값을 채워서 task에 저장
        let task = UserDiary(diaryTitle: titleLabel.text!, diaryContent: contentLabel.text!, diaryDate: Date(), diaryPostDate: Date())
        // Realm 에 위의 task 를 추가한다
        try! localRealm.write {
            localRealm.add(task)
            //PK 를 이미지의 이름으로 사용!
            saveImageToDocumentDirectory(imageName: "\(task._id).png", image: contentImageView.image!)
        }
        
    }

그리고 위의 코드처럼 기본키를 파일명으로 저장합니다.

그럼, 키본키로 부터 document 에 저장된 이미지 파일을 불러오는 과정은 어떻게 될까요?

//도큐먼트 폴더 경로에서 이미치를 찾아 UIImage 로 변환하는 과정
    func loadImageFromDocumentDirectory(imageName: String) -> UIImage? {
        
        let documentDirectory = FileManager.SearchPathDirectory.documentDirectory
        let userDomainMask = FileManager.SearchPathDomainMask.userDomainMask
        let path = NSSearchPathForDirectoriesInDomains(documentDirectory, userDomainMask, true)
        
        if let directoryPath = path.first {
            let imageURL = URL(fileURLWithPath: directoryPath).appendingPathComponent(imageName)
            return UIImage(contentsOfFile: imageURL.path)
        }
        return nil
    }
Realm 데이터를 삭제해야 할때, 기본키로 저장한 이미지 파일도 함께 삭제 해줘야 합니다!
(깃허브에서 해당 내용 확인)

그럼 한 레코드에 이미지 파일을 여러개 저장하려면 어떻게 해야 할까요?
위와 같은 방법으로는 하나의 이미지 파일 이름에 대해서만 설정할 수 있기 때문에 이미지 파일 이름을 Realm 데이터에 저장해야 합니다.

하지만 이렇게 이미지 파일이 많아지고, 또 만약 첫번째 레코드에서는 이미지가 한장, 두번째 레코드에서는 이미지가 3장 필요하다면 필요한 Column 의 수가 달라집니다.
최대를 기준으로 Column 을 변경하면서 맞추면 마이그레이션 과정이 필요하고, 다른 레코드에서는 그만큼의 공간이 필요하지 않기 때문에 메모리 효율이 떨어집니다.

이런 경우에 Realm 에 데이터를 List 로 저장하면 해결할 수 있습니다.

간단한 예제를 통해서 List 의 사용방법을 확인해보겠습니다.

위의 그림과 사용자가 검색한 날짜를 기준으로 박스 오피스 랭킹을 보여주려고 합니다. 물론 검색한 날짜를 기준으로 API 통신을 통해 데이터를 가져오면 되지만 한 사용자가 동일한 날짜를 10만번 검색한다 라는 극단적인 경우를 생각해보겠습니다.

너무 잦은 서버와의 통신은 서버에서 호출을 제한할 수 있다고 공부했었습니다.

사용자가 한번 검색을 했던 날짜는 Realm 데이터로 저장하고 만약 사용자가 검색한 날짜가 이미 Realm 데이터에 존재한다면, API 통신 없이 데이터를 불러와 tableView 를 갱신하도록 해보겠습니다.

위와 같이 어제날짜(사용자가 검색한 날짜 포함) 을 Column 으로 설정하고, 나머지 Column 을 [rank, movieName, movieDT] 형태의 배열로 구성하려고 합니다.

그리고 필터 기능을 이용해서 어제날짜에 해당하는 레코드가 존재하면 API 통신 없이 해당 데이터를 가져오도록 하겠습니다.

class UserTask: Object {
    
    @Persisted var movieRank: String // 순위
    @Persisted var movieTitle: String // 제목
    @Persisted var movieDate: String // 날짜
    @Persisted(primaryKey: true) var _id: ObjectId
    
    convenience init(movieRank: String, movieTitle: String, movieDate: String) {
        self.init()
        self.movieRank = movieRank
        self.movieTitle = movieTitle
        self.movieDate = movieDate
    }
}

class UserProject: Object {
    
    @Persisted var yesterdayDate: String
    @Persisted var boxOffice: List<UserTask>
    @Persisted(primaryKey: true) var _id: ObjectId
    
    convenience init(yesterdayDate: String, boxOffice: [UserTask]) {
        self.init()
        self.yesterdayDate = yesterdayDate
        self.boxOffice.append(objectsIn: boxOffice)
    }
}

위와 같이 영화정보를 저장할 UserTask 클래스를 정의하고, UserDefaultList<UserTask> 와 yserdayDate 를 프로퍼티를 가지도록 설정합니다.

❗️List 와 Array 는 다릅니다..
해당 부분에서 시행착오가 좀 많았습니다. List 에 바로 배열값을 넣어주면 오류가 발생하기 때문에 UserTask 타입으로 배열을 생성하고 해당 배열 요소를 모두 append 하는 방식으로 생성자를 구현했습니다.
func fetchBoxOfficeData3() {
        print("네트워크 통신을 통해 받아옵니다~!")
        BoxOfficeManager.shared.fetchBoxOfficeData(date: yesterday) { code, json in
            try! self.localRealm.write {
                var taskList: [UserTask] = []
                for item in json["boxOfficeResult"]["dailyBoxOfficeList"].arrayValue {
                    let rank = item["rank"].stringValue
                    let movieName = item["movieNm"].stringValue
                    let openDate = item["openDt"].stringValue
                    let task = UserTask(movieRank: rank, movieTitle: movieName, movieDate: openDate)
                    taskList.append(task)
                }
                let project = UserProject(yesterdayDate: self.yesterday, boxOffice: taskList)
                self.localRealm.add(project)
            }
            self.tableView.reloadData()
        }
    }

그래서 실제 네트워크 통신을 통해 Realm 에 데이터를 추가할 때도, 10개의 task 를 taskList 배열에 추가하고
해당 배열과 어제날짜를 이용해 UserProject 클래스의 인스턴스를 만들어 Realm 에 추가했습니다.

그럼, 사용자가 검색한 날짜가 Realm 데이터에 이미 존재하는지는 어떻게 확인할 수 있을까요?

어제와 동일하게 yesterdayDate Column 을 이용하는 것은 동일합니다.
필터를 이용해보겠습니다.

//앱이 처음 실행된 경우
        if projects.count == 0 {
            print("앱을 처음 실행시켰네요~!")
            fetchBoxOfficeData3()
        }
        
        //날짜가 바뀐 경우
        else if projects.filter("yesterdayDate = %@",yesterday).isEmpty {
            print("날짜가 변경되었네요~!")
            fetchBoxOfficeData3()
        }

projects.filter("yesterdayDate == %@",yesterday)
❗️현재 Realm 데이터의 yesterdayDate Column의 값이 yesterday 와 같은 레코드를 의미합니다

projects.filter("yesterdayDate = \(yesterday)") 를 사용하면 오류가 발생합니다.. 공식 홈페이지 나온대로 씁시다!
projects.filter("yesterdayDate = %@",yesterday) 로 작성하고 블로그 정리하다가 어! 하고 =하나 안붙였네? 하고 수정했습니다. 
(= 하나만 있어도 정상 동작 합니다???)

위와 같이 데이터가 아예 없거나, 필터를 사용해서 사용자가 검색한 날짜가 없으면 API 통신을 통해서 값을 가져오도록 설정했습니다.

func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell {
        
        guard let cell = tableView.dequeueReusableCell(withIdentifier: BoxOfficeTableViewCell.identifier) as? BoxOfficeTableViewCell else {
            return UITableViewCell()
        }
        
        let result = projects.filter("yesterdayDate == %@",yesterday).first?.boxOffice[indexPath.row]
                
        cell.rankLabel.text = result?.movieRank
        cell.titleLabel.text = result?.movieTitle
        cell.dateLabel.text = result?.movieDate
        
        return cell
    }

만약 이미 데이터가 존재한다면 해당 데이터를 기준으로 테이블 뷰를 갱신합니다.

실제 데이터는 위와 같은 방식으로 저장되게 됩니다.


🏷 P.S.

어제부터 계속 붙잡고 있었는데 알 수 없는 버그들과 싸우다가 드디어 해냈습니다!!!!

List 와 filter 를 사용하면서 처음에는 블로그에 정리된 글들을 보면서 많은 시행착오를 겪었는데, 결국 돌고돌아 공식 홈페이지를 유심히 읽다가 filter 부분이 잘못되었다는걸 깨달았습니다!

profile
개발자가 되고싶어요

1개의 댓글

comment-user-thumbnail
2021년 11월 5일

축하드립니다

답글 달기