<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>MySQL on 얼쑤 · Team Edo</title><link>https://team-edo.github.io/tags/mysql/</link><description>Recent content in MySQL on 얼쑤 · Team Edo</description><generator>Hugo</generator><language>ko-kr</language><lastBuildDate>Mon, 09 Nov 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://team-edo.github.io/tags/mysql/index.xml" rel="self" type="application/rss+xml"/><item><title>MySQL 복제가 끊기면 원본 쓰기를 막아야 할까?</title><link>https://team-edo.github.io/posts/2026/11/mysql-replication-write-policy/</link><pubDate>Mon, 09 Nov 2026 00:00:00 +0000</pubDate><guid>https://team-edo.github.io/posts/2026/11/mysql-replication-write-policy/</guid><description>&lt;p>운영 DB의 시간당 백업을 대기 서버로 보내고 있었다. 최근 24시간의 수신 기록에서 대기 서버가 가진 최신 스냅샷의 나이는 최대 &lt;strong>1시간 29초&lt;/strong>였다. 그때 원본 서버를 잃었다면 스냅샷 이후에 저장한 기록은 백업에서 복구할 수 없었다. 전송이 밀리면 이 구간은 더 길어질 수 있다.&lt;/p>
&lt;p>대기 서버에 MySQL 복제본을 두면 백업 사이의 변경도 따라갈 수 있다. 그러자 다른 질문이 생겼다. &lt;strong>두 서버 사이 연결만 끊겼을 때, 아직 정상인 원본은 계속 저장을 받아야 할까?&lt;/strong>&lt;/p></description></item><item><title>컨테이너에서만 타임아웃 난 MySQL SSH 터널 추적기</title><link>https://team-edo.github.io/posts/2026/11/ssh-tunnel-container-mysql-timeout/</link><pubDate>Mon, 09 Nov 2026 00:00:00 +0000</pubDate><guid>https://team-edo.github.io/posts/2026/11/ssh-tunnel-container-mysql-timeout/</guid><description>&lt;p>MySQL 복제를 준비하면서 원본 DB에 닿는 경로를 SSH 터널로 제한했다. 원본 서버의 전달 포트는 루프백에만 열었다. 대기 서버에서는 SSH 클라이언트가 전용 브리지의 호스트 주소에 포트를 열어 복제 컨테이너의 연결을 받도록 했다.&lt;/p>
&lt;p>대기 서버 호스트에서 터널 포트에 접속하자 MySQL이 첫 응답을 보냈다. 같은 포트에 &lt;strong>복제 컨테이너에서&lt;/strong> 접속하자 &lt;code>ERROR 2003&lt;/code>과 함께 연결 시간이 초과됐다.&lt;/p>
&lt;h2 id="응답이-끊긴-구간-찾기">응답이 끊긴 구간 찾기&lt;/h2>
&lt;p>연결 경로를 세 위치에서 비교했다.&lt;/p></description></item><item><title>폐기용 MySQL을 실행 단위로 분리한 이유</title><link>https://team-edo.github.io/posts/2026/11/isolate-disposable-mysql-tests/</link><pubDate>Mon, 09 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/isolate-disposable-mysql-tests/</guid><description>&lt;p>MySQL 통합 테스트는 실제 스키마를 만들고 데이터를 씁니다. 같은 데이터베이스와 계정을 다음 실행에 재사용하면 앞선 실행이 남긴 데이터가 새 결과에 영향을 줄 수 있습니다. 얼쑤에서는 실행마다 테스트 DB와 전용 계정을 만들고, 다른 DB에서 쓰는 접속 정보와 분리했습니다. 서버의 기동 조건이나 연결 한도를 시험할 때는 MySQL 인스턴스도 따로 띄웠습니다.&lt;/p>
&lt;h2 id="어디까지-분리할까">어디까지 분리할까&lt;/h2>
&lt;table>
 &lt;thead>
 &lt;tr>
 &lt;th>시험에서 바꾸는 것&lt;/th>
 &lt;th>실행마다 분리할 대상&lt;/th>
 &lt;/tr>
 &lt;/thead>
 &lt;tbody>
 &lt;tr>
 &lt;td>테이블, 테스트 데이터, 스키마 변경&lt;/td>
 &lt;td>데이터베이스와 해당 DB에만 접근하는 전용 계정&lt;/td>
 &lt;/tr>
 &lt;tr>
 &lt;td>기동 옵션, 최대 연결 수&lt;/td>
 &lt;td>MySQL 인스턴스, 데이터 폴더, 접속 주소&lt;/td>
 &lt;/tr>
 &lt;/tbody>
&lt;/table>
&lt;p>Gradle의 통합 테스트에는 &lt;code>TEST_DB_URL&lt;/code>, &lt;code>TEST_DB_USERNAME&lt;/code>, &lt;code>TEST_DB_PASSWORD&lt;/code>를 그 실행에만 전달했습니다. 새 계정은 시험에 필요한 권한을 받고, 데이터 쓰기 권한은 해당 DB에 한정했습니다. 실패한 실행도 정리할 수 있게 종료 절차를 테스트의 성공 여부와 분리했습니다.&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>녹음 재분석에서 결과가 섞이지 않게</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>Flyway 선실행과 배포 롤백</title><link>https://team-edo.github.io/posts/2026/11/flyway-deploy-rollback/</link><pubDate>Fri, 06 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/flyway-deploy-rollback/</guid><description>&lt;p>새 버전이 배포된 뒤 readiness는 &lt;code>UP&lt;/code>이었는데, 모범 음원 조회는 HTTP 500을 반환했습니다. 새 앱은 음원의 저장소 객체 키를 읽으려 했고, 운영 DB에는 그 컬럼이 아직 없었습니다. 실행 중인 앱을 그대로 두고 밀린 마이그레이션을 적용하자 같은 요청은 게시된 음원이 없을 때의 응답인 404로 바뀌었습니다. DB 연결이 정상이어도 새 쿼리에 필요한 컬럼이 준비됐는지는 별도로 확인해야 했습니다.&lt;/p>
&lt;p>당시 앱 배포는 자동이었고 Flyway 적용은 수동이었습니다. 앱 교체와 스키마 변경의 순서를 사람이 맞춰야 했습니다. 이 배포 흐름을 바꾸는 작업에 참여하면서, 새 앱이 필요한 스키마를 먼저 준비하고 이전 앱도 그 스키마에서 계속 동작할 수 있어야 한다는 두 가지 조건을 함께 다뤘습니다.&lt;/p></description></item><item><title>Flyway 체크섬의 줄바꿈과 공백</title><link>https://team-edo.github.io/posts/2026/11/flyway-line-checksum/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/flyway-line-checksum/</guid><description>&lt;p>앱을 교체하기 전에 후보 JAR의 SQL이 운영 DB에 적용된 마이그레이션과 같은지 확인하고 싶었습니다. 적용할 마이그레이션이 없으면 배포 도구는 Flyway를 실행하지 않습니다. 그때도 이미 적용한 SQL이 바뀌었는지 가려내려면 JAR의 파일에서 Flyway 이력과 같은 방식으로 체크섬을 계산해야 했습니다.&lt;/p>
&lt;h2 id="sql을-읽는-방식까지-맞췄다">SQL 읽기 방식 맞추기&lt;/h2>
&lt;p>프로젝트에서 사용한 Flyway는 파일을 UTF-8로 읽고, 첫 줄의 BOM과 각 줄의 줄바꿈 문자를 제외한 바이트를 순서대로 CRC32에 넣었습니다. 그래서 LF를 CRLF로 바꾸거나 마지막 줄바꿈을 빼도 체크섬이 같습니다. 줄 끝의 공백은 SQL을 읽는 줄에 그대로 남아 계산에 들어갑니다.&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>저장한 녹음을 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>회원 한 행이 막은 테스트 준비</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>Git 병합 뒤 DB 테스트를 한 이유</title><link>https://team-edo.github.io/posts/2026/10/verify-git-merge-beyond-conflicts/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/verify-git-merge-beyond-conflicts/</guid><description>&lt;p>진행 중인 작업을 바꾸지 않고 기준 브랜치와 충돌할지 미리 보고 싶을 때 &lt;code>git merge-tree --write-tree HEAD origin/develop&lt;/code>를 사용했습니다. 작업 트리와 인덱스를 그대로 두고 두 커밋을 병합한 결과를 확인할 수 있습니다. 한 브랜치에서는 기준 브랜치에 새 커밋이 23개 들어왔을 때 검사에 충돌이 없었습니다. 이후 다른 변경이 먼저 병합되자 다시 실행한 검사에서 문서 충돌이 나타났고, 기준 브랜치를 실제로 합쳐 그 파일을 정리했습니다. 미리 본 결과는 그때 비교한 두 커밋에 대한 것이었습니다.&lt;/p></description></item><item><title>MySQL 모니터링 계정에 회원 테이블 9개 열만 허용</title><link>https://team-edo.github.io/posts/2026/10/mysql-monitoring-column-privileges/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/mysql-monitoring-column-privileges/</guid><description>&lt;p>모니터링 화면에는 회원 수와 최근 가입 시점이 필요했습니다. 그 화면을 만드는 SQL에는 이메일이나 인증 토큰이 필요 없었지만, 모니터링 계정의 데이터베이스 전체 조회 권한으로는 그런 값도 읽을 수 있었습니다. 화면 쿼리에서 열을 빼는 것과 계정이 그 열을 읽을 수 없는 것은 다른 문제였습니다.&lt;/p>
&lt;h2 id="넓은-권한을-회수하고-필요한-열을-적었다">전체 조회 권한을 열 단위로 축소&lt;/h2>
&lt;p>MySQL은 계정에 부여된 조회 권한을 합칩니다. 데이터베이스 전체에 &lt;code>SELECT&lt;/code>가 남아 있는 상태에서 특정 열만 다시 &lt;code>GRANT&lt;/code>하면, 이전 권한으로 여전히 모든 열을 읽을 수 있습니다. 먼저 넓은 권한을 회수한 뒤, 모니터링에 필요한 테이블과 열을 다시 부여했습니다. 회원 테이블에서는 &lt;strong>9개 열&lt;/strong>만 열었습니다. &lt;a href="https://dev.mysql.com/doc/refman/8.4/en/grant.html">MySQL의 열 단위 GRANT 문서&lt;/a>&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>악보 체류 기록의 재전송 오류 구분</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/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>