VR Beat Saber — 코드 리뷰
Unity + Meta Quest 기반 커스텀 비트 세이버 클론 | 학습용 코드 리뷰 문서
프로젝트 개요
유저가 MP3 파일이나 URL을 올리면 Beat Sage AI가 비트맵을 자동 생성하고, Synology NAS에 저장한 뒤 Quest VR에서 플레이하는 시스템이다.
SongCreator 씬
MP3/URL 입력 → Beat Sage API → NAS 업로드. 곡을 시스템에 등록하는 파이프라인.
SongSelect 씬
NAS에서 songs.json 로드 → 카드 목록 표시 → 다운로드/플레이 선택.
Game 씬
캐시된 MP3 + 맵 JSON 로드 → 카운트다운 → 큐브 스폰 → 스코어 집계.
아키텍처 & 데이터 흐름
전체 파이프라인
AI 비트맵 생성
.dat → NoteData
NAS 업로드
NAS → 로컬 캐시
씬 간 선택 전달
로드 + 스폰
캐시 경로 규칙
// DownloadManager, SongController 공통 경로 — 항상 일치해야 함 Application.temporaryCachePath/beatsaber/ {songId}/ {songId}.mp3 ← 오디오 Map_{songId}_normal.json ← 난이도별 맵 JSON Map_{songId}_hard.json Map_{songId}_expert.json Map_{songId}_expertplus.json
SongController와 DownloadManager가 각자 CacheRoot 프로퍼티를 중복 선언한다. 경로가 달라지면 파일을 못 찾으므로 공용 상수로 리팩토링하면 좋다.
스크립트 의존 관계
| 스크립트 | 의존 대상 | 의존 방식 |
|---|---|---|
| SongController | GameSession, AudioManager, VR_BeatManager | static / FindObjectOfType / singleton |
| SongSelectManager | DownloadManager, SongDetailPanel, SongLibrary | SerializeField / singleton |
| NasPublisher | BeatSageConverter | static class 직접 호출 |
| BeatSageUploader | BeatSageConverter, NoteData | static class 직접 호출 |
| DownloadManager | NoteData (SongInfo) | 파라미터 |
NoteData.cs — 데이터 모델 계층
프로젝트의 모든 데이터 구조가 한 파일에 정의되어 있다. Unity의 JsonUtility와 호환되도록 [Serializable] 속성이 붙어 있다.
public class NoteData { public float time; // 초 단위 (비트 → 초 변환 후) public int position; // 열 0-3 public int lineLayer; // 행 0-2 public int colorType; // 0=빨강, 1=파랑 public int cutDirection; // 0-8 (Beat Saber 스펙) } public class DifficultyMap { public DifficultyInfo normal; public DifficultyInfo hard; public DifficultyInfo expert; public DifficultyInfo expertplus; public DifficultyInfo Get(string key) => key switch // C# 8 switch expression { "normal" => normal, "hard" => hard, "expert" => expert, "expertplus" => expertplus, _ => null }; }
C# 8 switch expression: 전통적인 switch문 대신 람다처럼 쓸 수 있는 표현식. _는 default 케이스다. 반환값이 있는 간단한 매핑에 적합.
DifficultyMap은 필드 4개가 하드코딩되어 있다. 난이도가 추가되면 클래스를 수정해야 한다. Dictionary<string, DifficultyInfo>로 바꾸면 유연하지만 JsonUtility가 Dictionary를 직렬화하지 못하므로 Newtonsoft.Json이 필요하다.
GameSession.cs — 씬 간 데이터 전달
// static container — 씬이 바뀌어도 메모리에 남는다 public static class GameSession { public static SongInfo SelectedSong; public static string SelectedDifficulty; }
인스턴스를 생성할 수 없고, 모든 멤버가 자동으로 static이다. MonoBehaviour를 상속하지 않으므로 씬이 바뀌어도 값이 유지된다. Unity에서 씬 간 데이터를 넘기는 가장 단순한 방법.
DontDestroyOnLoad: MonoBehaviour 기반, 씬 전환 후에도 GameObject 유지.
ScriptableObject: Inspector에서 확인 가능, 에디터 재시작 전까지 유지.
PlayerPrefs: 앱 재시작 후에도 유지 (영구 저장).
static class: 가장 단순. 앱 종료하면 사라짐. 이 프로젝트엔 충분.
BeatSageConverter.cs — 포맷 변환
Beat Saber의 .dat JSON 포맷을 우리 내부 NoteData로 변환하는 순수 로직 클래스다.
핵심 변환: 비트 → 초
// Beat Saber의 _time은 "비트 단위" // 실제 시간(초) = 비트 / BPM * 60 time = (note._time * 60f) / bpm, // 예: BPM=120, _time=4 → 4/120*60 = 2.0초 // 폭탄(type=3), 장애물 등은 스킵 if (note._type != 0 && note._type != 1) continue;
상태(필드)가 전혀 없는 순수 함수들의 모음이다. 인스턴스를 만들 필요가 없으므로 static class가 적합. C#의 유틸리티 클래스 패턴.
BPM 자동 감지 (info.dat)
public static SongMetadata ParseInfoDat(string json) { var info = JsonUtility.FromJson<BeatSageInfoDat>(json); if (info == null) return null; return new SongMetadata { title = (info._songName ?? "").Trim(), // null 병합 연산자 artist = (info._songAuthorName ?? "").Trim(), bpm = info._beatsPerMinute, }; }
a ?? b — a가 null이면 b를 반환. a != null ? a : b의 축약형. JSON 파싱 시 누락된 필드가 null로 들어올 때 안전하게 처리.
BeatSageUploader.cs — 외부 API 연동
Beat Sage 서버에 오디오를 올리고, 생성 완료를 폴링한 뒤, 결과 ZIP을 다운받아 변환까지 처리한다.
코루틴 체이닝 구조
Upload() / UploadFromUrl() └─ CreateLevel() / CreateLevelFromUrl() // [1/4] POST → levelId 획득 └─ PollAndDownload() // 공통 phase 2~4 ├─ PollHeartbeat() (5초 간격, 최대 300초) // [2/4] 생성 완료 대기 ├─ DownloadZip() (최대 3회 재시도) // [3/4] ZIP 다운로드 └─ ExtractAndConvert() // [4/4] .dat → NoteData
코루틴 안에서 다른 코루틴을 yield return StartCoroutine()으로 호출하면 내부 코루틴이 끝날 때까지 기다린다. 순차적인 비동기 작업을 async/await 없이 체이닝하는 Unity 방식.
멀티파트 폼 — URL vs 파일
// URL 업로드: audio_url 필드에 문자열 new MultipartFormDataSection("audio_url", audioUrl) // 파일 업로드: 바이트 배열 + MIME 타입 new MultipartFormFileSection("audio_file", audioBytes, fileName, "audio/mpeg")
폴링 타임아웃 패턴
float elapsed = 0f; while (!ready && elapsed < POLL_TIMEOUT) // 300초 초과 시 탈출 { yield return new WaitForSeconds(POLL_INTERVAL); // 5초 대기 elapsed += POLL_INTERVAL; yield return PollHeartbeat(levelId, status => { ready = status == "generated" || status == "done"; error = status == "error"; }, onError); onProgress?.Invoke(0.15f + Mathf.Clamp01(elapsed / POLL_TIMEOUT) * 0.6f); }
Unity 코루틴은 값을 return할 수 없다. 대신 콜백(Action)을 파라미터로 받아서 결과를 전달한다. onSuccess, onError가 대표적. ?.는 null 조건부 호출 — null이면 아무것도 안 함.
간이 JSON 파싱
private static string ParseJsonString(string json, string key) { string search = $"\"{key}\":"; // "id":" 를 찾음 int start = json.IndexOf(search); if (start < 0) return null; start += search.Length; int end = json.IndexOf('"', start); // 닫는 " 위치 return end > start ? json.Substring(start, end - start) : null; }
Beat Sage 응답이 간단해서 IndexOf로 파싱하지만, JSON 값에 이스케이프된 따옴표(\")가 있으면 틀린다. 복잡한 응답엔 JsonUtility나 Newtonsoft.Json을 써야 한다.
NasPublisher.cs — Synology DSM API
DSM FileStation API를 통해 NAS에 파일을 업로드한다. 로그인 → 업로드 → 로그아웃 세션 흐름을 코루틴 체이닝으로 구현.
수동 multipart body 구성 — 핵심 패턴
// Unity 기본 UnityWebRequest.Post(form) 는 DSM에서 401 반환 → 수동 구성 필요 string boundary = Guid.NewGuid().ToString("N"); // 랜덤 경계 문자열 using var body = new MemoryStream(); // 텍스트 필드 WriteField("path", nasFolder); WriteField("create_parents", "true"); WriteField("overwrite", "true"); // 파일 파트 WriteText($"--{boundary}\r\n"); WriteText($"Content-Disposition: form-data; name=\"file\"; filename=\"{fileName}\"\r\n"); WriteText("Content-Type: application/octet-stream\r\n\r\n"); body.Write(bytes, 0, bytes.Length); WriteText("\r\n--{boundary}--\r\n"); // 종료 경계 req.uploadHandler = new UploadHandlerRaw(body.ToArray()); req.SetRequestHeader("Content-Type", $"multipart/form-data; boundary={boundary}");
--boundary 로 각 파트를 구분하고, 마지막은 --boundary--로 끝낸다.
Content-Disposition에 name(필드명)과 파일이면 filename을 명시.
바이너리 데이터는 MemoryStream에 직접 Write().
songs.json Patch 전략
// 기존 목록 읽기 → 없으면 새로 생성 (null 병합) list ??= new SongsList { version = "1.0", songs = new List<SongInfo>() }; // 같은 id가 있으면 교체, 없으면 추가 — upsert 패턴 int idx = list.songs.FindIndex(s => s.id == newSong.id); if (idx >= 0) list.songs[idx] = newSong; else list.songs.Add(newSong);
DB의 INSERT OR UPDATE와 동일 개념. 리스트에서 인덱스를 찾아 있으면 교체, 없으면 추가. FindIndex는 조건 람다를 받아 인덱스를 반환하며, 없으면 -1.
DownloadManager.cs — 파일 다운로드 캐시
NAS 정적 서버에서 MP3와 맵 JSON을 로컬 캐시로 내려받는다. 오디오 70% + 맵 30% 비율로 진행률을 계산.
진행률 분할 계산
// 1단계: 오디오 (전체의 0% ~ 70%) yield return DownloadFile(audioUrl, audioPath, p => onProgress?.Invoke(p * 0.7f), // 0.0 ~ 0.7 err => { ... }); // 2단계: 맵 (전체의 70% ~ 100%) yield return DownloadFile(mapUrl, mapPath, p => onProgress?.Invoke(0.7f + p * 0.3f), // 0.7 ~ 1.0 err => { ... });
이미 있으면 스킵
// 캐시 히트 → 다운로드 건너뜀 (멱등성 보장) if (!File.Exists(audioPath)) { yield return DownloadFile(...); }
같은 작업을 여러 번 해도 결과가 동일한 성질. 이미 다운로드된 파일은 재다운로드하지 않으므로 다운로드 중 앱 종료 후 재시도해도 안전하다. 단, 오디오는 있고 맵이 없으면 오디오를 스킵하고 맵만 받는다.
DownloadHandlerFile이 실패하면 File.Delete(savePath)로 정리한다. 그렇지 않으면 0바이트 파일이 남아 다음에 "캐시 히트"로 오판할 수 있다.
SongController.cs — Game 씬 핵심 브릿지
Game 씬이 시작되면 GameSession에서 선택 정보를 읽어 오디오·맵을 로드하고, 카운트다운 후 큐브를 스폰한다.
전체 코루틴 흐름
비동기
동기
3,2,1,GO
travelTimeOverride — 동시 노트 보정
private void SpawnNote(NoteData note) { // 스폰 시점의 실제 남은 시간으로 travelTime을 역산 // → foreach 순차 처리로 생기는 16ms 프레임 차이를 흡수 float remaining = note.time - _audio.CurrentTime; float travelTime = Mathf.Max(0.05f, remaining); var info = new SpawnEventInfo { travelTimeOverride = travelTime, // VRBeatsKit이 이 값을 우선 사용 ... }; }
문제: BPM=120에서 동시 노트 2개가 foreach로 처리되면 1프레임(16ms) 차이가 난다.
해결: travelTime을 "설정값"이 아니라 "스폰 시점에서 목표 시간까지 남은 실제 시간"으로 주입.
노트 A를 스폰할 때 remaining=1.800, B를 스폰할 때 remaining=1.784 → 각각 다른 speed로 발사되어 히트존에 동시 도착.
cutDirection 매핑 — 조회 테이블
// if-else 체인 대신 배열 인덱스로 O(1) 매핑 private static readonly Direction[] CutDirMap = { Direction.Up, Direction.Down, Direction.Left, Direction.Right, Direction.UpperLeft, Direction.UpperRight, Direction.LowerLeft, Direction.LowerRight, Direction.Center, // index 8 = Any/Dot }; private static Direction MapCutDirection(int cut) => (cut >= 0 && cut < CutDirMap.Length) ? CutDirMap[cut] : Direction.Center;
switch/if-else 9개 대신 static readonly 배열 + 인덱스 접근. 컴파일 타임에 메모리를 할당하고 GC 없이 재사용. 매핑 항목이 많을수록 코드가 깔끔하고 빠르다.
위치 계산
// Beat Saber 그리드 → 월드 좌표 선형 매핑 float x = -0.375f + note.position * 0.25f; // 열 0→-0.375, 1→-0.125, 2→0.125, 3→0.375 float y = -0.333f + note.lineLayer * 0.333f; // 행 0→-0.333, 1→0, 2→0.333
SongSelectManager.cs — 동적 UI 생성
Inspector에서 프리팹을 쓰지 않고 코드로 직접 GameObject를 생성해 카드 리스트를 만든다.
카드 생성 패턴
var card = new GameObject(song.title); card.transform.SetParent(cardContainer, worldPositionStays: false); // false = 로컬 유지 var bg = card.AddComponent<Image>(); var btn = card.AddComponent<Button>(); // 클로저 캡처 버그 방지 — foreach 루프 변수를 로컬에 복사 SongInfo captured = song; btn.onClick.AddListener(() => OnCardClicked(captured));
foreach에서 람다로 루프 변수를 캡처하면, 루프가 끝난 뒤 모든 버튼이 마지막 곡을 가리킨다.
SongInfo captured = song; 으로 로컬 복사본을 만들어야 각 클로저가 자신의 값을 가진다.
C# 5 이후 foreach는 수정됐지만, for(int i=0; ...)에선 여전히 발생하므로 습관적으로 복사하는 게 좋다.
마퀴 스크롤 구조
card └─ TitleMask (RectMask2D) ← 클리핑 컨테이너 └─ Title (TMP_Text + MarqueeText) ← 텍스트가 마스크 밖으로 스크롤 └─ Artist (TMP_Text) └─ Badge (Image + TMP_Text) ← 다운로드된 곡에만 표시
Mask 컴포넌트와 달리 스텐실 버퍼를 쓰지 않아 오버헤드가 적다. RectTransform 영역 밖의 자식을 잘라낸다. 텍스트가 컨테이너 너비를 넘어도 잘리고, 자식이 스크롤하면 마퀴처럼 보인다.
오프라인 폴백 (캐시)
downloadManager.FetchSongsList( onSuccess: list => { SaveCache(list); // 성공하면 캐시 갱신 RefreshCards(); }, onError: _ => { SongsList cached = LoadCache(); // 실패하면 캐시로 폴백 if (cached != null) RefreshCards(); else errorOverlay.SetActive(true); // 캐시도 없으면 에러 표시 });
디자인 패턴 정리
| 패턴 | 사용 위치 | 설명 |
|---|---|---|
| Coroutine Chaining | BeatSageUploader, SongController, NasPublisher | yield return StartCoroutine()으로 비동기 작업을 순차 실행. async/await 대신 Unity 방식. |
| Callback (Action) | DownloadManager, BeatSageUploader, NasPublisher | onSuccess / onError / onProgress 콜백으로 코루틴 결과 전달. |
| Static Class (전역 상태) | GameSession, BeatSageConverter, SongLibrary | GameSession은 씬 간 데이터 전달, BeatSageConverter는 순수 유틸리티. |
| Lookup Table | SongController.CutDirMap, BeatSageUploader.DiffNames | static readonly 배열/딕셔너리로 switch-case를 대체. GC 없음. |
| Upsert | NasPublisher.PatchSongsJson() | FindIndex로 기존 항목 교체, 없으면 추가. DB의 INSERT OR REPLACE와 동일. |
| Offline Fallback | SongSelectManager.FetchSongs() | 네트워크 실패 시 로컬 캐시 JSON을 사용. 오프라인에서도 목록 표시 가능. |
| Idempotent Cache | DownloadManager.DownloadSongCoroutine() | 파일 존재 여부 확인 후 스킵. 중복 다운로드 없음. |
Unity 핵심 팁 (이 프로젝트에서 배우는 것)
1. UnityWebRequest 패턴
using var req = UnityWebRequest.Get(url); yield return req.SendWebRequest(); if (req.result != UnityWebRequest.Result.Success) { /* 에러 처리 */ } string text = req.downloadHandler.text;
using var req = UnityWebRequestMultimedia.GetAudioClip("file://" + path, AudioType.MPEG); yield return req.SendWebRequest(); AudioClip clip = DownloadHandlerAudioClip.GetContent(req);
2. WaitUntil — 조건 기반 대기
// 조건이 true가 될 때까지 매 프레임 체크 yield return new WaitUntil(() => _audio.CurrentTime >= spawnAt);
3. LayoutRebuilder — 동적 UI 갱신
// 자식을 코드로 추가한 뒤 레이아웃 강제 재계산 LayoutRebuilder.ForceRebuildLayoutImmediate(cardContainer); Canvas.ForceUpdateCanvases();
4. using 선언 (C# 8)
// 블록 없이 — 변수가 선언된 스코프 끝에서 자동 Dispose using var req = UnityWebRequest.Get(url); using var ms = new MemoryStream(bytes); // 전통 방식 (C# 7 이하) using (var req = UnityWebRequest.Get(url)) { ... }
5. Unity ?? 연산자 주의사항
// 버그: Unity 오브젝트에 ?? 연산자 사용 → Destroyed 오브젝트를 null로 인식 못함 var light = _pointLight ?? GetComponent<Light>(); // ← 위험! // 수정: 명시적 GetComponent만 사용 var light = GetComponent<Light>(); if (light != null) { ... }
Unity의 UnityEngine.Object는 == 연산자를 오버로드해서 Destroyed 상태를 null처럼 처리한다. 하지만 C#의 ??는 CLR 레벨의 null만 체크하므로 Destroyed 오브젝트를 null로 인식하지 못한다. → Unity 오브젝트엔 ?? 대신 if (x != null) 사용.
셀프 퀴즈
SongInfo captured = song;으로 로컬 복사본을 만들면 각 람다가 독립적인 값을 캡처한다.
남은 작업
- SongCreator 씬 — Beat Sage API + NAS 업로드 파이프라인
- SongSelect 씬 — 카드 목록 + 다운로드 + 플레이
- Game 씬 — SongController, 카운트다운, 큐브 스폰
- travelTimeOverride — 동시 노트 보정
- Git remote 설정 (Synology NAS)
- Game 씬 ScoreManager / ScoreHUD 연결
- Game 씬 ResultsPanel 연결 (CLEAR/FAILED, 랭크)
- VR 기기 실제 플레이 테스트
- targetTravelTime 1.8 플레이 후 미세 조정