문제와 극복 — 안 되던 것을 어떻게 풀었나

실제로 막혔던 것과 그것을 어떻게 넘었는지의 기록이다. 증상 → 원인 → 조치 → 결과(수치) 순으로 쓴다. 대부분 설계가 아니라 사고에서 나온 장치다. 기준 2026-09-22.

  1. A. 채점 — “잘 불렀다”를 믿을 수 있는 숫자로
    1. A-1. 같은 녹음에 0점부터 70점까지
    2. A-2. 원곡과 비교하지 않는 채점
    3. A-3. 피아노로 같은 음을 두 번 치면 한 음
    4. A-4. 실제 Suno 보컬은 합성음과 달랐다
    5. A-5. 완벽하게 불러도 98점, 고치자 중간에 멈춘 사람이 만점
    6. A-6. 화면 점수와 랭킹 점수가 1점씩 어긋날 뻔
    7. A-7. 탭을 옮기면 게임이 영영 끝나지 않는다
    8. A-8. 브라우저 녹음을 서버가 못 읽는다
    9. A-9. “음정 92%” 말고는 해 줄 말이 없었다
    10. A-10. 추천이 부를 수 없는 곡을 골랐다
  2. B. 가사 — 줄마다 몇 초에 부르는가
    1. B-1. 인식이 맞았는지 재는 자가 틀렸다
    2. B-2. 후렴이 세 번 나오면 두 모델이 서로 다른 곳에서 틀린다
    3. B-3. 한국어는 어절로 맞추면 대부분 놓친다
  3. C. 인프라 · 운영
    1. C-1. EC2 가 부팅하자마자 죽는다
    2. C-2. 음성 인식 한 번에 PC 의 서비스가 전부 멈췄다
    3. C-3. .env 를 고쳤는데 반영이 안 된다
    4. C-4. C 드라이브가 3.5GB 남았다
    5. C-5. WSL 에서 코드를 고쳐도 화면이 안 바뀐다
    6. C-6. 느리다는데 코드가 아니었다 — 네 번 연속으로
    7. C-7. 운영 빌드가 디스크를 채우고 죽었다
    8. C-8. 배포했더니 서버가 숨을 못 쉰다
  4. D. 코드 품질 · 보안
    1. D-1. 누구나 챌린지를 만들고 S3 에 올릴 수 있었다
    2. D-2. autogenerate 가 테이블 15개를 지우려 했다
    3. D-3. 멀쩡한 코드가 하루아침에 122건 오류
    4. D-4. 서버가 아예 안 뜬다 — Mapped[int]
    5. D-5. 로그인한 사용자의 “내 기록”이 401
    6. D-6. 한글 테스트 데이터가 º¸Äà 로 깨졌다
    7. D-7. 한 곡 다 부르고 제출했는데 기록에만 안 남는다
    8. D-8. 앱을 떼어내자 살아 있는 기능이 끊길 뻔했다
    9. 아직 풀지 않은 것
  5. 한 줄 정리

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.

A-9. “음정 92%” 말고는 해 줄 말이 없었다

증상: AI 코칭이 늘 점수를 되풀이했다 — “음정이 92%로 좋습니다”.
원인: 코칭에 줄 재료가 음정·박자 두 숫자뿐이었다. 같은 92% 라도 고음에서만 무너진 사람과 음을 늘 아래로 쳐서 부르는 사람은 연습할 것이 다른데, 그 차이를 잴 방법이 없었다.
조치: 채점이 이미 훑는 (시각, MIDI) 프레임을 한 번 더 읽어 발성을 진단한다. 녹음을 다시 읽지 않으므로 비용이 거의 없다. 재는 것 — 소리 낸 비율, 음정이 쏠린 방향과 크기, 어긋난 순간 중 아래로 쳐진 비율, 음 잡는 시간, 긴 음의 흔들림, 음역별 정확도, 편한 음역, 놓친 음표.
확인한 방법: 정답대로 부르면 진단할 것이 없어 의미가 없다. 일부러 틀리게 부르는 가상 가창자를 만들었다 — 저음 −30센트, 고음 −80센트, 11개마다 한 음은 아예 안 부름. 정답을 알고 있으니 진단을 채점할 수 있다.
결과: 진단이 −52센트 · 쳐짐 100% · 저음 100%/고음 0% · 소리 낸 비율 90% 를 되찾았다. 코칭도 “음정이 평균적으로 47센트 낮게 치우친다”로 바뀌었다.

이 과정에서 내가 만든 지표 두 개가 틀린 것도 잡았다. 아예 안 부른 음표는 프레임이 없어 “소리 낸 비율”의 분모에서도 빠져 100% 로 보였고, 음표 앞의 쉬는 구간이 첫 프레임에 딸려 들어가 노래한 시간이 부풀었다. 정답을 알고 검증했기에 보였다.

A-10. 추천이 부를 수 없는 곡을 골랐다

증상: 한 곡을 부르고 “다음 추천곡”을 눌렀더니 악보가 없는 곡으로 갔다. 들어가도 도전이 안 된다.
원인 1: 추천이 활성 챌린지면 다 후보로 뒀다. 악보 상태를 보지 않았다.
원인 2: 점수가 7점이든 100점이든 같은 곡이 나왔다. 후보 목록의 첫 번째를 집는데 그 순서가 DB 가 주는 순서였다.
조치: 악보가 준비된 곡만 후보로 두고, 방금 읽은 약점에 맞춰 고른다 — 편한 음역과 겹치는 곡, 약한 쪽(고음/저음)으로 덜 가는 곡, 박자가 흔들렸으면 느린 곡.
그리고 또 하나: 편한 음역은 정확도 80% 를 넘긴 음이 넷은 있어야 잡힌다. 못 부른 녹음에서는 안 잡히는데, 그때 이미 알아낸 “고음 약함·박자 약함”까지 버리고 곡 번호 순으로 떨어졌다. 도움이 가장 필요한 사람이 가장 엉뚱한 곡을 받는 셈이었다. 음역을 몰라도 곡 자체의 높낮이와 빠르기로 판단하게 고쳤다.


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 를 지우고 다시.

C-6. 느리다는데 코드가 아니었다 — 네 번 연속으로

증상: “전체적으로 페이지 응답과 반응이 너무 느리다.”
재본 것: 홈 90ms, 백엔드 1.5ms. 서버는 빠르다.
진짜 원인 — 네 번 다 코드 밖이었다.

증상 실제 원인
사이트 전반 700~1000ms Cloudflare 엣지가 미국(SJC)을 거쳤고 Vercel 함수 리전도 미국이었다
노래방 시작이 느림 무압축 WAV 40MB 를 그대로 내려보냈다
클릭마다 버벅임 로컬 LLM 이 메모리를 3.3GB 잡아 dev 서버가 스왑으로 밀려났다
컨테이너 데이터가 비어 보임 Docker 엔진이 둘이라(Docker Desktop / WSL 안 dockerd) 이름만 같고 볼륨이 달랐다

교훈: 느리다는 말에 코드부터 고치지 말 것. 응답 시간을 먼저 재고, 빠르면 바깥을 본다.

C-7. 운영 빌드가 디스크를 채우고 죽었다

증상: EC2 배포 중 No space left on device. 디스크가 100% 찼다.
원인: ffmpeg 를 기존 apt 줄에 끼워 넣었다. RUN 줄을 하나 고치면 그 뒤 레이어가 전부 새로 만들어진다 — pip 레이어(torch 등 6.5GB)까지 다시 받았고, 옛 이미지 6.8GB 와 겹쳐 30GB 를 채웠다.
조치: ffmpeg 설치를 pip install 뒤로 옮겼다. 앞 레이어가 캐시에 맞아 100MB 남짓만 더 얹는다.
결과: 빌드 10분 → 41초, 추가 용량 6.8GB → 100MB. 운영 서비스는 무사했다(빌드가 죽으면서 임시 레이어가 정리돼 디스크도 돌아왔다).

C-8. 배포했더니 서버가 숨을 못 쉰다

원인: docker compose up -d backend 가 의존 관계 때문에 일부러 꺼 둔 neo4j 까지 켰다. RAM 912MB 짜리 인스턴스에서 여유 메모리가 37MB 까지 떨어졌다.
조치: 배포 뒤 docker stop neo4j && docker update --restart=no neo4j. 여유가 339MB 로 돌아왔다. 배포 절차에 적어 뒀다.


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 로 보낸다.

D-7. 한 곡 다 부르고 제출했는데 기록에만 안 남는다

증상: 노래를 끝까지 부르고 제출하면 점수도 AI 코칭도 정상으로 나오는데 랭킹에만 안 올라간다. 화면은 로그인 상태로 보이고, 오류도 없다. 알아챌 방법이 없었다.
원인: 로그인이 선택인 엔드포인트에서 “토큰이 없다”와 “토큰이 있는데 유효하지 않다”를 똑같이 비로그인으로 처리했다. 앞은 맞지만 뒤는 세션이 끊긴 로그인 사용자다. 서버를 재배포해 Redis 세션이 비면 로그인한 모든 사용자에게 한꺼번에 일어난다.
조치: 토큰을 보냈는데 유효하지 않으면 401 을 낸다. 클라이언트가 토큰을 갱신해 다시 보내므로 대부분 저절로 복구된다. 갱신까지 실패하면 그때는 화면에 오류가 보여 다시 로그인하고 제출할 수 있다.
곁가지: 같은 함정을 노래방 제출·리듬 게임 기록 제출 2곳·랭킹 조회가 함께 쓰고 있었다. 리듬 게임 점수도 똑같이 사라지고 있었다.

D-8. 앱을 떼어내자 살아 있는 기능이 끊길 뻔했다

수업 과제로 만들었던 앱들을 별도 브랜치로 분리하면서 두 번 걸렸다.

  • 홈 화면의 Gemini 채팅이 silicon_valley 의 엔드포인트를 타고 있었다. 그대로 지웠으면 화면이 죽는다.
  • Flutter OCR 앱(실제로 돌아가는 중)이 같은 앱의 다른 엔드포인트를 불렀다. 배포 직전에 발견해 멈췄다.

둘 다 필요한 부분만 제품 쪽으로 옮겨 살렸다. 앱을 지우기 전에 프론트·모바일이 그 경로를 타는지 grep 하는 것을 절차에 넣었다.

아직 풀지 않은 것

  • 권한을 바꿔도 다시 로그인하기 전까지 반영되지 않는다 — 토큰 갱신이 옛 토큰의 권한을 복사한다.
  • 악보가 공개돼 있어 악보대로 합성한 연주를 제출하면 막지 못한다.
  • 화음(여러 음을 동시에)은 판정하지 않는다.

한 줄 정리

숫자를 믿기 전에 그 숫자를 재는 자부터 의심했다. 0~70점으로 흔들리던 채점, 좋아 보이던 가사 정렬 지표, 98점에 머물던 만점 — 모두 자가 틀려서 생긴 문제였다.