카메라의 타이머 기능 구현이 필요한 상황이 발생했는데요.
개발하며 비동기를 포함한 자바스크립트 기본 개념을 오랜만에 복습해 볼 수 있는
좋은 시간이었습니다.
처음 문제가 발생한 시점부터 개념을 복습하며 문제를 해결하기까지의
과정을 정리해보았습니다.
타이머 1차 구현
'use client';
import { useRef, useState, useEffect } from 'react';
import BeforeCamera from '@/components/Camera/BeforeCamera';
import PhotoShoot from '@/components/Camera/PhotoShoot';
import { Flex } from '@chakra-ui/react';
const CameraContainer = () => {
const [timer, setTimer] = useState<number>(5);
const startTimer = () => {
setIsCapturing(true);
setTimer(5);
const interval = setInterval(() => {
if (timer > 0) {
setTimer((prevTime) => prevTime - 1);
} else {
clearInterval(interval);
capturePhoto();
setTimer(0);
setTimeout(() => {
startSecondTimer();
}, 3000);
}
}, 1000);
};
const startSecondTimer = () => {
setTimer(5);
const interval = setInterval(() => {
if (timer > 0) {
setTimer((prevTime) => prevTime - 1);
} else {
clearInterval(interval);
capturePhoto();
setTimer(0);
}
}, 1000);
};
return (
<Flex flexDirection="column" alignItems="center" justifyContent="center">
{!isCapturing && <BeforeCamera startCamera={startCamera} />}
<PhotoShoot
capturedImages={capturedImages}
timer={timer}
isCapturing={isCapturing}
videoRef={videoRef}
canvasRef={canvasRef}
/>
</Flex>
);
};
export default CameraContainer;
문제 상황
startTimer가 동작하지만 이후 setTimeout 내부의 콜백으로 호출되는
startSecondTimer가 동작하지 않았습니다.
React의 state 업데이트가 비동기적으로 이뤄진다는 점과 관련이 있는데요.
setState를 호출한다고 해서 즉시 상태가 업데이트되는 것은 아니며,
상태가 변경된 후 컴포넌트가 리렌더링 될 때 새로운 상태가 반영됩니다.
이는 상태 업데이트가 완료되기 전에 상태 업데이트가 여러 번 요청될 수 있음을 의미해요.
이러한 비동기적 특성 때문에 setInterval과 같은 타이머 함수 내부에서
상태 값을 사용하려고 할 때 문제가 발생할 수 있습니다.
타이머 함수는 설정된 주기마다 실행되지만, 상태가 최신 상태로 업데이트되지 않은 경우
사이드 이펙트가 발생할 수 있습니다.
예를 들어, startTimer 함수에서 setTimer를 사용하여 상태를 업데이트할 때,
setInterval 내부의 콜백 함수가 실행될 때마다 timer 상태는 최신 상태가 아닐 수 있거든요.
즉, timer 상태가 변경되었음에도 불구하고 setInterval 콜백 함수가
이전의 timer 상태를 참조할 수 있다는 겁니다.
고민을 통해 startTimer 함수 내부에서 지역변수로 타이머값을 관리하는 방법을 사용해 봤습니다.
2차 구현( 함수 내부에 지역 변수 활용)
'use client';
import { useRef, useState, useEffect } from 'react';
import BeforeCamera from '@/components/Camera/BeforeCamera';
import PhotoShoot from '@/components/Camera/PhotoShoot';
import { Flex } from '@chakra-ui/react';
const CameraContainer = () => {
const [capturedImages, setCapturedImages] = useState<string[]>([]);
const [timer, setTimer] = useState<number>(5);
const [isCapturing, setIsCapturing] = useState<boolean>(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const requestCameraAccess = async () => {
// 카메라 권한 획득 로직
};
const startTimer = () => {
setIsCapturing(true);
setTimer(5);
let countdown = 5;
const interval = setInterval(() => {
if (countdown > 0) {
setTimer(countdown - 1);
countdown -= 1;
} else {
clearInterval(interval);
capturePhoto();
setTimer(0);
setTimeout(() => {
startSecondTimer();
}, 3000);
}
}, 1000);
};
const startSecondTimer = () => {
setTimer(5);
let countdown = 5;
const interval = setInterval(() => {
if (countdown > 0) {
setTimer(countdown - 1);
countdown -= 1;
} else {
clearInterval(interval);
capturePhoto();
setTimer(0);
}
}, 1000);
};
const capturePhoto = () => {
// 촬영 이미지 데이터를 저장하는 로직
};
const startCamera = () => {
requestCameraAccess();
startTimer();
};
return (
<Flex flexDirection="column" alignItems="center" justifyContent="center">
{!isCapturing && <BeforeCamera startCamera={startCamera} />}
<PhotoShoot
capturedImages={capturedImages}
timer={timer}
isCapturing={isCapturing}
videoRef={videoRef}
canvasRef={canvasRef}
/>
</Flex>
);
};
export default CameraContainer;
지역 변수인 countdown을 사용해 구현했습니다.
상태를 최소화해 코드를 작성하고 싶었기에, 변수로 타이머값을 관리했는데요.
startTimer 함수가 호출될 때마다 새로운 countdown 변수를 생성하므로,
각 타이머는 독립적으로 동작하게 됩니다.
이렇게 하면 여러 타이머가 동시에 실행되더라도
각각의 countdown 값이 올바르게 관리되는데요.
하지만 좀 더 생각해 봤을 때, 지역 변수의 단점이 있었습니다.
이벤트 리스너가 여러 번 호출된다는 가정 하에
리렌더링 시 지역변수에 대한 초기화가 이뤄지며
동시성 문제가 발생할 가능성이 있었습니다.
그래서 시작한 3차 구현
'use client';
import { useRef, useState } from 'react';
import BeforeCamera from '@/components/Camera/BeforeCamera';
import PhotoShoot from '@/components/Camera/PhotoShoot';
import { Flex } from '@chakra-ui/react';
const CameraContainer = () => {
const [capturedImages, setCapturedImages] = useState<string[]>([]);
const [timer, setTimer] = useState<number>(5);
const [isCapturing, setIsCapturing] = useState<boolean>(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const timerRef = useRef<number>(5);
const requestCameraAccess = async () => {
// 카메라 권한 획득 로직
};
const startTimer = () => {
setIsCapturing(true);
timerRef.current = 5;
setTimer(5);
const interval = setInterval(() => {
if (timerRef.current > 0) {
timerRef.current -= 1;
setTimer(timerRef.current);
} else {
clearInterval(interval);
capturePhoto();
setTimeout(() => {
startSecondTimer();
}, 3000);
}
}, 1000);
};
const startSecondTimer = () => {
timerRef.current = 5;
setTimer(5);
const interval = setInterval(() => {
if (timerRef.current > 0) {
timerRef.current -= 1;
setTimer(timerRef.current);
} else {
clearInterval(interval);
capturePhoto();
}
}, 1000);
};
const capturePhoto = () => {
// 촬영 이미지 데이터를 저장하는 로직
};
const startCamera = () => {
requestCameraAccess();
startTimer();
};
return (
<Flex flexDirection="column" alignItems="center" justifyContent="center">
{!isCapturing && <BeforeCamera startCamera={startCamera} />}
<PhotoShoot
capturedImages={capturedImages}
timer={timer}
isCapturing={isCapturing}
videoRef={videoRef}
canvasRef={canvasRef}
/>
</Flex>
);
};
export default CameraContainer;
useRef를 활용해 보자!
동기적으로 처리되며 컴포넌트의 렌더링 사이클과 독립적으로 존재하는 요소가 있죠?
바로 ref를 사용해 타이머가 정상 동작하도록 처리했습니다.
컴포넌트가 리렌더링이 이뤄져도 ref 객체는
새로 생성되지 않고 동일한 객체를 계속 유지합니다.
Clear 처리는 꼭 해주기
처음엔 startTimer와 startSecondTimer 함수 내에서
clearInterval을 호출해주고 있기 때문에
useEffect에서 추가로 clearInterval을 호출하는 것이
불필요하다고 여겨졌습니다.
그러나 컴포넌트가 언마운트될 때 타이머가 멈추지 않아
Memory Leak이 발생할 수 있었는데요.
안전성을 위해 useEffect를 통해 정리해 주기로 했습니다.
최종
'use client';
import { useRef, useState, useEffect } from 'react';
import BeforeCamera from '@/components/Camera/BeforeCamera';
import PhotoShoot from '@/components/Camera/PhotoShoot';
import { Flex } from '@chakra-ui/react';
const CameraContainer = () => {
const [capturedImages, setCapturedImages] = useState<string[]>([]);
const [timer, setTimer] = useState<number>(5);
const [isCapturing, setIsCapturing] = useState<boolean>(false);
const videoRef = useRef<HTMLVideoElement | null>(null);
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const timerRef = useRef<number>(0);
const intervalRef = useRef<NodeJS.Timeout | null>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);
const requestCameraAccess = async () => {
if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: true,
});
if (videoRef.current) {
videoRef.current.srcObject = stream;
videoRef.current.play();
}
} catch (err) {
console.error('Error accessing webcam: ', err);
}
}
};
const startTimer = () => {
setIsCapturing(true);
timerRef.current = 5;
setTimer(5);
intervalRef.current = setInterval(() => {
if (timerRef.current > 0) {
timerRef.current -= 1;
setTimer(timerRef.current);
} else {
if (intervalRef.current) clearInterval(intervalRef.current);
capturePhoto();
timeoutRef.current = setTimeout(() => {
startSecondTimer();
}, 3000);
}
}, 1000);
};
const startSecondTimer = () => {
timerRef.current = 5;
setTimer(5);
intervalRef.current = setInterval(() => {
if (timerRef.current > 0) {
timerRef.current -= 1;
setTimer(timerRef.current);
} else {
if (intervalRef.current) clearInterval(intervalRef.current);
capturePhoto();
}
}, 1000);
};
const capturePhoto = () => {
if (videoRef.current && canvasRef.current) {
const context = canvasRef.current.getContext('2d');
if (context) {
const videoWidth = videoRef.current.videoWidth;
const videoHeight = videoRef.current.videoHeight;
const canvasWidth = canvasRef.current.width;
const canvasHeight = canvasRef.current.height;
context.drawImage(
videoRef.current,
0, // sx
0, // sy
videoWidth, // sWidth
videoHeight, // sHeight
0, // dx
0, // dy
canvasWidth, // dWidth
canvasHeight, // dHeight
);
const imageData = canvasRef.current.toDataURL('image/png');
setCapturedImages((prevImages) => [...prevImages, imageData]);
}
}
};
const startCamera = () => {
requestCameraAccess();
startTimer();
};
const clearTimers = () => {
if (intervalRef.current) clearInterval(intervalRef.current);
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
const clearCapturedImages = () => {
if (videoRef.current && videoRef.current.srcObject) {
const stream = videoRef.current.srcObject as MediaStream;
stream.getTracks().forEach((track) => track.stop());
}
};
useEffect(() => {
return () => {
clearCapturedImages();
clearTimers();
};
}, []);
return (
<Flex flexDirection="column" alignItems="center" justifyContent="center">
{!isCapturing && <BeforeCamera startCamera={startCamera} />}
<PhotoShoot
capturedImages={capturedImages}
timer={timer}
isCapturing={isCapturing}
videoRef={videoRef}
canvasRef={canvasRef}
/>
</Flex>
);
};
export default CameraContainer;
'JS & REACT' 카테고리의 다른 글
나는 useEffect를 잘못 사용하고 있었다 (0) | 2024.06.20 |
---|---|
서로 다른 컴포넌트에서 호출한 커스텀 훅이 지니는 개별성 (0) | 2024.06.08 |
charCodeAt, fromCharCode, 그리고 정규표현식 (0) | 2024.05.15 |
알고리즘 풀이 중 오랜만에 경험한 기초 지식 (0) | 2024.05.12 |
숫자와 문자가 섞인 문자열에서, 각 요소를 구분하는 방법 (0) | 2024.05.11 |