프로젝트 개요

유저가 MP3 파일이나 URL을 올리면 Beat Sage AI가 비트맵을 자동 생성하고, Synology NAS에 저장한 뒤 Quest VR에서 플레이하는 시스템이다.

SongCreator 씬

MP3/URL 입력 → Beat Sage API → NAS 업로드. 곡을 시스템에 등록하는 파이프라인.

SongSelect 씬

NAS에서 songs.json 로드 → 카드 목록 표시 → 다운로드/플레이 선택.

Game 씬

캐시된 MP3 + 맵 JSON 로드 → 카운트다운 → 큐브 스폰 → 스코어 집계.

아키텍처 & 데이터 흐름

전체 파이프라인

SongCreator
BeatSageUploader
AI 비트맵 생성
BeatSageConverter
.dat → NoteData
NasPublisher
NAS 업로드
songs.json 갱신
SongSelect
DownloadManager
NAS → 로컬 캐시
GameSession
씬 간 선택 전달
Game 씬
SongController
로드 + 스폰

캐시 경로 규칙

Cache Layout Application.temporaryCachePath
// 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
주의

SongControllerDownloadManager가 각자 CacheRoot 프로퍼티를 중복 선언한다. 경로가 달라지면 파일을 못 찾으므로 공용 상수로 리팩토링하면 좋다.

스크립트 의존 관계

스크립트의존 대상의존 방식
SongControllerGameSession, AudioManager, VR_BeatManagerstatic / FindObjectOfType / singleton
SongSelectManagerDownloadManager, SongDetailPanel, SongLibrarySerializeField / singleton
NasPublisherBeatSageConverterstatic class 직접 호출
BeatSageUploaderBeatSageConverter, NoteDatastatic class 직접 호출
DownloadManagerNoteData (SongInfo)파라미터

NoteData.cs — 데이터 모델 계층

프로젝트의 모든 데이터 구조가 한 파일에 정의되어 있다. Unity의 JsonUtility와 호환되도록 [Serializable] 속성이 붙어 있다.

NoteData.cs
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 — 씬 간 데이터 전달

GameSession.cs
// static container — 씬이 바뀌어도 메모리에 남는다
public static class GameSession
{
    public static SongInfo SelectedSong;
    public static string   SelectedDifficulty;
}
학습 포인트 — static class란?

인스턴스를 생성할 수 없고, 모든 멤버가 자동으로 static이다. MonoBehaviour를 상속하지 않으므로 씬이 바뀌어도 값이 유지된다. Unity에서 씬 간 데이터를 넘기는 가장 단순한 방법.

대안 비교

DontDestroyOnLoad: MonoBehaviour 기반, 씬 전환 후에도 GameObject 유지.
ScriptableObject: Inspector에서 확인 가능, 에디터 재시작 전까지 유지.
PlayerPrefs: 앱 재시작 후에도 유지 (영구 저장).
static class: 가장 단순. 앱 종료하면 사라짐. 이 프로젝트엔 충분.

BeatSageConverter.cs — 포맷 변환

Beat Saber의 .dat JSON 포맷을 우리 내부 NoteData로 변환하는 순수 로직 클래스다.

핵심 변환: 비트 → 초

BeatSageConverter.cs — Convert()
// 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 사용 이유

상태(필드)가 전혀 없는 순수 함수들의 모음이다. 인스턴스를 만들 필요가 없으므로 static class가 적합. C#의 유틸리티 클래스 패턴.

BPM 자동 감지 (info.dat)

BeatSageConverter.cs — ParseInfoDat()
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,
    };
}
null 병합 연산자 ??

a ?? b — a가 null이면 b를 반환. a != null ? a : b의 축약형. JSON 파싱 시 누락된 필드가 null로 들어올 때 안전하게 처리.

BeatSageUploader.cs — 외부 API 연동

Beat Sage 서버에 오디오를 올리고, 생성 완료를 폴링한 뒤, 결과 ZIP을 다운받아 변환까지 처리한다.

코루틴 체이닝 구조

BeatSageUploader.cs — Upload() 흐름
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()

코루틴 안에서 다른 코루틴을 yield return StartCoroutine()으로 호출하면 내부 코루틴이 끝날 때까지 기다린다. 순차적인 비동기 작업을 async/await 없이 체이닝하는 Unity 방식.

멀티파트 폼 — URL vs 파일

BeatSageUploader.cs
// URL 업로드: audio_url 필드에 문자열
new MultipartFormDataSection("audio_url", audioUrl)

// 파일 업로드: 바이트 배열 + MIME 타입
new MultipartFormFileSection("audio_file", audioBytes, fileName, "audio/mpeg")

폴링 타임아웃 패턴

BeatSageUploader.cs — PollAndDownload()
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);
}
학습 포인트 — Action 콜백 패턴

Unity 코루틴은 값을 return할 수 없다. 대신 콜백(Action)을 파라미터로 받아서 결과를 전달한다. onSuccess, onError가 대표적. ?.는 null 조건부 호출 — null이면 아무것도 안 함.

간이 JSON 파싱

BeatSageUploader.cs — ParseJsonString()
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 값에 이스케이프된 따옴표(\")가 있으면 틀린다. 복잡한 응답엔 JsonUtilityNewtonsoft.Json을 써야 한다.

NasPublisher.cs — Synology DSM API

DSM FileStation API를 통해 NAS에 파일을 업로드한다. 로그인 → 업로드 → 로그아웃 세션 흐름을 코루틴 체이닝으로 구현.

수동 multipart body 구성 — 핵심 패턴

NasPublisher.cs — UploadBytes()
// 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}");
학습 포인트 — multipart/form-data 구조

--boundary 로 각 파트를 구분하고, 마지막은 --boundary--로 끝낸다.
Content-Dispositionname(필드명)과 파일이면 filename을 명시.
바이너리 데이터는 MemoryStream에 직접 Write().

songs.json Patch 전략

NasPublisher.cs — PatchSongsJson()
// 기존 목록 읽기 → 없으면 새로 생성 (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);
학습 포인트 — Upsert 패턴

DB의 INSERT OR UPDATE와 동일 개념. 리스트에서 인덱스를 찾아 있으면 교체, 없으면 추가. FindIndex는 조건 람다를 받아 인덱스를 반환하며, 없으면 -1.

DownloadManager.cs — 파일 다운로드 캐시

NAS 정적 서버에서 MP3와 맵 JSON을 로컬 캐시로 내려받는다. 오디오 70% + 맵 30% 비율로 진행률을 계산.

진행률 분할 계산

DownloadManager.cs — DownloadSongCoroutine()
// 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 => { ... });

이미 있으면 스킵

DownloadManager.cs
// 캐시 히트 → 다운로드 건너뜀 (멱등성 보장)
if (!File.Exists(audioPath))
{
    yield return DownloadFile(...);
}
학습 포인트 — 멱등성(Idempotency)

같은 작업을 여러 번 해도 결과가 동일한 성질. 이미 다운로드된 파일은 재다운로드하지 않으므로 다운로드 중 앱 종료 후 재시도해도 안전하다. 단, 오디오는 있고 맵이 없으면 오디오를 스킵하고 맵만 받는다.

주의 — 실패 시 불완전 파일

DownloadHandlerFile이 실패하면 File.Delete(savePath)로 정리한다. 그렇지 않으면 0바이트 파일이 남아 다음에 "캐시 히트"로 오판할 수 있다.

SongController.cs — Game 씬 핵심 브릿지

Game 씬이 시작되면 GameSession에서 선택 정보를 읽어 오디오·맵을 로드하고, 카운트다운 후 큐브를 스폰한다.

전체 코루틴 흐름

Start()
LoadAndPlay()
GetAudioClip
비동기
File.ReadAllText
동기
Countdown
3,2,1,GO
SpawnRoutine
+
WaitForCompletion

travelTimeOverride — 동시 노트 보정

SongController.cs — SpawnNote()
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 매핑 — 조회 테이블

SongController.cs
// 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;
학습 포인트 — 조회 테이블(Lookup Table)

switch/if-else 9개 대신 static readonly 배열 + 인덱스 접근. 컴파일 타임에 메모리를 할당하고 GC 없이 재사용. 매핑 항목이 많을수록 코드가 깔끔하고 빠르다.

위치 계산

SongController.cs
// 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를 생성해 카드 리스트를 만든다.

카드 생성 패턴

SongSelectManager.cs — SpawnCard()
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; ...)에선 여전히 발생하므로 습관적으로 복사하는 게 좋다.

마퀴 스크롤 구조

SongSelectManager.cs — 마퀴 레이아웃
card
  └─ TitleMask  (RectMask2D)    ← 클리핑 컨테이너
       └─ Title (TMP_Text + MarqueeText)  ← 텍스트가 마스크 밖으로 스크롤
  └─ Artist (TMP_Text)
  └─ Badge  (Image + TMP_Text)  ← 다운로드된 곡에만 표시
학습 포인트 — RectMask2D

Mask 컴포넌트와 달리 스텐실 버퍼를 쓰지 않아 오버헤드가 적다. RectTransform 영역 밖의 자식을 잘라낸다. 텍스트가 컨테이너 너비를 넘어도 잘리고, 자식이 스크롤하면 마퀴처럼 보인다.

오프라인 폴백 (캐시)

SongSelectManager.cs — FetchSongs()
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 패턴

일반 GET
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 — 조건 기반 대기

SongController.cs
// 조건이 true가 될 때까지 매 프레임 체크
yield return new WaitUntil(() => _audio.CurrentTime >= spawnAt);

3. LayoutRebuilder — 동적 UI 갱신

SongSelectManager.cs
// 자식을 코드로 추가한 뒤 레이아웃 강제 재계산
LayoutRebuilder.ForceRebuildLayoutImmediate(cardContainer);
Canvas.ForceUpdateCanvases();

4. using 선언 (C# 8)

C# 8 using 선언
// 블록 없이 — 변수가 선언된 스코프 끝에서 자동 Dispose
using var req = UnityWebRequest.Get(url);
using var ms  = new MemoryStream(bytes);

// 전통 방식 (C# 7 이하)
using (var req = UnityWebRequest.Get(url)) { ... }

5. Unity ?? 연산자 주의사항

SaberGlow.cs 버그 사례
// 버그: Unity 오브젝트에 ?? 연산자 사용 → Destroyed 오브젝트를 null로 인식 못함
var light = _pointLight ?? GetComponent<Light>();  // ← 위험!

// 수정: 명시적 GetComponent만 사용
var light = GetComponent<Light>();
if (light != null) { ... }
중요 — Unity 오브젝트와 ??

Unity의 UnityEngine.Object== 연산자를 오버로드해서 Destroyed 상태를 null처럼 처리한다. 하지만 C#의 ??는 CLR 레벨의 null만 체크하므로 Destroyed 오브젝트를 null로 인식하지 못한다. → Unity 오브젝트엔 ?? 대신 if (x != null) 사용.

셀프 퀴즈

Q1. Beat Saber의 _time=8, BPM=120일 때 실제 재생 시간(초)은?
8 * 60 / 120 = 4.0초. 공식: time(초) = _time(비트) × 60 / BPM
Q2. travelTimeOverride가 필요한 이유는? 일반 노트에도 영향을 주는가?
forEach로 동시 노트를 처리할 때 첫 노트와 두 번째 노트 사이에 1프레임(~16ms) 차이가 생긴다. 각 노트를 스폰하는 시점의 실제 남은 시간을 travelTime으로 주입하면 두 노트가 히트존에 동시 도착한다. 일반 노트는 스폰 시점의 remaining ≈ TargetTravelTime(1.8s)이므로 사실상 동일하다.
Q3. SongSelectManager의 foreach 클로저 버그를 설명하고 해결책은?
람다가 foreach 루프 변수를 캡처하면, 루프 완료 후 람다 실행 시점에 변수는 마지막 값을 가리킨다. SongInfo captured = song;으로 로컬 복사본을 만들면 각 람다가 독립적인 값을 캡처한다.
Q4. NasPublisher가 Unity의 기본 UnityWebRequest.Post(form) 대신 수동 multipart를 쓰는 이유는?
Synology DSM이 Unity 기본 multipart 포맷을 거부하며 401 오류를 반환하기 때문이다. GUID boundary, CRLF 줄바꿈, Content-Type 헤더를 직접 구성한 UploadHandlerRaw로 DSM 요구 형식을 맞춘다.
Q5. static class GameSession의 한계는? 어떤 상황에서 문제가 생길 수 있나?
앱 프로세스가 살아있는 동안만 유지된다. 앱을 강제 종료했다가 재시작하면 값이 사라진다. 또한 Game 씬을 에디터에서 직접 Play하면 GameSession이 null이라 오류가 나 — 항상 SongSelect 씬부터 시작해야 한다.

남은 작업