'Contact' 엔티티를 추가해서 엔티티에는 'name', 'phoneNumber', 'profileImage' 속성을 추가했다.
그리고 ContactListViewController
파일로 돌아와서 아래 코드를 추가했다.
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
var contacts: [Contact] = []
이 코드는 Core Data
에서 작업을 수행하기 위해 managed object context
를 가져오는 방식인데,
목적은 Core Data의 컨텍스트(Context)를 이용하여 데이터를 읽고, 쓰고, 삭제하는 작업을 처리하기 위해서다.
윗 줄 코드의 이해가 부족해서 좀 더 설명을 덧붙이자면,
UIApplication.shared.delegate
는 앱의 AppDelegate
객체에 접근하는 코드인데,UIApplication.shared.delegate
는 현재 실행 중인 앱의 델리게이트 객체를 반환합니다.AppDelegate
는 앱이 시작될 때 시스템에서 자동으로 만들어지고, 이곳에서 앱에 관련된 설정이나 초기화 작업을 주로 한다.persistentContainer
는 Core Data
의 기본 구성 요소로 데이터베이스에 대한 작업을 처리하는 객체인데,Core Data
는 데이터를 저장하고 검색하는데 사용되고,persistentContainer
는 그 데이터베이스에 접근할 수 있는 관리된 객체 컨텍스트를 제공한다.persistentContainer
는 NSPersistentContainer
타입의 객체로,persistentContainer.viewContext
는 NSManagedObjectContext를 가져오는 코드다.NSManagedObjectContext
는 CoreData
에서 데이터를 관리하는 컨테이너 역할을 해주고,viewContext
는 메인 스레드에서 사용되는 컨텍스트로, UI와 연결된 작업에서 데이터를 처리하는 데 사용된다.그리고 contacts는 기존에 더미데이터를 넣어둔 건데, 이제 더미데이터가 아닌 저장할 데이터가 나와야하기에 더미데이터 배열을 없애고 연락처 목록을 담기 위한 빈 배열을 선언해준 코드다.
이 배열은 Contact 객체들을 담고, 나중에 Core Data에서 데이터를 읽어오거나, 새로 추가된 연락처들을 이 배열에 저장하게 된다.
그리고
let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext
이 코드는 PhoneBookViewController에도 추가를 해줘야한다.
이유는 두 컨트롤러가 각기 다른 Core Data 작업을 수행하기 때문인데,
context는 Core Data에서 데이터베이스 작업을 수행하는 중심 역할을 한다.
이걸 통해 다음과 같은 작업이 가능하다.
• 데이터를 삽입 (Insert) 또는 추가
• 데이터를 조회 (Fetch)
• 데이터를 수정 (Update)
• 데이터를 삭제 (Delete)
• 새로운 연락처를 추가하는 화면이다.
• 사용자가 입력한 정보를 바탕으로 Core Data에 데이터를 추가해야 한다.
• 이를 위해 context를 통해 새로운 Contact 객체를 생성하고, 속성 값을 설정하여 Core Data에 저장한다.
• Core Data에 저장된 연락처들을 조회하고, 이를 화면에 표시하는 화면이다.
• Core Data에서 데이터를 가져오기 위해 context를 사용해 Fetch Request를 실행한다.
그러기에 각 컨트롤러는 서로 다른 작업을 수행하고,
각각의 작업에서 Core Data와 연결된 context가 필요한 것이다.
dequeueReusableCell
을 사용해 테이블 뷰에서 재사용 가능한 셀을 가져왔다.
테이블뷰에서 늘 사용되는 코드이고, 새로운 셀을 생성하는 비용을 줄이기 위해 사용된다.
그리고 contacts
배열에서 indexPath.row
에 해당하는 데이터를 가져오고,
contact.name
은 Contact
객체의 이름(name)
속성 값을 셀의 이름 레이블에 설정한다는 것,
contact.phoneNumber
은 Contact
객체의 전화번호(phoneNumber)
속성 값을 셀의 전화번호 레이블에 설정한다는 것이다.
대략적인 코드의 흐름은 이렇다.
contact.profileImage
연락처(Contact)의 profileImage
속성을 확인할 때 이 속성은 프로필 이미지의 고유 이름을 생성하는 데 사용하는 날짜(Date)다.
이미지 파일 이름 생성
profile_\(date.timeIntervalSince1970)
Date 객체를 기준으로 유니크한 파일 이름을 만든다.
문서 디렉토리에서 이미지 로드
loadImageFromDocumentDirectory(imageName: imageName)
해당 이름을 가진 이미지를 문서 디렉토리에서 읽어오는 함수다.
이미지가 존재하면 셀의 profileImageView
에 설정한다.
기본 이미지 설정
이미지가 없거나 파일을 읽어오지 못하면 기본 이미지 (UIImage(systemName: "person.circle.fill"))
를 표시해준다.
이 코드는 Core Data와 파일 저장 시스템을 결합하여 연락처에 프로필 이미지를 저장하고 불러오기 위한 로직이다.
이걸 쓰게 된 이유는 앱에서 사용자의 프로필 이미지를 효율적으로 관리하기 위해서
이미지를 Core Data에 직접 저장하는 대신 파일로 저장하고 Core Data에 경로 정보를 유지하려는 설계를 결정 때문이다.
심지어 공식문서에도 있다.
1970년이 기준이 되는 이유는 Unix Time(또는 Epoch Time)이라는 시간 표기 방식 때문이라고 한다.
이건 컴퓨터 시스템에서 시간을 간단히 표현하고 계산하기 위해 만들어진 방식이다.
역사적 배경
기술적 이유
UTC(세계 표준시)를 기준으로 함
단위는 초
1702141056
은 1970년 기준으로 1,702,141,056초가 지났음을 의미한다. (대박..)음수로 과거 표현
Date(timeIntervalSince1970: -31536000)
은 1969년 1월 1일을 나타낸다고 한다..
.
결론적으로, 1970년은 "컴퓨터 시간의 출발점"으로 사용되는 일종의 기술적 관습이다.
현재까지도 여전히 이 방식을 사용하는 이유는 호환성 유지와 효율성 때문이라고 한다.
아까전에 이미지를 Core Data에 직접 저장하는 대신 파일로 저장하고 Core Data에 경로 정보를 유지하려는 설계를 결정 때문이라고 했던 것에 대해 좀 더 덧붙이자면,
Core Data에 이미지를 직접 저장하는 방식과 파일로 저장하고 경로 정보를 Core Data에 저장하는 방식은 각각 장단점이 있어서 비교 후에 선택을 한 것이다.
Binary Data
타입 속성으로 저장하는 방식장점
- 모든 데이터(텍스트, 이미지 등)가 하나의 데이터베이스 파일에 포함되므로 관리가 간편하다.
- 이미지와 관련 메타데이터가 같은 저장소에 있어 데이터 간의 동기화 문제가 적다.
- Core Data를 백업하면 이미지도 함께 백업되므로 별도의 처리 없이 복원이 가능하다.
단점
- 대용량 이미지를 저장하면 Core Data의 크기가 커져 성능이 저하될 수 있다.
- 메모리 사용량 증가 및 데이터베이스 액세스 속도 저하가 발생할 수 있다.
- Binary Data 최적화를 위해 "Allow External Storage" 이라는 옵션을 사용해야 하지만, 관리가 더 복잡해질 수 있다.
- 파일에 접근하거나 별도로 조작하기가 어렵다.
이미지는 파일 시스템에 따로 저장하고, 파일 경로를 Core Data에 저장하는 방식이다.
장점
- Core Data는 텍스트 및 경로 정보를 관리하고, 이미지는 파일 시스템에서 관리되므로 데이터베이스 크기와 메모리 사용량이 줄어든다.
- 파일 시스템을 통해 이미지를 쉽게 읽고 쓸 수 있고, 파일을 별도로 압축하거나 외부로 내보내기 쉽다.
- 이미지와 데이터베이스를 독립적으로 관리할 수 있어 이미지 처리와 저장이 더 유연하다.
단점
- 파일 경로가 삭제되거나 유실되면 Core Data의 데이터와 파일 간의 불일치가 발생할 수 있다.
- 파일 저장 및 삭제를 직접 처리해야 한다.
- 파일 경로를 관리하기 위한 코드가 추가된다.
- Core Data와 파일 시스템을 개별적으로 백업해야 한다.
이미지가 많고 크기가 큰 경우
→ 파일로 저장하고 경로를 Core Data에 저장하는 방식이 적합하다.
성능 문제를 줄이고 유연성을 높일 수 있다.
이미지 크기가 작고 데이터 일관성이 중요한 경우
→ Core Data에 이미지를 직접 저장하는 방식이 적합하다.
데이터가 일관되게 유지되며 관리가 간편하다.
나는 그래도 소규머이긴 하지만, 앞으로 만들어갈 프로젝트는 점점 커질 수 있기 때문에 파일로 저장하고 경로로 관리하는 방식으로 선택한 것이다.
@objc private func applyButtonTapped() {
guard let name = nameTextField.text, !name.isEmpty,
let phoneNumber = phoneTextField.text, !phoneNumber.isEmpty else {
showAlert(message: "이름과 전화번호를 입력해주세요.")
return
}
// 코어데이터에 연락처 저장
let newContact = Contact(context: context)
newContact.name = name
newContact.phoneNumber = phoneNumber
// profileImage를 현재 시간으로 저장
let currentDate = Date()
newContact.profileImage = currentDate
nameTextField.text
와 phoneTextField.text
는 사용자가 입력한 값을 가져오는데,
guard
구문을 이용해서 name
과 phoneNumber
가 비어 있지 않으면 계속 진행하고,
비어 있을 경우에는 showAlert
를 통해 경고 메시지를 띄유게끔 했다.
이 코드는 새로운 Contact
객체를 생성하는 부분인데,
Contact(context: context)
는 Core Data
에서 새로운 Contact
엔티티의 객체를 생성하고,
context
는 해당 객체가 Core Data
에서 관리될 수 있도록 하는 역할을 하며
context
는 Core Data
의 데이터 관리 객체로 데이터를 삽입, 수정, 삭제하는 작업을 관리해준다.
let currentDate = Date()
이 부분에서 Date()
는 현재 날짜와 시간을 반환하는 메서드인데,
profileImage
라는 필드에 현재 날짜를 저장하려고 하는 것이다.
아까 공부했다시피 timeIntervalSince1970
을 이용해 날짜값으로 구분해서 고유한 값을 만들어야하기 때문이다.
여기서 profileImage
는 연락처의 프로필 이미지를 저장하는 필드다.
하지만 여기서는 프로필 이미지 대신에 아까 만들었던 currentDate
의 현재 날짜를 저장하고 있는 것이고,
실제로 이미지를 저장하려면 이 코드에서 profileImage
대신 이미지 데이터를 저장한 다음,
그 데이터를 파일 시스템에 저장한 후, Core Data
에는 이미지 파일의 경로를 저장하는 방식이 사용된다.
// 이미지 데이터를 별도로 저장
if let image = profileImageView.image,
let imageData = image.pngData() {
let imageName = "profile_\(currentDate.timeIntervalSince1970)"
saveImageToDocumentDirectory(imageData: imageData, imageName: imageName)
}
do {
try context.save()
print("연락처 저장 완료")
navigationController?.popViewController(animated: true)
} catch {
print("\(error) 저장 실패")
showAlert(message: "연락처 저장에 실패했습니다.")
}
}
이 코드는 사용자가 선택한 프로필 이미지를 가져오는 부분인데,
profileImageView.image
는 이미지 뷰에 표시된 이미지를 가져오는 코드고,
pngData()
는 이미지 데이터를 PNG
형식으로 변환하는 메서드다.
처음 써본건데 신기하다. 저렇게 간단하게 변환을 하면 이 변환된 이미지 데이터를 imageData에 할당하고,
if let
구문은 profileImageView.image
가 nil
이 아니고, image.pngData()
가 성공적으로 데이터를 반환했을 때만 코드를 실행하게 된다.
이 코드는 이미지의 파일 이름을 생성하는 부분이다.
currentDate.timeIntervalSince1970
은 위에서 공부했다시피 현재 시간을 1970년 1월 1일부터의 초 단위 시간으로 변환한 값으로 고유한 값을 만들어낸다.
saveImageToDocumentDirectory
는 이미지 데이터를 Documents
디렉토리에 저장하는 함수인데,
이 함수는 위에서 만든 imageData
와 imageName
을 인수로 받아서 이미지를 파일로 저장을 한다.
imageName
은 파일 이름이므로, 이미지가 고유한 이름으로 저장된다.
마지막 catch
블록은 context.save()
에서 오류가 발생했을 때 실행되고,
오류 메시지를 로그로 출력한다음 사용자에게 저장 실패 알림을 보여주는 showAlert 함수를 호출했다.
// 이미지 저장하는 함수
func saveImageToDocumentDirectory(imageData: Data, imageName: String) {
let fileManager = FileManager.default
guard let documentURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
return
}
let imageUrl = documentURL.appendingPathComponent(imageName)
do {
try imageData.write(to: imageUrl)
print("이미지 저장 성공: \(imageUrl)")
} catch {
print("이미지 저장 실패: \(error)")
}
}
우선 처음 FileManager
라는 클래스를 사용했다.
이 클래스는 iOS에서 파일 시스템을 관리하는 데 사용되는 기본 클래스인데,
FileManager
는 파일 및 디렉터리 작업(생성, 삭제, 이동, 복사 등)을 처리하는 클래스이다.
그리고 이어서 나오는 가드문은 코드에서는 앱의 Documents
디렉터리 경로를 가져오거나 파일을 저장하는데 쓰였다.
fileManager.urls(for:in:)
를 호출해 앱의 Documents 디렉터리 경로를 가져오는데 해당 코드를 설명하자면,
for: .documentDirectory
= Documents 디렉터리를 요청함in: .userDomainMask
= 현재 사용자의 홈 디렉터리 범위 내에서 찾음.first
는 요청한 디렉터리 경로 중 첫 번째 값을 가져온다.
이 경로는 앱마다 고유한데, 사용자가 생성한 데이터(이미지 파일)를 저장하는데 적합했다.
이어서 documentURL
에 imageName
을 붙여 저장할 이미지 파일의 전체 경로를 생성해주는데,
이때 사용된appendingPathComponent
는 경로에 파일 이름을 추가하는 메서드다.
결과적으로 imageUrl
은 저장될 파일의 위치를 나타내는 것.
imageData.write(to:)
는 Data
객체를 특정 경로에 파일로 쓰는 작업을 수행한다.
func loadImageFromDocumentDirectory(imageName: String) -> UIImage? {
let fileManager = FileManager.default
guard let documentURL = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first else {
return nil
}
let imageUrl = documentURL.appendingPathComponent(imageName)
if fileManager.fileExists(atPath: imageUrl.path) {
return UIImage(contentsOfFile: imageUrl.path)
}
return nil
}
Documents
디렉터리 경로 확인을 위해 fileManager.urls(for: .documentDirectory, in: .userDomainMask)
를 호출해서 앱의 Documents
디렉터리 경로를 가져오는 작업이 필요했다.
이 부분에서 FileManager
객체가 필수적인데, 경로를 가져오기 위해 FileManager
의 메서드를 사용하고,
파일 존재 여부 확인을 위해서 fileManager.fileExists(atPath: imageUrl.path)
를 통해 파일이 해당 경로에 존재하는지 확인을 하게 한다.
가드문을 이용해서 경로를 잘 가져오지 못할 경우 nil
을 반환하하는데, documentURL
코드 부분을 더 설명하자면
.documentDirectory
: 앱의 Documents 디렉터리를 지정. 이곳은 앱에서 파일을 영구적으로 저장하기 위한 기본 디렉터리다..userDomainMask
: 사용자 도메인에 있는 디렉터리를 의미. 현재 앱에서 사용할 수 있는 디렉터리 영역이다.그리고 appendingPathComponent는 Foundation 프레임워크의 URL 클래스에서 제공하는 메서드인데,
기존 URL 경로에 새로운 경로 요소를 추가해 새로운 URL을 생성할 때 사용한다고 한다.
위 코드에서는 imageName
을 Documents
디렉터리 경로에 추가해서 이미지 파일의 전체 경로(URL)를 만드는데 사용됐다.
그리고 파일의 존재 확인을 위해 imageUrl.path
를 사용해서 해당 경로에 파일이 실제로 존재하는지 확인하고 그 값에 따라 bool 타읍으로 반환 해준다.
이때 사용되는 fileExists(atPath:)는 FileManager 클래스의 메서드로, 지정된 경로에 파일이나 디렉터리가 존재하는지 여부를 확인하는데 사용된다.
그렇게 앱의 Documents 디렉터리 경로를 가져오고,
가져온 디렉터리에 이미지 이름을 덧붙여 파일 경로를 생성한 뒤,
해당 경로에 파일이 존재하면 이미지 객체로 반환해준다.
마지막으로 파일이 존재하지 않으면 nil을 반환한다.
처음 Core Data를 다루면서 엔티티 생성, 관계 설정, Fetch Request 작성 등 새로운 개념을 익히는 데 시간이 걸렸다.
이미지를 저장하고 불러오는 과정에서 경로 관리가 꽤나 중요하구나 생각이 들었고, FileManager와 Core Data 간의 역할 분리를 명확히 해야 해서 좀 많이 어려웠다.
그리고 데이터가 제대로 저장되지 않거나 불러오기에 실패했을 때 원인을 파악하는 과정도 너무 복잡했다. 결국엔 에러메세지 이용해서 확인하긴 했지만..
그래도 Core Data를 통해 데이터를 성공적으로 저장하고 불러오는 기능을 구현하고, 이미지 파일을 효율적으로 관리하는 구조를 만들어볼 수 있었다.
Core Data는 처음 다루기에는 좀 어렵지만 데이터 영속성을 유지하는데 이만한게 없다는 생각도 들었다.
앞으로도 많이 쓰일 코어데이터. 잘 공부해보고싶다.
스압 장난 아니네요…
잘 정리되어 있어 보기 좋았습니다!!
다음 레벨들도 힘내봐요~