아리랑의 정답 악보에서 쉼을 제외하고 남은 음의 길이를 차례로 더했더니, 전체 61개 음 중 45개의 기대 시작 시각이 앞당겨졌습니다. 차이가 가장 큰 음은 약 1.67박, 96 BPM에서 약 1.04초 일찍 시작하는 것으로 계산됐습니다. 팀원이 검수된 악보를 기준으로 두 계산 방식의 기대 시각을 비교해 발견한 차이였습니다.
얼쑤의 백엔드는 사용자가 선택한 연습 구간의 음표를 골라 채점기에 보냅니다. 채점 결과의 expected_pitch_id는 NOT NULL이고, 실제 음높이 행을 외래 키로 참조합니다. 정답 음이 없는 쉼은 채점용 음표 목록에서 제외하고, 곡의 음표 수와 연습 구간 번호도 NOTE만 세는 기준에 맞췄습니다. 문제는 남은 음의 길이만으로 시간축을 다시 만들 때 생겼습니다. 음 하나의 길이는 그대로여도, 그다음 음까지 얼마나 쉬어야 하는지는 알 수 없기 때문입니다.
음 길이와 시작 박 분리
1박을 4틱으로 나눈 가상의 악보를 예로 들어 보겠습니다. A를 1박 연주하고 1박 쉰 뒤 B를 반 박 연주합니다. 그다음에 C가 이어집니다.
| 이벤트 | 시작 틱 | 원래 시작 박 | 음 길이만 이었을 때의 시작 박 |
|---|---|---|---|
| 음 A | 0 | 0 | 0 |
| 쉼 | 4 | 1 | — |
| 음 B | 8 | 2 | 1 |
| 음 C | 10 | 2.5 | 1.5 |
제가 작업한 백엔드 변환에서는 원본의 시작 틱과 길이를 각각 박으로 바꿔 전달했습니다. beats는 durationTick / ticksPerBeat로 음이 지속되는 길이이고, startBeat는 startTick / ticksPerBeat로 곡의 처음부터 센 시작 박입니다. 나누기 전에 실수형으로 바꾸면 C의 시작 위치인 2.5박도 유지됩니다.
채점기를 맡은 팀원은 받은 시작 박에서 연습 구간의 첫 음이 시작하는 박을 빼고, 그 차이를 녹음의 시간축에 맞췄습니다. 예시에서 B부터 연습한다면 B는 0박, C는 0.5박입니다. A부터 연습한다면 A가 끝난 뒤 B가 시작하기까지 1박이 비고, 채점기는 그 구간을 쉼으로 처리합니다.
화면과 채점기의 음표 기준 통일
시간축과 별개로 채점 결과를 화면의 어느 음에 표시할지도 정해야 했습니다. 백엔드는 원본 이벤트에서 실제 음표만 순서대로 세어 idx를 붙이고, 연습 구간을 잘라도 그 번호를 유지합니다. 가상의 악보에서는 B가 세 번째 이벤트이면서 채점 번호는 1입니다. 결과가 번호 1로 돌아오면 같은 버전의 악보에서 두 번째 음을 찾고, 그 음의 정간 위치에 표시할 수 있습니다.
원본 이벤트의 id는 이벤트를 가리키고, cellId는 그 이벤트가 놓인 정간을 가리킵니다. 채점 번호 idx는 NOTE만 세어 별도로 붙입니다. 곡 상세 응답에는 DB의 악보를 JSON 객체로 담아 이벤트 값과 배열 순서를 유지했습니다.
이를 위해 앱과 채점 요청이 같은 곡의 같은 버전에서 음표 순서를 해석하도록 했습니다. 세션을 만든 뒤 악보 버전이 바뀌면 분석 요청을 만들기 전에 멈춥니다. 같은 번호가 가리키는 음이나 정간이 바뀔 수 있기 때문입니다. 백엔드 변환 테스트에서는 예시처럼 쉼 뒤 음의 시작 박이 각각 2박과 2.5박으로 전달됐고, B부터 잘라 보내도 원곡 번호 1과 2가 유지됐습니다.
같은 MySQL 매퍼 검사에서 현재 곡의 악보 버전을 3, 세션이 기억하는 버전을 2로 엇갈리게 주자 채점용 음표를 만드는 단계에서 예외가 났습니다. 별도 분석 서비스 검사에서는 악보 읽기 예외가 난 경우 AI 호출 없이 SERVER_ERROR가 기록됐습니다.
채점기에 보낼 반음 번호와 DB에 저장할 음높이 ID도 나눴습니다. 매퍼는 율명과 옥타브로 단소 음높이 시드의 행을 찾고, 그 행의 semitone 값을 읽습니다. 같은 검사에서 청황은 반음 번호 12와 음높이 ID 4, 중청중은 반음 번호 29와 음높이 ID 11로 변환됐습니다.
팀원의 채점기 검증에서는 아리랑 악보의 시작 박을 읽어 61음의 합성 연주를 만들었습니다. 정상 입력에서는 모든 음이 정상으로 판정됐습니다. 오류를 섞은 입력에서는 쉼 뒤 0.1초 늦게 시작하도록 만든 음이 박자 오류로 분류됐습니다. 곡 중간 구간만 잘라 테스트했을 때도 박자 오류가 원곡의 음 번호로 돌아왔습니다.
필수 필드를 배포하는 순서
음표 번호 규칙은 먼저 스펙에 제안으로 적었습니다. 데이터 담당과는 note_count가 실제 음표만 세는지 확인했고, 앱이 구간을 선택할 때 사용하는 번호 기준은 별도 확인 항목으로 남겼습니다. 이 검토에서 데이터 담당이 쉼 뒤의 시작 위치도 전달해야 한다고 짚었습니다. startBeat를 추가한 이유를 스펙 변경 이력에 남겼습니다.
분석 API는 startBeat가 없는 요청을 HTTP 422로 거절하도록 했고, 필드를 뺀 요청으로 그 응답을 확인했습니다. 예전 백엔드가 보내는 요청에는 이 필드가 없어서, 수신 쪽 변경만 먼저 운영에 적용하면 분석 요청이 거절될 수 있었습니다. 병합 요청에 백엔드의 송신 변경과 함께 적용할 순서를 적었습니다. 병합 당시에는 백엔드의 분석 연결이 꺼져 있어 이 변경으로 인한 운영 장애는 없었습니다.
검수한 악보와 DB 시드 대조
startBeat를 올바르게 계산하려면 시드의 쉼과 시작 틱도 보존돼야 합니다. 팀이 검수한 정답에서 다시 만든 JSON은 저장소의 V6 시드와 네 곡 모두 일치했고, 곡별 이벤트 수와 별도 대조 방법은 시드 검증 글에 정리했습니다. 단소 악보 4곡의 DB 시드 검증

댓글
댓글과 반응은 GitHub 계정으로 로그인해 남길 수 있어요.