[ swift ] Lv.5 연락처 데이터 저장하기 (coreData 방법)

sonny·2024년 12월 9일
3

TIL

목록 보기
66/133

'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)를 이용하여 데이터를 읽고, 쓰고, 삭제하는 작업을 처리하기 위해서다.


윗 줄 코드의 이해가 부족해서 좀 더 설명을 덧붙이자면,

  1. UIApplication.shared.delegate
  • UIApplication.shared.delegate는 앱의 AppDelegate 객체에 접근하는 코드인데,
    AppDelegate는 앱의 라이프 사이클을 관리하는 객체로 앱이 실행될 때 시스템에서 가장 먼저 실행되는 객체라고 한다.
    이 객체는 앱 초기화, 상태 변경, 푸시 알림, 백그라운드 작업 등 여러 중요한 일을 처리해주고,
    UIApplication.shared.delegate는 현재 실행 중인 앱의 델리게이트 객체를 반환합니다.
  1. AppDelegate
  • AppDelegate는 앱이 시작될 때 시스템에서 자동으로 만들어지고, 이곳에서 앱에 관련된 설정이나 초기화 작업을 주로 한다.
    persistentContainerCore Data의 기본 구성 요소로 데이터베이스에 대한 작업을 처리하는 객체인데,
    Core Data는 데이터를 저장하고 검색하는데 사용되고,
    persistentContainer는 그 데이터베이스에 접근할 수 있는 관리된 객체 컨텍스트를 제공한다.
    persistentContainerNSPersistentContainer 타입의 객체로,
    데이터베이스를 초기화하면서 코어 데이터에 데이터를 저장하고 가져오는 작업을 편리하게 처리할 수 있게 해준다.
  1. persistentContainer.viewContext
  • persistentContainer.viewContextNSManagedObjectContext를 가져오는 코드다.
    NSManagedObjectContextCoreData에서 데이터를 관리하는 컨테이너 역할을 해주고,
    viewContext는 메인 스레드에서 사용되는 컨텍스트로, UI와 연결된 작업에서 데이터를 처리하는 데 사용된다.
    이 컨텍스트는 UI와 데이터를 연동하는 작업을 처리하는 데 중요한 역할을 하기 때문에,
    데이터를 읽고, 저장하고, 삭제하는 등의 작업을 한다.
    viewContext를 사용하면 메인 스레드에서 Core Data를 통해 UI 업데이트와 관련된 작업을 할 수 있다.

context

그리고 contacts는 기존에 더미데이터를 넣어둔 건데, 이제 더미데이터가 아닌 저장할 데이터가 나와야하기에 더미데이터 배열을 없애고 연락처 목록을 담기 위한 빈 배열을 선언해준 코드다.

이 배열은 Contact 객체들을 담고, 나중에 Core Data에서 데이터를 읽어오거나, 새로 추가된 연락처들을 이 배열에 저장하게 된다.

그리고

let context = (UIApplication.shared.delegate as! AppDelegate).persistentContainer.viewContext 

이 코드는 PhoneBookViewController에도 추가를 해줘야한다.

이유는 두 컨트롤러가 각기 다른 Core Data 작업을 수행하기 때문인데,

context의 역할

context는 Core Data에서 데이터베이스 작업을 수행하는 중심 역할을 한다.

이걸 통해 다음과 같은 작업이 가능하다.

• 데이터를 삽입 (Insert) 또는 추가
• 데이터를 조회 (Fetch)
• 데이터를 수정 (Update)
• 데이터를 삭제 (Delete)

PhoneBookViewController의 역할

• 새로운 연락처를 추가하는 화면이다.
• 사용자가 입력한 정보를 바탕으로 Core Data에 데이터를 추가해야 한다.
• 이를 위해 context를 통해 새로운 Contact 객체를 생성하고, 속성 값을 설정하여 Core Data에 저장한다.

ContactListViewController의 역할

• Core Data에 저장된 연락처들을 조회하고, 이를 화면에 표시하는 화면이다.
• Core Data에서 데이터를 가져오기 위해 context를 사용해 Fetch Request를 실행한다.

그러기에 각 컨트롤러는 서로 다른 작업을 수행하고,

각각의 작업에서 Core Data와 연결된 context가 필요한 것이다.


ContactListViewController에 데이터 표시하기

dequeueReusableCell을 사용해 테이블 뷰에서 재사용 가능한 셀을 가져왔다.

테이블뷰에서 늘 사용되는 코드이고, 새로운 셀을 생성하는 비용을 줄이기 위해 사용된다.

그리고 contacts 배열에서 indexPath.row에 해당하는 데이터를 가져오고,

contact.nameContact 객체의 이름(name) 속성 값을 셀의 이름 레이블에 설정한다는 것,

contact.phoneNumberContact 객체의 전화번호(phoneNumber) 속성 값을 셀의 전화번호 레이블에 설정한다는 것이다.


프로필 이미지 처리코드

대략적인 코드의 흐름은 이렇다.

  1. contact.profileImage
    연락처(Contact)의 profileImage 속성을 확인할 때 이 속성은 프로필 이미지의 고유 이름을 생성하는 데 사용하는 날짜(Date)다.

  2. 이미지 파일 이름 생성
    profile_\(date.timeIntervalSince1970)
    Date 객체를 기준으로 유니크한 파일 이름을 만든다.

  3. 문서 디렉토리에서 이미지 로드
    loadImageFromDocumentDirectory(imageName: imageName)
    해당 이름을 가진 이미지를 문서 디렉토리에서 읽어오는 함수다.
    이미지가 존재하면 셀의 profileImageView에 설정한다.

  4. 기본 이미지 설정
    이미지가 없거나 파일을 읽어오지 못하면 기본 이미지 (UIImage(systemName: "person.circle.fill"))를 표시해준다.

이 코드는 Core Data와 파일 저장 시스템을 결합하여 연락처에 프로필 이미지를 저장하고 불러오기 위한 로직이다.

이걸 쓰게 된 이유는 앱에서 사용자의 프로필 이미지를 효율적으로 관리하기 위해서

이미지를 Core Data에 직접 저장하는 대신 파일로 저장하고 Core Data에 경로 정보를 유지하려는 설계를 결정 때문이다.


난데 없는 1970

심지어 공식문서에도 있다.

1970년이 기준이 되는 이유는 Unix Time(또는 Epoch Time)이라는 시간 표기 방식 때문이라고 한다.

이건 컴퓨터 시스템에서 시간을 간단히 표현하고 계산하기 위해 만들어진 방식이다.

왜 1970년인가?

  1. 역사적 배경

    • 1970년 1월 1일 00:00:00 UTC는 Unix 운영 체제가 처음 개발될 때의 기준점(Epoch)으로 설정되었다.
    • Unix는 1969년에 탄생했고, 1970년이 시스템 시간의 시작점으로 자연스럽게 선택되었다.
  2. 기술적 이유

    • 컴퓨터는 숫자를 기반으로 시간을 계산한다. Unix Time은 특정 날짜(1970년 1월 1일)를 기준으로 초 단위로 시간의 흐름을 표현한다.
    • 이전 시간을 표현할 때는 음수(1969년)로, 이후 시간을 표현할 때는 양수(2024년)로 계산한다.
    • 이런 방식은 간단하고 계산 속도가 빠르다.

Unix Time의 특징

  1. UTC(세계 표준시)를 기준으로 함

    • 모든 지역의 시간대를 통일하기 위해 UTC가 사용된다.
    • 지역 시간으로 변환하려면 시간대를 적용해야 한다.
  2. 단위는 초

    • 1970년 이후 초 단위로 경과한 시간을 나타낸다.
    • 예: 1702141056은 1970년 기준으로 1,702,141,056초가 지났음을 의미한다. (대박..)
  3. 음수로 과거 표현

    • 1970년 이전의 시간은 음수로 나타낸다.
    • 예를 들어Date(timeIntervalSince1970: -31536000)은 1969년 1월 1일을 나타낸다고 한다.

.
.

결론적으로, 1970년은 "컴퓨터 시간의 출발점"으로 사용되는 일종의 기술적 관습이다.

현재까지도 여전히 이 방식을 사용하는 이유는 호환성 유지효율성 때문이라고 한다.


Core Data에 이미지를 직접 저장

아까전에 이미지를 Core Data에 직접 저장하는 대신 파일로 저장하고 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와 파일 시스템을 개별적으로 백업해야 한다.

상황에 따라 어떤 방식을 선택해야 할까?

  1. 이미지가 많고 크기가 큰 경우

    파일로 저장하고 경로를 Core Data에 저장하는 방식이 적합하다.
    성능 문제를 줄이고 유연성을 높일 수 있다.

  2. 이미지 크기가 작고 데이터 일관성이 중요한 경우

    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.textphoneTextField.text는 사용자가 입력한 값을 가져오는데,

guard 구문을 이용해서 namephoneNumber가 비어 있지 않으면 계속 진행하고,

비어 있을 경우에는 showAlert를 통해 경고 메시지를 띄유게끔 했다.

let newContact = Contact(context: context)

이 코드는 새로운 Contact 객체를 생성하는 부분인데,

Contact(context: context)Core Data에서 새로운 Contact 엔티티의 객체를 생성하고,

context는 해당 객체가 Core Data에서 관리될 수 있도록 하는 역할을 하며

contextCore Data의 데이터 관리 객체로 데이터를 삽입, 수정, 삭제하는 작업을 관리해준다.

let currentDate = Date() 이 부분에서 Date() 는 현재 날짜와 시간을 반환하는 메서드인데,

profileImage라는 필드에 현재 날짜를 저장하려고 하는 것이다.

아까 공부했다시피 timeIntervalSince1970 을 이용해 날짜값으로 구분해서 고유한 값을 만들어야하기 때문이다.

newContact.profileImage = currentDate

여기서 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: "연락처 저장에 실패했습니다.")
        }
    }

if let image = profileImageView.image, let imageData = image.pngData()

이 코드는 사용자가 선택한 프로필 이미지를 가져오는 부분인데,

profileImageView.image는 이미지 뷰에 표시된 이미지를 가져오는 코드고,

pngData()는 이미지 데이터를 PNG 형식으로 변환하는 메서드다.

처음 써본건데 신기하다. 저렇게 간단하게 변환을 하면 이 변환된 이미지 데이터를 imageData에 할당하고,

if let 구문은 profileImageView.imagenil이 아니고, image.pngData()가 성공적으로 데이터를 반환했을 때만 코드를 실행하게 된다.

let imageName = "profile_(currentDate.timeIntervalSince1970)"

이 코드는 이미지의 파일 이름을 생성하는 부분이다.

currentDate.timeIntervalSince1970 은 위에서 공부했다시피 현재 시간을 1970년 1월 1일부터의 초 단위 시간으로 변환한 값으로 고유한 값을 만들어낸다.

saveImageToDocumentDirectory(imageData: imageData, imageName: imageName)

saveImageToDocumentDirectory는 이미지 데이터를 Documents 디렉토리에 저장하는 함수인데,

이 함수는 위에서 만든 imageDataimageName을 인수로 받아서 이미지를 파일로 저장을 한다.
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는 요청한 디렉터리 경로 중 첫 번째 값을 가져온다.

이 경로는 앱마다 고유한데, 사용자가 생성한 데이터(이미지 파일)를 저장하는데 적합했다.

이어서 documentURLimageName을 붙여 저장할 이미지 파일의 전체 경로를 생성해주는데,

이때 사용된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을 생성할 때 사용한다고 한다.

위 코드에서는 imageNameDocuments 디렉터리 경로에 추가해서 이미지 파일의 전체 경로(URL)를 만드는데 사용됐다.

그리고 파일의 존재 확인을 위해 imageUrl.path를 사용해서 해당 경로에 파일이 실제로 존재하는지 확인하고 그 값에 따라 bool 타읍으로 반환 해준다.

이때 사용되는 fileExists(atPath:)는 FileManager 클래스의 메서드로, 지정된 경로에 파일이나 디렉터리가 존재하는지 여부를 확인하는데 사용된다.

정리하자면,

그렇게 앱의 Documents 디렉터리 경로를 가져오고,

가져온 디렉터리에 이미지 이름을 덧붙여 파일 경로를 생성한 뒤,

해당 경로에 파일이 존재하면 이미지 객체로 반환해준다.

마지막으로 파일이 존재하지 않으면 nil을 반환한다.

결과


음...

처음 Core Data를 다루면서 엔티티 생성, 관계 설정, Fetch Request 작성 등 새로운 개념을 익히는 데 시간이 걸렸다.

이미지를 저장하고 불러오는 과정에서 경로 관리가 꽤나 중요하구나 생각이 들었고, FileManager와 Core Data 간의 역할 분리를 명확히 해야 해서 좀 많이 어려웠다.

그리고 데이터가 제대로 저장되지 않거나 불러오기에 실패했을 때 원인을 파악하는 과정도 너무 복잡했다. 결국엔 에러메세지 이용해서 확인하긴 했지만..

그래도 Core Data를 통해 데이터를 성공적으로 저장하고 불러오는 기능을 구현하고, 이미지 파일을 효율적으로 관리하는 구조를 만들어볼 수 있었다.

Core Data는 처음 다루기에는 좀 어렵지만 데이터 영속성을 유지하는데 이만한게 없다는 생각도 들었다.

앞으로도 많이 쓰일 코어데이터. 잘 공부해보고싶다.

profile
iOS 좋아. swift 좋아.

6개의 댓글

스압 장난 아니네요…
잘 정리되어 있어 보기 좋았습니다!!
다음 레벨들도 힘내봐요~

1개의 답글
comment-user-thumbnail
2024년 12월 9일

이렇게 잘쓰는데 왜 우수TIL에 선정이 안될까.. 선정이....? 예나 선정이딸이에요!

1개의 답글
comment-user-thumbnail
2024년 12월 12일

난데없는 1970년으로의 시간여행에 파일매니저까지 하느라 정신이 쏙 빠지셨겠네요

1개의 답글

관련 채용 정보