넥시클
경력근거: 실시간 XR 스트림 모니터링 및 제어를 위한 백엔드 및 DB 구현
※ 실제 산출물이 아닌 AI 예측 시안입니다.
넥시클은 ControlXR 로 교육·산업·의료 훈련 현장의 다중 사용자 XR 운영을 맡고, 이 자리에 실시간 XR 스트림 모니터링 및 제어를 위한 백엔드 및 DB 구현과 운영자용 웹 대시보드 개발을 맡깁니다. 여러 교육생이 같은 시각에 헤드셋을 쓰는 훈련은 디바이스 한 대가 멈추면 그 조 전체가 기다리므로, 디바이스 보고와 스트림 통계를 훈련 일정에 겹쳐 보는 판정은 XR 다중 사용자 운영 플랫폼 위에서 의미가 섭니다. 이 포트폴리오는 Spring 백엔드에 손으로 짠 판정 규칙, React Router 운영자 대시보드, WebRTC 수신 통계를 모으는 Python 사이드카 구성을 보여줍니다. XR 디바이스 펌웨어나 영상 코덱 튜닝 전문성은 주장하지 않습니다.
공고는 이 자리에 실시간 XR 스트림 모니터링 및 제어를 위한 백엔드 및 DB 구현과 운영자용 웹 대시보드 개발을 함께 맡깁니다. 디바이스 보고와 스트림 통계를 받아 훈련 일정에 겹쳐 계산하고, 운영자가 그 결과로 디바이스를 고르는 판정판은 두 업무가 만나는 자리입니다.
사내에서 Codex 를 쓸 수 있지만 AI 가 만든 코드를 판단할 기본기가 있어야 한다고 적었습니다. 배터리 소모량, 버전 비교, 스트림 중앙값, 무응답 시간을 따지는 판정 규칙을 AI 도구 없이 손으로 짜고 단위 테스트로 고정하는 1주차 구성이 이 요구에 닿습니다.
우대사항에 WebRTC·ffmpeg 이해와 네트워크 처리 경험이 있고, 주요업무에는 XR 디바이스와 스트리밍 서버 간 연동 구조 개발이 있습니다. 2주차에 ffmpeg 로 테스트 영상을 WebRTC 로 내보내고 수신 통계를 Python 사이드카로 모아, 화면 스트림 끊김 판정을 더미가 아닌 실측으로 돌리는 단계가 여기에 닿습니다.
ControlXR 은 넥시클이 교육·산업·의료 훈련 기관에 파는 XR 운영 플랫폼이고, 이 화면은 그 운영자용 웹 대시보드에 붙는 훈련 시작 전 준비판입니다. 디바이스 상태 보고(배터리 잔량과 설치된 콘텐츠 버전), 화면 스트림 수신 통계(fps 와 끊긴 프레임), 훈련 일정, 세션별 디바이스 배정 네 표를 받아 세션과 디바이스 한 쌍마다 다섯 판정 중 하나를 붙입니다. 최근 30분 배터리 감소폭으로 분당 소모량을 내고 훈련 시작까지 대기하며 닳을 몫을 뺀 뒤 남은 잔량으로 버틸 분을 훈련 길이와 견주고, 설치 버전을 그 세션이 요구하는 버전과 견주고, 최근 5분 fps 중앙값과 끊긴 프레임 비율을 내고, 마지막 보고 뒤 지난 시간을 셉니다. 기관 운영자와 강사는 훈련 직전에 이 판정으로 어느 디바이스를 충전하거나 바꾸고 어느 디바이스에 콘텐츠를 다시 내려받을지 정합니다.
XR 디바이스 수십 대를 교육생에게 나눠 주고 훈련을 진행하는 기관 운영자·강사와, 그 기관의 문의를 받는 넥시클 운영 지원 담당자. 같은 운영자용 웹 대시보드를 보고 어느 디바이스를 충전·교체하고 어느 디바이스에 콘텐츠를 다시 내려받을지 나눈다
지금 기관 운영자는 훈련 직전에 헤드셋을 하나씩 켜 배터리 숫자와 콘텐츠 화면을 눈으로 확인합니다. 배터리 31% 가 80분 훈련에 충분한지는 그 디바이스가 지금 얼마나 빨리 닳고 시작까지 몇 분을 더 기다리는지 알아야 정해지고, 설치된 버전이 이 세션의 요구 버전인지는 훈련 일정표를 따로 펼쳐야 보입니다. ControlXR 이 받는 디바이스 보고와 스트림 통계를 세션 일정에 겹쳐 계산하면, 훈련이 시작되기 전에 도중에 멈출 디바이스를 사유별로 골라냅니다.
기관 운영자가 디바이스 보고·스트림 통계·훈련 일정·세션 배정 네 CSV 를 올리고 세션 하나를 고르면, 디바이스마다 다섯 판정 중 하나와 버틸 분·훈련 분, 설치 버전·요구 버전, fps 중앙값·끊긴 프레임 비율, 마지막 응답 시각이 서는 React Router 화면입니다. 소모량·버전·스트림·무응답을 따지는 판정 규칙은 AI 도구 없이 Spring 으로 손으로 짜고 단위 테스트로 고정합니다. 이 주에는 헤드셋을 한 대도 켜지 않고, 상태 보고 13줄과 스트림 통계 17줄을 ControlXR 수신 형식에 맞춰 손으로 지어 판정판을 돌립니다.
Python 사이드카가 ffmpeg 로 내보낸 테스트 영상을 WebRTC 로 받아 getStats 의 framesPerSecond 와 framesDropped 를 2분 단위로 stream_stats 에 넣고, 리눅스 tc 로 대역을 좁힌 12구간과 풀어 둔 12구간을 번갈아 흘려, 좁힌 구간에서만 화면 스트림이 끊김 판정이 켜지는지 실측 스트림 탭에 시간 순서로 세웁니다. 판정은 1분마다 다시 돌아 PostgreSQL 에 쌓입니다. 전부 Docker Compose 로 묶어 Linux 서버 한 대에 띄웁니다.
Kotlin 으로 짠 안드로이드 앱이 기기 배터리와 설치된 콘텐츠 버전을 1분마다 보고해, 더미가 아닌 실제 기기 한 대가 판정판에 섭니다. 강사가 세션을 열면 걸린 디바이스의 판정 사유를 Claude 가 충전·교체·재설치 조치 메모 한 단락으로 요약하고, 메모의 디바이스 번호와 판정 이름이 판정표와 같은지 규칙으로 다시 봅니다. 판정이 바뀐 디바이스는 Node.js 게이트웨이가 SSE 로 대시보드에 바로 밀어줍니다.
운영자가 ○○소방학교 화재진압 훈련(10-06 14:00~15:20)을 고르면 XR-07 이 분당 0.6% 로 닳아 14:00 시작 때 24.4% 만 남고 40.7분만 버텨 배터리 묶음에, XR-12 가 설치 v2.3.1 로 버전 묶음에, XR-15 가 fps 중앙값 18·끊긴 프레임 9.0% 로 스트림 묶음에, XR-21 이 13:41 뒤 8분째 말이 없어 응답 없음 묶음에 서는 과정을 시연합니다.
강사가 XR-12 를 펼치면 같은 기기가 16:00 인명구조 훈련(요구 v2.3.1)에서는 시작 가능으로 서 있어, 화재진압 훈련 자리에만 예비 기기를 넣습니다.
마지막 보고의 배터리 값이 비어 있는 디바이스, 대기 소모분을 뺀 버틸 분이 훈련 길이와 똑같은 디바이스, fps 중앙값이 정확히 30 이거나 끊긴 프레임이 정확히 5% 인 디바이스, 상태 보고가 한 줄도 없는 디바이스를 한 세션에 넣어도 디바이스마다 판정 하나가 정해집니다. 잔량을 「31%」처럼 퍼센트 기호째 보내는 구형 클라이언트 보고가 섞여도 그 보고만 소모량 계산에서 빠지고, 그 타일 모서리에 빠진 보고 시각이 찍힙니다.
2주차 실측 스트림 탭은 tc 로 대역을 좁힌 12구간과 풀어 둔 12구간을 시간 순서로 세우고, 좁힌 구간마다 화면 스트림이 끊김 판정이 몇 분 뒤에 켜졌는지 보여줍니다.
위 수치는 더미데이터 자가채점이며, 이 판정으로 훈련 도중 멈추는 디바이스가 실제로 줄어드는지는 넥시클 ControlXR 고객 기관의 디바이스 보고와 훈련 기록이 있어야 측정됩니다. ControlXR 의 실제 보고 주기가 1분이 아니면 30분·5분 창 길이만 고쳐 같은 타일 판정판에 얹습니다.
기대 출력 8건과 tc 로 만든 끊김 12구간은 훈련을 운영해 본 강사가 아니라 제가 지었으므로, 판정이 문서대로 나와도 그 디바이스가 실제 훈련에서 멈췄을지는 모릅니다. 면접에서 보여줄 수 있는 범위는 배터리 값이 빈 XR-03 이나 대기 소모분을 빼고 딱 80분을 버티는 XR-24 가 어느 묶음으로 가는지입니다.
fps 중앙값이 정확히 30 인 XR-03 은 '30 미만'을 '30 이하'로 잘못 쓰면 스트림 묶음으로 넘어가고, 마지막 응답이 정확히 3분 전인 XR-15 는 '넘으면'을 '이상'으로 쓰면 응답 없음이 됩니다. 16:00 훈련의 XR-24 는 버틸 분을 0 에서 막지 않으면 −40분이 화면에 찍히는 디바이스라, 이 셋이 판정 규칙의 부등호와 하한을 지키는 자리입니다.
대기 소모분은 판정 시각의 소모율이 시작 시각까지 그대로 간다고 보고 뺍니다. 16:00 인명구조 훈련처럼 두 시간 넘게 남은 세션은 그사이 14:00 화재진압 훈련에서 같은 기기를 쓰는 몫을 모르므로, XR-12 의 131분 대기는 대기 소모율로만 빠져 있습니다. 같은 기기가 연달아 배정된 세션은 앞 세션이 끝난 뒤 판정판을 다시 돌리고, 앞 세션 사용분을 미리 빼는 계산은 실제 기관의 배정 방식을 본 뒤 정할 도입 과제로 둡니다.
분당 소모량은 30분 창의 첫 값과 끝 값 두 개로만 냅니다. 대기 화면에서 훈련 콘텐츠로 넘어가면 소모가 몇 배로 뛸 수 있어, 대기 중 소모량으로 낸 버틸 분은 넉넉하게 나옵니다. 콘텐츠별 소모량 계수는 실제 기종과 콘텐츠 기록을 본 뒤 정합니다.
버전 비교는 앞의 v 와 공백만 지우고 나머지는 글자 그대로 견줍니다. v2.4.0 과 v2.4.0-hotfix1 처럼 같은 훈련에 써도 되는 빌드를 다르다고 판정할 수 있어, 호환 빌드 목록을 둘지는 넥시클 콘텐츠 배포 방식을 본 뒤 정합니다.
① 인라인 샘플 · ② seed.json 다운로드 · ③ 증강 프롬프트. 세 층 모두 동일한 schema 에서 파생.
ControlXR 에 연결된 XR 디바이스가 1분마다 보내는 상태 보고입니다. battery_pct 는 그 순간 잔량이고, content_version 은 디바이스에 설치된 훈련 콘텐츠 빌드를 디바이스가 보낸 그대로 둡니다. 인라인 샘플은 판정에 쓰이는 30분 창의 첫 보고와 마지막 보고만 싣습니다(사이 보고는 첫 값과 끝 값으로 내는 감소폭을 바꾸지 않습니다). 센서 읽기에 실패한 보고는 battery_pct 가 null 이고, 오래된 클라이언트는 버전 앞에 v 를 붙이지 않습니다. 값은 작성자가 만든 더미입니다.
| device_id | reported_at | battery_pct | content_version |
|---|---|---|---|
| XR-03 | 2026-10-06T13:19:00+09:00 | 96 | v2.4.0 |
| XR-03 | 2026-10-06T13:48:00+09:00 | 90 | v2.4.0 |
| XR-03 | 2026-10-06T13:49:00+09:00 | — | v2.4.0 |
| XR-07 | 2026-10-06T13:19:00+09:00 | 49 | v2.4.0 |
| XR-07 | 2026-10-06T13:49:00+09:00 | 31 | v2.4.0 |
아래 JSON Schema 에 맞는 XR 디바이스 상태 보고 행을 더 만들어 주세요. {"type":"object","properties":{"device_id":{"type":"string","description":"ControlXR 에 등록된 XR 디바이스 번호입니다."},"reported_at":{"type":"string","format":"date-time","description":"디바이스가 상태 보고를 보낸 시각입니다."},"battery_pct":{"type":["integer","null"],"minimum":0,"maximum":100,"description":"보고 순간의 배터리 잔량(%)입니다. 센서 읽기에 실패한 보고는 null 입니다."},"content_version":{"type":["string","null"],"description":"디바이스에 설치된 훈련 콘텐츠 빌드 문자열을 디바이스가 보낸 그대로 둡니다. 오래된 클라이언트는 앞에 v 를 붙이지 않습니다."}},"required":["device_id","reported_at","battery_pct","content_version"]} 디바이스 6~24대가 1분마다 보고하는 30~60분치를 만들고, 배터리는 대기 중 분당 0.1~0.3%, 훈련 콘텐츠 실행 중 분당 0.5~1.0% 씩 줄게 합니다. content_version 은 v2.3.1·v2.4.0 처럼 세 자리 빌드로 씁니다. 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) battery_pct 가 null 인 보고, (2) 충전기에 꽂혀 잔량이 오르는 디바이스, (3) 앞에 v 가 없는 버전 문자열, (4) 몇 분째 보고가 끊긴 디바이스, (5) 30분 창 안에 보고가 하나뿐인 디바이스. 생성한 행은 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"device_id":{"type":"string","description":"ControlXR 에 등록된 XR 디바이스 번호입니다."},"reported_at":{"type":"string","format":"date-time","description":"디바이스가 상태 보고를 보낸 시각입니다."},"battery_pct":{"type":["integer","null"],"minimum":0,"maximum":100,"description":"보고 순간의 배터리 잔량(%)입니다. 센서 읽기에 실패한 보고는 null 입니다."},"content_version":{"type":["string","null"],"description":"디바이스에 설치된 훈련 콘텐츠 빌드 문자열을 디바이스가 보낸 그대로 둡니다. 오래된 클라이언트는 앞에 v 를 붙이지 않습니다."}},"required":["device_id","reported_at","battery_pct","content_version"]}ControlXR 이 디바이스 화면 스트림을 받으며 2분마다 남기는 수신 통계입니다. 한 줄은 measured_at 직전 2분(120초)을 덮고, fps 는 그동안 운영자 화면에 실제로 표시된 평균 프레임률, dropped_frames 는 같은 2분 동안 버려진 프레임 수입니다. 그 구간 전체 프레임은 fps × 120 + dropped_frames 로 셉니다. 버려진 프레임이 0 인 구간과 fps 중앙값이 정확히 30 에 걸리는 디바이스를 일부러 담았습니다. 값은 작성자가 만든 더미입니다.
| device_id | measured_at | fps | dropped_frames |
|---|---|---|---|
| XR-03 | 2026-10-06T13:45:00+09:00 | 30 | 0 |
| XR-03 | 2026-10-06T13:47:00+09:00 | 31 | 12 |
| XR-03 | 2026-10-06T13:49:00+09:00 | 29 | 6 |
| XR-07 | 2026-10-06T13:45:00+09:00 | 72 | 420 |
| XR-07 | 2026-10-06T13:47:00+09:00 | 72 | 610 |
아래 JSON Schema 에 맞는 화면 스트림 수신 통계 행을 더 만들어 주세요. {"type":"object","properties":{"device_id":{"type":"string","description":"화면 스트림을 보낸 XR 디바이스 번호입니다."},"measured_at":{"type":"string","format":"date-time","description":"집계 구간이 끝난 시각입니다. 한 줄은 이 시각 직전 2분(120초)을 덮습니다."},"fps":{"type":"number","minimum":0,"description":"그 2분 동안 운영자 화면에 실제로 표시된 평균 프레임률입니다."},"dropped_frames":{"type":"integer","minimum":0,"description":"그 2분 동안 늦게 와서 버려진 프레임 수입니다. 구간 전체 프레임은 fps × 120 + dropped_frames 로 셉니다."}},"required":["device_id","measured_at","fps","dropped_frames"]} 디바이스마다 2분 간격으로 30분치를 만들고, 정상 디바이스는 fps 55~72, dropped_frames 0~40 사이에 둡니다. 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) fps 중앙값이 정확히 30 인 디바이스, (2) 끊긴 프레임 비율이 정확히 5% 인 5분 창, (3) dropped_frames 가 0 인 구간, (4) 무선망이 나빠 fps 가 15~25 로 떨어진 디바이스, (5) 몇 분 전에 통계가 끊긴 디바이스. 생성한 행은 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"device_id":{"type":"string","description":"화면 스트림을 보낸 XR 디바이스 번호입니다."},"measured_at":{"type":"string","format":"date-time","description":"집계 구간이 끝난 시각입니다. 한 줄은 이 시각 직전 2분(120초)을 덮습니다."},"fps":{"type":"number","minimum":0,"description":"그 2분 동안 운영자 화면에 실제로 표시된 평균 프레임률입니다."},"dropped_frames":{"type":"integer","minimum":0,"description":"그 2분 동안 늦게 와서 버려진 프레임 수입니다. 구간 전체 프레임은 fps × 120 + dropped_frames 로 셉니다."}},"required":["device_id","measured_at","fps","dropped_frames"]}기관이 ControlXR 에 잡아 둔 XR 훈련 일정입니다. required_version 은 그 훈련 시나리오가 요구하는 콘텐츠 빌드이고, 같은 기관이라도 훈련마다 요구 버전이 다릅니다. 값은 작성자가 만든 더미입니다.
| session_id | org_name | title | starts_at | ends_at | required_version |
|---|---|---|---|---|---|
| TS-1006-01 | ○○소방학교 | 화재진압 XR 훈련 | 2026-10-06T14:00:00+09:00 | 2026-10-06T15:20:00+09:00 | v2.4.0 |
| TS-1006-02 | ○○소방학교 | 인명구조 XR 훈련 | 2026-10-06T16:00:00+09:00 | 2026-10-06T16:40:00+09:00 | v2.3.1 |
| TS-1007-01 | △△대학병원 술기교육센터 | 기도삽관 XR 실습 | 2026-10-07T09:30:00+09:00 | 2026-10-07T10:30:00+09:00 | v1.8.2 |
| TS-1007-02 | △△대학병원 술기교육센터 | 심폐소생술 XR 실습 | 2026-10-07T13:00:00+09:00 | 2026-10-07T13:45:00+09:00 | v1.8.2 |
| TS-1008-01 | □□중공업 안전교육팀 | 밀폐공간 용접 안전 XR 교육 | 2026-10-08T10:00:00+09:00 | 2026-10-08T11:30:00+09:00 | v3.0.0 |
아래 JSON Schema 에 맞는 XR 훈련 세션 행을 더 만들어 주세요. {"type":"object","properties":{"session_id":{"type":"string","description":"ControlXR 에 잡힌 훈련 세션 번호입니다."},"org_name":{"type":"string","description":"훈련을 여는 기관 이름입니다."},"title":{"type":"string","description":"훈련 시나리오 이름입니다."},"starts_at":{"type":"string","format":"date-time","description":"훈련 시작 시각입니다."},"ends_at":{"type":"string","format":"date-time","description":"훈련 종료 시각입니다."},"required_version":{"type":"string","description":"이 훈련 시나리오가 요구하는 콘텐츠 빌드입니다."}},"required":["session_id","org_name","title","starts_at","ends_at","required_version"]} 소방학교·대학병원 술기교육센터·중공업 안전교육팀·고등학교 같은 기관이 하루 1~3개 세션을 여는 일주일치를 만들고, 훈련 길이는 30~120분으로 둡니다. 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) 같은 기관의 연달아 붙은 두 세션이 서로 다른 요구 버전을 쓰는 경우, (2) 같은 시나리오인데 날짜에 따라 요구 버전이 올라간 경우, (3) 훈련 길이가 90분을 넘는 긴 세션. 생성한 행은 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"session_id":{"type":"string","description":"ControlXR 에 잡힌 훈련 세션 번호입니다."},"org_name":{"type":"string","description":"훈련을 여는 기관 이름입니다."},"title":{"type":"string","description":"훈련 시나리오 이름입니다."},"starts_at":{"type":"string","format":"date-time","description":"훈련 시작 시각입니다."},"ends_at":{"type":"string","format":"date-time","description":"훈련 종료 시각입니다."},"required_version":{"type":"string","description":"이 훈련 시나리오가 요구하는 콘텐츠 빌드입니다."}},"required":["session_id","org_name","title","starts_at","ends_at","required_version"]}훈련 세션마다 교육생에게 나눠 줄 디바이스 배정표입니다. 같은 디바이스가 하루에 두 세션에 배정되기도 하고, XR-30 처럼 배정표에는 있는데 상태 보고가 한 번도 들어온 적 없는 새 디바이스도 있습니다. 값은 작성자가 만든 더미입니다.
| session_id | device_id | seat |
|---|---|---|
| TS-1006-01 | XR-03 | 1조-1 |
| TS-1006-01 | XR-07 | 1조-2 |
| TS-1006-01 | XR-12 | 1조-3 |
| TS-1006-01 | XR-15 | 2조-1 |
| TS-1006-01 | XR-21 | 2조-2 |
아래 JSON Schema 에 맞는 세션별 디바이스 배정 행을 더 만들어 주세요. {"type":"object","properties":{"session_id":{"type":"string","description":"training_sessions 의 세션 번호입니다."},"device_id":{"type":"string","description":"그 세션 교육생에게 나눠 줄 디바이스 번호입니다."},"seat":{"type":"string","description":"강사가 정한 교육생 자리 이름입니다."}},"required":["session_id","device_id","seat"]} 세션 하나에 디바이스 4~24대를 배정하고 seat 는 「1조-3」·「A-4」처럼 강사가 부르는 자리 이름으로 씁니다. 쉬운 데이터만 만들지 말 것 — 다음 하드 케이스를 의도적으로 섞어라: (1) 같은 날 연달아 열리는 두 세션에 같이 배정된 디바이스, (2) 상태 보고 표에 한 번도 나오지 않는 새 디바이스 번호. 생성한 행은 위 스키마를 통과해야 하며, 형식을 깨뜨리는 시도는 별도로 3건만 표시해 두세요.{"type":"object","properties":{"session_id":{"type":"string","description":"training_sessions 의 세션 번호입니다."},"device_id":{"type":"string","description":"그 세션 교육생에게 나눠 줄 디바이스 번호입니다."},"seat":{"type":"string","description":"강사가 정한 교육생 자리 이름입니다."}},"required":["session_id","device_id","seat"]}| Java Spring | 디바이스 보고·스트림 통계·훈련 일정·배정표를 받아 저장하고, 세션과 디바이스 한 쌍마다 판정을 계산해 내주는 API 서버입니다. |
| TypeScript | 운영자 대시보드와 3주차 SSE 게이트웨이 코드의 언어입니다. |
| React Router | 세션 목록, 세션별 디바이스 판정판, 디바이스 펼침, 2주차 실측 스트림 탭을 라우트로 나눠 그립니다. |
| Node.js | 3주차에 판정이 바뀐 디바이스를 대시보드로 밀어주는 SSE 게이트웨이입니다. |
| Python | 2주차 사이드카입니다. WebRTC 수신 통계를 2분 단위로 묶어 stream_stats 로 올립니다. |
| WebRTC | 사이드카가 수신 쪽 getStats 에서 framesPerSecond 와 framesDropped 를 읽어 화면 스트림 끊김을 실제로 잽니다. |
| ffmpeg | 2주차에 정해진 프레임률로 테스트 영상을 내보내는 송신기입니다. |
| Docker | Spring API·PostgreSQL·Python 사이드카·대시보드를 Compose 하나로 묶습니다. |
| Linux | 2주차부터 Compose 묶음을 띄우는 서버이고, tc 로 대역을 좁혀 스트림 끊김을 재현합니다. |
| Database | PostgreSQL 에 네 입력 표와 1분마다 다시 돈 판정 이력을 담습니다. |
| Kotlin | 3주차 안드로이드 보고 앱입니다. BatteryManager 잔량과 설치된 콘텐츠 버전을 1분마다 보냅니다. |
| 함수형 프로그래밍 | 판정 함수를 네 표와 판정 시각만 받아 결과를 돌려주는 순수 함수로 둬, 같은 입력이면 언제 돌려도 같은 판정이 나옵니다. |
| Claude Sonnet | 3주차에 세션마다 걸린 디바이스의 판정 사유를 강사용 조치 메모로 요약합니다. |
✗ fail → 강사가 응답 없음 타일보다 배터리 부족 타일을 먼저 집어 들면, 예비 헤드셋으로 바꿀 디바이스와 전원만 다시 켤 디바이스를 타일 위 표시로 나눕니다.
✓ pass → 실제 WebRTC 스트림을 흘리는 Python 사이드카로 2주차를 엽니다.
✗ fail → 좁힌 구간인데 판정이 늦게 켜지면 2분 집계 단위를 1분으로 줄여 같은 구간을 다시 흘립니다.
✓ pass → 실제 안드로이드 기기 보고와 강사용 조치 메모로 3주차를 엽니다.
위 예측 UI 는 3주차까지 완주했을 때의 화면입니다. 1주차에 실제로 만드는 것은 아래 표시된 화면 하나입니다.
verdict사유 라벨 · device_id + 한 줄 요약(근거 수치 포함)화면·코드·면접에서 이 단어를 그대로 씁니다. 키는 출력 스키마의 enum 과 같습니다.
훈련 시작 가능시작 가능배터리가 훈련 끝까지 못 감배터리 부족훈련 콘텐츠 버전이 다름버전 다름화면 스트림이 끊김스트림 끊김디바이스 응답 없음응답 없음세션과 디바이스 한 쌍이 무응답 시간, 배터리 소모량과 버틸 분, 버전 비교, 5분 스트림 집계를 거쳐 어떤 판정을 받는지 남기는 형식입니다. 판정은 문턱과 차례가 정하고, 3주차 강사용 조치 메모만 모델이 씁니다.
{"type":"object","properties":{"session_id":{"type":"string"},"device_id":{"type":"string"},"checked_at":{"type":"string","format":"date-time","description":"판정을 돌린 시각입니다. 모든 창은 이 시각을 끝으로 잡습니다."},"verdict":{"type":"string","enum":["훈련 시작 가능","배터리가 훈련 끝까지 못 감","훈련 콘텐츠 버전이 다름","화면 스트림이 끊김","디바이스 응답 없음"]},"also_flagged":{"type":"array","items":{"type":"string","enum":["배터리가 훈련 끝까지 못 감","훈련 콘텐츠 버전이 다름","화면 스트림이 끊김","디바이스 응답 없음"]},"description":"판정 차례에서 밀려 verdict 에 오르지 못한 나머지 판정입니다."},"last_reported_at":{"type":["string","null"],"format":"date-time","description":"그 device_id 의 가장 늦은 device_heartbeats.reported_at 입니다. 보고가 한 줄도 없으면 null 입니다."},"silence_min":{"type":["number","null"],"minimum":0,"description":"checked_at − last_reported_at 을 분으로 센 값입니다."},"battery_window_start":{"type":["integer","null"],"minimum":0,"maximum":100,"description":"checked_at 앞 30분 창에서 battery_pct 가 null 이 아닌 첫 보고의 잔량입니다."},"battery_now":{"type":["integer","null"],"minimum":0,"maximum":100,"description":"같은 창에서 battery_pct 가 null 이 아닌 마지막 보고의 잔량입니다."},"window_minutes":{"type":["number","null"],"minimum":0,"description":"위 두 보고 사이의 분입니다."},"drain_per_min":{"type":["number","null"],"description":"(battery_window_start − battery_now) ÷ window_minutes 를 소수 둘째 자리로 반올림한 분당 소모량입니다."},"wait_min":{"type":"number","minimum":0,"description":"training_sessions.starts_at − checked_at 을 분으로 센 대기 시간입니다. 이미 시작한 세션이면 0 입니다."},"battery_at_start":{"type":["number","null"],"minimum":0,"maximum":100,"description":"battery_now − 분당 소모량 × wait_min 입니다. 대기하는 동안에도 최근 30분과 같은 속도로 닳는다고 보고, 0 아래면 0 입니다."},"minutes_left":{"type":["number","null"],"minimum":0,"description":"battery_at_start ÷ 반올림 전 분당 소모량, 곧 battery_now ÷ 분당 소모량 − wait_min 입니다. 훈련이 시작된 뒤 버틸 분이며, battery_at_start 가 0 이면(시작 전에 방전) 음수 대신 0 이고, 소모량이 0 이하(충전 중)면 null 입니다."},"session_minutes":{"type":"number","minimum":0,"description":"training_sessions.ends_at − starts_at 을 분으로 센 훈련 길이입니다."},"content_version":{"type":["string","null"],"description":"content_version 이 null 이 아닌 가장 늦은 보고의 값입니다."},"required_version":{"type":"string"},"fps_median_5m":{"type":["number","null"],"minimum":0,"description":"checked_at 앞 5분 안에 끝난 stream_stats 구간들의 fps 중앙값입니다. 구간이 없으면 null 입니다."},"dropped_frames_5m":{"type":["integer","null"],"minimum":0,"description":"같은 구간들의 dropped_frames 합입니다."},"total_frames_5m":{"type":["integer","null"],"minimum":0,"description":"같은 구간들의 fps × 120 + dropped_frames 합입니다."},"drop_ratio_pct":{"type":["number","null"],"minimum":0,"maximum":100,"description":"dropped_frames_5m ÷ total_frames_5m × 100 을 소수 둘째 자리로 반올림한 값입니다."},"evidence":{"type":"array","items":{"type":"object","properties":{"field":{"type":"string"},"agg":{"type":["string","null"],"enum":["count","sum","avg","min","max",null]},"value":{}},"required":["field","value"]}},"summary":{"type":"string"},"hedge":{"type":["string","null"]}},"required":["session_id","device_id","checked_at","verdict","also_flagged","last_reported_at","silence_min","battery_window_start","battery_now","window_minutes","drain_per_min","wait_min","battery_at_start","minutes_left","session_minutes","content_version","required_version","fps_median_5m","dropped_frames_5m","total_frames_5m","drop_ratio_pct","evidence","summary"]}[
{
"session_id": "TS-1006-01",
"device_id": "XR-07",
"checked_at": "2026-10-06T13:49:00+09:00",
"verdict": "배터리가 훈련 끝까지 못 감",
"also_flagged": [
"화면 스트림이 끊김"
],
"last_reported_at": "2026-10-06T13:49:00+09:00",
"silence_min": 0,
"battery_window_start": 49,
"battery_now": 31,
"window_minutes": 30,
"drain_per_min": 0.6,
"wait_min": 11,
"battery_at_start": 24.4,
"minutes_left": 40.7,
"session_minutes": 80,
"content_version": "v2.4.0",
"required_version": "v2.4.0",
"fps_median_5m": 72,
"dropped_frames_5m": 1610,
"total_frames_5m": 27290,
"drop_ratio_pct": 5.9,
"evidence": [
{
"field": "battery_pct",
"value": 49
},
{
"field": "battery_pct",
"value": 31
},
{
"field": "reported_at",
"value": "2026-10-06T13:49:00+09:00"
},
{
"field": "starts_at",
"value": "2026-10-06T14:00:00+09:00"
},
{
"field": "ends_at",
"value": "2026-10-06T15:20:00+09:00"
},
{
"field": "dropped_frames",
"agg": "sum",
"value": 1610
}
],
"summary": "XR-07 은 최근 30분 동안 49% 에서 31% 로 분당 0.6% 씩 닳아 14:00 시작 때 24.4% 가 남고, 그걸로 40.7분만 버텨 80분 화재진압 훈련의 41분째쯤 꺼집니다.",
"hedge": "최근 5분 끊긴 프레임도 1,610장으로 전체의 5.9% 라 화면 스트림이 끊김이 함께 걸렸습니다. 배터리를 갈아 끼운 뒤에도 끊김이 남는지 다시 판정합니다."
},
{
"session_id": "TS-1006-01",
"device_id": "XR-12",
"checked_at": "2026-10-06T13:49:00+09:00",
"verdict": "훈련 콘텐츠 버전이 다름",
"also_flagged": [],
"last_reported_at": "2026-10-06T13:49:00+09:00",
"silence_min": 0,
"battery_window_start": 92,
"battery_now": 87,
"window_minutes": 30,
"drain_per_min": 0.17,
"wait_min": 11,
"battery_at_start": 85.2,
"minutes_left": 511,
"session_minutes": 80,
"content_version": "v2.3.1",
"required_version": "v2.4.0",
"fps_median_5m": 57,
"dropped_frames_5m": 1080,
"total_frames_5m": 21600,
"drop_ratio_pct": 5,
"evidence": [
{
"field": "content_version",
"value": "v2.3.1"
},
{
"field": "required_version",
"value": "v2.4.0"
},
{
"field": "session_id",
"value": "TS-1006-01"
}
],
"summary": "XR-12 에는 v2.3.1 이 설치돼 있는데 화재진압 훈련은 v2.4.0 을 요구해, 콘텐츠를 다시 내려받아야 이 훈련에 쓸 수 있습니다.",
"hedge": "최근 5분 끊긴 프레임 비율이 정확히 5.00% 라 5% 를 넘지 않아 스트림 판정에는 걸리지 않았습니다."
},
{
"session_id": "TS-1006-01",
"device_id": "XR-15",
"checked_at": "2026-10-06T13:49:00+09:00",
"verdict": "화면 스트림이 끊김",
"also_flagged": [],
"last_reported_at": "2026-10-06T13:46:00+09:00",
"silence_min": 3,
"battery_window_start": 84,
"battery_now": 78,
"window_minutes": 26,
"drain_per_min": 0.23,
"wait_min": 11,
"battery_at_start": 75.5,
"minutes_left": 327,
"session_minutes": 80,
"content_version": "v2.4.0",
"required_version": "v2.4.0",
"fps_median_5m": 18,
"dropped_frames_5m": 688,
"total_frames_5m": 7648,
"drop_ratio_pct": 9,
"evidence": [
{
"field": "fps",
"value": 18
},
{
"field": "fps",
"value": 24
},
{
"field": "fps",
"value": 16
},
{
"field": "dropped_frames",
"agg": "sum",
"value": 688
},
{
"field": "reported_at",
"value": "2026-10-06T13:46:00+09:00"
}
],
"summary": "XR-15 는 최근 5분 화면 스트림의 fps 중앙값이 18 로 30 에 못 미치고, 끊긴 프레임이 688장으로 전체 7,648장의 9.0% 입니다.",
"hedge": "마지막 상태 보고가 13:46 으로 판정 시각에서 정확히 3분 전이라, 3분을 넘지 않아 응답 없음으로 세지 않았습니다."
},
{
"session_id": "TS-1006-01",
"device_id": "XR-03",
"checked_at": "2026-10-06T13:49:00+09:00",
"verdict": "훈련 시작 가능",
"also_flagged": [],
"last_reported_at": "2026-10-06T13:49:00+09:00",
"silence_min": 0,
"battery_window_start": 96,
"battery_now": 90,
"window_minutes": 29,
"drain_per_min": 0.21,
"wait_min": 11,
"battery_at_start": 87.7,
"minutes_left": 424,
"session_minutes": 80,
"content_version": "v2.4.0",
"required_version": "v2.4.0",
"fps_median_5m": 30,
"dropped_frames_5m": 18,
"total_frames_5m": 10818,
"drop_ratio_pct": 0.17,
"evidence": [
{
"field": "battery_pct",
"value": 96
},
{
"field": "battery_pct",
"value": 90
},
{
"field": "battery_pct",
"value": null
},
{
"field": "fps",
"value": 30
},
{
"field": "fps",
"value": 31
},
{
"field": "fps",
"value": 29
}
],
"summary": "XR-03 은 14:00 시작 때 87.7% 가 남아 424분을 버티고 버전이 v2.4.0 으로 같으며, 최근 5분 fps 중앙값이 30 이라 화재진압 훈련을 시작해도 됩니다.",
"hedge": "13:49 보고의 배터리 값이 비어 있어 13:48 보고의 90% 를 지금 잔량으로 썼습니다. fps 중앙값이 정확히 30 이라 30 미만 기준에 걸리지 않았습니다."
},
{
"session_id": "TS-1006-01",
"device_id": "XR-24",
"checked_at": "2026-10-06T13:49:00+09:00",
"verdict": "훈련 시작 가능",
"also_flagged": [],
"last_reported_at": "2026-10-06T13:47:00+09:00",
"silence_min": 2,
"battery_window_start": 34,
"battery_now": 26,
"window_minutes": 28,
"drain_per_min": 0.29,
"wait_min": 11,
"battery_at_start": 22.9,
"minutes_left": 80,
"session_minutes": 80,
"content_version": "2.4.0",
"required_version": "v2.4.0",
"fps_median_5m": 72,
"dropped_frames_5m": 13,
"total_frames_5m": 25813,
"drop_ratio_pct": 0.05,
"evidence": [
{
"field": "battery_pct",
"value": 34
},
{
"field": "battery_pct",
"value": 26
},
{
"field": "reported_at",
"value": "2026-10-06T13:47:00+09:00"
},
{
"field": "starts_at",
"value": "2026-10-06T14:00:00+09:00"
},
{
"field": "content_version",
"value": "2.4.0"
},
{
"field": "required_version",
"value": "v2.4.0"
},
{
"field": "dropped_frames",
"value": 0
}
],
"summary": "XR-24 는 28분 동안 34% 에서 26% 로 닳아 14:00 시작까지 11분을 기다리면 22.9% 가 남고, 그걸로 딱 80분을 버팁니다. 설치 버전 2.4.0 은 앞의 v 를 떼면 요구 버전과 같아 시작해도 됩니다.",
"hedge": "시작 후 버틸 분이 훈련 길이 80분과 똑같아 겨우 통과했습니다. 대기 중 소모율을 훈련 중에도 그대로 쓴 값이라, 콘텐츠를 돌리며 더 빨리 닳으면 끝까지 못 갈 수 있어 강사가 예비 기기를 옆에 둡니다."
},
{
"session_id": "TS-1006-02",
"device_id": "XR-12",
"checked_at": "2026-10-06T13:49:00+09:00",
"verdict": "훈련 시작 가능",
"also_flagged": [],
"last_reported_at": "2026-10-06T13:49:00+09:00",
"silence_min": 0,
"battery_window_start": 92,
"battery_now": 87,
"window_minutes": 30,
"drain_per_min": 0.17,
"wait_min": 131,
"battery_at_start": 65.2,
"minutes_left": 391,
"session_minutes": 40,
"content_version": "v2.3.1",
"required_version": "v2.3.1",
"fps_median_5m": 57,
"dropped_frames_5m": 1080,
"total_frames_5m": 21600,
"drop_ratio_pct": 5,
"evidence": [
{
"field": "content_version",
"value": "v2.3.1"
},
{
"field": "required_version",
"value": "v2.3.1"
},
{
"field": "dropped_frames",
"agg": "sum",
"value": 1080
},
{
"field": "session_id",
"value": "TS-1006-02"
}
],
"summary": "XR-12 는 v2.3.1 을 요구하는 인명구조 훈련에서는 버전이 맞고, 끊긴 프레임 비율이 5.00% 라 5% 를 넘지 않아 시작해도 됩니다.",
"hedge": "16:00 시작까지 131분 대기를 빼도 65.2% 가 남아 391분을 버팁니다. 같은 기기가 14:00 화재진압 훈련에서는 버전이 다름으로 걸리고, 화재진압 훈련용으로 다시 내려받으면 이 훈련에서 버전이 다름이 됩니다."
},
{
"session_id": "TS-1006-02",
"device_id": "XR-30",
"checked_at": "2026-10-06T13:49:00+09:00",
"verdict": "디바이스 응답 없음",
"also_flagged": [],
"last_reported_at": null,
"silence_min": null,
"battery_window_start": null,
"battery_now": null,
"window_minutes": null,
"drain_per_min": null,
"wait_min": 131,
"battery_at_start": null,
"minutes_left": null,
"session_minutes": 40,
"content_version": null,
"required_version": "v2.3.1",
"fps_median_5m": null,
"dropped_frames_5m": null,
"total_frames_5m": null,
"drop_ratio_pct": null,
"evidence": [
{
"field": "device_id",
"value": "XR-30"
},
{
"field": "seat",
"value": "A-4"
},
{
"field": "reported_at",
"agg": "count",
"value": 0
}
],
"summary": "XR-30 은 인명구조 훈련 A-4 자리에 배정돼 있지만 상태 보고가 한 번도 들어온 적이 없어, 디바이스가 ControlXR 에 연결되지 않은 상태입니다.",
"hedge": "새로 받은 기기를 배정표에만 올리고 켜지 않은 경우가 많습니다. 보고가 없어 배터리·버전·스트림 값은 모두 비워 두었습니다."
},
{
"session_id": "TS-1006-02",
"device_id": "XR-24",
"checked_at": "2026-10-06T13:49:00+09:00",
"verdict": "배터리가 훈련 끝까지 못 감",
"also_flagged": [
"훈련 콘텐츠 버전이 다름"
],
"last_reported_at": "2026-10-06T13:47:00+09:00",
"silence_min": 2,
"battery_window_start": 34,
"battery_now": 26,
"window_minutes": 28,
"drain_per_min": 0.29,
"wait_min": 131,
"battery_at_start": 0,
"minutes_left": 0,
"session_minutes": 40,
"content_version": "2.4.0",
"required_version": "v2.3.1",
"fps_median_5m": 72,
"dropped_frames_5m": 13,
"total_frames_5m": 25813,
"drop_ratio_pct": 0.05,
"evidence": [
{
"field": "battery_pct",
"value": 34
},
{
"field": "battery_pct",
"value": 26
},
{
"field": "reported_at",
"value": "2026-10-06T13:47:00+09:00"
},
{
"field": "starts_at",
"value": "2026-10-06T16:00:00+09:00"
},
{
"field": "content_version",
"value": "2.4.0"
},
{
"field": "required_version",
"value": "v2.3.1"
}
],
"summary": "XR-24 는 분당 0.29% 씩 닳고 있어 16:00 인명구조 훈련 시작까지 131분을 기다리는 동안 남은 26% 를 다 쓰고, 훈련을 시작하기 전에 꺼집니다.",
"hedge": "버틸 분은 음수 대신 0 으로 적습니다. 설치 버전 2.4.0 이 이 훈련의 요구 버전 v2.3.1 과 달라 훈련 콘텐츠 버전이 다름도 함께 걸렸습니다."
}
][자동 검증 · 코드가 판정합니다] 1. verdict 가 훈련 시작 가능·배터리가 훈련 끝까지 못 감·훈련 콘텐츠 버전이 다름·화면 스트림이 끊김·디바이스 응답 없음 다섯 값 중 하나이고, also_flagged 의 값이 같은 다섯 값 가운데 verdict 가 아닌 것인지 확인합니다. 2. session_id·device_id·checked_at·verdict·also_flagged·last_reported_at·silence_min·battery_window_start·battery_now·window_minutes·drain_per_min·wait_min·battery_at_start·minutes_left·session_minutes·content_version·required_version·fps_median_5m·dropped_frames_5m·total_frames_5m·drop_ratio_pct·evidence·summary 필드가 빠짐없이 있는지 확인합니다. 3. battery_window_start·battery_now·battery_at_start 가 0~100, wait_min 이 0 이상, drop_ratio_pct 가 0~100, silence_min·minutes_left·session_minutes 가 0 이상인지 확인합니다. 4. 두 근거 값의 차이를 다시 계산합니다. drain_per_min 은 (battery_window_start − battery_now) ÷ window_minutes, wait_min 은 starts_at − checked_at(음수면 0), battery_at_start 는 battery_now − 분당 소모량 × wait_min, minutes_left 는 battery_now ÷ 분당 소모량 − wait_min(battery_at_start 가 0 이면 minutes_left 도 0), session_minutes 는 ends_at − starts_at, silence_min 은 checked_at − last_reported_at 입니다. fps_median_5m 은 checked_at 앞 5분 안에 끝난 stream_stats 구간의 fps 중앙값, total_frames_5m 은 그 구간들의 fps × 120 + dropped_frames 합, drop_ratio_pct 는 dropped_frames_5m ÷ total_frames_5m × 100 입니다. 5. last_reported_at 이 null 이거나 silence_min 이 3 을 넘으면 verdict 는 디바이스 응답 없음입니다. 6. 앞 조건에 걸리지 않고 drain_per_min 이 0 보다 크고 minutes_left 가 session_minutes 보다 작으면 verdict 는 배터리가 훈련 끝까지 못 감입니다. 7. 앞 두 조건에 걸리지 않고 content_version 과 required_version 에서 앞의 v·V 와 공백을 지운 문자열이 다르면 verdict 는 훈련 콘텐츠 버전이 다름입니다. 8. 앞 세 조건에 걸리지 않고 fps_median_5m 이 30 미만이거나 drop_ratio_pct 가 5 를 넘으면 verdict 는 화면 스트림이 끊김이고, 그렇지 않으면 훈련 시작 가능입니다. 9. evidence 의 field 이름이 device_heartbeats·stream_stats·training_sessions·session_devices 에 있는 컬럼인지 대조합니다. 10. evidence 의 value 가 수록된 행의 원문 값과 같은지 확인합니다. 여러 줄을 더하거나 센 수는 agg 를 적습니다. 11. summary 가 존댓말로 끝나는지 확인합니다. [확정 카드 판정식과 다른 점 · 대기 중 소모분을 뺍니다] 확정 카드의 배터리 판정식은 지금 battery_pct 로 버틸 분을 ends_at − starts_at 과 견줍니다. 이 편은 checked_at 부터 starts_at 까지 대기하는 동안 같은 소모율로 닳을 몫을 먼저 빼고, 시작 때 남을 잔량으로 버틸 분을 냅니다. 이유는 판정판이 훈련 몇 분에서 몇 시간 전에 열리기 때문입니다. 카드 식이면 13:49 에 91분을 버틴다고 나오는 XR-24 가 14:00 시작까지 11분 동안 3.1% 를 더 써 실제로는 80분을 버티고, 16:00 인명구조 훈련에서는 131분을 기다리다 시작 전에 꺼집니다. 이 차이로 판정이 갈리는 쌍은 TS-1006-02·XR-24 하나이며 카드 식이면 훈련 콘텐츠 버전이 다름이 됩니다. 카드 미리보기의 XR-07 「52분」은 이 식에서 40.7분입니다. [판정 차례 · 위 규칙이 둘 이상 동시에 걸릴 때] 세션과 디바이스 한 쌍에는 판정을 하나만 남기고 나머지는 also_flagged 에 둡니다. ① 디바이스 응답 없음 → ② 배터리가 훈련 끝까지 못 감 → ③ 훈련 콘텐츠 버전이 다름 → ④ 화면 스트림이 끊김 → ⑤ 훈련 시작 가능. 말이 없는 디바이스의 배터리·버전·스트림 값은 오래된 값이라 먼저 세우고, 기기를 갈아야 하는 배터리를 다시 내려받으면 되는 버전보다 앞에 둡니다. 스트림 끊김은 배터리를 갈거나 콘텐츠를 다시 받은 뒤 다시 판정해야 원인이 갈리므로 맨 뒤에 둡니다. [하드 케이스 · 판정이 갈리는 이유] XR-03 은 13:49 보고의 battery_pct 가 null 인 결측이라, 30분 창에서 값이 있는 첫 보고(13:19, 96%)와 마지막 보고(13:48, 90%)로 소모량을 냅니다. fps 중앙값은 정확히 30 이라 30 미만에 걸리지 않습니다. XR-24 는 대기 11분 소모분을 뺀 뒤 버틸 분 80.0 이 훈련 길이 80분과 같아 '모자라면'에 걸리지 않고, content_version 「2.4.0」은 앞의 v 를 떼면 「v2.4.0」과 같습니다. XR-24 의 13:49 스트림 구간 dropped_frames 가 0 인 것도 그대로 셉니다. XR-15 는 마지막 보고가 checked_at 정확히 3분 전이라 응답 없음이 아니고, 스트림 판정으로 갑니다. XR-12 는 끊긴 프레임 비율이 정확히 5.00% 라 5% 초과에 걸리지 않습니다. 같은 기기가 TS-1006-01 에서는 버전이 다름, TS-1006-02 에서는 시작 가능이라 버전은 세션마다 따로 견줍니다. TS-1006-02·XR-24 는 대기 131분 동안 분당 0.29% 씩 닳아 battery_at_start 가 0 이 되고, minutes_left 는 음수가 아니라 0 이라 배터리가 훈련 끝까지 못 감입니다. 요구 버전 v2.3.1 과도 달라 also_flagged 에 훈련 콘텐츠 버전이 다름이 남습니다. XR-30 은 session_devices 에만 있고 device_heartbeats 에 한 줄도 없어 last_reported_at 이 null 이고, 디바이스 응답 없음으로 셉니다. XR-21 의 stream_stats 는 13:41 에 끝나 5분 창 밖이라 fps_median_5m 이 null 입니다. [평가 러너 · 2주차] tc 로 대역을 좁힌 12구간과 풀어 둔 12구간을 번갈아 흘리고, 좁힌 구간마다 화면 스트림이 끊김 판정이 켜졌는지와 켜지기까지 걸린 분을, 풀어 둔 구간마다 판정이 꺼졌는지를 셉니다. [수동 검증 · 사람이 봅니다] 1. 배터리가 모자란 디바이스를 충전할지 예비 기기로 바꿀지는 강사가 훈련 일정과 예비 기기 수를 보고 정합니다. 2. 화면 스트림 끊김이 디바이스 탓인지 훈련장 무선망 탓인지는 넥시클 운영 지원 담당자가 같은 시간대 다른 디바이스의 끊김과 견줘 판단합니다.