상태: 초안 (2026-10-26), 코드 근거. 동작은 2026-10-25 기준 저장소 코드와 설계 문서에서 읽었습니다. 운영 DB 의 「고친 기록」 건수는 2026-10-18 에 읽은 값입니다. 사용자 수 같은 운영 통계는 싣지 않았습니다. 작성 규칙: 실측 데이터가 기본이고, 못 잰 것은 「미실측」, 근거를 찾지 못한 것은 「확인 못 함」으로 표시합니다.
목차
- 1. 경기가 끝나면
- 2. 「이 경기 기록하기」
- 3. 기록 화면들
- 4. 라이브 공유: 서버가 점수를 다시 센다
- 5. 고친 기록: 동의한 것만, 그리고 아직 0건
- 6. 영상은 기기 밖으로 나가지 않는다, 그리고 예외
- 7. 실명 대신 별명
- 8. 서버에는 어떤 표가 있나
- 9. 정리
- 참고문헌
- 함께 읽기
1. 경기가 끝나면
5게임제 경기가 3:1 로 끝났습니다. 카메라 점수판에는 「경기 끝」 화면이 뜨고, 게임 점수(11:7, 9:11, 11:5, 13:11)가 나란히 보입니다. 이 경기는 지금 어디에 있을까요?
휴대폰에만 있습니다. 2편에서 봤듯 카메라 점수판은 경기 이력 전체를 브라우저 localStorage(ttplay:cam)에 저장합니다. 이력의 칸마다 출처도 붙어 있습니다. 화면을 누른 점수(tap), 리모컨 키(key), 점수 조정(adj), 자동 판정을 받아들인 점수(auto)입니다. 서버는 이 경기를 모릅니다.
이 상태에서 서버에 무언가를 남기는 길은 사용자가 고르는 셋뿐입니다.
| 길 | 서버로 가는 것 | 남는 곳 |
|---|---|---|
| 이 경기 기록하기 | 게임 점수, 경기 길이, 상대 | 경기 기록, 레이팅 |
| 라이브로 공유(경기 중) | 득점·되돌리기·끝 이벤트 | 라이브 경기와 이벤트 열 |
| 자동 점수(경기 중) | 프레임(저장 안 함), 사람이 칩에 답한 기록 | 고친 기록(영상 없음) |
2. 「이 경기 기록하기」
무엇이 넘어가나
「경기 끝」 시트의 「이 경기 기록하기」를 누르면(backend/static/tt/play/lib/cam-sheets.mjs) 화면은 경기 요약 {best_of, games, source:'scorekeeper'} 를 sessionStorage 에 잠시 두고 「내 기록」 화면(/tt/me#match)으로 넘어갑니다. 로그인 전이면 로그인을 거칩니다. 기록 화면에서 상대를 고르고 「기록하기」를 누르면 그때 서버로 갑니다(POST /api/tt/matches).
넘어가는 것은 게임 점수뿐입니다. 랠리별 이력, 누가 언제 어떻게 득점했는지, 자동 판정의 흔적은 넘어가지 않습니다. 몇 가지 경우는 기록할 수 없습니다.
- 끝나지 않은 경기(승자가 없음).
- 점수 조정으로 손으로 올린 게임. 2편에서 본 대로 이 게임은 몇 대 몇이었는지 점수판이 모르므로 기록에서 빼고, 사람이 직접 적게 합니다. 지어낸 11:0 이 레이팅에 들어가면 안 됩니다.
로그인과 표시 이름
기록을 쓰려면 Google 로그인이 필요합니다. 게스트는 읽기만 합니다. 쓰기 자격은 사이트 커뮤니티와 같은 관문입니다.
여기서 중요한 설계가 하나 있습니다. 구글 계정의 이름은 어디에도 내보내지 않습니다. 구글 이름은 대개 실명이고, 경기 상대나 순위표에 그대로 나가면 탁구장에서 만난 사람이 내 실명을 알게 됩니다. 그래서 탁구 구역의 어떤 응답·화면도 구글 이름을 쓰지 않습니다(backend/app/tt_records.py 머리말 「구글 이름은 내보내지 않는다」). 표시 이름을 정하기 전의 선수는 「탁구인 #번호」이고, 경기를 기록하려면 먼저 표시 이름(2–20자)을 정해야 합니다. 상대 화면에 번호로만 나오는 경기는 누가 적었는지 알아볼 수 없기 때문입니다.
카메라 점수판의 별명 칸(12자까지)에도 「실명 대신 별명」 안내가 붙어 있습니다.
상대가 확인해야 레이팅이 움직인다
혼자 적은 승리로 레이팅을 올릴 수 있으면, 순위표는 누가 더 많이 적었는지의 표가 됩니다. 그래서 계정이 있는 상대와의 경기는 확인 대기로 들어가고, 상대가 「확인」을 눌러야 확인됨이 되며 그때 한 번 레이팅이 움직입니다. 상대가 「이의」를 걸 수도 있습니다. 7일 안에 확인이 없으면 레이팅에는 들어가지 않고 개인 기록으로만 남습니다. 만료는 배치 작업으로 행을 고치지 않고, 읽을 때 만든 시각으로 판정합니다(DB 시계 하나로 잼).
계정 없는 상대(동네 친구)와의 경기도 적을 수 있습니다. 이때 상대 이름은 그 경기를 적은 사람에게만 보이고, 레이팅은 움직이지 않습니다.
Elo 레이팅
레이팅은 체스에서 온 Elo 방식입니다.
# backend/app/tt_records.py 에서 줄임
def k_factor(rating, rated_games):
if rated_games < 10: return 40 # 처음 10경기: 제자리를 빨리 찾게
if rating >= 2000: return 16
return 24
def expected(ra, rb): # A 가 이길 기대값
return 1 / (1 + 10 ** ((rb - ra) / 400))
# 변화량 = K × (실제 결과 − 기대값). 두 사람이 각자 자기 K 를 쓴다.
- 모두 1500 에서 시작합니다.
- 레이팅은 경기의 승패만 봅니다. 3:0 과 3:2 를 구분하지 않습니다. 코드 주석은 「설명하기 쉬운 쪽을 골랐다」고 적고 있습니다.
- 두 사람의 K 가 다르면(한쪽만 처음 10경기) 변화량의 합이 0 이 아니므로, 경기 행에 두 사람의 변화량을 따로 남깁니다.
- 순위표에는 프로필을 공개로 켠 사람 중 확인된 경기가 3개 이상인 사람만 나옵니다. 프로필은 비공개가 기본입니다.
예를 들어 둘 다 1500 이고 처음 10경기 안이면 기대값은 0.5, 이긴 사람은 40 × 0.5 = +20, 진 사람은 −20 입니다. 같은 K 40 에서 1600 이 1500 을 이기면 기대값이 약 0.64 라 +14.4 정도만 오르고, 지면 −25.6 정도 내려갑니다.
하루 한도와 동시성
| 한도 | 값 | 왜 |
|---|---|---|
| 하루 경기 기록 | 30 | 몰아 적기 막기 |
| 같은 상대와 하루 | 10 | 짜고 주고받으며 레이팅 올리기를 늦춤 |
| 하루 연습 기록 | 20 | — |
| 경기 날짜 | 1년 안쪽 | 옛 기록을 몰아 넣어 레이팅을 만드는 길을 좁힘 |
| 한 게임 점수 상한 | 50 | 오타(110:9) 거르기 |
한도는 DB 에서 셉니다. 같은 사람의 요청 둘이 동시에 「29번째」로 세어져 함께 통과하지 못하게, 사람마다 DB 잠금(advisory lock)으로 줄을 세웁니다. 확인할 때는 경기 행을 먼저 잠그고 두 선수 행을 번호가 작은 쪽부터 잠급니다. 앨리스와 밥이 서로의 다른 경기를 동시에 확인할 때 「내 행부터」 잠그면 서로 상대 행을 기다리며 교착에 빠지기 때문입니다.
3. 기록 화면들
| 화면 | 보이는 것 | 누가 |
|---|---|---|
내 기록 /tt/me |
내 경기, 확인 대기·이의, 레이팅, 연습 일지 | 본인 |
순위표 /tt/rank |
공개 선수 순위, 동호회·이번 달 필터, 이번 주 급상승 | 누구나 |
히스토리 /tt/me/history |
신청·경기·점수의 변화를 한데 모음, CSV 내려받기 | 본인 |
경기 공유 /tt/m/{코드} |
공유를 켠 경기 하나 | 링크를 받은 사람 |
히스토리는 새 기록을 쌓지 않고 각 기능이 이미 남긴 것을 읽어 모읍니다(backend/app/tt_history.py). 경기 기록, 라이브 점수판 이벤트(서버가 다시 셈), 녹화본 분석 결과, 자동 점수를 고친 기록, 레이팅 변화가 한 줄에 섞여 나옵니다. 라이브 점수판과 경기 기록은 서로 열쇠로 이어져 있지 않아서, 「끝 점수가 같고 시간이 가까운」 경기에 추정으로 잇습니다. 틀리게 이을 수 있으므로 화면은 흐름의 출처(라이브·영상)를 늘 함께 보입니다. 주소에 사람 번호가 없고 모든 질의가 로그인한 사람으로만 묶여, 다른 사람의 히스토리를 볼 길이 없습니다. CSV 는 엑셀 수식 주입을 막아서 냅니다.
경기 공유는 경기 번호가 아니라 따로 만든 11자 코드를 씁니다. 경기 번호는 순번이라 주소에 쓰면 남의 경기를 하나씩 훑을 수 있기 때문입니다. 확인된 경기는 두 선수 누구나, 개인 기록은 적은 사람만 공유를 켭니다. 확인 대기나 이의가 걸린 경기는 「있었던 경기」인지부터 정해지지 않아 공유하지 않습니다. 공유 화면에서도 공개하지 않은 선수는 「탁구인」, 계정 없는 상대의 이름은 끝까지 나가지 않습니다.
4. 라이브 공유: 서버가 점수를 다시 센다
경기 중에 「라이브로 공유」를 켜면, 관람 링크를 받은 사람이 실시간으로 점수를 봅니다(backend/app/tt_watch.py).
- 이벤트만 보낸다. 브라우저가 「지금 7:5」를 보내면 아무 점수나 중계할 수 있습니다. 그래서 득점·되돌리기·끝 이벤트만 받고, 점수는 서버가 그 라이브의 이벤트를 처음부터 규칙대로 다시 돌려 정합니다. 2편의 「상태가 아니라 이력」이 서버에서도 그대로입니다. 이때 쓰는 규칙이 1편에서 말한 셋째 사본(
LiveBoard)이고, 같은 사례 파일과 엔진과의 무작위 대조로 묶여 있습니다. - 순서 번호. 이벤트마다 1부터 늘어나는 번호를 붙입니다. 휴대폰은 득점 직후 화면이 잠기거나 신호가 끊겨 같은 묶음을 다시 보낼 수 있으므로, 이미 받은 번호를 같은 내용으로 다시 보내면 조용히 받고, 다른 내용이거나 번호가 비면 409 와 서버의 마지막 번호를 돌려줍니다. 화면은 409 를 받으면 보내기를 멈춥니다.
- 쓰기는 주인 + 토큰. 라이브를 만든 계정으로 로그인해 있어야 하고, 만들 때 한 번만 받는 토큰을 머리글로 보내야 합니다. 주소나 본문에 싣지 않는 것은 로그와 공유 링크에 남지 않게 하려는 것이고, DB 에는 토큰의 해시만 둡니다.
- 조정은 이벤트로 옮긴다. 손 점수판에서 한참 전의 칸을 지우면, 라이브에는 그만큼 되돌리기를 보내고 다시 보냅니다.
- 복식은 보내지 않습니다(2편).
- 3시간 동안 이벤트가 없으면 끝난 것으로 봅니다. 응원 이모지는 이 시간을 늘리지 않습니다.
이름 규칙도 같습니다. 링크는 기록자가 직접 나눠 준 것이라 그 경기에 정한 별명을 보이지만, 아무나 보는 홈 피드에서는 공개를 켜지 않은 주인은 「탁구인」, 상대는 「상대 선수」로 가립니다.
5. 고친 기록: 동의한 것만, 그리고 아직 0건
3편에서 사람이 자동 판정의 칩에 답하면(「맞음」, 다른 선수로 고침, 「무효」) 그 답이 고친 기록이 된다고 했습니다. 이것이 어디로 가는지 따라가 봅니다.
무엇을 담나
자동 점수 화면은 사람이 답한 랠리마다 한 행을 만들어 POST /api/tt/corrections 로 보냅니다. 행에는 다음이 들어갑니다(backend/app/tt_corrections.py).
- 원래 판정(승자 쪽, 이유, 신뢰도, 확인 필요 여부)과 고친 판정.
- 그 순간 엔진이 본 이벤트 요약(16개 이하, 수·쪽·종류만).
- 신호별 근거(공의 신뢰도, 무엇이 판정했나, 신호가 엇갈렸나).
- 학습 이용 동의 여부.
영상은 담지 않습니다. 서버는 브라우저가 보낸 것을 그대로 넣지 않고 아는 모양만 새로 만듭니다. 글이 들어갈 자리가 거의 없어 욕설·주소·스크립트가 쌓일 곳이 없습니다. 행의 열쇠에는 로그인한 사람의 번호가 들어가므로 남의 행을 덮을 수 없습니다. 한도는 사람당 분당 30요청, 하루 2,000행입니다.
시간이 지나 그대로 들어간 판정은 고친 기록을 남기지 않습니다. 사람이 확인한 것이 아니기 때문입니다.
누가 무엇을 가져가나
- 동의하지 않은 행도 남깁니다. 「사람이 얼마나 고치나」를 세는 서비스 지표이기 때문입니다.
- 학습·평가로 나가는 내보내기에는 동의한 행만 갑니다. 운영자 전용 내보내기(
GET /api/tt/corrections/export)는 사람 번호 대신 내보내기마다 새로 뽑은 소금으로 만든 가림 값을 싣습니다. 한 파일 안에서는 같은 사람을 묶을 수 있지만, 파일끼리, 그리고 우리 DB 와는 잇지 못합니다. - 내 기록은 언제든 지울 수 있습니다(
DELETE /api/tt/corrections/mine).
지금까지 학습에 쓴 적이 없다
2026-10-18 에 운영 DB 를 읽었을 때 고친 기록은 0건이었습니다. 따라서 고친 기록을 학습이나 평가에 쓴 적은 아직 한 번도 없습니다. 5편의 학습 모델들은 모두 공개 데이터와 합성 그림으로 배웠습니다.
계획은 있습니다. 5편에서 본 대로 학습형 모듈을 상업 사용 가능한 자료로 다시 학습할 때, 학습 이용에 동의한 사람의 고친 판정은 「확인 필요」 보정 같은 모듈의 재료가 될 수 있습니다. 「사람이 고친 판정」은 곧 「엔진이 틀린 자리」의 정답이기 때문입니다. 다만 그러려면 고친 기록이 충분히 쌓여야 하고, 학습 재료로 쓸 때의 보관 기간과 동의 안내를 다시 점검하는 일이 먼저입니다.
6. 영상은 기기 밖으로 나가지 않는다, 그리고 예외
카메라 점수판의 첫 원칙은 카메라 영상은 기기 밖으로 나가지 않는다입니다(backend/static/tt/play/cam.js 머리말). 녹화도 휴대폰 안에서 합성하고 휴대폰에 저장합니다. 점수 읽어 주기도 브라우저의 음성 합성을 씁니다.
예외는 사용자가 켜는 기능 둘입니다.
자동 점수. 공을 찾는 GPU 가 서버에 있으므로 프레임을 보냅니다. 대신 범위를 좁혔습니다(1편).
- 512×288 로 줄인 JPEG 만, 초당 최대 30장.
- Google 로그인 회원만, 시간 몫 안에서만.
- 웹 중계는 프레임을 지나가게만 하고, 작업자는 공 모델이 보는 3장 창만 메모리에 둡니다. 프레임을 저장하지 않습니다.
- 작업자 로그에는 영상, 공 좌표, 사람 정보를 남기지 않습니다.
- 카메라를 열 때 소리는 요청하지 않습니다.
녹화본 올려 분석. 사용자가 영상 파일을 직접 골라 올리는 별도 기능입니다. 브라우저가 먼저 360p60 500kbps 로 줄여 올리고, 원본은 30일 뒤 지웁니다. 분석 작업자는 DB 와 저장소 열쇠 없이 웹의 내부 API 로만 일합니다.
7. 실명 대신 별명
개인정보 설계를 한 줄로 줄이면 「탁구장에서 만난 사람이 내 실명을 알게 되지 않게」 입니다.
| 자리 | 보이는 이름 |
|---|---|
| 카메라 점수판 화면 | 그 경기에 정한 별명(12자, 「실명 대신 별명」 안내) |
| 경기 기록·상대 화면 | 표시 이름(2–20자). 정하기 전에는 「탁구인 #번호」 |
| 순위표·공개 프로필 | 공개를 켠 사람만. 기본은 비공개 |
| 계정 없는 상대 | 그 경기를 적은 사람에게만 |
| 라이브 홈 피드 | 공개 안 한 주인은 「탁구인」, 상대는 「상대 선수」 |
| 고친 기록 내보내기 | 이름 없음, 내보내기마다 바뀌는 가림 값 |
| 구글 계정 이름 | 어디에도 나가지 않음 |
데모 영상에서도 같은 원칙입니다. 원본 화면에 박힌 이름(중계 자막)은 원본 그대로 두지만, 우리가 덧그린 글자는 「선수 A·B」로만 씁니다.
8. 서버에는 어떤 표가 있나
기록과 관련된 서버 쪽 표를 한곳에 모으면 이렇습니다. 표마다 DB 이전(migration) 번호가 붙어 있고, 이전 하나가 기능 하나를 들여왔습니다.
| 이전 | 표 | 담는 것 | 담지 않는 것 |
|---|---|---|---|
| 0036 | tt_players |
표시 이름(2–20자, 비어 있으면 「탁구인 #번호」), 레이팅, 레이팅에 든 경기 수, 공개 여부(기본 비공개) | 구글 이름 |
| 0036 | tt_matches |
경기 길이, 게임 점수, 출처(직접 입력·점수 도우미·녹화본 분석), 상태(확인 대기·확인됨·이의·기한 지남·개인 기록), 두 사람의 레이팅 변화량 | 랠리별 이력 |
| 0036 | tt_practice |
연습 일지(본인만 봄) | — |
| 0039 | tt_live_matches·tt_live_events |
라이브 경기와 이벤트 열(번호 붙은 득점·되돌리기·끝). 같은 이전에서 경기 공유용 11자 코드를 tt_matches 에 더함 |
점수 자체(서버가 이벤트에서 다시 셈), 영상 |
| 0040 | tt_corrections |
원래 판정·고친 판정·이벤트 요약·근거·동의 여부 | 영상 |
| 0048 | tt_videos |
사용자가 올린 영상의 분석 대기열과 결과 | — (원본은 30일 뒤 지움) |
이 표들에서 눈여겨볼 점은 「무엇을 담지 않는가」가 설계의 절반이라는 것입니다. 경기 기록은 랠리를 모르고, 라이브는 점수를 저장하지 않고 이벤트만 저장하며, 고친 기록은 영상을 모릅니다. 각 표는 자기 기능에 꼭 필요한 것만 담고, 히스토리 화면은 새 표를 만들지 않고 이 표들을 읽어 모읍니다(3절). 담지 않은 것은 새어 나갈 수도 없습니다.
9. 정리
- 끝난 경기는 기본적으로 휴대폰에만 남습니다. 서버로 가는 것은 사용자가 고른 기록·라이브·자동 점수뿐입니다.
- 기록은 게임 점수만 넘기고, 상대가 7일 안에 확인해야 Elo 레이팅(K 40·24·16)이 움직입니다. 하루 한도와 DB 잠금이 몰아 적기와 동시 요청을 막습니다.
- 라이브는 이벤트만 받고 서버가 규칙대로 다시 세며, 쓰기는 주인 계정과 머리글 토큰이 있어야 합니다.
- 고친 기록은 영상 없이 남고, 동의한 것만 가림 값으로 내보냅니다. 아직 0건이라 학습에 쓴 적이 없습니다.
- 카메라 영상은 기기 밖으로 나가지 않고, 자동 점수의 프레임은 서버를 지나가기만 합니다. 구글 실명은 어디에도 나가지 않습니다.
티티 한마디. 오늘 경기는 폰이 먼저 기억해요. 서버한테는 「몇 대 몇이었는지」만 알려 주고, 이름도 별명으로만요. 상대가 「맞아요」 해 줘야 점수가 올라가니까, 이긴 날은 꼭 확인 부탁해 주세요.
참고문헌
- A. E. Elo. The Rating of Chessplayers, Past and Present. Arco, 1978. (Elo 레이팅의 원 서지. 원문은 이번에 열지 않았고, 우리 식은 코드
backend/app/tt_records.py에서 확인) - International Table Tennis Federation. ITTF Statutes 2026, Chapter 2: The Laws of Table Tennis, §2.12(경기와 게임 수). https://db.ittf.com/document
- 코드와 설계: 저장소
backend/app/tt_records.py·tt_history.py·tt_watch.py·tt_corrections.py·tt_live.py머리말,backend/static/tt/play/cam.js·lib/cam-sheets.mjs·lib/camauto.mjs.
함께 읽기
- 이 시리즈 1편: 전체 구조: 무엇이 기기 밖으로 나가는지의 표.
- 이 시리즈 2편: 손 점수판과 경기 규칙 엔진: 「상태가 아니라 이력」과 조정 칸.
- 이 시리즈 3편: 자동 판정: 고친 기록이 생기는 칩의 답.
- 이 시리즈 5편: 데이터·라이선스·학습 과정: 고친 기록이 학습 재료가 될 수 있는 조건과 상업 서비스에 맞춘 학습 자료의 원칙.