최근에 비주얼 타이머를 찾아서 사용했는데 디자인이나 기능면에서 아쉬운 점이 있어서 내가 직접 만들어보기로 했다. 겸사겸사 웹과 모바일에서 모두 동작하도록 PWA도 도입해봤다.
그렇게 완성된 Visual Timer ~
시계 디자인을 예쁘게 만들고 싶어서 나름 열심히 했는데 어울리는 색상 뽑는게 제일 어려운 것 같다.🥲
(테마는 낼나 포커스 온 타이머를 참고했다.)
내가 만든 앱도 소개하고 정리도 할겸 글을 작성해본다.
⏱️ 소개
주요 기능
1. 기본(Default) 타이머 (메인 페이지)
- 드래그와 클릭만으로 시간을 자유롭게 설정해 바로 사용할 수 있는 기본 타이머.
2. 사용자 정의 타이머
Basic
타이머
Routine
타이머
- 여러
Basic
타이머를 그룹으로 묶어 순차적으로 실행 가능.
- 각 타이머의 실행 간격, 시작 시점, 반복 횟수(1회/무한)를 설정할 수 있어 다양한 활용이 가능.
- 활용 예시:
- 뽀모도로 타이머(집중 시간 + 휴식 시간 반복).
- 운동 루틴 관리.
- 기본 타이머의 60분 제한을 넘어서는 긴 시간을 설정하고 싶을 때도 유용.
3. 다양한 테마
- 베이지, 퍼플, 블랙 세 가지 테마로 원하는 디자인 선택 가능.
- 타이머 디스크 색상도 별도로 설정 가능.
- 사용자 정의 테마 추가 가능.
4. 화면 유지 기능
- 타이머 실행 중 화면이 꺼지지 않도록 유지되어 작업이나 운동 중 실시간으로 타이머를 확인 가능.
5. 백그라운드 알림 기능
- 타이머가 백그라운드에서도 동작하며, 알림을 통해 남은 시간과 종료 알림을 실시간으로 확인 가능.
6. 직관적인 동작과 조작 방식
- ESC 키로 타이머 닫기, 뒤로가기 버튼으로 작업 취소 등 익숙하고 직관적인 조작 방식을 지원.
7. 가로/세로 모드 최적화
- 디바이스의 방향에 따라 자동으로 화면이 최적화되어 더 편리하게 사용 가능.
8. 웹과 앱에서 모두 사용 가능
- PWA 지원: 브라우저에서 사용할 수 있을 뿐만 아니라 설치하여 앱처럼 사용할 수 있음.
(Chrome으로 웹사이트에 접속해 주소 표시줄 오른쪽의 모니터 모양의 버튼을 클릭하면 설치 가능)

- 앱처럼 사용 가능: 모바일 환경에서도 독립적인 앱으로 설치해 활용 가능.
(Chrome으로 웹사이트에 접속하면 자동으로 설치 알림이 뜸)

(알림이 뜨지 않는다면 오른쪽 위 점 세 개에서 스마트폰 모양의 앱 설치/홈 화면에 추가 버튼을 클릭해 설치)

※ iOS 한계 😭
- PWA 오디오 실행 정책 제한으로 알림 소리가 재생되지 않아 강제로 무음 타이머로 사용해야 한다.
- Notification 또한 동일한 tag임에도 replace되지 않고 stack되는 문제가 있어 background에서 남은 시간을 표시해주는 기능을 지원할 수 없다.
※ Windows, Android에서는 모두 잘 동작한다.
⇒ 앱 호환성 관련 정보는 링크 참고
👀 구현 이야기
구현하면서 다루었던 문제들을 정리해보았다.
🖥️ 화면 처리
1. 화면 유지
타이머가 포그라운드에서 실행 중일 때, 사용자의 디바이스 화면이 꺼지지 않도록 Wake Lock API를 사용했다. 이를 통해 타이머가 동작하는 동안 화면이 계속 켜져 있어 사용자가 진행 상황을 확인할 수 있도록 했다.
- 유지 방식: 타이머가 포그라운드에서 실행 중이면 Wake Lock을 활성화하고, 중단되거나 종료되면 Wake Lock을 해제하도록 구현했다. 백그라운드에서는 유지할 필요가 없으므로 해제한다.
- 향상된 사용자 경험: Wake Lock API를 통해 화면이 꺼지는 문제를 방지함으로써 타이머의 가시성을 유지하고, 특히 운동이나 작업 중 타이머 확인이 필요한 상황에서 유용성을 높였다.
2. 가로/세로 모드에 따른 화면 구성
가로/세로 모드 판단에는 다양한 방법이 있지만 나는 화면 비율에 중점을 두어 window.innerWidth / window.innerHeight
로 창의 너비와 높이 비율을 사용했다. 가로 모드에서는 화면을 좌우로 나누어 사용하고, 세로 모드에서는 단일 화면 구성을 적용했다.
다만 iOS에서는 화면 회전 시 화면 구성 전환이 제대로 되지 않는 문제가 있었는데 여러 삽질 끝에 visualViewport
를 도입해 해결했다.
3. Audio Controller가 알림창에 뜨는 문제
처음에는 new Audio
생성자로 알람 오디오 파일를 가져와 사용했는데 자꾸만 알림창에 음악 앱처럼 audio controller가 뜨는 문제가 있었다. 알람은 사용자가 마음대로 조정하면 안되기 때문에 audio controller를 없애야 했다. MediaSession API를 사용하면 audio controller가 생성된다는 사실을 알고 metadata를 비우는 등 비활성화시키려고 해봤지만 브라우저가 자동으로 오디오 소스를 감지하고 미디어 세션을 활성화하는 관계로 실패했다.
그래서 아예 MediaSession API를 사용하지 않고 Web Audio API를 사용해 해결했다. 비록 구현은 더 복잡해지지만 확실한 해결 방법이었다.
⏳ 시간 처리
0. 프로젝트 핵심 기반, 타이머 시스템
타이머 기능 구현을 위해 useCountdown을 참고하여, 밀리초 단위의 정밀한 시간 관리와 Foreground/Background 연동, Notification 및 Service Worker 통합 등을 지원하는 custom useTimer
Hook을 개발했다.
이 Hook은 타이머 기능의 기반으로, 기본 타이머와 루틴 타이머 모두에서 활용되고 있다.
1. Foreground-Background 전환 시 시간 보정
타이머 시스템 구현 후 가장 먼저 해결해야 했던 문제는 탭 이동이나 앱이 백그라운드로 전환되었을 때 타이머가 멈추는 상황이었다. 이 문제를 해결하기 위해, 앱이 백그라운드에서 포그라운드로 돌아올 때 경과된 시간을 계산해 타이머 값을 보정했다. 하지만 이는 포그라운드로 돌아온 이후의 동작에만 유효했고, 타이머가 백그라운드 상태에서 종료되는 경우를 처리하기에는 부족했다.
2. Background 타이머 종료를 위한 Service Worker 활용
타이머가 백그라운드에서 종료되는 시나리오를 처리하기 위해 Service Worker를 사용했다. Service Worker는 다음과 같은 역할을 수행하도록 했다.
- 백그라운드에서 타이머 관리
- 포그라운드와는 독립적으로 동작하며 타이머 종료 시점을 감지하고 처리할 수 있도록 Service Worker가 타이머 상태를 관리하게 하였다.
- Notification을 활용한 사용자 알림
- 타이머가 동작 중이거나 종료되었음을 사용자에게 알려주기 위해 Notification API를 사용했다.
- 500ms 간격으로 타이머의 남은 시간을 업데이트해 실시간 피드백을 제공했다.
3. Foreground와 Background 간 Messaging
Service Worker와 애플리케이션 간에는 메시징을 통해 상태를 주고받았다. 이 과정에서 타이머 정확도를 유지하기 위한 개선이 이루어졌다.
- Messaging의 정확도 문제
- 메시지 전송 과정에서 발생하는 지연으로 인해 타이머의 정확도가 떨어지는 문제가 있었다.
- 기존에는 남은 시간(
remainingTime
)을 메시지로 주고받았으나, 이 방식에서는 메시징 시간으로 인해 보정이 필요했다.
- endTime 기반 로직 변경
- 이러한 문제를 해결하기 위해 종료 시점(
endTime
)을 계산해 메시지로 전달하는 방식으로 변경했다.
endTime
을 기준으로 현재 시간을 비교해 남은 시간을 계산함으로써 메시징 지연으로 인한 오류를 최소화했다.
4. Service Worker 디버깅 및 캐싱 문제
Service Worker를 사용하며 몇 가지 기술적인 어려움을 겪었다.
- 로컬 개발 환경의 한계
- Service Worker는 보안 상의 이유로 HTTPS 환경에서만 동작하므로, 로컬에서 직접 디버깅할 수 없었다. 따라서 코드를 변경한 후 매번 애플리케이션을 배포해 디버깅해야 했고, 이는 시간 소모와 번거로움을 초래했다.
- 캐싱 문제
- Service Worker의 캐싱으로 인해 코드 변경 사항이 즉시 반영되지 않는 경우가 발생했다. 이를 해결하기 위해 캐시를 비우고 강력 새로고침을 수행해야 했다.
- 이 과정에서 Service Worker 코드의 버전 관리가 중요하다는 점을 실감했다.
🔙 뒤로가기/앞으로가기
PWA 환경에서는 브라우저의 뒤로가기/앞으로가기 동작이 문제가 되었다. 처음에는 타이머 목록/수정/추가 등 여러 페이지가 존재했고 별다른 고민 없이 이 페이지들에 path를 할당했다. 하지만 구현하고자 하는 구조에 페이지는 적합하지 않아 형태만 오버레이로 바꾸었고, 이 과정에서 뒤로가기/앞으로가기와 관련된 동작 문제가 발생했다. 이에 다음과 같은 과정을 거쳐 결론적으로 hash 기반의 오버레이 형태로 구현하게 되었다.
1. Path를 할당해 페이지 형태로 만들지 않고 Overlay로 구현한 이유
Path를 활용해 페이지 형태로 만들 경우 다음과 같은 문제가 발생했다.
- 페이지 리로드 문제: Path 기반으로 구현하면 URL 이동 시 브라우저는 기본적으로 새 페이지를 로드하게 된다. 이로 인해 오버레이가 앱의 일부분으로 동작하지 않고 독립된 페이지로 처리되었다.
- 내비게이션 혼란: 오버레이는 기존 페이지의 맥락(context) 위에서 동작해야 하지만, Path를 사용하면 오버레이가 새로운 페이지처럼 동작하므로 기존 상태와 맥락을 유지하기 어려웠다.
- 불필요한 API 호출: 새 페이지를 로드하게 되면 서버로 불필요한 추가 요청이 발생했다. 이는 사용자 경험과 퍼포먼스에 부정적인 영향을 미쳤다.
결론적으로 오버레이는 URL Path를 변경하기보다는 기존 페이지 위에 레이어로 동작하는 방식이 더 적합하다고 판단했다.
2. Path 기반으로 구현했을 때의 문제점
Path를 기반으로 오버레이를 구현했을 때 발생한 주요 문제점은 다음과 같다.
- 뒤로가기 동작의 불일치: 사용자가 뒤로가기를 눌렀을 때, 오버레이가 닫히는 대신 이전 페이지로 이동하는 경우가 발생했다. 이는 오버레이가 페이지 일부로 작동해야 한다는 사용자 기대와 어긋나는 동작이었다.
- 상태 복원 어려움: Path 기반으로 오버레이를 구현할 경우 상태를 URL만으로 저장하기 어려워 추가적인 상태 관리 로직이 필요했다.
- URL 구조의 복잡성: Path 기반으로 구현하면 URL 구조가 복잡해졌고, 사용자는 오버레이 상태를 독립된 페이지로 잘못 이해할 가능성이 높았다.
특히 브라우저는 기본적으로 페이지 방문 이력을 관리하기 때문에, 예를 들어 타이머 목록 페이지를 방문한 후 닫기 버튼을 눌러 닫은 뒤 앱을 종료하기 위해 뒤로가기를 누르면 다시 타이머 목록 페이지가 나타나는 문제가 있었다. 특히 모바일 앱에서 일반적인 뒤로가기 동작과는 다르게 동작하여 사용자의 기대를 크게 저버린다.
3. History API로 단순히 이전 방문 이력을 삭제하거나 조작했을 때의 문제점
History API를 통해 이전 방문 이력을 조작하거나 삭제하는 방식은 다음과 같은 제약이 있었다.
- 사용자 경험의 불일관성: History API를 조작하면 브라우저의 뒤로가기 버튼이 예상치 못한 동작을 하게 되었다. 예를 들어, 이전 페이지로 이동하려 했는데 기록이 삭제되어 사용자 경험이 악화되었다.
- 호환성 문제: History API는 모든 브라우저에서 동일하게 동작하지 않았다. 특히 오래된 브라우저에서는 지원되지 않거나 문제가 발생할 가능성이 있었다.
- 비직관적 동작: History API로 이력을 삭제하면 사용자가 뒤로가기를 눌러도 오버레이만 닫히지 않고 다른 페이지로 갑자기 이동하거나 예상치 못한 상태가 발생했다.
실제로 처음에는 History API로 단순히 이전 방문 이력을 삭제해보았다. 하지만 앱의 경우 뒤로가기 동작 시 이동할 페이지가 없으면 앱이 종료되는 문제가 발생했다. PWA에서는 네이티브 앱처럼 디바이스의 뒤로가기 동작을 완벽히 제어할 수 없기 때문에 다른 방법을 찾아야 했다.
4. Hash로 구현한 이유
해시(window.location.hash
)를 사용한 이유는 다음과 같다.
- URL 상태와 UI 상태의 동기화: 해시는 브라우저 내에서 페이지를 새로 로드하지 않고도 URL 상태를 업데이트할 수 있다. 이를 통해 오버레이 상태를 URL에 반영할 수 있었다.
- 뒤로가기 버튼과의 자연스러운 연동: 해시를 변경하면 브라우저의 히스토리에 기록이 추가되므로, 뒤로가기를 누르면 자동으로 이전 해시로 돌아갈 수 있었다. 이를 통해 오버레이가 닫히는 자연스러운 사용자 경험을 제공할 수 있었다.
- 리로드 방지: 해시는 페이지를 리로드하지 않기 때문에 오버레이와 기존 페이지의 상태를 유지할 수 있었다.
- 간단한 구현: 해시 변경은 History API보다 간단한 코드로 구현 가능하며, 브라우저 호환성도 뛰어났다.
5. 현재 코드 구현 방식
PWA 환경에서 오버레이를 구현하기 위해 다양한 방법을 시도한 결과, 다음과 같은 방식으로 최종 코드가 완성되었다.
- 해시 기반 상태 관리 도입
window.location.hash
를 통해 오버레이 상태를 URL에 반영하여 뒤로가기 버튼과 연동했다.
hashchange
이벤트를 활용해 해시 변경 시 오버레이 상태를 동기화했다.
- 키보드 이벤트 추가
Escape
키로 오버레이를 닫을 수 있도록 하여 네이티브 앱과 유사한 사용자 경험을 제공했다.
- History API와의 조합
window.history.back()
을 사용해 뒤로가기를 누를 때 URL 해시를 이전 상태로 되돌림으로써 오버레이를 닫는 자연스러운 동작을 구현했다.
- 콜백 처리
- 오버레이 닫힐 때 추가 작업을 실행할 수 있도록
onClose
콜백을 지원했다.
이 코드는 PWA에서 오버레이 상태를 효율적으로 관리하기 위해 URL 해시를 활용한 내 나름대로의 최적의 설계라고 할 수 있다... Path 기반 페이지 전환 방식과 History API만을 사용하는 방식의 한계를 보완하고, 사용자 경험까지 고려했다.
결론적으로 이 앱에서 hash 기반으로 구현된 Overlay(타이머 목록, 타이머 변경/수정, 설정)들은 뒤로가기, ESC, 닫기 버튼 요소를 통해 닫을 수 있어 웹의 특성을 사용하면서도 앱에서 기대되는 사용자 경험까지 만족시켰다.
PWA여서 장점도 있지만 그만큼 모바일 앱으로써는 한계가 명확했던 것 같다. 특히 웹 브라우저 보안상의 제약이 커서 기기 제어는 거의 불가능하고, 만약 하고 싶다면 하이브리드나 네이티브로 가야만 가능한 것으로 보인다. 알림 완료 시 화면 강제 깨우기/포커스, 위젯, 알림 소리를 기기 오디오와는 별개로 제어 등 타이머라면 지원하면 좋을 기능들을 지원할 수 없어서 아쉽다. 그래도 백그라운드 알림 동작, notification으로 실시간 타이머 정보 알림 등으로 해당 부분들을 최대한 보완하고자 노력했다.