{
    "componentChunkName": "component---src-templates-blog-template-js",
    "path": "/Music_Pipeline_08/",
    "result": {"data":{"cur":{"id":"aabb9630-b1e7-5303-8dd0-3c3828c61149","html":"<h2 id=\"선택-모듈--오디오를-실제로-흘려보내기\" style=\"position:relative;\"><a href=\"#%EC%84%A0%ED%83%9D-%EB%AA%A8%EB%93%88--%EC%98%A4%EB%94%94%EC%98%A4%EB%A5%BC-%EC%8B%A4%EC%A0%9C%EB%A1%9C-%ED%9D%98%EB%A0%A4%EB%B3%B4%EB%82%B4%EA%B8%B0\" aria-label=\"선택 모듈  오디오를 실제로 흘려보내기 permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>선택 모듈 — 오디오를 실제로 흘려보내기</h2>\n<p>메타데이터와 집계만 있던 파이프라인에 실제 재생 경로를 붙였다.\n제약: 기존 코드 무변경 — <code class=\"language-text\">streaming/</code> 패키지와 <code class=\"language-text\">audio_asset</code> 테이블만 추가.</p>\n<p>원본 음원은 <strong>ffmpeg 합성 사인파</strong>다. 저작권 문제가 없고, 길이를 <code class=\"language-text\">track.duration_ms</code>\n와 정확히 일치시킬 수 있어 검증이 깨끗하며, 저장소엔 바이너리 대신 <strong>재현 방법</strong>을\n버전관리한다. 트랙마다 주파수를 trackId 에서 유도해서 “다른 트랙인데 같은 소리” 같은\n배선 오류를 귀로 잡을 수 있게 했다.</p>\n<h2 id=\"hls-구조--rfc-8216-을-코드에-대응시키기\" style=\"position:relative;\"><a href=\"#hls-%EA%B5%AC%EC%A1%B0--rfc-8216-%EC%9D%84-%EC%BD%94%EB%93%9C%EC%97%90-%EB%8C%80%EC%9D%91%EC%8B%9C%ED%82%A4%EA%B8%B0\" aria-label=\"hls 구조  rfc 8216 을 코드에 대응시키기 permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>HLS 구조 — RFC 8216 을 코드에 대응시키기</h2>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">media-store/{trackId}/\n├─ master.m3u8          마스터 플레이리스트: \"무엇을 고를 수 있나\" (EXT-X-STREAM-INF)\n├─ v64k/playlist.m3u8   미디어 플레이리스트: \"어떤 순서로 재생하나\" (EXTINF, ENDLIST)\n├─ v64k/seg_000.ts ...  MPEG-TS 세그먼트 (AAC)\n└─ v128k/...</code></pre></div>\n<p>variant 인코딩·분할은 ffmpeg 에 맡기고 <strong>마스터 플레이리스트는 직접 썼다</strong> —\n<code class=\"language-text\">BANDWIDTH</code>(비트레이트 + TS 오버헤드)와 <code class=\"language-text\">CODECS</code>(“mp4a.40.2” = AAC-LC)가 어디서\n오는지 코드에 남는다. 플레이어는 이 두 값으로 다운로드 전에 variant 를 고른다.\nABR 의 선택 근거가 마스터에 선언돼 있다는 게 HLS 구조의 핵심이다.</p>\n<p>검증은 <strong>ffprobe 교차 검증</strong> — 우리가 만든 산출물을 우리가 아닌 도구가\n“AAC + MPEG-TS, 길이 N초”로 읽는지 확인한다. 생성기와 검증기가 같으면 순환 논증이다.</p>\n<h2 id=\"range-request206-와-hls-의-관계\" style=\"position:relative;\"><a href=\"#range-request206-%EC%99%80-hls-%EC%9D%98-%EA%B4%80%EA%B3%84\" aria-label=\"range request206 와 hls 의 관계 permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>Range Request(206) 와 HLS 의 관계</h2>\n<p>같은 문제(“파일 일부만 전송”)를 <strong>자르는 주체가 다르다.</strong></p>\n<table>\n<thead>\n<tr>\n<th></th>\n<th>Range (프로그레시브)</th>\n<th>HLS</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>자르는 주체</td>\n<td>클라이언트, 바이트 단위</td>\n<td>서버, 미디어 단위(미리)</td>\n</tr>\n<tr>\n<td>탐색</td>\n<td><code class=\"language-text\">Range: bytes=N-</code> 재요청</td>\n<td>다른 세그먼트 요청</td>\n</tr>\n<tr>\n<td>화질 전환</td>\n<td>표현 불가</td>\n<td>variant 의 같은 시점 세그먼트</td>\n</tr>\n<tr>\n<td>CDN</td>\n<td>부분 요청이라 비효율</td>\n<td>세그먼트 = 완결된 정적 파일</td>\n</tr>\n</tbody>\n</table>\n<p>Spring 은 <code class=\"language-text\">ResponseEntity&lt;Resource></code> 를 반환하면 Range 를 자동 처리한다 —\n세그먼트에 <code class=\"language-text\">bytes=0-99</code> 를 보내면 206 + <code class=\"language-text\">Content-Range</code> 가 온다. HLS 플레이어는 안\n쓰지만 두 방식의 차이를 설명하는 살아 있는 데모가 된다.</p>\n<h2 id=\"사전-생성-vs-온디맨드--측정이-가설을-기각한-네-번째-사례\" style=\"position:relative;\"><a href=\"#%EC%82%AC%EC%A0%84-%EC%83%9D%EC%84%B1-vs-%EC%98%A8%EB%94%94%EB%A7%A8%EB%93%9C--%EC%B8%A1%EC%A0%95%EC%9D%B4-%EA%B0%80%EC%84%A4%EC%9D%84-%EA%B8%B0%EA%B0%81%ED%95%9C-%EB%84%A4-%EB%B2%88%EC%A7%B8-%EC%82%AC%EB%A1%80\" aria-label=\"사전 생성 vs 온디맨드  측정이 가설을 기각한 네 번째 사례 permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>사전 생성 vs 온디맨드 — 측정이 가설을 기각한 네 번째 사례</h2>\n<p>패키징을 입수 후 미리 할 것인가, 첫 재생 요청 때 할 것인가. 측정을 위해\n<code class=\"language-text\">audio_asset</code> 테이블에 <code class=\"language-text\">packaging_ms</code>/<code class=\"language-text\">storage_bytes</code> 컬럼을 <strong>스키마 단계에</strong> 넣었다.</p>\n<p>실카탈로그 21트랙(평균 220초) 측정:</p>\n<table>\n<thead>\n<tr>\n<th>지표</th>\n<th align=\"right\">값</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>패키징 시간</td>\n<td align=\"right\">평균 <strong>5.4초</strong> (2.6~7.9초)</td>\n</tr>\n<tr>\n<td>스토리지</td>\n<td align=\"right\">5.51 MB/트랙 (비트레이트 역산과 일치)</td>\n</tr>\n<tr>\n<td>5만 트랙 외삽</td>\n<td align=\"right\">269 GB, 8워커 병렬 약 9.4시간</td>\n</tr>\n</tbody>\n</table>\n<p>가설은 “온디맨드도 1초쯤이면 쓸 만하다”였다. <strong>실측 5.4초로 기각.</strong> 음악 재생의 기대\n반응시간은 백 ms 대다. Zipf 분포로 “롱테일은 안 들으니 아끼자”는 온디맨드 옹호 논리도\n뒤집으면 “롱테일 청취자만 5초 페널티”다. <strong>사전 생성을 기본</strong>으로, 온디맨드는 같은\n준비 경로를 쓰므로 한계 비용 0 으로 보조·복구용으로 남겼다.</p>\n<h2 id=\"재사용된-패턴들\" style=\"position:relative;\"><a href=\"#%EC%9E%AC%EC%82%AC%EC%9A%A9%EB%90%9C-%ED%8C%A8%ED%84%B4%EB%93%A4\" aria-label=\"재사용된 패턴들 permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>재사용된 패턴들</h2>\n<p>새 도메인인데 새 개념이 거의 없었다.</p>\n<ul>\n<li>동시 준비 선점: <code class=\"language-text\">UNIQUE(track_id)</code> INSERT 원자성 (M1 재처리 claim 패턴)</li>\n<li>상태 머신: PREPARING → READY | FAILED (ingest_failure 와 동일)</li>\n<li>ffmpeg 실행 중 트랜잭션을 잡지 않는다 (M1 IngestService 와 동일 — 외부 프로세스가 커넥션 풀을 고갈시킨다)</li>\n<li>path traversal 방어: normalize + startsWith (M1 입수 API 와 동일)</li>\n</ul>\n<p><strong>패턴의 재사용이 곧 설계 일관성</strong>이라는 게 이 모듈의 결론이다.</p>\n<p>상세: <a href=\"https://github.com/Shim8934/media-project/blob/main/docs/adr/0006-hls-pregeneration-vs-ondemand.md\">ADR-0006</a>,\n<a href=\"https://github.com/Shim8934/media-project/blob/main/docs/learning/02-hls-implementation-notes.md\">HLS 구현 노트</a></p>\n<hr>\n<div class=\"table-of-contents\">\n<ul>\n<li><a href=\"#%EC%84%A0%ED%83%9D-%EB%AA%A8%EB%93%88--%EC%98%A4%EB%94%94%EC%98%A4%EB%A5%BC-%EC%8B%A4%EC%A0%9C%EB%A1%9C-%ED%9D%98%EB%A0%A4%EB%B3%B4%EB%82%B4%EA%B8%B0\">선택 모듈 — 오디오를 실제로 흘려보내기</a></li>\n<li><a href=\"#hls-%EA%B5%AC%EC%A1%B0--rfc-8216-%EC%9D%84-%EC%BD%94%EB%93%9C%EC%97%90-%EB%8C%80%EC%9D%91%EC%8B%9C%ED%82%A4%EA%B8%B0\">HLS 구조 — RFC 8216 을 코드에 대응시키기</a></li>\n<li><a href=\"#range-request206-%EC%99%80-hls-%EC%9D%98-%EA%B4%80%EA%B3%84\">Range Request(206) 와 HLS 의 관계</a></li>\n<li><a href=\"#%EC%82%AC%EC%A0%84-%EC%83%9D%EC%84%B1-vs-%EC%98%A8%EB%94%94%EB%A7%A8%EB%93%9C--%EC%B8%A1%EC%A0%95%EC%9D%B4-%EA%B0%80%EC%84%A4%EC%9D%84-%EA%B8%B0%EA%B0%81%ED%95%9C-%EB%84%A4-%EB%B2%88%EC%A7%B8-%EC%82%AC%EB%A1%80\">사전 생성 vs 온디맨드 — 측정이 가설을 기각한 네 번째 사례</a></li>\n<li><a href=\"#%EC%9E%AC%EC%82%AC%EC%9A%A9%EB%90%9C-%ED%8C%A8%ED%84%B4%EB%93%A4\">재사용된 패턴들</a></li>\n</ul>\n</div>","excerpt":"선택 모듈 — 오디오를 실제로 흘려보내기 메타데이터와 집계만 있던 파이프라인에 실제 재생 경로를 붙였다.\n제약: 기존 코드 무변경 —  패키지와  테이블만 추가. 원본 음원은 ffmpeg 합성 사인파다. 저작권 문제가 없고, 길이를 \n와 정확히 일치시킬 수 있어 검증이 깨끗하며, 저장소엔 바이너리 대신 재현 방법을\n버전관리한다. 트랙마다 주파수를 trackId 에서 유도해서 “다른 트랙인데 같은 소리” 같은\n배선 오류를 귀로 잡을 수 있게 했다. HLS 구조 — RFC 8216 을 코드에 대응시키기 variant 인코딩·분할은 ffmpeg 에 맡기고 마스터 플레이리스트는 직접 썼다 —\n(비트레이트 + TS 오버헤드)와 (“mp4a.40.2” = AAC-LC)가 어디서\n오는지 코드에 남는다. 플레이어는 이 두 값으로 다운로드 전에 variant 를 고른다.\nABR 의 선택 근거가 마스터에 선언돼 있다는 게 HLS 구조의 핵심이다. 검증은 ffprobe 교차 검증 — 우리가 만든 산출…","frontmatter":{"date":"July 24, 2026","title":"음악 스트리밍 파이프라인 만들기 - 08. HLS 스트리밍, 온디맨드 패키징이 기각당한 이유","categories":"Music_Pipeline","author":"shim8934","emoji":"🎧"},"fields":{"slug":"/Music_Pipeline_08/"}},"next":{"id":"294830e9-7d9b-503c-a525-65ad3c21e7e1","html":"<h2 id=\"문제를-일부러-만들다\" style=\"position:relative;\"><a href=\"#%EB%AC%B8%EC%A0%9C%EB%A5%BC-%EC%9D%BC%EB%B6%80%EB%9F%AC-%EB%A7%8C%EB%93%A4%EB%8B%A4\" aria-label=\"문제를 일부러 만들다 permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>문제를 일부러 만들다</h2>\n<p>이전 직장에서 N+1 로 60~90초 걸리던 조회를 개선한 경험이 있는데, MyBatis 환경이었다.\nJPA 환경에서 같은 문제를 재현하고 개선 전략을 <strong>수치로</strong> 비교하고 싶었다.</p>\n<p>앨범 목록 API(앨범 + 대표 아티스트명 + 트랙 목록)를 만들었다. Album→Artist(ToOne),\nAlbum→Tracks(OneToMany) 모두 LAZY. 리포지토리는 <code class=\"language-text\">findAll(Pageable)</code> 하나 —\n<strong>코드 어디에도 반복 조회가 없다.</strong> 그런데:</p>\n<div class=\"gatsby-highlight\" data-language=\"text\"><pre class=\"language-text\"><code class=\"language-text\">앨범 30개 조회 = 쿼리 61개 이상 (목록 1 + artist 30 + tracks 30)</code></pre></div>\n<p>쿼리는 리포지토리가 아니라 <strong>DTO 변환 중</strong>(지연 로딩 접근)에 나간다. 이게 N+1 이 늦게\n발견되는 이유다. MyBatis 는 resultMap 의 collection 쿼리가 XML 에 보이는데, JPA 는\n<code class=\"language-text\">getMainArtist().getName()</code> 한 줄이 조용히 SELECT 를 만든다. 그리고 응답은 <strong>완전히\n정확하다</strong> — N+1 은 틀린 결과가 아니라 느린 결과라서 기능 테스트로는 절대 안 잡힌다.</p>\n<p>측정은 Hibernate Statistics(<code class=\"language-text\">PrepareStatementCount</code>)로 했다. “느린 것 같다”가 아니라\n쿼리 수를 직접 센다.</p>\n<h2 id=\"3가지-개선-전략-측정\" style=\"position:relative;\"><a href=\"#3%EA%B0%80%EC%A7%80-%EA%B0%9C%EC%84%A0-%EC%A0%84%EB%9E%B5-%EC%B8%A1%EC%A0%95\" aria-label=\"3가지 개선 전략 측정 permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>3가지 개선 전략 측정</h2>\n<p>앨범 200 / 트랙 2,000 / 페이지 50, 3회 중앙값. <strong>지표를 두 개</strong> 봤다 —\n쿼리 수와 함께 Album 엔티티 로드 수(EntityLoadCount).</p>\n<table>\n<thead>\n<tr>\n<th>전략</th>\n<th align=\"right\">쿼리 수</th>\n<th align=\"right\">Album 로드 수</th>\n<th align=\"right\">중앙값</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>NAIVE (LAZY 순회)</td>\n<td align=\"right\">102</td>\n<td align=\"right\">50</td>\n<td align=\"right\">110ms</td>\n</tr>\n<tr>\n<td>fetch join</td>\n<td align=\"right\">2</td>\n<td align=\"right\"><strong>200</strong></td>\n<td align=\"right\">33ms</td>\n</tr>\n<tr>\n<td><code class=\"language-text\">@EntityGraph</code></td>\n<td align=\"right\">2</td>\n<td align=\"right\"><strong>200</strong></td>\n<td align=\"right\">22ms</td>\n</tr>\n<tr>\n<td><code class=\"language-text\">default_batch_fetch_size=100</code></td>\n<td align=\"right\">4</td>\n<td align=\"right\"><strong>50</strong></td>\n<td align=\"right\">-</td>\n</tr>\n</tbody>\n</table>\n<h2 id=\"반전--fetch-join-의-숨은-비용\" style=\"position:relative;\"><a href=\"#%EB%B0%98%EC%A0%84--fetch-join-%EC%9D%98-%EC%88%A8%EC%9D%80-%EB%B9%84%EC%9A%A9\" aria-label=\"반전  fetch join 의 숨은 비용 permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>반전 — fetch join 의 숨은 비용</h2>\n<p>쿼리 수만 보면 fetch join 완승(102→2)이다. 그런데 로드 수가 이상하다.\n<strong>페이지는 50개를 요청했는데 전체 200개를 로드했다.</strong></p>\n<p>컬렉션 fetch join 에 페이징을 걸면 Hibernate 는 LIMIT 을 SQL 로 못 내린다 —\n한 앨범이 트랙 수만큼 여러 행으로 뻥튀기된 결과에서 50행을 자르면 앨범 50개가\n아니기 때문이다. 대신 <strong>전체를 메모리에 올려놓고 자른다</strong> (로그의 HHH90003004 경고).\n앨범이 200개라 33ms 지, 200만 개면 OOM 이다. <code class=\"language-text\">@EntityGraph</code> 도 SQL 형태가 같아\n함정을 그대로 공유한다 — 차이는 선언 방식이지 성능이 아니다.</p>\n<p><strong>페이징을 DB 에 남기는 컬렉션 해법은 batch fetch 뿐이었다</strong> (쿼리 4개, 로드 50개).</p>\n<h2 id=\"네-번째-전략은-코드가-아니라-설정이다\" style=\"position:relative;\"><a href=\"#%EB%84%A4-%EB%B2%88%EC%A7%B8-%EC%A0%84%EB%9E%B5%EC%9D%80-%EC%BD%94%EB%93%9C%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-%EC%84%A4%EC%A0%95%EC%9D%B4%EB%8B%A4\" aria-label=\"네 번째 전략은 코드가 아니라 설정이다 permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>네 번째 전략은 코드가 아니라 설정이다</h2>\n<p><code class=\"language-text\">default_batch_fetch_size</code> 는 SessionFactory 설정이라 쿼리 단위로 켤 수 없다.\n그래서 벤치마크도 <strong>별도 테스트 컨텍스트</strong>가 필요했는데, 그 클래스에서 실행되는\n서비스 코드는 NAIVE 벤치마크와 <strong>한 글자도 다르지 않다.</strong> 같은 코드가 설정 한 줄로\n102쿼리 → 4쿼리가 된다. 동작 원리는 LAZY 프록시 초기화 시 영속성 컨텍스트의 같은 타입\n미초기화 프록시를 batch_size 개까지 모아 IN 쿼리 하나로 로딩하는 것.</p>\n<h2 id=\"정리\" style=\"position:relative;\"><a href=\"#%EC%A0%95%EB%A6%AC\" aria-label=\"정리 permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>정리</h2>\n<ul>\n<li>ToOne 만 필요하면 fetch join, <strong>컬렉션 + 페이징이면 batch fetch</strong> 가 사실상 유일해</li>\n<li>지표 하나(쿼리 수)만 보면 결론이 뒤집힌다 — EntityLoadCount 를 같이 볼 것</li>\n<li><code class=\"language-text\">open-in-view: false</code> 전제라 DTO 변환은 <code class=\"language-text\">@Transactional(readOnly=true)</code> 안에서.\nOSIV 로 미루면 편하지만 트랜잭션 밖 커넥션 점유를 편의로 포장하는 것이다</li>\n</ul>\n<p>벤치마크 코드: <a href=\"https://github.com/Shim8934/media-project/blob/main/src/test/java/com/skyshim/musicpipeline/catalog/AlbumFetchStrategyBenchmarkTest.java\">AlbumFetchStrategyBenchmarkTest</a></p>\n<hr>\n<div class=\"table-of-contents\">\n<ul>\n<li><a href=\"#%EB%AC%B8%EC%A0%9C%EB%A5%BC-%EC%9D%BC%EB%B6%80%EB%9F%AC-%EB%A7%8C%EB%93%A4%EB%8B%A4\">문제를 일부러 만들다</a></li>\n<li><a href=\"#3%EA%B0%80%EC%A7%80-%EA%B0%9C%EC%84%A0-%EC%A0%84%EB%9E%B5-%EC%B8%A1%EC%A0%95\">3가지 개선 전략 측정</a></li>\n<li><a href=\"#%EB%B0%98%EC%A0%84--fetch-join-%EC%9D%98-%EC%88%A8%EC%9D%80-%EB%B9%84%EC%9A%A9\">반전 — fetch join 의 숨은 비용</a></li>\n<li><a href=\"#%EB%84%A4-%EB%B2%88%EC%A7%B8-%EC%A0%84%EB%9E%B5%EC%9D%80-%EC%BD%94%EB%93%9C%EA%B0%80-%EC%95%84%EB%8B%88%EB%9D%BC-%EC%84%A4%EC%A0%95%EC%9D%B4%EB%8B%A4\">네 번째 전략은 코드가 아니라 설정이다</a></li>\n<li><a href=\"#%EC%A0%95%EB%A6%AC\">정리</a></li>\n</ul>\n</div>","frontmatter":{"date":"July 24, 2026","title":"음악 스트리밍 파이프라인 만들기 - 07. N+1, 쿼리 수만 세면 fetch join에 속는다","categories":"Music_Pipeline","author":"shim8934","emoji":"🔍"},"fields":{"slug":"/Music_Pipeline_07/"}},"prev":{"id":"d43844ee-35e5-550c-8c4b-84d8b83c50da","html":"<h2 id=\"어느-날-애플리케이션이-뜨지-않았다\" style=\"position:relative;\"><a href=\"#%EC%96%B4%EB%8A%90-%EB%82%A0-%EC%95%A0%ED%94%8C%EB%A6%AC%EC%BC%80%EC%9D%B4%EC%85%98%EC%9D%B4-%EB%9C%A8%EC%A7%80-%EC%95%8A%EC%95%98%EB%8B%A4\" aria-label=\"어느 날 애플리케이션이 뜨지 않았다 permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>어느 날 애플리케이션이 뜨지 않았다</h2>\n<p>M3 작업 중 bootRun 이 <code class=\"language-text\">Unknown database 'music_pipeline'</code> 으로 실패했다.\n어제까지 멀쩡했던 로컬 DB 다. 안에 들어가 보니:</p>\n<div class=\"gatsby-highlight\" data-language=\"sql\"><pre class=\"language-sql\"><code class=\"language-sql\"><span class=\"token keyword\">SHOW</span> <span class=\"token keyword\">DATABASES</span><span class=\"token punctuation\">;</span>\n<span class=\"token comment\">-- RECOVER_YOUR_DATA      ← ???</span>\n<span class=\"token comment\">-- information_schema</span>\n<span class=\"token comment\">-- mysql</span>\n<span class=\"token comment\">-- ...</span>\n<span class=\"token comment\">-- music_pipeline 이 없다</span></code></pre></div>\n<p><code class=\"language-text\">RECOVER_YOUR_DATA</code> 데이터베이스 안에는 이런 내용이 있었다.</p>\n<blockquote>\n<p>All your data was backed up by us. You must pay 0.013 bitcoin to …\nor in 48 hours, your data will be publicly disclosed and deleted.</p>\n</blockquote>\n<p>노출된 MySQL/MariaDB 를 스캔해서 약한 비밀번호로 로그인하고, DB 를 삭제한 뒤\n랜섬 노트를 남기는 자동화 봇의 전형적인 수법이다.</p>\n<h2 id=\"뚫린-경로\" style=\"position:relative;\"><a href=\"#%EB%9A%AB%EB%A6%B0-%EA%B2%BD%EB%A1%9C\" aria-label=\"뚫린 경로 permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>뚫린 경로</h2>\n<p>거창한 취약점이 아니다. 조합이 문제였다.</p>\n<div class=\"gatsby-highlight\" data-language=\"yaml\"><pre class=\"language-yaml\"><code class=\"language-yaml\"><span class=\"token comment\"># docker-compose.yml (사고 전)</span>\n<span class=\"token key atrule\">ports</span><span class=\"token punctuation\">:</span>\n  <span class=\"token punctuation\">-</span> <span class=\"token string\">\"3307:3306\"</span>          <span class=\"token comment\"># = 0.0.0.0:3307, IPv6 [::]:3307 까지 바인딩</span>\n<span class=\"token key atrule\">environment</span><span class=\"token punctuation\">:</span>\n  <span class=\"token key atrule\">MARIADB_ROOT_PASSWORD</span><span class=\"token punctuation\">:</span> root   <span class=\"token comment\"># 개발용 \"임시\" 비밀번호</span></code></pre></div>\n<p>Docker 포트 매핑의 기본값은 <strong>모든 인터페이스</strong>(<code class=\"language-text\">0.0.0.0</code>)다. “로컬 개발용이니까”라고\n쓴 root/root 와 만나는 순간, 네트워크에서 접근 가능한 무인 DB 가 된다.\n공유기 NAT 뒤라고 안심할 수 없다 — IPv6 는 공인 주소가 직접 붙는 경우가 많고,\n같은 네트워크의 감염된 기기가 경유지가 되기도 한다.</p>\n<h2 id=\"대응\" style=\"position:relative;\"><a href=\"#%EB%8C%80%EC%9D%91\" aria-label=\"대응 permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>대응</h2>\n<ol>\n<li><strong>몸값은 지불하지 않는다.</strong> 이런 봇은 대부분 백업 없이 삭제만 한다. 그리고 잃은 것은\n전부 재생성 가능한 로컬 샘플 데이터였다 — 결과적으로 피해 0.</li>\n<li><strong>모든 포트를 루프백 바인딩으로 변경.</strong> MariaDB 만이 아니라 Kafka(무인증 PLAINTEXT),\nRedis(무인증 — 노출 시 RCE 까지 가능) 전부.</li>\n</ol>\n<div class=\"gatsby-highlight\" data-language=\"yaml\"><pre class=\"language-yaml\"><code class=\"language-yaml\"><span class=\"token key atrule\">ports</span><span class=\"token punctuation\">:</span>\n  <span class=\"token punctuation\">-</span> <span class=\"token string\">\"127.0.0.1:3307:3306\"</span>\n  <span class=\"token punctuation\">-</span> <span class=\"token string\">\"127.0.0.1:9092:9092\"</span>\n  <span class=\"token punctuation\">-</span> <span class=\"token string\">\"127.0.0.1:16379:6379\"</span></code></pre></div>\n<ol start=\"3\">\n<li>오염된 볼륨은 폐기하고 재생성 — 공격자가 root 로 만졌던 데이터는 신뢰하지 않는다.\nFlyway 가 스키마를, 샘플 생성기 + 입수 API 가 데이터를 복원했다 (5,000 릴리스 / 50,000 트랙).</li>\n<li><code class=\"language-text\">mysql.user</code> 에 주입된 계정이 없는지 확인.</li>\n</ol>\n<p>복원이 몇 분에 끝난 것은 우연이 아니라 <strong>“파일이 아니라 재현 방법을 버전관리한다”</strong>\n원칙 덕이었다. 데이터가 전부 스크립트에서 재생성되는 구조면 랜섬은 협박이 못 된다.</p>\n<h2 id=\"배운-것\" style=\"position:relative;\"><a href=\"#%EB%B0%B0%EC%9A%B4-%EA%B2%83\" aria-label=\"배운 것 permalink\" class=\"anchor before\"><svg aria-hidden=\"true\" focusable=\"false\" height=\"16\" version=\"1.1\" viewBox=\"0 0 16 16\" width=\"16\"><path fill-rule=\"evenodd\" d=\"M4 9h1v1H4c-1.5 0-3-1.69-3-3.5S2.55 3 4 3h4c1.45 0 3 1.69 3 3.5 0 1.41-.91 2.72-2 3.25V8.59c.58-.45 1-1.27 1-2.09C10 5.22 8.98 4 8 4H4c-.98 0-2 1.22-2 2.5S3 9 4 9zm9-3h-1v1h1c1 0 2 1.22 2 2.5S13.98 12 13 12H9c-.98 0-2-1.22-2-2.5 0-.83.42-1.64 1-2.09V6.25c-1.09.53-2 1.84-2 3.25C6 11.31 7.55 13 9 13h4c1.45 0 3-1.69 3-3.5S14.5 6 13 6z\"></path></svg></a>배운 것</h2>\n<ul>\n<li><strong>로컬 개발 환경도 공격 표면이다.</strong> “어차피 로컬”이라는 가정이 0.0.0.0 바인딩과\n기본 비밀번호를 정당화하는 순간 뚫린다.</li>\n<li>compose 로 띄우는 개발용 인프라의 기본기: 루프백 바인딩, 그리고 그걸 <strong>왜</strong> 하는지\n주석으로 남기기 (다음 사람이 편의로 되돌리지 않게).</li>\n<li>사고를 숨기지 않기로 했다 — 프로젝트 README 에 경위를 그대로 적었다.\n“무엇을 만들었나”만큼 “무엇이 뚫렸고 어떻게 복구했나”도 기록할 가치가 있다.</li>\n</ul>\n<hr>\n<div class=\"table-of-contents\">\n<ul>\n<li><a href=\"#%EC%96%B4%EB%8A%90-%EB%82%A0-%EC%95%A0%ED%94%8C%EB%A6%AC%EC%BC%80%EC%9D%B4%EC%85%98%EC%9D%B4-%EB%9C%A8%EC%A7%80-%EC%95%8A%EC%95%98%EB%8B%A4\">어느 날 애플리케이션이 뜨지 않았다</a></li>\n<li><a href=\"#%EB%9A%AB%EB%A6%B0-%EA%B2%BD%EB%A1%9C\">뚫린 경로</a></li>\n<li><a href=\"#%EB%8C%80%EC%9D%91\">대응</a></li>\n<li><a href=\"#%EB%B0%B0%EC%9A%B4-%EA%B2%83\">배운 것</a></li>\n</ul>\n</div>","frontmatter":{"date":"July 24, 2026","title":"음악 스트리밍 파이프라인 만들기 - 09. 번외, 로컬 Docker DB가 랜섬 공격당했다","categories":"Music_Pipeline","author":"shim8934","emoji":"🚨"},"fields":{"slug":"/Music_Pipeline_09/"}},"site":{"siteMetadata":{"siteUrl":"https://shim8934.github.io","comments":{"utterances":{"repo":"Shim8934/shim8934.github.io"}}}}},"pageContext":{"slug":"/Music_Pipeline_08/","nextSlug":"/Music_Pipeline_07/","prevSlug":"/Music_Pipeline_09/"}},
    "staticQueryHashes": ["1073350324","1956554647","2938748437"]}