뽀모도로 타이머, AI로 10분 만에 직접 만들어 쓰기
SVG 원형 프로그레스 바와 Web Audio API 비프음을 갖춘 뽀모도로 타이머를 직접 만들었습니다. 외부 파일 없이 소리를 내는 방법도 공유합니다.
위 화면은 이 포스트의 프롬프트를 AI에 입력해서 생성한 샘플입니다
뽀모도로 기법이란?
1980년대 이탈리아 개발자 프란체스코 시릴로(Francesco Cirillo)가 고안한 시간 관리 기법입니다. 그가 사용하던 토마토 모양 주방 타이머 이름에서 따왔습니다 (이탈리아어로 pomodoro = 토마토).
기법은 간단합니다.
- 25분 집중: 한 가지 작업에만 집중합니다. 스마트폰 확인 금지.
- 5분 휴식: 완전히 쉽니다. 스트레칭, 물 마시기.
- 위 두 단계 반복: 4번 완료 후 15분 긴 휴식.
- 계속: 긴 휴식 후 다시 1번부터.
이 단순한 사이클이 왜 효과적일까요?
인간의 뇌는 장시간 집중을 유지하도록 설계되지 않았습니다. 중간에 짧은 휴식을 주면 집중력이 리셋되어 더 오래, 더 깊게 집중할 수 있습니다.
완성된 뽀모도로 타이머 살펴보기
위의 라이브 데모에서 지금 바로 써보세요.
3가지 모드:
- 🍅 집중 (25분): 작업에만 몰입
- ☕ 짧은 휴식 (5분): 잠깐 쉬기
- 🌴 긴 휴식 (15분): 4번 집중 완료 후 충분히 쉬기
원형 프로그레스 바가 시간이 지나면서 줄어들고, 완료 시 소리로 알려줍니다. 완료된 뽀모도로 수도 기록됩니다.
AI에게 어떻게 요청했나?
1단계 프롬프트 — 기본 타이머
Next.js 14 + TypeScript + Tailwind CSS로 뽀모도로 타이머 컴포넌트를 만들어줘.
요구사항:
- 'use client' 컴포넌트
- 3가지 모드: 집중(25분), 짧은 휴식(5분), 긴 휴식(15분)
- SVG circle을 이용한 원형 프로그레스 바
- stroke-dasharray와 stroke-dashoffset으로 진행률 표현
- 중앙에 MM:SS 형식 남은 시간 표시
- 시작/일시정지/리셋 버튼
- useInterval 또는 setInterval로 1초마다 카운트다운
2단계 프롬프트 — 사이클 관리
뽀모도로 사이클 로직을 추가해줘:
- 집중 완료 → 자동으로 짧은 휴식으로 전환
- 4번 집중 완료 → 짧은 휴식 대신 긴 휴식으로 전환
- 완료된 뽀모도로 개수 표시 (🍅 아이콘)
- 타이머 완료 시 Web Audio API로 비프음:
AudioContext로 oscillator 생성, 주파수 880Hz, 0.3초
- document.title에 남은 시간 표시: "🍅 24:59 — 집중 중"
3단계 프롬프트 — UI 완성
UI를 개선해줘:
- 현재 모드에 따라 테마 색상 변경 (집중: 빨강 계열, 휴식: 초록 계열)
- 모드 전환 시 부드러운 전환 효과
- 타이머 완료 알림 뱃지
- 모드 버튼을 상단에 탭 형태로 배치
코드 핵심 포인트
1. SVG 원형 프로그레스 바
원의 둘레를 계산해서 stroke-dashoffset으로 진행률을 표현합니다.
const RADIUS = 90
const CIRCUMFERENCE = 2 * Math.PI * RADIUS // ≈ 565.5
// 남은 시간 비율만큼 dash offset 설정
const progress = remainingSeconds / totalSeconds
const dashOffset = CIRCUMFERENCE * (1 - progress)
// SVG에 적용
<circle
r={RADIUS}
cx="100" cy="100"
fill="none"
stroke="currentColor"
strokeWidth="8"
strokeLinecap="round"
strokeDasharray={CIRCUMFERENCE}
strokeDashoffset={dashOffset}
style={{ transition: 'stroke-dashoffset 1s linear' }}
/>
strokeDasharray는 선의 패턴(실선 길이, 공백 길이)을 정의합니다. 전체 원 둘레 하나의 실선으로 지정하고, strokeDashoffset으로 시작점을 이동시켜 진행률을 표현합니다.
2. 정확한 타이머 — setInterval의 함정
setInterval은 탭이 비활성화될 때 지연될 수 있습니다. 더 정확한 방법은 시작 시각을 저장하고 현재 시각과의 차이로 계산하는 것입니다.
const startTimeRef = useRef<number | null>(null)
const [remaining, setRemaining] = useState(totalSeconds)
useEffect(() => {
if (!isRunning) return
startTimeRef.current = Date.now() - (totalSeconds - remaining) * 1000
const interval = setInterval(() => {
const elapsed = Math.floor((Date.now() - startTimeRef.current!) / 1000)
const newRemaining = totalSeconds - elapsed
if (newRemaining <= 0) {
clearInterval(interval)
handleComplete()
} else {
setRemaining(newRemaining)
}
}, 500) // 500ms마다 체크 (1초보다 짧게 해서 놓치지 않음)
return () => clearInterval(interval)
}, [isRunning])
3. Web Audio API 비프음
외부 오디오 파일 없이 브라우저에서 직접 소리를 생성합니다.
function playBeep() {
const ctx = new AudioContext()
const oscillator = ctx.createOscillator()
const gainNode = ctx.createGain()
oscillator.connect(gainNode)
gainNode.connect(ctx.destination)
oscillator.frequency.value = 880 // A5 음
oscillator.type = 'sine'
gainNode.gain.setValueAtTime(0.3, ctx.currentTime)
gainNode.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.5)
oscillator.start(ctx.currentTime)
oscillator.stop(ctx.currentTime + 0.5)
}
뽀모도로 기법 실전 팁
무엇을 집중 세션으로 나눌까?
좋은 예시:
- 블로그 포스트 초안 작성 (1~2 뽀모도로)
- 코드 기능 하나 구현 (2~3 뽀모도로)
- 이메일 처리 (1 뽀모도로)
- 책 읽기 (1~2 뽀모도로)
주의할 점:
- 회의 중에는 사용하지 마세요
- 창의적인 작업은 흐름이 끊기지 않도록 세션을 길게 설정해도 좋습니다
- 집중 도중 다른 생각이 떠오르면 메모만 하고 현재 작업에 집중합니다
뽀모도로 기록의 힘
완료된 뽀모도로 수를 기록하면 오늘 얼마나 집중했는지 시각적으로 확인할 수 있습니다. 하루 812개 뽀모도로(35시간 실질 집중)가 지속 가능한 수준입니다.
활용 예시
코딩 작업 기능 구현 1개 = 2 뽀모도로 할당. 오버엔지니어링 방지에도 효과적입니다.
글쓰기 1 뽀모도로에 초안만 씁니다. 다음 뽀모도로에 다듬습니다. 완벽주의를 차단합니다.
학습 25분 강의 수강 → 5분 내용 정리. 기억 강화에 효과적입니다.
운동 25분 운동 → 5분 스트레칭. 운동 루틴에도 적용 가능합니다.
이렇게 확장할 수 있습니다
- 알림 소리 선택: 종소리, 새소리, 빗소리 등 여러 옵션
- 커스텀 시간 설정: 집중 시간을 25분이 아닌 다른 시간으로 조정
- 통계 대시보드: 일별/주별 완료 뽀모도로 수 그래프
- 작업 태깅: 어떤 작업을 했는지 태그로 기록
- 알림: 브라우저 Notification API로 탭이 비활성화 상태에서도 알림
자주 묻는 질문
Q. 25분이 너무 짧거나 길면 어떻게 하나요? 처음에는 25분으로 시작하고, 익숙해지면 자신에게 맞는 시간으로 조정하세요. 일반적으로 20~45분 사이가 효과적입니다.
Q. 집중 도중 급한 일이 생기면? 타이머를 멈추고 처리한 뒤, 그 뽀모도로는 무효 처리하고 새로 시작합니다.
Q. 소리가 안 나는 경우? 브라우저 정책상 사용자 상호작용(클릭 등) 없이는 오디오를 재생할 수 없습니다. 타이머 시작 버튼을 클릭한 뒤에는 소리가 정상 재생됩니다.
다음 편 예고
다음 편은 색상 코드 변환기입니다. HEX, RGB, HSL, CMYK를 한 번에 변환하고 보색까지 확인하는 디자이너용 도구를 만들어봅니다.
'use client'
import { useState, useEffect, useRef, useCallback } from 'react'
const MODES = {
focus: { label: '집중', emoji: '🍅', seconds: 25 * 60 },
short: { label: '짧은 휴식', emoji: '☕', seconds: 5 * 60 },
long: { label: '긴 휴식', emoji: '🌿', seconds: 15 * 60 },
}
const CIRCLE_R = 80
const CIRCLE_C = 2 * Math.PI * CIRCLE_R // 원의 둘레 ≈ 502.65
// Web Audio API 비프음
function playBeep() {
const ctx = new AudioContext()
const osc = ctx.createOscillator()
const gain = ctx.createGain()
osc.connect(gain)
gain.connect(ctx.destination)
osc.frequency.value = 880
gain.gain.setValueAtTime(0.3, ctx.currentTime)
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 0.6)
osc.start(ctx.currentTime)
osc.stop(ctx.currentTime + 0.6)
}
export function PomodoroTimer() {
const [mode, setMode] = useState('focus')
const [timeLeft, setTimeLeft] = useState(MODES.focus.seconds)
const [isRunning, setIsRunning] = useState(false)
const [pomodoroCount, setPomodoroCount] = useState(0)
const intervalRef = useRef(null)
const progress = (MODES[mode].seconds - timeLeft) / MODES[mode].seconds
const dashOffset = CIRCLE_C * (1 - progress)
const mm = String(Math.floor(timeLeft / 60)).padStart(2, '0')
const ss = String(timeLeft % 60).padStart(2, '0')
const handleComplete = useCallback(() => {
setIsRunning(false)
playBeep()
if (mode === 'focus') {
const newCount = pomodoroCount + 1
setPomodoroCount(newCount)
const nextMode = newCount % 4 === 0 ? 'long' : 'short'
setMode(nextMode)
setTimeLeft(MODES[nextMode].seconds)
} else {
setMode('focus')
setTimeLeft(MODES.focus.seconds)
}
}, [mode, pomodoroCount])
useEffect(() => {
if (isRunning) {
intervalRef.current = setInterval(() => {
setTimeLeft(prev => {
if (prev <= 1) { handleComplete(); return 0 }
return prev - 1
})
}, 1000)
} else {
clearInterval(intervalRef.current)
}
return () => clearInterval(intervalRef.current)
}, [isRunning, handleComplete])
return (
<div>
{/* 모드 버튼 */}
{Object.entries(MODES).map(([key, m]) => (
<button key={key} onClick={() => { setMode(key); setTimeLeft(m.seconds); setIsRunning(false) }}>
{m.emoji} {m.label}
</button>
))}
{/* SVG 원형 프로그레스 */}
<svg width="200" height="200" viewBox="0 0 200 200">
{/* 배경 원 */}
<circle cx="100" cy="100" r={CIRCLE_R} fill="none" strokeWidth="8" stroke="#e5e7eb" />
{/* 진행 원 */}
<circle
cx="100" cy="100" r={CIRCLE_R}
fill="none" strokeWidth="8" stroke="#22c55e"
strokeLinecap="round"
strokeDasharray={CIRCLE_C}
strokeDashoffset={dashOffset}
transform="rotate(-90 100 100)"
style={{ transition: 'stroke-dashoffset 1s linear' }}
/>
</svg>
{/* 시간 표시 */}
<p>{mm}:{ss}</p>
{/* 컨트롤 */}
<button onClick={() => setIsRunning(prev => !prev)}>
{isRunning ? '일시정지' : '시작'}
</button>
<button onClick={() => { setIsRunning(false); setTimeLeft(MODES[mode].seconds) }}>
리셋
</button>
{/* 뽀모도로 카운트 */}
<p>{'🍅'.repeat(pomodoroCount)}</p>
</div>
)
}