Back to Blog
Dec 13, 2023

역도경기 관리 시스템 제작 후기

어쩌다 역도 경기 프로그램을 만들게 되었나

Posted by Tom

어린 시절 나는 사춘기가 꽤 길었다. 긴 사춘기 시간동안 집에서 빈둥거리면서 TV를 많이 봤던 기억이 있다. 그 당시 가끔 TV에 역도 경기장면이 나오면 나는 1분이상 보지 않고 이내 TV채널을 다른 곳으로 돌려 버렸었다. 그러다. 성인이 되고 장미란 선수가 베이징 올릭픽에서 금메달을 도전하는 방송을 보게 되었다. 평소에 관심도 없던 역도 였지만 그땐 손에 땀을 쥐고 경기 장면을 지켜 봤었다.

아마도 많은 사람들이 이 때부터 역도에 대해 많은 관심을 갖게 되지 않았나 생각한다. 하지만 다른 스포츠들과 다르게 역도 경기의 룰은 이해가 잘 되지 않았다.

장미란선수

그냥 무조건 많이 들면 그만 아닌가?

역도 경기는 인상과 용상에서 각 3번의 바벨을 들어 올려 최고의 무게를 합산하여 순위가 정해집니다. 하지만 2종목을 모두 한 번 이상 성공판정을 받아야 하고 경기장의 바벨은 하나만 있어서 대회에 참가한 선수들은 도전 무게가 낮은 순서부터 무게에 따라 도전하게 된다.

이때 무대에 설치된 바벨에는 무게를 추가할 수만 있고 줄일 수는 없어서 선수들은 자신이 도전하는 무게가 될 때까지 기다리게 된다. 또한 먼저 도전 하여 성공한 선수가 있는데 다음 선수가 같은 무게를 들게 되면 나중에 든 선수는 같은 무게 일지 라도 먼저 든 선수보다 낮은 순위를 갖게 된다.

그렇기 때문에 선수들은 도전 무게를 두고 경쟁자가 도전한 무게 보다 1키로 그램 더 들고 다른 경쟁자 들의 눈치를 본다.

성인이 되고 이제 중년이 지나니 운동하지 않고는 살 수 없다는 생각을 하게 되었다. 그런 생각을 한 후로 평생 운동과는 담쌓고 지내 오던 내가 헬스클럽에 다니게 되었다. 그러면서 깨닫게 된 점이 있었다.

오늘 내가 50Kg을 들 수 있다고 해도 내일 51Kg, 고작 1Kg만 추가 하여 들려고 하면 잘 안된다.

아마도 같은 이유로 아무리 많은 무게를 들 수 있는 선수들이라 해도 자신과 비슷한 수준의 선수들과 겨루게 될 때 1Kg의 무게는 쉽게 넘을 수 없는 벽으로 다가올 것이다. 나에겐 50이냐 51이냐의 싸움이지만 선수들에게는 100이냐 101이냐의 문제일 것이다.

아무튼 우리는 역도 경기 시스템을 만들게 되었다.

기존에 우연한 기회로 개발했었던 장애인용 역도 경기 시스템이 있었다. 그래서 일반을 위한 역도 경기는 어렵지 않게 만들 수 있다고 생각했다.

처음에 시작할 땐 웹소켓으로 선수 정보를 전송하고 데이터베이스에 선수의 판정 결과를 저장하는 수준의 프로그램일 거로 생각했다. 그래서 작업기간 역시 1주일 안에 끝낼 수 있을 것 같았다. 이런 생각을 할 수 있었던 건 어드민의 경우 기존에 개발되었던 장애인용 역도 경기 시스템을 재활용할 수 있다고 생각했기 때문이었다.

경기시스템구성도

나는 시스템구성도를 작성하고 작업에 착수했다. 작업 대부분은 백엔드 개발자들이 API와 웹소켓용 구독 메시지 포맷을 정하고 나면 디자이너에게 전달받은 화면을 연결하는 수준에서 작업을 마치려고 했다.

하지만 개발을 진행하다 보니 문제가 발생하게 되었다.

문제 1 : 빠른 경기진행에 맞지 않는 어드민 페이지

경기관리 시스템 어드민 페이지에서는 대회관리, 경기관리, 경기에 속한 선수명단 관리와 선수들이 시도할 도전 무게 값 정보를 등록하거나 수정할 수 있어야 하고 선수들이 바벨을 들어 올릴 때마다 성공 여부를 기록하고 다음 도전 무게 값을 등록할 수 있어야 한다. 선수들이 도전무게 값을 입력하게 되면 도전 무게와 시기 등의 정보로 우선순위를 정하고 그에 따라 다음 선수가 정해질 수 있어야 한다.

기존 장애인용 역도 경기 시스템은 심판 판정 결과에 따라 자동으로 경기 결과 입력이 완료되고 다음 도전 무겟값을 설정할 때 선수나 코치의 확인을 위해 온라인에서 서명받는 형태였다. 하지만 일반인을 위한 역도 경기는 장애인 경기에 비해 진행 속도가 너무 빨랐다. 일반인 경기장 아나운서는 한 선수가 경기를 마치자마자 다음 선수를 호명했고 경기장의 타이머는 움직이기 시작했다. 그럼 다음 선수는 자신에게 주어진 1분 안에 경기를 마쳐야 했다.

현장에서 확인한 결과 대부분의 선수는 20초 안에 경기를 마쳤다. 그럼 10~30초마다 경기의 심판 판정과 경기 결과 기록, 현재 선수의 다음 도전 무게 세팅이 쉴 사이 없이 진행되어야 했다.

새로운 어드민

AXFRAME 어드민

새로운 경기 운영관리 화면은 경기 상태를 관리하는 작업자의 동선을 고려 하여 버튼과 UI 요소들을 배치하는 데 심혈을 기울였다. 기존의 화면기획 스타일은 주로 데이터의 흐름에 기반하여 위에서 아래로 좌에서 우로 흐르는 형태의 기획을 많이 했었지만 이번에는 작업자가 집중해야 할 요소의 우선순위에 따라 UI 배치를 하였고 그런 이유로 중앙에 현재 선수의 경기 결과를 관리하는 양식을 두고 경기중인 선수목록을 아래쪽에 배치 하게 되었다.

모든 화면은 AXFrame을 활용하여 제작되었기 때문에 다소 난이도가 있는 작업이었지만 전체 작업시간은 2일 소요 되었다.

경기장 출력화면 제작

어드민은 재구성 하고 난 후, 전광판과 타이머조작, 타이머, 진행상태, 심판화면을 준비 하였다.

역도경기 전광판

전광판에는 선수들의 도전 결과와 현재 경기중인 선수 정보를 한눈에 확인할 수 있게 하였고, 한 경기에 여러 체급이 경기를 진행하는 경우를 대비하여 체급별 구분선을 추가하였다. 또한 우측 패널에는 현재시간과 세계기록, 동영상 출력 등의 기능을 두어 전광판에서 경기에 필요한 모든 정보를 표시할 수 있게 하였다.

역도경기 타이머와심판판정

타이머를 조작하면 나머지 화면들에서 웹소켓을 구독하고 있다가 지나는 시간과 현재 선수, 판정상태 등의 정보를 수신하게 된다. 심판판정 화면의 경우엔 타이머가 중지 상태가 되었을 때만 판정을 할 수 있도록 해야하고 심판1, 2, 3이 판정 결과를 선택하면 타이머와 진행상태 화면이 판정 결과 상태를 보여주도록 변경되어야 했다.

역도경기 심판판정

심판판정

역도경기 판정결과

판정결과

화면을 작업을 모두 마치고 로컬 개발환경에서 테스트를 여러 차례 진행했지만 큰 문제가 발생하지 않았다. 그래서 자신에게 이럴리가 없지만 잘했다고 칭찬해준 뒤에 배포 환경으로 전환을 준비 하였다. 조그만 맥미니를 하나 준비하고 도커로 배포를 준비하였다. 그리고 사무실 팀원들과 즐거운 마음으로 테스트를 시작하였다.

문제 2 : 타이머가 1초씩 움직이지 않았다.

타이머 기능을 설계 하면서 RESTApi로 API 서버에 타이머 시작 명령을 전송하면 서버내의 타이머 간격마다 작동되면서 매초 마다 웹 소켓에 시간을 전송하도록 설계하였다. 그리고 웹 소켓을 구독하고 있는 클라이언트들은 매 초마다 타이머의 시간을 받아서 화면에 표시하도록 설계하였다. 이런 방식으로 개발하게 되면 소켓 메시지를 수신받는 타이밍에 타이머의 시간 값이 변경 되게 된다.

타이머작동방식

몇 번의 테스트하는 과정에서 초반에는 문제가 없었다. 하지만 30분 이상 테스트가 지속되자 타이머의 시간이 한번은 1초씩 움직이고 한번은 2초씩 움직이는 현상이 발생하였다.

이런 문제가 발생한 원인은 소켓 서버가 매초 마다 메시지를 보내지 않고 한 번은 0.5초마다 한 번은 1.5초마다 메시지가 전송되는 현상이 발생 되었고 단순히 소켓 서버의 메시지를 구독하는 방식으로는 문제 해결을 할 수 없다는 결론을 얻었다.

잠깐의 고민을 하다가 타이머의 시간을 서버에서 결정해서 클라이언트에 전송하는 방식을 버리고 타이머의 시간의 좌푯값을 클라이언트에 전달하고 클라이언트에서는 타이머의 시간을 계산하는 방식으로 변경 해야겠다는 결론을 얻었다.

export interface Timer {
time?: number;
initTime?: number;

timerStatus?: string; // "RESET" | "START" | "STOP";
endSecond?: number; // 60 == 60 * 1000;
startTimeSerial?: number;
currentTimeSerial?: number;
}

타이머 소켓이 전송하는 데이터 인터페이스를 위와 같이 변경하고 타이머의 시간을 클라이언트에서 계산하도록 변경하였다. 그리고 타이머의 시간을 계산하는 함수를 작성하였다. 클라이언트는 소켓의 timerStats를 받아 타이머 작동을 시작하고 만일 타이머 시작 중에 시작된 클라이언트가 있다면 timeSerial값을 이용하여 현재 표시되어야 하는 타이머의 값을 계산하고 표시하도록 하였다.

let secondTimer: number;
class SecondWorker {
public startTimeSerial: number;
public currentTimeSerial: number;
public endSecond: number;
public timerStatus: string;
public second: number;
public onChangeSecond: (second: number) => void;

constructor(
startTimeSerial: number,
currentTimeSerial: number,
endSecond: number,
timerStatus: string,
onChangeSecond: (second: number) => void,
) {
this.startTimeSerial = startTimeSerial;
this.currentTimeSerial = currentTimeSerial;
this.endSecond = endSecond;
this.timerStatus = timerStatus;
this.second = endSecond;
this.onChangeSecond = onChangeSecond;
}

setStartTimeSerial(startTimeSerial: number) {
this.startTimeSerial = startTimeSerial;
}

setCurrentTimeSerial(currentTimeSerial: number) {
this.currentTimeSerial = currentTimeSerial;
}

setEndSecond(endSecond: number) {
this.endSecond = endSecond;
}

start() {
if (secondTimer) window.clearInterval(secondTimer);

secondTimer = window.setInterval(() => {
this.currentTimeSerial = new Date().getTime();
const diff = this.currentTimeSerial - this.startTimeSerial;
const _second = Math.max(0, this.endSecond - Math.floor(diff / 1000));

if (this.second !== _second) {
this.second = _second;
this.onChangeSecond(this.second);
}
}, 10);
}

stop() {
if (secondTimer) window.clearInterval(secondTimer);
const diff = this.currentTimeSerial - this.startTimeSerial;
this.second = Math.max(0, this.endSecond - Math.floor(diff / 1000));
this.onChangeSecond(this.second);
}
}

클라이언트에서 SecondWorker의 인스턴스를 생성하여 준비하여 두고 타이머 데이터를 수신하게 되면 작동되게 하니 소켓데이터의 간격이 일정하지 않아도 모든 클라이언트의 타이머가 같이 작동되는 것을 확인할 수 있었다. 이렇게 해서 타이머의 시간이 1초씩 움직이지 않는 문제를 해결할 수 있었다. 이런 경험을 통해서 소켓을 이용한 실시간 데이터 전송은 데이터의 흐름을 고려하여 설계해야 한다는 것을 배울 수 있었다.

맺음말

한 명의 개발자로서 쉽게 접해볼 수 없는 경험을 하게 되고 또 문제를 해결하는 과정을 고민하여 해결할 수 있게 되어 재미있는 경험을 한 프로젝트라고 생각한다. 개발자에게 새로운 문제라는 것은 두렵기도 하지만 힘든 만큼 즐거운 경험이 될 수 있다는 것을 다시 한번 느낄 수 있었다. 또 이런 문제를 만나게 될까 두렵기도 하지만 이런 문제를 만나게 된다면 또 어떻게 해결할 수 있을지에 대한 기대감도 든다.