<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>얼쑤 on 얼쑤 · Team Edo</title><link>https://team-edo.github.io/</link><description>Recent content in 얼쑤 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/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>백엔드 무중단 배포</title><link>https://team-edo.github.io/posts/2026/11/docker-compose-blue-green-drain/</link><pubDate>Mon, 09 Nov 2026 00:00:00 +0000</pubDate><guid>https://team-edo.github.io/posts/2026/11/docker-compose-blue-green-drain/</guid><description>&lt;p>백엔드 컨테이너 하나를 새 버전으로 교체하면, 이전 컨테이너가 내려간 시점과 새 컨테이너가 요청을 받을 수 있는 시점 사이에 공백이 생긴다. 이 공백을 없애려면 순서를 바꿔야 한다. &lt;strong>이전 버전이 요청을 받는 동안 새 버전을 준비하고, 연결을 넘긴 뒤에도 이미 받은 요청과 작업을 마칠 때까지 이전 버전을 살려 둔다.&lt;/strong> 얼쑤는 기존 Docker Compose와 Nginx에 이 순서를 적용했다.&lt;/p>
&lt;h2 id="같은-호스트에-백엔드-슬롯-두-개를-뒀다">같은 호스트에 백엔드 슬롯 두 개를 뒀다&lt;/h2>
&lt;p>슬롯은 백엔드 한 인스턴스를 실행하는 자리다. Compose에 블루와 그린을 정의하고, 두 슬롯이 같은 런타임 설정을 쓰도록 했다. 배포할 때 비활성 슬롯에 새 버전의 JAR을 붙인다. 두 슬롯은 같은 MySQL을 사용한다. 사용자는 평소와 같은 HTTPS 주소로 접속하고, Nginx가 활성 슬롯으로 요청을 보낸다.&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>APK는 받을 수 있는데 QA 목록 제목은 왜 비었을까</title><link>https://team-edo.github.io/posts/2026/11/apk-metadata-batch/</link><pubDate>Mon, 09 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/apk-metadata-batch/</guid><description>&lt;p>얼쑤의 Android QA 페이지에서는 테스트할 앱 설치 파일인 APK를 고를 수 있습니다. 목록에는 파일을 만든 빌드와 연결된 변경 요청(MR)의 제목과 작성자가 함께 나옵니다. 제목이 비어 있어도 보관된 파일은 내려받을 수 있었지만, 테스터는 어떤 변경이 들어간 빌드인지 목록에서 확인하기 어려웠습니다. 11월 9일 첫 점검에서는 목록에 연결된 MR 136개 중 37개의 상세 정보가 비어 있었습니다.&lt;/p>
&lt;h2 id="설치-파일과-설명이-오는-길">설치 파일과 설명이 오는 길&lt;/h2>
&lt;p>Jenkins는 빌드한 APK를 보관하고 어떤 변경 요청과 연결되는지 목록에 남깁니다. 별도의 동기화 작업은 이 목록에 있는 MR 번호로 GitLab을 조회해 제목과 작성자를 채웁니다. APK는 이미 보관된 파일이어서 이 조회가 실패해도 다운로드할 수 있습니다. 빈 제목을 고치려면 APK를 다시 만드는 대신, 설명을 채우는 작업을 확인해야 했습니다.&lt;/p></description></item><item><title>Docker 로그 6종 중 4종만 파일에 남은 이유</title><link>https://team-edo.github.io/posts/2026/11/docker-log-delivery/</link><pubDate>Mon, 09 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/docker-log-delivery/</guid><description>&lt;p>11월 9일 얼쑤의 운영 구성을 정리하면서, 나중에 문제가 생기면 각 프로그램의 기록을 어디에서 읽을 수 있는지 확인했습니다. Docker에는 로그 여섯 종류를 보내도록 설정돼 있었습니다. 그런데 점검일의 저장 폴더에서 찾을 수 있는 건 네 종류뿐이었습니다. 빠진 두 종류는 프로그램이 실행 중이었는데도 사후에 기록을 읽을 곳이 없었습니다.&lt;/p>
&lt;h2 id="로그가-지나가는-세-곳을-확인했습니다">로그가 지나가는 세 곳을 확인했습니다&lt;/h2>
&lt;p>컨테이너에서 실행되는 프로그램은 일을 하다가 필요한 내용을 출력합니다. Docker의 &lt;strong>로그 드라이버&lt;/strong>는 그 출력을 어디로 보낼지 정합니다. 얼쑤에서 확인한 여섯 프로그램은 모두 &lt;code>syslog&lt;/code> 방식으로 로그를 보냅니다. 같은 서버에서 기록을 받는 &lt;code>rsyslog&lt;/code>는 태그(출처 이름표)를 보고 어떤 기록을 파일로 남길지 결정합니다.&lt;/p></description></item><item><title>Docker 용량 조회 하나가 점검표의 네 칸을 비웠다</title><link>https://team-edo.github.io/posts/2026/11/docker-disk-stats-timeout/</link><pubDate>Mon, 09 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/docker-disk-stats-timeout/</guid><description>&lt;p>얼쑤 운영 점검에서 Docker가 응답하는데도 채팅 점검표의 Docker 상태와 저장공간이 ‘확인 불가’로 나왔습니다. 수집 순서와 코드가 오류를 처리하는 범위를 확인하니, 이미 읽은 값들이 마지막 조회의 시간 초과에 함께 지워지고 있었습니다.&lt;/p>
&lt;h2 id="저장공간에-대한-두-질문">저장공간에 대한 두 질문&lt;/h2>
&lt;p>저장공간에는 두 종류의 숫자가 있었습니다. &lt;strong>사용 중인 공간&lt;/strong>은 이미지, 컨테이너에 새로 쓴 파일, 볼륨, 빌드 캐시 등이 지금 차지한 양입니다. &lt;strong>정리 가능 공간&lt;/strong>은 Docker가 정리하면 확보할 수 있다고 보고한 양입니다. 서버 디스크 전체에 지금 남은 공간은 파일시스템에서 확인하는 별도 숫자입니다.&lt;/p></description></item><item><title>글씨를 키워도 연습을 이어갈 수 있을까?</title><link>https://team-edo.github.io/posts/2026/11/compose-practice-layout-font-scale/</link><pubDate>Mon, 09 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/compose-practice-layout-font-scale/</guid><description>&lt;p>휴대폰으로 단소를 연습할 때는 현재 음의 운지를 보고, 다음에 짚을 손가락을 미리 확인합니다. 악보에서 음을 찾고 재생 버튼도 눌러야 합니다. 시스템 글씨를 키웠더니 정간 아래의 가사가 잘리고, 운지 화면에서는 지금 짚을 그림보다 다음 두 음의 그림이 더 크게 보였습니다. 두 문제 모두 글자가 들어가는 칸과 실제 화면에 남은 높이를 따로 다루면서 생겼습니다.&lt;/p>
&lt;h2 id="화면에-남은-높이부터-재기">화면에 남은 높이부터 재기&lt;/h2>
&lt;p>Galaxy S25+의 도라지타령 연습 준비 화면에서 악보에 쓸 수 있는 높이는 약 226dp였습니다. 기본 크기로 두 각을 위아래로 쌓으려면 약 400dp가 필요했습니다. 여기서 각은 정간보의 여러 칸을 묶은 단위입니다. 페이지에 넣을 각의 개수만 조정하면, 같은 두 각이라도 글꼴 설정이나 칸 안 음표 수에 따라 잘리는 부분이 달라집니다.&lt;/p></description></item><item><title>서버 두 대로 어디까지 장애를 견딜 수 있을까</title><link>https://team-edo.github.io/posts/2026/11/two-host-availability/</link><pubDate>Mon, 09 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/two-host-availability/</guid><description>&lt;p>운영 서버 한 대가 꺼졌다고 가정해 보겠습니다. 두 번째 서버에서 앱이 떠 있어도 사용자가 쓰던 주소가 그 서버로 향하고, 앱이 기존 데이터를 읽고 새 데이터를 저장할 수 있어야 서비스가 돌아옵니다. 얼쑤에서는 서버 두 대를 유지하고 추가 유료 인프라를 전제하지 않는 조건으로 Docker Compose를 확장하는 안과 가벼운 Kubernetes 배포판인 K3s를 쓰는 안을 비교했습니다. 비교 기준은 각 도구가 앱 실행, 접속, 쓰기 복구 중 어디까지 맡을 수 있는가였습니다.&lt;/p></description></item><item><title>악기 이름을 못 불러와도 내 점수는 볼 수 있어야 한다</title><link>https://team-edo.github.io/posts/2026/11/practice-result-optional-data/</link><pubDate>Mon, 09 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/practice-result-optional-data/</guid><description>&lt;p>같은 곡을 두 번 연주한 뒤 첫 번째 기록을 열었다고 해봅시다. 화면에 곡명이 맞게 표시돼도 점수와 틀린 위치가 두 번째 연주의 것이라면, 사용자는 첫 번째 연주에서 무엇을 고쳐야 할지 판단할 수 없습니다. 반대로 악기 이름만 늦게 도착한 경우라면, 점수와 악보부터 확인할 수 있습니다.&lt;/p>
&lt;p>얼쑤의 이전 연주 결과 화면은 이 두 상황을 서로 다른 상태로 처리합니다. 선택한 기록의 결과인지 먼저 확인한 뒤, 점수를 읽는 데 필요한 데이터와 나중에 도착해도 되는 데이터를 나눴습니다.&lt;/p></description></item><item><title>읽기 전용 컨테이너에서 Gunicorn이 기동 오류를 남긴 이유</title><link>https://team-edo.github.io/posts/2026/11/gunicorn-control-socket-read-only-rootfs/</link><pubDate>Mon, 09 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/gunicorn-control-socket-read-only-rootfs/</guid><description>&lt;p>얼쑤 기술 블로그의 편집 API를 점검하던 중 Gunicorn 기동 로그에서 같은 오류가 반복되는 것을 발견했습니다. 편집 설정과 조회수 API는 계속 HTTP 200을 돌려줬는데, 컨테이너를 시작할 때마다 &lt;code>/.gunicorn&lt;/code>을 만들 수 없다는 메시지가 한 번씩 남았습니다. Gunicorn이 시작하면서 어떤 작업에 실패했는지부터 살펴봤습니다.&lt;/p>
&lt;h2 id="요청은-처리됐는데-어떤-소켓이-실패했을까">요청은 처리됐는데 어떤 소켓이 실패했을까&lt;/h2>
&lt;p>Gunicorn은 HTTP 요청을 받는 리스너 외에 프로세스 관리를 위한 Unix 소켓을 엽니다. &lt;code>gunicornc&lt;/code>는 이 제어 소켓으로 워커 상태를 조회하거나 재시작을 요청할 수 있습니다. 소켓 경로를 지정하지 않으면 Gunicorn은 &lt;code>XDG_RUNTIME_DIR&lt;/code>을 우선 사용하고, 그 값이 없으면 홈 아래의 &lt;code>.gunicorn&lt;/code> 디렉터리를 사용합니다. &lt;a href="https://gunicorn.org/reference/settings/#control_socket">Gunicorn 제어 소켓 설정&lt;/a>&lt;/p></description></item><item><title>절쑤가 뛰어도 그림자는 바닥에</title><link>https://team-edo.github.io/posts/2026/11/mascot-gif-floor-shadow/</link><pubDate>Mon, 09 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/mascot-gif-floor-shadow/</guid><description>&lt;p>얼쑤의 팀 캐릭터 절쑤는 갓을 쓰고 한복을 입은 백호입니다. 캐릭터가 확정된 뒤 앱 곳곳에 쓸 수 있도록 포즈 시트의 일러스트를 2초짜리 움짤로 만들었습니다. 감정 표현 12종과 정면·옆면·뒷면 같은 기본 포즈 7종입니다. 시트에서 포즈를 하나씩 잘라 낸 그림에 HTML과 GSAP로 점프, 흔들기, 숨쉬기 같은 움직임을 붙였습니다. 그 장면을 HTML을 영상으로 렌더링하는 오픈소스 도구 &lt;a href="https://hyperframes.heygen.com">HyperFrames&lt;/a>로 480×480, 15fps GIF로 뽑았습니다. 구현은 AI 코딩 에이전트에게 맡기고, 저는 결과 프레임을 보며 방향을 정했습니다.&lt;/p></description></item><item><title>팀 채팅에서 서버 상태만 읽게 한 방법</title><link>https://team-edo.github.io/posts/2026/11/mattermost-readonly-server-checks/</link><pubDate>Mon, 09 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/mattermost-readonly-server-checks/</guid><description>&lt;p>새 버전을 올린 뒤 서비스가 요청을 받을 준비가 됐는지 확인하고 싶을 때가 있습니다. 얼쑤에서는 팀 채팅에서 정해진 명령을 실행하면 서버가 답한 시각과 점검 결과를 표로 받을 수 있게 했습니다. 채팅 명령이 서버까지 이어지는 만큼, 어떤 입력을 받을지와 서버에서 무엇을 실행할지를 각각 정했습니다.&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>한글을 쳤는데 Enter가 동작하지 않았다</title><link>https://team-edo.github.io/posts/2026/11/compose-web-korean-ime-enter/</link><pubDate>Mon, 09 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/compose-web-korean-ime-enter/</guid><description>&lt;p>정간보 편집기에서 칸을 고르고 &lt;code>청태&lt;/code>를 입력한 뒤 Enter를 눌렀습니다. 입력칸에 글자는 보였지만 악보에 음이 적히지 않았습니다. 미리보기까지 사라져 있었으니, 처음 쓰는 사람이라면 입력이 아직 진행 중인지, 이 음을 쓸 수 없는지, Enter가 동작하지 않은 건지 구별하기 어렵습니다.&lt;/p>
&lt;h2 id="글자가-보이는-시점과-조합이-끝나는-시점">글자가 보이는 시점과 조합이 끝나는 시점&lt;/h2>
&lt;p>한글 입력기는 자모를 모아 글자를 만들고, 조합 중인 범위도 입력 상태에 남깁니다. Compose의 &lt;code>TextFieldValue&lt;/code>에는 현재 문자열과 조합 범위가 함께 들어 있습니다. 브라우저에서는 글자 조합 이벤트와 키 이벤트를 따로 다룹니다. Enter를 눌러도 그 키 이벤트가 앱의 완료 콜백으로 이어지는지는 입력기와 UI가 이벤트를 처리하는 방식에 달려 있습니다. &lt;a href="https://www.w3.org/TR/uievents/#keys-during-composition">W3C UI Events의 조합 중 키 이벤트&lt;/a>&lt;/p></description></item><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>10초 AAC를 4.62초만 읽고 성공한 FFmpeg</title><link>https://team-edo.github.io/posts/2026/11/aac-packet-truncation/</link><pubDate>Sun, 08 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/aac-packet-truncation/</guid><description>&lt;p>얼쑤의 연주 분석 서버는 녹음을 받은 뒤 FFmpeg로 소리를 풀어 음높이와 박자를 계산합니다. 이 변환을 구현하면서 10초짜리 AAC 파일을 중간에서 잘라 봤습니다. FFmpeg는 성공을 뜻하는 종료 코드 &lt;code>0&lt;/code>을 반환했고, FFprobe는 파일 길이가 여전히 10초라고 보고했습니다. 디코딩한 샘플을 세어 보니 실제로 읽힌 소리는 &lt;strong>4.62초&lt;/strong>였습니다.&lt;/p>
&lt;h2 id="잘린-위치에-따라-결과가-달랐습니다">잘린 위치에 따라 다른 결과&lt;/h2>
&lt;p>파일의 바이트를 임의 위치에서 자르면 AAC 패킷 중간을 끊을 수 있습니다. 디코더가 불완전한 패킷을 만나면 오류를 냅니다. 오류가 난 구간을 건너뛴 채 분석을 이어가면, 중간 소리가 빠져 뒤의 음이 앞당겨질 수 있습니다. 그래서 디코딩 오류가 발생하면 바로 멈추도록 &lt;code>-xerror&lt;/code>를 사용했습니다. &lt;a href="https://github.com/FFmpeg/FFmpeg/blob/n8.0/doc/ffmpeg.texi">FFmpeg 옵션 문서&lt;/a>&lt;/p></description></item><item><title>120초 녹음 분석에 약 3GiB가 든 이유</title><link>https://team-edo.github.io/posts/2026/11/dtw-recording-memory/</link><pubDate>Sun, 08 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/dtw-recording-memory/</guid><description>&lt;p>얼쑤는 연주자가 악보보다 조금 빠르거나 느리게 불어도 같은 음끼리 비교할 수 있도록 DTW로 두 시간축을 맞춥니다. 녹음 길이 제한을 검토하면서, 계산이 끝나는 데 걸리는 시간과 함께 한 요청이 차지하는 메모리도 확인했습니다. 운영과 같은 조건에서 120초 합성 녹음을 처리하는 데 &lt;strong>17.25초&lt;/strong>가 걸렸고 프로세스 최대 메모리는 &lt;strong>3,065MiB&lt;/strong>였습니다.&lt;/p>
&lt;h2 id="같은-서버-조건에서-길이를-바꿔-봤습니다">같은 서버에서 녹음 길이 비교&lt;/h2>
&lt;p>운영과 같은 분석 이미지와 6GiB 메모리 제한으로 일회용 컨테이너를 실행했습니다. 네트워크를 끄고 합성 음계를 이어 붙여 만든 녹음을 길이별로 한 번씩 처리했습니다. 각 실행에서 짧은 입력으로 pYIN을 먼저 호출해 준비 시간을 따로 기록했습니다. 아래 시간은 준비 이후의 음높이 추정과 채점을 합한 값입니다.&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>1.5.2·1.5.5·1.5.6 코드 탐색 결과와 검증 넘기기</title><link>https://team-edo.github.io/posts/2026/11/coco-152-155-plan-build-handoff/</link><pubDate>Fri, 06 Nov 2026 16:22:01 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/coco-152-155-plan-build-handoff/</guid><description>&lt;p>새 API를 계획하면서 라우트와 서비스의 위치를 확인해도, 구현 세션은 그 근거를 받지 못하면 같은 코드를 다시 찾게 됩니다. 반대로 검색 결과를 전부 넘기면 구현자가 다시 선별해야 합니다. 어느 범위에 메모가 도움이 되는지 알아보기 위해 새 기능과 기존 동작 변경, 고정 패치 검토를 나눠 비교했습니다. 1.5.2는 적용 범위를 좁혔고, 1.5.5는 넘겨받은 위치를 현재 코드와 대조했고, 1.5.6은 이전 검사가 이번 리뷰의 근거로 섞이지 않도록 했습니다.&lt;/p></description></item><item><title>1.5.4 작성자와 썸네일 고르기</title><link>https://team-edo.github.io/posts/2026/11/coco-154-blog-thumbnail-collaboration/</link><pubDate>Fri, 06 Nov 2026 15:38:35 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/coco-154-blog-thumbnail-collaboration/</guid><description>&lt;p>얼쑤 블로그 첫 글의 썸네일에는 반려견 코코가 등장합니다. 코코나 팀의 백호 캐릭터 절쑤를 다른 글에서도 쓰려면 프로젝트가 정한 외형과 작성자가 이번 글에 맞춰 고를 장면을 구분해야 했습니다. 예를 들어 코코의 곱슬털과 목걸이는 유지하되, 표정과 소품은 글에 따라 달라질 수 있습니다. 1.5.4에서는 캐릭터 기준은 블로그에서 읽고, 구도 선택과 실제 화면 확인은 작성자와 함께 진행하도록 했습니다. &lt;a href="https://team-edo.github.io/posts/2026/10/hello/">얼쑤 기술 블로그 첫 글&lt;/a>&lt;/p>
&lt;h2 id="캐릭터는-블로그가-정하고-절차는-coco에-둔다">캐릭터 기준과 제작 절차 분리&lt;/h2>
&lt;p>얼쑤 블로그의 가이드에는 캐릭터 이름, 승인된 참고 이미지, 유지할 외형이 들어갑니다. COCO 1.5.4의 &lt;code>coco-blog&lt;/code>는 작업하는 프로젝트에서 그 가이드를 찾고, 원고 방향이 정해지면 작성자에게 캐릭터 사용 여부부터 묻습니다. 한 캐릭터를 골라도 되고, 둘이 어울리는 장면을 구상할 수도 있습니다. 글의 개념 자체를 보여주는 그림이 적합하면 캐릭터 없이도 진행합니다. &lt;a href="https://github.com/thisis-joe/skills/blob/v1.5.4/docs/coco-1.5.4.md">COCO 1.5.4 변경 기록&lt;/a>&lt;/p></description></item><item><title>1.5.1·1.5.3 개인 메모에서 글감 찾기</title><link>https://team-edo.github.io/posts/2026/11/coco-151-153-til-to-blog/</link><pubDate>Fri, 06 Nov 2026 15:15:45 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/coco-151-153-til-to-blog/</guid><description>&lt;p>글감 추천을 검토하며 여러 사람의 메모와 제목이 같은 메모를 섞어 보았습니다. 후보를 고르기 전 원문을 전부 읽지 않도록 한 규칙이, 후보를 구별할 요약까지 막는 경우가 있었습니다. 문제 해결 사례에서는 또 다른 빈틈이 드러났습니다. 검토용 사례에서 조치 뒤 관찰을 빼면 해결을 확인할 근거가 사라졌습니다. 1.5.1에서는 글감을 고를 만큼 읽게 했고, 1.5.3에서는 사례로 저장할 때 해결을 확인한 증거를 요구했습니다.&lt;/p>
&lt;h2 id="151에서는-목록과-추천을-분리했다">1.5.1 목록과 추천 분리&lt;/h2>
&lt;p>&lt;code>coco-blog&lt;/code>는 로컬 TIL에서 대상이 되는 노트를 모두 찾아 번호, 제목, 짧은 요약을 보여 줍니다. 제목이 같아도 파일이 다르면 별도 항목으로 두고, 읽을 수 없거나 기여 관계가 확인되지 않은 항목은 그 상태를 표시합니다. 작성자가 고르기 전에는 메타데이터와 필요한 일부만 읽고, 선택이 끝난 뒤 해당 노트의 전문을 읽습니다. 이미 고른 노트가 있다면 그 선택에서 이어갑니다. &lt;a href="https://github.com/thisis-joe/skills/blob/v1.5.1/docs/coco-1.5.1.md">COCO 1.5.1 변경 기록&lt;/a>&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>백엔드는 자동, 웹은 검증한 SHA로 배포</title><link>https://team-edo.github.io/posts/2026/11/split-manual-web-deployment/</link><pubDate>Fri, 06 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/split-manual-web-deployment/</guid><description>&lt;p>얼쑤의 Jenkins는 개발 브랜치 변경을 감지하면 백엔드를 검사하고 배포했습니다. 뒤이어 웹 잡도 자동으로 예약되는 구조여서, 백엔드 변경을 운영에 올릴 때도 별도의 웹 빌드가 시작됐습니다. 웹 배포 시점을 따로 정할 수 있게 트리거를 나누되, 운영 웹이 어떤 소스에서 나왔는지는 계속 확인할 수 있어야 했습니다.&lt;/p>
&lt;h2 id="수동으로-실행해도-소스를-지정하게-했다">수동 배포에서 소스 SHA 지정&lt;/h2>
&lt;p>백엔드의 자동 배포는 두고, 웹은 트리거가 없는 전용 잡으로 옮겼습니다. 운영자는 성공한 백엔드 빌드에서 검증한 전체 커밋 SHA를 웹 잡에 입력합니다. 웹 잡은 체크아웃 결과가 입력과 같고 해당 SHA가 개발 브랜치 이력에 포함되는지 확인한 뒤 빌드를 시작합니다. 기존 웹 잡은 비활성화해 이력 조회용으로 남겼습니다.&lt;/p></description></item><item><title>테스트한 JAR을 이미지에 그대로 넣기</title><link>https://team-edo.github.io/posts/2026/11/reuse-verified-jar-in-image/</link><pubDate>Fri, 06 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/reuse-verified-jar-in-image/</guid><description>&lt;p>얼쑤의 백엔드 CI는 Jenkins에서 Gradle로 검사하고 실행 JAR을 만든 뒤, Docker로 배포 후보 이미지를 빌드했습니다. 오래 걸린 빌드의 로그를 보니 Dockerfile이 소스와 Gradle Wrapper를 받아 &lt;code>bootJar&lt;/code>를 한 번 더 실행하고 있었습니다. Docker 레이어 캐시가 적중하지 않은 한 실행에서 이미지 안의 재빌드만 1분 51초가 걸렸습니다.&lt;/p>
&lt;h2 id="실행할-jar을-먼저-확정했다">실행할 JAR 먼저 확정&lt;/h2>
&lt;p>배포 후보 이미지의 역할을 확인해 보니, 배포 도구는 후보를 띄워 확인한 뒤 그 안에서 JAR을 꺼내 운영 실행 이미지에 연결하고 있었습니다. 후보 안에서 JAR을 다시 만들어야 할 이유가 없어, Jenkins가 이미 검증한 실행 JAR을 이미지에 넣도록 바꿨습니다.&lt;/p></description></item><item><title>틀린 음을 불었는데, AI는 왜 맞다고 했을까?</title><link>https://team-edo.github.io/posts/2026/11/octave-error-in-music-scoring/</link><pubDate>Thu, 05 Nov 2026 00:00:00 +0000</pubDate><guid>https://team-edo.github.io/posts/2026/11/octave-error-in-music-scoring/</guid><description>&lt;p>단소를 연습할 때 누군가 옆에서 악보를 봐준다면 어떨까요? 어느 음을 잘못 불었는지, 박자는 어디서 빨라졌는지 알려주는 사람 말입니다.&lt;/p>
&lt;p>얼쑤에서 만들던 채점 기능도 비슷한 역할을 합니다. 단소 연주를 녹음하면 악보와 비교해서 음정과 박자를 알려줍니다.&lt;/p>
&lt;p>이 기능을 시험하면서 일부러 틀린 음을 넣었습니다. 악보에 적힌 음보다 한 옥타브 높은 음이었습니다.&lt;/p>
&lt;p>그런데 채점 결과는 정답이었습니다.&lt;/p>
&lt;p>더 이상한 점도 있었습니다. 같은 높이의 음을 따로 들려주면 제대로 찾았는데, 다른 음 사이에 짧게 넣으면 놓친 겁니다.&lt;/p></description></item><item><title>Jenkins Wasm 최적화 956초에서 237초로</title><link>https://team-edo.github.io/posts/2026/11/jenkins-wasm-build-bottleneck/</link><pubDate>Wed, 04 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/jenkins-wasm-build-bottleneck/</guid><description>&lt;p>얼쑤의 Jenkins 빌드가 길어졌을 때, 저는 완료 화면의 전체 시간부터 단계별로 쪼개 봤습니다. 백엔드 빌드에서는 시작 기록이 남은 뒤 첫 단계까지 14분 넘게 걸렸고, 별도의 웹 빌드에서는 실제로 실행기에 올라간 뒤 Wasm 최적화 도중 시간 제한에 걸렸습니다. 두 경우 모두 빌드가 오래 걸렸지만, 손봐야 할 곳은 달랐습니다.&lt;/p>
&lt;h2 id="첫-단계가-시작되기까지-얼마나-걸렸나">첫 단계까지의 대기 시간&lt;/h2>
&lt;p>빌드 기록의 시작·종료 시각을 Jenkins의 단계별 시작·종료 시각과 맞춰 봤습니다. 빌드 시작부터 첫 단계까지, 첫 단계부터 마지막 단계가 끝날 때까지, 마지막 단계 이후로 나누면 어느 구간이 길었는지 보입니다.&lt;/p></description></item><item><title>파일 교체 뒤에도 남은 컨테이너 설정</title><link>https://team-edo.github.io/posts/2026/11/reload-file-bind-mount-in-place/</link><pubDate>Wed, 04 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/reload-file-bind-mount-in-place/</guid><description>&lt;p>운영 Nginx 설정에 압축 옵션을 반영할 때, 설정 파일을 읽는 쪽이 어디인지부터 확인했습니다. Nginx 컨테이너는 호스트의 설정 디렉터리 전체 대신 설정 파일 하나를 읽기 전용으로 연결하고 있었습니다. 호스트에서 경로가 같은 파일을 새로 만들어 넣어도, 실행 중인 컨테이너는 이전 파일을 계속 볼 수 있는 구성입니다.&lt;/p>
&lt;h2 id="이름을-유지하는-것과-파일을-유지하는-것">같은 이름과 같은 파일의 차이&lt;/h2>
&lt;p>Linux에서 경로는 파일을 찾는 이름입니다. 파일 하나를 bind mount하면 마운트는 그때 찾은 파일의 실체에 연결됩니다. 임시 파일을 만든 뒤 &lt;code>rename&lt;/code>으로 기존 이름을 교체하면 호스트에서 같은 경로를 열 때 새 파일을 읽습니다. 컨테이너가 이미 연결한 파일은 그대로 남습니다. 반대로 기존 파일을 열어 내용을 덮어쓰면 inode를 유지하므로 컨테이너에서도 같은 파일의 바뀐 내용을 읽습니다.&lt;/p></description></item><item><title>1.5.0 스킬 목차 검사</title><link>https://team-edo.github.io/posts/2026/11/coco-150-skill-contents/</link><pubDate>Tue, 03 Nov 2026 22:28:09 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/coco-150-skill-contents/</guid><description>&lt;p>1.4.2의 스킬 문서를 보면 &lt;code>coco-research&lt;/code>는 한 문단으로 끝나고, &lt;code>coco-build&lt;/code>에는 구현과 검증에 필요한 여러 절이 이어졌습니다. 파일을 처음 여는 사람과 에이전트가 읽을 부분을 찾기 쉽도록 1.5.0에서 스킬 30개에 공통 목차를 넣었습니다. 다음으로 풀어야 할 문제는 절 제목이 바뀌어도 목차가 실제 본문을 가리키게 하는 일이었습니다. &lt;a href="https://github.com/thisis-joe/skills/blob/v1.5.0/docs/coco-1.5.0.md">COCO 1.5.0 변경 기록&lt;/a>&lt;/p>
&lt;h2 id="목차와-본문이-함께-바뀌게-했다">목차와 본문 맞추기&lt;/h2>
&lt;p>목차는 본문의 모든 &lt;code>##&lt;/code> 제목을 등장 순서대로 연결합니다. 문서에 아래와 같은 두 절이 있다면 목차에도 링크가 두 개 들어갑니다.&lt;/p></description></item><item><title>1.4.2 종료 코드는 0, 리뷰어는 빈칸</title><link>https://team-edo.github.io/posts/2026/11/coco-1-4-2-pr-metadata-readback/</link><pubDate>Tue, 03 Nov 2026 14:46:20 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/coco-1-4-2-pr-metadata-readback/</guid><description>&lt;p>새 PR에 리뷰어를 붙이는 규칙을 확인하던 중, &lt;code>gh pr edit --add-reviewer&lt;/code>로 PR 작성자 자신에게 리뷰를 요청했을 때 종료 코드가 &lt;code>0&lt;/code>으로 나왔습니다. 같은 요청을 GitHub API로 보내자 작성자에게 리뷰를 요청할 수 없다는 &lt;code>422&lt;/code> 응답이 돌아왔고, PR을 다시 조회하니 리뷰어는 비어 있었습니다. 이 차이 때문에 1.4.2에서는 설정 명령이 끝난 뒤 담당자·리뷰어·라벨을 다시 읽어 반영 결과를 판단하게 했습니다. &lt;a href="https://github.com/thisis-joe/skills/commit/7feeb2bafff91985702a574792ff10be082b2287">수정 커밋&lt;/a>&lt;/p>
&lt;h2 id="pr-생성과-메타데이터-쓰기를-분리한다">PR 생성과 메타데이터 설정 분리&lt;/h2>
&lt;p>&lt;code>coco-push&lt;/code>, &lt;code>coco-merge&lt;/code>, &lt;code>coco-ship&lt;/code>이 새 PR이나 MR을 생성하면 담당자와 리뷰어의 기본 후보는 해당 요청을 연 계정입니다. 로컬 Git 커밋 작성자 대신 GitHub나 GitLab에 로그인한 계정을 조회합니다. 사용자나 저장소 지침이 다른 사람을 지정했다면 그 지시를 따릅니다. 라벨은 저장소에 이미 있는 이름과 설명을 읽어 변경에 맞는 것만 추가합니다. 이미 열려 있던 요청은 생성 시점의 자동 설정 대상에서 제외합니다.&lt;/p></description></item><item><title>1.4.1 썸네일은 실제 카드에서</title><link>https://team-edo.github.io/posts/2026/11/coco-1-4-1-blog-cover-preview/</link><pubDate>Tue, 03 Nov 2026 12:08:31 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/coco-1-4-1-blog-cover-preview/</guid><description>&lt;p>Team Edo 블로그의 같은 썸네일은 추천 영역에서는 가로로 넓게, 모바일 목록에서는 세로로 좁게 보입니다. 핵심 소품이 이미지 가장자리에 있으면 원본에서는 잘 보여도 모바일 카드에서 잘릴 수 있습니다. 1.4.1에서는 그림을 내보내기 전에 실제 블로그 레이아웃에서 후보를 비교하는 절차를 &lt;code>coco-blog&lt;/code>에 넣었습니다.&lt;/p>
&lt;h2 id="시안은-글의-방향이-정해진-뒤-제안한다">글 방향을 정한 뒤 시안 제안&lt;/h2>
&lt;p>&lt;code>coco-blog&lt;/code>는 먼저 블로그가 썸네일을 어떤 필드에서 읽는지, 어떤 크기와 형식을 받는지, 이미지가 어디에 올라가는지 확인합니다. 글의 방향이 정해지면 본문에서 독자에게 남길 장면이나 개념을 골라 벡터 시안 두세 개를 대화에 보여 줍니다. 작성자는 후보를 고르거나 구도 수정을 요청할 수 있고, 직접 만든 이미지를 제시할 수도 있습니다. Team Edo 블로그에서 쓸 이미지라면 글 목록과 추천 영역에서 같은 그림을 어떻게 보여 주는지도 살핍니다.&lt;/p></description></item><item><title>206 응답 뒤에 숨은 CORS 오류</title><link>https://team-edo.github.io/posts/2026/11/cors-after-audio-redirect/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/cors-after-audio-redirect/</guid><description>&lt;p>얼쑤의 모범 연주 음원 API를 Swagger에서 확인하다가 &lt;code>Failed to fetch&lt;/code>를 만났습니다. 같은 음원의 처음 2바이트를 &lt;code>curl&lt;/code>로 요청하니 저장소는 HTTP 206을 반환했습니다. 저장소가 바이트를 보내고도 브라우저에서 읽을 수 없는 이유를 찾으려면, 처음 요청한 API와 음원을 실제로 돌려주는 서버를 나눠 봐야 했습니다.&lt;/p>
&lt;h2 id="302-뒤의-응답을-확인했다">302 이후의 응답 추적&lt;/h2>
&lt;p>Swagger 페이지와 음원 API는 같은 출처에 있었습니다. API는 곡과 음원의 게시 상태를 확인한 뒤 HTTP 302의 &lt;code>Location&lt;/code> 헤더에 R2 서명 URL을 담았습니다. 브라우저가 리다이렉트를 따라가면 음원 본문은 다른 출처인 R2에서 내려옵니다. 그 응답에는 Swagger 페이지의 출처를 허용하는 &lt;code>Access-Control-Allow-Origin&lt;/code>이 없었습니다. 브라우저는 저장소 응답을 받았지만 페이지의 자바스크립트에 넘기지 않았습니다. &lt;a href="https://fetch.spec.whatwg.org/#cors-check">Fetch 표준의 CORS 검사&lt;/a>&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>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>Play 배포판의 App Links가 빠진 이유</title><link>https://team-edo.github.io/posts/2026/11/android-app-links-signing/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/android-app-links-signing/</guid><description>&lt;p>얼쑤에서 Android 소셜 로그인은 외부 브라우저를 열고, 인증 뒤 App Links로 앱에 돌아오는 흐름을 사용했습니다. Play에서 설치한 일반 앱이 브라우저에서 돌아오지 못해 운영 서버의 &lt;code>assetlinks.json&lt;/code>을 읽었습니다. 서버는 기동 상태가 정상이었고, 연결 문서에는 QA 패키지 한 항목만 있었습니다. 우선 Play 설치판에 해당하는 연결 항목을 복구했습니다.&lt;/p>
&lt;h2 id="설치한-앱에-찍힌-서명이-필요했다">Play 앱 서명 지문 확인&lt;/h2>
&lt;p>App Links의 연결 문서에는 패키지 이름과 서명 인증서 지문이 함께 들어갑니다. 직접 빌드해 설치한 앱과 Play가 배포한 앱의 서명이 다르면, 한 쪽을 확인한 지문으로 다른 쪽까지 연결됐다고 볼 수 없습니다. Play에서 받은 설치본에 해당하는 지문은 Play Console의 앱 서명 정보에 나옵니다.&lt;/p></description></item><item><title>곡 목록은 200, 콘솔 오류는 어디서 왔나</title><link>https://team-edo.github.io/posts/2026/11/console-error-origin-before-api/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/console-error-origin-before-api/</guid><description>&lt;p>얼쑤의 곡 목록 API를 점검할 때 Chrome 콘솔에 &lt;code>Cannot read properties of undefined (reading 'sentence')&lt;/code> 오류가 &lt;strong>아홉 건&lt;/strong> 찍혔습니다. 곡 목록을 요청하는 동안 보인 오류라 처음에는 응답을 처리하는 코드부터 봐야 할지 판단하기 어려웠습니다. 같은 요청의 응답은 &lt;strong>HTTP 200&lt;/strong>이었고 본문에는 &lt;code>isSuccess: true&lt;/code>가 들어 있었습니다.&lt;/p>
&lt;h2 id="콘솔에서는-스택의-파일-출처를-봤습니다">스택에서 파일 출처 확인&lt;/h2>
&lt;p>오류 아홉 건의 스택에는 브라우저 확장의 &lt;code>contentScript.js&lt;/code>가 있었습니다. 파일명만으로는 앱의 스크립트인지 알기 어려웠고, 출처가 확장 프로그램의 주소라는 점이 단서였습니다. 같은 기록에는 Swagger UI의 번들에서 발생한 응답 표시 오류도 따로 있었습니다. 두 종류의 메시지는 비슷한 시점에 떴지만 서로 다른 스크립트에서 실행된 코드였습니다. &lt;a href="https://developer.chrome.com/docs/devtools/console/reference">Chrome DevTools 콘솔 문서&lt;/a>&lt;/p></description></item><item><title>문서 주소 없이 FastAPI 계약 검증</title><link>https://team-edo.github.io/posts/2026/11/generate-private-openapi-contract/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/generate-private-openapi-contract/</guid><description>&lt;p>백엔드가 호출하는 분석 API의 요청과 응답을 한곳에서 읽을 수 있게 만들고 싶었습니다. 분석 서버는 내부에서만 호출하는 구성이라 운영에 문서 조회 주소를 열 필요는 없었습니다. 또 이미 사용 중인 응답에는 상태마다 다른 필드와 JSON 이름이 있어서, 문서를 붙이는 작업이 기존 직렬화까지 바꾸면 호출자에 영향이 갈 수 있었습니다.&lt;/p>
&lt;h2 id="스키마-생성과-문서-조회-주소를-분리했다">스키마 생성과 공개 경로 분리&lt;/h2>
&lt;p>FastAPI의 &lt;code>docs_url&lt;/code>, &lt;code>redoc_url&lt;/code>, &lt;code>openapi_url&lt;/code> 설정은 꺼 둔 채, 앱 객체의 &lt;code>app.openapi()&lt;/code>에서 문서를 생성하도록 했습니다. 개발 환경에서 JSON을 파일로 저장해 문서를 읽을 수 있습니다. HTTP 테스트에는 문서 관련 경로 세 곳이 모두 404를 반환하는지도 추가했습니다. &lt;a href="https://fastapi.tiangolo.com/tutorial/metadata/#openapi-url">FastAPI의 문서 URL 설정&lt;/a>&lt;/p></description></item><item><title>백업 도구를 바꿔도 남은 오류</title><link>https://team-edo.github.io/posts/2026/11/diagnose-backup-status-by-host-role/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/diagnose-backup-status-by-host-role/</guid><description>&lt;p>DB를 내보내는 서버와 백업을 받는 서버가 분리된 환경에서 백업 도구를 교체했습니다. 교체 직후 보내는 서버에서 새 버전의 &lt;code>status&lt;/code>를 실행했더니 &lt;code>no completed backup: db&lt;/code>가 나왔습니다. 설치 직후라 도구의 변경을 먼저 의심할 만했지만, 이 메시지가 어느 서버의 어떤 저장소를 조회한 결과인지 확인해야 했습니다.&lt;/p>
&lt;h2 id="같은-호스트에서-버전만-바꿔-봤다">같은 호스트에서 버전만 비교&lt;/h2>
&lt;p>교체 전에 보관한 이전 버전으로 같은 서버에서 같은 &lt;code>status&lt;/code> 명령을 실행했습니다. 이전 버전도 동일하게 실패했습니다. 두 버전이 같은 위치에서 같은 결과를 냈으므로, 우선 두 버전이 공통으로 읽는 경로를 확인했습니다. 이 비교에서는 백업을 새로 보내거나 기존 데이터를 바꾸는 명령 대신, 현재 상태만 읽는 명령을 사용했습니다.&lt;/p></description></item><item><title>업로드 재시도에서 기존 파일 지키기</title><link>https://team-edo.github.io/posts/2026/11/retry-safe-object-upload-conditional-put/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/retry-safe-object-upload-conditional-put/</guid><description>&lt;p>팀에서 음원 파일이나 폴더를 R2에 올리는 공용 명령을 만들었습니다. 폴더를 올리다 중간에 연결이 끊기면 같은 명령을 다시 실행하고 싶습니다. 그런데 같은 키에 일반 PUT을 보내면, 이미 올라간 파일이 지금 로컬에 있는 내용으로 바뀔 수 있습니다. 기본 실행은 빈 키만 채우고, 기존 파일을 바꿀 때는 교체 옵션을 명시하도록 했습니다.&lt;/p>
&lt;h2 id="쓰기-요청에-빈-키라는-조건을-넣었다">비어 있는 키에만 쓰기&lt;/h2>
&lt;p>먼저 HEAD로 키가 비어 있는지 확인하고 뒤이어 PUT을 보내면, 두 요청 사이에 다른 작업자가 같은 키에 파일을 올릴 수 있습니다. 저는 기본 PUT에 &lt;code>If-None-Match: *&lt;/code>를 넣었습니다. R2는 이 조건을 객체 쓰기와 함께 판단합니다. 키가 이미 있으면 &lt;code>412 PreconditionFailed&lt;/code>가 돌아오고, 도구는 그 파일을 건너뛰었다고 표시한 뒤 다음 파일로 넘어갑니다. &lt;code>--overwrite&lt;/code>를 준 경우에는 이 조건을 붙이지 않습니다. &lt;a href="https://developers.cloudflare.com/r2/api/s3/extensions/#conditional-operations-in-putobject">Cloudflare R2의 조건부 PUT 문서&lt;/a>&lt;/p></description></item><item><title>테스트 결과에서 재현 절차 찾기</title><link>https://team-edo.github.io/posts/2026/11/trace-qa-results-to-acceptance-criteria/</link><pubDate>Tue, 03 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/trace-qa-results-to-acceptance-criteria/</guid><description>&lt;p>팀의 테스트 문서는 한 시나리오 안에 완료 조건, 따라 할 단계, 수행 결과가 있었습니다. 기존 결과를 보면 일부 결함이 있다는 메모를 남겨도 어느 조건을 다시 실행해야 하는지 단번에 찾기 어려웠습니다. 반대로 단계에 있는 확인 절차를 조건과 대조하면, 그 동작의 기대 결과가 문서 어디에도 없는 경우가 있었습니다.&lt;/p>
&lt;h2 id="번호-하나가-판정과-재현을-함께-가리키게-했다">번호로 판정과 재현 연결&lt;/h2>
&lt;p>완료 조건 번호와 가이드 단계 번호를 맞췄습니다. 예를 들어 조건 ④가 곡을 지정하지 않을 때 가장 최근에 연주한 곡이 선택된다는 내용이면, 단계 ④에는 곡 ID 없이 요청하고 응답과 DB에서 비교할 위치를 적습니다. 조건은 기대 결과를, 단계는 그 결과를 볼 방법을 담습니다. 로그인과 같은 공통 준비는 시작 상태에 적고, 결과나 결함은 같은 번호 줄에 남기도록 했습니다.&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>Git 브랜치 갱신 전 확인할 것</title><link>https://team-edo.github.io/posts/2026/11/guard-git-ref-updates/</link><pubDate>Mon, 02 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/guard-git-ref-updates/</guid><description>&lt;p>병합 뒤 로컬 기준 브랜치를 정해진 커밋으로 옮겨야 했습니다. 다른 작업을 열어 둔 상태라 기준 브랜치로 체크아웃하지 않고 처리했습니다. 이때 필요한 확인은 브랜치가 아직 제가 확인한 커밋에 있는지, 그리고 옮길 커밋이 현재 이력을 그대로 포함하는지였습니다.&lt;/p>
&lt;h2 id="움직여도-되는-참조인지-확인했다">현재 참조와 조상 관계 확인&lt;/h2>
&lt;p>먼저 &lt;code>git merge-base --is-ancestor &amp;quot;$old&amp;quot; &amp;quot;$new&amp;quot;&lt;/code>로 이전 커밋이 새 커밋의 조상인지 확인했습니다. 종료 코드가 0이어야 빨리 감기입니다. 다음으로 &lt;code>git worktree list --porcelain&lt;/code>에서 옮길 브랜치를 사용하는 체크아웃이 없는지 확인했습니다. 체크아웃한 브랜치의 참조를 직접 바꾸면 그 폴더의 인덱스와 파일이 새 커밋에 맞춰 갱신되지 않기 때문입니다.&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>약관 배포와 시행 사이의 시차</title><link>https://team-edo.github.io/posts/2026/11/separate-terms-publication-from-effective-time/</link><pubDate>Mon, 02 Nov 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/11/separate-terms-publication-from-effective-time/</guid><description>&lt;p>새 약관을 배포한 날 저녁, 웹의 &lt;code>current&lt;/code> 주소는 새 문서를 보여줬습니다. 그런데 앱이 조회하는 약관 목록에 새 판이 들어간 건 자정 뒤였습니다. 문서 버전에 시행일을 적어 두었지만, 웹과 API가 버전을 고르는 방식은 서로 달랐습니다.&lt;/p>
&lt;h2 id="배포와-시행이-갈라진-시간">자정 뒤 전환된 약관 목록&lt;/h2>
&lt;p>11월 2일 19:27 KST에 새 판이 들어 있는 변경을 병합했고, 19:36 배포 확인에서 &lt;code>current&lt;/code>가 시행일 11월 3일인 문서를 보여주는 것을 확인했습니다. 이미 11월 2일 판이 있어서 같은 날짜를 새 문서에 다시 쓸 수는 없었습니다. 병합부터 자정까지 약 4시간 반 동안 문서의 날짜와 현재라고 표시되는 시점이 어긋났습니다.&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>1.4.0 코드 질문과 조사를 한곳에서</title><link>https://team-edo.github.io/posts/2026/10/coco-1-4-0-askme-research/</link><pubDate>Fri, 30 Oct 2026 09:59:20 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/coco-1-4-0-askme-research/</guid><description>&lt;p>기술 조사는 &lt;code>coco-research&lt;/code>가, 복습 퀴즈는 &lt;code>coco-askme&lt;/code>가 맡고 있었습니다. 예를 들어 방금 바꾼 재시도 코드의 이유를 묻고 퀴즈로 이해를 확인하려면, 같은 맥락을 두 스킬에서 이어가야 했습니다. 1.4.0에서는 한 질문에서 답변·추가 조사·퀴즈로 이어지도록 진입점을 모았습니다. 요약으로 대화를 이어받을 때는 확인할 수 있는 근거의 범위도 밝히게 했습니다.&lt;/p>
&lt;h2 id="무엇을-묻는지에-따라-시작을-바꾼다">질문에 맞춰 시작하기&lt;/h2>
&lt;p>질문을 적어 호출하면 관련 근거로 바로 답합니다. 예를 들어 “재시도 횟수를 다 썼을 때 원래 오류는 어디로 전달돼?”라고 물으면 코드 흐름을 따라 답할 수 있습니다. 인자 없이 부르면 현재 대화에서 더 알아볼 만한 주제를 최대 세 개 제시하고, 목록에 없는 질문도 받을 수 있습니다. 퀴즈를 요청하면 근거가 있는 문제를 하나만 내고 답을 기다립니다. 같은 코드가 출발점이어도 질문, 주제 탐색, 복습은 서로 다른 시작을 갖습니다. &lt;a href="https://github.com/thisis-joe/skills/blob/aaeb1dc/docs/coco-1.4.0.md">COCO 1.4.0 릴리스 노트&lt;/a>&lt;/p></description></item><item><title>1.3.0 작업 폴더를 지워도 남는 TIL</title><link>https://team-edo.github.io/posts/2026/10/coco-1-3-0-til-primary-checkout/</link><pubDate>Fri, 30 Oct 2026 09:12:48 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/coco-1-3-0-til-primary-checkout/</guid><description>&lt;p>기존 &lt;code>coco-learn&lt;/code>은 실행한 worktree 아래에 노트를 저장했습니다. 작업이 끝난 worktree를 정리하면 Git이 추적하지 않는 개인 TIL도 함께 사라질 수 있는 구조였습니다. 1.3.0에서는 작업 폴더의 수명과 학습 기록의 수명을 분리했습니다. 어느 worktree에서 &lt;code>coco-til&lt;/code>을 실행하든 기본 체크아웃에서 기록을 다시 찾을 수 있게 했습니다.&lt;/p>
&lt;h2 id="작업-트리의-상태와-폴더에-남은-파일을-각각-본다">워크트리에 있던 가상 환경이 사라졌습니다&lt;/h2>
&lt;p>병합을 끝낸 워크트리를 &lt;code>git worktree remove&lt;/code>로 제거한 뒤, 그 아래에 만든 Python 가상 환경 &lt;code>.venv&lt;/code>가 함께 사라진 것을 확인했습니다. 브랜치 참조는 별도로 남았지만, Git이 무시하던 파일의 내용은 커밋에 없었습니다. 기본 &lt;code>git status&lt;/code>가 깨끗해도 삭제할 폴더에 이런 파일은 남아 있을 수 있습니다. 제거 전에는 &lt;code>git status --short --ignored&lt;/code>로 확인합니다. 가상 환경처럼 잠금 파일로 다시 만들 수 있는 산출물과 사본이 없는 작업 기록을 여기서 구분할 수 있습니다.&lt;/p></description></item><item><title>1.2.0 개발 노트를 공개 초안으로</title><link>https://team-edo.github.io/posts/2026/10/coco-1-2-0-blog-drafts/</link><pubDate>Thu, 29 Oct 2026 18:37:04 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/coco-1-2-0-blog-drafts/</guid><description>&lt;p>개발 중 남긴 개인 TIL에는 실패한 테스트와 선택의 이유가 흩어져 있습니다. 여기서 공개할 근거를 골라 원고를 쓰더라도, 글을 언제 공개할지는 블로그마다 다른 규칙을 따릅니다. 초기 &lt;code>coco-blog&lt;/code>는 공개 전환을 Hugo의 &lt;code>draft: false&lt;/code>로 고정했습니다. 1.2.0을 준비하면서 원본 노트를 글로 편집하는 과정과 프로젝트마다 달라지는 게시 절차를 분리했습니다. &lt;a href="https://github.com/thisis-joe/skills/blob/v1.2.0/docs/evaluation/coco-blog-1.2.0/report.md">1.2.0 검증 기록&lt;/a>&lt;/p>
&lt;h2 id="한-편의-글이-될-근거를-고른다">글감의 근거 고르기&lt;/h2>
&lt;p>글감은 작성자가 직접 했거나 확인한 작업에서 고릅니다. 예를 들어 한 노트에 재시도 중복을 확인한 테스트가 있고 다른 노트에 수정 후 재현 결과가 있다면, 둘을 엮어 문제를 어떻게 발견하고 검증했는지 설명할 수 있습니다. 초안에는 독자가 재현과 판단을 이해하는 데 필요한 부분을 옮기고, 원본 노트는 그대로 둡니다. 이미 작성자가 고쳐 둔 원고는 그 내용을 읽고 이어 씁니다. 원본을 다시 가져온다는 이유로 기존 문장을 덮어쓰지 않습니다.&lt;/p></description></item><item><title>Android SDK 없이 녹음 업로드 검증</title><link>https://team-edo.github.io/posts/2026/10/verify-kmp-without-android-sdk/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/verify-kmp-without-android-sdk/</guid><description>&lt;p>얼쑤의 녹음 업로드에는 두 종류의 코드가 있습니다. 연결이 끊긴 녹음을 다시 보낼지, 언제 보관 기록을 지울지는 공통 로직입니다. 녹음을 앱의 보관 디렉터리로 실제 이동하는 일은 플랫폼마다 다릅니다. 개발 환경에 Android SDK가 없었을 때도 업로드 판단을 검증할 수 있도록 두 부분을 분리했습니다.&lt;/p>
&lt;h2 id="업로드-판단은-브라우저의-공통-테스트에서-확인했다">Wasm에서 공통 업로드 로직 검사&lt;/h2>
&lt;p>공통 코드는 파일의 보관·존재 확인·삭제를 &lt;code>RecordingFiles&lt;/code> 인터페이스로 호출합니다. 전송도 인터페이스로 받습니다. Wasm에서 실행한 공통 테스트는 두 인터페이스의 가짜를 넣어, 연결 실패 뒤 기록이 남는지와 같은 세션의 재전송 결과를 확인합니다. 플랫폼의 파일 구현을 불러오지 않아도 테스트가 실행되는 이유입니다.&lt;/p></description></item><item><title>Docker 내부망으로 연결한 분석 서버</title><link>https://team-edo.github.io/posts/2026/10/analysis-service-network-boundary/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/analysis-service-network-boundary/</guid><description>&lt;p>얼쑤의 백엔드는 녹음 분석을 요청할 때 파일을 읽을 수 있는 서명 URL을 넘깁니다. 분석 서버는 그 주소에서 녹음을 내려받아 채점합니다. 이 서버를 운영에 추가할 때 외부에서 직접 호출할 수 있는 포트가 필요한지 먼저 살폈습니다. 앱은 이미 백엔드로 녹음을 올리고 결과를 조회하고 있어서, 분석 서버는 백엔드에서만 호출하면 됐습니다.&lt;/p>
&lt;h2 id="녹음-주소를-받는-api의-접근-범위">녹음 URL을 받는 API의 접근 범위&lt;/h2>
&lt;p>당시 분석 API는 호출자를 인증하거나 다운로드 목적지의 호스트를 제한하지 않았습니다. URL이 HTTP나 HTTPS로 시작하는지는 확인했지만, 사설 주소도 그 조건을 충족합니다. 외부 호출자가 이 API에 요청을 보낼 수 있다면 서버가 대신 내부 주소에 접속하게 할 수 있습니다. 응답 차이만으로도 어떤 주소에 연결이 됐는지 짐작할 여지가 생깁니다.&lt;/p></description></item><item><title>Jenkins가 새 Compose 파일을 못 읽은 이유</title><link>https://team-edo.github.io/posts/2026/10/atomic-compose-install-permissions/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/atomic-compose-install-permissions/</guid><description>&lt;p>운영 Compose 파일을 바꾼 뒤 Jenkins 배포가 설정 검사에서 멈췄습니다. 같은 파일을 root로 검사하니 통과했고, Jenkins 계정으로 실행하니 읽기 권한이 없었습니다. 파일은 &lt;code>root:root&lt;/code> 소유에 모드가 &lt;code>0600&lt;/code>이었습니다. 파일의 내용은 그대로 두고 Jenkins 그룹에 읽기 권한을 복구하자 같은 커밋의 재실행이 배포까지 성공했습니다. 이후 같은 문제가 다시 생겨 배포 네 건이 연속으로 실패했습니다.&lt;/p>
&lt;h2 id="바뀔-파일을-실제-계정으로-먼저-읽었다">Jenkins 계정으로 교체 전 검사&lt;/h2>
&lt;p>같은 파일시스템에서 새 파일을 준비한 뒤 이름을 바꾸면, 읽는 쪽에는 완성된 파일만 보입니다. 이때 새 경로의 권한은 옛 파일에서 이어지는 값이 아니라 교체해 넣는 파일이 가진 값입니다.&lt;/p></description></item><item><title>Lax 쿠키에 Origin·JSON 검사를 더한 이유</title><link>https://team-edo.github.io/posts/2026/10/layer-cookie-csrf-request-checks/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/layer-cookie-csrf-request-checks/</guid><description>&lt;p>서비스에서는 브라우저와 네이티브 앱이 로그인 쿠키를 사용했습니다. 브라우저는 조건이 맞으면 쿠키를 요청에 자동으로 실으므로, 다른 페이지에서 시작된 요청도 상태를 바꿀 수 있는지 확인해야 했습니다. 팀에서 쿠키에 &lt;code>SameSite=Lax&lt;/code>를 적용하고, 상태를 바꾸는 메서드에 출처와 본문 검사를 추가했습니다.&lt;/p>
&lt;h2 id="쿠키와-요청-필터가-각각-보는-것">쿠키와 요청 필터의 역할&lt;/h2>
&lt;p>&lt;code>Lax&lt;/code> 쿠키는 다른 사이트의 일반적인 POST에 실리지 않습니다. 다른 사이트에서 들어오는 최상위 GET 이동에는 실릴 수 있고, 호스트가 달라도 브라우저에서 같은 사이트로 분류될 수 있습니다. 상태 변경 API는 POST·PUT·PATCH·DELETE를 사용하고, 필터는 &lt;code>Origin&lt;/code>이 있는 요청에 대해 허용한 출처 또는 서비스의 호스트 규칙을 검사합니다. 네이티브 앱처럼 헤더가 없는 경우에는 본문과 인증 검사로 이어집니다. &lt;a href="https://httpwg.org/http-extensions/draft-ietf-httpbis-rfc6265bis.html">HTTP Working Group의 SameSite 사양 초안&lt;/a>&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>단소 악보 4곡의 DB 시드 검증</title><link>https://team-edo.github.io/posts/2026/10/verify-danso-score-seeds/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/verify-danso-score-seeds/</guid><description>&lt;p>얼쑤에서 단소 연습 세션을 시작하려면 DB에 채점 가능한 곡이 있어야 합니다. 처음 적용할 때는 곡 테이블이 비어 있어 선택할 대상이 없었습니다. 데이터팀이 검토한 정간보 네 곡을 공통 악보 JSON으로 변환하고, DB에 처음 넣는 데이터인 시드에 담았습니다. 곡 네 개가 조회되는 것과 그 안의 음표·쉼표·시간이 원본에서 그대로 넘어오는 것은 다른 확인이었습니다.&lt;/p>
&lt;h2 id="입력과-변환-방법을-다시-찾을-수-있게-남겼다">원본과 변환 결과 대조&lt;/h2>
&lt;p>시드 SQL 머리말에 변환기의 버전, 원본과 변환 코드의 기준 시점, 입력 파일의 SHA-256을 남겼습니다. 시간이 지나도 어떤 파일로 시드를 만들었는지 대조할 수 있어야 검사를 다시 실행할 수 있습니다. 서비스에서 곡을 게시 상태로 넣기로 한 결정도 함께 기록했습니다. 그 값은 원본 악보에서 읽어 낸 값과 구분했습니다.&lt;/p></description></item><item><title>상태 조회가 대기 녹음을 지울 수 있던 이유</title><link>https://team-edo.github.io/posts/2026/10/keep-pending-recordings-until-upload/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/keep-pending-recordings-until-upload/</guid><description>&lt;p>연주를 끝낸 직후 연결이 끊기면, 아직 서버에 도착하지 않은 녹음을 다시 보낼 수 있어야 합니다. 얼쑤에서는 녹음 파일을 기기의 보관 위치로 옮긴 다음, 어느 세션에 어떤 파일을 보낼지 기록하고 업로드하도록 만들었습니다. 앱을 다시 열어도 그 기록에서 보낼 녹음을 찾을 수 있습니다.&lt;/p>
&lt;p>리뷰에서 파일을 옮기는 일과 대기 기록을 저장하는 일 사이에 틈이 있다는 걸 발견했습니다. 새 파일은 이미 보관 위치에 있지만, 상태 조회에서 읽을 대기 기록은 아직 없습니다. 기존 조회 함수는 기록이 없으면 보관 폴더에 남은 파일을 정리했습니다. 이 순간에 조회가 끼어들면 방금 옮긴 녹음도 버려도 되는 파일로 보고 지울 수 있었습니다.&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/trace-shell-and-ssh-command-failures/</link><pubDate>Thu, 29 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/trace-shell-and-ssh-command-failures/</guid><description>&lt;p>운영 기록을 읽으려던 명령이 실패하면 먼저 원격 서버 상태를 의심하기 쉽습니다. 세 번의 실패를 다시 보니 Git에 인수를 넘기기 전, SSH에 인수를 넘기는 순간, SSH가 원격 명령을 시작하기 전이라는 서로 다른 위치에서 막혔습니다.&lt;/p>
&lt;h2 id="zsh가-만든-인수를-먼저-본다">zsh가 만든 인수 확인&lt;/h2>
&lt;p>첫 사례에서는 변수에 든 Git ref 뒤에 콜론과 경로를 바로 붙였습니다. zsh에서 뒤 문자열이 &lt;code>:a&lt;/code>로 시작하면 변수 확장의 경로 수정자로 읽힐 수 있습니다. &lt;code>git show &amp;quot;${ref}:archive/report.txt&amp;quot;&lt;/code>처럼 중괄호로 변수 이름을 닫으면 콜론은 Git이 받는 문자열에 그대로 남습니다. &lt;a href="https://zsh.sourceforge.io/Doc/Release/Expansion.html">zsh 확장 문서&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>−20센트 합성음에서 나온 −15센트 추정</title><link>https://team-edo.github.io/posts/2026/10/pyin-tuning-resolution/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/pyin-tuning-resolution/</guid><description>&lt;p>얼쑤에서 백엔드의 분석 요청을 받아 음표별 판정을 돌려주는 API를 구현했습니다. 검증할 때 악보의 모든 음보다 &lt;strong>20센트 낮게&lt;/strong> 만든 합성음을 넣었습니다. 반음이 100센트이므로, 같은 간격으로 음높이만 조금 낮춘 입력입니다.&lt;/p>
&lt;p>분석은 HTTP 200과 &lt;code>DONE&lt;/code>으로 끝났습니다. 그런데 응답의 조율 보정값 &lt;code>tuningOffsetCent&lt;/code>는 &lt;strong>−15.0센트&lt;/strong>였습니다. 입력을 만들 때 정한 값과 채점기가 실제로 적용한 값 사이에 5센트 차이가 있었습니다.&lt;/p>
&lt;h2 id="보정값이-만들어지는-과정">pYIN 추정에서 조율 보정까지&lt;/h2>
&lt;p>로컬에서 실제 분석 API를 띄우고, 저장소 역할을 하는 로컬 HTTP 서버가 합성 M4A를 보내도록 했습니다. API는 오디오를 16kHz 모노로 디코딩한 뒤 pYIN으로 짧은 구간마다 기본 주파수를 추정합니다. 당시 요청에는 기준음이 없어서 황의 기준 주파수를 311.13Hz로 두고, 악보의 각 음이 거기서 몇 반음 떨어져 있는지로 목표 음높이를 구했습니다.&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>Docker가 Mac의 여유 공간과 파일을 못 본 이유</title><link>https://team-edo.github.io/posts/2026/10/docker-macos-vm-boundary/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/docker-macos-vm-boundary/</guid><description>&lt;p>Mac에서 분석 서버 이미지를 만들 때 &lt;code>apt&lt;/code>가 패키지를 내려받을 공간이 부족하다고 했습니다. Mac에는 약 &lt;strong>20 GiB&lt;/strong>가 남아 있었습니다. 컨테이너 안에서 &lt;code>df -h /&lt;/code>를 실행하자 가상 디스크 파일시스템은 &lt;strong>40G&lt;/strong> 규모였고, 사용 가능량 &lt;strong>0&lt;/strong>, 사용률 **100%**로 표시됐습니다. 호스트의 남은 용량과 Linux 컨테이너가 실제로 쓸 수 있는 용량이 따로 움직이고 있었습니다.&lt;/p>
&lt;h2 id="검증에-필요한-이미지-수를-줄였다">실행 이미지 하나로 검증&lt;/h2>
&lt;p>당시에는 실행 이미지와 별도로 테스트용 빌드 단계까지 유지하려 했습니다. 로컬 Docker 디스크가 부족해 비슷한 크기의 이미지를 추가로 만들기 어려웠습니다. 테스트 전용 단계를 빼고, 실행 이미지에서 일회용 컨테이너를 띄워 개발 의존성을 받아 검사하는 방식으로 바꿨습니다. 검증 기록에서 실행 이미지는 약 &lt;strong>1.65 GB&lt;/strong>였고, 그 이미지로 컨테이너를 만들어 실행한 테스트 &lt;strong>63개&lt;/strong>가 통과했습니다. 디스크를 정리한 뒤 실패했던 리뷰의 빌드를 같은 조건으로 다시 돌렸다는 기록은 없습니다.&lt;/p></description></item><item><title>FFmpeg를 유지하며 Python 3.12로 맞추기</title><link>https://team-edo.github.io/posts/2026/10/pin-managed-python-in-shared-base-image/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/pin-managed-python-in-shared-base-image/</guid><description>&lt;p>녹음 업로드는 백엔드에서 검사하고, 채점은 Python 분석 서비스에서 실행합니다. 두 서비스에서 같은 FFmpeg 패키지를 쓰기 위해 분석 이미지의 기반을 백엔드와 같은 Ubuntu &lt;strong>26.04&lt;/strong>로 맞췄습니다. 당시 기반 이미지의 시스템 Python은 &lt;strong>3.14&lt;/strong>였고, 분석 프로젝트는 &lt;strong>3.12&lt;/strong>를 사용했습니다. Python 버전을 맞추려고 기반 이미지를 바꾸면 함께 맞춰 둔 FFmpeg 패키지부터 다시 확인해야 했습니다.&lt;/p>
&lt;h2 id="시스템-python과-프로젝트-python을-분리했다">시스템 Python과 프로젝트 Python 분리&lt;/h2>
&lt;p>이미지에 uv를 넣고 &lt;code>UV_PYTHON_PREFERENCE=only-managed&lt;/code>로 설정했습니다. 프로젝트의 &lt;code>.python-version&lt;/code>에 있는 버전을 읽어 uv가 관리하는 Python을 설치하게 했습니다. 런타임 계정도 읽을 수 있는 공용 위치에 설치하고, 앱이 시작될 때는 그 Python으로 만든 가상 환경을 사용하도록 했습니다. uv를 가져오는 이미지도 다이제스트로 지정해 빌드가 참조하는 도구의 버전을 고정했습니다. &lt;a href="https://docs.astral.sh/uv/concepts/python-versions/#adjusting-python-version-preferences">uv의 관리형 Python 설정&lt;/a>&lt;/p></description></item><item><title>Flyway를 끈 운영 프로필에서 API 켜기</title><link>https://team-edo.github.io/posts/2026/10/spring-profile-ops-terms-route/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/spring-profile-ops-terms-route/</guid><description>&lt;p>얼쑤의 백엔드는 운영에서 &lt;code>ops&lt;/code> 프로필로 실행했습니다. 이 프로필에서는 앱이 이미 만들어진 DB에 연결하고, 스키마 변경은 별도 작업에서 적용합니다. 업무 API를 켜기 전의 구성을 확인해 보니 약관을 비롯한 업무 컨트롤러와 서비스가 &lt;code>db&lt;/code> 프로필에만 등록돼 있었습니다. 프로세스가 기동해도 &lt;code>ops&lt;/code>로 뜬 컨텍스트에는 약관 요청을 받을 컨트롤러가 없었습니다.&lt;/p>
&lt;h2 id="업무-빈과-스키마-변경의-조건을-분리했다">업무 빈과 마이그레이션 분리&lt;/h2>
&lt;p>처음에는 두 프로필을 함께 켜면 업무 빈도 만들 수 있다고 생각했습니다. 하지만 당시 &lt;code>db&lt;/code>는 Flyway를 켰고 &lt;code>ops&lt;/code>는 껐습니다. 두 프로필에 같은 설정 키가 있으면 뒤에 지정한 프로필의 값이 적용됩니다. 순서를 바꾸는 것만으로 운영 앱이 기동할 때 마이그레이션을 실행할 수 있는 구성이었습니다.&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>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>Jira 작업 생성 뒤 다시 읽는 이유</title><link>https://team-edo.github.io/posts/2026/10/verify-jira-task-after-create/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/verify-jira-task-after-create/</guid><description>&lt;p>개발 작업을 시작할 때 Jira에 작업을 만들고 담당자와 스프린트를 지정했습니다. 생성할 때 필드를 한꺼번에 보내면 요청 한 번으로 끝날 거라고 생각했습니다. 당시 사용한 연결 도구에서는 스프린트 값을 포함한 생성 요청이 &lt;strong>HTTP 400&lt;/strong>으로 거절됐습니다. 값을 빼고 작업을 만들자 요청은 성공했지만, 응답에 적힌 담당자는 요청에 넣은 사람과 달랐습니다. 요청을 받아들였는지와 기대한 값이 남았는지를 따로 확인해야 했습니다.&lt;/p>
&lt;h2 id="생성과-배정을-분리했다">생성과 배정을 나눠 재조회&lt;/h2>
&lt;p>먼저 스프린트 값을 빼고 작업을 만들었습니다. 생성 응답에서 실제 작업을 가리키는 키를 얻은 뒤, 그 작업에 상태 전환과 스프린트 배정을 요청했습니다. 생성 응답에서 다르게 표시된 담당자도 확인해 수정했습니다. 담당자가 바뀐 이유로 프로젝트 기본값을 의심했지만, 어느 설정이 적용됐는지까지는 확인하지 못했습니다.&lt;/p></description></item><item><title>MR 병합 뒤에도 운영 확인이 남았다</title><link>https://team-edo.github.io/posts/2026/10/mr-merge-and-operational-verification/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/mr-merge-and-operational-verification/</guid><description>&lt;p>새 약관 API를 운영에서 호출해 HTTP 200을 받는 것이 한 작업의 완료 조건이었습니다. MR이 병합되자 Jira는 작업을 완료로 표시했지만, 같은 시점에 운영 API는 &lt;strong>404&lt;/strong>를 반환했습니다.&lt;/p>
&lt;h2 id="자동-종료가-가리키는-작업을-확인했다">자동 종료 대상과 운영 응답 확인&lt;/h2>
&lt;p>저희 프로젝트는 MR이 병합되면 연결된 작업을 완료로 전환했습니다. 약관 사례에서는 배포 이력과 운영 응답을 계속 확인해 새 버전의 &lt;strong>200&lt;/strong> 응답까지 기록했습니다. 배포가 막힌 원인과 복구 과정은 별도 글에 정리했습니다. &lt;a href="https://team-edo.github.io/posts/2026/10/separate-deployment-health-probes/">인증 추가 뒤 자동 배포가 10번 실패한 이유&lt;/a>&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>분석 실행기를 공용 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>분석 오류 응답과 로그에서 서명 URL 숨기기</title><link>https://team-edo.github.io/posts/2026/10/keep-signed-urls-out-of-analysis-errors/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/keep-signed-urls-out-of-analysis-errors/</guid><description>&lt;p>얼쑤의 분석 서버는 요청에 담긴 서명 URL로 녹음을 내려받습니다. URL에 읽기 권한이 담겨 있어 유효 시간 안에는 주소만으로 파일을 요청할 수 있습니다. 분석 API를 구현할 때 이 주소가 어디에 남는지 점검했습니다. 제가 작성한 코드에서 URL을 로그에 쓰지 않아도 두 곳에 남을 수 있었습니다. 요청을 검증하는 프레임워크의 응답과 녹음을 내려받는 HTTP 라이브러리의 로그입니다.&lt;/p>
&lt;h2 id="bpm을-틀리게-보냈을-때는-문제가-드러나지-않았다">BPM을 누락해야 드러난 응답 노출&lt;/h2>
&lt;p>당시 FastAPI 기본 검증 응답은 HTTP 422와 함께 오류가 난 입력값을 &lt;code>input&lt;/code>에 넣었습니다. &lt;code>bpm&lt;/code>에 0을 보내면 오류의 입력값도 0이었습니다. 이 경우만 검사하면 기존 처리기를 써도 서명 URL이 응답에 없었습니다. &lt;code>bpm&lt;/code>을 아예 빼자 검증기가 그 필드가 있어야 할 요청 객체를 오류에 담았습니다. 같은 객체의 &lt;code>audioUrl&lt;/code>도 응답에 실릴 수 있었습니다.&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>빈 로그에서도 통과하는 비밀값 검사</title><link>https://team-edo.github.io/posts/2026/10/capture-isolated-python-logs/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/capture-isolated-python-logs/</guid><description>&lt;p>얼쑤의 분석 API는 시도마다 처리 결과를 한 줄로 남깁니다. 개발 환경에 따라 루트 로거에 이미 처리기가 붙어 있을 수 있어서, 모듈 처리기에서도 같은 줄을 출력하면 기록이 두 번 나타날 수 있습니다. 모듈의 처리기는 유지하고 &lt;code>propagate = False&lt;/code>를 설정해 그 기록을 루트 쪽으로 다시 보내지 않도록 했습니다.&lt;/p>
&lt;p>이 설정을 바꾸자 테스트에서 별도의 경로를 고려해야 했습니다. pytest의 &lt;code>caplog&lt;/code>가 루트에 붙어 있는 상태에서는 이 모듈이 로그를 남겨도 캡처에 들어오지 않습니다. 검사 내용이 “서명값이 로그에 없다”뿐이면 빈 캡처에서도 통과합니다. 확인하려던 로거에 실제 이벤트가 들어오는지 먼저 확인해야 했습니다.&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/stereo-rms-test-mono-reference/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/stereo-rms-test-mono-reference/</guid><description>&lt;p>얼쑤의 연주 분석은 스테레오 녹음을 16kHz 모노 PCM으로 변환한 뒤 시작합니다. 두 채널을 같은 비중으로 평균 내는 동작을 검사하려고, &lt;strong>440Hz 사인파&lt;/strong>를 담은 &lt;strong>44.1kHz·2초짜리 AAC&lt;/strong> 파일 두 개를 만들었습니다. 한 파일에는 왼쪽 채널에만 소리를 넣고, 다른 파일에는 양쪽에 같은 소리를 넣었습니다.&lt;/p>
&lt;p>처음에는 두 출력의 RMS를 나눴습니다. RMS는 샘플을 제곱해 평균을 낸 뒤 제곱근을 구한 값으로, 여기서는 디코딩한 신호의 크기를 비교하는 데 썼습니다. 왼쪽만 소리가 나는 출력의 RMS를 양쪽에 같은 소리가 나는 출력으로 나눠 &lt;strong>0.5 ± 0.05&lt;/strong>인지 확인했습니다. 리뷰어가 FFmpeg 필터를 두 채널의 &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>인증 추가 뒤 자동 배포가 10번 실패한 이유</title><link>https://team-edo.github.io/posts/2026/10/separate-deployment-health-probes/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/separate-deployment-health-probes/</guid><description>&lt;p>얼쑤에서 새로 병합한 약관 조회 API를 운영에서 호출했더니 404가 돌아왔습니다. 기존 서비스는 계속 응답하고 있었지만, 실행 중인 JAR의 버전과 컨테이너가 시작한 시각을 확인하니 그 뒤의 변경은 배포되지 않은 상태였습니다. 처음 살펴본 Jenkins 실행 열 건은 연속으로 배포 검사에서 실패했습니다.&lt;/p>
&lt;p>Jenkins API에서 인증 때문에 실행 기록을 읽을 수 없어, 서버에서 빌드별 &lt;code>build.xml&lt;/code>과 콘솔 로그를 읽었습니다. &lt;code>build.xml&lt;/code>의 결과·시작 시각·커밋 SHA를 콘솔 로그의 마지막 실패 단계와 맞춰 보니, 서로 다른 커밋의 배포가 같은 검사에서 멈췄습니다.&lt;/p></description></item><item><title>잡음 다섯 프레임은 0점일까</title><link>https://team-edo.github.io/posts/2026/10/reject-all-missed-recordings/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/reject-all-missed-recordings/</guid><description>&lt;p>얼쑤는 녹음에서 추정한 음높이를 악보와 맞춘 뒤 음표마다 판정을 내립니다. 처음에는 어느 프레임에서도 음높이를 찾지 못했을 때 &lt;code>NO_SOUND&lt;/code>로 끝냈습니다. 잡음을 음높이로 잡으면 이 검사를 통과할 수 있었습니다. 그러면 악보의 모든 음표를 놓쳤는데도 분석이 완료돼 0점이 나왔습니다.&lt;/p>
&lt;p>이 경우를 고정하려고 악보 길이에 맞춘 음높이 배열을 만들었습니다. 대부분을 무음으로 표시하고 &lt;strong>다섯 프레임&lt;/strong>에만 음높이 값을 넣었습니다. 마이크로 잡음을 녹음하는 대신, 음높이 추정 단계가 몇 프레임을 소리로 넘긴 상황을 재현한 입력입니다. 기존에는 이 배열이 &lt;code>DONE&lt;/code>과 &lt;strong>0점&lt;/strong>을 냈습니다.&lt;/p></description></item><item><title>재실행 중 덮어쓴 배포 백업</title><link>https://team-edo.github.io/posts/2026/10/rerun-safe-deployment-backup/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/rerun-safe-deployment-backup/</guid><description>&lt;p>배포 도구를 교체할 때 현재 파일을 백업하고, 준비한 새 파일을 설치하는 명령을 이어서 실행했습니다. 같은 명령을 다시 실행했더니 &lt;code>install&lt;/code>에서 후보 파일을 찾지 못했습니다. 실패 메시지는 마지막 단계에서 나왔지만, 그 전에 실행된 복사가 기존 백업을 이미 바꾼 뒤였습니다.&lt;/p>
&lt;p>첫 실행에서는 백업 파일에 이전 버전이 들어갔고 설치 경로에는 새 버전이 들어갔습니다. 두 번째 실행에서는 그 새 버전이 같은 이름의 백업 위에 복사됐습니다. 그 뒤에야 설치할 후보 파일이 없다는 오류가 났습니다. 이번에는 변경 전 도구가 저장소 이력에 있어 이전 내용을 찾을 수 있었습니다. 운영에서만 관리하는 파일이라면 동일한 복원 경로를 기대하기 어렵습니다. 실제로 도구를 이전 상태로 복구했거나, 수정한 절차로 설치를 다시 검증했다는 기록은 없습니다.&lt;/p></description></item><item><title>정답인데 2~4번째 음이 박자 오류로 찍힌 이유</title><link>https://team-edo.github.io/posts/2026/10/aac-priming-timing-fixture/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/aac-priming-timing-fixture/</guid><description>&lt;p>채점 API의 정상 경로를 검사하려고 악보와 같은 음높이·박자로 합성 연주를 만들었습니다. 합성음을 AAC로 인코딩해 분석 API에 넣자 **2~4번째 음이 &lt;code>TIMING&lt;/code>**으로 판정됐습니다. 악보와 합성기의 박자는 그대로였는데, 파일을 거친 뒤에만 음이 늦어진 것처럼 보였습니다.&lt;/p>
&lt;h2 id="합성음과-채점기-사이에-있던-시간">AAC 준비 샘플과 박자 오차&lt;/h2>
&lt;p>테스트는 FFmpeg의 표준 출력에서 조각 MP4를 받아 사용했습니다. AAC 인코더는 앞에 준비 샘플을 붙일 수 있습니다. 재생 타임라인에는 그 구간을 건너뛰는 정보가 필요한데, 이 테스트의 조각 MP4에는 그 정보가 없었습니다. 앱의 녹음 파일은 일반 MP4였습니다. &lt;a href="https://developer.apple.com/documentation/quicktime-file-format/background_aac_encoding">Apple의 AAC 인코딩 설명&lt;/a>&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>컨테이너 기동 후 첫 분석 요청이 500인 이유</title><link>https://team-edo.github.io/posts/2026/10/numba-cache-nonroot-first-request/</link><pubDate>Wed, 28 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/numba-cache-nonroot-first-request/</guid><description>&lt;p>얼쑤의 녹음 분석 API를 Docker 이미지로 만들었습니다. 이미지 안에서 root로 실행한 테스트는 통과했고, 기본 실행 명령으로 서버도 떴습니다. 그런데 기본 계정으로 뜬 서버에 처음 녹음 분석을 요청하자 HTTP 500이 돌아왔습니다. 로그에는 &lt;code>RuntimeError&lt;/code>가 남았습니다. 서버가 요청을 받을 수 있게 된 시점과 실제로 분석 코드를 불러오는 시점 사이에 차이가 있었습니다.&lt;/p>
&lt;h2 id="분석-코드를-불러올-때-캐시가-필요했다">모듈을 불러올 때 필요한 Numba 캐시&lt;/h2>
&lt;p>분석에 쓰는 librosa에는 Numba가 JIT 컴파일하고 결과를 캐시하는 함수가 있습니다. 이번에는 분석 요청에서 관련 모듈을 불러올 때 Numba가 캐시 위치를 찾지 못해 &lt;code>cannot cache function … no locator available&lt;/code> 오류를 냈습니다. 요청 전체를 보내지 않고 같은 이미지에서 &lt;code>import librosa.core.notation&lt;/code>만 실행해도 이 오류를 확인할 수 있었습니다.&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><item><title>녹음 저장소를 바꿔도 지킨 15분 읽기 권한</title><link>https://team-edo.github.io/posts/2026/10/recording-storage-signed-read/</link><pubDate>Tue, 27 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/recording-storage-signed-read/</guid><description>&lt;p>얼쑤에서 연습 녹음을 받으면 백엔드는 원본을 객체 저장소에 보관하고, 분석 서버는 필요한 파일을 가져와 처리합니다. 처음에는 서울 리전과 신규 계정 크레딧을 고려해 Google Cloud Storage(GCS)를 쓰는 설계를 잡았습니다. 다음 날에는 크레딧이 끝난 뒤의 비용과 다운로드 조건, 저장소를 바꿀 때의 구현 부담을 다시 검토해 S3 호환 API를 쓰는 Cloudflare R2로 계획을 바꿨습니다.&lt;/p>
&lt;h2 id="분석-서버에는-15분-동안-한-파일을-읽을-권한만-준다">한 파일에 15분 읽기 권한&lt;/h2>
&lt;p>분석 서버가 해야 할 일은 전달받은 녹음 한 건을 읽는 일이었습니다. 그래서 백엔드가 특정 객체에 대해 &lt;code>GET&lt;/code> 요청만 &lt;strong>15분(900초)&lt;/strong> 동안 허용하는 URL을 서명하고, 분석 서버는 그 주소로 원본을 내려받도록 했습니다. 저장소의 자격 증명은 서명하는 백엔드에만 둡니다. 유효한 URL을 가진 사람도 파일을 읽을 수 있으므로 주소 자체를 노출하지 않는 것도 이 설계의 일부입니다. &lt;a href="https://docs.cloud.google.com/storage/docs/access-control/signed-urls">Google Cloud Storage 서명 URL&lt;/a> · &lt;a href="https://developers.cloudflare.com/r2/api/s3/presigned-urls/">Cloudflare R2 서명 URL&lt;/a>&lt;/p></description></item><item><title>악보는 29분 30초, 연습은 10분</title><link>https://team-edo.github.io/posts/2026/10/practice-time-idle-checkpoints/</link><pubDate>Tue, 27 Oct 2026 00:00:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/practice-time-idle-checkpoints/</guid><description>&lt;p>악기를 불면서 악보를 볼 때는 한동안 화면을 건드리지 않습니다. 반대로 악보를 열어 둔 채 자리를 비울 수도 있습니다. 얼쑤의 성장 화면에 연습 시간을 표시하려면 이 두 상황을 구분할 규칙이 필요했습니다. 저는 팀과 집계 기준을 정리하고 서버의 구간 저장 API를 구현했습니다. 앱에서 시간을 재고 기기에 보관해 보내는 부분은 프론트엔드 팀원이 구현했습니다.&lt;/p>
&lt;h2 id="화면이-보인-시간과-연습으로-인정한-시간">화면 표시 시간과 인정 시간&lt;/h2>
&lt;p>악보가 앞에 보이기 시작하면 구간을 하나 엽니다. 화면을 벗어나거나 앱이 뒤로 가면 구간을 닫고, 돌아왔을 때는 새 구간을 엽니다. 구간마다 화면이 보인 시간과 그중 연습으로 인정한 시간을 따로 남깁니다. 성장 화면의 연습 시간은 인정 시간의 합입니다.&lt;/p></description></item><item><title>1.1.0 구현 전에 합의할 것</title><link>https://team-edo.github.io/posts/2026/10/coco-1-1-0/</link><pubDate>Mon, 26 Oct 2026 22:50:31 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/coco-1-1-0/</guid><description>&lt;p>CSV 가져오기를 만든다고 해 봅시다. 열 하나가 빈 행을 만나면 그 행만 건너뛸까요, 파일 전체를 거절할까요? 구현 세션이 답을 가정하면 코드는 완성돼도 다음 작업자는 그 동작이 합의된 규칙인지 알아내기 어렵습니다. 1.1.0에서는 코드에서 확인할 수 있는 기존 동작과 사람이 정해야 할 새 동작을 구분했습니다. 이어서 작업하는 사람이 같은 판단을 재현할 수 있도록, 빠진 선택을 확인하는 시점과 합의를 기록할 위치도 정했습니다. &lt;a href="https://github.com/thisis-joe/skills/blob/v1.1.0/docs/coco-1.0.0-to-1.1.0.md">1.1.0 변경 근거&lt;/a>&lt;/p></description></item><item><title>1.0.0 시작 안내가 오래 걸린 이유</title><link>https://team-edo.github.io/posts/2026/10/coco-1-0-0/</link><pubDate>Fri, 23 Oct 2026 12:35:00 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/coco-1-0-0/</guid><description>&lt;p>다음 작업을 고르려고 &lt;code>coco-start&lt;/code>를 호출했을 때 테스트와 병합 미리보기까지 이어졌습니다. 답변까지 147초가 걸렸지만, 사용자가 원한 결과는 다음 작업 경로였습니다. 1.0.0을 준비하며 한 사용자의 Claude Code 호출 11건을 살펴봤습니다. 요청에 필요한 확인과 스킬이 덧붙인 절차를 구분해야, 오래 걸리는 이유를 찾고 필요한 검사도 지킬 수 있었습니다. &lt;a href="https://github.com/thisis-joe/skills/blob/v1.0.0/docs/evaluation/latency/README.md">대기 시간 진단&lt;/a>&lt;/p>
&lt;h2 id="지금-받은-요청만-처리하게-만들다">시작 안내에 필요한 작업만&lt;/h2>
&lt;p>&lt;code>coco-start&lt;/code>에 구체적인 작업 없이 도움을 요청한 경우에는 경로를 안내하는 데 필요한 사실만 확인하도록 바꿨습니다. 검증이나 배포 점검이 필요한 경우에도 변경이 미치는 영향에 맞춰 절차를 골랐습니다. 예를 들어 마이그레이션은 수정 줄 수가 적어도 실제 실행 검사가 필요한 변경으로 다뤘습니다.&lt;/p></description></item><item><title>0.2.0 팀 규칙은 어디에 둘까</title><link>https://team-edo.github.io/posts/2026/10/coco-0-2-0/</link><pubDate>Tue, 20 Oct 2026 10:32:34 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/coco-0-2-0/</guid><description>&lt;p>얼쑤에서 AI에 푸시를 맡기려면 먼저 무엇을 확인해야 할지 정해야 했습니다. 개발 브랜치가 바뀌었는지, 기존 테스트 결과를 재사용해도 되는지, Jira에는 언제 무엇을 남겨야 하는지가 모두 판단 대상이었습니다. HTTPS 전환 작업에서는 최신 개발 브랜치가 이미 작업 브랜치에 포함돼 검사 19개의 결과를 재사용했습니다. 서버 점검 작업에서는 새 변경을 통합한 뒤 테스트 13개를 다시 실행했습니다. 두 사례를 놓고 보면 팀별 기록 규칙과 검증을 다시 할 조건을 각각 어디에 둘지가 문제였습니다.&lt;/p></description></item><item><title>0.1.0–0.1.2 흩어진 스킬을 한곳에</title><link>https://team-edo.github.io/posts/2026/10/coco-0-1-x/</link><pubDate>Tue, 20 Oct 2026 09:28:41 +0900</pubDate><guid>https://team-edo.github.io/posts/2026/10/coco-0-1-x/</guid><description>&lt;p>기능을 구현하다 막혔을 때, 먼저 풀어야 할 문제가 하나 더 있었습니다. 지금 필요한 게 요구사항을 좁히는 질문인지, 테스트를 설계하는 절차인지에 따라 여러 스킬 모음에서 비슷한 지침을 찾아 비교해야 했습니다. COCO는 이 탐색을 줄이기 위해 개발자가 하려는 일을 기준으로 스킬을 다시 묶는 작업에서 출발했습니다. 이름은 얼쑤 기술 블로그 첫 글에 등장한 팀원의 반려견 코코에서 따왔습니다. &lt;a href="https://team-edo.github.io/posts/2026/10/hello/">얼쑤 기술 블로그 첫 글&lt;/a>&lt;/p>
&lt;h2 id="작성자별-목록을-작업별-목록으로-바꾸다">스킬을 작업별로 묶다&lt;/h2>
&lt;p>Matt Pocock, Addy Osmani, Lauren Tan의 스킬 모음에서 출발했습니다. 각각을 통째로 설치하는 대신, 개발자가 하려는 일에서 출발하도록 묶었습니다. 요구사항을 좁힐 때는 &lt;code>coco-clarify&lt;/code>, 구현할 때는 &lt;code>coco-build&lt;/code>, 검증할 때는 &lt;code>coco-verify&lt;/code>를 찾는 식입니다. 같은 작업에 도움이 되는 기법을 한곳에서 찾을 수 있게 했습니다.&lt;/p></description></item><item><title>얼쑤 기술 블로그를 시작합니다</title><link>https://team-edo.github.io/posts/2026/10/hello/</link><pubDate>Tue, 20 Oct 2026 00:00:00 +0000</pubDate><guid>https://team-edo.github.io/posts/2026/10/hello/</guid><description>&lt;p>얼쑤를 만드는 &lt;strong>Team Edo&lt;/strong>의 기술 블로그를 시작합니다.&lt;/p>
&lt;p>앞으로 이곳에 프로젝트를 만들며 배운 것과 문제를 해결한 과정을 파트별로 기록할 예정이에요.&lt;/p>
&lt;h2 id="코코와-함께-쌓아-갈-개발-기록">코코와 함께 쌓아 갈 개발 기록&lt;/h2>
&lt;p>썸네일의 주인공 &lt;strong>코코&lt;/strong>는 팀원이 키우는 반려견이에요. 저희가 사용하는 개발 도구 &lt;strong>COCO 스킬&lt;/strong>도 코코의 이름에서 따왔습니다.&lt;/p>
&lt;p>앞으로 COCO 스킬을 활용해 개발을 진행하고, 그 과정에서 어떤 선택을 했는지, 무엇을 확인하고 배웠는지 이 블로그에 남기려고 해요.&lt;/p>
&lt;p>얼쑤의 백호 캐릭터와 코코도 블로그 곳곳에서 함께 인사할 예정입니다. 우리 소리에 가까워지는 앱을 만드는 과정, 차근차근 기록해 볼게요.&lt;/p></description></item><item><title>팀 소개</title><link>https://team-edo.github.io/about/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://team-edo.github.io/about/</guid><description/></item><item><title>프로젝트</title><link>https://team-edo.github.io/project/</link><pubDate>Mon, 01 Jan 0001 00:00:00 +0000</pubDate><guid>https://team-edo.github.io/project/</guid><description/></item></channel></rss>