WIKI전투력 측정기
개인 제품·2026 (참가 신청 중)·기획·개발

전투력 측정기

얼굴 이목구비를 재서 전투력과 등급 카드를 내주는 미니앱.

ReactTypeScriptViteface-apiApps in TossFirestore REST

전투력 측정기는?

카메라로 얼굴을 비추면 이목구비 비율을 재서 전투력 수치와 등급 카드를 내주는 미니앱이다. 앱인토스 바이브코딩 챌린지 출품작이고, 얼굴 인식은 전량 기기 안에서 돈다. 사진도 얼굴 벡터도 서버로 나가지 않는다.

결과는 8자리 숫자 하나와 C·R·SR·SSR·UR 다섯 등급, 그리고 카드 한 장이다. 카드에는 "묠니르를 들어 올린 카피바라와 동급 또는 그 이상." 같은 문구가 붙는다.

얼굴을 재서 전투력을 매기고 카드 한 장으로 돌려준다
얼굴을 재서 전투력을 매기고 카드 한 장으로 돌려준다
진입 화면. 조준 프레임 안에 얼굴을 넣고 스캔을 시작한다
진입 화면. 조준 프레임 안에 얼굴을 넣고 스캔을 시작한다

왜 만들었나

재미 하나로 끝나는 앱이지만 안에서 답해야 할 질문은 재미있지 않았다. 같은 얼굴을 두 번 찍으면 같은 값이 나오는가. 이게 안 되면 전투력이 아니라 난수 뽑기다.

그래서 화면보다 계측 도구를 먼저 만들었다. 기획서에 적어 둔 계산 방식(Face Embedding → SHA256 → Power)이 성립하는지부터 실측하고, 안 되면 갈아엎기로 했다. 실제로 갈아엎었다.

전체 흐름

카메라
토스 웹뷰 · 전면/후면 전환
얼굴 검출
tinyFaceDetector 190KB · 120ms 간격
자세 게이트
정면/옆모습 판별, 잴 수 없는 각도는 반려
2초 · 8프레임 수집
68점 랜드마크에서 이목구비 원시값
가중합 → 전투력
안광·기세·골격·턱선
등급 · 카드 · 랭킹
캔버스 합성 저장 · Firestore REST
3.5K
줄 TypeScript
0.65%
재측정 흔들림 (연속 방식)
8
프레임 평균
4
측정 항목 (표정 제외)
5
등급 C~UR
12MB
얼굴 모델 · 전량 온디바이스
68점 랜드마크 이목구비 가중합으로 전투력 산출
정면·옆모습을 서로 다른 기준으로 측정
8프레임 평균 + 자세 게이트로 재측정 흔들림 억제
등급별 색과 문구를 입힌 카드, 결과 이미지 캔버스 합성
Firestore REST 랭킹 · 키 없으면 기기 안에 저장
사진·얼굴 정보는 기기 밖으로 나가지 않음

전투력을 어떻게 재는가

기획서의 해시 방식은 실측으로 기각했다

face-api가 주는 128차원 descriptor에 SHA256을 씌우면 사람마다 고정된 값이 나올 거라고 봤다. 계측 화면(#lab)에서 여섯 전략을 한 번에 계산해 비교했고, 결과는 반대였다.

전략서로 다른 값등급 종류흔들림판정
해시 q=0.055215.96%탈락
해시 q=0.14360.80%탈락
해시 q=0.23266.42%탈락
LSH 16bit2220.45%탈락
LSH 24bit2244.71%탈락
연속 투영510.65%통과

2차 실측(정면 부동자세 5건)에서 descriptor 거리는 최소 0.031, 평균 0.054였다. 동일 인물 기준선이 0.6이니 사실상 같은 사진이다. 그런데도 해시 값은 전부 갈렸다. 이유는 구조적이다. 해시는 입력이 1비트만 달라도 출력이 완전히 바뀌는데, 128차원 중 단 한 축만 양자화 경계를 넘으면 그 조건이 성립한다. 양자화 폭을 4배로 키워도 결과는 같았다.

경위는 블로그에 따로 적었다. 임베딩 해시가 전투력을 재지 못한 이유를 실측으로 알았습니다

그리고 연속 투영도 제품에 쓰지 않았다

흔들림 0.65%로 통과한 연속 투영은 고정 랜덤 벡터에 사영해 표준정규 누적분포로 펴는 방식이다. 값은 안정적이었지만 "왜 높은가"를 설명할 수 없다. 랜덤 벡터와의 내적이 컸다는 말은 사용자에게 아무 의미가 없다.

제품이 쓰는 것은 68점 랜드마크의 이목구비 가중합이다. 해시·LSH·연속 투영은 계측 도구(KillTest)에서 비교용으로 살아 있을 뿐 제품 경로가 아니다.

측정 중. 얼굴에 찍힌 파란 점 68개가 그대로 계산에 들어간다
측정 중. 얼굴에 찍힌 파란 점 68개가 그대로 계산에 들어간다
항목무엇을 재나가중치
안광눈 세로 대 가로. 가늘고 길수록0.2
기세눈썹 중앙과 눈 중앙의 세로 거리0.3
골격광대 폭 대 얼굴 길이0.1
턱선턱 끝에서 좌우 턱선이 벌어지는 각도0.4

가중치는 2026-08-06 3인 실측에서 항목별 변별력을 보고 정했다. 기세(0.350.53)와 턱선(117135)은 사람마다 뚜렷이 갈렸고, 안광(0.300.33)과 골격(1.221.27)은 거의 붙어 있었다. 잘 갈리는 항목에 비중을 준다.

표정은 일부러 뺐다. 표정을 넣으면 노려보기 대회가 되고 얼굴 자체는 무관해진다. 뼈대는 표정을 바꿔도 안 변하므로 "같은 사람은 같은 값"도 같이 해결된다. 축을 미모가 아니라 "쎄 보임"으로 잡은 것도 같은 이유다. 낮게 나와도 상처가 되지 않는 축이라야 한다.

균형(좌우 대칭) 항목은 만들었다가 지웠다. 실측에서 원시값이 0.14와 0.13으로 거의 같은데 점수가 1과 10으로 튀었다. 대칭은 뼈대보다 촬영 각도에 좌우된다.

흔들림을 줄이는 두 장치

값이 흔들리는 원인은 계산식이 아니라 입력이다. 그래서 입력 단에 두 개를 뒀다.

8프레임 평균. 랜덤 노이즈는 장수의 제곱근에 비례해 줄어든다. 측정에 2초가 걸리는 것은 연출이 아니라 그 시간 동안 실제로 프레임을 모으기 때문이다.

자세 게이트. 68점 랜드마크로 고개 각도를 대충 재서(pose.ts) 정면인지 옆모습인지 판별한다. 측면을 정면 기준으로 재면 얼굴 폭이 줄어 보인다. yaw 0.30짜리 사진은 골격 원시값이 1.34에서 1.26으로 어긋났다. 처음에는 옆모습을 아예 막았는데, 그러면 남을 몰래 찍는 상황에서 영영 측정이 안 된다. 막는 대신 나눴다. 정면과 옆모습에 다른 구간을 쓰고, 옆에서 잴 수 없는 골격은 비중을 0으로 두고 나머지에 나눠 준다. 그보다 더 돌아가면 랜드마크 자체가 추정치라 반려한다.

재미를 설계한 것

등급과 숫자

전투력 상한은 99,999,999다. 6자리로는 격차가 안 느껴진다. 55만과 89만은 비슷해 보이는데 5,595만과 8,923만은 확 벌어져 보인다. 전투력이라는 소재 자체에 인플레가 있다.

등급은 위로 갈수록 좁아진다. SSR이 흔하면 등급이 의미를 잃는다. 경계는 잠정치다. 랭킹에 값이 쌓이면 분위수로 다시 잡는 게 맞다.

재설계 전 결과 카드. SSR 등급, 전투력 86,980,617, 지구인 상위 0.22%. 등급을 금속 테두리와 홀로 반사로 지고 있었다
재설계 전 결과 카드. SSR 등급, 전투력 86,980,617, 지구인 상위 0.22%. 등급을 금속 테두리와 홀로 반사로 지고 있었다

등급을 재질로 지다가 색으로 옮긴 이야기

처음에는 카드가 등급을 재질로 지고 있었다. 금속 테두리와 홀로 반사. TCG는 등급이 색이 아니라 재질로 드러난다는 관찰에서 나온 것인데, 화면에서는 기계적으로 읽혔다. 2026-08-09에 재질을 걷어내고 흰 카드로 갈았다.

재질을 걷어내면 등급을 지고 있던 수단이 같이 사라진다. 다시 재질로 돌아가지 않고 색 세 자리에 나눠 실었다.

자리무엇
숫자와 등급 로마자흰 카드 위의 잉크 색
화면 배경3정지점 그라데이션. 카드가 아니라 화면이 바뀌므로 등급 차이가 제일 크게 보인다
카드 그림자흰 카드가 무슨 색 바닥에 놓였는지

잉크는 다섯 등급 전부 흰 카드 대비 4.8:1 이상이다(C 7.11 / R 5.41 / SR 6.30 / SSR 5.02 / UR 6.05). 색을 등급 신호로 쓰기로 한 이상 대비가 무너지면 읽히지 않으니, 대비 값을 코드 주석에 못박고 "값을 바꿀 일이 생기면 이걸 먼저 확인하라"고 적어 뒀다.

UR만 종류를 다르게 뒀다. 숫자에 분홍에서 보라를 지나 주황으로 가는 3색을 걸고, 배경도 한 계열이 아니라 분홍에서 라벤더를 지나 하늘로 간다. 등급 차이를 정도가 아니라 종류로 만드는 자리다. 섞는 세 색 모두 4.8:1을 넘으므로 어느 획에서도 흐려지지 않는다.

색값의 주인은 power.ts 한 곳이다. 화면은 CSS 변수로 받아 가고, 저장 카드는 같은 값으로 캔버스 그라데이션을 만든다. 두 곳이 갈라지면 공유받은 사람이 본 것과 보낸 사람이 본 것이 달라진다.

저장 카드에는 여백을 둘렀다. 예전에는 캔버스 전체가 카드였는데, 흰 카드에 등급색 그림자를 걸고 나니 그림자가 갈 곳이 없어 카드가 종이 한 장으로만 보였다. 카드 둘레를 등급 배경색으로 채우고 나서야 카드가 바닥에서 떨어졌다. 다만 안쪽 치수 비율은 화면 CSS의 짝과 손으로 맞춰 둔 것이고 공유하는 상수가 없다. 한쪽만 고치면 화면과 저장본이 갈라지는데 저장해서 나란히 놓기 전까지 아무도 모른다. 코드에 경고로 적어 뒀다.

카드 문구, 밈 서른 장을 걷어낸 이유

처음에는 밈으로 채웠다. 2026-08-07에 서른 장을 전부 내리고 형식 하나로 고정했다.

{비교 대상} 와 동급 또는 그 이상.

밈은 아는 사람에게만 웃긴다. 심사위원이 그 밈을 모르면 그 카드는 그냥 이상한 문장이다. 비교 대상은 설명 없이 누구나 그림이 그려지는 것이라야 한다.

웃음은 스케일이 아니라 과한 구체성에서 나온다. "곰"은 안 웃기고 "30일 굶은 그리즐리 베어"는 웃긴다. 종을 특정하고, 상태를 붙이고, 숫자를 박는다. 등급이 오를수록 대상이 커지되 구체성은 끝까지 유지한다. 최고 등급만 규모가 아니라 종류를 바꿔서, 우주급 사이에 "금요일 저녁 6시의 직장인" 같은 현실의 사람을 끼워 넣었다. UR은 넉 장뿐이다. 억지로 여섯을 채우면 약한 카드가 나머지를 죽인다.

같은 사람은 같은 카드가 나와야 하므로 전투력 값을 씨앗으로 대상을 고른다. 재측정할 때마다 바뀌면 카드가 아니라 뽑기가 된다. 조사도 받침을 보고 "와/과"를 가른다. 이걸 안 하면 "게코도마뱀와"처럼 문장이 한 번에 싸구려가 된다. 걷어낸 서른 장은 docs/meme-lines.md에 그대로 남겨 뒀다.

랭킹

Firebase SDK를 통째로 넣지 않고 Firestore REST로 붙였다. 얼굴 모델만 12MB라 번들에 여유가 없고, 실제로 쓰는 건 읽기와 쓰기 하나씩뿐이다. 키가 없으면 기기 안(localStorage)에만 저장한다. 키를 받기 전에도 화면을 완성해 둘 수 있고 서버가 죽어도 앱이 멈추지 않는다.

올라가는 것은 닉네임·숫자·등급·시각뿐이다. 사진과 얼굴 정보는 올리지 않는다. 결과 이미지도 캔버스에서 합성해 그 자리에서 저장하거나 공유한다.

브라우저에서 직접 쓰므로 값을 위조할 수 있다. 서버에서 얼굴을 다시 계산하지 않는 한 근본적으로 못 막는다. 재미 앱에 그 비용을 쓸 이유가 없어 범위 검사만 뒀다.

토스 미니앱 제약

문서에 없고 겪어야만 아는 것들이 있었다. 기록은 docs/toss-webview-camera.md에 남겼다.

영상이 전체화면으로 튀어 HUD를 겹칠 수 없었다

이 앱은 카메라 위에 조준선과 랜드마크를 겹쳐야 성립한다. 그런데 iOS 토스 웹뷰에서 스트림은 열리는데 <video>가 재생되는 순간 전체화면 기본 재생기로 강제 전환됐다. 겹칠 자리가 없어지는 것이다.

webView.allowsInlineMediaPlayback: true는 SDK 3.0.2 타입에 정식으로 있고 빌드된 .ait 안에 문자열이 들어간 것까지 확인했는데 앱에서 적용되지 않았다. playsInline, webkit-playsinline, controls={false}, disablePictureInPicture를 전부 걸어도 소용없었다. 웹뷰가 인라인 재생을 허용하지 않으면 태그 속성은 무시된다.

그래서 video를 설득하는 것을 포기하고 보이는 층을 태그째 바꿨다. video는 1px 크기에 투명도 0으로 화면에서 치우고, 매 프레임 drawImage로 캔버스에 그려 그 캔버스를 화면에 깐다. iOS가 video를 전체화면으로 끌고 가도 사용자가 보는 층은 캔버스라 HUD가 그대로 겹친다. 웹뷰가 손댈 수 있는 요소를 화면 밖으로 뺀 셈이다.

토스가 설정을 적용해서 풀린 것이 아니다. 웹뷰 쪽 원인은 그대로다.

그 뒤에 남은 문제는 화면이 아니라 재생이었다

전체화면 자체는 여전히 일어난다. 그리고 iOS가 숨겨진 video를 전체화면으로 가져가면 재생이 멈춘다. 재생이 멈추면 캔버스에 새 프레임이 안 들어와 화면이 얼어붙는다. 전체화면에서 빠져나오는 코드가 아직 필요한 이유가 여기 있고, 그 역할은 레이아웃이 아니라 재생 유지다.

빠져나오는 방식은 두 번 다듬었다. 한 번만 부르면 시점이 일러 안 먹고, 끝없이 부르면 들락날락하며 깜빡인다. 그래서 120ms 간격으로 최대 16회만 시도하고 빠져나오는 즉시 멈춘다. 나온 뒤의 재생도 딱 한 번만 부른다. 멈출 때마다 다시 재생하면 그 재생이 또 전체화면을 불러 왕복이 생기고, 그 사이 화면이 검게 된다. 스캔 도중에 검은 화면이 뜨던 원인이 이것이었다.

getUserMedia는 사용자 탭 핸들러의 첫 줄이어야 한다. 화면이 뜨자마자 자동으로 부르면 권한이 allowed여도 NotAllowedError다. 버튼 클릭 안에서 불러야 열리는데, 그 앞에 await가 하나라도 있으면 안 된다. SDK 동적 import나 권한 조회를 먼저 하면 그 사이에 사용자 동작 자격이 만료돼 역시 거부된다.

SDK 권한과 웹 API는 별개 통로다. getPermission({ name: 'camera' })allowed를 반환해도 웹 표준 getUserMedia와는 무관하다. SDK 권한은 네이티브 기능용이고, 권한이 허용인데 웹 API가 거부되는 상황이 정상적으로 발생한다.

스키마에 없는 키를 넣으면 설정 전체가 무시된다. 구버전 이름(webViewProps)을 함께 넣었더니 카메라가 아예 거부됐다. webView만 남기니 다시 열렸다. 모르는 키를 시험 삼아 넣으면 안 된다.

웹뷰에는 GPU 가속이 없다. ssdMobilenetv1(5.6MB)로 사진 한 장 검출에 10초가 넘게 걸렸다. tinyFaceDetector(190KB)와 tiny 랜드마크로 바꾸고 검출 루프를 120ms 간격으로 제한해야 쓸 만해진다. 매 프레임 돌리면 CPU를 다 먹어 버튼도 안 눌린다.

전면·후면 전환 버튼에서 검출 루프가 죽고 앞 카메라의 랜드마크가 화면에 남던 문제는 블로그에 따로 적었다. 카메라를 바꾸면 검출이 멈추는 이유가 두 가지였습니다

웹(사파리, Vercel 배포)에서는 이 문제들이 처음부터 없었다. 전부 토스 웹뷰 한정이다.

기술 스택

  • 프런트: Vite 8 · React 19 · TypeScript 7 · pnpm
  • 얼굴 인식: @vladmandic/face-api 1.7.15 (tinyFaceDetector + 68점 tiny 랜드마크 + FaceRecognitionNet)
  • 미니앱: @apps-in-toss/web-framework 3.0.2 · vite build && ait build.ait 번들
  • 랭킹: Firestore REST (SDK 미사용) · 키 없으면 localStorage 폴백
  • 결과 이미지: Canvas 2D 합성 (서버 업로드 없음)

왜 이 선택인가

  • face-api 온디바이스: 얼굴을 서버로 보내면 재미 앱이 개인정보 수집 앱이 된다. 추론을 전부 기기에서 돌리면 그 문제 자체가 사라진다.
  • tinyFaceDetector: 정확도가 아니라 웹뷰의 CPU 예산이 골랐다. 무거운 검출기는 한 장에 10초가 걸려 제품이 성립하지 않는다.
  • Firestore REST: 모델만 12MB인 번들에 SDK를 더 얹을 여유가 없었다. 필요한 호출이 둘뿐이라 REST로 충분하다.
  • 토스 이름을 안 받음: User.getConsentedData로 이름을 받아 오는 길이 있지만 콘솔 동의 항목 등록과 약관 웹뷰가 앞에 붙고 개인정보 수집 항목이 생겨 검수가 무거워진다. 카드의 이름 자리를 눌러 직접 적게 했고, 안 적는 사람은 단계가 늘지 않는다.

Decision Log

Decision #01계측 도구를 제품보다 먼저

기획서의 계산 방식이 성립하는지가 프로젝트 전체의 전제였다. 화면을 만들기 전에 여섯 전략을 한 번에 재는 킬테스트 페이지부터 붙였고, 그 결과로 전제가 부정됐다.

Decision #02해시·LSH 폐기

정면 부동자세 descriptor 거리 0.031에서도 해시 값이 전부 갈렸다. 128차원 중 한 축만 양자화 경계를 넘으면 출력 전체가 바뀐다. 양자화 폭을 4배로 키워도 같았다.

Decision #03연속 투영이 아니라 이목구비 가중합

연속 투영은 흔들림 0.65%로 안정적이었지만 "왜 높은가"를 설명할 수 없다. 68점 랜드마크에서 실제로 잴 수 있는 뼈대 특징만 써서 항목을 화면에 그대로 보여 준다.

Decision #04표정과 좌우 대칭은 측정에서 제외

표정을 넣으면 노려보기 대회가 되고 얼굴 자체가 무관해진다. 대칭은 뼈대보다 촬영 각도에 좌우돼 원시값이 거의 같은데 점수가 1과 10으로 튀었다.

Decision #05옆모습을 막는 대신 다른 기준으로 잰다

정면만 받으면 남을 찍는 상황에서 영영 측정이 안 된다. 정면·옆모습 구간을 나누고, 옆에서 잴 수 없는 골격은 비중을 0으로 두고 나머지에 나눠 준다.

Decision #06밈을 걷어내고 형식 하나로

밈은 아는 사람에게만 웃긴다. 모르는 사람에게는 이상한 문장일 뿐이라 {비교 대상} 와 동급 또는 그 이상. 한 형식으로 고정하고, 웃음을 스케일이 아니라 과한 구체성에서 냈다.

Decision #07랭킹은 SDK 없이 REST, 키 없으면 로컬

모델 12MB짜리 번들에 Firebase SDK를 얹을 여유가 없다. 키가 없어도 화면이 완성되게 로컬 폴백을 두고, 올라가는 값은 닉네임·숫자·등급·시각으로 제한했다.

Decision #08보이는 층을 video에서 canvas로

웹뷰가 <video>를 전체화면으로 가져가는 것을 태그 속성으로는 막을 수 없었다. video를 화면 밖으로 치우고 캔버스에 프레임을 그려 그 캔버스를 보여 준다. 웹뷰가 손댈 수 있는 요소를 화면에서 없앤 것이다.

아직 못 잰 것

사람이 달라도 값이 갈리는지를 충분히 안 쟀다. 한 사람의 재측정 안정성은 실측으로 확인했지만, 개인 간 변별력은 3인 표본이 전부다. 얼굴 특징값들이 좁은 영역에 뭉쳐 있으면 사람이 달라도 결과가 비슷해져 전원이 같은 등급으로 몰릴 수 있다. 3인 실측에서 가중합이 0.59~0.72로 붙었고, 지금은 관측 중심을 기준으로 폭을 넓혀 등급이 갈리게 해 두었다. 사람이 늘면 그 중심과 폭을 실측 평균과 표준편차로 다시 잡아야 한다.

features.ts의 항목별 구간(min~max)도 같은 이유로 잠정치다. 등급 경계도 마찬가지다. 둘 다 계산 구조를 바꾸는 일이 아니라 상수를 다시 잡는 일이다.

회고

이 프로젝트에서 실제로 한 것은 두 가지다. 하나는 전제를 코드보다 먼저 실측해서 기각한 것이고, 다른 하나는 안정성 검사를 통과한 방식조차 설명 가능성 때문에 버린 것이다. 연속 투영은 숫자로는 이겼지만 사용자에게 보여 줄 것이 없었다. 이목구비 가중합은 재측정 안정성이 연속 투영보다 나쁠 여지가 있는 대신 화면에 네 항목을 그대로 펼칠 수 있다.

세 번째는 웹뷰 문제를 푼 방식이다. 처음에는 <video>를 인라인으로 붙들어 두려고 속성을 하나씩 걸어 봤고 전부 무시당했다. 웹뷰가 그 태그를 가져가겠다고 정했으면 태그 안에서 이길 방법이 없다. 보이는 층을 캔버스로 옮기고 나서야 풀렸다. 원인을 없애지 못할 때는 원인이 닿는 표면을 옮기는 쪽이 빠르다.

앱인토스 바이브코딩 챌린지 참가 신청 중이다. 심사 결과에 대해서는 말할 것이 없다.