앱 소개
- 앱 스토어 다운로드 링크
냉싸부 - 냉장고를 싸그리 부탁해
구매한 식품, 식품에 대한 자세한 설명, 유통기한, 수량등을 자유롭게 등록하면 캘린더에서 쉽게 관리할 수 있도록 도와드립니다. [주요 기능] 1. 캘린더 - 캘린더를 통해 유통기한 임박 상품
apps.apple.com
- 기획 및 디자인, 공수 산정, DB 스키마 구조 노션 링크
https://thankful-gymnast-355.notion.site/README-1c2d64adac9c4c219347d7b6ca2287a2?pvs=4
출시 기능 명세 & 기록(README)
UI/UX 초안
thankful-gymnast-355.notion.site
앱 기능 소개
- 홈
- 식품 등록
- UICollectionViewDiffableDataSource를 활용하여 등록할 식품 목록을 구성하였고 데이터 변화가 일어났을 때 스냅샷 기반으로 애니메이션을 활용하기 위해 사용하였습니다.
- 저장한 식품 종류, 이름, 구매일자, 유통기한(d-day) 등록
- UISheetPresentController를 활용해 등록할 식품의 아이콘을 보여줄 수 있는 뷰를 절반 띄워 빠르게 전체 뷰로 보거나 내릴 수 있도록 하였습니다.
- 식품 검색 또한 스냅샷 기반 데이터변화를 통해 애니메이션을 활용하였습니다.
- 사용자 편의를 위해 식품 데이터를 기준으로 한글 ‘초성’ 검색이 가능하게 하였습니다.
- 식품 등록
- 캘린더
- FSCalendar를 사용하여 달력을 표시해 해당 날에 유통기한 임박한 음식의 존재유무를 ‘식품’ 아이콘을 통해 이벤트 표시
- 해당 날짜에 유통기한이 도래한 상품이 있다면 캘린더 아래 컬렉션뷰로 list를 띄워주었고 개별 식품을 검색하면 ‘유튜브’ 사이트를 웹뷰로 띄우게 구현하였습니다.
- 검색어는 미리 선택한 식품의 이름을 디폴트로 검색창에 검색되어 있도록 구현했습니다.
- 차트
- DGCharts 라이브러리를 활용했고 해당 달의 식품에 대한 다음과 같은 정보를 토대로 pie차트, 와 테이블뷰를 활용해 나타내었습니다.
- 현재 보관된 식품의 수
- 유통기한 내에 먹은 음식 수
- 유통기한을 지키지 못한 음식 수
- 현재 보관된 식품의 카테고리(야채,과일,고기…등)
- DGCharts 라이브러리를 활용했고 해당 달의 식품에 대한 다음과 같은 정보를 토대로 pie차트, 와 테이블뷰를 활용해 나타내었습니다.
- 소비
- UICollectionViewDiffableDataSource 활용해 컬렉션뷰를 구성하였고 유통기한이 임박한 식품을 빠르게 확인 후 개수를 소비할 수 있도록 구현하였습니다.
- 개수가 많다면 빠르게 소비를, 개수가 없다면 구매를 유도
- UICollectionViewDiffableDataSource 활용해 컬렉션뷰를 구성하였고 유통기한이 임박한 식품을 빠르게 확인 후 개수를 소비할 수 있도록 구현하였습니다.
- 알림
- 소비자가 원하는 시간대에 유통기한이 임박한 식품이 있다면 푸쉬 알림을 보낼 수 있도록 하였습니다.
회고
앱 출시하며 느낀점
iOS 개발자를 도전하며 처음 앱을 출시했다. 지금까지 미니 프로젝트만 해봤고 앱 출시는 처음인데 나름 잘 마무리한 것 같다. 기획과 디자인을 모두 혼자하며 느낀 점도 많고 다사다난 했던 것 같다. 우선 기획과 디자인이 100% 완벽하게 해서 작업을 들어갈 수는 없지만 최대한 꼼꼼하게 체계화 두고 들어가야 그나마 불필요한 시간을 많이 줄일 수 있다는 것을 느꼈다. 또한 앱을 개발할 때 많은 레퍼런스를 찾고 뛰어난 UI/UX를 가진 앱을 찾아보며 많은 영감을 받는 것도 중요하다고 느꼈다.
아쉬웠던 부분
실제 서비스를 개발할 때 제한된 시간내에 공수산정을 통해 해당 기능을 해내는 것 또한 중요한 역량이라 생각했다. 그래서 최대한 3~4주라는 시간에 맞춰 개발을 완료하고 출시까지 하려고 노력했었다. 하지만 이 과정에서 너무 공수에 맞추고 일정에 맞게 완료해야한다는 강박이 생겨서 챌린지 성격이 있는 기능 큰 기능들을 많이 넣지 못했던 것이 아쉬운 부분이었다. 물론 출시를 할 수 있을 만큼은 기능들은 완성이 되었지만 그 과정에서 생각했던 것들을 많이 도전하지 못했었던 것 같다. 물론 추후 기능을 업데이트할 수 있지만 아쉬움이 남았다.
잘했다고 생각한 부분
그래도 제한된 시간안에 공수산정을 최대한 맞춰 기능을 완성하고 출시까지 했다는 것에는 나름 잘했다고 생각했던 부분이다. 하루하루의 Todo를 리스트업하고 세분화해서 티켓을 나눴던 것이 기간안에 완수할 수 있었던 원동력이 되었던 것 같다. 하지만 이 부분에서 공수 산정이 너무 많이 들어가는 큰 기능을 많이 배제하고 개발을 했던 것이 아쉬운 부분이었던 같다. 이 부분은 시간이 가면서 앱 개발이 능숙해지고 실력이 오른다면 자연스레 해결될 부분이라고 생각한다.
또한 2개월 동안 학습했던 부분을 최대한 앱에 녹이려고 노력했다. 2개월이란 시간동안 엄청난 양의 개념을 학습하고 연습했지만 개발할 때 쓰지 못하고 녹이지 못한다면 그것 또한 내 것이 아니라고 생각했고 최대한 Diffabledatasource를 활용해 컬렉션뷰 구성하기, MVVM 디자인 패턴 적용해보기 등 많은 것을 구현해내기 위해 노력했다.
Trouble Shooting
1. 앱 내의 '알림 설정'과 시스템 설정의 '알림 설정' 동기화 문제
시스템 알림과 앱 내 알림을 동기화하고 매끄러운 상태가 되기 위해선 다음과 같은 플로우에 따라 로직을 구현한다.
알림 허용 상태 Flow
- 앱 최초 실행시 알림 허용 O
- 시스템 알림 허용 O 상태
- 앱 내 알림 허용 O -> 푸쉬 알림 O
- 앱 내 알림 허용 X -> 푸쉬 알림 X
- 앱 최초 실행시 알림 허용 X
- 시스템 알림 허용 X 상태
- 앱 내 알림 허용 O -> 시스템 알림 허용 상태와 동기화 -> 앱 내 알림 상태 X
- 앱 내 알림 허용 X -> 푸쉬 알림 X
알림 허용 상태에 따른 로직을 확정해놓고 코드로 구현하기 시작했다.
우선 최초 앱을 시작했을 때 유저가 알림 허용 얼럿에 대한 상태를 선택했을 것이고 그 값을 통해 앱 내의 설정 -> 알림뷰에 들어왔을 때 상태에 따른 switch 값의 동기화가 필요했다.
문제 상황
이것은 설정뷰에 들어왔을 때 UNUserNotificationCenter 클래스의 authorizationStatus 속성을 통해 현재 사용자 기기의 시스템 알림 설정 상태를 받아올 수 있었고 처음엔 동기화가 가능했다. 하지만 문제는 이후 사용자가 최초 알람을 허용을 한 뒤 시스템 설정에 가서 알림 상태를 Off로 했을 때 문제가 발생한다.
이 때 푸쉬 알림은 더 이상 보내면 안되므로 인 앱 내에서도 Off 상태로 동기화를 해줘야한다. 하지만 앱이 이미 실행중인 상태이고 종료하고 다시 설정 탭을 들어가지 않는 이상 새로 권한 체크를 하지 않기 때문에 다른 동기화 방식을 사용해 한다고 생각했다.
문제 해결
1. ViewController의 생명주기를 활용해서 해결하기
- 즉 viewWillAppear 메서드를 활용해서 다시 앱에 진입했을 때 알림 상태 권한 체크를 통해 동기화하려 했지만 실패했다.
이 방법은 쉽게 생각했지만 잘 생각해보면 뷰는 움직이지 않아 viewWillAppear 는 실행되지 않는다는 것을 알았다. 뷰가 이전화면으로 가거나 다음 화면으로 갔다가 돌아온다면 이 방법을 고려해볼 수 있겠지만 사실상 설정 탭에서 바로 동기화 되는 모습까지 구현하기 싶었기 때문에 이 방법은 포기했다.
2. sceneWillEnterForeground 메서드와, NotificationCenter 활용하여 해결
- 그렇다면 뷰가 변경되지 않고 앱이 background 상태였다가 foreground 상태가 될 때마다 실행되는 메서드를 활용해야겠다는 결론이 나왔다.
사용자가 다시 앱을 실행할 때마다 즉 sceneWillEnterForeground 메서드가 실행될 때마다 권한 체크하여 SettingViewController에 알려줄 수 있으면 해결할 수 있을 것이라고 생각했고 바로 구현해보았다.
- SceneDelegate.swift
class SceneDelegate: UIResponder, UIWindowSceneDelegate {
func sceneWillEnterForeground(_ scene: UIScene) {
NotificationCenter.default.post(
name: NSNotification.Name("permission"),
object: nil
)
}
}
- AlarmViewController.swift
final class AlarmViewController: BaseViewController {
override func viewDidLoad() {
super.viewDidLoad()
startAddObserver()
}
private func startAddObserver() {
NotificationCenter.default.addObserver(
self,
selector: #selector(checkNotificationSetting),
name: UIApplication.willEnterForegroundNotification,
object: nil
)
}
}
다행히 NotificationCenter로 포그라운드 감지를 쉽게 할 수 있었고 selector를 통해 권한체크 메서드를 실행시킨뒤 그 값을 통해 알림 UISwitch을 현재 값을 동기화할 수 있게 되었다.
@objc private func checkNotificationSetting(notification: NSNotification) {
UserNotificationRepository.shared.checkPermission { [weak self] value in
self?.setSwitchValue(UserDefaultsHelper.standard.permission)
}
}
private func setSwitchValue(_ permission: Bool) {
DispatchQueue.main.async { [weak self] in
if permission {
self?.switchView.setOn(permission, animated: true)
self?.footerView.isHidden = !permission
} else {
self?.switchView.setOn(permission, animated: true)
self?.footerView.isHidden = !permission
}
}
}
2. Realm과 DiffableDatasource를 사용했을 때 Delete 시 크래시 문제
문제 상황
식품을 등록하는 메인 뷰는 현재 DiffableDatasource 기반으로 CollectionView로 구현되어 있고, Realm Database를 같이 사용하고 있다. 문제는 Realm에서 데이터를 삭제했을 때 발생했다. `DiffableDatasource` 는 뷰의 갱신을 위해 뷰의 현재 데이터 상태를 스냅샷 찍어 보관하고 있는데 다음 처럼 해당 데이터가 삭제되면서 문제가 발생한다.
DiffableDatasource - diff -> DiffableDatasource
Realm<Object>1 Realm<Object>1
Realm<Object>2 Realm<Object>2
Realm<Object>3 -> delete Realm<Object>4
Realm<Object>4
이렇게 데이터를 삭제하고 snapshot을 재구성하여 apply를 하게 되면 Realm에서 예외처리 오류가 발생하게 된다.
Terminating app due to uncaught exception 'RLMException', reason: 'Object has been deleted or invalidated.'
terminating with uncaught exception of type NSException
에러 내용을 살펴보면 RLMException 에러가 발생했고 Object 가 삭제되었거나, 유효하지 않다고 말한다.
그래서 Realm에서 삭제한 뒤 해당 객체를 참조하거나 출력만 하려해도 에러가 발생한다.
Realm Object로 생성한 객체는 삭제한 뒤에는 참조할 수 없도록, Realm 자체적으로 예외처리가 들어가 있는 것 같습니다. 결론적으로 DiffableDatasource 에서는 스냅샷의 diff를 확인하기 위해 삭제된 데이터 객체를 가지고 있어야하는데 Realm에서 에러를 발생시켜 Snapshot apply가 되지 않는 상황입니다.
문제 해결
이것을 해결하기 위해서 생각한 방법은 2가지 정도가 있었습니다.
1. Realm에서 바로 삭제처리하는 것이 아닌 삭제된 데이터를 표시할 다른 속성을 가지고 있고 DiffableDatasource 에서 diff를 처리한 이후에 뷰를 갱신하고 Realm 데이터를 삭제하는 방법
DiffableDatasource - diff -> DiffableDatasource
Realm<Object>1 Realm<Object>1
Realm<Object>2 Realm<Object>2
Realm<Object>3 -> delete Realm<Object>4
Realm<Object>4
2. 데이터를 삭제하는 ViewModel에서 Closure로 데이터 삭제 여부를 전달하기
데이터를 삭제할 View
- 데이터 삭제 Scene - ViewController
final class FoodDetailManagementViewController: BaseViewController {
let viewModel = FoodDetailManagementViewModel()
@objc func deleteButtonTapped() {
showAlertAction2(
preferredStyle: .alert,
title: Constant.AlertText.deleteAlertTitleMessage
) {} _: {
self.viewModel.deleteData()
self.navigationController?.popViewController(animated: true)
}
}
}
- 데이터 삭제 Scene - ViewModel
final class FoodDetailManagementViewModel {
var isDelete = Observable(false)
var completionHandler: ((Bool) -> Void)?
func deleteData() {
isDelete.value = true
completionHandler?(isDelete.value)
}
}
데이터를 삭제하기 전 데이터를 넘겨줄 View
- ViewController
final class FoodManagementViewController: BaseViewController {
override func viewWillAppear(_ animated: Bool) {
super.viewWillAppear(animated)
performQuery(searchText: "", storageType: currentStorageType)
}
private func performQuery(
searchText: String,
sortType: SortType = .expiration,
storageType: Constant.FoodStorageType
) {
let item = viewModel.filterFoodData(
query: searchText,
sortType: sortType,
storageType: storageType
)
guard let item else { return }
updateEmptyView()
var snapshot = NSDiffableDataSourceSnapshot<Section, Food>()
snapshot.appendSections([.main])
snapshot.appendItems(item)
// relam 데이터 삭제시 snapshot 처리
if let deleteFood = viewModel.deleteFoodData, !deleteFood.isInvalidated {
snapshot.deleteItems([deleteFood])
dataSource.apply(snapshot, animatingDifferences: true)
RealmTableRepository.shared.delete(object: deleteFood)
viewModel.filteredFoodDataArray = viewModel.filteredFoodData?.toArray()
updateEmptyView()
}
dataSource.apply(snapshot, animatingDifferences: true)
}
}
extension FoodManagementViewController: UICollectionViewDelegate {
func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) {
let nextVC = FoodDetailManagementViewController()
guard let filteredFoodDataArray = self.viewModel.filteredFoodDataArray else { return }
let food = filteredFoodDataArray[indexPath.item]
nextVC.viewModel.food = food
nextVC.viewModel.completionHandler = { [weak self] isDelete in
guard let weakSelf = self else {return }
if isDelete {
weakSelf.viewModel.deleteFoodData = food
weakSelf.view.makeToast(Constant.ToastMessage.foodDeleteSuccessMessage)
}
}
transition(viewController: nextVC, style: .push)
}
}
- ViewModel
final class FoodManagementViewModel {
var deleteFoodData: Food?
}
클로저를 활용해 우선 데이터를 선택해서 다음 화면으로 넘어가기 전 다음 화면 ViewModel의 클로저에 isDelete 라는 삭제 여부 속성을 CompletionHandler로 전달받을 수 있도록 넘겨줬고 만약 삭제가 되었다면 isDelete 속성의 값은 true 가 되었을 것이고 삭제된 데이터를 가지는 속성에 해당 데이터를 할당한 뒤
snapshot 이 실행될 때 해당 데이터가 존재하는지, 또한 유효한지에 대한 유효성 검사를 실행한 뒤 snapshot 의 deleteItems 메서드를 통해 해당 데이터를 삭제하고 diff 검사한 뒤 apply가 된다면 에러 없이 View가 갱신된다.
3. DiffableDatasource의 검색 애니메이션을 구현할 때 한글 검색으로 할 경우 문제
문제 상황
우선 애플에서 제공하느 DiffableDatasource 예제에서 실시간 검색시 셀들이 갱신될 때 애니메이션을 활용하고 싶어서 구현하던 중 발견 했던 문제였다.
애플 예제에서는 영어로 검색을 하게되는 예를 들어 알파벳 하나를 입력하더라도 조건에 맞는 검색결과가 바로 snapshot에 적용되어 물 흐르듯 자연스러운 애니메이션이 실행되었는데 한글로 검색할 경우 처음 자음을 검색하는 경우 예를 들어 ‘고구마’를 검색할 때 유저는 ’ㄱ’을 먼저 입력하게 될 것이고 그렇게 되면 `snapshot`을 적용할 때 그 순간 ‘ㄱ’에 해당하는 데이터는 존재하지 않고 빈 셀을 apply할 것이고 ‘고’ 가 입력되는 순간 조건에 해당되는 데이터가 있어 새롭게 셀이 갱신되는 것 처럼 보여 결과적으로 애플 예제의 애니메이션을 사용할 수 없게 되었다.
문제 해결
1. 한글 데이터 자모 분리
해결 방법은 식품의 이름을 한글 기준으로 자음, 모음을 분리해서 검색결과에 반영되도록 할 수 밖에 없다고 생각했다. 결과적으로 유저 입장에서는 자음 검색이 가능해져 사용성이 올라가고 원하는 갱신 애니메이션 효과도 사용할 수 있어서 꼭 해내야했었다.
- 한글의 자음, 모음을 분리하는 메서드
import Foundation
let korean = ["ㄱ", "ㄲ", "ㄴ", "ㄷ", "ㄸ", "ㄹ", "ㅁ", "ㅂ", "ㅃ", "ㅅ", "ㅆ", "ㅇ", "ㅈ", "ㅉ", "ㅊ", "ㅋ", "ㅌ", "ㅍ", "ㅎ"]
func getInitialConsonants(word: String) -> String {
var result = ""
for char in word {
let scalar = char.unicodeScalars.first!
if scalar >= "\u{AC00}" && scalar <= "\u{D7A3}" {
let index = Int((scalar.value - 0xAC00) / 28 / 21)
result.append(korean[index])
} else {
result.append(char)
}
}
return result
}
func isChosung(word: String) -> Bool {
var isChosung = false
for char in word {
if 0 < korean.filter({ $0.contains(char)}).count {
isChosung = true
} else {
isChosung = false
break
}
}
return isChosung
}
검색된 단어의 첫 글자의 자모를 분리해 unicodeScalars 의 비교를 통해 해당 단어의 자음만 찾게된다.
- 검색 query
import Foundation
final class FoodRegisterListViewModel {
var foodIconInfo = Observable(Constant.FoodConstant.foodIconInfo)
var isSave = Observable(false)
var completionHandler: ((Bool) -> Void)?
func filterInitialConsonant(with searchText: String) -> [FoodModel] {
let foodIconData = Constant.FoodConstant.foodIconInfo
if searchText.isEmpty {
return foodIconData
}
let text = searchText.trimmingCharacters(in: .whitespaces)
let isChosungCheck = isChosung(word: text)
let filteredData = foodIconData.filter({
if isChosungCheck {
return ($0.name.contains(text) || getInitialConsonants(word: $0.name).contains(text))
} else {
return $0.name.contains(text)
}
})
return filteredData
}
}
우선 isChosung 메서드를 통해 해당 검색어 query가 초성인지 검색해서 초성이라면 해당 데이터의 한글 이름 예를 들어 ‘고구마’ 단어를 자모 분리 메서드를 통해 ‘ㄱㄱㅁ’ 로 분리한 뒤 검색한 단어가 포함되는지 검사해서 `filteredData` 에 다시 할당하는 방식으로 해결하였다.
이렇게 되면 사용자가 단어를 검색할 때 ‘ㄱ’만 입력하더라도 snapshot 애니메이션의 공백이 발생하지 않고 잘 적용되는 것을 볼 수 있었다.
추후 업데이트 예정 기능
- MapKit을 이용한 근처 식료품점 확인
- 레시피 크롤링, API를 활용한 저장 기능
- 차트 다변화 및 상세화