어제와 마찬가지로 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
클래스를 정의하고, UserDefault
는 List<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
}
만약 이미 데이터가 존재한다면 해당 데이터를 기준으로 테이블 뷰를 갱신합니다.
실제 데이터는 위와 같은 방식으로 저장되게 됩니다.
어제부터 계속 붙잡고 있었는데 알 수 없는 버그들과 싸우다가 드디어 해냈습니다!!!!
List 와 filter
를 사용하면서 처음에는 블로그에 정리된 글들을 보면서 많은 시행착오를 겪었는데, 결국 돌고돌아 공식 홈페이지를 유심히 읽다가 filter
부분이 잘못되었다는걸 깨달았습니다!
축하드립니다