Skip to content

feat: 07-Stopwatch 과제 (이윤지)#29

Open
le2yunji wants to merge 2 commits into
mainfrom
feat/07-Stopwatch-yunji
Open

feat: 07-Stopwatch 과제 (이윤지)#29
le2yunji wants to merge 2 commits into
mainfrom
feat/07-Stopwatch-yunji

Conversation

@le2yunji

Copy link
Copy Markdown
Contributor

📝 과제 요약

  • 과제명:
  • 소요 시간: 2시간

💡 설계 및 고민한 부분

AI 없이 스스로 설계하며 가장 신경 쓴 핵심 로직이나 아키텍처(컴포넌트 구조, 상태 관리 등), 혹은 사용자 경험(UX) 개선 사항을 적어주세요.

  1. 스톱워치의 동작 원리를 생각해봤습니다.
    경과 시간 = 현재 시각 - 누른 시간 + 이전에 누적된 시간
    이 식을 이용해서 구현할 수 있을 거라고 판단했습니다.

  2. 필요한 상태를 생각했습니다.
    위 식에 들어가는 현재 시각, 누른 시간, 누적된 시간, 그리고 작동 여부를 상태로 만들었습니다.

  3. 경과된 시간의 연속적인 흐름을 화면에 어떻게 표현할지 고민했습니다.

처음에는 setInterval을 사용해 일정 주기마다 현재 시각을 갱신하는 방식으로 구현했습니다.

useEffect(() => {
  if (!isRunning) return;

  const intervalId = setInterval(() => {
    setNow(Date.now());
  }, 10);

  return () => clearInterval(intervalId);
}, [isRunning]);

이 방식은 동작 자체는 가능했지만, 실제 화면에서 밀리초 단위의 변화가 매끄럽게 보이지 않는 문제가 있었습니다.
또한 setInterval은 지정한 시간 간격마다 정확하게 실행되는 것이 아니라 브라우저 상황에 따라 지연될 수 있다는 점도 알게 되었습니다.

그래서 AI의 도움을 받아 requestAnimationFrameperformance.now()를 사용하는 방식으로 개선했습니다.

useEffect(() => {
  if (!isRunning) return;

  let animationFrameId: number;

  const update = () => {
    setNow(performance.now());
    animationFrameId = requestAnimationFrame(update);
  };

  animationFrameId = requestAnimationFrame(update);

  return () => cancelAnimationFrame(animationFrameId);
}, [isRunning]);

requestAnimationFrame은 브라우저의 화면 갱신 타이밍에 맞춰 콜백을 실행하기 때문에, 스톱워치처럼 화면에 시간이 계속 갱신되어야 하는 UI에 더 적합하다고 판단했습니다.

또한 기존에는 Date.now()를 사용했지만, 스톱워치에서는 실제 날짜나 시각보다 “얼마나 시간이 지났는지”가 중요하므로 performance.now()를 사용하는 것이 더 자연스럽다고 이해했습니다.

const handleStopwatch = () => {
  if (!isRunning) {
    const currentTime = performance.now();

    setStartTime(currentTime);
    setNow(currentTime);
    setIsRunning(true);
    return;
  }

  if (startTime === null || now === null) return;

  const currentTime = performance.now();

  setElapsedTime((prev) => prev + currentTime - startTime);
  setStartTime(null);
  setNow(null);
  setIsRunning(false);
};

Start를 누르면 현재 시각을 startTime으로 저장하고, Stop을 누르면 현재 시각 - 시작 시각을 기존 누적 시간에 더해 elapsedTime에 저장하도록 했습니다.
이렇게 하면 스톱워치를 멈췄다가 다시 시작해도 이전에 측정한 시간이 유지되고, 새로 흐른 시간만 이어서 누적됩니다.


📖 학습한 내용 및 어려웠던 점 (선택)

  1. 렌더링 중에는 Date.now()performance.now()처럼 호출할 때마다 값이 달라지는 함수를 직접 실행하면 안 된다는 것을 알게 되었습니다.

처음에는 컴포넌트 본문에서 현재 시각을 바로 구해 displayTime을 계산하려고 했지만, 렌더링 중에 변하는 값을 직접 호출하면 React의 순수성 규칙에 맞지 않는다는 오류가 발생했습니다.
따라서 현재 시각은 이벤트 핸들러, useEffect, setInterval, requestAnimationFrame 같은 렌더링 외부 흐름에서 구하고, 그 값을 상태로 저장해 화면에 반영해야 한다는 점을 배웠습니다.

  1. requestAnimationFrame은 한 번만 실행되는 함수이며, 반복 실행하려면 콜백 내부에서 다시 자기 자신을 예약해야 한다는 것을 알게 되었습니다.
const update = () => {
  setNow(performance.now());
  animationFrameId = requestAnimationFrame(update);
};

위 코드에서 update 함수가 실행될 때마다 다시 requestAnimationFrame(update)를 호출하기 때문에 시간이 계속 갱신됩니다.
즉, useEffect가 계속 반복 실행되는 것이 아니라, requestAnimationFrame 안의 update 함수가 자기 자신을 계속 다시 예약하는 구조입니다.

  1. useEffect는 타이머 루프를 시작하고 정리하는 역할을 하고, requestAnimationFrame(update)는 실행 중에 화면 시간을 계속 갱신하는 반복 루프 역할을 한다는 점을 이해했습니다.

isRunningtrue가 되면 useEffect에서 animation frame 루프를 시작하고, isRunningfalse가 되거나 컴포넌트가 언마운트되면 cleanup 함수에서 cancelAnimationFrame을 호출해 기존 루프를 정리합니다.

return () => cancelAnimationFrame(animationFrameId);

이를 통해 타이머가 멈춘 뒤에도 불필요하게 화면 갱신이 계속 예약되는 문제를 방지할 수 있었습니다.

  1. 스톱워치의 경과 시간은 단순히 숫자를 계속 더하는 방식보다, 기준 시각과 현재 시각의 차이로 계산하는 방식이 더 안정적이라는 것을 알게 되었습니다.

최종적으로 화면에 표시할 시간은 아래 기준으로 계산했습니다.

const displayTime =
  isRunning && startTime !== null && now !== null
    ? elapsedTime + (now - startTime)
    : elapsedTime;

실행 중일 때는 기존 누적 시간에 현재 실행 구간에서 흐른 시간을 더하고, 정지 중일 때는 확정된 누적 시간만 보여주도록 처리했습니다.

❓ 질문 사항 (선택)

라이브 리뷰 때 팀원들과 함께 토론하고 싶거나 의견이 궁금한 부분이 있다면 남겨주세요.


📸 스크린샷 (선택)

UI 관련 과제인 경우 결과 화면 캡처나 GIF를 첨부해 주세요.

2026-06-26.5.20.11.mov

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant