전투력 측정기에는 전면·후면 카메라를 바꾸는 버튼이 있다. 타인을 측정할 때 후면을 쓰고, 자기 자신을 볼 때 전면으로 전환한다. 이 버튼을 눌렀을 때 두 가지 일이 생겼다.
첫째로, 전환 직후에 얼굴 검출이 멈췄다. 새 카메라에서 얼굴을 아무리 비춰도 박스가 안 잡혔고, 앞 카메라에서 잡았던 랜드마크 점 68개가 그 자리에 얼어붙어 있었다. 둘째로, 얼어붙음을 고쳤더니 이번에는 전환 뒤에 화면이 꺼진 채로 남았다. 새 카메라 영상이 안 뜨고 검출도 안 됐다.
원인이 달랐다.
첫 번째: 루프가 예외 한 번에 죽었다
power-scanner의 얼굴 검출은 setTimeout으로 자기 자신을 반복 예약하는 구조다. 루프 함수 본문 맨 끝에서 다음 프레임을 예약한다.
const loop = async () => {
// ... 검출 ...
const all = await faceapi.detectAllFaces(video, DETECTOR).withFaceLandmarks(true);
// ... 결과 처리 ...
setTimeout(loop, DETECT_INTERVAL_MS); // 여기에만 있었다
};
카메라를 전환하는 순간 <video> 엘리먼트가 새 스트림으로 교체된다. 교체 직후에는 video.videoWidth가 0이 되는 구간이 있다. 이때 faceapi.detectAllFaces에 크기가 0인 영상을 넘기면 내부에서 예외가 난다. 예외가 나면 함수 실행이 중단되고 맨 끝의 setTimeout 줄에 닿지 못한다. 루프가 다음 프레임을 예약하지 못한 채 끝난다. 그 다음에는 아무것도 없다. 루프가 죽은 것이다(커밋 07dcb18).
try {
// 영상이 아직 올라오지 않았으면 이번 차례는 건너뛴다
if (video.videoWidth === 0 || video.videoHeight === 0) return;
// ... 검출 ...
} finally {
if (alive) setTimeout(loop, DETECT_INTERVAL_MS);
}
try/finally로 루프 본문을 감쌌다. 예외가 나든, 중간에 return을 하든, finally 블록은 무조건 실행된다. 루프는 이제 어떤 경우에도 다음 프레임을 예약한다. 그리고 videoWidth가 0이면 이번 차례 검출을 건너뛰는 조건을 앞에 달았다.
얼어붙었던 점 문제는 별도로 처리했다. 카메라 전환 함수에서 boxRef.current와 marksRef.current를 즉시 비운다. 스트림을 교체하기 전에 앞 카메라 데이터를 먼저 지우는 것이다.
두 번째: 여는 경로가 두 곳이었다
루프를 살렸더니 이번에는 다른 문제가 나왔다. 화면이 검게 꺼진 채로 남고, 새 얼굴이 안 잡혔다. 스트림은 바뀌었는데 화면이 안 켜졌다.
원인을 파보니 카메라를 여는 경로가 두 곳이었다. 하나는 처음 앱을 열 때 쓰는 openLive(), 다른 하나는 전환 버튼이 부르는 swap()이다. swap()은 카메라 전환이 필요하니 직접 getUserMedia를 다시 호출했다. 그런데 openLive()가 스트림을 연 뒤에 하는 마무리 세 가지가 swap()에 빠져 있었다.
swap() 이 하던 것
- streamRef 갱신
- video.srcObject 교체
- setFacing 업데이트
openLive() 가 추가로 하던 것
- setVideoReady(true)
- startDrawing() 호출
- 검출 루프 킥
setVideoReady가 true로 안 바뀌니 화면은 검정 상태로 남는다. startDrawing을 안 부르니 캔버스에 아무것도 안 그려진다. 검출 루프 킥이 빠졌으니 새 루프가 시작되지 않는다(커밋 2e640ab).
고친 방법은 swap()에서 getUserMedia를 직접 부르는 대신 openLive()를 다시 타게 한 것이다. 스트림을 끊고 방향만 바꾼 뒤 openLive()를 호출하면, 나머지 초기화는 기존 경로가 알아서 한다. getUserMedia 호출 지점이 코드에 한 곳뿐이 됐다.
const swap = useCallback(async () => {
const next = facingRef.current === 'user' ? 'environment' : 'user';
facingRef.current = next;
setFacing(next);
// 앞 카메라 트랙을 먼저 끊는다
streamRef.current?.getTracks().forEach((t) => t.stop());
streamRef.current = null;
// 앞 카메라 자국을 즉시 지운다
boxRef.current = null;
marksRef.current = [];
setBox(null);
setAligned(false);
// openLive를 통째로 다시 탄다. 여는 경로를 하나로 유지한다
await openLiveRef.current?.();
}, []);
두 버그를 합치면 구조적으로 같은 교훈이 나온다. 루프는 예외를 방어하지 않으면 한 번에 죽는다. 그리고 같은 일을 하는 코드 경로가 두 곳이면, 나중에 추가한 쪽이 먼저 만들어진 쪽의 초기화 단계를 하나 빠뜨린다. 처음엔 알아채기 어렵다. 빠진 단계가 setVideoReady처럼 조용히 영향을 주는 상태이기 때문이다. 화면이 검어지고 나서야 연결이 보인다.