Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

카테고리 CRUD를 CoreData와 연결하여 구현 #99

Merged
merged 29 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from 26 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
60930fa
refactor: toABC() -> convertToABC()로 변경
Kyxxn Nov 28, 2024
196b2a7
docs: CoreDataStorage 설명하는 주석 추가
Kyxxn Nov 28, 2024
945cefc
feat: BookCategory 엔티티와 DTO 추가
Kyxxn Nov 28, 2024
ec1a4a3
chore: 주석 추가
Kyxxn Nov 28, 2024
d0b950e
feat: BookCategoryEntity 코어데이터 모델 추가
Kyxxn Nov 28, 2024
fb8d717
refactor: BookCategoryEntity 코어데이터 네이밍 수정
Kyxxn Nov 28, 2024
32a81e6
Merge branch 'develop' into feature/crud-coredata-category
Kyxxn Nov 28, 2024
7f809c8
feat: Error 코드 추가
Kyxxn Nov 28, 2024
38275e1
feat: BookCategory에 대한 Storage 작업 완료
Kyxxn Nov 28, 2024
53db6b7
refactor: Category에서 BookCategory 네이밍 변경
Kyxxn Nov 28, 2024
bc048cc
chore: 불필요한 코드 제거
Kyxxn Nov 28, 2024
67fd189
feat: BookCategory 관련 의존성 주입
Kyxxn Nov 28, 2024
d7dacc8
feat: BookCategory Fetch UseCase 추가
Kyxxn Nov 28, 2024
0ad5b0f
chore: 네이밍 변경
Kyxxn Nov 28, 2024
9237ecc
refactor: 카테고리 삭제할 때만 String 넘겨주고, 나머지는 BookCategory로 변경
Kyxxn Nov 28, 2024
0e49823
refactor: BookCategoryViewModel에서 카테고리를 문자열이 아닌 BookCategory로 변경
Kyxxn Nov 28, 2024
ffed81a
chore: TODO 추가
Kyxxn Nov 28, 2024
d6adc76
fix: CoreData: error: Failed to load model named 에러 해결
Kyxxn Nov 28, 2024
ccf78f0
chore: 불필요한 코드 제거
Kyxxn Nov 28, 2024
8032520
fix: 동시성 문제 임시해결
Kyxxn Nov 28, 2024
d0e1c74
fix: 전체, 즐겨찾기 추가
Kyxxn Nov 29, 2024
aa25a64
refactor: 커스텀 Detent에서 medium으로 변경
Kyxxn Nov 29, 2024
5456b0a
fix: 카테고리 Update 안되는 문제 해결
Kyxxn Nov 29, 2024
9478ab8
refactor: UIAlertController 편의 생성자에 취소 액션 추가
Kyxxn Nov 29, 2024
02cc76e
chore: 불필요한 코드 제거
Kyxxn Nov 29, 2024
22cc775
fix: 삭제 버튼 클릭 시 카테고리 이름이 제대로 보이지 않는 문제 해결
Kyxxn Nov 29, 2024
a187d3c
feat: 카테고리 CRUD 테스트 코드 작성
Kyxxn Nov 29, 2024
2ee700d
refactor: 에러 명시글 수정
Kyxxn Nov 29, 2024
d27f9fd
refactor: NSPersistentContainer의 lazy 제거
Kyxxn Nov 29, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,8 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {

func registerDependency() {
do {
registerRepositoryDependency()
try registerStorageDepedency()
try registerRepositoryDependency()
try registerUseCaseDependency()
try registerViewModelFactoryDependency()
} catch let error as MHCoreError {
Expand All @@ -45,14 +46,26 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
}
}

private func registerRepositoryDependency() {
private func registerStorageDepedency() throws {
DIContainer.shared.register(CoreDataStorage.self, object: CoreDataStorage())

let coreDataStorage = try DIContainer.shared.resolve(CoreDataStorage.self)
DIContainer.shared.register(
BookCategoryStorage.self,
object: CoreDataBookCategoryStorage(coreDataStorage: coreDataStorage)
)
}

private func registerRepositoryDependency() throws {
DIContainer.shared.register(
MemorialHouseRepository.self,
object: DefaultMemorialHouseRepository()
)

let bookCategoryStorage = try DIContainer.shared.resolve(BookCategoryStorage.self)
DIContainer.shared.register(
CategoryRepository.self,
object: DefaultCategoryRepository()
BookCategoryRepository.self,
object: LocalBookCategoryRepository(storage: bookCategoryStorage)
)
}

Expand All @@ -65,47 +78,49 @@ final class SceneDelegate: UIResponder, UIWindowSceneDelegate {
)

// MARK: Category UseCase
let categoryRepository = try DIContainer.shared.resolve(CategoryRepository.self)
let bookCategoryRepository = try DIContainer.shared.resolve(BookCategoryRepository.self)
DIContainer.shared.register(
CreateCategoryUseCase.self,
object: DefaultCreateCategoryUseCase(repository: categoryRepository)
CreateBookCategoryUseCase.self,
object: DefaultCreateBookCategoryUseCase(repository: bookCategoryRepository)
)
DIContainer.shared.register(
FetchCategoriesUseCase.self,
object: DefaultFetchCategoriesUseCase(repository: categoryRepository)
FetchBookCategoriesUseCase.self,
object: DefaultFetchBookCategoriesUseCase(repository: bookCategoryRepository)
)
DIContainer.shared.register(
UpdateCategoryUseCase.self,
object: DefaultUpdateCategoryUseCase(repository: categoryRepository)
UpdateBookCategoryUseCase.self,
object: DefaultUpdateBookCategoryUseCase(repository: bookCategoryRepository)
)
DIContainer.shared.register(
DeleteCategoryUseCase.self,
object: DefaultDeleteCategoryUseCase(repository: categoryRepository)
DeleteBookCategoryUseCase.self,
object: DefaultDeleteBookCategoryUseCase(repository: bookCategoryRepository)
)
}

private func registerViewModelFactoryDependency() throws {
// MARK: MemorialHouse ViewModel
let fetchMemorialHouseUseCase = try DIContainer.shared.resolve(FetchMemorialHouseUseCase.self)
let fetchCategoryUseCase = try DIContainer.shared.resolve(FetchCategoriesUseCase.self)
let fetchBookCategoryUseCase = try DIContainer.shared.resolve(FetchBookCategoriesUseCase.self)
DIContainer.shared.register(
HomeViewModelFactory.self,
object: HomeViewModelFactory(
fetchMemorialHouseUseCase: fetchMemorialHouseUseCase,
fetchCategoryUseCase: fetchCategoryUseCase
fetchCategoryUseCase: fetchBookCategoryUseCase
)
)

// MARK: Category ViewModel
let createCategoryUseCase = try DIContainer.shared.resolve(CreateCategoryUseCase.self)
let updateCategoryUseCase = try DIContainer.shared.resolve(UpdateCategoryUseCase.self)
let deleteCategoryUseCase = try DIContainer.shared.resolve(DeleteCategoryUseCase.self)
let createBookCategoryUseCase = try DIContainer.shared.resolve(CreateBookCategoryUseCase.self)
let fetchBookCategoriesUseCase = try DIContainer.shared.resolve(FetchBookCategoriesUseCase.self)
let updateBookCategoryUseCase = try DIContainer.shared.resolve(UpdateBookCategoryUseCase.self)
let deleteBookCategoryUseCase = try DIContainer.shared.resolve(DeleteBookCategoryUseCase.self)
DIContainer.shared.register(
CategoryViewModelFactory.self,
object: CategoryViewModelFactory(
createCategoryUseCase: createCategoryUseCase,
updateCategoryUseCase: updateCategoryUseCase,
deleteCategoryUseCase: deleteCategoryUseCase
BookCategoryViewModelFactory.self,
object: BookCategoryViewModelFactory(
createBookCategoryUseCase: createBookCategoryUseCase,
fetchBookCategoriesUseCase: fetchBookCategoriesUseCase,
updateBookCategoryUseCase: updateBookCategoryUseCase,
deleteBookCategoryUseCase: deleteBookCategoryUseCase
)
)
}
Expand Down
3 changes: 3 additions & 0 deletions MemorialHouse/MHCore/MHCore/MHDataError.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ public enum MHDataError: Error, CustomStringConvertible, Equatable {
case fileDeletionFailure
case fileMovingFailure
case fileNotExists
case generalFailure

public var description: String {
switch self {
Expand Down Expand Up @@ -46,6 +47,8 @@ public enum MHDataError: Error, CustomStringConvertible, Equatable {
"파일 이동 실패"
case .fileNotExists:
"파일이 존재하지 않습니다"
case .generalFailure:
"알 수 없는 에러입니다."
}
}
}
22 changes: 22 additions & 0 deletions MemorialHouse/MHData/MHData/DTO/BookCategoryDTO.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import MHFoundation
import MHDomain

public struct BookCategoryDTO {
let order: Int
let name: String

public init(
order: Int,
name: String
) {
self.order = order
self.name = name
}

func convertToBookCategory() -> BookCategory {
BookCategory(
order: order,
name: name
)
}
}
2 changes: 1 addition & 1 deletion MemorialHouse/MHData/MHData/DTO/BookCoverDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public struct BookCoverDTO {
self.favorite = favorite
}

func toBookCover() -> BookCover? {
func convertToBookCover() -> BookCover? {
guard let color = BookColor(rawValue: self.color) else { return nil }

return BookCover(
Expand Down
4 changes: 2 additions & 2 deletions MemorialHouse/MHData/MHData/DTO/BookDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ public struct BookDTO {
self.pages = pages
}

func toBook() -> Book {
func convertToBook() -> Book {
return Book(
id: self.id,
pages: self.pages.map { $0.toPage() }
pages: self.pages.map { $0.convertToPage() }
)
}
}
2 changes: 1 addition & 1 deletion MemorialHouse/MHData/MHData/DTO/MediaDescriptionDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@
self.attributes = attributes
}

func toMediaDescription() -> MediaDescription? {
func convertToMediaDescription() -> MediaDescription? {
guard let type = MediaType(rawValue: self.type) else { return nil }
let attributes = try? JSONSerialization.jsonObject(with: attributes ?? Data(), options: []) as? [String: any Sendable]

Check warning on line 17 in MemorialHouse/MHData/MHData/DTO/MediaDescriptionDTO.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line Length Violation: Line should be 120 characters or less; currently it has 126 characters (line_length)

Check warning on line 17 in MemorialHouse/MHData/MHData/DTO/MediaDescriptionDTO.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line Length Violation: Line should be 120 characters or less; currently it has 126 characters (line_length)

return MediaDescription(
id: self.id,
Expand Down
4 changes: 2 additions & 2 deletions MemorialHouse/MHData/MHData/DTO/PageDTO.swift
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ public struct PageDTO {
self.text = text
}

func toPage() -> Page {
func convertToPage() -> Page {
let metadata = self.metadata
.compactMapValues { $0.toMediaDescription() }
.compactMapValues { $0.convertToMediaDescription() }

return Page(
id: self.id,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import MHFoundation
import MHCore

public protocol BookCategoryStorage: Sendable {
func create(with category: BookCategoryDTO) async -> Result<Void, MHDataError>
func fetch() async -> Result<[BookCategoryDTO], MHDataError>
func update(oldName: String, with category: BookCategoryDTO) async -> Result<Void, MHDataError>
func delete(with categoryName: String) async -> Result<Void, MHDataError>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import MHFoundation
import MHCore
import CoreData

public final class CoreDataBookCategoryStorage {
private let coreDataStorage: CoreDataStorage

public init(coreDataStorage: CoreDataStorage) {
self.coreDataStorage = coreDataStorage
}

private func performDatabaseTask<T>(
_ task: @escaping (NSManagedObjectContext) throws -> T
) async -> Result<T, MHDataError> {
let context = coreDataStorage.persistentContainer.viewContext
do {
return try await context.perform {
do {
return .success(try task(context))
} catch let error as MHDataError {
MHLogger.debug("Core Data 에러: \(error.description)")
throw error
} catch {
MHLogger.debug("알 수 없는 Core Data 에러: \(error.localizedDescription)")
throw error
}
}
} catch let error as MHDataError {
return .failure(error)
} catch {
return .failure(MHDataError.generalFailure)
}
}
Comment on lines +12 to +33
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

코어데이터 CRUD 로직이 겹치길래 얘로 묶어줬는데, 다른 분들 코드에 적용해보면 어떨까요 ??
중복되는 코드 길이를 줄일 수 있을 거 같습니당

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

좋아욤!!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

구뜨!

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ERROR를 처리할 때 Result의 failure와 throw를 나누신 의도가 궁금합니다!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@yuncheol-AHN
내부 디버깅이랑 호출하는 곳에서의 에러 처리를 동시에 해주려고 그랬습니다 !!
crud 메소드에서 발생한 throw를 해당 메소드 catch에서 잡고 디버그 로그로 출력하고 이걸 Result로 반환합니다 !

}

extension CoreDataBookCategoryStorage: BookCategoryStorage {
public func create(with category: BookCategoryDTO) async -> Result<Void, MHDataError> {
return await performDatabaseTask { context in
guard let entity = NSEntityDescription.entity(forEntityName: "BookCategoryEntity", in: context) else {
throw MHDataError.noSuchEntity(key: "BookCategoryEntity")
}
let bookCategory = NSManagedObject(entity: entity, insertInto: context)
bookCategory.setValue(category.order, forKey: "order")
bookCategory.setValue(category.name, forKey: "name")
try context.save()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: CoreDataStorage를 보면 실은 saveContext라는 메서드가 있어서.. try를 쓰지 않고도 저장할 수 있긴한데�이것도 괜찮아 보이네용.. saveContext 메서드는 지울까엽??

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

옷 그러네요..!
지워도 좋을 거 같아용

}
}

public func fetch() async -> Result<[BookCategoryDTO], MHDataError> {
return await performDatabaseTask { [weak self] context in
let request = BookCategoryEntity.fetchRequest()
let bookCategoryEntities = try context.fetch(request)
return bookCategoryEntities.compactMap { self?.coreBookCategoryToDTO($0) }
}
}

public func update(oldName: String, with category: BookCategoryDTO) async -> Result<Void, MHDataError> {
return await performDatabaseTask { context in
let request = BookCategoryEntity.fetchRequest()
if let entity = try context.fetch(request).first(where: { $0.name == oldName }) {
entity.setValue(category.name, forKey: "name")
entity.setValue(category.order, forKey: "order")
try context.save()
}
}
}

public func delete(with categoryName: String) async -> Result<Void, MHDataError> {
return await performDatabaseTask { context in
let request = BookCategoryEntity.fetchRequest()
if let entity = try context.fetch(request).first(where: { $0.name == categoryName }) {
context.delete(entity)
try context.save()
}
}
}
}

// MARK: - Mapper
extension CoreDataBookCategoryStorage {
func coreBookCategoryToDTO(_ bookCategory: BookCategoryEntity) -> BookCategoryDTO? {
guard let name = bookCategory.name else { return nil }
let order = Int(bookCategory.order)

return BookCategoryDTO(
order: order,
name: name
)
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,17 @@
import CoreData
import MHCore

class CoreDataStorage {
/// Core Data 스택을 구성하는 기본 클래스이며, 다른 코어데이터 구현체의 프로퍼티로 사용됩니다.
/// 또한, 테스트 환경에서 `MockCoreDataStorage`를 상속받아 확장할 수 있도록 설계되었습니다.
///
/// 이 클래스를 프로토콜로 작성하지 않은 이유는,
/// 테스트 코드에서도 `MemorialHouseModel`이라는 Core Data 모델을 동일하게 사용하기 위해서입니다.
/// 이를 통해 테스트 환경에서도 실제 DB 모델 구조를 유지하며 간단히 확장할 수 있습니다.
///
/// - 주요 특징:
/// - `NSPersistentContainer`를 활용해 Core Data 스택을 구성합니다.
/// - `saveContext` 메서드를 통해 변경된 컨텍스트를 저장합니다.
public class CoreDataStorage: @unchecked Sendable {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

혹시

public final class CoreDataStorage: Sendable {
    static let modelName: String = "MemorialHouseModel"
    
    nonisolated(unsafe) static let memorialHouseModel: NSManagedObjectModel = {
        guard let modelURL = Bundle(for: CoreDataStorage.self).url(
            forResource: CoreDataStorage.modelName,
            withExtension: "momd"
        ) else {
            fatalError("Error loading model from bundle")
        }
        return NSManagedObjectModel(contentsOf: modelURL)!
    }()
    
    let persistentContainer: NSPersistentContainer
    
    public init() {
        let container = NSPersistentContainer(
            name: CoreDataStorage.modelName,
            managedObjectModel: Self.memorialHouseModel
        )
        container.loadPersistentStores { _, error in
            guard let error else { return }
            MHLogger.error("\(#function): PersistentContainer 호출에 실패; \(error.localizedDescription)")
        }
        self.persistentContainer = container
    }
...
}

요렇게 하셔도 에러 뜨시나요?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lazy 사용하지 않는 방식인 거죵 ?
한 번 해보겠습니다

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

문제없이 잘 돌아가네요, 저도 lazy 방식보다 정현님 말씀대로 하는게 좋은 거 같습니다
수정해서 올릴게요 !!

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

참 테스트코드에서 final class MockCoreDataStorage: CoreDataStorage {}
이렇게 사용하고 있어서 final은 제거하겠습니다 !!

static let modelName: String = "MemorialHouseModel"

nonisolated(unsafe) static let memorialHouseModel: NSManagedObjectModel = {
Expand All @@ -15,7 +25,7 @@
}()

lazy var persistentContainer: NSPersistentContainer = {
let container = NSPersistentContainer(name: CoreDataStorage.modelName)
let container = NSPersistentContainer(name: CoreDataStorage.modelName, managedObjectModel: Self.memorialHouseModel)

Check warning on line 28 in MemorialHouse/MHData/MHData/LocalStorage/CoreData/CoreDataStorage.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line Length Violation: Line should be 120 characters or less; currently it has 123 characters (line_length)

Check warning on line 28 in MemorialHouse/MHData/MHData/LocalStorage/CoreData/CoreDataStorage.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Line Length Violation: Line should be 120 characters or less; currently it has 123 characters (line_length)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CoreData: error: Failed to load model named "우리팀모델.xcdatamodeld"
위 문제가 발생해서 다음과 같이 수정해주었습니다.

container.loadPersistentStores { _, error in
guard let error else { return }
MHLogger.error("\(#function): PersistentContainer 호출에 실패; \(error.localizedDescription)")
Expand All @@ -24,7 +34,7 @@
return container
}()

init() { }
public init() { }

func saveContext() async {
let context = persistentContainer.viewContext
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23507" systemVersion="24A348" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="23507" systemVersion="24B2082" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithSwiftData="YES" userDefinedModelVersionIdentifier="">
<entity name="BookCategoryEntity" representedClassName="BookCategoryEntity" syncable="YES" codeGenerationType="class">
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="order" optional="YES" attributeType="Integer 64" defaultValueString="0" usesScalarValueType="YES"/>
</entity>
<entity name="BookCoverEntity" representedClassName="BookCoverEntity" syncable="YES" codeGenerationType="class">
<attribute name="category" optional="YES" attributeType="String"/>
<attribute name="color" optional="YES" attributeType="String" customClassName="BookColor"/>
Expand Down

This file was deleted.

Loading
Loading