<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Backend on 얼쑤 · Team Edo</title><link>https://team-edo.github.io/categories/backend/</link><description>Recent content in Backend on 얼쑤 · Team Edo</description><generator>Hugo</generator><language>ko-kr</language><lastBuildDate>Sun, 08 Nov 2026 00:00:00 +0900</lastBuildDate><atom:link href="https://team-edo.github.io/categories/backend/index.xml" rel="self" type="application/rss+xml"/><item><title>1.76MB 곡 목록에서 악보 덜어내기</title><link>https://team-edo.github.io/posts/2026/11/song-list-payload-and-gzip/</link><pubDate>Sun, 08 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/song-list-payload-and-gzip/</guid><description>&lt;p>얼쑤의 곡 목록 API를 Swagger UI에서 열었더니 &lt;code>Could not render responses&lt;/code>가 표시됐습니다. 브라우저 콘솔에는 &lt;code>Maximum call stack size exceeded&lt;/code>가 남았습니다. 응답을 살펴보니 111곡을 담은 JSON이 약 1.76 MB였고, 그중 약 99%가 곡마다 붙은 &lt;code>score&lt;/code>였습니다. 곡 하나를 고르는 화면에서 111곡의 악보까지 받고 있었습니다.&lt;/p>
&lt;h2 id="곡을-고를-때-필요한-필드만-목록에-남겼다">목록에서 악보 본문 분리&lt;/h2>
&lt;p>곡 하나당 메타데이터는 평균 189바이트였고, 악보는 평균 약 16 KB였습니다. 악보가 들어간 응답 타입을 목록과 상세에서 함께 쓴 결과였습니다. 곡이 늘면 각 곡의 악보 크기만큼 목록 응답도 함께 커지는 구조였습니다.&lt;/p></description></item><item><title>Spring 테스트 53개 실패와 MySQL 연결 한도</title><link>https://team-edo.github.io/posts/2026/11/spring-test-mysql-connections/</link><pubDate>Sun, 08 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/spring-test-mysql-connections/</guid><description>&lt;p>얼쑤 백엔드의 &lt;code>databaseTest&lt;/code>는 Spring 애플리케이션과 실제 MySQL을 연결해 실행하는 통합 테스트입니다. 앞선 실행에서 161개가 모두 통과했는데, 다른 변경을 병합한 뒤에는 174개 중 53개가 실패했습니다. 서로 다른 기능을 확인하는 테스트에서 같은 메시지가 45번 나왔습니다. &lt;code>Too many connections&lt;/code>였습니다.&lt;/p>
&lt;h2 id="실패한-테스트보다-먼저-연결을-확인했다">테스트보다 먼저 연결 확인&lt;/h2>
&lt;p>Spring 테스트가 애플리케이션 컨텍스트를 올리는 도중 DB에 연결하지 못하면 테스트 코드까지 실행되지 못합니다. 같은 설정의 컨텍스트를 여는 데 이미 실패한 뒤에는 Spring이 다음 로딩 시도를 건너뛰면서 &lt;code>ApplicationContext failure threshold exceeded&lt;/code>를 남길 수 있습니다. 이 메시지만 보면 원인이 비슷한 실패가 여러 개 생긴 것처럼 보입니다. 앞선 로딩 실패의 예외까지 내려가면 MySQL이 새 연결을 거부했다는 단서가 나옵니다. &lt;a href="https://docs.spring.io/spring-framework/reference/testing/testcontext-framework/ctx-management/failure-threshold.html">Spring의 컨텍스트 로딩 실패 정책&lt;/a> · &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/too-many-connections.html">MySQL의 연결 초과 오류&lt;/a>&lt;/p></description></item><item><title>쉼표가 바꾼 아리랑 음표 45개의 시작 박</title><link>https://team-edo.github.io/posts/2026/11/rests-and-note-start-time/</link><pubDate>Sun, 08 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/rests-and-note-start-time/</guid><description>&lt;p>아리랑의 정답 악보에서 쉼을 제외하고 남은 음의 길이를 차례로 더했더니, 전체 61개 음 중 45개의 기대 시작 시각이 앞당겨졌습니다. 차이가 가장 큰 음은 &lt;strong>약 1.67박, 96 BPM에서 약 1.04초&lt;/strong> 일찍 시작하는 것으로 계산됐습니다. 팀원이 검수된 악보를 기준으로 두 계산 방식의 기대 시각을 비교해 발견한 차이였습니다.&lt;/p>
&lt;p>얼쑤의 백엔드는 사용자가 선택한 연습 구간의 음표를 골라 채점기에 보냅니다. 채점 결과의 &lt;code>expected_pitch_id&lt;/code>는 &lt;code>NOT NULL&lt;/code>이고, 실제 음높이 행을 외래 키로 참조합니다. 정답 음이 없는 쉼은 채점용 음표 목록에서 제외하고, 곡의 음표 수와 연습 구간 번호도 &lt;code>NOTE&lt;/code>만 세는 기준에 맞췄습니다. 문제는 남은 음의 길이만으로 시간축을 다시 만들 때 생겼습니다. 음 하나의 길이는 그대로여도, 그다음 음까지 얼마나 쉬어야 하는지는 알 수 없기 때문입니다.&lt;/p></description></item><item><title>녹음 재분석에서 결과가 섞이지 않게</title><link>https://team-edo.github.io/posts/2026/11/recording-analysis-retry/</link><pubDate>Sat, 07 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/recording-analysis-retry/</guid><description>&lt;p>얼쑤에서 사용자가 연주 녹음을 보내면 앱은 분석 접수를 확인하고 결과를 기다립니다. 분석 서버에 일시적으로 연결하지 못해도 원본 파일은 서버에 남습니다. 저는 사용자가 그 녹음으로 분석을 다시 요청할 수 있게 했습니다.&lt;/p>
&lt;p>이때 같은 세션 안에 시도가 여럿 생깁니다. 첫 시도가 시간 초과로 끝난 뒤 두 번째 시도를 시작했는데 첫 시도의 성공 응답이 도착할 수 있습니다. 두 결과는 세션 ID가 같습니다. 그대로 저장하면 앱이 두 번째 시도를 기다리는 동안 첫 시도의 점수를 보여줄 수 있습니다.&lt;/p></description></item><item><title>API 명세 68페이지에서 팀 수정 148곳 지키기</title><link>https://team-edo.github.io/posts/2026/11/safe-bulk-api-doc-edits/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/safe-bulk-api-doc-edits/</guid><description>&lt;p>팀의 Notion API 명세 68페이지를 코드와 대조했습니다. 요청과 응답의 필드, 오류가 나는 조건, 예제의 타입이 구현과 맞는지 확인해 교정안을 만들었습니다. 하루 뒤 적용하려고 문서를 다시 읽으니 교정 후보 1,033곳 중 148곳은 팀원이 이미 다른 문구로 바꾼 상태였습니다. 81곳은 페이지가 개편되거나 작업이 취소되면서 대상 행이나 표를 찾을 수 없었습니다. 검토가 끝난 교정안이라도 그대로 덮어쓰면 그사이 팀이 고친 내용을 되돌릴 수 있었습니다.&lt;/p></description></item><item><title>고치기 전에 실패해야 증거가 되는 테스트</title><link>https://team-edo.github.io/posts/2026/11/test-fails-first/</link><pubDate>Mon, 02 Nov 2026 00:00:00 +0000</pubDate><guid>https://team-edo.github.io/posts/2026/11/test-fails-first/</guid><description>&lt;p>버그를 고치고 테스트를 추가했더니 통과했습니다. 그럼 버그가 고쳐졌다고 말할 수 있을까요?&lt;/p>
&lt;p>얼쑤의 연주 비교 기능을 고치면서, 답은 &lt;strong>고치기 전에 그 테스트가 실패했는지 봐야 안다&lt;/strong>는 걸 배웠습니다.&lt;/p>
&lt;h2 id="다시-연주한-구간을-비교하는-기능">다시 연주한 구간을 비교하는 기능&lt;/h2>
&lt;p>&lt;strong>얼쑤&lt;/strong>는 국악기 연주를 녹음하면 음표마다 맞게 연주했는지 알려 주는 앱입니다. 틀린 구간만 골라 다시 연주하면, 앱이 &lt;strong>이전 연주와 이번 연주를 비교&lt;/strong>해 어떤 음을 고쳤고 어떤 음이 아직 틀렸는지 보여 줍니다.&lt;/p></description></item><item><title>Jackson 3에서 JSON 배열과 누락 필드 읽기</title><link>https://team-edo.github.io/posts/2026/11/read-stored-json-trees-with-jackson/</link><pubDate>Mon, 02 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/read-stored-json-trees-with-jackson/</guid><description>&lt;p>학습 단계의 설명과 채점에 쓸 악보는 DB에 JSON으로 저장합니다. 학습 단계에서 잘못된 항목을 건너뛰면 화면에 설명이 빠진 채 나타납니다. 악보에서 정수를 잘못 읽으면 음표의 길이나 시작 박을 다르게 해석할 수 있습니다. 두 파서에서 저장값을 읽는 지점에 입력 계약을 드러냈습니다.&lt;/p>
&lt;h2 id="배열-노드에서-원소를-꺼낸-뒤-변환했다">배열 원소를 꺼내 변환&lt;/h2>
&lt;p>학습 단계에는 할 일과 운지 설명처럼 배열로 저장하는 항목이 있습니다. Jackson 3의 &lt;code>JsonNode.map&lt;/code>은 노드 자체에 함수를 적용합니다. 배열의 각 원소를 변환하려면 노드가 배열인지 확인하고 &lt;code>values()&lt;/code>로 원소를 꺼낸 뒤 Kotlin의 &lt;code>map&lt;/code>을 적용합니다.&lt;/p></description></item><item><title>MySQL 행 잠금 대기로 요청 경합 확인</title><link>https://team-edo.github.io/posts/2026/11/test-observed-mysql-row-lock/</link><pubDate>Mon, 02 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/test-observed-mysql-row-lock/</guid><description>&lt;p>연습 기록을 저장하는 요청은 세션을 읽고 값을 갱신한 뒤 응답에 넣을 행을 다시 읽습니다. 그 사이 같은 기록을 삭제하면 마지막 조회가 대상을 잃을 수 있습니다. 저장이 끝날 때까지 삭제가 기다리도록 저장 트랜잭션에서 세션 행을 &lt;code>SELECT … FOR UPDATE&lt;/code>로 잠갔습니다.&lt;/p>
&lt;p>두 작업을 서로 다른 스레드에서 출발시키는 것만으로는 이 잠금을 검사하기 어렵습니다. 스케줄링에 따라 삭제 요청이 저장보다 늦게 시작하면 두 작업이 실제로 겹치지 않습니다. 일정 시간 멈춘 뒤 삭제가 아직 끝나지 않았다고 단언해도, 삭제 스레드가 DB에 도달했는지 알 수 없습니다.&lt;/p></description></item><item><title>곡 목록에 페이지를 추가할 시점</title><link>https://team-edo.github.io/posts/2026/11/write-conditions-for-deferred-pagination/</link><pubDate>Mon, 02 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/write-conditions-for-deferred-pagination/</guid><description>&lt;p>곡 목록에 서버 검색과 페이지를 붙일지 검토할 때, 공개한 곡은 단소 네 곡이었습니다. 앱은 목록을 한 번 받은 뒤 제목과 난이도로 거를 수 있었습니다. 당시 구현에서 확인한 화면에는 더보기, 정렬, 악기 필터가 없었습니다. 이 상태에서 서버 페이지를 붙이면 요청·응답 계약과 앱 연결을 함께 바꿔야 했습니다. 우선 화면이 이미 제공하는 검색 방식을 쓰기로 하고 서버 작업을 보류했습니다.&lt;/p>
&lt;h2 id="기능을-보류하면서-재검토할-신호를-남겼다">페이지 도입을 다시 검토할 신호&lt;/h2>
&lt;p>그때는 곡 목록이 곡마다 원본 악보를 포함했습니다. 초기 데이터에 들어 있던 네 곡의 악보 JSON을 합치면 &lt;strong>82,212바이트&lt;/strong>였습니다. 전체 HTTP 응답 크기와는 별개로, 곡이 늘 때 목록에 포함되는 악보가 얼마나 커질지 가늠할 근거였습니다. 재검토 조건은 구현 변경이나 측정으로 확인할 수 있는 세 가지로 적었습니다.&lt;/p></description></item><item><title>녹음 재생 API가 내부 주소를 반환한 이유</title><link>https://team-edo.github.io/posts/2026/11/playback-url-store-boundary/</link><pubDate>Mon, 02 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/playback-url-store-boundary/</guid><description>&lt;p>얼쑤의 녹음 다시 듣기 API에 기존 저장소의 읽기 주소 발급 기능을 연결했습니다. 로컬 저장소에서는 분석 컨테이너가 쓰는 내부 주소가 HTTP 200 응답에 들어왔습니다. 테스트는 그 주소의 호스트를 테스트 프로세스가 접근할 수 있는 주소로 바꿔 읽었기 때문에 성공했습니다. 휴대폰은 응답에 들어 있는 주소 그대로 접속하므로 같은 결과를 기대할 수 없었습니다.&lt;/p>
&lt;h2 id="주소를-받을-쪽을-기준으로-연결을-나눴다">앱이 접근할 주소만 발급&lt;/h2>
&lt;p>DB의 &lt;code>recording_url&lt;/code>에는 녹음 원본을 찾는 객체 키를 저장합니다. 앱에 넘길 임시 주소는 그 키로 다시 듣기를 요청할 때 따로 만듭니다. 그래서 같은 세션의 객체 키를 읽어도 연결한 저장소에 따라 앱이 사용할 주소를 발급할 수 있는지가 달랐습니다.&lt;/p></description></item><item><title>없는 행과 NULL 값 구분하기</title><link>https://team-edo.github.io/posts/2026/11/preserve-empty-record-and-cache-results/</link><pubDate>Mon, 02 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/preserve-empty-record-and-cache-results/</guid><description>&lt;p>얼쑤는 녹음 파일이 없어도 연습 기록을 저장할 수 있습니다. 또 저장 뒤 악보 버전이 바뀌면 기록은 남지만 현재 악보에서 어느 칸에 해당하는지 표시하지 않습니다. 연습 기록 API를 만들면서 이렇게 값이 비는 경우를 두 곳에서 따로 처리했습니다. SQL을 읽는 매퍼와 한 요청 안에서 조회를 줄이는 캐시였습니다.&lt;/p>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>구분할 곳&lt;/th>
 &lt;th>항목이 없음&lt;/th>
 &lt;th>항목이 있고 값이 비어 있음&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>DB 조회&lt;/td>
 &lt;td>지울 세션이 없습니다.&lt;/td>
 &lt;td>세션은 지울 수 있고 녹음 파일은 없습니다.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>요청 안의 캐시&lt;/td>
 &lt;td>악보 위치를 아직 조회하지 않았습니다.&lt;/td>
 &lt;td>이미 조회했고 현재 악보에서 표시할 위치가 없습니다.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;h2 id="녹음-파일이-없어도-지울-기록은-있다">녹음 없이도 남는 삭제 대상&lt;/h2>
&lt;p>삭제 API는 먼저 사용자가 지울 수 있는 기록인지 찾고, 녹음 경로가 있으면 파일 삭제도 처리합니다. 이때 &lt;code>recording_url&lt;/code>만 조회하면 MyBatis의 기본 설정에서는 조회한 열이 전부 NULL인 행이 &lt;code>null&lt;/code>로 반환됩니다. 결과만 보면 행이 없었는지, 행은 있는데 녹음 경로만 비었는지 구분할 수 없습니다.&lt;/p></description></item><item><title>저장한 녹음을 30일 삭제에서 빼기</title><link>https://team-edo.github.io/posts/2026/11/protect-saved-recordings-from-purge/</link><pubDate>Mon, 02 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/protect-saved-recordings-from-purge/</guid><description>&lt;p>얼쑤에서는 연주를 분석한 뒤 마음에 드는 기록을 저장하고 나중에 녹음을 다시 들을 수 있습니다. 처음에는 R2의 수명 주기 규칙이 녹음 객체를 생성한 지 30일이 지나면 삭제 대상으로 잡았습니다. 연습 기록을 저장했다는 사실은 DB에 있었기 때문에 같은 접두사 아래의 객체에 적용되는 규칙은 그 차이를 알 수 없었습니다. 저장한 녹음을 계속 들을 수 있게 하려면, 어떤 녹음을 정리할지 정하는 곳부터 바꿔야 했습니다. &lt;a href="https://developers.cloudflare.com/r2/buckets/object-lifecycles/">Cloudflare R2의 객체 수명 주기&lt;/a>&lt;/p></description></item><item><title>학습의 첫 완료 시각 지키기</title><link>https://team-edo.github.io/posts/2026/11/keep-first-lesson-completion/</link><pubDate>Mon, 02 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/keep-first-lesson-completion/</guid><description>&lt;p>얼쑤의 기초 학습에서 사용자는 단계를 끝낸 뒤 완료를 표시할 수 있습니다. 같은 단계를 다시 표시해도 처음 완료한 시각은 유지하기로 했습니다. 서버가 저장한 직후 응답이 끊겨 앱이 같은 요청을 다시 보낼 수 있고, 두 번 누른 요청이 거의 함께 도착할 수도 있기 때문입니다.&lt;/p>
&lt;h2 id="두-번째-요청은-시각을-갱신하지-않는다">중복 완료는 시각 유지&lt;/h2>
&lt;p>완료 여부의 범위는 회원과 학습 단계의 조합입니다. DB의 유일 키가 그 조합을 하나로 제한합니다. 처음에는 회원, 단계, 완료 시각을 삽입합니다. 같은 조합이 이미 있으면 &lt;code>ON DUPLICATE KEY UPDATE&lt;/code>에서 기존 ID를 자기 자신으로 대입해 값을 유지합니다. 서버가 각 요청을 다른 시각에 받더라도 저장된 시각은 한 번만 정해집니다.&lt;/p></description></item><item><title>MySQL 한글이 물음표로 보일 때</title><link>https://team-edo.github.io/posts/2026/10/diagnose-mysql-result-charset/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/diagnose-mysql-result-charset/</guid><description>&lt;p>곡 제목을 MySQL 클라이언트에서 읽을 때, 일부 한글이 &lt;code>???&lt;/code> 또는 &lt;code>?????&lt;/code>로 표시됐습니다. 화면만 보고 저장값을 고치면 멀쩡한 행까지 바꿀 수 있습니다. 이 기록에서 확인한 것은 조회 화면에 물음표가 있었다는 사실입니다. 저장된 바이트에 물음표가 있었는지는 별도의 확인이 필요했습니다.&lt;/p>
&lt;h2 id="출력과-저장을-따로-읽는다">표시와 저장값 분리&lt;/h2>
&lt;p>MySQL은 연결에서 사용하는 문자 집합에 맞춰 결과를 변환할 수 있습니다. 클라이언트가 요청한 결과 문자 집합으로 한글을 표현하지 못하면 저장값이 정상이어도 화면에는 물음표가 나올 수 있습니다. 먼저 같은 행을 &lt;code>utf8mb4&lt;/code>를 지정한 세션에서 다시 읽고, 연결이 실제로 어떤 문자 집합을 쓰는지 확인하는 방식이 있습니다. &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/charset-connection.html">MySQL 연결 문자 집합 문서&lt;/a>&lt;/p></description></item><item><title>녹음 업로드 202 이후 앱이 기다리는 것</title><link>https://team-edo.github.io/posts/2026/10/recording-analysis-polling/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/recording-analysis-polling/</guid><description>&lt;p>얼쑤에서 연주 녹음을 보낸 사용자는 평가 화면을 기다립니다. 앱 입장에서는 파일을 서버에 전달했는지, 평가를 받을 수 있는지 두 가지를 확인해야 합니다. 둘을 한 요청에서 처리하면 파일을 저장한 뒤에도 분석이 끝날 때까지 업로드 연결을 유지해야 합니다. 저는 녹음을 접수한 뒤 &lt;code>202 Accepted&lt;/code>로 답하고, 앱이 같은 연습 세션의 상태를 따로 조회하도록 연결했습니다. &lt;a href="https://www.rfc-editor.org/rfc/rfc9110.html#section-15.3.3">RFC 9110의 202 Accepted&lt;/a>&lt;/p>
&lt;h2 id="서버가-접수했다고-답하는-시점">서버의 접수 시점&lt;/h2>
&lt;p>백엔드는 앱이 보낸 파일을 검사해 R2에 원본을 저장합니다. DB에 원본 키와 녹음 정보를 기록하고 세션을 &lt;code>PENDING&lt;/code>으로 바꾼 뒤, 저장이 커밋되면 업로드를 &lt;code>202&lt;/code>로 응답합니다. 분석 제출은 같은 커밋을 기준으로 이벤트 리스너가 따로 시도합니다.&lt;/p></description></item><item><title>단독 MockMvc에서 JWT가 비어 500이 난 이유</title><link>https://team-edo.github.io/posts/2026/10/mockmvc-principal-test-boundaries/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/mockmvc-principal-test-boundaries/</guid><description>&lt;p>성장 화면 API의 단독 MockMvc 테스트를 작성하고 실행하니 정상 요청과 400을 기대한 요청이 모두 500으로 실패했습니다. 로그를 따라가 보니 컨트롤러의 &lt;code>@AuthenticationPrincipal jwt: Jwt&lt;/code>에 값이 들어오지 않았습니다. Kotlin은 non-null 매개변수에 &lt;code>null&lt;/code>이 넘어오는 순간 예외를 던졌고, 요청은 정작 검사하려던 코드까지 가지 못했습니다.&lt;/p>
&lt;h2 id="단독-테스트가-빠뜨린-연결을-채웠다">인자 해석기와 예외 처리기 등록&lt;/h2>
&lt;p>&lt;code>standaloneSetup&lt;/code>은 제가 넘긴 컨트롤러를 중심으로 작은 MVC 환경을 만듭니다. 실제 앱에서 쿠키를 읽고 JWT를 검증하는 보안 필터까지 자동으로 붙지는 않습니다. 요청에 인증 후처리기만 붙인 첫 테스트에서는 컨트롤러가 읽을 인증 정보가 비어 있었습니다.&lt;/p></description></item><item><title>업무 결과를 HTTP 응답으로 바꾸기</title><link>https://team-edo.github.io/posts/2026/10/map-use-case-results-to-http/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/map-use-case-results-to-http/</guid><description>&lt;p>연주 평가를 조회하는 요청은 평가가 있는지, 다른 회원의 세션인지, 아직 분석 중인지에 따라 답이 달라집니다. 분석을 다시 요청할 때도 아직 작업을 시작할 수 없는 경우가 여러 가지입니다. 두 기능에서 예상되는 업무 결과를 이름 있는 값으로 표현하고, HTTP 상태와 응답 본문은 컨트롤러에서 정했습니다.&lt;/p>
&lt;h2 id="평가-조회는-누가-무엇을-판단하는가">평가 조회의 판단 경계&lt;/h2>
&lt;p>컨트롤러는 인증을 마친 토큰에서 회원 ID를 읽어 세션 ID와 함께 넘깁니다. 유스케이스는 조회 계약을 통해 소유한 세션을 찾고, 완료된 세션에서만 평가와 음표 결과를 읽습니다. 세션과 음표 결과는 같은 읽기 전용 트랜잭션에서 조회합니다. 그 계약을 구현한 MyBatis 어댑터는 조회 행을 업무 코드가 읽을 값으로 바꿉니다.&lt;/p></description></item><item><title>연주 분석 60초의 시작점</title><link>https://team-edo.github.io/posts/2026/10/budget-end-to-end-analysis-time/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/budget-end-to-end-analysis-time/</guid><description>&lt;p>얼쑤의 연주 분석에는 60초 제한이 있습니다. 이 시간은 세션이 &lt;code>PENDING&lt;/code>이 된 시각부터 결과를 저장할 수 있는 마감까지 흐릅니다. 실행기에서 대기하는 시간, 녹음 다운로드와 디코딩, 음높이 추정과 채점, DB 쓰기가 모두 같은 예산을 사용합니다. 모델의 처리 시간만 재면 가장 먼저 늦어지는 단계가 어디인지 알 수 없습니다.&lt;/p>
&lt;h2 id="같은-요청에도-여러-시간-제한이-있다">한 요청에 걸린 여러 제한&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>시간 제한&lt;/th>
 &lt;th>언제부터 재나&lt;/th>
 &lt;th>만료되면&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>백엔드의 결과 저장 마감 &lt;strong>60초&lt;/strong>&lt;/td>
 &lt;td>DB에 기록한 분석 시도 시작 시각부터&lt;/td>
 &lt;td>늦은 결과는 저장하지 않고, 마감이 지난 &lt;code>PENDING&lt;/code>은 상태 조회에서 &lt;code>TIMEOUT&lt;/code>으로 처리합니다.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>백엔드 HTTP 클라이언트의 읽기 제한 기본 &lt;strong>65초&lt;/strong>&lt;/td>
 &lt;td>AI 응답을 읽으면서 다음 응답 데이터를 기다릴 때&lt;/td>
 &lt;td>대기 시간을 넘기면 HTTP 읽기가 실패합니다.&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>앱의 상태 무응답 제한 &lt;strong>70초&lt;/strong>&lt;/td>
 &lt;td>상태 조회를 시작하거나 재개한 시점부터, 응답을 받으면 그 시점부터&lt;/td>
 &lt;td>앱이 시간 초과 화면을 표시합니다.&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>HTTP 연결을 맺는 제한은 별도로 &lt;strong>5초&lt;/strong>였습니다. 읽기 제한을 저장 마감보다 길게 둬, 느린 응답을 클라이언트가 먼저 끊어 서버 오류로 분류하는 경우를 줄이려 했습니다. HTTP 연결이 끊겨도 이미 실행 중인 AI 연산이 취소되는지는 별도의 문제입니다. 앱의 조회 간격과 무응답 경계는 가상 시계로 확인했습니다. &lt;a href="https://team-edo.github.io/posts/2026/10/recording-analysis-polling/">녹음 업로드 202 이후 앱이 기다리는 것&lt;/a>&lt;/p></description></item><item><title>회원 한 행이 막은 테스트 준비</title><link>https://team-edo.github.io/posts/2026/10/preserve-seed-parent-fixtures/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/preserve-seed-parent-fixtures/</guid><description>&lt;p>기본 악기와 악보를 DB 시드로 넣고 나니 매퍼 테스트가 시드와 같은 ID에 다른 값을 넣어야 했습니다. 처음에는 고정 데이터를 넣기 직전에 시드 행을 지웠습니다. 깨끗한 DB에서는 동작했습니다. 가입 테스트가 먼저 회원을 커밋해 둔 상태에서는 테스트 본문을 실행하기도 전에 준비 단계가 실패했습니다.&lt;/p>
&lt;h2 id="회원이-참조하는-악기는-지울-수-없었다">기본 악기를 가리키는 외래 키&lt;/h2>
&lt;p>실제 HTTP로 가입하는 테스트는 회원 행을 커밋합니다. 그 행은 기본 악기인 단소를 참조합니다. 정상 실행에서는 회원 행을 정리하지만, 테스트가 중간에 멈추면 참조가 남을 수 있습니다. 그 상태에서 매퍼 테스트가 악기를 &lt;code>DELETE&lt;/code>하면 MySQL은 외래 키 오류 &lt;strong>1451&lt;/strong>로 막습니다.&lt;/p></description></item><item><title>1초 늦는 저장소로 SDK 타임아웃 검증</title><link>https://team-edo.github.io/posts/2026/10/storage-sdk-call-budget/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/storage-sdk-call-budget/</guid><description>&lt;p>녹음 업로드 요청은 파일 검사와 객체 저장을 거친 뒤 응답합니다. 코드 리뷰에서 저장소 호출 상한이 &lt;strong>2분&lt;/strong>이라는 점을 확인했습니다. 이 경로에서 Nginx의 응답 읽기 제한은 &lt;strong>60초&lt;/strong>였습니다. 저장소가 느리면 애플리케이션이 오류 형식을 정해 응답하기 전에 프록시가 연결을 끝낼 수 있었습니다.&lt;/p>
&lt;h2 id="재시도까지-하나의-예산으로-묶었다">재시도를 포함한 40초 상한&lt;/h2>
&lt;p>파일 검사 도구에는 10초, 저장소 호출 전체에는 &lt;strong>40초&lt;/strong>를 배정했습니다. 두 설정을 더하면 50초입니다. Nginx의 60초는 연속한 읽기 사이의 대기 제한이고, 50초는 이 경로의 두 주요 작업에 배정한 예산입니다. 둘 다 실제 요청 시간의 측정값은 아닙니다. &lt;a href="https://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_read_timeout">Nginx의 응답 읽기 제한&lt;/a>&lt;/p></description></item><item><title>21MiB와 26MiB가 받은 서로 다른 413</title><link>https://team-edo.github.io/posts/2026/10/recording-upload-size-layers/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/recording-upload-size-layers/</guid><description>&lt;p>얼쑤의 녹음 업로드를 확인하면서 &lt;strong>21MiB&lt;/strong>와 &lt;strong>26MiB&lt;/strong> 크기의 입력을 같은 Nginx를 거쳐 보냈습니다. 두 요청 모두 HTTP 413이었습니다. 21MiB 요청에는 백엔드가 만든 JSON 오류 코드 &lt;code>RECORDING_TOO_LARGE&lt;/code>가 들어 있었고, 26MiB 요청에는 Nginx가 만든 HTML 응답이 돌아왔습니다. 상태 코드는 같았지만 요청을 멈춘 곳이 달랐습니다.&lt;/p>
&lt;h2 id="파일-하나의-한도와-요청-본문의-한도를-나눴다">파일 한도와 요청 본문 한도&lt;/h2>
&lt;p>연주 녹음 한 파일에는 &lt;strong>20MiB&lt;/strong>, 즉 20,971,520바이트까지 허용하기로 했습니다. 앱은 녹음 파일을 multipart 요청의 한 파트로 보냅니다. 이 요청의 본문에는 파일 바이트 외에도 파트를 구분하는 경계와 파트 헤더가 붙습니다. 요청 본문은 Nginx를 지나고, Spring이 multipart를 파싱한 뒤에야 백엔드의 녹음 검사기가 파일 파트의 크기를 확인할 수 있습니다.&lt;/p></description></item><item><title>3바이트 업로드가 175바이트가 된 이유</title><link>https://team-edo.github.io/posts/2026/10/signed-chunk-upload-fixture/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/signed-chunk-upload-fixture/</guid><description>&lt;p>녹음 저장소를 붙이면서 작은 파일 하나를 업로드하는 검사를 만들었습니다. SDK를 대역으로 바꾸면 서비스가 SDK를 호출했는지만 볼 수 있습니다. 실제 SDK가 메서드, 경로, 인증 헤더와 본문을 어떤 모양으로 보내는지 보려고 로컬 HTTP 서버를 저장소 주소로 넣었습니다.&lt;/p>
&lt;p>원본은 &lt;strong>3바이트&lt;/strong>였습니다. 대역 서버가 받은 본문은 &lt;strong>175바이트&lt;/strong>였고, 본문을 그대로 원본과 비교하던 검사가 실패했습니다.&lt;/p>
&lt;h2 id="요청-본문에-파일과-서명이-함께-있었다">전송 본문에 붙은 청크 서명&lt;/h2>
&lt;p>대역이 받은 본문에는 청크 길이와 청크별 서명이 들어 있었습니다. 이 요청의 SDK는 파일 데이터를 &lt;code>aws-chunked&lt;/code> 형식으로 감쌌고, 데이터가 끝나는 지점도 크기 0인 청크로 표시했습니다. 그래서 전송 본문의 길이에는 원본 외에 프레이밍과 서명 정보까지 포함됐습니다. AWS 문서에서 &lt;code>x-amz-decoded-content-length&lt;/code>는 이 메타데이터를 제외한 원본 길이입니다. &lt;a href="https://docs.aws.amazon.com/AmazonS3/latest/developerguide/sigv4-streaming.html">AWS SigV4 청크 업로드 문서&lt;/a>&lt;/p></description></item><item><title>96.7 BPM이 96으로 바뀐 이유</title><link>https://team-edo.github.io/posts/2026/10/strict-json-input-types/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/strict-json-input-types/</guid><description>&lt;p>연습 세션을 시작할 때 앱은 곡과 템포를 서버에 보냅니다. 템포는 JSON 정수만 받기로 했는데, 리뷰 중 &lt;code>&amp;quot;bpm&amp;quot;: 96.7&lt;/code>을 보냈더니 서버는 &lt;strong>96 BPM으로 처리하고 HTTP 201&lt;/strong>을 돌려줬습니다. 숫자가 허용 범위에 드는지는 검사했지만, 그 전에 소수점이 사라졌습니다.&lt;/p>
&lt;h2 id="검증기에-도착한-값은-이미-정수였다">검증 전에 정수로 바뀐 BPM&lt;/h2>
&lt;p>당시 프로젝트의 Jackson 3.1.5 설정으로 입력 모양을 바꿔 봤습니다. 정수 칸에 보낸 문자열 &lt;code>&amp;quot;96&amp;quot;&lt;/code>도 96이 됐고, 문자열이어야 하는 곡 ID에는 숫자 7을 보내도 &lt;code>&amp;quot;7&amp;quot;&lt;/code>이 됐습니다. 불리언 칸에 보낸 1은 &lt;code>true&lt;/code>가 됐습니다.&lt;/p></description></item><item><title>HTTP 406에도 공통 오류 본문 적용하기</title><link>https://team-edo.github.io/posts/2026/10/keep-common-error-body-for-http-406/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/keep-common-error-body-for-http-406/</guid><description>&lt;p>앱은 요청에 실패하면 HTTP 상태와 응답 본문의 &lt;code>result.errorCode&lt;/code>를 읽습니다. 컨트롤러가 거절한 요청에 공통 형식을 쓰더라도, Spring이 컨트롤러 호출 전에 예외를 처리하면 다른 형태의 본문이 내려갈 수 있습니다. 지원하지 않는 응답 형식을 요청해 406이 나오는 경우가 그 예였습니다.&lt;/p>
&lt;h2 id="상태와-헤더를-살리고-본문을-공통-형식으로-맞췄다">상태와 헤더를 유지한 공통 본문&lt;/h2>
&lt;p>필수 쿼리 파라미터가 없거나 값의 형식이 틀리면 Spring MVC가 400을 만듭니다. 공통 예외 처리기는 이때 앱이 사용하는 &lt;code>INVALID_REQUEST&lt;/code> 본문을 돌려줍니다. 405는 허용 메서드를 알려 주는 &lt;code>Allow&lt;/code> 헤더도 보존해야 합니다. 본문을 맞추면서 Spring이 이미 정한 상태와 헤더를 함께 전달한 이유입니다.&lt;/p></description></item><item><title>HTTP 테스트에서만 수신 시각 고정하기</title><link>https://team-edo.github.io/posts/2026/10/scope-fixed-clock-to-http-test/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/scope-fixed-clock-to-http-test/</guid><description>&lt;p>악보 화면을 본 시간을 서버에 보낼 때, 서버는 앱이 보낸 시간과 실제로 요청을 받은 시각을 대조합니다. 화면을 본 시간이 수신 시각에서 시작 시각을 뺀 값보다 지나치게 길면 저장하지 않습니다. 이런 API를 고정된 날짜로 테스트하면, 테스트를 실행하는 날짜에 따라 같은 요청이 허용되거나 거절될 수 있습니다.&lt;/p>
&lt;h2 id="실제-http-테스트에만-수신-시계를-고정했다">테스트 설정에 고정 &lt;code>Clock&lt;/code> 주입&lt;/h2>
&lt;p>체류 기록 테스트는 애플리케이션을 띄우고 실제 HTTP로 요청합니다. 서비스만 직접 호출하는 테스트처럼 생성자에 시계를 건네는 것으로는 서버의 수신 시각을 고정할 수 없습니다. 그래서 이 테스트가 가져오는 &lt;code>@TestConfiguration&lt;/code>에 고정된 &lt;code>Clock&lt;/code>을 만들고 &lt;code>@Primary&lt;/code>를 붙였습니다. 이 설정을 가져온 테스트에서는 같은 타입을 주입할 때 고정 시계를 우선합니다.&lt;/p></description></item><item><title>JDBC로 바꾼 값을 MyBatis가 다시 읽지 못한 이유</title><link>https://team-edo.github.io/posts/2026/10/mybatis-session-cache-in-db-tests/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/mybatis-session-cache-in-db-tests/</guid><description>&lt;p>연습 세션에 분석 시도 번호와 시작 시각을 추가할 때 두 가지를 확인하려고 했습니다. 기존 행을 조회하면 새 필드에 기본값이 들어가야 하고, 값을 채운 행을 조회하면 매퍼가 새 필드를 올바르게 읽어야 했습니다. 처음에는 테스트 행 하나로 두 가지를 검사했습니다. MyBatis로 먼저 읽고 &lt;code>JdbcTemplate&lt;/code>로 값을 바꾼 뒤 같은 매퍼에서 다시 읽었습니다. 두 번째 조회에는 변경 전 시도 번호가 남아 있었습니다.&lt;/p>
&lt;h2 id="같은-sql을-다시-불러도-db까지-가지-않을-수-있다">세션 캐시가 재조회를 가로막은 이유&lt;/h2>
&lt;p>테스트는 Spring 트랜잭션 안에서 실행됐고, MyBatis는 같은 &lt;code>SqlSession&lt;/code>을 사용했습니다. MyBatis의 기본 로컬 캐시는 세션 안에서 같은 쿼리를 같은 인자로 요청하면 이전 결과를 재사용할 수 있습니다. 매퍼의 갱신은 이 캐시를 비우지만, 여기서 값을 바꾼 것은 &lt;code>JdbcTemplate&lt;/code>였습니다. 같은 트랜잭션에서 SQL을 실행했어도 MyBatis가 자신의 캐시에 있는 조회 결과를 무효화할 계기는 없었습니다.&lt;/p></description></item><item><title>SQL 조건을 빼도 테스트가 통과한 이유</title><link>https://team-edo.github.io/posts/2026/10/sql-test-fixtures-overlapping-filters/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/sql-test-fixtures-overlapping-filters/</guid><description>&lt;p>얼쑤의 성취도 화면은 곡을 처음부터 끝까지 연주해 채점을 마친 기록을 비교합니다. 조회 SQL은 세션에 평가 행이 있는지, 세션 상태가 &lt;code>DONE&lt;/code>인지, 연주 구간이 악보의 첫 음부터 마지막 음까지인지 확인합니다. 실제 MySQL에 데이터를 넣고 HTTP로 조회하는 테스트도 통과했습니다. 화면에서 왜 같은 조건의 연주를 고르는지는 점수 비교 글에 정리했습니다. &lt;a href="https://team-edo.github.io/posts/2026/10/compare-like-for-like-practice-scores/">같은 조건의 연주만 비교한 이유&lt;/a>&lt;/p>
&lt;p>독립 리뷰에서 SQL 조건을 일부러 빼 보니 예상과 달랐습니다. 완료 상태를 확인하는 조건이나 시작 음을 확인하는 조건을 빼도 테스트는 계속 통과했습니다.&lt;/p></description></item><item><title>같은 조건의 연주만 비교한 이유</title><link>https://team-edo.github.io/posts/2026/10/compare-like-for-like-practice-scores/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/compare-like-for-like-practice-scores/</guid><description>&lt;p>얼쑤의 성장 화면에서는 음정, 박자, 길이 점수가 처음보다 어떻게 바뀌었는지 보여줍니다. 같은 곡을 느리게 분 연주와 빠르게 분 연주, 혹은 같은 녹음을 서로 다른 기준으로 채점한 점수를 나란히 두면 점수는 달라질 수 있습니다. 비교에 넣을 연주를 정하는 일이 점수를 빼는 계산보다 먼저였습니다.&lt;/p>
&lt;h2 id="최근의-전체-곡-연주를-기준으로-삼았다">최근 전체 곡 연주가 비교 기준&lt;/h2>
&lt;p>먼저 채점이 끝나 평가가 있는 전체 곡 연주 중 가장 최근 것을 고릅니다. 그 연주와 곡, 악보 버전, 채점 모델 버전, BPM과 BPM 단위가 같은 연주만 비교 대상으로 둡니다. BPM 단위도 같은 이유는 숫자가 같아도 어떤 박을 기준으로 세느냐에 따라 실제 빠르기가 달라지기 때문입니다. 중간부터 끝까지 분 연습 구간은 마지막 음에 도달했어도 전체 곡 연주에서 뺐습니다.&lt;/p></description></item><item><title>녹음 정보 한 칸이 비어도 CHECK가 통과한 이유</title><link>https://team-edo.github.io/posts/2026/10/mysql-check-unknown-recording-metadata/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/mysql-check-unknown-recording-metadata/</guid><description>&lt;p>연습 세션은 녹음이 올라오기 전에 먼저 만들어집니다. 그래서 파일 크기, 길이, 샘플레이트, 채널 수는 처음에는 모두 비어 있을 수 있습니다. 업로드를 마친 뒤에는 네 값이 모두 양수여야 합니다. 이 두 가지 상태만 허용하도록 MySQL에 &lt;code>CHECK&lt;/code>를 추가했습니다.&lt;/p>
&lt;h2 id="양수-비교는-null을-거짓으로-만들지-않는다">NULL과 양수 비교가 만든 UNKNOWN&lt;/h2>
&lt;p>처음 식은 네 칸이 모두 비었거나, 네 칸 모두 0보다 크면 허용하는 모양이었습니다. 그런데 채널 수만 NULL로 바꾼 테스트 행이 저장됐습니다. 네 칸 모두 비었다는 조건은 거짓이었지만, &lt;code>NULL &amp;gt; 0&lt;/code>은 거짓 대신 SQL의 &lt;code>UNKNOWN&lt;/code>이 됩니다. 그 결과 식 전체도 &lt;code>UNKNOWN&lt;/code>이었고, MySQL의 &lt;code>CHECK&lt;/code>는 평가가 거짓일 때 위반으로 처리합니다.&lt;/p></description></item><item><title>동시 업로드에서 접수된 녹음만 남기기</title><link>https://team-edo.github.io/posts/2026/10/concurrent-upload-keep-winning-object/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/concurrent-upload-keep-winning-object/</guid><description>&lt;p>얼쑤에서 녹음을 올리면 서버는 파일을 객체 저장소에 쓴 뒤, 세션에 원본의 키를 기록하고 상태를 &lt;code>PENDING&lt;/code>으로 바꿉니다. 같은 세션에 업로드 요청이 거의 동시에 오면 두 요청 모두 처음에는 상태를 &lt;code>RECORDING&lt;/code>으로 읽을 수 있습니다. DB에서 한 요청만 접수하더라도 저장소에는 두 요청 모두 파일을 쓰는 시점이 생깁니다.&lt;/p>
&lt;p>세션마다 고정된 객체 키를 쓴다고 가정해 봤습니다. 요청 A가 먼저 DB 갱신을 마쳐도, 요청 B가 같은 키에 늦게 쓰면 DB가 가리키는 파일은 B의 바이트가 됩니다. B가 갱신에 실패하고 자신이 쓴 파일을 지우는 경우에도 같은 키라 A의 원본이 사라집니다. DB가 접수 순서를 정하는 것과 별도로, 어느 요청이 어느 파일을 소유하는지 저장소에서도 구분해야 했습니다.&lt;/p></description></item><item><title>동시 요청 네 건, 연습 세션 한 개</title><link>https://team-edo.github.io/posts/2026/10/practice-session-idempotency/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/practice-session-idempotency/</guid><description>&lt;p>앱이 연습 세션을 만들라는 요청을 보냈는데 응답을 받기 전에 연결이 끊겼다고 가정해 봅시다. 서버에서는 이미 저장을 마쳤을 수도 있습니다. 앱이 같은 요청을 다시 보냈을 때 새 세션을 만들면 한 번의 연습에 세션이 두 개 생깁니다. 얼쑤에서는 앱이 재전송에도 같은 &lt;code>Idempotency-Key&lt;/code>를 쓰도록 하고, 서버는 같은 회원·키로 만들어진 세션을 다시 돌려주도록 했습니다.&lt;/p>
&lt;h2 id="두-요청이-모두-아직-없음을-볼-수-있다">중복 삽입을 막는 유일 제약&lt;/h2>
&lt;p>키가 들어오면 먼저 같은 회원이 그 키를 쓴 적이 있는지 조회합니다. 이미 세션이 있으면 저장 단계까지 갈 필요가 없습니다. 다만 두 요청이 거의 함께 오면 둘 다 저장 전에 조회를 마치고 “아직 없음”으로 판단할 수 있습니다.&lt;/p></description></item><item><title>만료 배치에서 인덱스가 잠금 범위를 바꾸는 이유</title><link>https://team-edo.github.io/posts/2026/10/innodb-expiry-update-scan-locks/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/innodb-expiry-update-scan-locks/</guid><description>&lt;p>얼쑤는 녹음 세션을 연 뒤 업로드가 오랫동안 오지 않으면 만료 배치에서 정리합니다. 쿼리는 &lt;code>RECORDING&lt;/code> 상태이면서 시작 시각이 마감보다 이른 세션만 찾아 &lt;code>FAILED&lt;/code>로 바꿉니다. 조건을 충족한 행이 적으면 쓰기 범위도 작아 보입니다. 잠금 범위를 검토할 때는 DB가 그 행들을 찾으려고 어떤 인덱스를 훑는지도 봐야 했습니다.&lt;/p>
&lt;h2 id="바꾼-행-수만으로는-잠금-범위를-알-수-없다">갱신 행 수와 잠금 범위의 차이&lt;/h2>
&lt;p>InnoDB의 &lt;code>UPDATE&lt;/code>는 조건에 맞는 행을 찾는 동안 잠금을 잡습니다. &lt;code>REPEATABLE READ&lt;/code>에서 범위를 훑으면 접근 경로에 따라 인덱스 레코드와 그 앞 간격에 next-key 잠금이 걸릴 수 있습니다. 조건을 좁히는 데 쓸 인덱스가 없어 전체 스캔을 택하면, 결과적으로 갱신한 행 수보다 넓은 범위가 잠길 수 있습니다. 새 세션을 넣을 자리가 그 간격 안에 있으면 삽입도 기다릴 수 있습니다. &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/innodb-locks-set.html">InnoDB에서 문장별로 설정하는 잠금&lt;/a>&lt;/p></description></item><item><title>분석 실행기를 공용 Bean으로 등록하지 않은 이유</title><link>https://team-edo.github.io/posts/2026/10/own-analysis-executor-lifecycle/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/own-analysis-executor-lifecycle/</guid><description>&lt;p>얼쑤는 녹음을 접수한 뒤 분석을 별도 작업으로 실행합니다. 이 기능을 붙이던 때에는 악보를 채점용 음표로 바꾸는 구현, 녹음 읽기 주소를 만드는 구현, 분석 서버 주소가 서로 다른 작업에서 들어왔습니다. 일부만 합쳐졌을 때도 앱은 켜져야 했습니다. 세 가지가 모두 준비되면 분석을 열고, 동시에 처리할 작업과 대기열은 이 기능의 규모에 맞춰 제한하기로 했습니다.&lt;/p>
&lt;h2 id="앱은-켜-두고-조건이-갖춰지면-분석-열기">앱은 켜 두고 조건이 갖춰지면 분석 열기&lt;/h2>
&lt;p>설정은 Spring의 &lt;code>ObjectProvider&lt;/code>로 악보 변환기와 녹음 읽기 주소 제공자를 찾고, 분석 서버 주소가 비었는지도 봅니다. 세 가지가 모두 있으면 HTTP 클라이언트와 분석기를 전용 실행 경로에 연결합니다. 하나라도 빠지면 분석이 꺼진 객체를 반환합니다. 이 상태에서는 업로드가 끝나도 누락 항목을 기록하고 작업을 넘기지 않습니다. 분석을 명시적으로 다시 요청하는 경로에서는 제출 거절을 알려, 호출자가 이미 열었던 시도를 정리할 수 있게 했습니다.&lt;/p></description></item><item><title>분석 제출에 실패하면 이미 커밋한 시도는?</title><link>https://team-edo.github.io/posts/2026/10/compensate-failed-analysis-handoff/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/compensate-failed-analysis-handoff/</guid><description>&lt;p>얼쑤에서 같은 녹음의 분석을 다시 요청하면 세션을 &lt;code>PENDING&lt;/code>으로 바꾸고 시도 번호를 올립니다. 실행기는 커밋 뒤에 작업을 받습니다. 상태가 반영되기 전 작업이 시작되면 새 시도를 읽을 수 없기 때문입니다.&lt;/p>
&lt;p>커밋과 작업 제출 사이에 실패할 수 있는 지점이 남습니다. 실행기의 대기열이 가득 차면 작업을 넘길 수 없지만, DB에는 이미 진행 중인 시도가 기록돼 있습니다. 이때 호출자에게 제출 실패만 돌려주면 상태를 읽는 쪽에는 아직 시작하지 못한 작업이 진행 중인 것처럼 보입니다.&lt;/p></description></item><item><title>서비스 검사를 꺼도 통과한 테스트 8개</title><link>https://team-edo.github.io/posts/2026/10/fake-repository-masks-service-check/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/fake-repository-masks-service-check/</guid><description>&lt;p>얼쑤에서 분석을 다시 시작할 수 있는 경우는 기존 녹음을 유지한 서버 오류나 시간 초과입니다. 다른 상태에서 요청이 들어오면 서비스는 먼저 거절합니다. 그 검사를 통과한 뒤에도 DB 갱신은 조건부로 실행합니다. 서비스가 상태를 읽은 뒤 다른 요청이 그 행을 바꿀 수 있기 때문입니다.&lt;/p>
&lt;p>단위 테스트의 가짜 저장소도 실제 DB의 조건을 비슷하게 구현했습니다. 서비스의 재시도 가능 여부 검사를 일부러 항상 참으로 바꿔 봤는데, 당시 테스트 &lt;strong>여덟 개&lt;/strong>가 모두 통과했습니다. 서비스는 거절했어야 할 요청을 저장소에 넘겼지만, 가짜 저장소가 대신 거절해서 바깥에서 본 결과와 행 상태는 같았습니다.&lt;/p></description></item><item><title>악보 이벤트 오타가 음표 번호를 밀면</title><link>https://team-edo.github.io/posts/2026/10/reject-unknown-score-event-kind/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/reject-unknown-score-event-kind/</guid><description>&lt;p>얼쑤는 악보에서 음표를 골라 번호를 매기고, 앱이 지정한 시작 번호와 끝 번호로 연습 구간을 자릅니다. 쉼표는 악보의 시간에는 영향을 주지만 번호를 매기는 음표 목록에는 들어가지 않습니다. 저장된 이벤트를 읽을 때는 그래서 &lt;code>kind&lt;/code>가 &lt;code>NOTE&lt;/code>인 항목만 골랐습니다.&lt;/p>
&lt;p>이 필터에는 한 가지 가정이 있었습니다. &lt;code>NOTE&lt;/code>가 아닌 항목은 모두 알고 있는 다른 이벤트라는 가정입니다. 실제로는 &lt;code>Note&lt;/code>라는 오타도 같은 방식으로 빠집니다. 중간 음표 하나가 빠진 뒤에도 요청 범위가 남은 음표 수 안에 있다면, 뒤의 번호는 하나씩 밀리고 서버는 다른 음을 골라 응답할 수 있습니다.&lt;/p></description></item><item><title>악보 체류 기록의 재전송 오류 구분</title><link>https://team-edo.github.io/posts/2026/10/permanent-score-view-sync-rejections/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/permanent-score-view-sync-rejections/</guid><description>&lt;p>얼쑤 앱은 악보 화면을 떠날 때 그동안 보인 시간과 연습으로 인정한 시간을 기록합니다. 네트워크가 끊겨도 기록을 남길 수 있도록 앱은 닫힌 구간을 기기의 대기열에 넣었다가 다시 보냅니다. 저는 팀과 이 전송 규칙을 정리하고 서버의 저장 API를 구현했습니다.&lt;/p>
&lt;p>이 API에서 상태 코드는 앱의 다음 동작을 정합니다. 5xx나 연결 오류가 오면 구간을 보관하고 다음 기회에 보냅니다. 400이나 404가 오면 해당 구간은 대기열에서 빼고 오류를 기록합니다. 401에는 토큰을 갱신하는 경로가 따로 있습니다. 리뷰에서 같은 요청을 다시 보내도 해결될 이유가 없는 &lt;strong>두 가지 경우&lt;/strong>가 500으로 분류되는 것을 확인했습니다.&lt;/p></description></item><item><title>지난달과 연속 연습일 계산하기</title><link>https://team-edo.github.io/posts/2026/10/practice-calendar-previous-month-and-streak/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/practice-calendar-previous-month-and-streak/</guid><description>&lt;p>얼쑤의 성장 화면에는 선택한 기간을 이전 기간과 비교한 수치와 현재 연속 연습일이 함께 나옵니다. 두 숫자 모두 날짜로 계산하지만 기준이 다릅니다. 비교 기간은 사용자가 고른 구간에 따라 바뀝니다. 연속 연습일은 과거 달을 보고 있어도 오늘을 기준으로 유지하기로 했습니다.&lt;/p>
&lt;p>초안에서는 서버가 주간·월간 구간을 정하려고 했습니다. 팀의 API 계약을 대조한 뒤 화면에서 고른 시작일과 종료일을 앱이 보내고, 서버가 그 범위의 요약과 날짜별 집계를 반환하도록 맞췄습니다. 인정 시간 &lt;strong>1분 이상&lt;/strong>을 연습한 날로 보는 규칙도 합의를 거쳐 계약에 반영했습니다. 이때 정한 시작일·종료일 계약을 기준으로 비교 기간과 연속 일수의 계산을 나눴습니다.&lt;/p></description></item><item><title>타임아웃 뒤 ffprobe 종료 확인하기</title><link>https://team-edo.github.io/posts/2026/10/terminate-ffprobe-after-timeout/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/terminate-ffprobe-after-timeout/</guid><description>&lt;p>얼쑤는 녹음을 받으면 ffprobe를 실행해 오디오 형식과 길이를 확인합니다. 리뷰에서 요청을 처리하는 스레드가 대기 중에 중단되면, 서버 코드가 예외로 빠져나가도 자식 프로세스는 남을 수 있다는 점을 발견했습니다. 사용자의 파일 형식이 잘못된 경우와 검사를 끝낼 수 없었던 경우도 앱에서는 다르게 처리해야 합니다.&lt;/p>
&lt;h2 id="예외를-반환하는-경로에서-자식도-정리했다">예외 경로에서 자식 프로세스 종료&lt;/h2>
&lt;p>ffprobe의 표준 출력은 별도 스레드가 읽습니다. 부모가 출력을 읽지 않고 종료만 기다리면, 쓰기 버퍼가 찼을 때 자식도 진행할 수 없기 때문입니다. 부모는 프로세스의 종료와 출력 읽기 완료를 각각 기다립니다. 시간 초과나 읽기 실패는 검사 도구의 오류로 처리합니다. 대기 중 인터럽트를 받으면 현재 스레드에 중단 표시를 복구해 상위 코드도 그 상태를 볼 수 있게 했습니다.&lt;/p></description></item><item><title>트랜잭션 커밋 경계 테스트</title><link>https://team-edo.github.io/posts/2026/10/test-transaction-commit-boundary/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/test-transaction-commit-boundary/</guid><description>&lt;p>연주 분석에는 트랜잭션이 끝나는 시점이 중요한 일이 두 가지 있습니다. 분석 결과를 기록할 때는 세션 상태와 평가를 함께 저장합니다. 녹음이 올라왔을 때는 업로드가 확정된 뒤에만 분석을 시작합니다. 둘 다 오류를 던지는지만 확인하면, 앞서 실행한 쓰기나 바깥에 넘긴 작업이 남았는지 알 수 없습니다.&lt;/p>
&lt;h2 id="db에-실제로-남는-값을-읽었다">롤백 뒤 남은 DB 상태 확인&lt;/h2>
&lt;p>보통 매퍼 테스트는 테스트에 트랜잭션을 열어 두고 끝에서 한꺼번에 되돌립니다. 결과 저장의 원자성 검사에서는 테스트 트랜잭션을 열지 않았습니다. 테스트가 서비스 트랜잭션의 커밋 경계를 대신하면, 서비스를 벗어난 뒤에도 무엇이 남는지 구분하기 어렵기 때문입니다.&lt;/p></description></item><item><title>한국 시간으로 집계하고 UTC 인덱스 쓰기</title><link>https://team-edo.github.io/posts/2026/10/kst-practice-aggregation-utc-index/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/kst-practice-aggregation-utc-index/</guid><description>&lt;p>얼쑤의 성장 화면은 한국 시간으로 하루의 연습 시간을 보여줍니다. DB에는 연습 구간이 시작된 시각을 UTC로 저장합니다. 한국 시간 월요일 자정은 DB에서 일요일 15시이므로, 저장된 날짜를 그대로 묶으면 월요일에 연습한 기록이 지난주에 들어갑니다. 하루를 한국 시간으로 나누면서도, 조회할 회원과 기간은 인덱스로 좁힐 수 있게 쿼리를 구성했습니다.&lt;/p>
&lt;h2 id="조회-조건에는-utc-경계값을-넘겼다">UTC 경계값으로 범위 조회&lt;/h2>
&lt;p>서버는 사용자가 요청한 첫날의 한국 시간 자정과 마지막 날 다음 날의 자정을 각각 UTC로 바꿉니다. 예를 들어 10월 26일 하루를 조회하면 &lt;strong>10월 25일 15:00 UTC 이상, 10월 26일 15:00 UTC 미만&lt;/strong>이 범위입니다. 마지막 시각을 다음 날 시작 미만으로 두면, 마지막 초에 들어 있는 마이크로초도 포함됩니다.&lt;/p></description></item><item><title>형식이 맞는 날짜가 500을 낸 이유</title><link>https://team-edo.github.io/posts/2026/10/reject-out-of-range-calendar-dates/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/reject-out-of-range-calendar-dates/</guid><description>&lt;p>얼쑤의 연습 통계 API는 시작일과 종료일을 받아 해당 기간의 연습 시간과 횟수를 돌려줍니다. 요약 API는 바로 앞 기간의 통계도 함께 제공합니다. 두 날짜를 Spring의 &lt;code>@DateTimeFormat(iso = ISO.DATE)&lt;/code>로 받아 &lt;code>LocalDate&lt;/code>로 변환했습니다. 그러면 &lt;code>2026/10/01&lt;/code>처럼 형식이 어긋난 날짜는 컨트롤러가 실행되기 전에 400으로 거절됩니다.&lt;/p>
&lt;p>리뷰에서 날짜 형식 검사를 통과하는 값이 500을 냈습니다. 요약 API에 시작일과 종료일을 모두 **&lt;code>-999999999-01-01&lt;/code>**로 보내면 두 값은 &lt;code>LocalDate&lt;/code>로 변환됐습니다. 길이도 하루여서 기존 기간 제한을 통과했습니다. 직전 기간을 구하려고 시작일에서 하루를 빼는 순간 &lt;code>LocalDate&lt;/code>의 범위를 벗어났고, &lt;code>DateTimeException&lt;/code>이 500으로 이어졌습니다.&lt;/p></description></item></channel></rss>