오랑 - 오늘도 사랑해! 출시 회고

앱 소개

반려동물의 생활을 기록할 수 있는 서비스입니다.

 

 

 

 

 

주요 기능

  • 다양한 반려동물을 기록하고 관련 기록을 한번에 모아볼 수 있습니다.
  • 생활 기록이나 진료 기록을 저장하여 볼 수 있습니다.
    • 일상 기록: 일기처럼 제목, 내용, 사진으로 기록 가능
    • 생활 기록: 몸무게, 간식, 대소변, 이상 증상 기록 가능
    • 진료 기록: 동물별 예방 접종 기록, 진료 내역 기록 가능
      • 기초 예방 접종이 존재하는 동물의 경우에는 시기별 접종할 수 있는 예방 주사 내역을 책을 참고하여 작성하였습니다!

 

 

 

 

추후 업데이트 사항

  • 생활 / 병원 알람
  • 사료량 계산하여 기록
  • 산책 / 놀이 추가
  • 사료 / 간식 호불호 기록
    • 이어서 간식 기록에서 선택해서 사용할 수 있도록!
  • 간식 / 몸무게 기록을 chart로 보여주기

 

 

 

 

 

 

앱 스토어 다운 링크

https://apps.apple.com/kr/app/%EC%98%A4%EB%9E%91-%EC%98%A4%EB%8A%98%EB%8F%84-%EC%82%AC%EB%9E%91%ED%95%B4/id6470393264

 

‎오랑 - 오늘도 사랑해!

‎다양한 반려동물의 생활과 진료 내역을 기록해요! ~ 오랑의 주요 기능 ~ 소중한 반려동물과의 매일매일을 쉽게 기록해요. 개와 고양이 말고도 햄스터, 토끼, 고슴도치, 기니피그, 파충류를 지

apps.apple.com

 

 

 

개발 환경

참여 인원: 개인

개발 환경: Xcode: 15 / iOS: 15 이상 / swift: 5.8.1

개발 기간: 2023년 9월 25일 ~ 2023년 10월 25일 (약 3주)

 

진행 사항 진행 기간 세부 내역
기획 및 디자인, 프로젝트 초기 세팅 9월 25일 ~ 10월 1일 이터레이션 세부 계획 수립, 프로젝트 내 데이터 구조화, 기초 UI 구성
프로필 탭 구현, Realm 도입 10월 2일 ~ 10월 8일 Realm 데이터 설계, ProfileViewController 및 이어지는 뷰들 UI와 기능 설계
기록 탭 UI 구현 및 세부적인 기능 구현 10월 9일 ~ 10월 17일 RecordViewController와 세부적인 뷰 구현, Realm 데이터 모듈화, 화면 전환 로직 수정
모아보기 탭 구현, launchScreen Animation 설정, 버그 수정 10월 18일 ~ 10월 23일 TotalViewController 및 기록을 보여주는 세부 화면 구현, Timer를 활용한 animation 구현, 버그 수정
앱 출시 준비, 심사 10월 24일 ~ 10월 25일 목업 이미지 준비, 앱 설명 작성, 개인정보 처리방침 준비
Reject 처리 10월 26일 ~ 10월 27일 설명 Notion 작성 및 버그 수정

 

 

 

사용한 기술 스택

  • UIKit, autoLayout
  • SnapKit
  • Realm
  • MVC, MVVM
  • FSCalendar
  • Toast
  • PhotosUI
  • IQKeyboardManager
  • M13Checkbox

 

 

 

기획

본래는 제가 키우고 있는 햄스터 반려인 전용 앱을 생각했으나, 너무 타겟층이 적다는 멘토님의 조언에 반려동물 앱으로 키우게 되었습니다. 그러면서, 당시 기획하고 있던 것에서는 다른 반려동물을 가진 사용자들이 앱을 만족스럽게 사용할 수 없겠다고 판단하여 반려인을 대상으로 한 설문을 올리게 되었습니다.

 

감사하게도 생각보다 많은 관심을 받게 되면서 60개 이상의 설문이 들어왔습니다. 설문 결과를 바탕으로 하여 기획을 점검하게 되었습니다. 본래 기획은 햄스터에게 초점을 맞춰져 있었기 때문에, 아무래도 전체적인 반려동물을 커버하려면 추가되어야 할 요소가 많았습니다. 각각의 반려동물마다 필요한 것이 달라 추가되어야 할 것과 아니어야 할 것이 나뉘어야 했습니다.

 

 

 

설문 결과를 기준으로 짠 앱의 전체 구조와 세부 구조는 다음과 같습니다.

 

 

위를 바탕으로 만든 피그마는 다음과 같습니다.

 

다만, 초반의 기획은 도저히 한 달 정도의 개발 기간으로는 만들 수 없다고 판단하여 만드는 데까지 해보자! 라는 걸 목적으로 개발에 들어갔습니다. 적어도 프로필 화면과 기록 화면, 모아보기 화면은 만드는 걸 목표로 했고, 일상 기록 중 사료량과 구토 기록을 덜어내어 우선 출시를 하게 되었습니다.

앞으로도 차차 업데이트해갈 예정입니다!

 

기획 부분에서 부족한 부분을 회고해 보자면, 자세하게 짰다고 생각했음에도 부족한 부분이 정말 많았습니다. 전체적인 틀만 잡아 놓고 자세한 설정 화면 같은 건 구현해나가면서 알아서 하자! 라는 생각으로 들어갔는데, 그렇게 하니 자세한 설정 화면에서 디자인 때문에 헤매는 시간이 많아졌습니다. 이후 앱 출시를 준비할 때는 전체적인 테마와 느낌 같은 디자인적인 부분도 미리 체계화해 두고 들어가야겠다는 생각을 했습니다.

 

 

 

 

 

 

Trouble Shooting

 

 

1. 관계형 데이터베이스를 기반으로 테이블을 정규화함

 

왼쪽의 복잡했던 테이블을 정규화하여 다루기 쉽도록 했다.

초반의 스키마는 너무 자잘하게 쪼개져서 오히려 사용하기 어렵고 한 정보에 저장하거나 불러오려면 필요한 정보가 너무 많고 그 과정이 복잡했는데, 테이블을 정규화하면서 간편하게 저장하고 불러올 수 있게 되었다. 

 

 

 

 

 

1. 다른 enum을 protocol과 struct를 이용하여 추상화

 

 

소변 기록과 대변 기록은 같은 tableViewCell을, 이상 증상과 대소변 기록은 둘은 사진을 제외한다면 비슷한 TableViewCell을 활용하고 있습니다.

 

대변의 색깔과 형태, 소변의 색깔, 이상 증상 모두 각각 다른 enum으로 구성되어 있었기 때문에, 이 요소들을 하나로 묶어 같은 셀에 표현해 주기 위해서는 다른 방식으로 변환해 주는 게 필요하다고 생각했습니다. 각각의 구조체 안에는 title과 subtitle이 있었는데, 그 부분을 필수로 가지고 있는 타입을 받는다는 것을 프로토콜을 이용하여 먼저 정의하였습니다. 이후, 해당 프로토콜을 채택한 제네릭 타입의 구조체를 생성하였고, 해당 셀에서 check 유무에 따라 데이터를 저장할지 말지를 결정하기 때문에 check 유무를 확인할 수 있는 변수를 추가해 주었습니다.

 

 

protocol CheckProtocol {
    var title: String { get }
    var subtitle: String { get }
}
struct CheckRecord<T: CheckProtocol>: Equatable {
    var type: T
    var title: String
    var subtitle: String
    var ischecked: Bool
    
    init(type: T) {
        self.type = type
        self.title = type.title
        self.subtitle = type.subtitle
        self.ischecked = false
    }
    
    static func == (lhs: CheckRecord<T>, rhs: CheckRecord<T>) -> Bool {
        if lhs.title == rhs.title && lhs.subtitle == rhs.subtitle &&
            lhs.ischecked && rhs.ischecked {
            return true
        } else {
            return false
        }
    }
}

 

 

 

뷰모델에서 화면 전환 시에 fetch되도록 아래와 같이 받아왔습니다.

 

class SymptomsViewModel {
    
    var list: Observable<[CheckRecord<AbnormalSymptomsType>]> = Observable([])
    
    func fetchSymptoms() {
        AbnormalSymptomsType.allCases.forEach{ self.list.value.append(CheckRecord(type: $0)) }
    }
    
    ... ...
}

 

 

 

2. protocol을 이용한 모듈화

protocol MoveToFirstScene {
    func moveToFirstScene()
}
extension MoveToFirstScene {
    func moveToFirstScene() {
        // 이전에 쌓였던 화면이 clear => 새로 진입
        let windowScene = UIApplication.shared.connectedScenes.first as? UIWindowScene
        let SceneDelegate = windowScene?.delegate as? SceneDelegate
        
        let tabBar = UITabBarController()
        
        let totalNav = UINavigationController(rootViewController: TotalViewController())
        totalNav.tabBarItem = UITabBarItem(title: "totalNavigationTitle".localized(), image: Design.image.totalVC, tag: 0)
        
        let recordNav = UINavigationController(rootViewController: RecordViewController())
        recordNav.tabBarItem = UITabBarItem(title: "recordNavigationTitle".localized(), image: Design.image.recordVC, tag: 1)
        
        let profileNav = UINavigationController(rootViewController: ProfileViewController())
        profileNav.tabBarItem = UITabBarItem(title: "ProfileNavigationTitle".localized(), image: Design.image.profileVC, tag: 2)
        
        tabBar.setViewControllers([totalNav, recordNav, profileNav], animated: true)
        
        UIView.transition(with: SceneDelegate?.window ?? UIWindow(), duration: 0.3, options: .transitionCrossDissolve, animations: {
            SceneDelegate?.window?.rootViewController = tabBar
        }) { (completed) in
            if completed {
                SceneDelegate?.window?.makeKeyAndVisible()
            }
        }
    }
}

 

 

 

 

 

 

3. 저장 버튼을 누르는 시점에 Cell에서 데이터 fetch하기

    func getVaccineTypeFromCell() {
        
        var vaccineTypes: [(Bool, String)] = []
        
        for i in 0..<vaccineCollectionView.numberOfItems(inSection: 0) {
            if let cell = vaccineCollectionView.cellForItem(at: IndexPath(item: i, section: 0)) as? VaccineCategoryCollectionViewCell {
                let cellData = cell.loadVaccineType()
                if let text = cellData.1 {
                    if !text.isEmpty {
                        vaccineTypes.append((cellData.0, text))
                    } else {
                        sendOneSidedAlert(title: "inputVaccineType".localized())
                    }
                } else {
                    sendOneSidedAlert(title: "inputVaccineType".localized())
                }
            }
        }
        
        self.vaccineTypes = vaccineTypes
    }
    func loadVaccineType() -> (Bool, String?) {
        if noVaccineButton.isSelected == true { // inputTextField
            return (true, inputVaccineTextField.text)
        } else { // vaccineTextField
            return (false, vaccineTypeTextField.text)
        }
    }

 

사실 트러블 슈팅에 넣기에는 부끄러운 코드라고 생각합니다 ㅠ,ㅠ

그럼에도 넣는 이유는 이 부분으로 거의 3일을 헤맸기 때문에 기록으로 남기고 싶었습니다.

위 부분의 로직은 다음과 같습니다.

  1. 셀을 클릭함
  2. 기본 예방 접종 항목을 보여주는 ViewController를 띄워줌
  3. 해당 VC에서 접종한 백신을 클릭 후 해당 정보를 셀에 있는 textField에 전달함

여기까지는 아주 간단했으나, 이후 저장이 문제였습니다.

처음에는 delegate / notificationCenter를 이용하여 해당 셀에게 정보를 전달하거나 관찰하는 식으로 문제를 해결하면 어떨까 했지만, 삭제 시에 문제가 생기는 건 물론이고 일단 제대로 동작하지 않았습니다.

사용자가 최대 3개까지 추가하고 자유롭게 삭제할 수 있는 만큼 저장할 때만 해당 셀에서 정보를 받아올 수 없을까? 생각하게 되었고, 결국 해당하는 CollectionView를 돌면서 셀에 있는 text를 저장하는 시점에 가져오는 것이 가장 정확할 것 같아 위와 같은 코드를 짜게 되었습니다.

 

 

 

 

 

 

3. 사용자 파일앱에서 Realm 접근이 가능했던 문제

[설정] - [일반] - [iPhone 저장 공간] - [앱 이름] 순으로 들어갈 때, Realm 파일이 공개되는 이슈가 있었습니다. 확인해 보니 파일 앱에서도 램에 접근할 수 있었고, 이 경우에 사용자가 Realm에 접근해서 데이터를 강제적으로 조작할 수 있는 이슈가 있었습니다.

해당 부분은 코드적으로 따로 작성한 기억이 없기에 헤매고 있을 때, J 님의 도움으로 Info.plist 수정을 통해 해결할 수 있었습니다.

 

 

사진은 제 사진인데 막상 저한테 원본이 없어서 캘리 님 블로그에서 가져왔습니다 ^____^ 쵝오.

암튼!

캘리님도 저와 같은 이슈를 겪으셨는데, 공통점은 FileManager로 이미지 저장 경로를 관리할 때 참고한 블로그가 잘못되어 있었던 것이었습니다. 위에서 설정해 준 Application supports iTunes file sharing, Supports opening documents in place는 파일 접근 범위를 넓혀 주는 설정이었기 때문에 사용자가 realm에 접근 권한을 가지게 된 것이었어요. ( ! )

 

fileManager 코드를 쓸 때 많은 블로그를 참고하면서 썼는데, 어떤 블로그에서는 해당 설정이 들어가 있고, 어떤 블로그에서는 들어가 있지 않은 경우가 있었어요.

개발자는 꾸준히 이 코드를, 이 설정을 '왜' 써야 할까? 를 생각해야 했는데, 이 부분이 부족했던 것 같습니다. 

 

Application supports iTunes file sharing 설정은 아이튠즈에서 앱을 띄우고 싶을 경우에 사용합니다.

 Supports opening documents in place 설정의 경우에는 사용자가 파일앱을 열어 해당 앱에서 저장한 폴더와 파일을 접근할 수 있도록 허용할 경우에 사용합니다.

 

 

 

 

... ... 나중에 crashLog를 봤던 것도 추가하고 싶다... ... :3