Skip to content

Setting/#101 코어데이터를 통한 저장소 구현#104

Open
dev-domo wants to merge 26 commits intodevfrom
setting/#101-coredata
Open

Setting/#101 코어데이터를 통한 저장소 구현#104
dev-domo wants to merge 26 commits intodevfrom
setting/#101-coredata

Conversation

@dev-domo
Copy link
Copy Markdown
Collaborator

✅ PR 타입

  • feat: 새로운 기능 추가

🪾 반영 브랜치

setting/#101-coredata -> dev

✨ 변경 사항

  • 코어데이터 기반 시나리오, 미션, 약관, 멤버 엔티티 및 스토리지 개발
  • 각 스토리지 기능에 대한 테스트코드 작성 완료

📂 관련 이슈

#101

유저 이름 조회, 유저 이름 수정, 유저 탈퇴
MockuserDefaultsService -> MockUserDefaultsService
@dev-domo dev-domo self-assigned this Mar 11, 2026
@dev-domo dev-domo added enhancement New feature or request setting labels Mar 11, 2026
@gemini-code-assist
Copy link
Copy Markdown

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

이 Pull Request는 앱의 데이터 지속성 계층을 CoreData로 전환하여 핵심 데이터 관리 방식을 재정의합니다. 기존의 네트워크 기반 인증 및 멤버 관리 시스템을 로컬 CoreData 저장소로 대체함으로써, 시나리오, 미션, 약관, 멤버 정보를 효율적으로 관리할 수 있게 됩니다. 이는 로그인 흐름을 간소화하고 외부 소셜 로그인 의존성을 제거하며, 새로운 저장소 계층에 대한 광범위한 단위 테스트를 포함하여 데이터 관리의 견고함을 향상시킵니다.

Highlights

  • CoreData 기반 저장소 구현: 시나리오, 미션, 약관, 멤버 엔티티 및 관련 저장소 기능을 CoreData를 사용하여 구현했습니다.
  • 인증 및 멤버 관리 로직 간소화: 기존 네트워크 기반의 복잡한 인증 및 멤버 관리 로직을 CoreData 기반의 로컬 저장소로 대체하여 간소화했습니다. 소셜 로그인 관련 UI 및 로직이 제거되었습니다.
  • 새로운 에러 타입 추가: CoreData 작업 및 데이터 조회 실패 시 발생할 수 있는 다양한 에러 케이스를 BeforeGoingError.swift에 추가했습니다.
  • 의존성 주입 업데이트: 새로운 CoreData 저장소 구현체에 맞춰 DataDependencyAssemblerDomainDependencyAssembler의 의존성 주입 설정을 업데이트했습니다.
  • 단위 테스트 추가: 새롭게 구현된 CoreData 기반의 멤버, 미션, 시나리오, 약관 UseCase에 대한 포괄적인 단위 테스트를 추가하여 안정성을 확보했습니다.

🧠 New Feature in Public Preview: You can now enable Memory to help Gemini Code Assist learn from your team's feedback. This makes future code reviews more consistent and personalized to your project's style. Click here to enable Memory in your admin console.

Changelog
  • BeforeGoing.xcodeproj/project.pbxproj
    • 테스트 타겟에 CoreData 모델을 포함하도록 프로젝트 파일이 업데이트되었습니다.
  • BeforeGoing/Core/BeforeGoingError.swift
    • CoreData 작업 및 데이터 조회 실패와 관련된 새로운 에러 케이스가 추가되었습니다.
  • BeforeGoing/Data/BeforeGoingModel.xcdatamodel/contents
    • Member, Mission, Notification, Scenario, Terms, TimeNotification 엔티티에 대한 CoreData 모델 정의가 추가되었습니다.
  • BeforeGoing/Data/DataDependencyAssembler.swift
    • AuthInterfaceMemberInterface에 대한 새로운 CoreData 저장소 구현을 사용하도록 의존성 주입이 수정되었습니다.
  • BeforeGoing/Data/Model/Notification/NotificationsDTO.swift
    • NotificationsEntity 매핑에서 속성 이름이 scenarios에서 notifications로 변경되었습니다.
  • BeforeGoing/Data/Network/Extensions/Notification.Name+.swift
    • Notification.Name 확장자가 Foundation.Notification.Name을 사용하도록 업데이트되었습니다.
  • BeforeGoing/Data/Persistence/CoreData/CoreDataStack.swift
    • NSPersistentContainerNSManagedObjectContext 관리를 위한 CoreDataStack이 추가되었습니다.
  • BeforeGoing/Data/Persistence/Service/UserDefaultsKey.swift
    • UserDefaultsKey 열거형에 userID 키가 추가되었습니다.
  • BeforeGoing/Data/Persistence/Service/UserDefaultsService.swift
    • 테스트 목적으로 MockUserDefaultsService가 추가되었습니다.
  • BeforeGoing/Data/Repository/API/AuthRepository.swift
    • UserDefaults를 사용하여 로컬 로그인 확인에 중점을 둔 새로운 AuthRepository 구현이 추가되었습니다.
  • BeforeGoing/Data/Repository/API/MemberRepository.swift
    • 파일 이름이 변경되었습니다.
  • BeforeGoing/Data/Repository/API/MissionRepository.swift
    • 파일 이름이 변경되었습니다.
  • BeforeGoing/Data/Repository/API/ScenarioRepository.swift
    • 파일 이름이 변경되었습니다.
  • BeforeGoing/Data/Repository/API/TermsRepository.swift
    • 파일 이름이 변경되었습니다.
  • BeforeGoing/Data/Repository/AuthRepository.swift
    • 이전 AuthRepository 파일이 제거되었습니다.
  • BeforeGoing/Data/Repository/Storage/AutoCounter.swift
    • CoreData 엔티티에 대한 고유 ID 생성을 위한 AutoCounter 유틸리티가 추가되었습니다.
  • BeforeGoing/Data/Repository/Storage/MemberStorage.swift
    • CoreData 기반 멤버 지속성을 위한 MemberStorage가 추가되었습니다.
  • BeforeGoing/Data/Repository/Storage/MissionStorage.swift
    • CoreData 기반 미션 지속성을 위한 MissionStorage가 추가되었습니다.
  • BeforeGoing/Data/Repository/Storage/ScenarioStorage.swift
    • CoreData 기반 시나리오 지속성을 위한 ScenarioStorage가 추가되었습니다.
  • BeforeGoing/Data/Repository/Storage/TermsStorage.swift
    • CoreData 기반 약관 지속성을 위한 TermsStorage가 추가되었습니다.
  • BeforeGoing/Domain/DomainDependencyAssembler.swift
    • 목업 UseCase 및 여러 인증 관련 UseCase가 제거되었습니다.
    • LoginUseCase 등록이 업데이트되었습니다.
  • BeforeGoing/Domain/Entity/NotificationsEntity.swift
    • NotificationsEntityscenarios 대신 notifications 배열을 사용하도록 업데이트되었습니다.
  • BeforeGoing/Domain/Interface/AuthInterface.swift
    • AuthInterfacelogin() 메서드만 포함하도록 간소화되었습니다.
  • BeforeGoing/Domain/Interface/MemberInterface.swift
    • isAppleLogined 속성이 제거되었습니다.
  • BeforeGoing/Domain/UseCase/Auth/AutoLoginUseCase.swift
    • 파일이 제거되었습니다.
  • BeforeGoing/Domain/UseCase/Auth/GetLastLoginUseCase.swift
    • 파일이 제거되었습니다.
  • BeforeGoing/Domain/UseCase/Auth/LoginUseCase.swift
    • LoginUseCaseexecute() 메서드만 포함하도록 간소화되었습니다.
  • BeforeGoing/Domain/UseCase/Auth/LogoutUseCase.swift
    • 파일이 제거되었습니다.
  • BeforeGoing/Presentation/Enum/DaysOfWeek.swift
    • 쉼표로 구분된 요일 문자열을 정수 배열로 변환하는 정적 메서드 toRawvalues가 추가되었습니다.
  • BeforeGoing/Presentation/Feature/Approach/View/Login/LoginView.swift
    • 카카오 및 애플 로그인 버튼, LastLoginBadgeView 및 관련 제약 조건이 제거되고 단일 '시작하기' 버튼으로 대체되어 LoginView가 간소화되었습니다.
  • BeforeGoing/Presentation/Feature/Approach/ViewController/LoginViewController.swift
    • 소셜 로그인 로직, 자동 로그인 및 viewWillAppear 로직이 제거되어 LoginViewController가 간소화되었습니다.
  • BeforeGoing/Presentation/Feature/Approach/ViewModel/LoginViewModel.swift
    • 자동 로그인, 소셜 로그인 및 마지막 로그인 제공자 로직이 제거되어 LoginViewModel이 간소화되었습니다.
  • BeforeGoing/Presentation/Feature/Scenario/ViewModel/GetScenariosViewModel.swift
    • createScenarios 호출이 notifications 속성을 사용하도록 업데이트되었습니다.
  • BeforeGoing/Presentation/Feature/Setting/ViewController/ProfileViewController.swift
    • 로그아웃 로직이 제거되었습니다.
  • BeforeGoing/Presentation/Feature/Setting/ViewModel/ProfileViewModel.swift
    • logoutUseCase 의존성 및 logoutButtonDidTap 입력이 제거되었습니다.
  • BeforeGoing/Presentation/PresentationDependencyAssembler.swift
    • 인증 및 소셜 로그인과 관련된 여러 UseCase 등록이 제거되었습니다.
    • LoginViewModelProfileViewModel 등록이 업데이트되었습니다.
  • BeforeGoingTests/UseCase/ContextProvider.swift
    • 테스트를 위한 목업 CoreData 컨텍스트 생성을 위한 ContextProvider가 추가되었습니다.
  • BeforeGoingTests/UseCase/MemberUseCaseTest.swift
    • MemberUseCase 기능에 대한 단위 테스트가 추가되었습니다.
  • BeforeGoingTests/UseCase/MissionUseCaseTest.swift
    • MissionUseCase 기능에 대한 단위 테스트가 추가되었습니다.
  • BeforeGoingTests/UseCase/ScenarioUseCaseTest.swift
    • ScenarioUseCase 기능에 대한 단위 테스트가 추가되었습니다.
  • BeforeGoingTests/UseCase/TermsUseCaseTest.swift
    • TermsUseCase 기능에 대한 단위 테스트가 추가되었습니다.
Activity
  • CoreData를 사용하여 앱의 데이터 지속성 계층을 구현했습니다.
  • 기존 리포지토리와 UseCase를 CoreData 저장소와 통합하도록 리팩토링했습니다.
  • 이전 네트워크 기반 인증 및 소셜 로그인 기능이 제거되거나 간소화되었습니다.
  • CoreDataStack, AutoCounter와 같은 새로운 CoreData 모델 및 헬퍼 클래스가 추가되었습니다.
  • 새로운 CoreData 기반 UseCase(멤버, 미션, 시나리오, 약관)에 대한 포괄적인 단위 테스트가 구현되었습니다.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

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

Code Review

이번 PR은 Core Data를 사용하여 로컬 데이터 영속성을 구현하는 중요하고 잘 수행된 리팩토링입니다. CoreDataStack, 다양한 스토리지 레포지토리(MemberStorage, ScenarioStorage 등)의 추가와 새로운 레이어에 대한 포괄적인 단위 테스트는 인상적입니다. 특히 관심사 분리와 Core Data의 perform 블록과 함께 async/await를 사용한 점 등 코드가 잘 구조화되어 있습니다.

하지만 몇 가지 중요한 수정 사항이 있습니다. MemberStorage의 의존성 주입에서 NSManagedObjectContext가 누락되어 런타임 크래시를 유발할 수 있습니다. 또한 ScenarioStorage에서 새로운 미션이 중복된 ID(0)로 생성될 수 있는 잠재적인 버그가 있습니다. 주석 처리된 코드 제거 및 업데이트 메서드의 효율성 개선과 같은 몇 가지 사소한 개선 사항에 대한 제안도 남겼습니다.

전반적으로 애플리케이션의 데이터 레이어를 견고하게 구축하는 데 있어 훌륭한 진전입니다.

// userDefaultsService: userDefaultsService,
// updateNicknameRequestMapper: updateNicknameRequestMapper
// )
MemberStorage(userDefaultsService: userDefaultsService)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

critical

MemberStorage를 초기화할 때 NSManagedObjectContext가 누락되었습니다. MemberStorageinitcontext를 필요로 하므로, 이대로는 런타임에 크래시가 발생할 것입니다. CoreDataStack.shared.context를 주입해야 합니다.

Suggested change
MemberStorage(userDefaultsService: userDefaultsService)
MemberStorage(userDefaultsService: userDefaultsService, context: CoreDataStack.shared.context)

id: Int? = nil
) {
let mission = Mission(context: context)
mission.id = Int64(id ?? 0)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

high

createMission 함수에서 새로운 미션을 생성할 때 idnil이면 mission.id가 0으로 설정됩니다. 여러 개의 새로운 미션이 동시에 추가될 경우 ID가 중복될 수 있는 심각한 버그입니다. idnil일 때는 AutoCounter.getNextID를 사용하여 새로운 ID를 생성해야 합니다.

Suggested change
mission.id = Int64(id ?? 0)
mission.id = id.map(Int64.init) ?? AutoCounter.getNextID(for: Mission.self, in: context)

Comment on lines +25 to +174
// func requestNonce(provider: Provider) async throws -> NonceEntity {
// let nonceRequestDTO = nonceRequestMapper.map(provider.rawValue)
// let response = try await networkService.request(
// endPoint: AuthAPI.nonce(dto: nonceRequestDTO),
// responseType: NonceResponseDTO.self
// )
// return response.toEntity()
// }
//
// func requestLogin(provider: Provider) async throws -> Bool {
// let nonceEntity = try await requestNonce(provider: provider)
// let idToken = try await requestIDToken(nonce: nonceEntity.nonce)
//
// return try await requestLogin(provider: provider, idToken: idToken)
// }
//
// private func requestLogin(provider: Provider, idToken: String) async throws -> Bool {
// let requestDTO = loginRequestMapper.map((provider.rawValue, idToken))
// let response = try await networkService.request(
// endPoint: AuthAPI.login(dto: requestDTO),
// responseType: LoginResponseDTO.self
// )
// saveKeyChain(response: response)
// saveProvider(provider)
//
// let isAgreedTerms = try await isAgreedTerms(accessToken: response.accessToken)
// let isCompletedJoin = !response.isNewMember && isAgreedTerms
// return isCompletedJoin
// }
//
// func requestLogin(provider: Provider, idToken: String, name: String?) async throws -> Bool {
// let isCompletedJoin = try await requestLogin(provider: provider, idToken: idToken)
//
// if let name,
// !name.isBlank,
// let accessToken = keyChainService.load(key: .accessToken) {
// try await networkService.request(
// endPoint: MemberAPI.updateNickname(
// accessToken: accessToken,
// dto: .init(nickname: name)
// )
// )
// }
//
// return isCompletedJoin
// }
//
// func autoLogin() async throws -> Bool {
// guard let accessToken = keyChainService.load(key: .accessToken) else {
// return false
// }
//
// guard isTokenExists,
// try await isAgreedTerms(accessToken: accessToken) else {
// return false
// }
//
// guard let accessTokenExpirationDate = keyChainService.load(key: .accessTokenExpirationDate),
// let refreshTokenExpirationDate = keyChainService.load(key: .refreshTokenExpirationDate) else {
// return false
// }
//
// if tokenValidator.isAccessTokenValid(expirationDate: accessTokenExpirationDate) {
// return true
// }
//
// if tokenValidator.isRefreshTokenValid(expirationDate: refreshTokenExpirationDate) {
// do {
// try await tokenReissuer.reissue()
// return true
// } catch {
// return false
// }
// }
// return false
// }
//
// func getLastLogin() -> Provider? {
// guard let lastLogin: LastLogin = userDefaultsService.load(key: .lastProvider) else {
// return nil
// }
// return Provider(rawValue: lastLogin.provider)
// }
//
// func logout() async throws {
// guard let accessToken = keyChainService.load(key: .accessToken) else {
// BeforeGoingLogger.error(BeforeGoingError.accessTokenMissing)
// return
// }
// try await networkService.request(endPoint: AuthAPI.logout(accessToken: accessToken))
//
// deleteUserInformation()
// }
//
// private func requestIDToken(nonce: String?) async throws -> String {
// return try await networkService.requestKakaoIDToken(nonce: nonce)
// }
//
// private func saveKeyChain(response: LoginResponseDTO) {
// let responseData: [KeyChainKey: String] = [
// .accessToken: response.accessToken,
// .refreshToken: response.refreshToken,
// .accessTokenExpirationDate: tokenValidator.calculateExpirationDate(
// expiresIn: response.accessTokenExpiresIn
// ),
// .refreshTokenExpirationDate: tokenValidator.calculateExpirationDate(
// expiresIn: response.refreshTokenExpiresIn
// )
// ]
//
// for data in responseData {
// keyChainService.save(data.value, forKey: data.key)
// }
// }
//
// private func saveProvider(_ provider: Provider) {
// let lastLogin = LastLogin(provider: provider.rawValue, timestamp: Date())
//
// let _ = userDefaultsService.save(provider.rawValue, key: .provider)
// let _ = userDefaultsService.save(lastLogin, key: .lastProvider)
// }
//
// private var isTokenExists: Bool {
// if let accessToken = keyChainService.load(key: .accessToken),
// let refreshToken = keyChainService.load(key: .refreshToken),
// !accessToken.isEmpty,
// !refreshToken.isEmpty {
// return true
// }
// return false
// }
//
// private func isAgreedTerms(accessToken: String) async throws -> Bool {
// do {
// let _ = try await networkService.request(
// endPoint: TermsAPI.getTerms(accessToken: accessToken),
// responseType: TermsResponseDTO.self
// )
// return true
// } catch {
// return false
// }
// }
//
// private func deleteUserInformation() {
// for key in KeyChainKey.allCases {
// keyChainService.delete(key: key)
// }
// let _ = userDefaultsService.delete(key: .provider)
// }
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

현재 사용되지 않는 많은 양의 코드가 주석 처리되어 있습니다. 향후 다시 사용할 계획이 없다면 가독성을 위해 삭제하는 것이 좋습니다.

Comment on lines +25 to +35
try await context.perform { [weak self] in
guard let self,
let member = try getMember()
else {
throw BeforeGoingError.memberNotFound
}

member.nickname = nickname

try context.save()
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

context.perform 클로저 내에서 getMember()를 호출하고 있습니다. getMember()는 내부적으로 context.fetch를 호출하는데, 이는 동기 작업이므로 context.perform 블록 내에서 안전하게 실행됩니다. 하지만 getMember()throws 키워드를 가지고 있으므로, try를 사용하여 에러를 처리해야 합니다. 현재 코드에서는 try가 올바르게 사용되었지만, getMember 함수 자체는 context의 스레드에서만 호출되어야 한다는 점을 명확히 하기 위해 함수 이름을 getMember(on:) 등으로 변경하고 context를 파라미터로 전달하는 것을 고려해볼 수 있습니다. 이는 강제 사항은 아니지만 코드의 스레드 안정성을 더 명확하게 표현하는 데 도움이 될 수 있습니다.

Comment on lines +69 to +73
func checkMission(
missionID: Int,
date: String,
isChecked: Bool
) async throws {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

checkMission 함수에서 date 파라미터가 사용되지 않고 있습니다. 불필요한 파라미터로 보입니다. 이 파라미터를 제거하고 MissionInterface 프로토콜도 함께 수정하는 것을 고려해 보세요.


func deleteScenario(scenarioID: Int) async throws {
try await context.perform { [weak self] in
guard let self else { return }
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

deleteScenariocontext.perform 클로저에서 selfnil일 경우 아무 동작 없이 return하고 있습니다. 다른 메서드들처럼 BeforeGoingError.unknownErrorthrow하여 일관성을 유지하고 에러 상황을 명확히 알리는 것이 좋을 것 같습니다.

Suggested change
guard let self else { return }
guard let self else { throw BeforeGoingError.unknownError }

Comment on lines +460 to +462
if let existingMissions = scenario.missions as? Set<Mission> {
existingMissions.forEach { self.context.delete($0) }
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

medium

updateMissions 함수에서 기존 미션을 모두 삭제하고 다시 생성하는 방식은 미션의 수가 많아질 경우 비효율적일 수 있습니다. 변경된 미션만 찾아 업데이트하거나, 추가/삭제된 미션을 개별적으로 처리하는 방식으로 리팩토링을 고려해볼 수 있습니다.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request setting

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant