웹캠으로 부장님이 다가오면 째려보는 걸 만들었다
2026.07.31
노트북으로 일하다 보면 뒤에서 누가 오는 걸 모른다. 그래서 웹캠으로 얼굴을 세다가, 나 말고 다른 얼굴이 다가오면 눈이 그쪽을 째려보는 것을 만들었다. 보조 모니터에 띄워두는 용도다.
만들기 전에 정한 전제가 하나 있었다. 신원 식별·저장·전송을 하지 않는다. 다루는 값은 얼굴 박스의 좌표와 개수뿐이고, 영상은 브라우저 밖으로 한 바이트도 나가지 않는다. 이 전제가 뒤에 나오는 설계를 거의 다 결정했다.
검출은 쉬웠다
MediaPipe의 BlazeFace 숏레인지 모델을 쓴다. 얼굴 박스만 뱉는 가벼운 모델이라 랜드마크도 임베딩도 없다. 애초에 얼굴을 구분할 능력이 없는 모델을 고른 것이다.
무거운 건 런타임 쪽이다. wasm이 11MB짜리 두 개다. 그래서 시작 버튼을 누른 뒤에야 동적 import 한다.
페이지를 열기만 한 사람은 11MB wasm을 한 바이트도 받지 않는다.
에셋은 커밋하지 않고 prebuild/predev에서 node_modules에서 public/mediapipe/로 복사한다. Cloudflare Pages도 빌드 전에 npm install을 하니까 배포본에는 그대로 들어간다. 모델 파일(blaze_face_short_range.tflite)만 node_modules에 없어서 커밋했다 — 224KB다.
검출은 10fps로 스로틀한다. 사람이 걸어오는 속도에 60fps는 과하고, 노트북 팬만 돈다.
진짜 문제는 "누가 나인가"였다
얼굴 박스가 두 개 잡혔다고 치자. 어느 쪽이 나인가?
제일 먼저 떠오르는 답은 제일 큰 얼굴이다. 내가 화면에 제일 가까우니까. 이게 틀렸다.
- 등받이에 기대면 내 박스가 작아진다
- 상대가 화면 쪽으로 몸을 숙이면 상대 박스가 더 커진다
둘 중 하나만 일어나도 주인이 뒤바뀐다. 그러면 내가 침입자가 되고 부장님이 나가 돼서, 눈이 나를 째려본다.
그래서 크기 대신 지속성으로 골랐다.
/**
* 나를 고르는 규칙. 크기가 아니라 지속성으로 고른다 —
* 등받이에 기대면 내 박스가 작아지고, 상대가 화면 쪽으로 숙이면 상대가 더 커진다.
* 기대든 숙이든 프레임에 계속 있었던 건 나뿐이므로 age는 항상 내가 최대다.
*/기대든 숙이든 프레임에 계속 있었던 건 나뿐이다. 이건 자세와 무관하다.
여기에 조건을 셋 더 붙였다.
나로 인정받으려면 3초가 필요하다. MIN_SELF_AGE = 30 — 10fps 기준 30프레임이다. 켜자마자 지나가던 사람이 나로 굳어버리는 걸 막는다.
한번 정해진 나는 고정된다. 매 프레임 age를 다시 비교하면 검출이 한 번 튈 때마다 주인이 바뀐다.
if (prevSelfId !== null && tracks.some((t) => t.id === prevSelfId)) return prevSelfId;미검출 5프레임까지는 age를 유지한다. 고개를 한 번 돌릴 때마다 누가 나인지가 흔들리면 안 된다.
// 미검출. age는 그대로 둔다 — 여기서 리셋하면 고개 한 번 돌릴 때마다
// "누가 나인가"가 흔들린다.트래킹 자체는 IoU 그리디 매칭이다. 10fps 사이에 사람은 몇 픽셀밖에 못 움직여서 박스가 충분히 겹친다. 칼만 필터 같은 건 필요 없었다.
매칭 순서에도 이유가 있다. age가 큰 트랙부터 자기 박스를 가져간다. 오래 있던 사람(=나)이 먼저 골라야, 새로 들어온 사람과 박스가 겹칠 때 주인이 안 바뀐다.
째려볼 대상은 크기로 고른다
재미있는 건, 침입자를 고를 때는 크기를 써도 된다는 점이다.
/**
* 째려볼 대상 — 나를 제외한 트랙 중 박스가 가장 큰 것.
* 여기서는 크기를 써도 된다. "나"라는 기준점이 이미 고정돼 있으므로,
* 남은 사람들 중 가까운 쪽이 곧 나에게 볼일이 있는 쪽이다.
*/기준점이 고정된 뒤에는 크기가 다시 신뢰할 만한 신호가 된다. 같은 값이 어떤 맥락에서는 못 쓰고 어떤 맥락에서는 쓸 수 있다.
그리고 나가 확정되지 않았으면 아무도 고르지 않는다. 틀린 대상을 째려보느니 가만히 있는 게 낫다.
4단계와 떨림
거리는 박스 높이 ÷ 프레임 높이로 역산한다. 노트북 웹캠 세로 화각이 대략 45°라 비율에서 거리가 나온다.

여기서 이 앱의 품질이 갈린다. 검출 박스는 매 프레임 몇 픽셀씩 떨리고, 임계값 근처에 사람이 서 있으면 상태가 초당 몇 번씩 오간다. 눈이 발작하듯 표정을 바꾸면 무섭지도 웃기지도 않고 그냥 고장 난 것처럼 보인다.
떨림 대책을 셋 넣었다.
1. 비대칭 지연. 올라갈 땐 3프레임(0.3초), 내려올 땐 15프레임(1.5초).
export const RISE_FRAMES = 3;
export const FALL_FRAMES = 15;올라갈 때 굼뜨면 반응이 늦어 보인다. 내려올 때 빠르면 김이 샌다 — 상대가 잠깐 고개를 돌렸다고 바로 평온해지면 안 된다.
2. 임계값 여유구간 10%. 올라갈 땐 110%를 넘어야 하고, 내려올 땐 90% 밑으로 떨어져야 한다. 경계에 딱 선 사람 때문에 떨리는 것을 막는다.
3. 목표가 바뀌면 카운터 리셋. 흔들려서 잠깐 다른 값이 나왔던 것이면 대기를 취소한다.
두 단계 이상 점프도 3프레임이면 올라간다. 코앞까지 성큼 다가온 경우 한 단계씩 기어 올라가면 반응이 늦다.
눈이 좌우 반대로 갔다
이게 제일 오래 붙잡고 있었다.
침입자가 왼쪽에 있는데 눈이 오른쪽을 봤다. 좌우를 뒤집었더니 이번엔 다른 상황에서 틀렸다. 반전을 두 번 적용해서 서로 상쇄시켜 놓고 있었다.
정리하면 이렇다.
- 카메라는 나를 마주 보고 찍는다 → 내 왼쪽에 선 사람이 프레임 오른쪽에 찍힌다
- (웹캠 미리보기를 거울처럼 뒤집어 보여주는 이유가 정확히 이것이다)
- 그 사람은 화면을 보는 내 기준으로도 왼쪽에 있다
- 그러니 눈은 화면 왼쪽을 봐야 한다
- → 좌우만 한 번 뒤집는다. 위아래는 뒤집을 것이 없다.
const tx = clamp1(-(b.x + b.w / 2 - 0.5) * 2 * GAZE_GAIN);
const ty = clamp1((b.y + b.h / 2 - 0.5) * 2 * GAZE_GAIN);x에만 마이너스가 붙어 있다.
더 무서웠던 건 테스트도 같은 착각을 담고 있었다는 점이다. 잘못된 구현에 맞춰 기대값을 적어놨으니 통과할 수밖에 없다. 좌표계 변환은 테스트를 먼저 써도 소용이 없다 — 머릿속 모델이 틀렸으면 테스트도 같이 틀린다. 실제 화면에서 왼쪽에 서 보는 것 말고는 확인할 방법이 없었다.
눈이 끊기지 않게
처음엔 프레임을 3x3 아홉 칸으로 나눠 시선을 정했다. 칸이 바뀌면 눈이 툭툭 끊겼다.
연속값으로 바꿨다. 침입자 박스 중심을 -1~1로 옮기고, 매 프레임 목표 쪽으로 45%씩 다가간다.
/**
* 한 프레임에 목표 지점으로 다가가는 비율. 1이면 검출 박스가 떠는 대로 눈도 떤다.
* 10fps에서 0.45면 두세 프레임 만에 따라붙으면서 떨림은 눌린다.
*/
export const GAZE_SMOOTHING = 0.45;이 감쇠 하나가 두 가지를 동시에 한다. 사람이 걸어가면 눈이 중간 지점들을 거쳐 따라가고, 검출 박스가 떠는 것은 눌린다.
CSS transition은 130ms로 잡았다. 검출 주기(100ms)보다 조금 길게 잡아야 프레임 사이가 메워진다. 더 길면 사람보다 눈이 확연히 늦는다.
그리면서 만난 두 가지
캐릭터를 그렸다가 걷어내고 눈만 남겼는데, 그 과정에서 둘을 고쳤다.
2차 베지어가 닿는 높이는 제어점의 절반이다. 이걸 몰라서 눈이 의도한 절반 크기로 찌그러졌고, 눈동자가 눈을 꽉 채워서 어디를 보는지가 안 보였다. 시선 로직을 아무리 고쳐도 화면에 안 나타나던 이유가 이거였다.
`Q 0 ${-2 * half - drop * 0.6} ${EYE_W} 0`,제어점에 2를 곱해서 해결했다.
clipPath id가 고정 문자열이었다. 한 페이지에 눈을 두 쌍 놓는 순간 둘 다 첫 번째 눈매로 잘렸다. useId로 바꿨다.
// clipPath는 문서 전체에서 유일한 id가 필요하다. 고정 문자열을 쓰면 한 페이지에
// 두 쌍을 놓는 순간 둘 다 첫 번째 눈매로 잘린다.
const uid = useId();사나움은 딱 두 값이 만든다. 눈매 높이가 줄고(실눈), 안쪽 눈꼬리가 내려간다(찌푸림). 위 그림에서 오른쪽으로 갈수록 그 둘이 같이 움직이는 게 보인다.
백그라운드 탭에서는 안 돌아간다
만들고 나서 알게 된 것. 같은 창의 다른 탭으로 두면 브라우저가 백그라운드 탭의 프레임 처리를 초당 1회까지 줄여버려서 감지가 사실상 멈춘다.
그래서 페이지에 안내를 붙였다 — 별도 창으로 띄워 보조 모니터에 두라고. 애초에 그 용도로 만든 것이라 전체화면도 눈이 들어 있는 상자만 대상으로 한다.
남은 것
프라이버시 문구는 어떤 상태에서도 화면에서 사라지지 않는다. 전체화면에서도 남는다. 설정에 저장하는 것은 민감도와 미리보기 토글뿐이고, 영상에서 나온 값은 아무것도 저장하지 않는다.
이걸 지키려니 오히려 설계가 단순해졌다. 얼굴을 구분할 수 없는 모델을 골랐으니 "누가 나인가"를 좌표와 시간만으로 풀어야 했고, 그 결과가 크기가 아니라 지속성이라는 규칙이었다. 얼굴 임베딩을 쓸 수 있었다면 아마 훨씬 쉽게, 훨씬 나쁘게 만들었을 것이다.
이름은 원래 「개바리 째려보기」였다. 캐릭터를 걷어내고 눈만 남긴 뒤로 화면에 개바리가 없어서 이름과 그림이 어긋나 있었고, 8월 1일에 「부장님 째려보기」로 바꿨다. 경로와 코드 상의 이름은 그대로 뒀다.
카메라 권한을 주면 바로 돌아간다. 부장님 째려보기