AppOrbit
← blog

육아앱 직접 만든 개발자의 회고 (2) — 실시간 기록 시스템과 퀵액션 UX

React Native, UX, 애니메이션, Haptic Feedback, 사이드프로젝트

1. 들어가며: 기록이 귀찮으면 기록하지 않는다

육아 기록 앱의 가장 큰 적은 "귀찮음"입니다. 앱을 열고, 메뉴를 찾고, 폼을 채우고, 저장 버튼을 누르는 과정이 3단계만 넘어가도 사용자는 포기합니다. 특히 새벽 수유 중에 한 손으로 아기를 안고 있는 상태라면 더욱 그렇습니다.

DailyBaby의 UX 설계 원칙은 단순했습니다. 기록 시작까지 탭 1회, 종료까지 탭 1회. 이 원칙을 지키기 위해 플로팅 컨트롤바, 퀵액션 바, 실시간 경과 시간 추적, 실행취소 스낵바 등 여러 기술적 장치를 도입했습니다.

이번 글에서는 이 "원터치 기록 시스템"이 어떤 기술적 고민 위에 만들어졌는지 이야기하겠습니다.

2. 플로팅 컨트롤바: 항상 손이 닿는 곳에

화면 하단에 고정된 플로팅 컨트롤바는 수유, 기저귀, 수면 세 개의 버튼으로 구성됩니다. 탭 바 바로 위에 위치해서 엄지 손가락이 자연스럽게 닿는 영역에 있습니다.

시작과 종료의 토글 패턴

이 버튼들은 단순한 "기록 추가" 버튼이 아닙니다. 현재 진행 중인 기록이 있으면 종료하고, 없으면 새로 시작하는 토글 방식으로 동작합니다.

플로팅 버튼 탭 시 로직:

IF 해당 유형의 진행 중인 기록이 있으면:
  → 현재 시간을 end_time으로 설정
  → 기록 종료 + 성공 햅틱
  → 스낵바로 "수유 120ml 기록됨" 알림

ELSE:
  → 현재 시간을 start_time으로 설정
  → 새 기록 시작 + 라이트 햅틱
  → 버튼이 펄스 애니메이션으로 전환

여기서 한 가지 문제가 있었습니다. 버튼을 빠르게 두 번 탭하면 기록이 시작되자마자 바로 종료되는 현상이 발생했습니다. 이 문제는 endingRecordRef라는 Ref 값을 활용해서 종료 처리가 진행 중일 때 추가 탭을 무시하는 방식으로 해결했습니다.

펄스 애니메이션으로 상태 전달

기록이 진행 중이라는 것을 사용자에게 어떻게 알릴 것인가는 중요한 UX 문제였습니다. 텍스트로 "진행 중"이라고 쓰는 것만으로는 새벽에 졸린 눈으로 보기에 부족합니다.

선택한 방식은 **숨쉬기 애니메이션(breathing animation)**입니다. 아이콘의 투명도가 1에서 0.25까지 부드럽게 반복되면서, 동시에 크기도 미세하게(1 → 1.18) 커졌다 줄었다 합니다. 이 두 가지 애니메이션이 겹쳐지면서 마치 아이콘이 "숨쉬는" 것 같은 느낌을 줍니다.

펄스 애니메이션 (수도코드):

IF 기록 진행 중:
  opacity: 반복(1 → 0.25 → 1, 주기 800ms)
  scale: 반복(1 → 1.18 → 1, 주기 800ms)
  라벨: "수유" → "수유 중"

여기에 더해, 수유 버튼에는 권장 수유 간격 알림 기능도 넣었습니다. 마지막 수유 후 권장 시간이 지나면 버튼 위에 작은 빨간 점이 나타납니다. "슬슬 먹일 시간이에요"를 앱이 넌지시 알려주는 것입니다.

경과 시간 실시간 표시

진행 중인 기록의 버튼 위에는 "1시간 30분 경과"처럼 실시간 경과 시간이 표시됩니다. 이 기능을 구현하면서 성능과 정확성 사이의 균형을 맞추는 것이 핵심 과제였습니다.

매 초마다 UI를 갱신하면 가장 정확하겠지만, 배터리 소모와 성능 저하가 심합니다. 반대로 갱신 주기를 너무 길게 잡으면 사용자가 "업데이트가 안 되는 것 같다"고 느낍니다. 최종적으로 60초 간격으로 갱신하도록 결정했습니다. 육아 기록에서 초 단위 정밀도는 필요 없고, 분 단위면 충분하다고 판단했습니다.

useElapsedTime 훅 설계 (수도코드):

입력: startTime, interval(60초), isActive
출력: "30분 경과" 또는 "2시간 15분 경과"

1. isActive가 false이면 타이머를 설정하지 않음 (최적화)
2. 마운트 시 현재 시간 - startTime으로 초기값 계산
3. 60초마다 경과 시간 재계산
4. 앱이 백그라운드에서 돌아올 때 즉시 재계산
5. 언마운트 시 타이머 정리

4번이 중요합니다. 앱이 백그라운드에 있으면 JavaScript 타이머가 정지됩니다. 이 상태에서 5분 뒤에 앱을 다시 열면 경과 시간이 5분 전 값으로 멈춰 있습니다. AppState의 change 이벤트를 구독해서 앱이 포그라운드로 돌아올 때마다 즉시 경과 시간을 재계산하도록 처리했습니다.

3. 스크롤 기반 타이머 최적화

경과 시간 추적에서 가장 어려웠던 문제는 성능이었습니다. 홈 화면의 기록 목록에서도 진행 중인 기록은 경과 시간을 표시합니다. 만약 하루에 기록이 30개 있고, 그중 3개가 진행 중이라면, 화면 밖에 있는 기록까지 60초마다 타이머를 돌리는 것은 낭비입니다.

이 문제를 해결하기 위해 Viewability 기반 렌더링을 도입했습니다.

스크롤 기반 타이머 최적화 (수도코드):

1. SectionList에 viewabilityConfig 설정
   → 아이템이 10% 이상 보이면 "visible"로 판정

2. onViewableItemsChanged 콜백
   → 보이는 아이템의 ID를 Set에 저장

3. RecordItem 렌더링 시
   → isVisible = visibleIds.has(item.id)
   → useElapsedTime(startTime, 60000, isVisible)
   → isVisible이 false이면 타이머를 아예 생성하지 않음

이 방식을 적용하자 화면에 보이는 2~3개의 아이템만 타이머가 동작하고, 나머지는 완전히 비활성화됩니다. CPU 사용량이 눈에 띄게 줄었고, 특히 기록이 많은 날에 스크롤 성능이 크게 개선되었습니다.

4. 실행취소 스낵바: 실수를 허용하는 UX

원터치 기록의 속도감은 강점이지만, 동시에 "실수로 잘못 눌렀을 때"에 대한 안전장치가 필요합니다. 이를 위해 기록 완료 후 5초 동안 표시되는 실행취소 스낵바를 구현했습니다.

스낵바의 동작 방식

기록이 저장되면 화면 하단에 스낵바가 올라옵니다. "수유 120ml 기록됨"이라는 메시지와 함께 "실행취소"와 "수정" 두 개의 버튼이 표시됩니다.

스낵바 라이프사이클 (수도코드):

1. 기록 저장 완료
2. 스프링 애니메이션으로 스낵바 슬라이드 업
3. 5초 타이머 시작
4. 사용자 액션에 따라 분기:
   a) 5초 경과 → 페이드아웃 후 자동 닫힘
   b) "실행취소" 탭 → 기록 삭제 + 미디엄 햅틱
   c) "수정" 탭 → 수정 모달 오픈
   d) 아래로 스와이프 (50px 이상) → 즉시 닫힘
   e) 스와이프 중 50px 미만 → 스프링으로 원위치 복귀

스와이프 투 디스미스의 디테일

스와이프 제스처에서 신경 쓴 부분이 있습니다. 사용자가 스낵바를 아래로 드래그하는 동안에는 자동 닫힘 타이머를 일시정지합니다. 드래그했다가 다시 올려놓으면 남은 시간만큼만 다시 타이머가 작동합니다. 이렇게 하지 않으면 사용자가 스와이프하려고 터치한 순간 5초가 지나서 스낵바가 갑자기 사라지는 황당한 경험을 하게 됩니다.

스와이프 중 타이머 일시정지 (수도코드):

터치 시작 시:
  remainingTime = 원래 타임아웃 - (현재 시간 - 타이머 시작 시간)
  타이머 클리어

터치 종료 시:
  IF 드래그 거리 > 50px → 닫기
  ELSE → 스프링 애니메이션으로 복귀 + 새 타이머(remainingTime)

스프링 애니메이션의 tension과 friction 값도 여러 번 조정했습니다. tension 50, friction 8로 설정했는데, 너무 탄력적이지 않으면서도 자연스럽게 원위치로 돌아오는 느낌을 만들어냅니다.

5. 햅틱 피드백: 만져지는 기록감

시각적 피드백만으로는 부족합니다. 특히 새벽에는 화면을 제대로 보지 못하는 경우가 많습니다. 그래서 햅틱(진동) 피드백을 적극적으로 활용했습니다.

햅틱 피드백 전략:

기록 시작:  라이트 햅틱 (가벼운 진동)
기록 종료:  성공 햅틱 (짧은 2번 진동)
실행취소:  미디엄 햅틱 (중간 진동)
오류 발생:  에러 햅틱 (강한 진동)
버튼 프레스: 스케일 바운스 (0.9 → 1 스프링)

각 상황에 맞는 진동 패턴을 다르게 적용하면, 사용자는 화면을 보지 않아도 "기록이 시작됐구나", "저장됐구나"를 손끝으로 느낄 수 있습니다. 이 차이는 글로 설명하기 어렵지만, 실제로 새벽에 사용해보면 확실하게 체감됩니다.

6. 퀵액션 바 커스터마이징

홈 화면 상단에는 8가지 기록 유형의 퀵액션 버튼이 수평 스크롤로 배치되어 있습니다. 하단 플로팅 컨트롤바가 수유, 기저귀, 수면에 집중한다면, 상단 퀵액션 바는 나머지 유형(성장, 활동, 투약, 건강, 메모)까지 커버합니다.

사용자마다 자주 쓰는 기록 유형이 다르기 때문에, 이 퀵액션 버튼의 순서를 드래그 앤 드롭으로 재배열할 수 있게 했습니다. 또한 3~5개 사이에서 표시할 버튼 개수도 설정할 수 있습니다.

드래그 앤 드롭 구현 (수도코드):

1. DraggableFlatList 컴포넌트 사용
2. 아이템을 길게 누르면(onLongPress) 드래그 모드 활성화
3. ScaleDecorator로 드래그 중인 아이템에 확대 효과
4. 드롭 시 onDragEnd 콜백으로 순서 업데이트
5. 새 순서를 스토어에 저장 → AsyncStorage에 영속화

제약 조건:
- 최소 3개, 최대 5개의 버튼만 활성화 가능
- 비활성화된 버튼은 토글 UI로 켜기/끄기

7. RecordItem 컴포넌트의 메모이제이션

홈 화면의 기록 목록은 하루에 수십 개의 아이템을 렌더링합니다. 하나의 기록이 변경될 때 전체 목록이 리렌더링되는 것을 방지하기 위해 React.memo를 적용했는데, 단순한 얕은 비교로는 부족했습니다.

기록의 details 필드는 JSONB 기반 객체입니다. 서버에서 동기화될 때마다 새 참조가 생성될 수 있기 때문에 얕은 비교로는 동일한 데이터여도 매번 리렌더가 발생합니다. 커스텀 비교 함수를 작성해서 실제로 내용이 바뀐 경우에만 리렌더되도록 했습니다.

커스텀 메모 비교 (수도코드):

RecordItem을 memo()로 감싸되, 비교 함수 커스텀:
  같은 조건:
    - record.id 동일
    - record.updated_at 동일
    - record.note 동일
    - record.details를 JSON 직렬화해서 비교
    - isVisible 값 동일
    - colorScheme (테마) 동일

  → 모두 같으면 리렌더 건너뛰기

JSON.stringify로 details를 비교하는 것은 성능적으로 이상적이지 않지만, 각 details 객체의 크기가 작기 때문에(대부분 5개 미만의 필드) 실측해보니 영향이 미미했습니다. 오히려 불필요한 리렌더가 줄어드는 효과가 훨씬 컸습니다.

8. 마치며

이번 글에서 다룬 내용을 정리하면 다음과 같습니다.

  • 플로팅 컨트롤바: 토글 방식의 원터치 기록 시작/종료
  • 펄스 애니메이션: 진행 중인 기록의 시각적 상태 전달
  • useElapsedTime 훅: 60초 간격 갱신 + AppState 기반 즉시 보정
  • Viewability 최적화: 화면에 보이는 아이템만 타이머 활성화
  • 실행취소 스낵바: 5초 윈도우 + 스와이프 제스처 + 타이머 일시정지
  • 햅틱 피드백: 상황별 차등 진동으로 비시각적 피드백 제공

이 모든 디테일은 "새벽 3시에 한 손으로 아기를 안은 채로 기록을 남길 수 있는가?"라는 하나의 질문에서 출발했습니다. 기술적으로 복잡한 것은 아니지만, 이런 디테일들이 모이면 앱의 체감 품질이 확연히 달라집니다.

다음 편에서는 야간모드와 테마 시스템을 다룹니다. 단순한 다크 테마가 아니라, 새벽 수유에 최적화된 OLED 야간모드를 왜, 어떻게 만들었는지 이야기하겠습니다.

👉 데일리베이비 - App Store에서 다운로드