문제와 극복 — 안 되던 것을 어떻게 풀었나
실제로 막혔던 것과 그것을 어떻게 넘었는지의 기록이다. 증상 → 원인 → 조치 → 결과(수치) 순으로 쓴다. 대부분 설계가 아니라 사고에서 나온 장치다. 기준 2026-09-22.
A. 채점 — “잘 불렀다”를 믿을 수 있는 숫자로
A-1. 같은 녹음에 0점부터 70점까지
증상: 같은 파일을 여러 번 제출하면 점수가 0 · 10 · 30 · 50 · 70 으로 흔들렸다. 랭킹을 붙일 수 없는 상태.
원인: 점수를 Gemini 의 판단에만 맡겼다. 기준표도 없고 샘플링도 켜져 있었다.
조치: librosa 로 음정 안정성·박자 일관성을 결정적으로 측정해 근거로 넘기고, 점수 구간표를 명시하고, temperature 0 으로 고정했다.
결과: 같은 파일이 10 · 10 · 10.
곁가지로 두 가지를 더 찾았다. 코드에 박혀 있던 gemini-1.5-flash 가 폐기돼 404 가 나고 있었다(설정의 모델명을 쓰도록 교체).
그리고 무료 한도(분당 20회)를 넘긴 429 가 기본값 70점으로 가려져, 한동안 “점수가 일정해졌다”고 잘못 읽었다.
A-2. 원곡과 비교하지 않는 채점
증상: 원곡과 전혀 다른 멜로디를 불러도 흔들림 없이만 부르면 음정 점수가 높았다.
원인: “음정 점수”가 원곡이 아니라 사용자 목소리의 안정성만 쟀다. 곡마다 정답이 없었다.
조치: 보컬 스템에서 정답 음표를 뽑는다(pyin 16kHz · 20ms, 비브라토에 흔들리지 않게 0.7반음 히스테리시스). 채점은 옥타브 무관, ±50센트 온전 · ±100센트 절반, 박자 0.2초.
결과: 음정을 아는 합성 보컬로 3분 곡 240/240 음표 일치, 시작 오차 중앙값 10ms.
A-3. 피아노로 같은 음을 두 번 치면 한 음
증상: “반짝반짝 작은 별”(도-도-솔-솔-라-라-솔)을 뽑으면 반복음이 합쳐졌다. 보컬 방식으로는 28개 중 15개만 맞았다.
원인: 건반·줄 소리는 이어져서 음높이만 보면 같은 음을 다시 친 걸 알 수 없다.
조치: 악기 프로필에 어택(새로 친 순간) 감지를 넣었다. 그러자 28개가 47개로 쪼개졌다 — 앞 음 잔향과 새 음이 겹친 맥놀이를 새 타건으로 착각. 소리가 실제로 +3dB 이상 솟을 때만 어택으로 인정했다.
결과: 28/28, 음표 수도 정확히 28개. 기타 최저음(E2)~C6 넓은 음역도 16/16.
A-4. 실제 Suno 보컬은 합성음과 달랐다
증상: Suno 곡(Still Here In The Dark)을 Demucs 로 분리해 뽑자 음표 508개 중 33% 가 0.15초 미만 조각, 22곳에서 옥타브 이상 튀었다(C#4 → G#2 → C#4).
원인: R&B 보컬의 끌어올리기·꺾기에서 지나가는 중간음이 따로 잡혔고, 숨 섞인 소리·잔향에서 pyin 이 아래 옥타브를 잡았다.
조치: 보컬에만 후처리 — 주변 2.5초 흐름에서 9반음 넘게 벗어난 음은 짧으면 버리고 길면 옥타브만 맞추고, 1~2반음 차이로 붙은 짧은 조각은 옆 음표에 합친다. 악기에는 적용하지 않는다(트릴이 뭉개진다).
결과: 508 → 380 음표, 조각 33% → 12%, 튐 22 → 9, 노래를 덮는 시간 164초 → 163초.
남은 9개는 후렴마다 같은 자리에서 반복되는 G#3↔G#4 라 오류가 아니라 실제 멜로디 도약으로 판단했다. 합성음 회귀(보컬 48/48, 피아노 28/28)는 그대로.
A-5. 완벽하게 불러도 98점, 고치자 중간에 멈춘 사람이 만점
증상: 정답 그대로 불러도 99~98점에 머물렀다.
원인: 프레임이 음표 경계를 걸치면 그 프레임이 다음 음표로 넘어가, 음표마다 한 프레임씩 빠졌다.
1차 조치 → 새 문제: “지켜본 시간” 대비로 바꾸자 100점이 됐지만, 음표 중간에 멈춘 사람이 그 음표를 다 부른 것으로 쳐졌다.
조치: 분모를 음표 길이에서 두 프레임(30fps 기준 33ms)만 뺀 값으로.
결과: 60fps · 30fps 모두 정확히 100점, 중간에 멈추면 39점.
A-6. 화면 점수와 랭킹 점수가 1점씩 어긋날 뻔
원인: 파이썬 round() 는 .5 를 짝수 쪽으로 보낸다(round(62.5) == 62). 브라우저의 Math.round 는 올린다.
조치: 서버에서 JS 와 같은 반올림을 쓰고, 같은 입력으로 두 채점기를 비교하는 검증을 만들었다(40개 무작위 음표 · 7가지 부르는 방식).
결과: 7/7 같은 점수.
A-7. 탭을 옮기면 게임이 영영 끝나지 않는다
증상: 가상 연주자(정답 음을 그대로 내는 발진기)로 돌렸는데 점수 0, 전부 MISS, 곡이 끝나도 결과 화면이 안 떴다.
원인: 판정과 “곡이 끝났나” 확인을 모두 requestAnimationFrame 에 묶었다. 화면이 그려지지 않으면 이 루프가 멈춘다 — 측정해 보니 2초에 0프레임. 실제 사용자라면 탭을 옮기거나 폰 화면이 꺼지는 순간이다.
조치: 판정은 화면과 떼어 20ms 타이머로(서버 재채점과 같은 간격), 그리기만 rAF 로. 곡 종료는 오디오의 ended 가 알려 준다.
결과: 화면 99점 · 콤보 27 · 28/28 PERFECT, 곡이 끝나면 저절로 결과. 녹음을 제출하면 서버 최종 96점.
A-8. 브라우저 녹음을 서버가 못 읽는다
증상: 녹음 제출의 음정·박자 지표가 비어 있었다.
원인: MediaRecorder 는 webm/mp4 만 낸다. 서버의 soundfile 은 둘 다 못 읽고 ffmpeg 도 없다.
조치: 브라우저에서 PCM 으로 풀어 WAV 로 다시 싸서 보낸다. 그런데 48kHz 그대로면 4분 곡이 23MB — Gemini 인라인 한도(20MB)를 넘어 AI 코칭이 조용히 빠졌다. 노래방 제출은 16kHz 로 줄였다.
결과: 1분에 5.8MB → 1.9MB.
B. 가사 — 줄마다 몇 초에 부르는가
B-1. 인식이 맞았는지 재는 자가 틀렸다
증상: Whisper 로 맞춘 줄 시작이 정답 음표 시작과 중앙값 160ms 로 가까웠다 — 좋아 보였다.
원인: 노래하는 동안 음표가 0.4초마다 있어, 아무 시각이나 찍어도 음표 근처에 떨어진다. 기준이 변별력이 없었다.
조치: 일부러 줄 시작을 ±1초 밀어 보는 대조 실험 — 제자리(0초)에서 뚜렷이 좋아지지 않았다. 이 방법은 버리고, 서로 독립인 두 번의 인식(small · medium)이 일치하는지로 검증하기로 했다.
결과: 첫 곡 86줄 중 45줄이 두 인식에서 0.3초 안으로 일치.
B-2. 후렴이 세 번 나오면 두 모델이 서로 다른 곳에서 틀린다
증상: Close Enough To Touch 에서 small 은 첫 소절을 0:27 로(실제 보컬 시작 0:11), medium 은 첫 후렴을 두 번째 후렴 자리(1:25)에 붙였다.
1차 조치 → 새 문제: “엇갈리면 큰 모델을 믿는다”, 다음엔 “앞뒤 확정 줄 사이에 들어가는 값” — 둘 다 뒤따르는 15줄이 4초 안에 몰렸다. 확정 줄이 7개뿐이라 기준점이 모자랐다.
조치: 줄 하나씩이 아니라 곡 전체 경로를 고른다. 줄 사이 간격이 단어 수로 예상한 길이와 맞을수록 좋게, 시간이 거꾸로 가면 불가능으로 두고 동적 계획법으로 최적 조합을 찾는다.
결과: 첫 소절은 medium(0:10), 첫 후렴은 small(0:35) — 각 모델의 실수가 서로 보정됐다. 세 곡 모두 시간 역전 0, 0.5초 미만으로 몰린 줄 0.
B-3. 한국어는 어절로 맞추면 대부분 놓친다
원인: 인식 결과와 가사의 띄어쓰기·조사가 자주 다르다(“그림자가” / “그림자 가”).
조치: 한국어는 음절 단위로 맞춘다.
결과: 음절 92% 일치, 28/28줄 위치 확인.
C. 인프라 · 운영
C-1. EC2 가 부팅하자마자 죽는다
증상: t3.micro 가 부팅 후 10초 안에 컨테이너 재시작 루프, SSH 도 안 됨.
원인: 메모리 912MB 에 스왑이 없었다 — zram 이 한도(800MB) 때문에 건너뛰어졌다.
조치: 부팅 시 docker 를 끄는 user data 로 들어가 살린 뒤, 2GB 스왑 파일(/etc/fstab), vm.swappiness=10, 무거운 neo4j 는 자동 재시작 끄기. user data 는 원복.
결과: 이후 3주 넘게 재시작 없이 가동.
C-2. 음성 인식 한 번에 PC 의 서비스가 전부 멈췄다
증상: Whisper medium 을 돌리던 중 백엔드와 프론트 개발 서버가 동시에 응답 없음, docker version 조차 500.
원인: Docker 와 WSL Ubuntu 가 같은 WSL 가상머신에 있고, 컨테이너에 메모리 한도가 없었다. 가상머신 전체가 응답을 멈췄다(0x8007274c).
조치: wsl --shutdown 후 재기동(데이터는 볼륨에 있어 무사). 이후 모든 모델 컨테이너에 메모리·CPU 상한(예: medium 4GB · 6코어). 한도에 걸려 죽으면 나머지 결과로 계속하도록 도구에 넣었다.
결과: 이후 같은 작업에서 재발 없음. 다국어 medium 은 4GB 에서 종료됐지만 서비스는 멀쩡했다.
C-3. .env 를 고쳤는데 반영이 안 된다
원인: docker restart 는 env_file 을 다시 읽지 않는다. Gemini 모델을 바꾸고 재시작했는데 옛 모델로 돌았다.
조치: docker compose up -d --force-recreate 를 규칙으로.
C-4. C 드라이브가 3.5GB 남았다
원인: Docker 데이터(가상 디스크)가 C 에 있었다. 설정 파일의 DataFolder 를 바꿨지만 Docker 가 무시하고 C 에 빈 디스크를 새로 만들었다 — 이동은 GUI 에서만 된다.
결과: GUI 로 D 로 옮겨 C 여유 3.56GB → 34.99GB.
C-5. WSL 에서 코드를 고쳐도 화면이 안 바뀐다
원인: WSL 이 Windows 폴더(/mnt/c)를 보면 파일 변경 알림이 오지 않아 핫 리로드가 안 된다. PC 를 재부팅한 날엔 빌드 캐시(.next)가 깨져 ChunkLoadError 도 났다.
조치: 수정 뒤 dev 서버 재시작을 규칙으로, 캐시가 깨지면 .next 를 지우고 다시.
D. 코드 품질 · 보안
D-1. 누구나 챌린지를 만들고 S3 에 올릴 수 있었다
증상: POST /challenges 에 인증이 없었다. 화면의 “관리자만” 은 버튼을 숨길 뿐이었다.
조치: require_admin. 그런데 붙이는 순간 등록 화면이 인증 없이 호출하고 있어 깨질 참이었다 — 같이 고쳤다.
결과: 비로그인 401 · 일반 사용자 403 · 관리자 200 확인.
D-2. autogenerate 가 테이블 15개를 지우려 했다
증상: 마이그레이션을 자동 생성하자 다른 앱의 테이블 15개 이상과 users 컬럼 2개를 삭제하는 파일이 나왔다.
원인: alembic/env.py 가 그 앱들의 모델을 불러오지 않아 “없는 테이블”로 보였다.
조치: 이번 마이그레이션은 필요한 부분만 손으로 남기고, 파일 머리에 경고를 적었다. env.py 보완은 별도 작업으로 분리.
D-3. 멀쩡한 코드가 하루아침에 122건 오류
원인: ruff 0.16 에서 기본 규칙이 넓어졌다(Depends() 기본값까지 걸림).
조치: 이전 기본값(E4, E7, E9, F)을 설정에 명시해 버전이 올라가도 기준이 흔들리지 않게.
D-4. 서버가 아예 안 뜬다 — Mapped[int]
원인: SQLModel 모델에 SQLAlchemy 2.0 의 Mapped[] 를 섞었다. SQLModel 은 어노테이션으로 Pydantic 모델도 만들어 이 타입을 처리하지 못한다.
조치: Field() 스타일로 통일.
D-5. 로그인한 사용자의 “내 기록”이 401
원인: 프론트 프록시가 POST 에서만 Authorization 을 넘기고 GET 에서는 버리고 있었다. 인증이 필요한 GET 을 처음 만들면서 드러났다.
D-6. 한글 테스트 데이터가 º¸Äà 로 깨졌다
원인: Windows 터미널의 curl 이 CP949 바이트로 보낸 것을 Latin-1 로 읽어 저장했다.
조치: 데이터는 복원하고, 도구는 한글을 UTF-8 파일·직접 만든 multipart 로 보낸다.
아직 풀지 않은 것
- 권한을 바꿔도 다시 로그인하기 전까지 반영되지 않는다 — 토큰 갱신이 옛 토큰의 권한을 복사한다.
- 악보가 공개돼 있어 악보대로 합성한 연주를 제출하면 막지 못한다.
- 화음(여러 음을 동시에)은 판정하지 않는다.
한 줄 정리
숫자를 믿기 전에 그 숫자를 재는 자부터 의심했다. 0~70점으로 흔들리던 채점, 좋아 보이던 가사 정렬 지표, 98점에 머물던 만점 — 모두 자가 틀려서 생긴 문제였다.