Nari

LevelUp Piano — MIDI 피아노 연습 앱 · 블로그

앱에서 오픈소스로 — 검증 가능한 LLM 출력을 위한 재사용 패턴

블로그 목록
개발 일지2026-07-09
AILLMOpenAI오픈소스

앞서 LevelUp Piano가 LLM으로 연습곡을 만드는 방법을 글로 썼다. 그건 설계 이야기였다 — 파이프라인의 모양. 하지만 실제로 실행해볼 수 있는 코드는 없었다.

그래서 핵심을 오픈소스 도구로 떼어냈다: etude-gen. 명령어 한 줄이 사양(spec)을 검증된, 연주 가능한 MIDI 파일로 바꾼다. 작곡에는 OpenAI API를, 그걸 실체로 만드는 데엔 평범한 결정적 코드를 쓴다.

npx tsx src/cli.ts --difficulty easy --key C --technique "left-hand arpeggios"

하지만 "실행 가능하다"는 것만으로 두 번째 글을 쓸 이유가 되진 않는다. 진짜 이유는, 피아노를 걷어내고 나니 남은 것이 음악과 아무 상관 없는 패턴이었기 때문이다 — 그리고 그건 언어 모델에서 신뢰할 수 있는 출력을 얻는 일과 전적으로 관련이 있다.

npx명령어 한 줄로 실행
Structured Outputs스키마로 강제된 생성
offlineAPI 키 없이 체험 가능
MIT오픈소스

훔쳐 갈 가치가 있는 단 하나의 아이디어

LLM은 잘하는 상징적 언어로 일하게 하라. 정확해야 하는 모든 것은 결정적 코드로 밀어내라. 그 사이를 검증-재생성 루프로 이어라.

이게 전부다. 아래는 저 한 문장을 구체화한 것에 불과하다.

✗ 실패 → 오류 메시지를 그대로 모델에 되먹임✓ 통과사양(spec)난이도·조성·기법OpenAI APIStructured OutputsIR (JSON)마디·박·음이름검증기봉투·음역·조성컴파일러박→ms, 결정적.mid + 노트차트ms 단위 정확, 연주 가능
세 부분 패턴 — 모델이 스키마 아래에서 상징적 IR을 쓰고, 검증기가 문지기 역할을 하며(실패는 그대로 되돌아간다), 통과한 것만 결정적 컴파일러가 정확한 출력으로 바꾼다.

1부 — 모델은 스키마 아래에서 작곡하게

순진한 접근은 완성품을 바로 달라고 하는 것이다 — 밀리초, MIDI 번호, 전부. 그러면 산수 오류가 쏟아진다. "84 BPM에서 3박째가 몇 ms인가?"는 LLM이 매 토큰마다 가장 못하는 종류의 계산이다.

그래서 모델은 밀리초를 아예 보지 않는다. 사람 작곡가가 생각하는 방식대로 — 마디, 박, 음이름으로 — **중간 표현(IR)**을 쓰고, 나는 그 모양을 OpenAI Structured Outputs로 강제한다. Zod 스키마가 곧 계약서다:

const NoteSchema = z.object({
  hand: z.enum(["L", "R"]),
  pitch: z.string().describe('음이름, 예: "E4", "F#3"'),
  beat: z.number().describe("1-기반 박 위치, 소수 허용"),
  len: z.number().describe("박 단위 길이"),
});
 
const SongIRSchema = z.object({
  title: z.string(),
  key: z.string(),
  tempo_bpm: z.number(),
  time_signature: z.object({ beats: z.number().int(), unit: z.number().int() }),
  difficulty: z.enum(["easy", "normal", "hard"]),
  measures: z.array(z.object({ n: z.number().int(), notes: z.array(NoteSchema) })),
});
 
const completion = await client.beta.chat.completions.parse({
  model: "gpt-5.5",
  messages,
  response_format: zodResponseFormat(SongIRSchema, "song"),
});
const song = completion.choices[0].message.parsed; // 타입 보장, 모양 보장

파싱도, "JSON으로 답해주세요"도, 정규식 복구도 없다. 응답은 이미 타입에 맞춰 돌아온다. 덕분에 모델은 형식이 아니라 창의적인 부분 — 멜로디 — 에 힘을 쓸 수 있다.

2부 — 검증기가 곧 사양이고, 그 오류는 프롬프트다

Structured Outputs는 모양을 보장하지 결코 의미를 보장하지 않는다. 모델이 마디를 넘치는 음을 쓰거나, 어떤 손도 못 짚는 화음을 쓰거나, 조성을 벗어나 헤매는 것을 막는 건 아무것도 없다. 그래서 모든 IR은 신뢰되기 전에 검증기를 통과해야 한다.

연습곡 생성기에서 "올바르다"는 건 연주 가능하고 동시에 교육적으로 적절하다는 뜻이다. 그건 **난이도 봉투(envelope)**로 인코딩된다 — 출력을 단지 음악이 아니라 교육적으로 만드는 제약이다:

easynormalhard
마디당 최대 음 수61224
한 손 동시 최대 음 수124
최소 음 길이 (박)10.50.25

여기에 모든 난이도 공통으로: 손별 음역, 실제로 딱 맞아떨어지는 마디, 조성 이탈 음 20% 이하, 연주 가능한 템포 범위.

전체를 수렴시키는 결정적 한 수는 이것이다: 검증 오류는 사람이 아니라 모델을 위해 쓴다.

measure 1, note 1 (E4): beat 4.5 + len 1 overflows a 4/4 measure
measure 1: R hand plays 2 simultaneous notes, exceeding the easy limit of 1
measure 2, note 1 (H9): "H9" is not a valid pitch name

각 줄이 어디서, 무엇이, 얼마나 어긋났는지를 말한다. 이건 로그가 아니라 지시문이다. 그대로 다음 프롬프트로 들어간다.

Tip

검증기의 오류 메시지를 사람이 읽을 로그가 아니라 모델이 읽을 지시문으로 써라. 실패가 스스로를 설명하면, 재생성 루프가 저절로 닫힌다 — 디버깅은 당신이 아니라 파이프라인이 한다.

3부 — 모든 산수를 결정적 코드로 밀어내기

IR이 통과한 뒤에야 결정적 절반이 돈다. 컴파일러는 모델이 결코 건드리지 못하게 한 모든 계산을 한다: 박은 틱이 되고, 틱은 밀리초가 되고, 음이름은 MIDI 번호가 된다.

const ticksPerBeat = TPQN * (4 / unit);
const startTick = Math.round(((measure.n - 1) * beats + (note.beat - 1)) * ticksPerBeat);
const startMs   = Math.round(startTick * (60_000 / (tempoBpm * TPQN)));

같은 IR을 넣으면 언제나 같은 바이트가 나온다. 제로 의존성 Standard MIDI File 인코더가 .mid를 쓰고, 나란히 만들어지는 노트차트가 게임 엔진이 소비할 밀리초 타이밍을 담는다.

💡 Insight

모호하고 창의적인 일은 모델에게 그의 모국어로, 정확해야 하는 모든 것은 평범한 코드에게. 이 경계를 분명히 그으면 "LLM이 계산을 틀렸다"는 부류의 버그가 통째로 사라진다.

한 함수 안의 루프

Structured Outputs(모양) + 검증기(의미) + 결정적 컴파일러(정확성)는, 실패 경로를 모델로 되돌려 연결할 때 비로소 파이프라인이 된다:

for (let attempt = 1; attempt <= maxAttempts; attempt++) {
  const song = (await client.beta.chat.completions.parse({ ... })).choices[0].message.parsed;
  const errors = validate(song);
  if (errors.length === 0) return { song, attempts: attempt };
 
  messages.push(
    { role: "assistant", content: JSON.stringify(song) },
    { role: "user", content: `That song failed validation:\n${errors.map(e => `- ${e}`).join("\n")}\n\nRegenerate the complete song with every issue fixed.` },
  );
}

생성 → 검증 → 실패를 되먹임 → 재생성. 모델은 사람이 아니라 확고한 오라클(oracle)을 상대로 스스로를 교정한다.

30초 만에 돌려보기

결정적 절반이 도는 걸 보는 데엔 키조차 필요 없다 — 동봉된 예제 IR이 오프라인으로 컴파일된다:

git clone https://github.com/devyourown/etude-gen && cd etude-gen && npm install
npm run generate -- --from-ir examples/meadow-morning.json   # API 키 불필요

키가 있으면 전체 루프가 돈다. 방금 실제로 --difficulty easy --key C로 돌리니 Morning Brook이 나왔다 — 8마디, 47음, 걷는 듯한 오른손 멜로디 아래 왼손 C장조 분산화음 반주 — 검증을 통과하고 GarageBand에 바로 넣을 수 있는 MIDI 파일로 컴파일됐다.

왜 이것이 음악과 무관한가

도메인만 바꾸면 골격은 그대로다. 모델이 하드 제약을 동시에 만족해야 하는 창의적 산출물을 내야 하는 곳이라면 어디든, 같은 세 부분이 적용된다:

도메인모델이 쓰는 상징적 IR검증기가 강제하는 것컴파일러가 하는 일
음악 (이 글)마디, 박, 음이름음역, 폴리포니, 조성박 → ms, MIDI 바이트
일정 관리상대 순서·소요시간을 가진 작업겹침 없음, 마감 준수실제 시각으로 해석
UI 레이아웃의도를 담은 컴포넌트 트리그리드 적합, 대비, 접근성픽셀 좌표 / CSS 출력
게임 레벨방, 문, 열쇠풀 수 있음, 도달 가능, 공정타일맵 좌표
SQL / 설정제약된 DSL로 표현된 의도스키마, 허용 컬럼, 안전성정확한 쿼리 / 파일 렌더

교훈은 깔끔하게 일반화된다: 모델에게 정확하라고 요구하지 마라 — 당신이 검사할 수 있는 언어로 창의적이 되라고 하고, 정확함은 코드가 대신하게 하라.

마치며

원래 글은 실제 제품 문제 — 곡이 바닥나지 않게 하기 — 를 푼 이야기였다. 이 글은 그 아래에 있던 것을, 누구나 집어 쓸 수 있게 추출한 것이다 — 작고 MIT 라이선스인 OpenAI API 샘플, 그리고 내가 계속 다시 꺼내 쓰는 패턴. 밀리초는 코드에게, 멜로디는 모델에게, 검증은 스스로 닫히는 루프에게.

리포지토리: github.com/devyourown/etude-gen.