01 — RESEARCH ·R-005 ·2026.06.17

손흥민 리포트 제작기 — 이벤트 데이터로 기사급 리포트 만들기

월드컵 RAG 개발기 5편(마지막) — WhoScored 이벤트 분석, 검증 파이프라인, 편집 피드백 반영. 실제 산출물 PDF 포함.

목차
  1. 했다: “손흥민 3경기를 수치로 리뷰해줘”
  2. 막혔다 1: 후반 시계는 45분부터 다시 시작한다
  3. 막혔다 2: “이 좌표 단위가 뭐지?”
  4. 막혔다 3: “손흥민이 받은 패스”라는 필드는 없다
  5. 무결성 파이프라인: 원샷 스크립트
  6. 막혔다 4: 예쁘게, 그리고 또 예쁘게
  7. 막혔다 5: 데이터는 맞는데 “논조”가 틀렸다
  8. 배운 것
  9. 산출물
  10. 손흥민 3경기 심층리뷰
  11. 경기 프리뷰 (자동 생성)

연재 · 월드컵 RAG 프로젝트 개발기 (5/5 · 마지막)

  1. 월드컵을 통째로 서비스에 담아보기로 했다
  2. 크롤러가 죽었다 — 데이터 수집 잔혹사
  3. 배포했더니 데이터가 사라졌다 — Railway 운영의 함정들
  4. LLM은 숫자를 지어낸다 — 환각과 싸운 기록
  5. 손흥민 리포트 제작기 — 이벤트 데이터로 기사급 리포트 만들기 ← 지금 글

했다: “손흥민 3경기를 수치로 리뷰해줘”

한국이 조별리그에서 탈락했다. 감독의 기용(조기 교체, 선발 제외)을 두고 여론이 시끄러웠고, “손흥민이 실제로 어땠는지 객관적 수치로 정리한 리포트를 기자에게 전달하자”는 미션이 생겼다. 요구사항이 명확했다:

  • 볼 배급(누가, 어떤 거리의 패스로 줬나)과 고립도 — 단, 비교군(같은 경기 다른 공격수)이 있어야 의미가 있다
  • 수비 가담 깊이, 파울 유도
  • 손흥민이 있을 때 vs 없을 때 팀 지표 변화
  • mplsoccer로 피치 시각화
  • 그리고 무엇보다: 수치 오류 제로. 기자 이름을 달고 나가는 자료다.

sofascore 집계(평점·xG·터치)에 더해, WhoScored 이벤트 데이터(경기당 107개 컬럼 × 1,500여 행 — 모든 패스·슛·태클의 좌표와 시각)를 크롤링해 재료로 삼았다.

막혔다 1: 후반 시계는 45분부터 다시 시작한다

첫 대형 함정. WhoScored의 MIN_TIME후반에 45부터 다시 센다. 문제는 전반 추가시간이 MIN_TIME 45~49(PERIOD 1)로 기록된다는 것 — 즉 “45분 이후” 필터를 그냥 걸면 전반 추가시간 이벤트 66개가 후반 구간에 섞여 들어온다. 실제로 손흥민 투입 전후 분석이 이걸로 오염됐었고, 사용자의 “남아공전 OFF 시간이 45+추가시간 아니야?”라는 지적으로 발견했다.

해법: 경기 전체에서 단조 증가하는 expanded_minute로 모든 구간 계산을 통일. 시계 표기(~68’)와 지속시간(추가시간 포함)을 분리해서 관리했다.

막혔다 2: “이 좌표 단위가 뭐지?”

패스 길이 q_Length가 0.886.9 사이 값이었다. 정규화 좌표 거리라고 추정했다가, “평균 거리를 미터로 보여달라”는 요구에 확인이 필요해졌다. 가설 검증: 시작도착 좌표를 105×68m 피치로 환산한 거리와 q_Length의 상관을 돌려보니 corr 1.0000, 비율 1.002 — q_Length는 애초에 미터였다. 추측으로 각주까지 달았던 “정규화 단위” 문구를 전부 걷어냈다. 데이터 사전이 없는 필드는 이렇게 기하로 검증할 수 있다.

비슷하게 검증한 것들: 파울 이벤트의 Successful = 파울 획득(sofascore ‘피파울’ 수와 교차 일치), 승부차기 골은 situation == "shootout"(이걸 안 거르면 승부차기 경기 스코어가 4-5 같은 괴상한 값이 된다), 좌표 X축 = 공격 깊이(Y가 아니다).

막혔다 3: “손흥민이 받은 패스”라는 필드는 없다

이벤트 데이터에 패스 수신자 필드가 없다. 그래서 휴리스틱을 썼다: 성공한 우리 팀 패스 직후, 같은 팀의 다음 터치가 손흥민이면 손흥민이 받은 패스로 간주. 보수적 과소집계라는 한계를 인지하고, 수신 수 ≤ 터치 수 같은 sanity 검증으로 감쌌다. 수신 후 이벤트 체인을 따라가 “유지/상실/슛/파울 획득”까지 분류하니, 고립도 비교표(수신/90분, 평균 수신거리, 수신 후 유지율)가 나왔다.

결과가 재밌었다. 손흥민은 받은 공을 가장 안 잃는 선수(유지율 75.9~81%)였고, 그에게 간 패스는 다른 공격수보다 짧았다(롱볼로 던져준 게 아니라 연계로 받음). “고립”의 실체는 손흥민 개인이 아니라 배급 구조였다.

무결성 파이프라인: 원샷 스크립트

기자용이라 4편의 원칙을 총동원해 한 번에 돌아가는 파이프라인을 만들었다:

  1. 분석: 지표 전부 pandas 계산 (경기별 + 비교군 + ON/OFF 팀 분할)
  2. 교차검증 18종: 슛 수(WhoScored vs sofascore), 출전 분, 패스 수, 파울 획득… 하나라도 실패하면 생성 자체를 중단
  3. 시각화: mplsoccer (활동맵, ON/OFF 비교)
  4. 생성: 기존 리뷰 엔진(GPT) 재사용, 확정 지표를 player_focus payload로 주입. 표는 [[TABLE:키]] 플레이스홀더 → 코드가 정본 치환
  5. 이중 검산: 표 무결성(문자열 대조) + 서술-수치 LLM 대조 → 불일치 시 자동 수정 1회 후 재검수
  6. 내보내기: PDF·DOCX (표·이미지 임베드)

막혔다 4: 예쁘게, 그리고 또 예쁘게

시각화는 기술보다 반복이 힘들었다. 다크 네이비 피치 + 코멧 라인으로 만들었더니 “예쁘다”, 그런데 문서에 넣으니 “배경은 흰색, 글씨는 검정, 폰트는 본고딕으로”. 제목이 패널과 겹치는 버그는 mplsoccer의 pitch.grid()(제목 전용 영역 예약)로 근본 해결했다. matplotlib subplots_adjust로 어림잡던 것보다 훨씬 안정적이다.

문서 쪽도 잔버그의 연속이었다: ReportLab에서 15열 표가 페이지 폭을 넘어 negative availWidth 크래시(→ 가용 폭을 열 수로 균등 분배), DOCX 제목 옆에 뜨는 검은 사각형(→ Heading 스타일의 keep_with_next 속성 제거), 푸터 로고 두 개의 높이 불일치(→ 같은 높이로 한 문단 배치).

막혔다 5: 데이터는 맞는데 “논조”가 틀렸다

초안은 감독 비판 일변도였다. 편집 피드백이 왔다: 체코전 교체는 교체 투입된 선수가 결승골을 넣어 결과적으로 옳았으니 그 경기의 교체 비판은 삭제할 것, 멕시코전은 “존재감” 각도로, 크로스 평가는 팀 관점과 손흥민 ON 구간 평가가 논리 충돌하니 일관되게.

흥미로웠던 건 피드백 중 “교체 후 상대가 라인을 올렸다”는 관찰 제안이었는데 — 데이터를 돌려보니 반대였다(상대는 리드 지키기 모드로 공격이 줄었다). 이런 건 정중히 반영하지 않았다. 데이터 기반 리포트에서 뉘앙스는 편집의 영역이지만, 사실은 협상의 대상이 아니다. 논조 수정 후에도 표 검산과 서술-수치 검수를 다시 돌려 “불일치 없음”을 확인하고 내보냈다.

배운 것

  • 이벤트 데이터는 필드 시맨틱 검증이 분석의 절반이다. 시계 리셋, 단위, 플래그 의미 — 문서가 없으면 데이터로 증명하라.
  • 파생 지표(수신, 고립도)는 휴리스틱임을 명시하고 sanity 체크로 감싸면 기자 질문에도 방어된다.
  • 사람에게 전달되는 리포트는 “생성”이 아니라 “생성 + 검산 + 편집 반영 + 재검산”의 루프다. 파이프라인을 원샷 스크립트로 만들어 두면 이 루프가 두렵지 않다.

산출물

이 파이프라인으로 실제 만든 결과물입니다.

손흥민 3경기 심층리뷰

손흥민 3경기 심층리뷰 (PDF) 새 창에서 열기 ↗

경기 프리뷰 (자동 생성)

같은 서비스가 생성한 조별리그 프리뷰입니다.


연재를 마치며 — 이 프로젝트에서 가장 크게 배운 건, LLM 시대의 데이터 서비스는 “모델을 잘 쓰는 것”보다 “모델이 틀릴 수 없는 구조를 짜는 것”에 가깝다는 사실이었다. 월드컵은 아직 진행 중이고, 8강 데이터가 오면 파이프라인은 또 돌아간다.