추석 기간동안 개발 16시간, 디자인 및 기획 8시간 하루에 8시간씩 24시간 정도 했습니다.
저는 이번 추석동안 혼자 해커톤을 진행해 보았습니다. 요즘 수영에 맛들렸는데, Apple이 제공하는 수영 관련 데이터들의 불편함을 느꼈습니다. 그래서 이번에 수영관련 데이터들을 관리하는 SwimLight이라는 해커톤을 혼자 진행해보았습니다.
1. 프로젝트 기획하기 _ Chat GPT
해커톤에서 가장 어려운 브랜딩 부분을 ChatGPT로 대체하였습니다. ChatGPT를 활용하면 복잡한 브랜딩 과정을 생략하고 빠르게 개발에 전념할 수 있다고 생각했습니다. 다음은 실제 제가 ChatGPT에게 물어본 질문들입니다.
Q: 나는 현재 어플리케이션을 브랜딩 할려고 해.
대한민국에서 수영에 관한 어플리케이션이 그렇게 성능이 뛰어나거나 UX적으로 친숙하지 않아 이번 기회에 새로 헬스케어 어플리케이션을 만들려고 해. 어떤 식으로 브랜딩 하면 좋을까?
A: 브랜딩을 통해 수영 관련 헬스케어 어플리케이션을 성공적으로 만들기 위해 몇 가지 전략을 고려할 수 있습니다:
1. 차별화된 메시지 설정
- 핵심 가치: 대한민국에서 성능과 UX가 뛰어난 수영 앱이 부족하다면, "최고의 수영 코치", "개인화된 수영 트레이닝", "스마트한 수영 데이터 분석" 같은 메시지로 차별화를 시도해보세요.
- 사용자 문제 해결: 기존 앱들의 불편한 점을 해결하는 것이 중요합니다. 사용자들이 겪는 불편함을 분석하고, 이를 극복하는 부분을 강조하세요.
2. 타겟 사용자 정의
- 경험자와 초보자 모두를 위한 UX: 초보자부터 전문가까지 사용 가능한 직관적인 인터페이스를 제공하고, 수영 초보자를 위한 학습 모드와 고급자를 위한 성과 측정 도구를 차별화 포인트로 잡으면 좋습니다.
- 국내 시장에 맞는 감성: 한국적인 디자인 요소나 한국 수영 문화에 맞는 기능을 제공하면 로컬 사용자에게 더 친근하게 다가갈 수 있습니다.
3. 디자인 & UX
- 미니멀리즘과 청결함: 수영과 관련된 청결하고 시원한 느낌의 색상을 중심으로 (파란색, 흰색, 회색) 미니멀한 UI를 구성하세요.
- 유동적인 애니메이션: 물의 흐름처럼 유동적인 애니메이션이나 스크롤링 효과를 활용해 수영과의 연관성을 시각적으로 강화하세요.
4. 브랜드 네임 & 로고
- 이름: 수영과 관련된 단어를 활용하거나 물, 바다를 연상시키는 단어를 선택하세요. 예를 들어 "SwimFlow", "AquaTrack" 같은 느낌을 줄 수 있는 이름이 좋습니다.
- 로고: 물방울, 물결, 수영 관련 실루엣 등을 활용한 단순하면서도 강렬한 로고가 효과적입니다.
5. 커뮤니티 중심의 마케팅
- 소셜 미디어: 수영을 좋아하는 사람들끼리 소통할 수 있는 온라인 커뮤니티나 SNS 기능을 추가하거나, 챌린지 등을 통해 사용자들끼리 경쟁하거나 협력할 수 있도록 유도하세요.
- 영웅 사례 만들기: 실제 사용자들이 앱을 통해 수영 실력을 향상시키거나 성과를 기록한 사례를 통해 긍정적인 인상을 심어줄 수 있습니다.
브랜딩에서 중요한 것은 기능과 감성적 요소를 모두 잘 살려야 한다는 것입니다. 기술적으로 뛰어나면서도 사용자에게 친숙하고 매력적인 경험을 제공할 수 있도록 브랜딩을 구축해보세요.
Q2: 현재까지 한 기능정의를 보여줄게. 혹시 어떤게 더 필요할 것 같아?
- 수영 기록
- 9월 한달간 수영 기록에 대한 CalendarView를 보여줄 수 있게 정의
- 일주일간 수영 기록에 대한 정보를 보여줄 수 있게 기능 정의
- 시간대별로 심박수 보여줄 수 있게 정의
- 자유형, 평형, 접영 등 다양한 수영을 얼마나 했는지에 대한 기록 정의
- Only Apple Watch - Youtube recap 처럼 통계를 보여줄 수 있게 생성
- 공유 기능
- 인스타그램 공유
- 카카오톡 공유
- 수영장 List 보여주기
- OpenAPI적용
- 수영장 Link로 들어갈 수 있는 Safari VC Present
A2: 현재 기능 정의를 보면 사용자에게 수영 기록과 공유 기능을 잘 제공할 수 있는 틀이 잡혀 있습니다. 몇 가지 추가로 고려해볼 기능을 제안해 드리겠습니다:
1. 개인화된 피드백 및 목표 설정
- 목표 설정: 사용자가 매주 또는 매달 수영 목표(거리, 시간, 칼로리 소모 등)를 설정하고 이를 달성하는 과정을 시각적으로 보여주는 기능.
- AI 기반 코칭: 수영 기록을 분석하여 개인 맞춤형 피드백을 제공하는 기능. 예를 들어 "평균 접영 속도를 높이기 위한 팁" 같은 내용을 제안할 수 있습니다.
2. 더 다양한 기기 연동
- 멀티 기기 지원: 현재 Apple Watch만 지원되지만, 삼성 Galaxy Watch나 다른 웨어러블 기기와의 호환성을 추가하면 더 많은 사용자에게 다가갈 수 있습니다.
- 수동 기록 입력 기능: 수영을 할 때 스마트워치를 착용하지 않은 경우 수동으로 기록할 수 있는 옵션 제공.
3. 심층 분석 및 통계
- 자세한 통계: 사용자가 자신만의 데이터(예: 평균 속도, 구간별 심박수 변화, 스트로크 빈도 등)를 더 깊이 분석할 수 있도록 추가적인 통계 자료를 제공하는 옵션.
- 지도 기반 기록: 수영장이 아닌 자연 수역(바다, 강)에서 수영을 할 경우 GPS 기반 기록과 경로를 지도에 표시하는 기능.
4. 커뮤니티 기능
- 챌린지 및 리더보드: 사용자가 일정 기간 동안 수영 관련 챌린지를 진행하고 다른 사용자들과 기록을 비교할 수 있는 리더보드 기능.
- 수영 그룹: 친구나 가족과 함께 그룹을 만들어 서로의 수영 기록을 공유하고 경쟁할 수 있는 기능.
5. 헬스케어 통합
- 통합 건강 분석: 수영 기록과 다른 운동 기록(예: 달리기, 자전거)과 통합하여 전체적인 건강 상태를 분석해주는 기능. Apple HealthKit 또는 Google Fit과 연동하여 종합적인 건강 데이터를 제공.
- 수면 및 영양 기록 연동: 수영 성과에 영향을 미치는 수면 및 영양 상태를 함께 기록하고 분석하여 전반적인 생활 패턴을 관리할 수 있는 기능.
6. UX 개선
- 다크모드 지원: 사용자 경험을 향상시키기 위한 다크모드 추가.
- 오프라인 모드: 수영장이나 실외에서 인터넷 연결이 안 되는 상황을 대비해 오프라인 상태에서도 데이터를 기록하고 나중에 동기화할 수 있는 기능.
이러한 기능들은 앱을 더 풍부하게 만들고, 사용자 맞춤형 경험을 제공할 수 있도록 도와줄 것입니다.
2. 프로젝트 디자인하기(갈릴레오AI & uxpilot)
프로젝트에 대한 대충 브랜딩이 잡혔고, 어떤 기능을 개발할지 정했습니다. 그리고 이를 통해서 사용자에게 어떤 화면을 가시성 좋게 보여줄 지에 관해서 AI에게 물어보기로 했습니다. AI에게 질문을 통해서 어떤 화면을 그릴지에 대해서 정확한 요구를 위해 ChatGPT에게 프롬프트 작성을 요청하였습니다.
2-1 디자이너 AI 좋다메...?
프롬프트를 활용해서 화면을 만들었을 때 정말 못 만들어준다는 것을 느꼈습니다. 아무리 무료버전에 와이어 프레임이라고는 하지만, 정확한 정보들을 넣었는데도 불구하고 너무 구식의 스타일 Component를 활용한 어플리케이션이 생성된 것을 확인했습니다.
2-2. 다 해줬잖아. 왜 그대로 행동하지 않는거지? (프롬프트 입력거부 사태 발생...?)
프롬프트에 정확하게 이 부분이 누락되었다고 말 해줬는데도 끝까지 자기 고집을 부리면서 Design을 해주지 않았습니다. 특히 달리 쓰면서 그런 일이 너무나 많이 일어났습니다. 만든 로고가 너무 AI틱스러워서 최대한 적은 선과 도형을 사용한 로고를 만들어달라고 요청했지만 이는 기각 되었습니다. 흑흑 그래서 홀로 로고를 만들었습니다.
SwimLight라는 서비스 이름에 맞는 Logo를 만들고 싶었는데 그 로고 이미지에 대한 생각은 "오른쪽에서 빛이 들어오고, 자유형 하는 사람은 빛을 향해 숨쉰다"라는 이미지를 생각했지만 도저히 그릴 수 없었습니다. 그래서 결국 수영장 구분선 플라스틱에 대해 로고를 작성했습니다. 하하..
2-3. 피그마로 유저에게 보여줄 화면 그리기
그래도 몇번의 프롬프트 수정과 추가를 통해서 그나마 마음에 드는 템플릿을 발견하였고 이를 통해서 Figma로 다시 그렸습니다.
3. 개발하기
아키텍쳐
일단 Architecture는 제일 자신있는 SwiftUI+TCA를 썼습니다. 그리고 Tusit로 모듈화를 하였습니다.(너무 대충했지만...) 사실 2일짜리 프로젝트에 아키텍쳐를 왜 쓰냐고 물을 수도 있지만, 그건 나중에 설명하겠습니다.
1. SwiftUI에서 달력 보여주고 싶어요! (Make UICalendarView to SwiftUI View)
UICalendarViewController는 이 세상에 존재하지 않습니다. Delegate Protocol이 있는데도 불구하고 UICalendarView를 UIViewController에 내장해서 써야하는구조를 갖고 있습니다. 따라서 `UIViewControllerRepresentable`을 쓸려면 따로 ViewController를 만들어야 했습니다.
// MARK: - SLCalendarViewController
final class SLCalendarViewController: UIViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.addSubview(calendarView)
calendarView.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true
calendarView.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true
calendarView.topAnchor.constraint(equalTo: view.topAnchor).isActive = true
calendarView.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true
}
let gregorianCalendar = Calendar(identifier: .gregorian)
lazy var calendarView: UICalendarView = {
let view = UICalendarView()
view.tintColor = UIColor(SLColor.main01.color)
view.calendar = gregorianCalendar
view.fontDesign = .rounded
if let fromDate = fromDateComponents().date,
let toDate = makeTodayDateComponent().date {
let calendarViewDateRange = DateInterval(start: fromDate, end: toDate)
view.availableDateRange = calendarViewDateRange
}
view.translatesAutoresizingMaskIntoConstraints = false
return view
}()
private func fromDateComponents() -> DateComponents {
DateComponents(calendar: gregorianCalendar, year: 2024, month: 1, day: 1)
}
private func makeTodayDateComponent() -> DateComponents {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd"
let dateString = dateFormatter.string(from: .now)
let components = dateString.split(separator: "-").map { Int($0) ?? 0 }
return DateComponents(
calendar: gregorianCalendar,
year: components[safe: 0],
month: components[safe: 1],
day: components[safe: 2]
)
}
}
또한 이 ClandarView에 관한 Delegate를 연결했어야 합니다. delegate에는 두종류가 있었는데, Specific Date에 Decoration을 보여주는 calendarDelegate과 선택에 관한 selectionBehavior 입니다. 두 개 모두 UIViewControllerRepresentable에 Init쪽에서 받아주었습니다.
struct SLCalendarView: UIViewControllerRepresentable {
weak var calendarDelegate: UICalendarViewDelegate?
weak var singleSelectDelegate: UICalendarSelectionSingleDateDelegate?
init(
calendarDelegate: UICalendarViewDelegate? = nil,
singleSelectDelegate: UICalendarSelectionSingleDateDelegate? = nil
) {
self.calendarDelegate = calendarDelegate
self.singleSelectDelegate = singleSelectDelegate
}
func makeUIViewController(context _: Context) -> SLCalendarViewController {
let vc = SLCalendarViewController()
vc.calendarView.delegate = calendarDelegate
let dateSelection = UICalendarSelectionSingleDate(delegate: singleSelectDelegate)
vc.calendarView.selectionBehavior = dateSelection
dateSelection.selectedDate = Calendar(identifier: .gregorian).dateComponents([.calendar, .year, .month, .day], from: .now)
return vc
}
func updateUIViewController(_: SLCalendarViewController, context _: Context) {}
typealias UIViewControllerType = SLCalendarViewController
}
그리고 HealthKit으로 언제 운동했는지에 대한 정보를 받는 순간 뷰를 다시 그려야 했습니다. 하지만, View가 바로 바뀌지 않는 문제가 발생했습니다. 그래서 날짜가 업데이트 되었을 때 뷰를 다시그리기 위해 SwiftUI뷰 구조를 다시 강제로 래핑했습니다. Data가 새로 들어올 때 Structured View ID reloading을 통해 뷰를 다시 그렸습니다.
//SwiftUI View
SLCalendarView(
calendarDelegate: store.calendarDelegate,
singleSelectDelegate: store.calendarDelegate
)
.frame(maxWidth: .infinity, idealHeight: 450)
.preferredColorScheme(.light)
.padding(.vertical, 6)
.background(Color.main03.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 12))
.id(store.calendarViewID)
// Reudcer Action Case
case let .updateSwimWorkoutDates(dates):
state.swimDataStorage = dates
state.calendarDelegate.updateSwimWorkoutDates(dates)
state.calendarViewID = .init() // UUID()
return .none
2. 건강 정보 Deny하면 설정으로 가는거 아님.
보통 헬스 정보 관련 Access Deny하면 다 Settings에서 하는 줄 알았는데 아닌것을 알았습니다. 만약 Health정보 Access거부시 Health앱을 따로 열어야 합니다.
이를 위해서 만약 헬스 앱 거부했던것을 인지하면 설정창으로 보내려던 기획은 엎어졌습니다. 왜냐하면 healthkit데이터 접근은 share(앱을 통해서 Fitness data 쓰기)와 read(App을 통해 Fitness 데이터 읽기)로 나눠집니다. 하지만, share에 관한 method만 존재하고 authorizationStatus(for:) 읽기권한 관련된 매서드는 존재하지 않는 것을 깨달았습니다. 오직isHealthDataAvailable으로만 첫번째 요청했는지에 대해서만 알 수 있습니다.
3 HealthKit 활용하기
HealthKit에서 모든 부분이 순조로웠지만, 제일 고생했던 두 부분 소개하려고 합니다.
(1) Stroke MetaData 가져오기
HealthKit에서 모든 부분이 순조로웠지만, 짜증났던 두 부분을 소개하려고 합니다. Apple HealthKit에서는 StrokeStyle에 관한 것들이 정의되어 있습니다. 그런데 이에 관해서 정확한 Example들이 없었습니다. 어디서 어떻게 가져와야 할지를 몰랐고, 깃허브나 이렇다할 저장소에 코드가 소개되어 있지 않았습니다.
공식 문서에 짤막하게 metaData에 있다고 했었는데, 직접 하나하나 API를 실행시키면서 어디 MetaData에 있는지 확인했습니다.
- workoutType: 수영할 때 라인 길이, 습도, 등에 관한 MetaData 존재
- DistanceMetaData: 존재 하지 않았음(nil만 리턴...)
- strokeCount: 영법에 관한 정보 존재
이 과정에서 정말 많은 시간을 허비했습니다. 생성형 AI에게도 물어봤지만, 역시 깊은 정보들을 몰라서 발로 뛰면서 수영 StrokeStyle에 대한 정보들을 찾았습니다.
결국 Distance의 여러 값을 async - await 으로 한번더 호출하는 로직을 작성했습니다.
private static func _getStrokeStyleDistance(_ targetDate: Date) async throws -> [SLStrokeStyle: Int] {
// GetDistanceAtSpecificDate Closure
var getDistanceClosure: (_ startDate: Date, _ endDate: Date) async throws -> Int?
getDistanceClosure = { startDate, endDate in
let datePredicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate)
let swimWorkoutPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
datePredicate,
])
let distances = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[HKSample], Error>) in
store.execute(
HKSampleQuery(
sampleType: HKQuantityType(.distanceSwimming),
predicate: swimWorkoutPredicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [.init(keyPath: \HKSample.startDate, ascending: false)],
resultsHandler: { _, samples, error in
if let hasError = error {
continuation.resume(throwing: hasError)
return
}
guard let samples else {
continuation.resume(throwing: NSError())
return
}
continuation.resume(returning: samples)
}
)
)
}
guard let distance = distances.first as? HKQuantitySample else {
return nil
}
let targetDistance = Int(distance.quantity.doubleValue(for: .meter()))
return targetDistance
}
// Swim storke style logic
let (startDate, endDate) = startAndEndOfDay(for: targetDate)
let datePredicate = HKQuery.predicateForSamples(withStart: startDate, end: endDate)
let swimWorkoutPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: [
datePredicate,
])
let workoutSamples = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[HKSample], Error>) in
store.execute(
HKSampleQuery(
sampleType: HKQuantityType(.swimmingStrokeCount),
predicate: swimWorkoutPredicate,
limit: HKObjectQueryNoLimit,
sortDescriptors: [.init(keyPath: \HKSample.startDate, ascending: false)],
resultsHandler: { _, samples, error in
if let hasError = error {
continuation.resume(throwing: hasError)
return
}
guard let samples else {
continuation.resume(throwing: NSError())
return
}
continuation.resume(returning: samples)
}
)
)
}
// return logic
var distanceByStrokeStyle: [SLStrokeStyle: Int] = [:]
await workoutSamples.asyncForEach { sample in
if let strokeStyleInt = sample.metadata?["HKSwimmingStrokeStyle"] as? Int,
let strokeStyle = SLStrokeStyle(rawValue: strokeStyleInt),
let targetDistance = try? await getDistanceClosure(sample.startDate, sample.endDate) {
distanceByStrokeStyle[strokeStyle, default: 0] += targetDistance
}
}
return distanceByStrokeStyle
}
(2) HeartRate관련 로직
HeartRate 관련 로직은 2개 정도로 작성했습니다.
- AVG HeartRate 직접 구하기
- HeartRate Zone 데이터 생성하기
AVG HeartRate를 구하기 위해 HKQuntitySampleData를 구하고, 구한 값들의 Index를 통해 startDate, endDate를 조회하면서 Average 값을 구했습니다. (알고리즘 풀던 실력이 빛을 바란듯...)
let heartRateWeightSum = samples
.map { samples in
// 과거 날짜
var prevDate: Date? = nil
var currentHeartRateWeightSum = 0
samples.forEach { sample in
// 만약 prevDate가 nil일경우 즉 초기 값일 경우
guard let currentPrevDate = prevDate else {
prevDate = sample.startDate
return
}
// convert heart Rate, get interval
let currentHeartRate = sample.quantity.doubleValue(for: heartRateUnit)
let interval = Double(sample.startDate.timeIntervalSince(currentPrevDate))
// calculate WeightSum
currentHeartRateWeightSum += Int(currentHeartRate * interval)
prevDate = sample.startDate
}
return currentHeartRateWeightSum
}
.reduce(0) { $0 + $1 }
let averageHeartRage = heartRateWeightSum / totalSeconds
HeartRateZone에 대해서 많은 견문이 있지 않아, 애플이 제공하는 Zone과 비슷하게 코드 작성을 하였습니다. 애플은 유산소 운동의 Zone을 높게 상정하였습니다.
우리가 알고 있는 유산소 운동의 기준으로 zone1: 50% ~ 60% .... zone5: 90 ~ 100% 를 기준을 하지만, 저는 다른 기준으로 상정하고 Zone을 만들었습니다.
그리고 HealtRate의 SampleQuntity마다 Interval을 계산했습니다.
private static func _calculateTimeInHeartRateZones(targetDate: Date) async throws -> [HeartRateZone: TimeInterval] {
let heartRateSamples = try await getTargetDateSwimmingHeartRateSamples(targetDate).flatMap { $0 }
let zoneManager = HeartRateZoneManager(maximumHeartRate: 190)
var res: [HeartRateZone: Double] = [:]
var prevDate: Date?
heartRateSamples.forEach { sample in
let heartRateUnit = HKUnit.count().unitDivided(by: HKUnit.minute())
let heartRate = Int(sample.quantity.doubleValue(for: heartRateUnit))
guard let heartRateZone = zoneManager.getHeartRateZone(for: heartRate) else {
return
}
// 과거 데이터가 없을 경우
guard let targetPrevDate = prevDate else {
prevDate = sample.startDate
return
}
let interval = sample.startDate.timeIntervalSince(targetPrevDate)
if interval / 60 > 5 || interval < 0 {
prevDate = nil
return
}
res[heartRateZone, default: 0] += interval
prevDate = sample.endDate
}
return res
}
4. 작동 화면
5. 후기
애플 헬스킷이 엄청 깔끔한 FrameWork은 아니고, 정리도 잘 안되있는 것 같습니다. 프로젝트에 아키텍쳐를 적용한 이유는 누군가 수영 관련 코드를 작성할 때 이 Examples이 도움이 되었으면 해서 아키텍쳐를 사용하였습니다. 다음에는 실제 수영 기록을 저장하는 APP을 만들고 싶네요. 그럼 안녕히 계세요!
PS: 그리고 만약 이 서비스에 관심있는 분들께서 연락주시면 감사합니다! 저 수영 관련 사이드 프로젝트 하고 싶어요!
https://github.com/MaraMincho/Swimlight
GitHub - MaraMincho/Swimlight: 추석 동안 즐기는 셀프 해커톤
추석 동안 즐기는 셀프 해커톤 . Contribute to MaraMincho/Swimlight development by creating an account on GitHub.
github.com