<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>어제오늘내일</title>
    <link>https://yestomo.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Thu, 2 Jul 2026 09:17:21 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>phonil</managingEditor>
    <item>
      <title>[O+T] 영상 품질 기반 개선기 (2. Per-Title-Encoding)</title>
      <link>https://yestomo.tistory.com/27</link>
      <description>&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;1편: &lt;a href=&quot;https://yestomo.tistory.com/25&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;https://yestomo.tistory.com/25&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;figure data-ke-type=&quot;opengraph&quot; data-og-title=&quot;[O+T]: 영상 품질 측정하기 (1. 올바른 영상 변환과 품질 측정)&quot; data-ke-align=&quot;alignCenter&quot; data-og-description=&quot;영상 트랜스코딩 - VMAF 품질 측정 과정을 거치며 마주한 문제를 해결하는 과정에 대해 적어놓은 글입니다. 문제 해결 중심으로 글이 전개되므로 영상에서 사용하는 개념에 대한 설명이 적게 들&quot; data-og-host=&quot;yestomo.tistory.com&quot; data-og-source-url=&quot;https://yestomo.tistory.com/25&quot; data-og-image=&quot;https://blog.kakaocdn.net/dna/bJlG9d/dJMb9aKPBOx/AAAAAAAAAAAAAAAAAAAAABk5g_YPP1M66mtuHzxBoTTc0M6S-YnG3HQs6uP9Xe6k/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1782831599&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=uXZLSHp8op0QsTBvyY%2BGLMDpb8U%3D&quot; data-og-url=&quot;https://yestomo.tistory.com/25&quot;&gt;&lt;a href=&quot;https://yestomo.tistory.com/25&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://yestomo.tistory.com/25&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://blog.kakaocdn.net/dna/bJlG9d/dJMb9aKPBOx/AAAAAAAAAAAAAAAAAAAAABk5g_YPP1M66mtuHzxBoTTc0M6S-YnG3HQs6uP9Xe6k/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1782831599&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=uXZLSHp8op0QsTBvyY%2BGLMDpb8U%3D');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[O+T]: 영상 품질 측정하기 (1. 올바른 영상 변환과 품질 측정)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;영상 트랜스코딩 - VMAF 품질 측정 과정을 거치며 마주한 문제를 해결하는 과정에 대해 적어놓은 글입니다. 문제 해결 중심으로 글이 전개되므로 영상에서 사용하는 개념에 대한 설명이 적게 들&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;yestomo.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;0. 개요&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;1편을 진행하기도 전.. 프로젝트를 막 시작했을 때, 팀원들과 영상에 대해 처음 공부하고 이것저것 알아보며 Per-Title-Encoding이라는 것을 알게 되었습니다. 프로젝트 기간에는 시간이 부족해서 완성하지 못했지만, 그때 논의했던 것들이 생각나 따로 공부해봤습니다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;영상 단위로 최적화하는 Per-Title-Encoding도 있고, 아예 장면 단위로 최적화를 진행하는 Per-Shot-Encoding도 있지만, 넷플릭스나 유튜브처럼 초대형 플랫폼이 아니라면 큰 의미가 있지는 않을 것이라고 생각했습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;1편에서는 트랜스코딩 결과물을 VMAF로 믿고 측정할 수 있게 만들고, 변환 시 기본적으로 갖춰야 할 요소들(정렬&amp;middot;CFR&amp;middot;GOP&amp;middot;매니페스트)을 보완했습니다. 그 과정에서 모든 영상의 VMAF 점수가 94~97점으로 높게 나타났습니다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;이번 2편에서는 그 기준선 위에서 '&lt;b&gt;모든 영상에 같은 bitrate ladder를 쓰는 것이 정말 효율적인가?&lt;/b&gt;'를 따져보았습니다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;효과를 확실하게 살펴보고자 영상은 저/중/고 복잡도로 구분하여 3개 준비했고, 작업에 앞서 이번 작업의 최종 결과를 표로 먼저 정리해봤습니다.&lt;/p&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; 영상 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 복잡도 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; PTE 판단 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 결과 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Big Buck Bunny&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;저복잡도&lt;/td&gt;
&lt;td&gt;줄여도 됨&lt;/td&gt;
&lt;td&gt;총 bitrate 36.1% 절감&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Meridian&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;중복잡도&lt;/td&gt;
&lt;td&gt;일부 절감 가능&lt;/td&gt;
&lt;td&gt;총 bitrate 12.4% 절감&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Tears of Steel&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;고복잡도&lt;/td&gt;
&lt;td&gt;무리한 절감 부적합&lt;/td&gt;
&lt;td&gt;총 bitrate 6.8% 증가, 품질 방어&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;PTE는 '압축률을 무조건 높이는 기술'이 아니라 영상별로 적절한 품질-용량 균형점을 찾는 과정이었음을 확인할 수 있었습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. Per-Title-Encoding 소개&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;영상 인코딩과 재생 시 사용되는 데이터의 양을&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;비트레이트&lt;/b&gt;&lt;span style=&quot;color: #333333;&quot;&gt;라고 하며, 이에 따라 파일의 용량이 결정됩니다.&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;/span&gt;&lt;br /&gt;해당 프로젝트도 그렇고, 대부분의 영상 스트리밍 서비스에서는 &lt;b&gt;영상을 &lt;/b&gt;S3같은 &lt;b&gt;저장소에 &lt;/b&gt;저장하고, &lt;span style=&quot;color: #333333;&quot;&gt;CDN을 사용하기도 합니다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;영상 자체가 대부분 크기가 매우 큰 파일&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이기 때문에 이 용량이 항상 비용과 직결됩니다. 따라서 품질에 이상이 없는 한 용량을 최대한 줄여 &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;저장 공간을 효율적으로 사용&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;할 필요가 있습니다.&amp;nbsp;&lt;/span&gt;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;전송 측면에서도 동일하게 적은 비트레이트로 충분한 영상에 많은 비트레이트를 할당하게 되면 &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;전송량이 낭비&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;될 수 있습니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이러한 문제를 해결하기 위해 &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;영상별 최적화 방식인 Per-Title-Encoding&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;을 도입해보기로 결정했습니다.&lt;/span&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1-1. 기존 방식: 모든 영상에 같은 Ladder 사용&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;633&quot; data-origin-height=&quot;964&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uxDFM/dJMcadJemkW/Sg2CegYDNbAiJxFP3slNS1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uxDFM/dJMcadJemkW/Sg2CegYDNbAiJxFP3slNS1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uxDFM/dJMcadJemkW/Sg2CegYDNbAiJxFP3slNS1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuxDFM%2FdJMcadJemkW%2FSg2CegYDNbAiJxFP3slNS1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;270&quot; height=&quot;411&quot; data-origin-width=&quot;633&quot; data-origin-height=&quot;964&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;위 표는 넷플릭스에서 제시한 대부분의 영상에 적합한 해상도 x 비트레이트 조합입니다. 이를 바탕으로 기존 트랜스코딩 과정에서는 영상이 무엇이든 360p&amp;middot;720p&amp;middot;1080p에 고정 비트레이트(800&amp;middot;2400&amp;middot;4800k)를 주도록 구성했습니다. 어떤 영상이든 업로드되면 360p, 720p, 1080p를 만들고, &lt;b&gt;각 해상도에 고정된 bitrate&lt;/b&gt;를 적용합니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;하지만, 영상마다 필요한 bitrate가 다르다는 점을 온전히 반영하지는 못합니다. 같은 1080p라도 영상이 단순하면 낮은 bitrate로도 충분하고, 영상이 복잡하면 더 많은 bitrate가 필요할 수 있습니다. 잔잔한 애니메이션과 장면 전환이 잦은 액션 영상에 같은 비트레이트를 주면 단순한 영상은 용량을 낭비하고 복잡한 영상은 품질이 떨어질 수 있습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1-2. Per-Title-Encoding이란 무엇인가?&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;PTE(Per-Title Encoding)는 &lt;b&gt;영상마다 최적의 비트레이트 ladder&lt;/b&gt;를 &lt;b&gt;측정으로 결정&lt;/b&gt;하는 방법입니다. 한 영상을 여러 품질 후보로 인코딩해보고, 화질(VMAF)과 용량(비트레이트)을 함께 재서 가장 효율적인 지점을 고르는 것입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1398&quot; data-origin-height=&quot;534&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/23CNv/dJMcadJeoM7/ISNbaTK8OGQcyJ1KptnLc1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/23CNv/dJMcadJeoM7/ISNbaTK8OGQcyJ1KptnLc1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/23CNv/dJMcadJeoM7/ISNbaTK8OGQcyJ1KptnLc1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F23CNv%2FdJMcadJeoM7%2FISNbaTK8OGQcyJ1KptnLc1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;275&quot; data-origin-width=&quot;1398&quot; data-origin-height=&quot;534&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PTE의 핵심은 '영상별 품질-용량 곡선'을 찾는 것&lt;/b&gt;입니다.&lt;/p&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 7.67442%;&quot;&gt;&lt;b&gt; 구분 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50.9302%;&quot;&gt;&lt;b&gt; 기존 고정 ladder &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.2791%;&quot;&gt;&lt;b&gt; PTE &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 7.67442%;&quot;&gt;&lt;b&gt;기준&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50.9302%;&quot;&gt;모든 영상 동일&lt;/td&gt;
&lt;td style=&quot;width: 41.2791%;&quot;&gt;영상별로 다름&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 7.67442%;&quot;&gt;&lt;b&gt;판단&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50.9302%;&quot;&gt;해상도별 고정 bitrate&lt;/td&gt;
&lt;td style=&quot;width: 41.2791%;&quot;&gt;후보별 bitrate-quality 측정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 7.67442%;&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50.9302%;&quot;&gt;단순, 운영 쉬움&lt;/td&gt;
&lt;td style=&quot;width: 41.2791%;&quot;&gt;영상별 최적화 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 7.67442%;&quot;&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50.9302%;&quot;&gt;쉬운 영상은 용량 낭비, 어려운 영상은 품질 부족 가능&lt;/td&gt;
&lt;td style=&quot;width: 41.2791%;&quot;&gt;후보 인코딩/측정 비용 증가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 7.67442%;&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 50.9302%;&quot;&gt;하나의 공통 ladder&lt;/td&gt;
&lt;td style=&quot;width: 41.2791%;&quot;&gt;영상마다 다른 ladder&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;주의할 점은 용량을 줄이는 것이 목적이긴 하지만, 무작정 줄이면 안 된다는 점입니다. 적절한 품질을 유지하는 선에서 용량을 절감하는 것이 핵심입니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;품질 지표로 사용되는 &lt;b&gt;VMAF 6점당 1JND&lt;/b&gt;(사람이 눈으로 인식 가능한 품질 차이라고 하네요..!)라고 합니다. 이를 활용해서 JND 하락을 신경 쓰며 작업을 수행해야 합니다. 어쨌든 영상 플랫폼의 주 목적은 좋은 품질의 영상을 제공하는 것도 있기 때문입니다!&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PTE는 단순히 bitrate를 줄이는 기술이 아닙니다. 더 정확히는 아래 내용을 자동화하는 과정입니다.&lt;/p&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 84px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 35.4651%;&quot;&gt;&lt;b&gt; 영상 상태 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 64.5349%;&quot;&gt;&lt;b&gt; PTE 판단 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 35.4651%;&quot;&gt;기존 bitrate가 과한 영상&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 64.5349%;&quot;&gt;bitrate를 줄인다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 35.4651%;&quot;&gt;기존 bitrate가 적절한 영상&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 64.5349%;&quot;&gt;유지하거나 일부만 줄인다&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 35.4651%;&quot;&gt;기존 bitrate가 부족한 영상&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 64.5349%;&quot;&gt;줄이지 않거나 오히려 품질을 방어한다&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&lt;b&gt;PTE의 목적&lt;/b&gt;은 &lt;b&gt; '적절한 비트레이트'를 설정&lt;/b&gt;하여 모든 영상을 작게 만드는 것이 아니라, &lt;b&gt;영상별로 적절한 품질-용량 균형점을 찾는 것&lt;/b&gt;입니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1614&quot; data-origin-height=&quot;915&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ceUlbX/dJMcadvGUkW/khcKERlAIkiwn3kFCnLfR0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ceUlbX/dJMcadvGUkW/khcKERlAIkiwn3kFCnLfR0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ceUlbX/dJMcadvGUkW/khcKERlAIkiwn3kFCnLfR0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FceUlbX%2FdJMcadvGUkW%2FkhcKERlAIkiwn3kFCnLfR0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;363&quot; data-origin-width=&quot;1614&quot; data-origin-height=&quot;915&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PTE 작업은 위 흐름으로 이어집니다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1-3. 가설&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;복잡도가 낮은 영상은 Bitrate가 적게 필요&lt;/b&gt;하고, &lt;b&gt;복잡도가 높은 영상은 &lt;/b&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt;상대적으로&lt;/b&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;b&gt; &lt;/b&gt;&lt;/span&gt;&lt;b&gt;Bitrate가 많이 필요할 것이라고 생각&lt;/b&gt;했습니다. 따라서 영상의 복잡도에 따라 절감되는 Bitrate 비율이 달라질 것이라고 생각했습니다. 특히, 고복잡도 영상의 고화질에서는 Bitrate 절감보다 영상 품질 방어를 위해 오히려 Bitrate를 더 사용할 수도 있습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;복잡도가 낮은 영상은 적은 bitrate로도 충분한 품질을 유지할 수 있고, 복잡도가 높은 영상은 같은 품질을 유지하기 위해 상대적으로 더 많은 bitrate가 필요할 것이라고 생각했습니다.&lt;br /&gt;따라서 PTE를 적용하면 영상 복잡도에 따라 결과가 다르게 나올 것이라고 가정했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;고복잡도 영상에서는 '얼마나 줄였는가'보다 '무리하게 줄이지 않는가'가 더 중요하다고 보았습니다. PTE가 단순한 용량 절감 기술이라면 고복잡도 영상에서도 억지로 bitrate를 낮추겠지만, 실제로 필요한 것은 영상별 품질-용량 균형점을 찾는 것입니다.&lt;br /&gt;그래서 이번 실험에서는 다음 두 가지를 함께 확인했습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; 확인할 것&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 의미&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Bitrate 변화&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;기존 고정 ladder보다 얼마나 줄거나 늘었는가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;VMAF 변화&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;bitrate 변화 후 품질이 유지되는가&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;이를 바탕으로 &lt;b&gt;영상 복잡도가 낮을수록 bitrate 절감 폭이 크고, 복잡도가 높을수록 절감 폭은 줄어들거나 품질 방어를 위해 bitrate가 증가&lt;/b&gt;할 것이라는 가설을 세웠습니다.&lt;/span&gt;&lt;/div&gt;
&lt;/div&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 대표 구간 추출&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;PTE는 영상을 여러 후보로 인코딩해 비교하는데, 영상 인코딩과 VMAF 측정은 영상 길이에 비례해 시간이 늘어납니다. 특히, VMAF는 원본과 후보 영상을 프레임 단위로 비교하기 때문에 후보 수가 늘어날수록 비용이 급격히 커집니다. 실제로 1시간 짜리 영상을 인코딩하고 VMAF 측정하는 데 각각 50분 이상 걸렸던 것으로 확인한 적이 있습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;그런데 후보는 해상도 &amp;times; CRF 조합이라 수가 많고, 이걸 전체 영상마다 돌리면 매우 오랜 시간과 자원이 필요합니다. 그래서 &lt;b&gt;전체를 대신할 대표 구간&lt;/b&gt;을 뽑아 인코딩과 VMAF 측정 시간을 줄이고, 이들을 통해 Ladder를 결정하기로 했습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2-1. 대표 구간 정의&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표 구간은 하나의 '평균 장면'만 의미하지 않습니다. PTE에서는 &lt;b&gt;쉬운 구간과 어려운 구간&lt;/b&gt;을 모두 봐야 합니다. 쉬운 구간만 보면 bitrate를 너무 많이 줄여도 된다고 오판할 수 있고, 반대로 어려운 구간만 보면 모든 영상을 지나치게 보수적으로 인코딩하게 되기 때문입니다.&lt;br /&gt;그래서 이번 실험에서는 &lt;b&gt;영상마다 5개 구간&lt;/b&gt;을 사용했습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-style=&quot;style8&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 26.8605%;&quot;&gt;&lt;b&gt; 구간 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.9767%;&quot;&gt;&lt;b&gt; 의미 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 31.0465%;&quot;&gt;&lt;b&gt; 목적 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 26.8605%;&quot;&gt;&lt;b&gt;LOW&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.9767%;&quot;&gt;복잡도가 낮은 구간&lt;/td&gt;
&lt;td style=&quot;width: 31.0465%;&quot;&gt;절감 가능성 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 26.8605%;&quot;&gt;&lt;b&gt;AVERAGE_1&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.9767%;&quot;&gt;평균적인 구간 1&lt;/td&gt;
&lt;td style=&quot;width: 31.0465%;&quot;&gt;일반 품질 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 26.8605%;&quot;&gt;&lt;b&gt;AVERAGE_2&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.9767%;&quot;&gt;평균적인 구간 2&lt;/td&gt;
&lt;td style=&quot;width: 31.0465%;&quot;&gt;평균 구간 편향 완화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 26.8605%;&quot;&gt;&lt;b&gt;SCENE_CHANGE&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.9767%;&quot;&gt;장면 전환이 뚜렷한 구간&lt;/td&gt;
&lt;td style=&quot;width: 31.0465%;&quot;&gt;전환 구간 품질 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 26.8605%;&quot;&gt;&lt;b&gt;HIGH&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.9767%;&quot;&gt;복잡도가 높은 구간&lt;/td&gt;
&lt;td style=&quot;width: 31.0465%;&quot;&gt;품질 붕괴 방지&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;대표 구간을 영상의 난이도 분포를 나눠서 보는 샘플로 구성하고자 했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1498&quot; data-origin-height=&quot;416&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wLorR/dJMcafmSUyD/dLVVEedvp0BsMTkm3H7Kvk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wLorR/dJMcafmSUyD/dLVVEedvp0BsMTkm3H7Kvk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wLorR/dJMcafmSUyD/dLVVEedvp0BsMTkm3H7Kvk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwLorR%2FdJMcafmSUyD%2FdLVVEedvp0BsMTkm3H7Kvk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1498&quot; height=&quot;416&quot; data-origin-width=&quot;1498&quot; data-origin-height=&quot;416&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2-2. 구간 분석 방식&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;해당 구간의 복잡도가 얼마나 되는지, 장면 전환은 어떻게 되는지 등을 사람이 확인할 수는 없었습니다. 많은 영상을 모두 할 수 없을 뿐더러 직접 고르면 기준이 흔들리고, 나중에 자동화하기 어렵기 때문입니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;영상 분석 지표를 기준으로 구간을 분석했습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 126px;&quot; border=&quot;1&quot; data-ke-style=&quot;style8&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.6511%; height: 21px;&quot;&gt;&lt;b&gt; 지표 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.8138%; height: 21px;&quot;&gt;&lt;b&gt; 역할 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.4188%;&quot;&gt;&lt;b&gt; 사용 방식 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.6511%; height: 21px;&quot;&gt;&lt;b&gt;SI&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.8138%; height: 21px;&quot;&gt;공간 복잡도, 디테일/질감&lt;/td&gt;
&lt;td style=&quot;width: 34.4188%;&quot; rowspan=&quot;2&quot;&gt;FFmpeg로 영상을 디코딩하면서 프레임별 공간/시간 복잡도 계산&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.6511%; height: 21px;&quot;&gt;&lt;b&gt;TI&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.8138%; height: 21px;&quot;&gt;시간 복잡도, 움직임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.6511%; height: 21px;&quot;&gt;&lt;b&gt;Scene Change Density&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.8138%; height: 21px;&quot;&gt;장면 전환 구간 선택&lt;/td&gt;
&lt;td style=&quot;width: 34.4188%;&quot;&gt;FFmpeg scene change 계열 필터로 컷 변화 감지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.6511%; height: 21px;&quot;&gt;&lt;b&gt;Black Frame Ratio&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.8138%; height: 21px;&quot;&gt;검은 화면/무의미 구간 제외&lt;/td&gt;
&lt;td style=&quot;width: 34.4188%;&quot;&gt;FFmpeg blackdetect 계열로 검은 구간 감지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.6511%; height: 21px;&quot;&gt;&lt;b&gt;Luma Mean&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.8138%; height: 21px;&quot;&gt;극단적으로 어둡거나 밝은 무정보 구간 보조 판단&lt;/td&gt;
&lt;td style=&quot;width: 34.4188%;&quot;&gt;FFmpeg signalstats 계열로 밝기 평균 확인&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;이 지표들을 이용해 각 구간의 복잡도를 계산했습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;대표 구간 추출 흐름&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1002&quot; data-origin-height=&quot;620&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lQsNG/dJMcadih2mk/EliKn12pbMww9kCcqlngbk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lQsNG/dJMcadih2mk/EliKn12pbMww9kCcqlngbk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lQsNG/dJMcadih2mk/EliKn12pbMww9kCcqlngbk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlQsNG%2FdJMcadih2mk%2FEliKn12pbMww9kCcqlngbk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1002&quot; height=&quot;620&quot; data-origin-width=&quot;1002&quot; data-origin-height=&quot;620&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5단계를 거쳤으며, 각 단계에서 이상치를 줄이려고 노력했습니다. 구간들이 너무 붙어있거나 크레딧이나 의미 없는 장면이 대표 구간으로 선택될 수 있었고, 이들은 압축 난이도를 대표하기 어렵기 때문입니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2-3. 한계&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표 구간 방식은 전체 영상을 완전히 대체하지 않기 때문에 대표 구간 밖에서 품질이 무너질 가능성은 항상 남아 있습니다. 그래서 대표 구간은 후보 탐색에만 사용했고, 최종 선택된 ladder는 반드시 전체 영상으로 다시 검증했습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;대표 구간의 역할은 최종 판단이 아니라 탐색 비용 절감으로 후보 Ladder를 빠르게 찾는 것입니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 인코딩 조합 후보 생성 및 인코딩&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상마다 대표 구간 5개를 뽑은 후 이제 이 구간들을 실제 인코딩 후보로 펼쳐야 합니다. PTE에서 필요한 것은 '이 영상은 복잡하다/단순하다'는 판단이 아닌 실제로 여러 설정으로 인코딩했을 때, &lt;b&gt;각 설정이 어느 정도 bitrate와 품질을 만드는지&lt;/b&gt;입니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;그래서 이 단계에서는 대표 구간을 &lt;b&gt;해상도와 CRF 조합으로 인코딩&lt;/b&gt;해 후보군을 만들었습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3-1. 후보 축 결정: 해상도 &amp;times; CRF&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;후보는 해상도와 CRF 두 축으로 만들었습니다.&lt;br /&gt;해상도는 360p/720p/1080p 3개로, CRF는 18/21/24/27/30 5개로 정했습니다. CRF 값이 낮을수록 화질이 좋고 bitrate가 커집니다. 해상도는 업스케일하지 않아 원본이 720p이라면 최대 제공 해상도는 720p로 설정됩니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;CRF란?&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;CRF(Constant Rate Factor)는 목표 비트레이트가 아니라 목표 화질 수준을 정해 인코딩하는 방식&lt;/b&gt;입니다.&lt;br /&gt;PTE에서 CRF를 쓴 이유는 영상마다 복잡도가 다르기 때문에 같은 bitrate로 후보를 찍는 것보다 &lt;b&gt;CRF로 여러 화질 단계를 만들면 영상별 bitrate&amp;ndash;quality 관계&lt;/b&gt;를 더 자연스럽게 관찰할 수 있기 때문입니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;후보 생성 방식으로 고정 bitrate, 2-pass ABR, CRF, VMAF Target 방식을 검토했습니다. 고정 bitrate 방식은 ladder 제어가 쉽지만 영상별 복잡도를 반영하기 어렵고, 2-pass 방식은 목표 bitrate 정확도는 높지만 후보 수가 많은 PTE 실험에서는 인코딩 비용이 커집니다. VMAF Target 방식은 품질 기준이 명확하지만 반복 인코딩과 측정 비용이 큽니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;따라서 초기 후보 생성 단계에서는 CRF를 사용해 동일한 화질 수준에서 영상 복잡도에 따라 자연스럽게 bitrate가 달라지도록 했고, 이후 각 후보의 실제 bitrate와 VMAF를 측정해 Convex Hull 기반으로 비효율 후보를 제거했습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3-2. 후보 수&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2장에서 선택한 대표구간은 영상마다 5개입니다.&lt;br /&gt;각 구간마다 3개 해상도와 5개 CRF를 조합했습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 영상을 모두 실험했기 때문에 총 후보 수는 75 x 3 == 225개가 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1439&quot; data-origin-height=&quot;218&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bPAI11/dJMcagzgi1I/qSKF9h5AdUXJPpXNW0reJ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bPAI11/dJMcagzgi1I/qSKF9h5AdUXJPpXNW0reJ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bPAI11/dJMcagzgi1I/qSKF9h5AdUXJPpXNW0reJ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbPAI11%2FdJMcagzgi1I%2FqSKF9h5AdUXJPpXNW0reJ1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1439&quot; height=&quot;218&quot; data-origin-width=&quot;1439&quot; data-origin-height=&quot;218&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 그림처럼 이 75개가 그대로 최종 R-D Curve의 점이 되는 것은 아닙니다. &lt;b&gt;각 구간&lt;/b&gt;은 &lt;b&gt;같은 해상도와 같은 CRF끼리 나중에 합산&lt;/b&gt;됩니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;구간 인코딩 방식&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 빠르게 진행하기 위해 5개 대표구간을 하나의 클립으로 이어붙인 뒤, 15개 후보만 인코딩하는 방식을 생각했습니다. 하지만, 구간을 이어붙이면 새로운 문제가 생길 수 있습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-style=&quot;style8&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; 문제 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 설명 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;PTS 정렬 위험&lt;/td&gt;
&lt;td&gt;구간마다 시간축이 끊기고 다시 붙으면서 PTS reset/offset 문제가 생길 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;가짜 장면 전환&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;원래 이어지지 않는 장면을 붙이면서 &lt;b&gt;인위적인 컷이 생김&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;원인 추적 어려움&lt;/td&gt;
&lt;td&gt;특정 후보가 나쁠 때 어느 구간 때문인지 분리하기 어려움&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 PTS 정렬 문제로 VMAF가 크게 흔들리는 것을 겪었습니다..ㅜㅜ...&lt;br /&gt;따라서 PTE 후보 실험에서 다시 시간축 리스크를 만들 이유가 없었기에&amp;nbsp;구간을 이어붙이지 않고, 각 구간을 독립적으로 인코딩했습니다.&lt;br /&gt;가장 큰 문제는 대표 구간에서 다른 대표 구간으로 이어지는 부분에 어색한 장면 전환이 발생할 것이라 생각했고, 이것이 점수에 영향을 줄 것이라고 판단했습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3-3. 원본과 결과물 클립&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VMAF 측정을 하려면 &lt;b&gt;원본 역할을 하는 reference&lt;/b&gt;와, &lt;b&gt;인코딩 결과물인 distorted&lt;/b&gt;가 필요합니다.&lt;br /&gt;이 단계에서는 먼저 대표구간마다 reference clip을 뽑아낸 후&amp;nbsp;reference clip을 해상도와 CRF 조합으로 인코딩해 distorted clip을 만들었습니다.&lt;br /&gt;이렇게 한 이유는 &lt;b&gt;정렬 안정성&lt;/b&gt; 때문입니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-style=&quot;style8&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; 방식 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 문제 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;후보마다 원본에서 직접 seek&lt;/td&gt;
&lt;td&gt;매번 잘리는 위치가 미세하게 달라질 수 있음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;reference clip을 한 번 만들고 재사용&lt;/td&gt;
&lt;td&gt;모든 후보가 같은 입력에서 파생되어 프레임 대응이 안정적&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1153&quot; data-origin-height=&quot;635&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/BA2u5/dJMcagTBaSH/43UTXdHGqpau9fdSQHEIsk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/BA2u5/dJMcagTBaSH/43UTXdHGqpau9fdSQHEIsk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/BA2u5/dJMcagTBaSH/43UTXdHGqpau9fdSQHEIsk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FBA2u5%2FdJMcagTBaSH%2F43UTXdHGqpau9fdSQHEIsk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;352&quot; data-origin-width=&quot;1153&quot; data-origin-height=&quot;635&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;이 구조 덕분에 이후 VMAF 측정에서 '서로 다른 구간을 비교하는 문제'를 줄일 수 있었습니다!!&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. VMAF 기반 R-D Curve 그리기&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;이제 후보들의 VMAF 점수를 측정해서 R-D Curve의 점을 만들어야 합니다. 각 후보가 실제로 어느 정도 bitrate를 만들고, 어느 정도 품질을 내는지 파악하기 위함입니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;'이 후보가 실제로 얼마나 많은 용량을 쓰는가'를 나타내는 bitrate와 '이 후보가 원본 대비 어느 정도 품질을 유지하는가'를 나타내는 VMAF 점수를 축으로 그래프 위에 점을 찍어 bitrate-quality 관계를 보고자 했습니다. 이 곡선은 bitrate가 증가할 때 품질이 어떻게 좋아지는지 나타내며, R-D Curve(Rate-Distortion Curve)라고 부른다고 합니다!!&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;원본에 맞춰 VMAF 측정&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1편에서는 HLS 결과물이 원본 대비 얼마나 잘 유지됐는지 보기 위해, 원본을 HLS 해상도로 낮춰 비교하는 Downscale VMAF를 사용했습니다.&lt;br /&gt;하지만 PTE에서는 서로 다른 해상도인 360p, 720p, 1080p 후보끼리 비교해야 합니다. 이때, 각 후보를 자기 해상도에서만 비교하면 &lt;b&gt;해상도 간 비교&lt;/b&gt;가 불가능해집니다.&lt;br /&gt;예를 들어 360p 후보는 원본을 360p로 낮춰 비교하고, 1080p 후보는 원본을 1080p로 낮춰 비교한다고 하면 신뢰할 수 없는 결과가 나타날 수 있습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;PTE에서는 실제 사용자가 보는 화면 기준에서 후보들을 비교해야 합니다. 그래서 &lt;b&gt;후보들을 공통 기준 해상도로 올려 비교&lt;/b&gt;했습니다. 해당 프로젝트의 정책으로 설정한 최대 품질인 1080p을 상한선으로 두고 후보들을 1080p 기준으로 비교했습니다. 물론 업스케일을 진행하지 않기 때문에 정확히는 1080p을 넘지 않는 한에서 원본과 1080p 중 더 낮은 것을 원본 기준으로 삼았습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; 4-1. 왜 R-D Curve가 필요한가?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;PTE는 여러 후보 중 VMAF가 가장 높은 것을 고르는 작업이 아닙니다. VMAF가 가장 높은 후보는 보통 bitrate도 가장 큽니다. 예를 들어 CRF 18은 품질은 좋지만 용량이 커질 가능성이 높습니다. 반대로 CRF 30은 용량은 작지만 품질이 떨어질 수 있습니다.&lt;br /&gt;그래서 '어느 지점부터 bitrate를 더 써도 품질 개선이 크지 않은가?'에 대한 판단이 필요합니다.&lt;br /&gt;예를 들어 아래와 같은 후보가 있다고 가정할 수 있습니다.&lt;/p&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 72px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 25.6977%; height: 21px;&quot;&gt;&lt;b&gt; 후보 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.6046%; height: 21px;&quot;&gt;&lt;b&gt; bitrate &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 35.5814%; height: 21px;&quot;&gt;&lt;b&gt; VMAF &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 25.6977%; height: 17px;&quot;&gt;A&lt;/td&gt;
&lt;td style=&quot;width: 38.6046%; height: 17px;&quot;&gt;1000k&lt;/td&gt;
&lt;td style=&quot;width: 35.5814%; height: 17px;&quot;&gt;88&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 25.6977%; height: 17px;&quot;&gt;B&lt;/td&gt;
&lt;td style=&quot;width: 38.6046%; height: 17px;&quot;&gt;2000k&lt;/td&gt;
&lt;td style=&quot;width: 35.5814%; height: 17px;&quot;&gt;94&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 25.6977%; height: 17px;&quot;&gt;C&lt;/td&gt;
&lt;td style=&quot;width: 38.6046%; height: 17px;&quot;&gt;4000k&lt;/td&gt;
&lt;td style=&quot;width: 35.5814%; height: 17px;&quot;&gt;95&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;A에서 B로 가면 bitrate는 1000k 늘고 VMAF는 6점 오릅니다. 하지만, B에서 C로 가면 bitrate는 2000k나 늘었는데 VMAF는 1점만 오릅니다. 이 경우 C는 품질은 조금 더 좋지만, &lt;b&gt;효율&lt;/b&gt;은 낮을 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;R-D Curve는 그래프를 통해 이러한 판단이 가능하도록 도와줍니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1498&quot; data-origin-height=&quot;664&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/brxlrN/dJMcaiw61Wa/xKPTIjGKedL9kxhKKKG11k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/brxlrN/dJMcaiw61Wa/xKPTIjGKedL9kxhKKKG11k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/brxlrN/dJMcaiw61Wa/xKPTIjGKedL9kxhKKKG11k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbrxlrN%2FdJMcaiw61Wa%2FxKPTIjGKedL9kxhKKKG11k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;319&quot; data-origin-width=&quot;1498&quot; data-origin-height=&quot;664&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4-2. 후보 75개를 R-D 점 15개로 바꾸기&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;영상마다 75개의 해상도-비트레이트 조합 후보가 존재합니다. 하지만, R-D Curve에서 보고 싶은 것은 '각 구간의 점'보다 '이 영상에서 720p CRF24는 어느 정도 효율인가?'입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 같은 해상도와 같은 CRF끼리 5개 구간을 합쳤습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;합산 방식&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;R-D Curve의 각 점은 bitrate와 VMAF를 가져야 합니다.&lt;br /&gt;bitrate는 CRF 값으로 추정하기보다 5개 구간의 실제 인코딩 결과를 합쳤고, VMAF은 5개 구간의 평균 품질로 계산했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;883&quot; data-origin-height=&quot;346&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lBRWL/dJMcahkIyQH/EKZkrTo08kwOeYxw9pNYjk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lBRWL/dJMcahkIyQH/EKZkrTo08kwOeYxw9pNYjk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lBRWL/dJMcahkIyQH/EKZkrTo08kwOeYxw9pNYjk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlBRWL%2FdJMcahkIyQH%2FEKZkrTo08kwOeYxw9pNYjk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;282&quot; data-origin-width=&quot;883&quot; data-origin-height=&quot;346&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;평균 VMAF가 높아도 특정 구간 하나가 크게 무너지면 실제 시청 품질은 나쁠 수 있습니다.&lt;br /&gt;그래서 이후 후보 선택 단계에서 worst segment와 P5 같은 하위 지표도 함께 확인할 수 있도록 기록했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4-3. 측정 결과&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;R-D Curve를 그려보니, 처음 예상처럼 단순히 저복잡도 영상은 항상 효율이 좋고, 고복잡도 영상은 항상 효율이 나쁘다로 정리되지는 않았습니다. 사용자 체감을 나타내는 VMAF를 사용하는 만큼 영상의 특성이 반영되었습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;BBB&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저복잡도 영상에서는 낮은 bitrate에서도 품질이 잘 유지될 것이라고 예상했고, 실제로 1080p에서는 그 경향이 뚜렷했습니다. 실제로 CRF30에서 CRF24까지는 bitrate를 늘릴수록 VMAF가 의미 있게 상승했지만, CRF24 이후부터는 증가폭이 줄어듭니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;889&quot; data-origin-height=&quot;558&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/3IVdu/dJMcadih9da/osvgwkUkd0XAfzgNrEUKqk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/3IVdu/dJMcadih9da/osvgwkUkd0XAfzgNrEUKqk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/3IVdu/dJMcadih9da/osvgwkUkd0XAfzgNrEUKqk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F3IVdu%2FdJMcadih9da%2FosvgwkUkd0XAfzgNrEUKqk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;560&quot; height=&quot;351&quot; data-origin-width=&quot;889&quot; data-origin-height=&quot;558&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BBB는 일정 수준 이후부터 bitrate를 더 써도 VMAF가 크게 오르지 않았습니다. 이 곡선은 기존 고정 ladder가 다소 보수적이었고, bitrate를 줄일 여지가 있다는 신호로 볼 수 있습니다. &lt;b&gt;전반적으로 비트레이트 효율이 꽤 높게 나타나는 것으로 분석&lt;/b&gt;할 수 있습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;다만, 360p에서는 VMAF가 낮게 나왔습니다. 이는 저복잡도 영상이라도 4K 애니메이션의 선명한 경계와 디테일이 360p로 줄어든 뒤 1080p 기준으로 비교되면 손실이 크게 반영되기 때문입니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Meridian&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;중간 복잡도 영상에서는 저복잡도 영상보다 더 많은 bitrate가 필요할 것이라고 예상했습니다. 하지만 R-D Curve에서 예상과 다른 부분이 있었습니다. 오히려 BBB보다 낮은 bitrate로 비슷한 VMAF에 도달했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;752&quot; data-origin-height=&quot;483&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b10L0Z/dJMb991gh2H/uYKX2JZoarhSqngxGNf1bK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b10L0Z/dJMb991gh2H/uYKX2JZoarhSqngxGNf1bK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b10L0Z/dJMb991gh2H/uYKX2JZoarhSqngxGNf1bK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb10L0Z%2FdJMb991gh2H%2FuYKX2JZoarhSqngxGNf1bK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;560&quot; height=&quot;360&quot; data-origin-width=&quot;752&quot; data-origin-height=&quot;483&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;Meridian이 BBB보다 더 효율적이지만, Meridian이 전체적으로 더 쉬운 영상이라는 뜻은 아닙니다.&lt;br /&gt;Meridian&lt;span style=&quot;background-color: #fdfdfc;&quot;&gt;&lt;span style=&quot;color: #0a0a0a;&quot;&gt;은 어두운 SF로, 화면에 검은 영역이 많은 영상입니다. 검은 부분은 압축 비용이 저렴해서&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #fdfdfc;&quot;&gt;&lt;span style=&quot;color: #0a0a0a;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;적은 비트레이트로도 인코딩이 가능했기 때문에 예상했던 BBB와 Tears 사이로 나타나지 않은 것입니다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;Meridian은 &lt;b&gt;낮은 구간에서 비트레이트 효율이 매우 높게&lt;/b&gt; 나타난 대신, &lt;b&gt;천장이 좀 낮고&lt;/b&gt; &lt;b&gt;높은 해상도 구간에서의 효율은 조금 낮다&lt;/b&gt;고 분석할 수 있습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;이는 복잡도 라벨과 VMAF 효율이 완전히 같은 개념이 아니기 때문입니다. Meridian은 실사 영상이지만 중간 품질대에서는 압축 손실이 VMAF에 덜 불리하게 나타났고, BBB는 애니메이션 특유의 선명한 경계와 디테일 손실이 VMAF에 더 민감하게 반영되었습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, BBB는 약 3.6Mbps에서 VMAF 95.76까지 올라갔지만, Meridian은 약 4.3Mbps를 사용해도 VMAF 94.17에 머물렀습니다. Meridian의 곡선은 중간 품질대까지는 빠르게 올라가지만, 고품질 구간에서는 빨리 둔화되는 모습을 보입니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Tears&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;고복잡도 영상에서는 VFX, 장면 변화, 디테일이 많기 때문에 같은 품질을 유지하려면 더 많은 bitrate가 필요할 것이라고 예상했고, R-D Curve도 이 방향을 보여줬습니다. 같은 CRF24 기준으로 BBB 1080p는 약 3.6Mbps에서 VMAF 95.76이었지만, Tears는 약 4.8Mbps를 사용해도 VMAF 95.56으로 비슷한 품질에 머물렀습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;764&quot; data-origin-height=&quot;476&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ov1mp/dJMcadWQtDv/I4lqzJGLxvk1g7Q0vX3EJ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ov1mp/dJMcadWQtDv/I4lqzJGLxvk1g7Q0vX3EJ1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ov1mp/dJMcadWQtDv/I4lqzJGLxvk1g7Q0vX3EJ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fov1mp%2FdJMcadWQtDv%2FI4lqzJGLxvk1g7Q0vX3EJ1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;560&quot; height=&quot;349&quot; data-origin-width=&quot;764&quot; data-origin-height=&quot;476&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tears는 같은 품질을 얻기 위해 BBB보다 더 많은 bitrate가 필요했습니다. 특히 360p와 720p에서는 낮은 bitrate로 줄이면 품질 하락이 커져, 단순 절감보다 품질 방어가 더 중요한 영상으로 볼 수 있습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;다만, 1080p에서는 CRF24 근처에서 VMAF 95점대를 확보했기 때문에, &lt;b&gt;고화질 구간은 무조건 bitrate를 늘리는 것이 아니라 품질을 유지하는 선에서 일부 절감 여지&lt;/b&gt;도 있었습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;한 번에 보기&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1098&quot; data-origin-height=&quot;582&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfB4rM/dJMcaiKzQ0Q/SaQKnBYSVEyDSa3SoCPIS0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfB4rM/dJMcaiKzQ0Q/SaQKnBYSVEyDSa3SoCPIS0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfB4rM/dJMcaiKzQ0Q/SaQKnBYSVEyDSa3SoCPIS0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfB4rM%2FdJMcaiKzQ0Q%2FSaQKnBYSVEyDSa3SoCPIS0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;318&quot; data-origin-width=&quot;1098&quot; data-origin-height=&quot;582&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;세 영상의 그래프를 한 번에 보고 주요 포인트를 뽑아봤습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;VMAF 95 라인 교차:&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;BBB &amp;asymp; 3,600k / Tears &amp;asymp; 4,800k / Meridian 도달 불가.&lt;/li&gt;
&lt;li&gt;&amp;rarr; 같은 95점을 만드는 비용이 영상마다 다름.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;곡선 교차(~2,500k 부근):&lt;/b&gt; 저비트레이트에선 Meridian이 위(효율적)지만, 그 지점을 지나면 천장에 막혀 BBB&amp;middot;Tears가 추월. '저비트레이트 효율 &amp;ne; 고품질 가능'을 한 그림이 보여줌.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;수확&amp;nbsp;체감(무릎):&lt;/b&gt; 세&amp;nbsp;곡선&amp;nbsp;다&amp;nbsp;초반&amp;nbsp;급상승&amp;nbsp;&amp;rarr;&amp;nbsp;90&amp;nbsp;부근부터&amp;nbsp;완만.&amp;nbsp;특히&amp;nbsp;95&amp;nbsp;위로는&amp;nbsp;비트레이트를&amp;nbsp;크게&amp;nbsp;써도&amp;nbsp;거의&amp;nbsp;안&amp;nbsp;오름.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&lt;b&gt;PTE와의 연결&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;BBB: 95를 baseline(5,069k)보다 싸게(3,638k) 달성 &amp;rarr; 절감&lt;/li&gt;
&lt;li&gt;Tears: 95에 4,800k 필요 &amp;asymp; baseline(5,157k) &amp;rarr; 더 못 깎음 &amp;rarr; 방어&lt;/li&gt;
&lt;li&gt;Meridian: 1080p 천장 94 &amp;asymp; baseline(4,165k, 94.46) &amp;rarr; 1080 유지, 곡선이 낮은 360/720만 절감&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 후보 추리고 해상도별 Ladder 선택하기&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 필요한 것은 위에서 그린&amp;nbsp;점들 중에서 &lt;b&gt;실제 &lt;/b&gt;&lt;b&gt;ladder &lt;/b&gt;&lt;b&gt;후보로 &lt;/b&gt;&lt;b&gt;볼 &lt;/b&gt;&lt;b&gt;만한 &lt;/b&gt;&lt;b&gt;점만 &lt;/b&gt;&lt;b&gt;남기는 &lt;/b&gt;&lt;b&gt;것&lt;/b&gt;입니다.&lt;br /&gt;후보는 많지만, 모든 후보가 의미 있는 것은 아닙니다. 어떤 후보는 더 많은 bitrate를 쓰면서도 VMAF가 낮기도 한데, 이런 후보는 실제 서비스 ladder에 들어갈 이유가 없습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;저는 Pareto 지배 제거와 Convex Hull이라는 두 단계를 거쳐 후보를 줄였습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5-1. Pareto 지배 제거 &lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 Pareto 기준으로 &lt;b&gt;명백히 비효율적인 후보를 제거&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;909&quot; data-origin-height=&quot;397&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pfEV3/dJMcajo8sSP/pBUcbITBW5191j8XL8qt8K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pfEV3/dJMcajo8sSP/pBUcbITBW5191j8XL8qt8K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pfEV3/dJMcajo8sSP/pBUcbITBW5191j8XL8qt8K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpfEV3%2FdJMcajo8sSP%2FpBUcbITBW5191j8XL8qt8K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;580&quot; height=&quot;253&quot; data-origin-width=&quot;909&quot; data-origin-height=&quot;397&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;A 후보가 B 후보보다 bitrate는 낮거나 같고,VMAF는 높거나 같다면 B 후보는 선택할 이유가 없습니다. 같은 품질이면 더 작은 파일이 좋고, 같은 bitrate면 더 높은 품질이 좋습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;예를 들어 아래와 같은 후보가 있다고 하면&lt;/p&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 23.4884%;&quot;&gt;&lt;b&gt; 후보 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.6744%;&quot;&gt;&lt;b&gt; Bitrate &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 38.7209%;&quot;&gt;&lt;b&gt; VMAF &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 23.4884%;&quot;&gt;&lt;b&gt;A&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.6744%;&quot;&gt;2,000kbps&lt;/td&gt;
&lt;td style=&quot;width: 38.7209%;&quot;&gt;92&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 23.4884%;&quot;&gt;&lt;b&gt;B&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.6744%;&quot;&gt;2,400kbps&lt;/td&gt;
&lt;td style=&quot;width: 38.7209%;&quot;&gt;91&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;B는 A보다 bitrate를 더 쓰면서 VMAF는 낮습니다. 따라서 B는 제거할 수 있습니다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;Pareto 제거 후 남은 후보 수는 다음과 같습니다.&lt;/p&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 72px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 24.5349%;&quot;&gt;&lt;b&gt; 영상 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 39.3023%;&quot;&gt;&lt;b&gt; 전체 R-D 점 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 36.0465%;&quot;&gt;&lt;b&gt; Pareto 통과 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 24.5349%; height: 17px;&quot;&gt;&lt;b&gt;BBB&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 39.3023%; height: 17px;&quot;&gt;15&lt;/td&gt;
&lt;td style=&quot;width: 36.0465%; height: 17px;&quot;&gt;11&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 24.5349%; height: 17px;&quot;&gt;&lt;b&gt;Meridian&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 39.3023%; height: 17px;&quot;&gt;15&lt;/td&gt;
&lt;td style=&quot;width: 36.0465%; height: 17px;&quot;&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 24.5349%; height: 17px;&quot;&gt;&lt;b&gt;Tears&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 39.3023%; height: 17px;&quot;&gt;15&lt;/td&gt;
&lt;td style=&quot;width: 36.0465%; height: 17px;&quot;&gt;12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;업스케일해서 비교했고, 이 단계의 목적은 최종 후보를 고르는 것이 아니라 &lt;b&gt;명백히 손해인 후보를 먼저 제거하는 것&lt;/b&gt;이기 때문에 &lt;span style=&quot;color: #333333;&quot;&gt;해상도 간 교차 비교가 가능했습니다.&lt;/span&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt; 5-2. Convex Hull &lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pareto 제거 후에도 후보가 여전히 많이 남았습니다. Pareto 후보는 '명백히 나쁘지는 않은 점'일 뿐 모두 효율적인 점은 아니므로 다음 단계로 Convex Hull을 사용했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&lt;b&gt;Convex Hull&lt;/b&gt;은 &lt;b&gt;R-D Curve&lt;/b&gt;에서 &lt;b&gt;품질 대비 bitrate 효율이 좋은 바깥 경계선을 찾는 과정&lt;/b&gt;입니다. 쉽게 말하면, 여러 점 중에서 실제 효율 곡선을 만드는 점만 남기는 것입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1667&quot; data-origin-height=&quot;838&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bAvXrn/dJMcaglJZIH/mBS4ZOERdCHns6p0SKJqr0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bAvXrn/dJMcaglJZIH/mBS4ZOERdCHns6p0SKJqr0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bAvXrn/dJMcaglJZIH/mBS4ZOERdCHns6p0SKJqr0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbAvXrn%2FdJMcaglJZIH%2FmBS4ZOERdCHns6p0SKJqr0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;302&quot; data-origin-width=&quot;1667&quot; data-origin-height=&quot;838&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 점을 연결시켜 기울기를 비교합니다. 왼쪽 점부터 시작해서 점1-점3의 기울기보다 점1-점2의 기울기가 작다면(완만함) 효율이 낮다는 뜻이고, 기울기가 크다면(가파름) 효율이 좋다는 뜻입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;점 세 개를 이었을 때 기울기가 '가파름-완만함'이면 가운데 점의 효율이 좋고, '완만함-가파름'이면 가운데 점의 효율이 좋지 않다고 정리할 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 점을 이어보면 아래처럼 Pareto, Convex Hull을 그래프로 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;907&quot; data-origin-height=&quot;395&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/prTGQ/dJMcah55aKk/eWOESlG8iQ3ssyR4XFt5Rk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/prTGQ/dJMcah55aKk/eWOESlG8iQ3ssyR4XFt5Rk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/prTGQ/dJMcah55aKk/eWOESlG8iQ3ssyR4XFt5Rk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FprTGQ%2FdJMcah55aKk%2FeWOESlG8iQ3ssyR4XFt5Rk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;580&quot; height=&quot;253&quot; data-origin-width=&quot;907&quot; data-origin-height=&quot;395&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;판단 순서를 아래 5단계로 정리해보았습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;1. Pareto 통과 점들을 bitrate 낮은 순서로 정렬한다.
2. 왼쪽부터 점을 하나씩 이어 선을 만든다. 
	- 이웃한 점 사이의 기울기로, 비트레이트를 더 쓸 때 VMAF가 얼마나 오르는 지 확인하기 위함이다.
3. 새 점을 이었을 때, 가운데 점이 그 선보다 아래에 있으면 제거한다.
4. 이걸 끝까지 반복한다.
5. 남은 점들이 bitrate를 더 쓸 가치가 있는 효율 경계선이다.&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;위 순서로 Convex Hull까지 적용한 결과는 다음과 같습니다.&lt;/p&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; 영상 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 전체 R-D 점 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; Pareto 통과 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; Convex Hull &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;BBB&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;11&lt;/td&gt;
&lt;td&gt;8&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Meridian&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;Tears&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;15&lt;/td&gt;
&lt;td&gt;12&lt;/td&gt;
&lt;td&gt;10&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;15개의 후보 점 중에서 실제 ladder 선택에 참고할 효율 후보는 영상별로 8~10개 정도로 줄었습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5-3. 왜 Pareto만으로는 부족한가?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pareto만 적용하면 &amp;ldquo;명백히 손해인 후보&amp;rdquo;는 제거할 수 있습니다. 하지만 효율이 애매한 후보는 남습니다.&lt;br /&gt;예를 들어 어떤 후보가 아래처럼 있을 때,&lt;/p&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;후보&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; Bitrate &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; VMAF &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;A&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;1,000kbps&lt;/td&gt;
&lt;td&gt;88&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;B&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;1,500kbps&lt;/td&gt;
&lt;td&gt;90&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;C&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;2,000kbps&lt;/td&gt;
&lt;td&gt;91&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;C는 B보다 bitrate도 높고 VMAF도 높기 때문에 Pareto 기준으로는 제거되지 않습니다. 하지만 B에서 C로 갈 때 bitrate는 500kbps 늘었는데 VMAF는 1점만 올랐습니다. 이 경우 C가 무조건 나쁘다고 할 수는 없지만, 효율이 좋은 지점인지는 따져봐야 합니다. Convex Hull은 이런 후보를 효율선 기준으로 다시 걸러줍니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;정리하면 Pareto는 더 많이 쓰고도 더 나쁜 후보를 제거하는 것이고, Convex Hull은 남은 후보 중 효율선 안쪽에 있는 후보를 제거하는 것입니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5-4. 해상도별 ladder 선택&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Pareto와 Convex Hull로 후보를 줄인 뒤에도 바로 최종 ladder가 정해지지는 않습니다.&lt;br /&gt;실제 스트리밍 ladder는 360p, 720p, 1080p처럼 해상도별 rung이 필요합니다. 따라서 최종 선택은 전체 &lt;b&gt;R-D Curve에서 효율적인 후보를 보되, 각 해상도별로 하나씩 선택&lt;/b&gt;해야 합니다.&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;저는 아래와 같은 기준으로 선택했습니다.&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;1. baseline(PTE 적용 x) 품질을 크게 해치지 않을 것
2. 가능하면 더 낮은 bitrate를 선택할 것
3. 품질이 부족한 영상은 bitrate를 줄이지 말고 방어할 것
4. 각 해상도별 ladder를 유지할 것&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;무조건 bitrate를 줄이는 것이 아니라, 영상별로 판단했으며, 아래와 같이 정리했습니다.&lt;/p&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; 상황 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 선택 방향 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기존 bitrate가 과한 경우&lt;/td&gt;
&lt;td&gt;더 낮은 bitrate 후보 선택&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기존 bitrate가 적절한 경우&lt;/td&gt;
&lt;td&gt;비슷한 품질의 최소 bitrate 선택&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;기존 bitrate가 부족한 경우&lt;/td&gt;
&lt;td&gt;bitrate를 늘려 품질 방어&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;결정된 영상 &amp;amp; 해상도 Ladder&lt;/b&gt;&lt;/h4&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 84px;&quot; border=&quot;1&quot; data-ke-style=&quot;style8&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.4884%; height: 21px;&quot;&gt;&lt;b&gt; 영상 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 17.7907%; height: 21px;&quot;&gt;&lt;b&gt; 360p &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 16.3953%; height: 21px;&quot;&gt;&lt;b&gt; 720p &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 16.3953%; height: 21px;&quot;&gt;&lt;b&gt; 1080p &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 30.9302%; height: 21px;&quot;&gt;&lt;b&gt; 선택 결과 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.4884%; height: 21px;&quot;&gt;&lt;b&gt;BBB&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 17.7907%; height: 21px;&quot;&gt;CRF24&lt;/td&gt;
&lt;td style=&quot;width: 16.3953%; height: 21px;&quot;&gt;CRF24&lt;/td&gt;
&lt;td style=&quot;width: 16.3953%; height: 21px;&quot;&gt;CRF24&lt;/td&gt;
&lt;td style=&quot;width: 30.9302%; height: 21px;&quot;&gt;세 해상도 모두 bitrate 절감&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.4884%; height: 21px;&quot;&gt;&lt;b&gt;Meridian&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 17.7907%; height: 21px;&quot;&gt;CRF18&lt;/td&gt;
&lt;td style=&quot;width: 16.3953%; height: 21px;&quot;&gt;CRF18&lt;/td&gt;
&lt;td style=&quot;width: 16.3953%; height: 21px;&quot;&gt;CRF18&lt;/td&gt;
&lt;td style=&quot;width: 30.9302%; height: 21px;&quot;&gt;360p/720p 절감, 1080p 유지&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.4884%; height: 21px;&quot;&gt;&lt;b&gt;Tears&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 17.7907%; height: 21px;&quot;&gt;CRF21&lt;/td&gt;
&lt;td style=&quot;width: 16.3953%; height: 21px;&quot;&gt;CRF21&lt;/td&gt;
&lt;td style=&quot;width: 16.3953%; height: 21px;&quot;&gt;CRF24&lt;/td&gt;
&lt;td style=&quot;width: 30.9302%; height: 21px;&quot;&gt;360p/720p 품질 방어, 1080p 소폭 절감&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;BBB는 CRF24에서도 baseline 대비 품질 하락이 JND 미만으로 유지되어, 세 해상도 모두 절감 후보로 선택되었습니다.&lt;br /&gt;Meridian은 360p/720p는 절감 가능했지만, baseline 품질과의 차이를 줄이기 위해 후보 중 가장 높은 품질인 CRF18을 선택했고 1080p는 거의 유지에 가까웠습니다.&lt;br /&gt;Tears는 360p/720p는 기존 bitrate가 부족해 CRF21로 품질을 방어했고, 1080p만 CRF24에서 품질 유지와 소폭 절감 가능합니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. Per-Title-Encoding 적용 전/후 비교&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종으로 선택한 ladder를 실제 전체 영상에 적용해 검증했습니다.&lt;br /&gt;앞 단계까지는 대표 구간 5개를 기반으로 후보를 비교했지만, 대표 구간에서 좋아 보인 선택이 전체 영상에서도 유지되는지는 별도로 확인해야 합니다. 그래서 최종 ladder만 전체 영상으로 인코딩한 뒤, baseline과 같은 조건으로 VMAF를 다시 측정했습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;고정 Ladder로 인코딩한 결과와 PTE를 적용한 선택 Ladder 결과의 전체 영상을 1080p로 업스케일하여 VMAF 측정했습니다. 품질에 대한 판단은 Baseline 대비 VMAF 변화량이고, 용량에 대한 판단은 Bitrate 변화량으로 진행했습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6-1. 전체 결과&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;결과는 영상별로 명확하게 나타났습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;비트레이트 비교&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;860&quot; data-origin-height=&quot;626&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnKBVl/dJMcagTBhhn/3GyrjZFMvfMRWnizHpCsg0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnKBVl/dJMcagTBhhn/3GyrjZFMvfMRWnizHpCsg0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnKBVl/dJMcagTBhhn/3GyrjZFMvfMRWnizHpCsg0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbnKBVl%2FdJMcagTBhhn%2F3GyrjZFMvfMRWnizHpCsg0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;860&quot; height=&quot;626&quot; data-origin-width=&quot;860&quot; data-origin-height=&quot;626&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;모든 영상에서 bitrate가 줄어든 것은 아니고, 영상의 성격에 따라 결과가 달라졌습니다.&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;BBB      &amp;rarr; 기존 ladder가 과해서 크게 줄일 수 있음
Meridian &amp;rarr; 일부 줄일 수 있지만 고화질 구간은 유지에 가까움
Tears    &amp;rarr; 기존 ladder가 부족해 일부 해상도는 오히려 늘려야 함&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;이 결과가 무조건 bitrate를 줄이는 작업이 아니라, 영상마다 적절한 품질-용량 균형점을 찾는 작업인 &lt;span style=&quot;color: #333333;&quot;&gt;PTE의 핵심을 잘 보여줍니다.&lt;/span&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;VMAF 비교&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;708&quot; data-origin-height=&quot;497&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/csFgJ0/dJMcahrtdiE/abfrN7686zT3un4JtC13q0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/csFgJ0/dJMcahrtdiE/abfrN7686zT3un4JtC13q0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/csFgJ0/dJMcahrtdiE/abfrN7686zT3un4JtC13q0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcsFgJ0%2FdJMcahrtdiE%2FabfrN7686zT3un4JtC13q0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;580&quot; height=&quot;407&quot; data-origin-width=&quot;708&quot; data-origin-height=&quot;497&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;품질 변화가 전부 JND(&amp;plusmn;6) 이내로 나타났습니다. 고정 래더에서 목표 품질 달성에 부족했던 건 품질을 높였고, 품질에 여유가 있었던 건 품질을 낮추어 용량을 줄이는 방안으로 진행되었습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6-2. BBB 결과&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;BBB는 저복잡도 영상으로, 가장 큰 절감 효과가 나왔습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-style=&quot;style8&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; 해상도 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; Baseline bitrate &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; PTE bitrate &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 변화율 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; Baseline VMAF &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; PTE VMAF &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; VMAF 변화 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;360p&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;959k&lt;/td&gt;
&lt;td&gt;602.5k&lt;/td&gt;
&lt;td&gt;&lt;b&gt;-37.2%&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;65.76&lt;/td&gt;
&lt;td&gt;62.09&lt;/td&gt;
&lt;td&gt;-3.67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;720p&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;2641k&lt;/td&gt;
&lt;td&gt;1747.6k&lt;/td&gt;
&lt;td&gt;&lt;b&gt;-33.8%&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;90.95&lt;/td&gt;
&lt;td&gt;88.68&lt;/td&gt;
&lt;td&gt;-2.27&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;1080p&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;5069k&lt;/td&gt;
&lt;td&gt;3185.9k&lt;/td&gt;
&lt;td&gt;&lt;b&gt;-37.2%&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;96.72&lt;/td&gt;
&lt;td&gt;95.17&lt;/td&gt;
&lt;td&gt;-1.55&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BBB는 세 해상도 모두 CRF24가 선택되었습니다. 전체 bitrate는 약 36.1% 줄었고, VMAF 하락은 모두 JND 기준인 6점 이내였습니다.&lt;br /&gt;특히, 1080p는 bitrate를 약 37% 줄였지만 VMAF는 1.55점만 낮아졌습니다. 기존 고정 ladder가 BBB에는 다소 과하게 잡혀 있었던 것이고, 품질을 크게 해치지 않으면서 bitrate를 줄일 수 있었습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6-3. Meridian 결과&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;Meridian은 중간 복잡도 영상으로, 일부 절감 효과가 나왔습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-style=&quot;style8&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; 해상도 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; Baseline bitrate &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; PTE bitrate &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 변화율 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; Baseline VMAF &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; PTE VMAF &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; VMAF 변화 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;360p&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;862k&lt;/td&gt;
&lt;td&gt;490.1k&lt;/td&gt;
&lt;td&gt;&lt;b&gt;-43.1%&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;75.55&lt;/td&gt;
&lt;td&gt;73.88&lt;/td&gt;
&lt;td&gt;-1.67&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;720p&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;2215k&lt;/td&gt;
&lt;td&gt;1682.5k&lt;/td&gt;
&lt;td&gt;&lt;b&gt;-24.0%&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;90.59&lt;/td&gt;
&lt;td&gt;90.13&lt;/td&gt;
&lt;td&gt;-0.46&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;1080p&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;4165k&lt;/td&gt;
&lt;td&gt;4171.3k&lt;/td&gt;
&lt;td&gt;&lt;b&gt;+0.15%&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;94.46&lt;/td&gt;
&lt;td&gt;94.65&lt;/td&gt;
&lt;td&gt;+0.19&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Meridian은 세 해상도 모두 CRF18이 선택되었습니다. 360p와 720p에서는 bitrate가 줄었지만, 1080p는 baseline과 거의 같은 수준으로 유지되었습니다.&lt;br /&gt;즉 Meridian은 &amp;ldquo;무조건 크게 줄일 수 있는 영상&amp;rdquo;은 아니었습니다. 낮은 해상도에서는 절감 여지가 있었지만, 고화질 구간에서는 기존 ladder가 이미 적정에 가까웠습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6-4. Tears 결과&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tears는 고복잡도 영상으로, 전체 bitrate가 오히려 증가했습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-style=&quot;style8&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; 해상도 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; Baseline bitrate &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; PTE bitrate &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 변화율 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; Baseline VMAF &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; PTE VMAF &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; VMAF 변화 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;360p&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;960k&lt;/td&gt;
&lt;td&gt;1182k&lt;/td&gt;
&lt;td&gt;&lt;b&gt;+23.1%&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;67.71&lt;/td&gt;
&lt;td&gt;73.33&lt;/td&gt;
&lt;td&gt;+5.62&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;720p&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;2645k&lt;/td&gt;
&lt;td&gt;3467k&lt;/td&gt;
&lt;td&gt;&lt;b&gt;+31.1%&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;90.69&lt;/td&gt;
&lt;td&gt;93.31&lt;/td&gt;
&lt;td&gt;+2.62&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;1080p&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;5157k&lt;/td&gt;
&lt;td&gt;4704k&lt;/td&gt;
&lt;td&gt;&lt;b&gt;-8.8%&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;95.52&lt;/td&gt;
&lt;td&gt;95.40&lt;/td&gt;
&lt;td&gt;-0.12&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;ears는 360p와 720p에서 bitrate가 증가했습니다. 이는 실패라기보다, 기존 고정 ladder가 이 영상의 낮은 해상도 품질을 충분히 방어하지 못했다는 의미입니다.&lt;br /&gt;반면 1080p에서는 bitrate를 약 8.8% 줄이면서도 VMAF는 거의 유지되며, 해상도별로 판단이 달랐습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;6-5. 대표 구간은 신뢰성이 있었는가?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞선 단계에서 전체 영상을 전부 후보 탐색하지 않고, 대표 구간 5개로 후보를 고른 뒤 최종 ladder만 전체 영상으로 검증했습니다. 이에 따라 대표 구간의 신뢰성도 검증할 필요가 있었습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-style=&quot;style8&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 16.7442%;&quot;&gt;&lt;b&gt; 영상 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.4419%;&quot;&gt;&lt;b&gt; 해상도 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 31.1628%;&quot;&gt;&lt;b&gt; 대표 구간 예측 VMAF &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 27.907%;&quot;&gt;&lt;b&gt; 전체 영상 VMAF &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 11.6279%;&quot;&gt;&lt;b&gt; 차이 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 16.7442%;&quot; rowspan=&quot;3&quot;&gt;&lt;b&gt;BBB&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.4419%;&quot;&gt;360p&lt;/td&gt;
&lt;td style=&quot;width: 31.1628%;&quot;&gt;60.59&lt;/td&gt;
&lt;td style=&quot;width: 27.907%;&quot;&gt;62.09&lt;/td&gt;
&lt;td style=&quot;width: 11.6279%;&quot;&gt;+1.50&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.4419%;&quot;&gt;720p&lt;/td&gt;
&lt;td style=&quot;width: 31.1628%;&quot;&gt;90.38&lt;/td&gt;
&lt;td style=&quot;width: 27.907%;&quot;&gt;88.68&lt;/td&gt;
&lt;td style=&quot;width: 11.6279%;&quot;&gt;-1.70&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.4419%;&quot;&gt;1080p&lt;/td&gt;
&lt;td style=&quot;width: 31.1628%;&quot;&gt;95.76&lt;/td&gt;
&lt;td style=&quot;width: 27.907%;&quot;&gt;95.17&lt;/td&gt;
&lt;td style=&quot;width: 11.6279%;&quot;&gt;-0.59&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 16.7442%;&quot; rowspan=&quot;3&quot;&gt;&lt;b&gt;Meridian&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.4419%;&quot;&gt;360p&lt;/td&gt;
&lt;td style=&quot;width: 31.1628%;&quot;&gt;74.40&lt;/td&gt;
&lt;td style=&quot;width: 27.907%;&quot;&gt;73.88&lt;/td&gt;
&lt;td style=&quot;width: 11.6279%;&quot;&gt;-0.52&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.4419%;&quot;&gt;720p&lt;/td&gt;
&lt;td style=&quot;width: 31.1628%;&quot;&gt;89.93&lt;/td&gt;
&lt;td style=&quot;width: 27.907%;&quot;&gt;90.13&lt;/td&gt;
&lt;td style=&quot;width: 11.6279%;&quot;&gt;+0.20&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.4419%;&quot;&gt;1080p&lt;/td&gt;
&lt;td style=&quot;width: 31.1628%;&quot;&gt;94.17&lt;/td&gt;
&lt;td style=&quot;width: 27.907%;&quot;&gt;94.65&lt;/td&gt;
&lt;td style=&quot;width: 11.6279%;&quot;&gt;+0.48&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 16.7442%;&quot; rowspan=&quot;3&quot;&gt;&lt;b&gt;Tears&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 12.4419%;&quot;&gt;360p&lt;/td&gt;
&lt;td style=&quot;width: 31.1628%;&quot;&gt;72.36&lt;/td&gt;
&lt;td style=&quot;width: 27.907%;&quot;&gt;73.33&lt;/td&gt;
&lt;td style=&quot;width: 11.6279%;&quot;&gt;+0.97&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.4419%;&quot;&gt;720p&lt;/td&gt;
&lt;td style=&quot;width: 31.1628%;&quot;&gt;93.72&lt;/td&gt;
&lt;td style=&quot;width: 27.907%;&quot;&gt;93.31&lt;/td&gt;
&lt;td style=&quot;width: 11.6279%;&quot;&gt;-0.41&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.4419%;&quot;&gt;1080p&lt;/td&gt;
&lt;td style=&quot;width: 31.1628%;&quot;&gt;95.56&lt;/td&gt;
&lt;td style=&quot;width: 27.907%;&quot;&gt;95.40&lt;/td&gt;
&lt;td style=&quot;width: 11.6279%;&quot;&gt;-0.16&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;결과는 대체로 신뢰성 있는 대표 구간임이 나타났습니다. 모든 차이가 대략 &amp;plusmn;1.7점 안에 들어왔고, 대표 구간 기반의 판단이 전체 영상에서도 크게 벗어나지 않았습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;888&quot; data-origin-height=&quot;508&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kzAJS/dJMcaay56Ty/ZELUUCzBKKONg5XSrHFEgK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kzAJS/dJMcaay56Ty/ZELUUCzBKKONg5XSrHFEgK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kzAJS/dJMcaay56Ty/ZELUUCzBKKONg5XSrHFEgK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkzAJS%2FdJMcaay56Ty%2FZELUUCzBKKONg5XSrHFEgK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;580&quot; height=&quot;332&quot; data-origin-width=&quot;888&quot; data-origin-height=&quot;508&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;y=x 그래프를 예측 = 실측으로 두고 점을 찍어 확인해보면 크게 벗어나지 않는 것을 확인할 수 있습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;7. 마무리&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;영상 처리를 접해본 적이 없어서 팀 프로젝트 기간 동안 회의를 정말 많이 했었는데, 기획을 좀 줄이고 아예 이러한 부분을 팀원들과 함께 더 깊게 공부해봤으면 좋았겠다는 아쉬움이 있습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;영상하면 역시 넷플릭스가 떠올라서 기술 블로그도 종종 봤었는데, 하나같이 다 거대하고 어려워서 프로젝트에 적용하기엔 어려웠습니다. 참고한 글도 처음엔 난해하고 어려웠는데, 지하철 오가며 여러 번 반복해서 보니까 좀 익숙해지는 것 같았습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;테스트를 정말 많이 했습니다. 부하 테스트나 쿼리 돌려보는 건 많이 해보기도 했고 익숙했는데, 이렇게 직접 가설을 세우고 뽑아낼 수치를 정하고, 비교군을 설정하여 계속해서 숫자를 만들어가는 건 조금 낯설었습니다. 하지만, 이 과정에서 작업의 목적과 제가 확인하고 싶은 게 무엇인지 여러 번 생각해 볼 수 있어 방향을 잃지 않았던 것 같습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;역시나 실제 결과로 확인하는 게 정말정말 중요한 것을 느꼈습니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;br /&gt;참고 자료: &lt;a href=&quot;https://netflixtechblog.com/per-title-encode-optimization-7e99442b62a2&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;span&gt;https://netflixtechblog.com/per-title-encode-optimization-7e99442b62a2&lt;/span&gt;&lt;/a&gt;&lt;/p&gt;
&lt;figure data-ke-type=&quot;opengraph&quot; data-og-title=&quot;Per-Title Encode Optimization&quot; data-ke-align=&quot;alignCenter&quot; data-og-description=&quot;delivering the same or better experience while using less bandwidth&quot; data-og-host=&quot;netflixtechblog.com&quot; data-og-source-url=&quot;https://netflixtechblog.com/per-title-encode-optimization-7e99442b62a2&quot; data-og-image=&quot;https://blog.kakaocdn.net/dna/xtIU9/dJMb8U83WLj/AAAAAAAAAAAAAAAAAAAAAMMGkGwsQnbEVqSXRPWxbDcw31UUMrayxFNUY8Rc3cg-/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1782831599&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=8%2Fy7p5alj8YxtJL2GcpIiHK38Nw%3D&quot; data-og-url=&quot;https://netflixtechblog.com/per-title-encode-optimization-7e99442b62a2&quot;&gt;&lt;a href=&quot;https://netflixtechblog.com/per-title-encode-optimization-7e99442b62a2&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://netflixtechblog.com/per-title-encode-optimization-7e99442b62a2&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://blog.kakaocdn.net/dna/xtIU9/dJMb8U83WLj/AAAAAAAAAAAAAAAAAAAAAMMGkGwsQnbEVqSXRPWxbDcw31UUMrayxFNUY8Rc3cg-/img.png?credential=yqXZFxpELC7KVnFOS48ylbz2pIh7yKj8&amp;amp;expires=1782831599&amp;amp;allow_ip=&amp;amp;allow_referer=&amp;amp;signature=8%2Fy7p5alj8YxtJL2GcpIiHK38Nw%3D');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Per-Title Encode Optimization&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;delivering the same or better experience while using less bandwidth&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;netflixtechblog.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;</description>
      <category>Project</category>
      <author>phonil</author>
      <guid isPermaLink="true">https://yestomo.tistory.com/27</guid>
      <comments>https://yestomo.tistory.com/27#entry27comment</comments>
      <pubDate>Fri, 26 Jun 2026 14:40:13 +0900</pubDate>
    </item>
    <item>
      <title>영상은 어떻게 전달되는가</title>
      <link>https://yestomo.tistory.com/26</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;0. 개요&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상 업로드, 트랜스코딩, 스트리밍 가능한 OTT 프로젝트를 진행하면서 영상 처리에 사용되는 개념들을 처음 접했습니다. 익숙하지 않았고 비슷한 개념들이 많았기 때문에 뭐가 뭔지 파악하는 것부터 난관이었습니다. 이번 글에서는 아키텍처나 세세한 설정, 개념이 아닌 &lt;b&gt;영상 처리&lt;/b&gt;에 있어 필수적인 &lt;b&gt;기본 개념들을 소개&lt;/b&gt;하는 느낌으로 정리해보았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜스코딩이나 영상 처리 개발 관련 개념을 빠르게 보고 싶으신 분은 &lt;b&gt;'2-3 코덱과 컨테이너&amp;rsquo;&lt;/b&gt; 부분부터 보시면 됩니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 동영상이란&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;동영상&lt;/b&gt;은 &lt;b&gt;여러 이미지(영상)들과 오디오, 자막 등 부가 데이터의 조합&lt;/b&gt;입니다. 이렇게 생각하면 모호하게 느껴지니 이미지부터 하나씩 알아보고 이들을 조합해보겠습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1-1. 프레임과 비트레이트&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;프레임&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동영상에서는 사진을 빠르게 이어 붙여 사람에게 움직이는 것처럼 보이게 합니다. 우리 눈은 1초에 24장 이상의 그림이 바뀌면 움직이는 것으로 인식한다고 합니다.&lt;br /&gt;학창 시절에 책 모서리에 그림을 그려놓고 빠르게 파바박 넘겼을 때 움직이는 것처럼 보였던 것과 같습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;220&quot; data-origin-height=&quot;161&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ckXgxV/dJMcafGVSBV/6pmRBVxGIYWSmFEwPd6bPk/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ckXgxV/dJMcafGVSBV/6pmRBVxGIYWSmFEwPd6bPk/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ckXgxV/dJMcafGVSBV/6pmRBVxGIYWSmFEwPd6bPk/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/ckXgxV/dJMcafGVSBV/6pmRBVxGIYWSmFEwPd6bPk/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;360&quot; height=&quot;263&quot; data-origin-width=&quot;220&quot; data-origin-height=&quot;161&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이때, &lt;b&gt;한 장의 정지 영상을&lt;/b&gt; &lt;b&gt;프레임(Frame)&lt;/b&gt;이라고 하며, &lt;b&gt;영상 1초에 들어가는 프레임의 수&lt;/b&gt;를 &lt;b&gt;프레임레이트(FPS, Frames Per Second)&lt;/b&gt;라고 합니다. 게임에서 네트워크가 느릴 때 fps를 보는데, 바로 이것입니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;비트레이트&lt;/b&gt;&amp;nbsp;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;비트레이트&lt;/b&gt;는 &lt;b&gt;1초당 사용하는 데이터의 양&lt;/b&gt;입니다. 단위는 bps(bits per second)를 사용하며, 영상에서는 주로 Kbps나 Mbps 단위가 쓰입니다. 영상 품질과 파일 크기를 결정하는 요소로, 비트레이트가 높을수록 빠른 것이 아니라 고품질이며 파일 용량이 큽니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;850&quot; data-origin-height=&quot;461&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNWHTA/dJMcaaMmvay/zg7anCvkjsoPfhKcIV6eHk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNWHTA/dJMcaaMmvay/zg7anCvkjsoPfhKcIV6eHk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNWHTA/dJMcaaMmvay/zg7anCvkjsoPfhKcIV6eHk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNWHTA%2FdJMcaaMmvay%2Fzg7anCvkjsoPfhKcIV6eHk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;580&quot; height=&quot;315&quot; data-origin-width=&quot;850&quot; data-origin-height=&quot;461&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비트레이트가 높을수록 영상을 표현하기 위한 데이터가 더 많이 필요합니다. 하지만, 그만큼 초당 전송해야 할 데이터의 양이 많아지기 때문에 네트워크 속도가 빨라야 합니다. 느린 네트워크 환경에서 높은 비트레이트의 영상을 재생하면 데이터를 충분히 받지 못하게 되어 버퍼링이 발생하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1-2. 픽셀과 해상도&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한 장의 이미지인 프레임은 다시 아주 작은 블럭들로 이루어져 있으며,이 블럭들을 &lt;b&gt;픽셀(Pixel)&lt;/b&gt;이라고 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;859&quot; data-origin-height=&quot;429&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cmXeVb/dJMcac4sfmx/nonBn8YCPaAsysKJd0ytnk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cmXeVb/dJMcac4sfmx/nonBn8YCPaAsysKJd0ytnk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cmXeVb/dJMcac4sfmx/nonBn8YCPaAsysKJd0ytnk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcmXeVb%2FdJMcac4sfmx%2FnonBn8YCPaAsysKJd0ytnk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;620&quot; height=&quot;310&quot; data-origin-width=&quot;859&quot; data-origin-height=&quot;429&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서 각 픽셀은 색을 나타내고, &lt;b&gt;한 프레임에 들어갈 수 있는 픽셀 수&lt;/b&gt;가 &lt;b&gt;해상도&lt;/b&gt;입니다.&lt;br /&gt;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;색을 저장하는 방법&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;저에겐 미술이 너무 어렵지만, 색을 나타내는 가장 직관적인 방식은 빛의 삼원색을 섞는 방식이라고 배웠던 기억이 있습니다. &lt;b&gt;RGB&lt;/b&gt;를 얼마씩 섞느냐로 모든 색을 만들 수 있습니다. 각 색마다 1바이트씩 사용하며, &lt;b&gt;픽셀 1개당 3바이트&lt;/b&gt;를 차지합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;561&quot; data-origin-height=&quot;309&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8AmuH/dJMcahEGH0j/Ss5Et11s1bx8HudjSwaP51/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8AmuH/dJMcahEGH0j/Ss5Et11s1bx8HudjSwaP51/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8AmuH/dJMcahEGH0j/Ss5Et11s1bx8HudjSwaP51/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8AmuH%2FdJMcahEGH0j%2FSs5Et11s1bx8HudjSwaP51%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;220&quot; data-origin-width=&quot;561&quot; data-origin-height=&quot;309&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;하지만, 픽셀 하나가 3byte를 차지하게 되므로 영상이 길어질수록 용량이 어마어마하게 늘어날 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;픽셀당 3바이트로 FHD 영상 1시간 용량을 계산해 보면 아래와 같습니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #14181f;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt; 픽셀 수      : 1920 &amp;times; 1080  &amp;asymp; 207만 픽셀
 픽셀당 색    : 3 byte (RGB)
 프레임 1장   : 207만 &amp;times; 3   &amp;asymp; 6.2 MB
 1초(30fps)   : 6.2 MB &amp;times; 30 &amp;asymp; 187 MB
 1시간        : 187 MB &amp;times; 3600 &amp;asymp; 660 GB&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;이렇게 데이터를 있는 그대로 저장하는 것이 어렵기 때문에 영상에서는 RGB 대신 YUV와 크로마 서브샘플링 방식을 사용해서 크기를 줄입니다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;YUV와 크로마 서브샘플링&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;YUV&lt;/b&gt;는 &lt;b&gt;밝기(Y)와 색차(U, V)를 분리해서 표현&lt;/b&gt;합니다.&lt;br /&gt;&lt;br /&gt;사람 눈은 밝기 변화에는 민감하지만 색 변화에는 둔감합니다. 그래서 밝기는 꼼꼼히, 색은 대충 저장해도 사람은 차이를 거의 못 느낍니다. 문제는 RGB는 색과 밝기가 한 덩어리로 섞여 있어 &amp;lsquo;색만 골라서 줄이는 것&amp;rsquo;이 불가능하다는 점입니다.&lt;br /&gt;&lt;br /&gt;그래서 먼저 YUV로 밝기와 색을 분리해 둡니다. 이렇게 떼어 놓으면 밝기(Y)는 그대로 두고 색(U, V)만 줄일 수 있습니다.&lt;br /&gt;&lt;br /&gt;이때, &lt;b&gt;&amp;lsquo;색 정보만 줄이는 기술&amp;rsquo;&lt;/b&gt;을 &lt;b&gt;크로마 서브샘플링&lt;/b&gt;이라고 합니다. 주로 색상 정보를 2&amp;times;2 픽셀당 1개씩만 저장하는 4:2:0 비율을 사용합니다.(해상도가 짝수인 이유)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;850&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cGgbBl/dJMcabqZKWb/punpsyxUvGm9S64kHzZb0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cGgbBl/dJMcabqZKWb/punpsyxUvGm9S64kHzZb0K/img.png&quot; data-alt=&quot;크로마 서브샘플링&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cGgbBl/dJMcabqZKWb/punpsyxUvGm9S64kHzZb0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcGgbBl%2FdJMcabqZKWb%2FpunpsyxUvGm9S64kHzZb0K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;440&quot; height=&quot;292&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;850&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;크로마 서브샘플링&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 압축: 줄이고 표현하는 방법&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;압축의 본질은 &amp;lsquo;똑같거나, 사람이 못 느끼는 정보를 반복 저장하지 않는 것&amp;rsquo;입니다. 주로 용량을 줄이기 위한 목적으로 사용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;손실 압축과 무손실 압축이 있는데, &lt;b&gt;영상&lt;/b&gt;은 대부분 &lt;b&gt;손실 압축&lt;/b&gt;이 사용됩니다. 화질을 약간 희생해서 용량을 많이 줄이기 위함입니다. 따라서 여러 번 반복되면 화질이 저하되어 화질구지가 될 수 있습니다.&lt;br /&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2-1. 네 가지 압축 관점&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 105px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.7984%; height: 21px;&quot;&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 39.4961%; height: 21px;&quot;&gt;&lt;b&gt;의미&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 41.7054%; height: 21px;&quot;&gt;&lt;b&gt;예시&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.7984%; height: 21px;&quot;&gt;&lt;b&gt;공간적 압축&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 39.4961%; height: 21px;&quot;&gt;한 프레임 안의 중복 제거&lt;/td&gt;
&lt;td style=&quot;width: 41.7054%; height: 21px;&quot;&gt;하늘처럼 비슷한 색 영역 압축&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.7984%; height: 21px;&quot;&gt;&lt;b&gt;시간적 압축&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 39.4961%; height: 21px;&quot;&gt;프레임 사이의 중복 제거&lt;/td&gt;
&lt;td style=&quot;width: 41.7054%; height: 21px;&quot;&gt;이전 프레임과 달라진 부분만 저장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.7984%; height: 21px;&quot;&gt;&lt;b&gt;지각적 압축&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 39.4961%; height: 21px;&quot;&gt;사람이 잘 못 느끼는 정보 줄이기&lt;/td&gt;
&lt;td style=&quot;width: 41.7054%; height: 21px;&quot;&gt;색 정보 일부 줄이기, 미세한 디테일 제거&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.7984%; height: 21px;&quot;&gt;&lt;b&gt;엔트로피 압축&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 39.4961%; height: 21px;&quot;&gt;남은 데이터를 더 짧은 코드로 표현&lt;br /&gt;(표현 자체를 더 효율적으로 줄임)&lt;/td&gt;
&lt;td style=&quot;width: 41.7054%; height: 21px;&quot;&gt;자주 나오는 값은 짧게, 드문 값은 길게 저장&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;사진과 다르게 &lt;b&gt;영상&lt;/b&gt;은 시간이라는 축이 존재하기 때문에 &lt;b&gt;시간적 압축&lt;/b&gt;을 통해 사진 묶음보다 훨씬 효율적으로 압축될 수 있습니다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;오디오 압축(AAC&amp;middot;MP3&amp;middot;Opus)&lt;/b&gt;도 똑같이 사람 귀의 약점을 이용합니다. 큰 소리에 묻혀 안 들리는 작은 소리, 사람이 못 듣는 주파수 대역을 버리는 방식입니다.(심리음향 압축). '압축 == 사람 지각의 약점을 이용한다.'는 원리는 영상의 색(눈)이든 오디오(귀)든 동일하게 적용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2-2. I&amp;middot;P&amp;middot;B프레임과 GOP&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;영상에는 프레임이 여러 장 존재한다는 특성을 살려 &lt;b&gt;프레임을 세 역할로 나누어 시간적 압축을 활용&lt;/b&gt;할 수 있습니다.&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;544&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dO52XD/dJMcahEGIDj/g1BQLTrYoAiM6tMTZbw4V0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dO52XD/dJMcahEGIDj/g1BQLTrYoAiM6tMTZbw4V0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dO52XD/dJMcahEGIDj/g1BQLTrYoAiM6tMTZbw4V0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdO52XD%2FdJMcahEGIDj%2Fg1BQLTrYoAiM6tMTZbw4V0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;580&quot; height=&quot;544&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;544&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 105px;&quot; border=&quot;1&quot; data-ke-style=&quot;style8&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.7984%; height: 21px;&quot;&gt;&lt;b&gt;구분&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 56.9379%; height: 21px;&quot;&gt;&lt;b&gt;의미&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.2636%; height: 21px;&quot;&gt;&lt;b&gt;예시&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.7984%; height: 21px;&quot;&gt;&lt;b&gt;I 프레임&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 56.9379%; height: 21px;&quot;&gt;- 다른 프레임을 참조하지 않고 &lt;b&gt;자기 자신만으로 복원 가능한 프레임&lt;/b&gt;&lt;br /&gt;- 용량은 크지만 탐색/재생 시작 기준이 됨&lt;/td&gt;
&lt;td style=&quot;width: 24.2636%; height: 21px;&quot;&gt;장면의 시작점, 탐색 기준점&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.7984%; height: 21px;&quot;&gt;&lt;b&gt;P 프레임&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 56.9379%; height: 21px;&quot;&gt;이전 프레임을 참고해서 &lt;b&gt;변화한 부분 중심으로 저장하는 프레임&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.2636%; height: 21px;&quot;&gt;사람이 조금 움직인 부분만 저장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.7984%; height: 21px;&quot;&gt;&lt;b&gt;B &lt;b&gt;프레임&lt;/b&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 56.9379%; height: 21px;&quot;&gt;이전 프레임과 이후 프레임을 모두 참고해서 &lt;b&gt;더 효율적으로 압축하는 프레임&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.2636%; height: 21px;&quot;&gt;움직임이 중간에 있는 프레임&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 18.7984%; height: 21px;&quot;&gt;&lt;b&gt;GOP&lt;br /&gt;(Group Of Pictures)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 56.9379%; height: 21px;&quot;&gt;- 하나의 I 프레임을 기준으로 이어지는 &lt;b&gt;프레임 묶음&lt;br /&gt;&lt;/b&gt;- 보통 다음 I 프레임 전까지를 하나의 GOP로 봄&lt;/td&gt;
&lt;td style=&quot;width: 24.2636%; height: 21px;&quot;&gt;I B B P B B P 한 묶음&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;GOP가 길면 I 프레임이 적기 때문에 용량은 줄지만 중간 탐색&amp;middot;오류 복원이 불리하고, 짧으면 용량이 늘어납니다. 또한, &lt;b&gt;I 프레임의 위치&lt;/b&gt;는 ABR이 화질을 바꿀 때 중요한 조건으로 사용됩니다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2-3. 코덱과 컨테이너&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;코덱 (Codec)&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;앞에서 계속 살펴본 것처럼 동영상 원본의 크기는 너무 거대합니다. 그래서 저장하거나 전송하기 좋게 줄여 사용하는데, 이때 어떤 방식으로 줄이고 어떤 방식으로 다시 풀지 정한 규칙이 &lt;b&gt;코덱&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;362&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/voJzj/dJMcaar556p/jJ2LceOGliz0S7hKkcsAs0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/voJzj/dJMcaar556p/jJ2LceOGliz0S7hKkcsAs0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/voJzj/dJMcaar556p/jJ2LceOGliz0S7hKkcsAs0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvoJzj%2FdJMcaar556p%2FjJ2LceOGliz0S7hKkcsAs0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;255&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;362&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;덱은 Coder와 Decoder의 합성어로, 영상을 압축하고 다시 복원하기 위한 규칙입니다. &lt;br /&gt;&lt;br /&gt;H.264 같은 영상 코덱은 공간적 중복, 시간적 중복을 줄이고 I&amp;middot;P&amp;middot;B 프레임 구조, 예측, 변환, 양자화, 엔트로피 부호화 같은 과정을 통해 데이터를 줄입니다. 보통 YUV 계열 표현과 크로마 서브샘플링을 함께 활용해 사람이 덜 민감한 색 정보도 효율적으로 저장합니다.&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;대표적인 비디오 코덱들은 아래와 같습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 85px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 18.1007%; height: 17px;&quot;&gt;&lt;b&gt; 코덱 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 44.4961%; height: 17px;&quot;&gt;&lt;b&gt; 특징 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 37.4031%; height: 17px;&quot;&gt;&lt;b&gt; 같은 화질일 때 용량 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 18.1007%; height: 17px;&quot;&gt;&lt;b&gt; H.264 (AVC) &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 44.4961%; height: 17px;&quot;&gt;가장 널리 쓰임, 호환성 최고. 사실상 기본값&lt;/td&gt;
&lt;td style=&quot;width: 37.4031%; height: 17px;&quot;&gt;기준 (100%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 18.1007%; height: 17px;&quot;&gt;&lt;b&gt; H.265 (HEVC) &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 44.4961%; height: 17px;&quot;&gt;H.264의 약 절반 용량, 라이선스 비용 이슈&lt;/td&gt;
&lt;td style=&quot;width: 37.4031%; height: 17px;&quot;&gt;~50%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 18.1007%; height: 17px;&quot;&gt;&lt;b&gt; VP9 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 44.4961%; height: 17px;&quot;&gt;구글, 로열티 무료&lt;/td&gt;
&lt;td style=&quot;width: 37.4031%; height: 17px;&quot;&gt;~50%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 18.1007%; height: 17px;&quot;&gt;&lt;b&gt; AV1 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 44.4961%; height: 17px;&quot;&gt;차세대, 무료&amp;middot;고압축. 인코딩 매우 느림&lt;/td&gt;
&lt;td style=&quot;width: 37.4031%; height: 17px;&quot;&gt;~30~40%&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;코덱은 알고리즘은 호환성, 용량, 인코딩/디코딩 속도 등 용도에 따라 선택해야 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;text-align: left;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;컨테이너&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;영상 파일&lt;/b&gt;은 &lt;b&gt;컨테이너(포장 상자) + 코덱(압축 방식)&lt;/b&gt;으로 이뤄집니다.&lt;br /&gt;코덱은 &amp;ldquo;어떻게 압축할지&amp;rdquo;만 정하기 때문에 압축된 영상&amp;middot;오디오&amp;middot;자막을 하나로 묶는 포장 상자가 따로 필요합니다.&lt;br /&gt;&lt;br /&gt;&lt;b&gt;컨테이너&lt;/b&gt;는 &lt;b&gt;비디오, 오디오, 자막, 메타데이터를 하나의 파일에 담는 규격&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;653&quot; data-origin-height=&quot;332&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SeM6U/dJMcadPNCSo/lEmpdSJbarayyD3g1kghQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SeM6U/dJMcadPNCSo/lEmpdSJbarayyD3g1kghQ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SeM6U/dJMcadPNCSo/lEmpdSJbarayyD3g1kghQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSeM6U%2FdJMcadPNCSo%2FlEmpdSJbarayyD3g1kghQ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;480&quot; height=&quot;332&quot; data-origin-width=&quot;653&quot; data-origin-height=&quot;332&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;같은 .mp4 확장자라도 내부 코덱이 다를 수 있고, 같은 H.264 코덱이라도 MP4, MKV, TS 등 다른 컨테이너에 담길 수 있습니다.&lt;br /&gt;&lt;br /&gt;여기서 비디오&amp;middot;오디오 등 &lt;b&gt;여러 트랙을 하나의 컨테이너로 묶는 행위&lt;/b&gt;를 &lt;b&gt;먹싱(Muxing)&lt;/b&gt;이라 합니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.6977%;&quot;&gt;&lt;b&gt;컨테이너&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 84.186%;&quot;&gt;&lt;b&gt;특징 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.6977%;&quot;&gt;&lt;b&gt;MP4&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 84.186%;&quot;&gt;호환성 최고, 웹&amp;middot;모바일 표준&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.6977%;&quot;&gt;&lt;b&gt;MKV&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 84.186%;&quot;&gt;다중 트랙&amp;middot;자막 자유로움&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.6977%;&quot;&gt;&lt;b&gt;MOV&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 84.186%;&quot;&gt;애플이 개발한 멀티미디어 비디오 컨테이너 포맷&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.6977%;&quot;&gt;&lt;b&gt;TS&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 84.186%;&quot;&gt;방송&amp;middot;스트리밍 전송용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;코덱을 모르면 파일을 열어볼 수 없기 때문에 &lt;b&gt;같은 .mp4라도 안에 든 코덱이 다르면 재생이 안 될 수 있습니다&lt;/b&gt;.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 인코딩, 디코딩 그리고 트랜스코딩&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3-1. 인코딩과 디코딩&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상에서 &lt;b&gt;인코딩(Encoding)&lt;/b&gt;은 원본 영상 데이터를 &lt;b&gt;코덱 규칙에 따라 압축하는 과정&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인코딩은 연산량이 매우 많아서 오래 걸리고 CPU를 많이 사용합니다. 같은 코덱이라도 '얼마나 공들여 압축하느냐'에 따라 결과가 달라지는데, 이때 쓰이는 요소들을 조절하여 &lt;b&gt;영상의 품질과 속도, 용량을 결정&lt;/b&gt;할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞에서 살펴본 압축 방식들이 인코딩에 포함됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;b&gt; 1)&lt;/b&gt; RGB &amp;rarr; YUV + 크로마 서브샘플링 (지각적 중복) &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span&gt; 2&lt;/span&gt;&lt;/b&gt;&lt;span&gt;&lt;b&gt;)&lt;/b&gt; 프레임을 I&amp;middot;P&amp;middot;B로 나눠 차이만 (시간적 중복) &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;b&gt; 3)&lt;/b&gt; 한 프레임 안 비슷한 영역 요약 (공간적 중복)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;디코딩(Decoding)&lt;/b&gt;은 압축된 영상을 풀어 화면에 그릴 픽셀 프레임으로 되돌리는 과정입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상 재생기는 그 코덱의 디코더를 반드시 가져야 합니다. 없으면 소리는 나는데 화면이 안 나오거나 재생 불가 현상이 발생할 수 있습니다. 바로 이 문제 때문에 트랜스코딩이 필요해집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3-2. 트랜스코딩&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;트랜스코딩&lt;/b&gt;은 &lt;b&gt;이미 인코딩된 영상&lt;/b&gt;을 &lt;b&gt;디코딩 후&amp;nbsp;다른 코덱/포맷/해상도/비트레이트로 변환&lt;/b&gt;하여 &lt;b&gt;다시 인코딩&lt;/b&gt;하는 작업입니다. &lt;b&gt;스트리밍 환경&lt;/b&gt;에서 꼭 필요한 과정으로,&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;다양한 환경의 사용자가 영상 시청을 할 수 있도록 돕습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;635&quot; data-origin-height=&quot;375&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bg4ZKU/dJMcagluWau/h9843N9ZRKKVa7ZDZePy8K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bg4ZKU/dJMcagluWau/h9843N9ZRKKVa7ZDZePy8K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bg4ZKU/dJMcagluWau/h9843N9ZRKKVa7ZDZePy8K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbg4ZKU%2FdJMcagluWau%2Fh9843N9ZRKKVa7ZDZePy8K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;620&quot; height=&quot;366&quot; data-origin-width=&quot;635&quot; data-origin-height=&quot;375&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜스코딩은 이미 압축된 것을 풀어서 또 다른 압축본으로 만드는데, 이미 손실된 걸 또 손실 압축하므로 화질이 더 떨어질 수 있습니다(세대 손실).&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;FFmpeg&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;트랜스코딩(디코딩 &amp;rarr; 재인코딩)을 실제 코드로 실행할 때 사실상 표준처럼 쓰이는 도구가 &lt;b&gt;FFmpeg&lt;/b&gt;입니다. 오픈소스 커맨드라인 도구이고 거의 모든 코덱&amp;middot;컨테이너를 다룹니다. 유튜브&amp;middot;넷플릭스를 비롯한 수많은 서비스의 내부 파이프라인에 들어가 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1338&quot; data-origin-height=&quot;423&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PP11D/dJMcabkf6sV/FlGpVKTCntMjd85lJNK5z1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PP11D/dJMcabkf6sV/FlGpVKTCntMjd85lJNK5z1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PP11D/dJMcabkf6sV/FlGpVKTCntMjd85lJNK5z1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPP11D%2FdJMcabkf6sV%2FFlGpVKTCntMjd85lJNK5z1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;228&quot; data-origin-width=&quot;1338&quot; data-origin-height=&quot;423&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래는 input.mp4 파일을 1080p, 영상은 H.264, 오디오는 AAC로 트랜스코딩하여 output_1080p.mp4 파일로 변환하는 명령어입니다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #14181f;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;ffmpeg -i input.mp4 -vf scale=-2:1080 -c:v libx264 -c:a aac output_1080p.mp4&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FFmpeg은 필터, 고급 기술, 하드웨어 가속 등 여러 기능을 제공합니다!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. 전달: 스트리밍&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상을 시청자에게 보내는 방식은 &lt;b&gt;파일을 건네줄 것인지 흘려보낼 것인지&lt;/b&gt;에 따라 다운로드와 스트리밍 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;두 가지로 나눌 수 있습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4-1. 다운로드와 스트리밍&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;다운로드&lt;/b&gt;는 영상 파일 하나를 통째로 보냅니다. 시청자 받은 파일은 내 것이 되고, 다 받아야 온전히 쓸 수 있습니다. 그런데 영상은 파일이 크다 보니 다운로드 안에서도 &lt;b&gt;다 받고 나서 재생하는 방식&lt;/b&gt;과 &lt;b&gt;받는 중에 앞부분부터 재생하는 방식(Progressive Download)&lt;/b&gt;으로 갈립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼에도 영상을 받으면서 재생하긴 하지만 결국 하나의 통파일을 처음부터 순서대로 받는 것이라 중간으로 건너뛰기(seek)도 불편하고 &lt;b&gt;화질을 바꿀 수 없습니다.&lt;/b&gt; 스트리밍을 통해 이 문제를 해결할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;스트리밍&lt;/b&gt;은 영상 재생을 위해 전체 파일을 보내지 말고, &lt;b&gt;하나의 영상 파일을 잘게 쪼개 필요한 만큼만 주는 방식&lt;/b&gt;입니다. 스트리밍을 위해서는 조각들에 대한 정보가 담긴 &lt;b&gt;플레이리스트&lt;/b&gt;를 먼저 다운로드 받아야 합니다. 이를 바탕으로 작게 나눠진 영상 조각들 중에서&amp;nbsp;&lt;b&gt;보고싶은 지점을 쉽게 찾아 볼 수 있고, 중간에 화질을 마음대로 변경할 수 있습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;803&quot; data-origin-height=&quot;496&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cpIaZU/dJMb997KZpk/3HsvkiARiTRTegBlBK9I8k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cpIaZU/dJMb997KZpk/3HsvkiARiTRTegBlBK9I8k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cpIaZU/dJMb997KZpk/3HsvkiARiTRTegBlBK9I8k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcpIaZU%2FdJMb997KZpk%2F3HsvkiARiTRTegBlBK9I8k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;520&quot; height=&quot;321&quot; data-origin-width=&quot;803&quot; data-origin-height=&quot;496&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4-2. 세그먼트와 버퍼링&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현대 스트리밍은 영상을 &lt;b&gt;작은 조각(Segment)&lt;/b&gt;으로 쪼갭니다. 보통 2~10초로 쪼개며, 6초 단위가 국룰이라고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상을 여러 조각으로 자르면 전체 영상을 다 받지 않아도 &lt;b&gt;첫 조각부터 바로 재생&lt;/b&gt;할 수 있으며, &lt;b&gt;조각마다 다른 화질&lt;/b&gt;로 받을 수 있습니다. 물론 이를 위해 &lt;b&gt;트랜스코딩 과정&lt;/b&gt;에서 미리 &lt;b&gt;같은 영상을 화질별로 여러 개 만들어두어야&lt;/b&gt; 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;쾌적한 시청을 위해&lt;span&gt; &lt;/span&gt;&lt;/span&gt;플레이어는 영상을 시청하는 동안 뒷 조각을 미리 받아 버퍼에 쌓아둡니다. 네트워크가 느려지더라도 버퍼에 다음 세그먼트가 존재한다면 기다릴 필요 없이 바로 재생이 가능하지만, 만약 버퍼에 다음 영상이 존재하지 않는다면 &lt;b&gt;버퍼링&lt;/b&gt;이 발생합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4-3. 매니페스트와 ABR&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 영상을 4K&amp;middot;1080p&amp;middot;720p&amp;middot;480p로 트랜스코딩해 두고, 각각을 조각으로 쪼개어 저장합니다. 그리고 '어떤 화질의, 몇 번째 조각이, 어디 있는지' 적어둔 &lt;b&gt;목차 파일&lt;/b&gt;을 만드는데 이것이 &lt;b&gt;매니페스트(Manifest)&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;590&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMzit0/dJMcaar59eE/N3MCld06DNEGStq4zwJGMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMzit0/dJMcaar59eE/N3MCld06DNEGStq4zwJGMk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMzit0/dJMcaar59eE/N3MCld06DNEGStq4zwJGMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMzit0%2FdJMcaar59eE%2FN3MCld06DNEGStq4zwJGMk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;610&quot; height=&quot;281&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;590&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매니페스트 파일을 바탕으로 &lt;b&gt;네트워크 상태에 따라&lt;/b&gt; 조각 단위로 &lt;b&gt;화질을 자동 전환&lt;/b&gt;하는 것이 &lt;b&gt;ABR(Adaptive Bitrate)&lt;/b&gt;입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자동 화질 전환 기능을 효율적으로 사용하려면 화질이 골고루 준비되어 있어야 합니다. 고화질 영상만 제공한다면 버퍼링이 많이 발생할 것이고, 저화질 영상만 제공한다면 불필요하게 낮은 화질만 제공되기 때문입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;522&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bgvuzF/dJMcaiKlYN9/3TiCRMh3aGVhUbS6VLuYX0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bgvuzF/dJMcaiKlYN9/3TiCRMh3aGVhUbS6VLuYX0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bgvuzF/dJMcaiKlYN9/3TiCRMh3aGVhUbS6VLuYX0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbgvuzF%2FdJMcaiKlYN9%2F3TiCRMh3aGVhUbS6VLuYX0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;620&quot; height=&quot;253&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;522&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ABR 알고리즘&lt;/b&gt;은 여러 가지가 존재한다고 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Throughput 기반 (대역폭 측정)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;최근 N개 세그먼트 다운로드 속도 측정&lt;/li&gt;
&lt;li&gt;간단하고, 반응이 빠름 / 네트워크 변동에 민감하여 화질 변경이 잦음&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Buffer 기반&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;버퍼에 쌓인 재생 시간을 모니터링&lt;/li&gt;
&lt;li&gt;안정적이며, 버퍼링 방지에 좋음 / 반응이 느리고, 초기화질 결정에 어려움이 존재함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;하이브리드 (Throughput + Buffer)&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실제로 많은 플레이어들이 사용한다고 함&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4-4. 스트리밍 프로토콜&amp;nbsp;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;스트리밍을 위한 별도 프로토콜이 존재하며, 대표적인 스트리밍 방식으로는 &lt;/span&gt;&lt;b&gt;&lt;span&gt;HLS&lt;/span&gt;&lt;/b&gt;&lt;span&gt;와 &lt;/span&gt;&lt;b&gt;&lt;span&gt;MPEG-DASH&lt;/span&gt;&lt;/b&gt;&lt;span&gt;가 있습니다. 두 방식 모두 기본 구조는 비슷합니다. 긴 영상을 몇 초 단위의 &lt;/span&gt;&lt;b&gt;&lt;span&gt;세그먼트&lt;/span&gt;&lt;/b&gt;&lt;span&gt;로 나누고, 이 세그먼트들의 위치와 재생 순서를 적어둔 &lt;/span&gt;&lt;b&gt;&lt;span&gt;재생목록 또는 매니페스트&lt;/span&gt;&lt;/b&gt;&lt;span&gt; 파일을 함께 제공합니다. 이 구조를 바탕으로 네트워크 상황에 따라 화질을 바꿀 수 있는 &lt;b&gt;ABR&lt;/b&gt;이 가능하게 됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;HLS와 DASH&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버에서 다수 시청자에게 영상을 배포하는 &lt;b&gt;HTTP 기반&lt;/b&gt; 프로토콜의 양대 표준이 &lt;b&gt;HLS&lt;/b&gt;와 &lt;b&gt;DASH&lt;/b&gt;입니다. 조각(세그먼트)을 평범한 HTTP 파일로 주고받는 방식 덕분에 일반 웹서버&amp;middot;CDN에 사용되며 사실상 표준이 됐습니다.&lt;/p&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 122px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 17.2093%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 39.6511%;&quot;&gt;&lt;b&gt; HLS &lt;/b&gt;(HTTP Live Streaming)&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 43.0233%;&quot;&gt;&lt;b&gt; DASH &lt;/b&gt;(Dynamic Adaptive Streaming over HTTP)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 17.2093%;&quot;&gt;&lt;b&gt;만든 곳&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 39.6511%;&quot;&gt;애플&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 43.0233%;&quot;&gt;국제 표준(MPEG)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 17.2093%;&quot;&gt;&lt;b&gt;매니페스트&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 39.6511%;&quot;&gt;- 플레이리스트 &lt;br /&gt;- .m3u8&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 43.0233%;&quot;&gt;- MPD &lt;br /&gt;- .mpd&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 17.2093%;&quot;&gt;&lt;b&gt;조각&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 39.6511%;&quot;&gt;- 전통적 .ts&lt;br /&gt;- 현대 .mp4(fMP4)&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 43.0233%;&quot;&gt;.mp4(fMP4)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 17.2093%;&quot;&gt;&lt;b&gt;코덱 제약&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 39.6511%;&quot;&gt;비교적 한정적&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 43.0233%;&quot;&gt;코덱 무관(유연)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 17.2093%;&quot;&gt;&lt;b&gt;호환성&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 39.6511%;&quot;&gt;애플 생태계 필수 지원&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 43.0233%;&quot;&gt;웹&amp;middot;안드로이드 강세&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예전에는 구분을 해서 사용했지만, 각각의 장단점이 존재하기 때문에 요즘은 &lt;b&gt;CMAF&lt;/b&gt;를 사용하는 경우도 종종 있다고 합니다. CMAF는 HLS와 DASH 양쪽에서 사용할 수 있는 세그먼트 포맷으로 활용되어 있습니다. 공용 .mp4(FMP4)을 하나 만들어두고, HLS용 m3u8과 DASH용 mpd가 가리키도록 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;플레이리스트&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대부분의 동영상 플랫폼에서 사용하는 HLS는 2단 구조로 되어 있습니다. 먼저 화질 목록을 담은 &lt;b&gt;마스터 플레이리스트&lt;/b&gt;가 있고, 각 화질마다 그 화질의 조각 목록을 담은 &lt;b&gt;미디어 플레이리스트&lt;/b&gt;가 따로 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;770&quot; data-origin-height=&quot;252&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nbw9M/dJMcaaS9qem/BBK0AKCFjpGocLwNpkI131/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nbw9M/dJMcaaS9qem/BBK0AKCFjpGocLwNpkI131/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nbw9M/dJMcaaS9qem/BBK0AKCFjpGocLwNpkI131/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fnbw9M%2FdJMcaaS9qem%2FBBK0AKCFjpGocLwNpkI131%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;560&quot; height=&quot;252&quot; data-origin-width=&quot;770&quot; data-origin-height=&quot;252&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 플레이리스트를 바탕으로 아래와 같이 영상 재생이 진행됩니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li style=&quot;list-style-type: decimal;&quot;&gt;&lt;b&gt;마스터 플레이리스트 파일(m3u8)을 다운로드&lt;/b&gt;하여 재생 가능한 화질 확인&lt;/li&gt;
&lt;li style=&quot;list-style-type: decimal;&quot;&gt;&lt;b&gt;네트워크 상황&lt;/b&gt;에 맞는 미디어 플레이리스트 선택&lt;/li&gt;
&lt;li style=&quot;list-style-type: decimal;&quot;&gt;선택한 미디어 플레이리스트를 읽으며 &lt;b&gt;세그먼트를 다운받으며 재생&lt;/b&gt;&lt;/li&gt;
&lt;li style=&quot;list-style-type: decimal;&quot;&gt;재생하면서 네트워크 상황을 측정하여 &lt;b&gt;상황에 맞는 화질로 자동 변경&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;키프레임 정렬&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;화질을 변경하기 위해 조각을 바꿔 끼우려면 그 조각이 혼자 완전하게 시작될 수 있어야 합니다. 조각이 이전 조각을 참고하는 P&amp;middot;B 프레임으로 시작하면 화질을 바꾼 순간 기준이 사라져 화면이 깨지기 때문에 &lt;b&gt;모든 조각&lt;/b&gt;은 &lt;b&gt;I 프레임으로 시작&lt;/b&gt;하도록 만들고, &lt;b&gt;화질이 달라도 조각 경계를 똑같이 맞춥니다.&lt;/b&gt; 이를 &lt;b&gt;키프레임 정렬&lt;/b&gt;이라고 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1090&quot; data-origin-height=&quot;332&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBGmKG/dJMb99NpPeb/EeQXHaKjFf3ykUxCiGMmM1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBGmKG/dJMb99NpPeb/EeQXHaKjFf3ykUxCiGMmM1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBGmKG/dJMb99NpPeb/EeQXHaKjFf3ykUxCiGMmM1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBGmKG%2FdJMb99NpPeb%2FEeQXHaKjFf3ykUxCiGMmM1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;219&quot; data-origin-width=&quot;1090&quot; data-origin-height=&quot;332&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국, &lt;b&gt;스트리밍 조각 길이&lt;/b&gt;와 &lt;b&gt;GOP 길이가 맞아야&lt;/b&gt; ABR이 정상적으로 동작합니다. 만약 둘이 일치하지 않는다면 다른 조각이 필요하게 될 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;라이브 스트리밍의 경우&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VOD는 미리 다 처리해두므로 영상 재생 시 별도 수집 과정이 필요하지 않습니다. 하지만, &lt;b&gt;라이브&lt;/b&gt;의 경우 실시간으로 &lt;b&gt;수집 구간 작업이 필요&lt;/b&gt;합니다. 이 구간은 '한 명(방송자)이 서버로 끊김 없이 올린다'가 목표이기 때문에 배포(HLS/DASH)와는 다른 프로토콜을 사용합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;845&quot; data-origin-height=&quot;480&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mpFJl/dJMcaicCLlD/HbTQSRBYBMjRUXkeM2IKE1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mpFJl/dJMcaicCLlD/HbTQSRBYBMjRUXkeM2IKE1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mpFJl/dJMcaicCLlD/HbTQSRBYBMjRUXkeM2IKE1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmpFJl%2FdJMcaicCLlD%2FHbTQSRBYBMjRUXkeM2IKE1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;560&quot; height=&quot;318&quot; data-origin-width=&quot;845&quot; data-origin-height=&quot;480&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;라이브 파이프라인은 보통 방송자가 RTMP(또는 SRT)로 올리면 서버가 실시간 트랜스코딩&amp;middot;세그먼트화해서 HLS/DASH로 재생할 수 있도록 제공합니다. 영상을 올리고 내리는 프로토콜이 다르게 사용됩니다.&lt;/p&gt;
&lt;table style=&quot;letter-spacing: 0px; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 14.6512%;&quot;&gt;&lt;b&gt; 프로토콜 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 16.279%;&quot;&gt;&lt;b&gt; 구간 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 68.9535%;&quot;&gt;&lt;b&gt; 특징 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 14.6512%;&quot;&gt;&lt;b&gt;RTMP&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 16.279%;&quot;&gt;수집&lt;/td&gt;
&lt;td style=&quot;width: 68.9535%;&quot;&gt;- 오래된 사실상 표준&lt;br /&gt;- 지연이 낮고 OBS 등 송출 도구 호환성이 좋다. 단 옛 기술이라 시청자 배포용으로는 부적합&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 14.6512%;&quot;&gt;&lt;b&gt;SRT&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 16.279%;&quot;&gt;수집&lt;/td&gt;
&lt;td style=&quot;width: 68.9535%;&quot;&gt;- RTMP의 현대적 대안&lt;br /&gt;- 불안정하거나 먼 네트워크에서도 손실을 복구하며 안정적으로 전송&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 14.6512%;&quot;&gt;&lt;b&gt;WebRTC&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 16.279%;&quot;&gt;수집&amp;middot;배포 양쪽&lt;/td&gt;
&lt;td style=&quot;width: 68.9535%;&quot;&gt;- 초저지연(1초 미만)&lt;br /&gt;- 양방향 실시간 상호작용 용도&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 14.6512%;&quot;&gt;&lt;b&gt;HLS / DASH&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 16.279%;&quot;&gt;배포&lt;/td&gt;
&lt;td style=&quot;width: 68.9535%;&quot;&gt;- HTTP 기반, 확장성 최고&lt;br /&gt;- 단, 조각을 모으느라 지연 발생&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. 영상 품질은 어떻게 알까&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5-1. 영상 품질 지표&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영상의 품질을 알아보는 가장 쉬운 방법은 사람이 직접 눈과 귀로 확인하는 것입니다. 하지만, 세세한 차이까지 다 알 수 없고 모든 영상을 사람이 검토하는 것은 매우 오랜 시간이 걸립니다. 또한, 일정 품질 목표가 있다고 한들 '봐줄 만한가'가 모호합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;화질을 수치화&lt;/b&gt;하는 방식이 있는데, 크게 세 가지가 사용됩니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 13.2558%;&quot;&gt;&lt;b&gt;&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%;&quot;&gt;&lt;b&gt; PSNR (픽셀 오차)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 32.6745%;&quot;&gt;&lt;b&gt; SSIM (구조적 유사도)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%;&quot;&gt;&lt;b&gt; VMAF (사람 체감 예측)&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 13.2558%;&quot;&gt;&lt;b&gt;세대&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%;&quot;&gt;1세대&lt;/td&gt;
&lt;td style=&quot;width: 32.6745%;&quot;&gt;2세대&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%;&quot;&gt;3세대 (현재 표준)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 13.2558%;&quot;&gt;&lt;b&gt;무엇을 재나&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%;&quot;&gt;&lt;b&gt;픽셀이 얼마나 다른가&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 32.6745%;&quot;&gt;&lt;b&gt;구조(윤곽&amp;middot;명암 패턴)가 얼마나 비슷한가&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%;&quot;&gt;&lt;b&gt;사람이 몇 점이라 느낄까&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 13.2558%;&quot;&gt;&lt;b&gt;원리&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%;&quot;&gt;원본&amp;harr;압축본의 수학적 픽셀 오차&lt;/td&gt;
&lt;td style=&quot;width: 32.6745%;&quot;&gt;사람은 개별 픽셀이 아닌 &quot;전체 형태&quot;로 인식한다는 점 활용&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%;&quot;&gt;사람이 직접 매긴 평가를 학습해 예측 (여러 지표를 fusion)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 13.2558%;&quot;&gt;&lt;b&gt;결과 형태&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%;&quot;&gt;dB (높을수록 좋음)&lt;/td&gt;
&lt;td style=&quot;width: 32.6745%;&quot;&gt;0~1 (1에 가까울수록 좋음)&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%;&quot;&gt;0~100 점수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 13.2558%;&quot;&gt;&lt;b&gt;사람 체감과의 일치&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%;&quot;&gt;낮음 (괴리 큼)&lt;/td&gt;
&lt;td style=&quot;width: 32.6745%;&quot;&gt;중간&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%;&quot;&gt;높음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 13.2558%;&quot;&gt;&lt;b&gt;약점&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%;&quot;&gt;픽셀 오차 작아도 눈엔 거슬리거나 그 반대&lt;/td&gt;
&lt;td style=&quot;width: 32.6745%;&quot;&gt;픽셀보단 낫지만 여전히 체감과 거리&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%;&quot;&gt;결국 예측값 (진짜 기준은 사람 평가 MOS)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;낮은 세대일수록 계산이 빠르고 단순하다는 장점이 있지만, 그 수치가 영상 품질을 대표하기 부정확하다는 단점이 있습니다. 따라서 실제 사용할 때에는 전체 영상에 대한 검수는 &lt;b&gt;PSNR과 SSIM으로 빠르게 처리&lt;/b&gt;하고, &lt;b&gt;최종 품질 판단은 VMAF로&lt;/b&gt; 하는 방식으로 섞기도 한다고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;VMAF&lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;가 있으면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;화질을 점수로 관리&lt;/b&gt;할 수 있습니다. 이걸 활용하면 'VMAF 93점을 유지하면서 비트레이트는 최소로'같은 목표를 세우고 최적화를 진행할 수 있게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 VMAF 6점 차이는 인간의 시각으로 인지할 수 있는 최소 단위인 &lt;b&gt;1JND (Just Noticeable Difference)&lt;/b&gt;라고 한다고 하네요!!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;5-2. 영상 품질 최적점 찾기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;영상 품질과 용량은 반비례합니다. 용량이 커지면 품질이 높아지고, 그 반대도 마찬가지이기 때문에 품질과 용량의 균형점을 잘 잡아야 합니다. 이를 위해 &lt;/span&gt;&lt;span&gt;&lt;b&gt;CRF&lt;/b&gt;라는 방식을 사용하여 품질을 기준으로 용량을 결정해 압축할 수 있습니다. CRF &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;비트레이트가 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;&lt;span&gt;품질을 기준으로 인코딩&lt;/span&gt;&lt;/b&gt;&lt;span&gt;합니다.&lt;/span&gt;&amp;nbsp;&lt;/span&gt;&lt;span&gt;CRF 말고도 CBR, VBR 등 방식이 존재하는데, 간단하게 아래처럼 정리할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;properties&quot;&gt;&lt;code&gt;CBR &amp;rarr; 비트레이트 고정
VBR &amp;rarr; 평균 비트레이트 중심
CRF &amp;rarr; 품질 기준 고정&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;예를 들어, CRF 값을 하나 정하면 인코더는 비슷한 체감 품질을 유지하기 위해 필요한 만큼 비트를 사용합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;b&gt;CRF&lt;/b&gt;는&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt; 파일 크기를 정하는 방식이 아니라 원하는 &lt;b&gt;품질을 정하는 방식&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;span&gt;입니다. &lt;/span&gt;&lt;span&gt;같은 CRF 값이라도 토크쇼는 파일이 작게 나오고, 스포츠 영상은 훨씬 크게 나올 수 있습니다. 품질을 유지하기 위해 필요한 데이터 양이 다르기 때문입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;그렇다면 적절한 CRF 값을 설정해야 하는데, &lt;/span&gt;&lt;span&gt;여기서 &lt;/span&gt;&lt;b&gt;&lt;span&gt;VMAF&lt;/span&gt;&lt;/b&gt;&lt;span&gt;같은 품질 지표가 등장합니다. &lt;/span&gt;&lt;span&gt;같은 영상을 여러 CRF 값으로 인코딩한 뒤 품질을 측정해서 비교해볼 수 있습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot;&gt;&lt;code&gt;CRF 18 &amp;rarr; 파일 큼, 화질 높음
CRF 23 &amp;rarr; 균형점
CRF 28 &amp;rarr; 파일 작음, 화질 낮음&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;그리고 각 결과의 비트레이트와 VMAF를 비교합니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;CRF 값 변경 &amp;rarr; 인코딩 &amp;rarr; 비트레이트 측정 &amp;rarr; VMAF 측정 &amp;rarr; 품질 대비 효율 비교&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이 과정을 통해 &lt;/span&gt;&lt;span&gt;어느 지점부터 화질 차이가 거의 느껴지지 않는지, &lt;/span&gt;&lt;span&gt;어느 지점부터 비트를 더 써도 품질 향상이 크지 않은지&lt;/span&gt;&lt;span&gt;를 확인할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CRF와 VMAF가 헷갈릴 수 있는데, &lt;b&gt;CRF는 인코딩 설정값&lt;/b&gt; / &lt;b&gt;VMAF는 품질 측정 점수라는 차이&lt;/b&gt;가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 마무리&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최대한 흐름 중심으로 내용을 연결시키려고 했는데, 막상 작성해보니 조금 장황한 것 같아서 슬픕니다.. 다소 깊어진 내용이나 영상 트랜스코딩에는 불필요했던 개념들이 들어가기도 했네요. 그래도&amp;nbsp;영상 처리를 위한 기초 개념을 전체적으로 한 번 훑어본다는 느낌으로 읽어보면 좋을 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음부터 모든 개념이 필요하지는 않지만, 기본적인 영상 트랜스코딩 작업을 수행하면서 '기본적으로 올바른' 결과물을 내는 것만으로도 상당히 많은 설정이 들어갑니다. 이들을 모르고 사용할 수 있는데, 그렇다면 나중에 품질을 측정하거나 고도화를 진행할 때 기본적인 곳에서부터 문제가 발생할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 잘 모른 채로 영상 트랜스코딩을 완료됐지만, 조금씩 아는 것이 생기면서 저는 제대로 한 것이 아니었음을 깨달았던 기억이 있습니다. 그래서 기본 개념을 익히고 &lt;b&gt;FFmpeg 명령어들이 영상에 어떤 영향을 미치는지 체감할 필요가 있겠다&lt;/b&gt;고 느껴서 글을 작성했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <author>phonil</author>
      <guid isPermaLink="true">https://yestomo.tistory.com/26</guid>
      <comments>https://yestomo.tistory.com/26#entry26comment</comments>
      <pubDate>Tue, 9 Jun 2026 22:57:05 +0900</pubDate>
    </item>
    <item>
      <title>[O+T] 영상 품질 기반 개선기 (1. 올바른 영상 변환과 품질 측정 방식)</title>
      <link>https://yestomo.tistory.com/25</link>
      <description>&lt;blockquote data-ke-style=&quot;style3&quot;&gt;영상 트랜스코딩 - VMAF 품질 측정 과정을 거치며 마주한 문제를 해결하는 과정에 대해 적어놓은 글입니다. 문제 해결 중심으로 글이 전개되므로 영상에서 사용하는 개념에 대한 설명이 적게 들어가 있습니다!&lt;/blockquote&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;2편: &lt;a href=&quot;https://yestomo.tistory.com/27&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://yestomo.tistory.com/27&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1782752552905&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;[O+T] 영상 품질 측정하기 (2. Per-Title-Encoding 적용)&quot; data-og-description=&quot;1편: https://yestomo.tistory.com/25 [O+T]: 영상 품질 측정하기 (1. 올바른 영상 변환과 품질 측정)영상 트랜스코딩 - VMAF 품질 측정 과정을 거치며 마주한 문제를 해결하는 과정에 대해 적어놓은 글입니다.&quot; data-og-host=&quot;yestomo.tistory.com&quot; data-og-source-url=&quot;https://yestomo.tistory.com/27&quot; data-og-url=&quot;https://yestomo.tistory.com/27&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bZQpRY/dJMb8UHZXv4/hppZAXg7Qm6fTPYHRTF1X0/img.png?width=800&amp;amp;height=424&amp;amp;face=0_0_800_424,https://scrap.kakaocdn.net/dn/e0zAS/dJMb8QewUPg/XacTik9M0GMDgksi7QWHm0/img.png?width=800&amp;amp;height=424&amp;amp;face=0_0_800_424,https://scrap.kakaocdn.net/dn/iYQjQ/dJMb8U84nwA/66vNvMig6mI4wz6kNRNzn0/img.png?width=1398&amp;amp;height=534&amp;amp;face=0_0_1398_534&quot;&gt;&lt;a href=&quot;https://yestomo.tistory.com/27&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://yestomo.tistory.com/27&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bZQpRY/dJMb8UHZXv4/hppZAXg7Qm6fTPYHRTF1X0/img.png?width=800&amp;amp;height=424&amp;amp;face=0_0_800_424,https://scrap.kakaocdn.net/dn/e0zAS/dJMb8QewUPg/XacTik9M0GMDgksi7QWHm0/img.png?width=800&amp;amp;height=424&amp;amp;face=0_0_800_424,https://scrap.kakaocdn.net/dn/iYQjQ/dJMb8U84nwA/66vNvMig6mI4wz6kNRNzn0/img.png?width=1398&amp;amp;height=534&amp;amp;face=0_0_1398_534');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;[O+T] 영상 품질 측정하기 (2. Per-Title-Encoding 적용)&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;1편: https://yestomo.tistory.com/25 [O+T]: 영상 품질 측정하기 (1. 올바른 영상 변환과 품질 측정)영상 트랜스코딩 - VMAF 품질 측정 과정을 거치며 마주한 문제를 해결하는 과정에 대해 적어놓은 글입니다.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;yestomo.tistory.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;0. 개요&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;영상 업로드와 스트리밍을 제공하는 OTT 서비스 프로젝트에서 트랜스코딩 서버를 개발했습니다. FFmpeg으로 HLS 변환을 완료하면 360p, 720p, 1080p 결과물은 생성되지만, 막상 해당 영상이 '잘 변환되었는지' 판단하기는 어려웠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;눈으로 봤을 때 큰 차이를 느끼기 어려웠고, 모든 영상을 하나하나 사람이 볼 수도 없었습니다. 영상 원본과 트랜스코딩 후 변환된 영상을 수치로 비교할 수 있으면 좋겠다고 생각했고 이를 기반으로 내가 정말 잘 변환했는지, 더 좋은 방법이 있는지 알 수 있을 것이라고 생각했습니다.&lt;br /&gt;&amp;nbsp;&lt;br /&gt;이번 글에서는 트랜스코딩 결과물에 대한 최적화(2편, Per-Title-Encoding) 전 영상 변환 시 기본적으로 적용해야 할 요소들을 다룹니다. 품질을 측정하며 이상한 숫자가 발견되면 원인을 찾고 정말 문제인지 알아보며 진행했습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;1. 영상 품질 측정&lt;/b&gt;&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1-1.&lt;/b&gt;&lt;b&gt;지표 선택&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;화질을 수치화&lt;/b&gt;하는 방식으로 보통 세 가지가 사용됩니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 210px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 13.2558%; height: 21px;&quot;&gt;&lt;b&gt;&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%; height: 21px;&quot;&gt;&lt;b&gt;PSNR (픽셀 오차)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 32.6745%; height: 21px;&quot;&gt;&lt;b&gt;SSIM (구조적 유사도)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%; height: 21px;&quot;&gt;&lt;b&gt;VMAF (사람 체감 예측)&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 13.2558%; height: 21px;&quot;&gt;&lt;b&gt;세대&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%; height: 21px;&quot;&gt;1세대&lt;/td&gt;
&lt;td style=&quot;width: 32.6745%; height: 21px;&quot;&gt;2세대&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%; height: 21px;&quot;&gt;3세대&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 13.2558%; height: 21px;&quot;&gt;&lt;b&gt;무엇을 재나&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%; height: 21px;&quot;&gt;&lt;b&gt;픽셀이 얼마나 다른가&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 32.6745%; height: 21px;&quot;&gt;&lt;b&gt;구조(윤곽&amp;middot;명암 패턴)가 얼마나 비슷한가&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%; height: 21px;&quot;&gt;&lt;b&gt;사람이 몇 점이라 느낄까&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;width: 13.2558%; height: 42px;&quot;&gt;&lt;b&gt;원리&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%; height: 42px;&quot;&gt;원본 &amp;harr; 압축본의 수학적 픽셀 오차&lt;/td&gt;
&lt;td style=&quot;width: 32.6745%; height: 42px;&quot;&gt;사람은 개별 픽셀이 아닌 '전체 형태'로 인식한다는 점 활용&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%; height: 42px;&quot;&gt;사람이 직접 매긴 평가를 학습해 예측 (여러 지표를 fusion)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 13.2558%; height: 21px;&quot;&gt;&lt;b&gt;결과 형태&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%; height: 21px;&quot;&gt;dB (높을수록 좋음)&lt;/td&gt;
&lt;td style=&quot;width: 32.6745%; height: 21px;&quot;&gt;0~1 (1에 가까울수록 좋음)&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%; height: 21px;&quot;&gt;0~100 점수&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;width: 13.2558%; height: 42px;&quot;&gt;&lt;b&gt;사람 체감과의 일치&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%; height: 42px;&quot;&gt;낮음 (괴리 큼)&lt;/td&gt;
&lt;td style=&quot;width: 32.6745%; height: 42px;&quot;&gt;중간&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%; height: 42px;&quot;&gt;높음&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;width: 13.2558%; height: 42px;&quot;&gt;&lt;b&gt;약점&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.4186%; height: 42px;&quot;&gt;픽셀 오차 작아도 눈엔 거슬리거나 그 반대&lt;/td&gt;
&lt;td style=&quot;width: 32.6745%; height: 42px;&quot;&gt;픽셀보단 낫지만 여전히 체감과 거리있음&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%; height: 42px;&quot;&gt;결국 예측값임&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;낮은 세대일수록 계산이 빠르고 단순하다는 장점이 있지만, 그 수치가 영상 품질을 대표하기 부정확하다는 단점이 있습니다. &lt;span style=&quot;letter-spacing: 0px;&quot;&gt;이번 작업의 목표는 그저 픽셀 차이를 줄이는 것이 아니라 트랜스코딩 후 &lt;/span&gt;&lt;b&gt;사용자가 체감하는 품질&lt;/b&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;을 확인하는 것이기 때문에 &lt;/span&gt;&lt;b&gt;VMAF을 선택&lt;/b&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;추가로 실제로는 전체 영상에 대한 검수는 &lt;b&gt;PSNR과 SSIM으로 빠르게 처리&lt;/b&gt;하고,&amp;nbsp;&lt;b&gt;최종 품질 판단은 VMAF로&lt;/b&gt; 하는 방식으로 섞기도 한다고 합니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1-2. 영상 고르기&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;품질 측정 대상은 하나의 영상만 사용하지 않기로 했습니다. 영상마다 움직임의 양, 장면 전환 빈도, 질감, 조명, FPS 특성이 다르기 때문에 한 영상에서 좋은 결과가 나왔다고 해서 기존 트랜스코딩 로직이 항상 안정적이라고 보기 어렵기 때문입니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;저는 &lt;b&gt;저/중/고 복잡도&lt;/b&gt;로 나눠 서로 다른 특성의 영상을 세 개 준비했습니다. 움직임은 적은 애니메이션을 복잡도가 낮은 영상으로, 액션이 많은 실사 CG 영상을 고복잡도 영상으로 골랐습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/YGxwT/dJMcafHaHqc/WcxbGuwhkgHoW5B6NIZkck/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/YGxwT/dJMcafHaHqc/WcxbGuwhkgHoW5B6NIZkck/img.png&quot; data-origin-width=&quot;1163&quot; data-origin-height=&quot;857&quot; data-is-animation=&quot;false&quot; style=&quot;width: 26.1872%; margin-right: 10px;&quot; data-widthpercent=&quot;26.81&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/YGxwT/dJMcafHaHqc/WcxbGuwhkgHoW5B6NIZkck/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FYGxwT%2FdJMcafHaHqc%2FWcxbGuwhkgHoW5B6NIZkck%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1163&quot; height=&quot;857&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJAmd6/dJMcaff6qTI/2W5LyZfEcHgzmDkoI7K53k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJAmd6/dJMcaff6qTI/2W5LyZfEcHgzmDkoI7K53k/img.png&quot; data-origin-width=&quot;1034&quot; data-origin-height=&quot;633&quot; data-is-animation=&quot;false&quot; style=&quot;width: 31.5216%; margin-right: 10px;&quot; data-widthpercent=&quot;32.27&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJAmd6/dJMcaff6qTI/2W5LyZfEcHgzmDkoI7K53k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJAmd6%2FdJMcaff6qTI%2F2W5LyZfEcHgzmDkoI7K53k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1034&quot; height=&quot;633&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LDs6n/dJMcah54jUH/YTzn5LggdxqndARIuOKUeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LDs6n/dJMcah54jUH/YTzn5LggdxqndARIuOKUeK/img.png&quot; data-origin-width=&quot;1253&quot; data-origin-height=&quot;605&quot; data-is-animation=&quot;false&quot; style=&quot;width: 39.9656%;&quot; data-widthpercent=&quot;40.92&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LDs6n/dJMcah54jUH/YTzn5LggdxqndARIuOKUeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLDs6n%2FdJMcah54jUH%2FYTzn5LggdxqndARIuOKUeK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1253&quot; height=&quot;605&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;큰 토끼 / 미국 / 멋있는 아저씨&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1310&quot; data-origin-height=&quot;406&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmgmzq/dJMcabLz8IQ/QfkkSIL3msO4OK22T3npy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmgmzq/dJMcabLz8IQ/QfkkSIL3msO4OK22T3npy1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmgmzq/dJMcabLz8IQ/QfkkSIL3msO4OK22T3npy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbmgmzq%2FdJMcabLz8IQ%2FQfkkSIL3msO4OK22T3npy1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;660&quot; height=&quot;205&quot; data-origin-width=&quot;1310&quot; data-origin-height=&quot;406&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;&lt;span&gt;Big Buck Bunny &lt;/span&gt;&lt;/b&gt;&lt;span&gt;(낮은 복잡도)&lt;/span&gt;&lt;span&gt;: 밝은 애니메이션 영상&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span&gt;Meridian&lt;/span&gt;&lt;/b&gt;&lt;span&gt; (중간 복잡도)&lt;/span&gt;&lt;span&gt;: 높은 FPS 특성을 가진 영상&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span&gt;Tears of Steel &lt;/span&gt;&lt;/b&gt;&lt;span&gt;(높은 복잡도)&lt;/span&gt;&lt;span&gt;: 장면 전환, 실사, 어두운 장면 등 압축 난도가 높은 영상&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;2. 기존 트랜스코딩 방식으로 vmaf 측정&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;먼저 기존 트랜스코딩 로직으로 생성된 HLS 결과물을 기준으로 VMAF를 측정했습니다. 측정 대상은 Big Buck Bunny, Meridian, Tears of Steel 세 영상이었고, 각 영상마다 360p, 720p, 1080p 결과물을 비교했습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2-1. 측정 방식&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;원본(4K)과 변환본의 해상도가 다르기 때문에 &lt;b&gt;원본을 HLS 결과물의 실제 해상도로 낮춘 뒤 비교&lt;/b&gt;했습니다. 예를 들어 720p 결과물을 평가할 때는 원본도 720p 기준으로 맞춘 뒤 VMAF를 계산했습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1245&quot; data-origin-height=&quot;893&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bRBnCB/dJMcaiKztGR/k7naLDvPNxBiuI9la9Axwk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bRBnCB/dJMcaiKztGR/k7naLDvPNxBiuI9la9Axwk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bRBnCB/dJMcaiKztGR/k7naLDvPNxBiuI9la9Axwk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbRBnCB%2FdJMcaiKztGR%2Fk7naLDvPNxBiuI9la9Axwk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;580&quot; height=&quot;416&quot; data-origin-width=&quot;1245&quot; data-origin-height=&quot;893&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;넷플릭스는 VMAF 측정의 국룰?로 HLS 결과물을 원본 해상도에 맞추는 Upscale 방식을 사용하라고 합니다. 하지만, 지금 저의 측정 의도는&lt;span style=&quot;letter-spacing: 0px;&quot;&gt; 최종 시청 품질을 판단하기 위한 목적이 아닌 현재 트랜스코딩 결과물의 &lt;b&gt;압축 품질과 측정 과정에서 발생할 수 있는 문제 신호를 확인&lt;/b&gt;하기 위한 진단용 측정이기 때문에 &lt;b&gt;스케일로 인한 점수 저하는 배제&lt;/b&gt;하고자 Downscale로 측정했습니다. 이는 같은 해상도 안에서 '현재 인코딩 결과가 얼마나 원본 정보를 유지하는가'를 진단하기 위함입니다&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2-2. 측정 결과&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1935&quot; data-origin-height=&quot;530&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dfBd0m/dJMcabktQ1A/kT1I2UdHUm1DwZ9aY3TkO1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dfBd0m/dJMcabktQ1A/kT1I2UdHUm1DwZ9aY3TkO1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dfBd0m/dJMcabktQ1A/kT1I2UdHUm1DwZ9aY3TkO1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdfBd0m%2FdJMcabktQ1A%2FkT1I2UdHUm1DwZ9aY3TkO1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1935&quot; height=&quot;530&quot; data-origin-width=&quot;1935&quot; data-origin-height=&quot;530&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저/중 복잡도 영상은 VMAF 점수가 정상으로 나타났지만, 고복잡도 영상은 점수가 이상하리만치 낮았습니다. 하위 5% 구간의 점수가 0이 나타난 것을 보아 상당 구간에서 이상치가 발생한 것임을 알 수 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 영상의 평균 VMAF 점수를 표로 보면 아래와 같습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 22.2093%;&quot;&gt;&lt;b&gt; 영상 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%;&quot;&gt;&lt;b&gt; 360p &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 18.4884%;&quot;&gt;&lt;b&gt; 720p &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.3952%;&quot;&gt;&lt;b&gt; 1080p &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 17.6745%;&quot;&gt;&lt;b&gt; 해석 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 22.2093%;&quot;&gt;&lt;b&gt;Big Buck Bunny&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%;&quot;&gt;96.43&lt;/td&gt;
&lt;td style=&quot;width: 18.4884%;&quot;&gt;96.92&lt;/td&gt;
&lt;td style=&quot;width: 21.3952%;&quot;&gt;97.17&lt;/td&gt;
&lt;td style=&quot;width: 17.6745%;&quot;&gt;정상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 22.2093%;&quot;&gt;&lt;b&gt;Meridian&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%;&quot;&gt;95.68&lt;/td&gt;
&lt;td style=&quot;width: 18.4884%;&quot;&gt;95.07&lt;/td&gt;
&lt;td style=&quot;width: 21.3952%;&quot;&gt;94.52&lt;/td&gt;
&lt;td style=&quot;width: 17.6745%;&quot;&gt;정상&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 22.2093%;&quot;&gt;&lt;b&gt;Tears of Steel&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20%;&quot;&gt;49.84&lt;/td&gt;
&lt;td style=&quot;width: 18.4884%;&quot;&gt;40.23&lt;/td&gt;
&lt;td style=&quot;width: 21.3952%;&quot;&gt;35.55&lt;/td&gt;
&lt;td style=&quot;width: 17.6745%;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt;&lt;b&gt;비정상&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;문제를 두 갈래로 나누기&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;결과만 보면 고복잡도 영상의 트랜스코딩 품질이 크게 낮다고 판단할 수도 있지만, 품질 측정 과정에서 오류가 발생했을 수도 있습니다. VMAF는 원본과 변환본을 비교하는 방식이기 때문에, 비교 조건이 어긋나도 점수가 낮게 나올 수 있기 때문입니다. &lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 문제를 두 갈래로 나누어 보기로 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;첫 번째는 &lt;/span&gt;&lt;b&gt;&lt;span&gt;트랜스코딩 산출물 자체의 문제&lt;/span&gt;&lt;/b&gt;&lt;span&gt;입니다. 트랜스코딩 과정에서 적절한 보정이 이뤄지지 않아 segment와 keyframe 구조가 불안정하다면 VMAF 이전에 결과물 자체를 먼저 보완해야 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;두 번째는 &lt;/span&gt;&lt;b&gt;&lt;span&gt;VMAF 측정 방식의 문제&lt;/span&gt;&lt;/b&gt;&lt;span&gt;입니다. timestamp, frame alignment, FPS 처리 방식이 맞지 않으면 실제 화질이 나쁘지 않아도 서로 다른 프레임끼리 비교되어 점수가 낮게 나올 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&lt;b&gt;실제 화질이 나쁜 것인지&lt;/b&gt;, 아니면 &lt;b&gt;비교 조건이 잘못된 것인지&lt;/b&gt;를 분리해서 '트랜스코딩 변환'과 'VMAF 측정' 과정에서의 오류로 나눠서 살펴보았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;먼저 HLS 산출물에서 확인된 문제를 정리하고, 그 다음 VMAF 측정 방식에서 점수를 왜곡할 수 있는 요소를 분리해 확인했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;3. 트랜스코딩 산출물 문제&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 분리한 두 문제는 완전하게 떨어지지 않습니다. 특히, 고복잡도 영상의 낮은 점수에는 측정 왜곡과 산출물 문제가 섞여 있었습니다. 그래서 먼저 점수가 몇 점이든 상관없이 결과물(HLS) 자체에서 객관적으로 확인되는 구조적 결함만 먼저 정리하기로 했습니다. '그래서 실제 화질이 낮은가?'라는 판단은 측정 왜곡을 걷어낸 뒤 진행해야 또다른 오류로 인해 엉키는 문제가 없기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 산출물 문제는 '결과물이 원래부터 가지고 있던, 측정과 무관한 문제'입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3-1. master.m3u8 표기와 실제 HLS 해상도가 달랐다 &lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;첫 번째로 확인한 문제는 &lt;/span&gt;&lt;span&gt;master.m3u8&lt;/span&gt;&lt;span&gt;에 표기된 해상도와 실제 HLS 결과물의 해상도가 다르다는 점이었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Big Buck Bunny와 Meridian은 master에 표기된 해상도와 실제 HLS 해상도가 일치했지만, Tears of Steel은 달랐습니다.&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 69px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 19.6512%; height: 21px;&quot;&gt;&lt;b&gt; 영상 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.6512%; height: 21px;&quot;&gt;&lt;b&gt; 화질 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.186%; height: 21px;&quot;&gt;&lt;b&gt; master 표기 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 31.5116%; height: 21px;&quot;&gt;&lt;b&gt; 실제 HLS 해상도 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 19.6512%; height: 48px;&quot; rowspan=&quot;3&quot;&gt;&lt;b&gt;&lt;span&gt;Tears of Steel&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.6512%; height: 17px;&quot;&gt;&lt;span&gt;360p&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.186%; height: 17px;&quot;&gt;&lt;span&gt;640x360&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 31.5116%; height: 17px;&quot;&gt;&lt;span&gt;806x360&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 14px;&quot;&gt;
&lt;td style=&quot;width: 24.6512%; height: 14px;&quot;&gt;&lt;span&gt;720p&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.186%; height: 14px;&quot;&gt;&lt;span&gt;1280x720&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 31.5116%; height: 14px;&quot;&gt;&lt;span&gt;1614x720&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 24.6512%; height: 17px;&quot;&gt;&lt;span&gt;1080p&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.186%; height: 17px;&quot;&gt;&lt;span&gt;1920x1080&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 31.5116%; height: 17px;&quot;&gt;&lt;span&gt;2420x1080&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;Tears of Steel은 원본 영상의 화면 비율이 프로젝트 정책 16:9와 다른 21:9였고, 트랜스코딩 과정에서 비율을 유지하며 리사이징되었습니다. 그 결과 실제 출력 영상은 806x360, 1614x720, 2420x1080처럼 생성되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;문제는 &lt;b&gt;실제 출력은 비율을 유지해 정상적으로 생성&lt;/b&gt;되었지만, &lt;b&gt;master에는 고정된 16:9 해상도처럼 기록&lt;/b&gt;되고 있었다는 점입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;불일치가 발생한 이유&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 값이 서로 다른 출처에서 나왔기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 방식은 360p, 720p, 1080p 같은 화질 enum을 기준으로 master 정보를 구성하고 있었습니다. 실제 영상은 ffmpeg의 scale=-2:height 명령어로 만들어지며, 높이만 맞추고 너비는 원본 비율대로 자동 계산합니다. 일반적인 16:9 영상에서는 큰 문제가 없어 보일 수 있지만, 입력 영상의 화면 비율이 다르면 실제 출력 해상도는 enum에 고정된 값과 달라질 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;반면, 매니페스트(master.m3u8) 표기는 코드에 박힌 고정값(16:9를 가정한 640 &amp;times; 360 등)을 그대로 적었습니다. 실제 출력물을 다시 확인하지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;즉, '영상을 만드는 쪽'과 '목록에 적는 쪽'에서 각기 다른 것을 바라보도록 로직이 작성되어 있었기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;왜 문제인가&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 단순한 metadata 표기 오류처럼 보였습니다. 하지만 HLS에서 master playlist는 플레이어가 각 화질을 선택할 때 참고하는 정보입니다. master에 적힌 해상도와 실제 미디어의 해상도가 다르면, 플레이어와 모니터링 도구는 실제 출력과 다른 정보를 기준으로 판단하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, VMAF 측정에서도 혼란이 생길 수 있습니다. 측정은 실제 HLS 해상도를 기준으로 진행했지만, master에 기록된 값과 실제 값이 다르다면 '어떤 해상도의 결과물을 평가하고 있는가'를 문서와 리포트에서 명확히 설명하기 어려워집니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ABR 환경에서 플레이어는 master의 해상도&amp;middot;대역폭을 보고 네트워크 상황에 맞는 화질을 고름. 표기가 틀리면 잘못된 화질을 선택할 수 있음.&lt;/li&gt;
&lt;li&gt;측정&amp;middot;리포트에서 '해상도'의 의미가 흔들림. 640 &amp;times; 360을 비교 기준으로 삼으면 실제 806 &amp;times; 360 결과를 잘못 평가하게 됨.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매니페스트를 고정값이 아니라 실제 출력물을 다시 probe한 값 기준으로 생성하는 것입니다. 원본의 비율을 살리기로 결정했다면, 해당 비율에 맞춰 스케일된 실제 값을 매니페스트 파일에 작성합니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;&lt;span&gt;기존 방식&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt;&lt;span&gt;보완 방향&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;360p, 720p, 1080p enum 기준으로 master 작성&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;실제 HLS output metadata 기준으로 master 작성&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;예상 해상도를 기록&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;실제 생성된 해상도를 기록&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;코드가 의도한 결과를 기준으로 설명&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;FFmpeg이 실제 만든 결과를 기준으로 설명&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;이렇게 해야 HLS 산출물과 metadata가 서로 일치하고, 이후 품질 측정 리포트에서도 어떤 결과물을 비교했는지 명확하게 설명할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3-2. 키프레임/GOP가 고정되어 있지 않다&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HLS는 단일 영상 파일이 아니라 여러 segment와 playlist로 구성됩니다. 따라서 파일이 생성되었다고 해서 곧바로 안정적인 HLS 결과물이라고 볼 수는 없습니다. 특히, ABR 스트리밍에서는 segment 길이, keyframe 위치, rendition 간 segment 정렬이 중요합니다. 화질 전환은 보통 segment 경계에서 발생하기 때문에, keyframe과 segment 구조가 어긋나면 seek나 화질 전환 시점에서 문제가 생길 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;827&quot; data-origin-height=&quot;489&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d73a1F/dJMcaaeMsoW/BgsvKsq9RP7xFQnUYOuRUK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d73a1F/dJMcaaeMsoW/BgsvKsq9RP7xFQnUYOuRUK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d73a1F/dJMcaaeMsoW/BgsvKsq9RP7xFQnUYOuRUK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd73a1F%2FdJMcaaeMsoW%2FBgsvKsq9RP7xFQnUYOuRUK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;237&quot; data-origin-width=&quot;827&quot; data-origin-height=&quot;489&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;키프레임은 혼자서 완전히 디코딩되는 프레임이고, GOP(Group of Pictures)는 키프레임 하나부터 다음 키프레임 직전까지의 묶음을 말합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 기존 ffmpeg 명령에는 키프레임 간격을 정하는 옵션(-g, -keyint_min, force_key_frames)이 하나도 없었습니다. 키프레임을 언제 넣을지를 인코더 자율에 맡긴 셈입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 되면 세그먼트는 시간(ex. 6초)으로 자르는데, 키프레임 간격이 제멋대로면 세그먼트 시작점이 키프레임과 맞지 않습니다. 그래서&amp;nbsp;화질 전환(ABR) 시 깨지거나, 특정 시점 이동(seek)이 부정확하거나, 세그먼트 단위로 영상을 다룰 때 경계가 어긋납니다. &lt;br /&gt;측정&amp;nbsp;관점에서도&amp;nbsp;세그먼트&amp;nbsp;경계가&amp;nbsp;들쭉날쭉하면&amp;nbsp;구간&amp;nbsp;단위&amp;nbsp;분석이&amp;nbsp;불안정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;711&quot; data-origin-height=&quot;498&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bv6nOf/dJMcabdAJK7/Mx2BKYcN6G5Y8jdlZAbHF0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bv6nOf/dJMcabdAJK7/Mx2BKYcN6G5Y8jdlZAbHF0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bv6nOf/dJMcabdAJK7/Mx2BKYcN6G5Y8jdlZAbHF0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbv6nOf%2FdJMcabdAJK7%2FMx2BKYcN6G5Y8jdlZAbHF0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;560&quot; height=&quot;392&quot; data-origin-width=&quot;711&quot; data-origin-height=&quot;498&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GOP를&amp;nbsp;일정&amp;nbsp;간격(예:&amp;nbsp;2초)으로&amp;nbsp;고정하고,&amp;nbsp;장면&amp;nbsp;전환마다&amp;nbsp;키프레임을&amp;nbsp;추가로&amp;nbsp;넣지&amp;nbsp;않도록(closed&amp;nbsp;GOP)&amp;nbsp;만들어&amp;nbsp;세그먼트&amp;nbsp;경계와&amp;nbsp;키프레임을&amp;nbsp;정렬합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저는 &lt;b&gt;GOP&lt;/b&gt;를 2초로 설정했는데, 중요한 건 &lt;b&gt;세그먼트 길이의 약수로 설정&lt;/b&gt;하는 것입니다. 세그먼트의 경계와 키프레임이 일치해야 영상을 끊김없이 독립적으로 제공할 수 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;추가로 세그먼트 길이는 6초로 설정했습니다. 일반적으로 HLS에서 6초를 많이 사용한다고 하며, 길이에 따라 장단점이 존재합니다. 세그먼트가 길면 압축 효율이 좋고 클라이언트가 적은 요청으로 재생할 수 있다는 장점이 있지만, 네트워크 상황에 민감하게 반응하지 못 한다는 단점이 있습니다. 반대로 세그먼트 길이가 짧으면 네트워크 상황에 따라 빠르게 화질을 변경할 수 있지만, 압축 효율이 좋지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, independent_segments를 설정하여 각 세그먼트가 독립적으로 디코딩 가능하도록 했습니다. 결국 세그먼트 단위로 전송되고 플레이어에서 재생되기 때문에 하나의 세그먼트만 있어도 그만큼의 영상을 시청할 수 있도록 하기 위함입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;4. VMAF 측정 방식 문제&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;HLS 산출물에서 확인된 구조적 문제를 정리한 뒤에는 VMAF 측정 방식 자체를 확인했습니다.&amp;nbsp;&lt;/span&gt;&lt;span&gt;여기서 확인하려는 것은 &amp;ldquo;영상이 정말 나쁜가?&amp;rdquo;가 아니라 &amp;ldquo;같은 장면끼리 비교하고 있는가?&amp;rdquo;입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1075&quot; data-origin-height=&quot;706&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PQRMQ/dJMcafNYiOY/nmKUpXS43Nnebcszh12MMk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PQRMQ/dJMcafNYiOY/nmKUpXS43Nnebcszh12MMk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PQRMQ/dJMcafNYiOY/nmKUpXS43Nnebcszh12MMk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPQRMQ%2FdJMcafNYiOY%2FnmKUpXS43Nnebcszh12MMk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;328&quot; data-origin-width=&quot;1075&quot; data-origin-height=&quot;706&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞에서 본 측정에서 고복잡도(Tears) 영상의 VMAF 점수가 낮았습니다. 이에 대한 결과 지표를 중심으로 하나씩 원인을 파악하고 개선해보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4-1. 시도 1: 시작 시간 맞추기&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1181&quot; data-origin-height=&quot;421&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dqo1RA/dJMcafHbdl8/3ALs1diKHlHA6p3UZk2oj0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dqo1RA/dJMcafHbdl8/3ALs1diKHlHA6p3UZk2oj0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dqo1RA/dJMcafHbdl8/3ALs1diKHlHA6p3UZk2oj0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdqo1RA%2FdJMcafHbdl8%2F3ALs1diKHlHA6p3UZk2oj0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;540&quot; height=&quot;192&quot; data-origin-width=&quot;1181&quot; data-origin-height=&quot;421&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;측정 전 ffprobe로 원본과 HLS 출력의 정보를 확인했는데, 이를 바탕으로 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;살펴보니&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;b&gt;HLS 결과물이 원본보다 늦게 시작&lt;/b&gt;했습니다(BBB 1.445초, Meridian 1.433초, Tears 1.463초). VMAF는 '원본 0초 프레임 &amp;harr; 변환본 0초 프레임'을 비교한다고 가정하는데, 한쪽이 1.4초 밀려 있으면 같은 인덱스인데 다른 장면을 비교하게 되므로 정상적인 비교를 할 수 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 먼저 시작 시점을 맞추는 필터&amp;nbsp;&lt;b&gt;setpts=PTS-STARTPTS&lt;/b&gt;(각 입력의 첫 프레임을 0초로 당김)를 떠올려 바로 적용해봤습니다. 여기서 PTS는 간단하게 말하면 '이 프레임을 영상의 몇 초 시점에 화면에 보여줘야 하는가'를 나타내는 시간표입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;745&quot; data-origin-height=&quot;687&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dz7vyP/dJMcadihPoU/Hztq1P6Olnt0KQNb8F81Hk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dz7vyP/dJMcadihPoU/Hztq1P6Olnt0KQNb8F81Hk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dz7vyP/dJMcadihPoU/Hztq1P6Olnt0KQNb8F81Hk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdz7vyP%2FdJMcadihPoU%2FHztq1P6Olnt0KQNb8F81Hk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;369&quot; data-origin-width=&quot;745&quot; data-origin-height=&quot;687&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tears에 적용하니 전체 영상 평균이 회복됐지만, 저복잡도인 BBB에서는 오히려 점수가 크게 떨어졌습니다. 흐르는 눈물을 닦고 제가 놓친 것이 무엇인가 고민해보았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세 영상 모두 start_time이 비슷한 1.4초였는데 결과가 갈렸으므로 start_time이 직접적인 원인이 아니었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;찾아보니 setpts는 같은 장면을 찾아 맞춰주는 필터가 아니라 첫 PTS를 0으로 미는 필터였습니다. &lt;b&gt;Tears&lt;/b&gt;는 그 밀림이 &lt;b&gt;오차&lt;/b&gt;라 0으로 당기는 게 도움이 됐지만, &lt;b&gt;BBB&lt;/b&gt;는 그 밀림 자체가 '원본이 정확히 몇 프레임 늦다'는 &lt;b&gt;의미 있는 정렬 정보&lt;/b&gt;였습니다. 이 상태에서 setpts가&amp;nbsp;그&amp;nbsp;정보를&amp;nbsp;지워버리니,&amp;nbsp;오히려&amp;nbsp;어긋난&amp;nbsp;프레임끼리&amp;nbsp;비교된&amp;nbsp;것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진짜 기준은 디코딩된 첫 프레임의 PTS가 fps 프레임 격자(1/fps 간격의 눈금) 위 어디에 놓이는가이고, 모든 영상에 같은 보정을 적용하면 안 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4-2. 시도 2: 영상마다 다르게 정렬하기&lt;/b&gt;&lt;b&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;FPS와 PTS&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;780&quot; data-origin-height=&quot;341&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wL8S6/dJMcaay5M2e/g7Ckk7ACwCUNixx6dCR7xk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wL8S6/dJMcaay5M2e/g7Ckk7ACwCUNixx6dCR7xk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wL8S6/dJMcaay5M2e/g7Ckk7ACwCUNixx6dCR7xk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwL8S6%2FdJMcaay5M2e%2Fg7Ckk7ACwCUNixx6dCR7xk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;480&quot; height=&quot;210&quot; data-origin-width=&quot;780&quot; data-origin-height=&quot;341&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;fps는 프레임의 시각(PTS)을 결정합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;fps&amp;nbsp;=&amp;nbsp;초당&amp;nbsp;프레임&amp;nbsp;수&amp;nbsp;&amp;rarr;&amp;nbsp;1프레임의&amp;nbsp;시간&amp;nbsp;=&amp;nbsp;1/fps&amp;nbsp;(30fps면&amp;nbsp;0.033초,&amp;nbsp;24fps면&amp;nbsp;0.042초)&lt;/li&gt;
&lt;li&gt;PTS = 그 프레임이 표시될 시각. CFR이면 프레임 N의 PTS = 첫 PTS + N &amp;times; (1/fps)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프레임들은 1/fps 간격으로 시간축에 일정하게 놓입니다. 이 균일한 눈금이 프레임 격자입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Offset이 정수냐 비정수냐&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원본과 HLS는 같은 fps라 격자 간격은 똑같습니다. 다만 첫 PTS가 다르면 격자가 통째로 옆으로 밀리는데(offset), 이 밀림이 프레임 간격의 정수배냐 아니냐가 갈림길입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1456&quot; data-origin-height=&quot;379&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/byOaqB/dJMcaaZ7euz/0MBhUzfpCLSLCmpLgcn1D0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/byOaqB/dJMcaaZ7euz/0MBhUzfpCLSLCmpLgcn1D0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/byOaqB/dJMcaaZ7euz/0MBhUzfpCLSLCmpLgcn1D0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbyOaqB%2FdJMcaaZ7euz%2F0MBhUzfpCLSLCmpLgcn1D0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;760&quot; height=&quot;198&quot; data-origin-width=&quot;1456&quot; data-origin-height=&quot;379&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;BBB:&lt;/b&gt; offset 0.067초 = &lt;b&gt;30fps로 정확히 2.0프레임(정수)&lt;/b&gt; &amp;rarr; 두 격자 눈금이 포개짐 &amp;rarr; 1:1로 깔끔. 이 2프레임은 &quot;원본이 2칸 늦다&quot;는 정확한 정렬 정보라 &lt;b&gt;보존&lt;/b&gt;해야 합니다. 따라서 이를 setpts로 지우면 깨지는 것입니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Tears:&lt;/b&gt; offset 0.003초 = &lt;b&gt;24fps로 0.072프레임(비정수)&lt;/b&gt; &amp;rarr; 눈금이 영영 포개지지 않아 framesync가 경계에서 흔들립니다. 그래서 &lt;b&gt;0으로 리셋(reset)&lt;/b&gt;해 양쪽을 같은 칸에 올리는 게 낫습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;보강: 프레임 끝단 오염 차단&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시작점을 맞춰도 원본과 HLS의 프레임 수가 미세하게 다르면 끝부분에서 한 칸씩 밀려 비교가 오염될 수 있습니다. 그래서 측정 명령에 'shortest=1:repeatlast=0:eof_action=endall'을 넣어 짧은 쪽 기준으로 비교를 종료하고, 끝 프레임이 반복되며 점수를 왜곡하는 것을 막았습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1142&quot; data-origin-height=&quot;506&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ctxgVd/dJMcaay5N57/kPLS34am8QKowSTjgERpyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ctxgVd/dJMcaay5N57/kPLS34am8QKowSTjgERpyk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ctxgVd/dJMcaay5N57/kPLS34am8QKowSTjgERpyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FctxgVd%2FdJMcaay5N57%2FkPLS34am8QKowSTjgERpyk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;440&quot; height=&quot;195&quot; data-origin-width=&quot;1142&quot; data-origin-height=&quot;506&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;결과&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1505&quot; data-origin-height=&quot;703&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/B6BY6/dJMcac4GTQk/9IHdUQDwaKP5D3SGGHQ64K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/B6BY6/dJMcac4GTQk/9IHdUQDwaKP5D3SGGHQ64K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/B6BY6/dJMcac4GTQk/9IHdUQDwaKP5D3SGGHQ64K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FB6BY6%2FdJMcac4GTQk%2F9IHdUQDwaKP5D3SGGHQ64K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;620&quot; height=&quot;290&quot; data-origin-width=&quot;1505&quot; data-origin-height=&quot;703&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정렬을 모두 보정하자 Tears의 중간값은 90점대, 평균은 70점대가 나타났습니다. 이는 아직 이상치가 존재한다는 신호였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음 측정 결과인 '비정상 저점'의 상당 부분은 화질이 아니라 측정 정렬 문제였습니다. 다만, 정렬 후에도 Tears 평균은 72~77로 90 미만이 남았습니다. 남은 문제는 VMAF 측정 문제가 아닌 &lt;b&gt;인코딩 결과물에 대한 보정이 필요&lt;/b&gt;했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;5. CFR로 재인코딩&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;측정 자체를 신뢰할 수 있게 만든 다음, 3장에서 살펴본 산출물 결함(키프레임/GOP 등)을 실제로 고쳐 HLS를 CFR로 재인코딩하고 같은 규칙으로 다시 측정했습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;VFR 원본과 CFR 출력의 비대칭&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;VFR은 Variable Frame Rate, 즉 프레임 간격이 일정하지 않은 영상이고, CFR은 Constant Frame Rate, 즉 일정한 간격으로 프레임이 배치된 영상입니다. VMAF는 프레임 단위 비교이기 때문에, &lt;b&gt;한쪽은 VFR이고 다른 한쪽은 CFR이면 문제&lt;/b&gt;가 생길 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;Tears는 원본이 &lt;b&gt;VFR(가변 프레임레이트)&lt;/b&gt;이었습니다(다른 영상은 원본이 CFR). reset_pts로 시작점은 맞췄지만, VFR 원본과 CFR 출력의 프레임 간격 자체가 달라 여전히 짝이 어긋났습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;기존 명령에는 출력 프레임레이트를 명시적으로 CFR로 고정하는 정책이 없었습니다. 그래서 입력이 VFR일 때 출력의 프레임 간격을 어떤 기준으로 정규화할지 파이프라인에서 결정하지 못했습니다. 이 점이 아래 두 문제를 일으켰습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;측정: 시작점을 맞춰도(reset) 프레임 간격이 불규칙해서, 중간 프레임들이 다시 한 칸씩 어긋납니다. 시작만 정렬해선 부족했던 이유입니다.&lt;/li&gt;
&lt;li&gt;재생&amp;middot;세그먼트: 프레임 타이밍이 들쭉날쭉하면 세그먼트 경계 정렬과 플레이어 동기화에 불리합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;885&quot; data-origin-height=&quot;347&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ctCD6R/dJMcajbBZRn/TZ50iAbX7H0jFjipPa0Oyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ctCD6R/dJMcajbBZRn/TZ50iAbX7H0jFjipPa0Oyk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ctCD6R/dJMcajbBZRn/TZ50iAbX7H0jFjipPa0Oyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FctCD6R%2FdJMcajbBZRn%2FTZ50iAbX7H0jFjipPa0Oyk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;251&quot; data-origin-width=&quot;885&quot; data-origin-height=&quot;347&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 인코딩할 때 '-r {대표 fps} -fps_mode cfr'로 &lt;b&gt;출력을 CFR로 고정&lt;/b&gt;했습니다. 입력이 VFR이어도 출력은 일정한 간격의 프레임으로 정규화하여 일정한 fps 격자 위에 놓이도록 만든 것입니다. &lt;br /&gt;&lt;br /&gt;다만 VFR을 CFR로 바꾸는 과정에서는 프레임 복제나 삭제가 발생할 수 있습니다. 이는 원본의 시간 리듬을 일부 바꿀 수 있다는 단점이 있지만, HLS 재생 안정성과 VMAF 측정 일관성을 얻기 위한 선택이었습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;그리고 측정할 때는 원본도 출력과 같은 fps 격자에 올린 뒤 비교했습니다. 즉 &amp;ldquo;원본 VFR vs 출력 CFR&amp;rdquo;을 그대로 비교하지 않고, 양쪽을 같은 시간 격자에 맞춘 상태에서 VMAF를 측정했습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이렇게&amp;nbsp;출력을&amp;nbsp;CFR로&amp;nbsp;만들어&amp;nbsp;두면,&amp;nbsp;측정할&amp;nbsp;때&amp;nbsp;원본(VFR)과&amp;nbsp;출력(CFR)을&amp;nbsp;같은&amp;nbsp;fps&amp;nbsp;격자&amp;nbsp;위에&amp;nbsp;올려&amp;nbsp;공정하게&amp;nbsp;비교할&amp;nbsp;수&amp;nbsp;있습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;그래서 원본과 출력을 모두 같은 24fps 격자에 올린 뒤 정렬하는 'symmetric_cfr_reset_pts'를 적용했고, Tears의 VMAF 점수는 360p 94.97 / 720p 95.52 / 1080p 95.56으로 정상 수준에 올라왔습니다.&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;아래 표로 정리해보았습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 127px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 15.4651%; height: 21px;&quot;&gt;&lt;b&gt;영상&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20.9303%; height: 21px;&quot;&gt;&lt;b&gt;원본 프레임레이트&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%; height: 21px;&quot;&gt;&lt;b&gt;인코딩 (HLS 출력)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 34.0697%; height: 21px;&quot;&gt;&lt;b&gt;측정 (VMAF 정렬)&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 15.4651%; height: 21px;&quot;&gt;&lt;b&gt;BBB&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20.9303%; height: 21px;&quot;&gt;CFR 30fps&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%; height: 21px;&quot;&gt;-fps mode cfr -&amp;gt; CFR 30fps&lt;/td&gt;
&lt;td style=&quot;width: 34.0697%; height: 21px;&quot;&gt;preserve_pts (PTS 보존)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 15.4651%; height: 21px;&quot;&gt;&lt;b&gt;Meridian&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20.9303%; height: 21px;&quot;&gt;CFR 59.94fps&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%; height: 21px;&quot;&gt;-fps mode cfr -&amp;gt; CFR 59.94fps&lt;/td&gt;
&lt;td style=&quot;width: 34.0697%; height: 21px;&quot;&gt;preserve_pts (PTS 보존)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 64px;&quot;&gt;
&lt;td style=&quot;width: 15.4651%; height: 64px;&quot;&gt;&lt;b&gt;Tears&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 20.9303%; height: 64px;&quot;&gt;VFR&lt;/td&gt;
&lt;td style=&quot;width: 29.5349%; height: 64px;&quot;&gt;-fps mode cfr -&amp;gt; CFR 24fps&lt;/td&gt;
&lt;td style=&quot;width: 34.0697%; height: 64px;&quot;&gt;symmetric_cfr_reset_pts (원본, 출력을 원본(24) fps 격자로 맞춤 + 시작점 리셋)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;text-align: justify;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;6. 결론 및 정리&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;871&quot; data-origin-height=&quot;639&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Mk87h/dJMcagMSjJR/8ad2SFGjYfOV3GuzP2xtlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Mk87h/dJMcagMSjJR/8ad2SFGjYfOV3GuzP2xtlK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Mk87h/dJMcagMSjJR/8ad2SFGjYfOV3GuzP2xtlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMk87h%2FdJMcagMSjJR%2F8ad2SFGjYfOV3GuzP2xtlK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;460&quot; height=&quot;337&quot; data-origin-width=&quot;871&quot; data-origin-height=&quot;639&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;BBB와 Meridian은 원래 높았던 점수를 유지했고, Tears의 점수가 정상 궤도로 진입했다고 볼 수 있는 값이 되었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1157&quot; data-origin-height=&quot;857&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WijHF/dJMcadoWUtj/WgZIRPKWgeAmiEUSMDdP6K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WijHF/dJMcadoWUtj/WgZIRPKWgeAmiEUSMDdP6K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WijHF/dJMcadoWUtj/WgZIRPKWgeAmiEUSMDdP6K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWijHF%2FdJMcadoWUtj%2FWgZIRPKWgeAmiEUSMDdP6K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;460&quot; height=&quot;341&quot; data-origin-width=&quot;1157&quot; data-origin-height=&quot;857&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Tears의 상승을 단계별로 그려봤습니다. 보정 전 41.9 &amp;rarr; 측정 정렬만 바로잡은 단계(기존 출력 그대로) 74.6 &amp;rarr; 출력 개선 + 대칭 측정 95.4로 상승분 기준 약 60%는 정렬 보정 단계에서 회복되었고, 화질 자체 문제는 일부였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3장에서 개선한 트랜스코딩 과정에서의 보강은 &lt;span style=&quot;letter-spacing: 0px;&quot;&gt;GOP/세그먼트 보강은 재생 안정성이 1차 목적이고, CFR/대칭 측정은 VMAF 신뢰성에도 관련이 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;마무리&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서 다룬 작업은 'VMAF를 몇 점 올렸다'보다 아래 두 가지 목적이 있었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;측정을 신뢰할 수 있게 만들었다 (정렬&amp;middot;프레임&amp;middot;해상도 기준 확립).&lt;/li&gt;
&lt;li&gt;HLS 구조를 안정화했다 (GOP&amp;middot;세그먼트&amp;middot;매니페스트&amp;middot;CFR).&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이들이 합쳐져 신뢰 가능한 VMAF 기준선을 얻었고, 이것이 2편(Per-Title Encoding)의 출발점이 되어 영상과 품질 측정에서의 문제로 슬플 일이 없게 해줍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인코딩하고 측정하는 시간이 너무 오래 걸려서 힘들었습니다... 영상 관련 내용도 이론으로 공부를 해 두었던 게 조금은 도움이 되었던 것 같아요. 이것저것 참고하고, 의심하며 측정을 여러 번 해봤습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 편에는 넷플릭스에서 제시한 Per-Title-Encoding을 적용한 과정을 다뤄보겠습니다.&amp;nbsp;&lt;/p&gt;</description>
      <author>phonil</author>
      <guid isPermaLink="true">https://yestomo.tistory.com/25</guid>
      <comments>https://yestomo.tistory.com/25#entry25comment</comments>
      <pubDate>Sat, 16 May 2026 22:01:21 +0900</pubDate>
    </item>
    <item>
      <title>[O+T] 영상 이어보기 처리량 개선하기</title>
      <link>https://yestomo.tistory.com/24</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;0. 개요&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;유튜브나 넷플릭스, 혹은 다른 OTT 서비스에서 영상을 시청하면 아래와 같이 어디까지 봤는지 그 지점을 표시해줍니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;761&quot; data-origin-height=&quot;362&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/l4FY1/dJMcacpzb3d/X1pUBrEpyl4Pb3p6fr7JA1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/l4FY1/dJMcacpzb3d/X1pUBrEpyl4Pb3p6fr7JA1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/l4FY1/dJMcacpzb3d/X1pUBrEpyl4Pb3p6fr7JA1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fl4FY1%2FdJMcacpzb3d%2FX1pUBrEpyl4Pb3p6fr7JA1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;420&quot; height=&quot;362&quot; data-origin-width=&quot;761&quot; data-origin-height=&quot;362&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이렇게 시청 전에 재생 지점을 보여주는 것뿐만 아니라, 이전에 시청했던 영상을 다시 볼 때 &lt;b&gt;마지막으로 봤던 지점부터 이어 볼 수 있는 기능&lt;/b&gt;을 제공합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;특히, 사용자가 영상을 시청하는 동안 일정 주기로 현재 재생 위치를 갱신해야 하기 때문에 같은 사용자가 같은 영상에 대해 짧은 간격으로 반복 요청을 보내는 구조가 됩니다. 또한, OTT 서비스에서 사용자는 영상 시청에 대부분의 시간을 사용하므로 자연스레 트래픽이 가장 몰리는 지점이 됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;이 요청은 같은 사용자와 같은 영상에 대해 짧은 간격으로 반복 호출된다는 특징이 있습니다. 따라서 &lt;/span&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;일반적인 조회와 다르게 반복적인 write 요청을 어떻게 더 효율적으로 처리할 것인지의 관점에서 접근했습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이어보기 갱신 작업이 트래픽을 더 잘 견디도록 개선한 과정을 정리해보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;1. 테스트 결과&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;많은 트래픽이 예상되는 지점이므로 &lt;b&gt;K6 부하 테스트&lt;/b&gt;를 진행해봤습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;각 VU가 이어보기 지점을 5초마다 갱신하는 /playlist API를 호출합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1680&quot; data-origin-height=&quot;1150&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kxn1f/dJMcaglfnzr/hsbo8qFFdgJzJIGSTEjae1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kxn1f/dJMcaglfnzr/hsbo8qFFdgJzJIGSTEjae1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kxn1f/dJMcaglfnzr/hsbo8qFFdgJzJIGSTEjae1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fkxn1f%2FdJMcaglfnzr%2Fhsbo8qFFdgJzJIGSTEjae1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;493&quot; data-origin-width=&quot;1680&quot; data-origin-height=&quot;1150&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;VU2000부터 평균 응답 시간이 매우 높아지는 것을 볼 수 있고, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;VU3000이상은 &lt;/span&gt;에러율이 너무 높아서 기존 상태에서 사실상 무의미했습니다. 처리량 역시 기대에 미치지 못했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;요청 스레드 대기 증가&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;DB Connection 부족&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;GC&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;등이 주요 병목 원인으로 예상되었고, Grafana를 살펴봤습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;704&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WJLTw/dJMcadPwDAg/FnDDcf2bmBChSowbcxRRR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WJLTw/dJMcadPwDAg/FnDDcf2bmBChSowbcxRRR1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WJLTw/dJMcadPwDAg/FnDDcf2bmBChSowbcxRRR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWJLTw%2FdJMcadPwDAg%2FFnDDcf2bmBChSowbcxRRR1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;704&quot; data-origin-width=&quot;2048&quot; data-origin-height=&quot;704&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;우선 VU2000~부터는 지표 수집 자체가 잘 되지 않았고, 로그에서 request fail이 많이 발생한 것을 확인했습니다. 이는 요청 스레드의 대기가 증가해서 요청 자체가 실패한 것으로 파악되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;서버 스펙이 작아서 발생하는 것이기도 하지만, 병목 지점이 있기 때문에 요청-응답의 순환이 원활하지 않은 것이 근본적인 문제였습니다. &lt;b&gt;DB Connection 부족&lt;/b&gt;이 발견되었고, 쿼리 자체의 속도 혹은 쿼리 개수로 문제를 좁혔습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;타임아웃은 없었지만 pending 상태가 70개를 넘어서는 것을 통해 쿼리 자체의 속도보다는 &lt;b&gt;쿼리 개수가 너무 많아서 발생하는 병목&lt;/b&gt;으로 확인했고, 실제 로직에서 사용하는 쿼리 또한 간단했기에 쏟아지는 트래픽에서 &lt;b&gt;쿼리 수를 줄이는 방향&lt;/b&gt;으로 잡았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;컴퓨터 자원의 부담을 최소화&lt;/b&gt;한 상태에서 &lt;b&gt;응답 시간을 줄이고 처리량을 향상시키는 것을 목적&lt;/b&gt;으로 진행했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;2. Read: 이어보기 캐시 도입&lt;/span&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;2-1. 이어보기 지점 갱신 로직&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;먼저, 이어보기 기능&lt;/span&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;을 담당하는 Playback 갱신 API의 &lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;로직을 살펴봤습니다.&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1206&quot; data-origin-height=&quot;496&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/opRvY/dJMcag6BBTi/DkJAKgldqlEqyOE9ew9jSk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/opRvY/dJMcag6BBTi/DkJAKgldqlEqyOE9ew9jSk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/opRvY/dJMcag6BBTi/DkJAKgldqlEqyOE9ew9jSk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FopRvY%2FdJMcag6BBTi%2FDkJAKgldqlEqyOE9ew9jSk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;620&quot; height=&quot;496&quot; data-origin-width=&quot;1206&quot; data-origin-height=&quot;496&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Select문으로 mediaId에 대한 예외 처리를 먼저 하고 contentsId를 가져온 후 Playback에 대한 Upsert를 진행합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;우선 &lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;쿼리를 단순화하고자 &lt;/span&gt;매번 Upsert가 아닌 플레이어 화면 진입 후 &lt;b&gt;첫 재생 시 Insert, 영상 재생 중 update API로 분리&lt;/b&gt;했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Playback 갱신 요청은 사용자가 영상을 시청하는 동안 일정 주기로 반복해서 호출됩니다. 한 번 호출되고 끝나는 요청이 아니라 같은 사용자와 같은 영상에 대해 짧은 간격으로 계속 들어오는 write 요청입니다. 이런 요청 경로에서 기존 구조는 성공 요청마다 항상 두 번의 데이터베이스 접근이 필요했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;한 번의 요청만 보면 큰 차이가 없어 보일 수 있지만, 이 요청은 반복 호출되기 때문에 요청당 데이터베이스 접근이 한 번 더 필요하다는 것이 부담된다고 생각했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;특히, 대부분인 성공 요청도 체크를 위해 항상 먼저 조회를 수행해야 했고, 아래과 같은 아쉬움이 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;성공 경로에서도 &lt;b&gt;DB round trip이 2번 발생&lt;/b&gt;한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;반복적인 write 요청 경로에서 &lt;b&gt;select 비용이 누적&lt;/b&gt;된다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2-2. 단일 쿼리 통합을 선택하지 않은 이유&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;처음에는 기존 SELECT + UPSERT 흐름을 하나의 SQL로 합치는 방향을 생각했습니다. &lt;b&gt;INSERT ... SELECT ... ON DUPLICATE KEY UPDATE&lt;/b&gt; 형태로 mediaId -&amp;gt; contentsId 변환, 재생 가능 여부 확인, playback upsert를 하나의 경로로 묶는 방식입니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779165885152&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;INSERT INTO playback (member_id, contents_id, position_sec, created_date, modified_date, status)
SELECT :memberId, c.id, :positionSec, NOW(), NOW(), 'ACTIVE'
FROM contents c JOIN media m ON m.id = c.media_id
WHERE m.id = :mediaId AND c.status = 'ACTIVE' ...
ON DUPLICATE KEY UPDATE position_sec = :positionSec, ...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1199&quot; data-origin-height=&quot;370&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/beAzsH/dJMcafNrBWh/kLbKDTgx07dq9MRqvaYu9K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/beAzsH/dJMcafNrBWh/kLbKDTgx07dq9MRqvaYu9K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/beAzsH/dJMcafNrBWh/kLbKDTgx07dq9MRqvaYu9K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbeAzsH%2FdJMcafNrBWh%2FkLbKDTgx07dq9MRqvaYu9K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;370&quot; data-origin-width=&quot;1199&quot; data-origin-height=&quot;370&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이 방식은 동기 write의 경우 장점이 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;성공 경로의 DB 왕복을 줄일 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;조회와 저장을 하나의 경로로 묶을 수 있다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;하지만 이번 작업은 반복적으로 들어오는 write 자체를 버퍼링하고, 이후 bulk로 처리하는 구조가 필요했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1779165807206&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;int affectedRows = playbackRepository.updatePlaybackByMediaId(memberId, mediaId, positionSec);
if (affectedRows &amp;gt; 0)
	return;

if (!contentsRepository.existsPlayableByMediaId(mediaId)
	throw new BusinessException(ErrorCode.CONTENTS_NOT_FOUND);&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이를 기준으로 보면 단일 쿼리 통합에는 한계가 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;잘못된 mediaId에 대해 &lt;b&gt;즉시 실패 응답을 주기가 어려움&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;버퍼에 적재한 뒤 바로 204를 반환하면 검증이 DB write 시점으로 밀리는 순간 잘못된 요청도 일단 성공처럼 보이게 됨&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;bulk upsert의 복잡함&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;나중에 버퍼에 쌓인 데이터를 한 번에 반영할 때는 검증과 변환이 붙은 무거운 쿼리보다 필요한 데이터만 빠르게 반영하는 쿼리가 더 적합함&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Upsert -&amp;gt; Update 변경 후에도 결국 조인이 필요함&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;따라서 검증 + 저장을 하나의 SQL로 묶는 방식보다 &lt;b&gt;검증은 앞단에서 빠르게 처리하고, 저장은 뒤로 미루는 구조&lt;/b&gt;가 더 자연스럽다고 판단했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2-3. 캐시 도입&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #24292e; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;갱신 요청 외에도 해당 요청이 유효한지 확인하기 위해 &lt;b&gt;MediaID를 검증&lt;/b&gt;하는 과정에서 지속적으로 DB Connection을 사용하여 리소스를 낭비하고 있었습니다. 이때&amp;nbsp;&lt;b&gt;캐시를 도입&lt;/b&gt;하여 &lt;b&gt;DB Connection 사용을 최소화&lt;/b&gt;하는 방법으로 개선했습니다. &lt;span style=&quot;background-color: #ffffff; color: #24292e; text-align: start;&quot;&gt;영상 최초 진입 시에만 쿼리를 날리고, 그 다음 요청부터는 캐시에 접근합니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1482&quot; data-origin-height=&quot;756&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/B8V4y/dJMcacQGo5R/x4GWNkiyfeeWEtZHqeBwd1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/B8V4y/dJMcacQGo5R/x4GWNkiyfeeWEtZHqeBwd1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/B8V4y/dJMcacQGo5R/x4GWNkiyfeeWEtZHqeBwd1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FB8V4y%2FdJMcacQGo5R%2Fx4GWNkiyfeeWEtZHqeBwd1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;620&quot; height=&quot;756&quot; data-origin-width=&quot;1482&quot; data-origin-height=&quot;756&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;mediaId가 재생 가능한 대상인지 먼저 확인한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이 검증 결과를 캐시에 저장한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이후 요청에서는 &lt;b&gt;DB 조회 없이 캐시로 검증&lt;/b&gt;한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;검증을 통과한 요청만 갱신 작업을 진행한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;크게 아래 세 가지 이유로 캐시를 선택했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;자주 변경되지 않고 조회가 빈번하다&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;미디어는 관리자 페이지에서만 수정이 가능하고, 변경이 매우 드문 특성을 가지고 있습니다.&lt;span&gt;&amp;nbsp;반면, &lt;/span&gt;&lt;span&gt;이어보기 갱신은 영상을 시청하고 있는 모든 사용자로부터 5초마다 요청이 발생하여 같은 데이터를 반복적으로 조회하게 됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;미디어 수정은 관리자 기능을 통해 제한적으로 발생하므로, 수정 시점에 해당 캐시를 갱신하거나 삭제하는 방식으로 데이터 정합성 관리가 비교적 단순하다고 판단했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;잘못된 요청에 대해 즉시 실패 응답을 줄 수 있다&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;버퍼링 구조에서는 요청을 받은 뒤 바로&amp;nbsp;204를 반환하는 쪽이 자연스럽습니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이때 검증까지 뒤로 미뤄버리면 존재하지 않는 mediaId나 재생 불가능한 콘텐츠에 대해서도 일단 성공 응답을 주게 됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;반면, 앞단에서 캐시 기반 검증을 수행하면 재생 가능한 대상인지 먼저 판별한 뒤에만 버퍼에 적재할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;유효한 요청 -&amp;gt;&amp;nbsp;204&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;잘못된 요청 -&amp;gt;&amp;nbsp;404&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;를 바로 구분할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;bulk update를 단순한 저장 쿼리로 유지할 수 있다&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;검증 단계에서&amp;nbsp;contentsId까지 확정해 두면, flush 시점에는 더 이상&amp;nbsp;mediaId -&amp;gt; contentsId 변환이나 상태 검증이 필요하지 않습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;버퍼를 비울 때의 SQL은 단순해집니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;(memberId, contentsId, positionSec, ...)으로 Update&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt; &lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;Update&lt;/span&gt; 내 Join 없음&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;무거운 검증 로직을 다시 요청 시점으로 끌어오고, bulk update는 최대한 저장 전용 쿼리로 단순화할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2-4. 로컬 vs 글로벌 캐시&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;로컬 캐시를 사용할지 글로벌 캐시를 사용할지 결정해야 했는데, &lt;b&gt;다중 서버 환경임에도 로컬 캐시가 적합하다고 생각&lt;/b&gt;했습니다.&amp;nbsp; &lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;속도&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;글로벌 캐시는 매 요청마다 네트워크를 왕복해야 하지만, 로컬 캐시는 바로 응답 가능&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;캐시 장애 시 DB 부하&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;별도 캐시 서버에 장애가 발생하면 검증을 위한 Select 쿼리가 매 요청마다 DB에 발생&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;추가 인프라 및 관리의 어려움&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;Media 데이터 변경에 민감하지 않음&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;메타 데이터 변경이 빈번하지 않으며 Select 검증 로직에서 상태를 체크하지만, 이 값들이 변경되더라도 즉시 반영되어 playback 갱신을 막을 필요는 없음. 캐시 데이터의 일관성을 완벽히 유지하지 않고, TTL로 충분히 허용 가능한 수준.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2-5. 문제&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;정합성 문제&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;어떤 media가 &lt;/span&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;PUBLIC&lt;/span&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;에서&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;PRIVATE&lt;/span&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;로 바뀌거나,&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;COMPLETED&lt;/span&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt; 상태가 아닌 값으로 바뀌는 경우 캐시에 이전 상태가 남아 있으면 재생 불가능한 대상임에도 검증을 통과시킬 수 있습니다. 변경 후 이어보기 갱신은 큰 문제가 없기 때문에 &lt;b&gt;짧은 TTL&lt;/b&gt;로 충분히 관리 가능하다고 보았습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;메모리 관리&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;미디어 데이터에 대해 각 서버별로 같은 미디어를 캐싱하게 되며, 그 수가 많아질 경우 메모리 관리가 필수적입니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;적절한 TTL 설정&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;캐싱 미디어 수 제한&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;키-값 크기 최소화&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Grafana에서 Heap을 보며 Promotion, GC가 얼마나 일어나는지 체크해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2-6. 결론&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;로컬 캐시로 Caffeine을 사용했고, &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;아래 그림처럼&amp;nbsp;&lt;b&gt;검증에 캐시를 두어 Select를 최소화&lt;/b&gt;했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1321&quot; data-origin-height=&quot;754&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cgw1Zi/dJMcabxvNx0/FJ5OPhAz06XCxh3QPZyrfk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cgw1Zi/dJMcabxvNx0/FJ5OPhAz06XCxh3QPZyrfk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cgw1Zi/dJMcabxvNx0/FJ5OPhAz06XCxh3QPZyrfk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcgw1Zi%2FdJMcabxvNx0%2FFJ5OPhAz06XCxh3QPZyrfk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;560&quot; height=&quot;754&quot; data-origin-width=&quot;1321&quot; data-origin-height=&quot;754&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;구분&lt;/span&gt; &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;기존 흐름&lt;/span&gt; &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;개선 후 흐름&lt;/span&gt; &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;정상 요청&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;SELECT -&amp;gt; UPDATE&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;캐시 체크 -&amp;gt; Update&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;실패 요청&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;조회 단계에서 예외&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;캐시 확인 후 예외&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;3. Write: 문제 인식&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;캐시로 Select를 줄였으나, 부하 테스트 결과에는 큰 변화가 없었습니다. 여전히 이어보기 갱신 요청마다 Update 쿼리를 수행했고, 이로 인한 &lt;b&gt;DB 접근이 너무 잦았기 때문&lt;/b&gt;입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;캐시 후에도 눈에 띄는 개선이 없는 이유&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;여전히 Update 자체가 요청 수만큼 발생하기 때문에 요청 스레드가 매번 DB Update에 묶여, DB Connection이 금방 부족해졌습니다. 기본값인 이 설정을 늘리는 방법도 있지만, 처음부터 늘릴 것이 아니라고 생각해 다른 방식을 찾아보았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;동기 Write 구조의 한계&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;DB 접근 대기로 &lt;b&gt;요청 스레드의 대기가 늘어남&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;커넥션 점유 및 대기 시간동안 요청에 대한 &lt;b&gt;응답 시간이 지연&lt;/b&gt;됨&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;고부하에서 &lt;b&gt;톰캣 스레드 고갈&lt;/b&gt;로 &lt;b&gt;요청&lt;/b&gt; 자체가&amp;nbsp;&lt;b&gt;실패&lt;/b&gt;함&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;위의 이유들로 인해 &lt;b&gt;검증은 캐싱&lt;/b&gt;으로, &lt;b&gt;저장은 미루는 방식을 선택&lt;/b&gt;하여 사용자 요청에 대한 응답은 빨리 주는 구조로 변경하는 방안을 선택했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;4. Write: 버퍼링&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt; 버퍼를 두어 요청에 대한 작업을 모아두고 일정량 혹은 일정 주기마다 한 번에 DB에 접근하는 방식으로 변경했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;작업 특성&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Playback의 특성을 간단히 정리해보자면 아래와 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;어느 정도의 유실은 허용&lt;/b&gt;한다. (이어보기 요청 3~4회 정도의 사이클까지)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;즉시 반영되지 않아도 된다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;가장 최근 발생한 요청이 반영&lt;/b&gt;되어야 한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Write 비율이 매우 높다.&lt;/span&gt;&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;사용자 + 콘텐츠 조합으로 구분되며, &lt;b&gt;하나의 조합으로 지속적인 요청&lt;/b&gt;이 들어온다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Step 1. 큐 도입&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;요청과 DB Write를 분리하기 위해 작업을 큐에 모아 한 번에 갱신합니다.&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;&lt;b&gt;요청 스레드가 직접 DB에 접근하는 대신 큐에 작업만 넣고 즉시 응답&lt;/b&gt;하도록 바꿨습니다. 실제 DB 반영은 별도의 워커 스레드가 뒤에서 처리합니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;794&quot; data-origin-height=&quot;429&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/y39ya/dJMcafz4sX4/3DkiJwkVEvXdB1ibOMFDX0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/y39ya/dJMcafz4sX4/3DkiJwkVEvXdB1ibOMFDX0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/y39ya/dJMcafz4sX4/3DkiJwkVEvXdB1ibOMFDX0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fy39ya%2FdJMcafz4sX4%2F3DkiJwkVEvXdB1ibOMFDX0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;620&quot; height=&quot;429&quot; data-origin-width=&quot;794&quot; data-origin-height=&quot;429&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;로컬 큐 vs 글로벌 큐&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;캐시처럼 큐도 마찬가지로 로컬이냐 글로벌이냐 결정해야 했고, 큐 외의 다른 자료구조를 쓸 것인지 고민했습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 68px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 19.6511%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;후보&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 29.3024%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.0465%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;적합한 상황&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 19.6511%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;Local Queue&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 29.3024%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;네트워크 왕복, 인프라 추가 x&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.0465%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;서버 다운 시 큐 작업 유실, &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;서버 간 순서 보장 필요&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;빠른 속도 필요, 손실 허용&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 19.6511%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;Redis Write-Behind&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 29.3024%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;다중 서버 공유 간편, 순서 보장 가능&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.0465%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;네트워크 왕복 발생, 인프라 추가&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;다중 서버, 내구성&amp;nbsp;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 19.6511%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;In-Memory Map&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 29.3024%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;중복 key 자동 제거&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 26.0465%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;크기 한정 및 원자적 처리 어려움&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;중복이 많이 발생하는 경우&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;저는 &lt;b&gt;로컬 큐를 선택&lt;/b&gt;했습니다. 다중 서버 환경에서 순서 문제가 가장 크게 다가왔지만 충분히 해결할 수 있었고, 글로벌 큐 다운 시 트래픽을 견디기 어려울 것이라 생각했습니다. 또한, Playback 특성 상 주기가 짧다면 중복이 많이 발생하지 않고, 자연스러운 FIFO를 사용하고자 큐를 선택했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;LinkedBlockingQueue&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;큐는 LinkedBlockingQueue를 사용했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;bounded:&lt;/b&gt; 트래픽이 매우 많아져 큐에 작업이 무한히 쌓일 수 있으므로 큐의 상한을 정했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;put/take 락 분리:&lt;/b&gt; 큐에 작업을 넣는 쪽과 꺼내는 쪽의 락이 분리되어 있어 락 경합을 줄일 수 있습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;div style=&quot;background-color: #0f111a; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public class PlaybackCommandQueue {

    private final BlockingQueue&amp;lt;PlaybackCommand&amp;gt; queue;

    public PlaybackCommandQueue(int capacity) {
        this.queue = new LinkedBlockingQueue&amp;lt;&amp;gt;(capacity);
    }
    // offer(), drain(), size() ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;상한은 Bulk Size(1000)보다 큰 10000으로 잡았습니다. 순간적으로 몰리는 경우 Flush보다 더 많은 요청이 들어올 수 있기 때문입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;큐에 추가하는 함수는 put()이 아닌 &lt;b&gt;offer()&lt;/b&gt;를 사용했습니다. 큐에 빈 공간이 없는 경우 put()은 대기합니다. 하지만, 스레드 대기로 인해&amp;nbsp;트래픽이 늘어날수록 병목이 거대해질 수 있었고, 이어보기는 모든 데이터가 반드시 포함되어야 하는 것은 아니&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;므로&lt;/span&gt; 바로 false를 반환하는 offer()를 수행합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Step 2. Bulk Update&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;큐에 모은 PlaybackCommand 객체를 작업 단위로, 하나씩 쿼리를 날리는 것이 아니라 일정 주기마다 &lt;b&gt;Bulk Update를 수행&lt;/b&gt;했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;412&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nM6Dr/dJMcaffNM0F/FXQNKBR662WAAEQI9ZuimK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nM6Dr/dJMcaffNM0F/FXQNKBR662WAAEQI9ZuimK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nM6Dr/dJMcaffNM0F/FXQNKBR662WAAEQI9ZuimK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnM6Dr%2FdJMcaffNM0F%2FFXQNKBR662WAAEQI9ZuimK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;290&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;412&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;API 요청은 큐에 적재 후 사용자에게 응답하고, 백그라운드 스레드로 큐를 확인합니다. &lt;b&gt;큐를 보는 스레드는 하나로 고정하여 스레드 낭비를 줄였습니다.&lt;/b&gt; &lt;b&gt;Flush 트리거는 두 개&lt;/b&gt;로, &lt;b&gt;큐가 꽉 차거나 정해진 시간(5초 ~)마다&lt;/b&gt; 큐를 비우고, 쿼리를 날립니다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #0f111a; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;cpp&quot;&gt;&lt;code&gt;public record PlaybackCommand(
    long memberId,
    long contentsId,
    int positionSec,
    Instant requestedAt
) {
    public PlaybackKey key() {
        return new PlaybackKey(memberId, contentsId);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;A유저가 M영상에 대한 이어보기를 보내는 것을 구분하고자 &lt;b&gt;member, contents를 key로&lt;/b&gt; 잡았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;데드락&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Bulk Update로 인해 여러 대의 서버에서 Flush를 할 때,&amp;nbsp; &lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;Update 쿼리는 로우 락을 잡기 때문에&lt;/span&gt; &lt;b&gt;순서에 따라 데드락이 발생&lt;/b&gt;할 수 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1098&quot; data-origin-height=&quot;587&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/df8CgL/dJMcagscgtR/bvcBp4HZw2FDE27Usyri80/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/df8CgL/dJMcagscgtR/bvcBp4HZw2FDE27Usyri80/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/df8CgL/dJMcagscgtR/bvcBp4HZw2FDE27Usyri80/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdf8CgL%2FdJMcagscgtR%2FbvcBp4HZw2FDE27Usyri80%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1098&quot; height=&quot;587&quot; data-origin-width=&quot;1098&quot; data-origin-height=&quot;587&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;서버 1에서 K1, K2, K3로, 서버 2에서 K2, K1, K3 순서로 Flush를 진행할 때, 위처럼 락이 엇갈릴 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;쿼리를 날리기 전&lt;/b&gt;에 &lt;b&gt;키 기준으로 정렬해 데드락을 방지&lt;/b&gt;했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1780476941747&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;List&amp;lt;PlaybackCommand&amp;gt; sorted = commands.stream()
    .sorted(Comparator.comparing(PlaybackCommand::memberId)
                       .thenComparing(PlaybackCommand::contentsId))
    .toList();&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Step 3. 중복 제거&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;큐에서 작업을 꺼내는 Flush 주기가 사용자 이어보기 지점 갱신 주기보다 길다면 한 번의 Flush 작업에 동일한 키를 가진 Command가 여러 개 존재할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이어보기는 '가장 최근 발생'한 것만 DB에 반영되면 되므로 &lt;b&gt;한 번의 Flush에서&amp;nbsp;중복 키에 대한 비효율&lt;/b&gt;을 줄일 수 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;860&quot; data-origin-height=&quot;377&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dwTuKk/dJMcaiQ4aJW/6KchZ2O2Oy8kSE3dm3sPi1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dwTuKk/dJMcaiQ4aJW/6KchZ2O2Oy8kSE3dm3sPi1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dwTuKk/dJMcaiQ4aJW/6KchZ2O2Oy8kSE3dm3sPi1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdwTuKk%2FdJMcaiQ4aJW%2F6KchZ2O2Oy8kSE3dm3sPi1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;377&quot; data-origin-width=&quot;860&quot; data-origin-height=&quot;377&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;그림처럼 큐에 있는 5개의 작업에서 중복을 제거한 후 3개만 Flush 한다면 수행해야 할 작업이 줄어듭니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;하지만, 현재 유실 문제를 최소화하고자 클라이언트로부터 발생하는 이어보기 요청 주기와 Flush 주기를 5초로 같게 설정했기 때문에 하나의 큐에 동일한 키의 작업이 존재하지 않습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이는 트래픽이 더 높아져, 클라이언트가 작업을 모아서 주거나, Flush 주기를 늘리게 되는 경우 더욱 최적화할 수 있는 방안입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Step 4. 순서 보장&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;앞서 본 것처럼 이어보기는 '가장 최근 발생'한 것이 DB에 반영되어야 하는데, 여러 서버의 각 큐에서 동시다발적으로 Flush가 발생하면 &lt;b&gt;요청이 발생한 순서&lt;/b&gt;&lt;span style=&quot;color: #0a0a0a; text-align: start;&quot;&gt;와&amp;nbsp;&lt;/span&gt;&lt;b&gt;DB에 반영되는 순서&lt;/b&gt;&lt;span style=&quot;color: #0a0a0a; text-align: start;&quot;&gt;가 어긋날 수 있습니다.&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1780479062115&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;13:00:01  사용자 A &amp;rarr; 서버 2 큐: key=(42, 100), position=100, event_time=13:00:01
13:00:06  사용자 A &amp;rarr; 서버 1 큐: key=(42, 100), position=200, event_time=13:00:06

13:00:08  서버 1 Flush &amp;rarr; UPDATE playback SET position_sec=200
                         WHERE member_id=42 AND contents_id=100   &amp;rarr; DB position=200 ✅
13:00:10  서버 2 Flush &amp;rarr; UPDATE playback SET position_sec=100
                         WHERE member_id=42 AND contents_id=100   &amp;rarr; DB position=100 ❌&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt; 사용자 A가 200초 지점까지 봤는데, DB에는 100초가 마지막으로 기록되는 문제가 발생할 수 있습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;5초 주기로 이어보기 지점 갱신을 하니까 괜찮을 수 있다고 생각했지만, 사용자가 직접 바를 눌러서 다른 지점으로 이동하여 시청하는 경우 불편함이 있을 것이라고 생각했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Event_Time 컬럼 추가&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;요청이 발생한 시각을 데이터베이스에 함께 저장하고, 더 오래된 요청은 무시&lt;/b&gt;하는 방식으로 해결했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;playback&amp;nbsp;테이블에&amp;nbsp;event_time&amp;nbsp;컬럼을 추가하고, Update 조건으로&amp;nbsp;event_time을 확인하도록 구성했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1780500679266&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;UPDATE playback
SET position_sec = ?, modified_date = NOW(), event_time = ?
WHERE member_id = ? AND contents_id = ? AND ...
AND event_time &amp;lt; ?&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;사용자가 직접 영상 시청 지점을 이동할 수 있기 때문에 position으로 정렬하는 방식은 사용할 수 없었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;5. 결과&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;개선 작업을 진행하고 다시 부하 테스트를 진행했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1003&quot; data-origin-height=&quot;612&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dBxHJs/dJMcag6NjVG/aFDZ4C5ukRBxiXe00x3Gkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dBxHJs/dJMcag6NjVG/aFDZ4C5ukRBxiXe00x3Gkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dBxHJs/dJMcag6NjVG/aFDZ4C5ukRBxiXe00x3Gkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdBxHJs%2FdJMcag6NjVG%2FaFDZ4C5ukRBxiXe00x3Gkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;439&quot; data-origin-width=&quot;1003&quot; data-origin-height=&quot;612&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;VU2000부터 문제가 보이던 것이 &lt;b&gt;VU5000까지 에러 없이 처리&lt;/b&gt;하는 것을 확인했습니다. TPS 또한 VU / 5초 정도로 예상치만큼 요청을 처리하여 최대 761/s까지 나타나며 처리량이 상당이 높아졌습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;VU10000&lt;/b&gt;은 또다시 에러율이 높은데, 이는 &lt;b&gt;요청 자체가 실패&lt;/b&gt;하는 경우가 많았습니다. 요청을 받아줄 &lt;b&gt;톰캣 스레드가 부족&lt;/b&gt;하여 서버에 들어가지 못했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;DB 커넥션&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2490&quot; data-origin-height=&quot;514&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cEfkow/dJMcabxDm2g/nKtIR2acKHrv3amcU3adb1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cEfkow/dJMcabxDm2g/nKtIR2acKHrv3amcU3adb1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cEfkow/dJMcabxDm2g/nKtIR2acKHrv3amcU3adb1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcEfkow%2FdJMcabxDm2g%2FnKtIR2acKHrv3amcU3adb1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2490&quot; height=&quot;514&quot; data-origin-width=&quot;2490&quot; data-origin-height=&quot;514&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;기본값으로 설정해 둔 10개를 넘어 커넥션을 얻기 위해 대기하던 작업 전과 다르게 안정된 커넥션 사용을 보여주고 있었고, DB 커넥션 부족 문제는 해결되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;버퍼&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2525&quot; data-origin-height=&quot;465&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/F4caZ/dJMcadhXL2f/n45TFidPhkO5Xm9eVaE7ok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/F4caZ/dJMcadhXL2f/n45TFidPhkO5Xm9eVaE7ok/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/F4caZ/dJMcadhXL2f/n45TFidPhkO5Xm9eVaE7ok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FF4caZ%2FdJMcadhXL2f%2Fn45TFidPhkO5Xm9eVaE7ok%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2525&quot; height=&quot;465&quot; data-origin-width=&quot;2525&quot; data-origin-height=&quot;465&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;큐 사이즈와 실패 횟수를 메트릭으로 확인했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;큐가 상한까지 차지 않았고, 버려진 요소가 존재하지 않았기에 버퍼의 병목이 크지 않았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Heap&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2508&quot; data-origin-height=&quot;522&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cTO1EI/dJMcaf7SbT4/aeGdowiLZMmnbwK2T5Nd01/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cTO1EI/dJMcaf7SbT4/aeGdowiLZMmnbwK2T5Nd01/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cTO1EI/dJMcaf7SbT4/aeGdowiLZMmnbwK2T5Nd01/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcTO1EI%2FdJMcaf7SbT4%2FaeGdowiLZMmnbwK2T5Nd01%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2508&quot; height=&quot;522&quot; data-origin-width=&quot;2508&quot; data-origin-height=&quot;522&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주된 원인은 아니었지만, 힙이 작고 Servivor Space의 크기가 작아서 금방금방 가득 찼습니다. 이로 인해 트래픽이 높은 경우 요청 객체 생성이 많아지며 공간을 많이 차지하고, Major GC가 발생하기도 했습니다. 이는 작은 사이즈로 인한 조기 승격으로 Old 영역에 객체가 금방 많아져 발생한 것으로 파악되었습니다. 힙 사이즈를 키워 어느 정도 해결이 가능했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;CPU&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2496&quot; data-origin-height=&quot;455&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1L50F/dJMcahLpNln/yvKKjffkhZqUpUbLSb9860/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1L50F/dJMcahLpNln/yvKKjffkhZqUpUbLSb9860/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1L50F/dJMcahLpNln/yvKKjffkhZqUpUbLSb9860/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1L50F%2FdJMcahLpNln%2FyvKKjffkhZqUpUbLSb9860%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2496&quot; height=&quot;455&quot; data-origin-width=&quot;2496&quot; data-origin-height=&quot;455&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;vcpu가 2개인&lt;/span&gt; t3.micro 인스턴스로 부하 테스트를 진행하여 낮은 서버 스펙으로 인해 &lt;b&gt;Load 패널에서 코어를 넘어서는 것을 확인&lt;/b&gt;할 수 있습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;마찬가지로 그 시점에 CPU 사용률과 스레드 개수, runnable 스레드까지 CPU 할당을 대기하고 있는 것이 매우 많았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;자원을 할당받지 못한 스레드로 인해 결국 요청이 실패하게 되어 VU10000 지점에서는 업그레이드가 필요했습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;혹은 요청 주기를 늘리며 이어보기 지점을 클라이언트 단에서도 버퍼링하여 모아서 보내는 방식으로 처리량을 늘릴 수 있을 것 같습니다. 아래 7번 항목에서 이 경우 어떤 식으로 확장 가능한지 더 다루겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;span style=&quot;color: #0a0a0a; text-align: start;&quot;&gt;VU10000 구간에서 발생한 실패는&amp;nbsp;&lt;/span&gt;&lt;b&gt;DB나 큐의 병목이 아니라 톰캣 스레드와 CPU 자원 한계&lt;/b&gt;&lt;span style=&quot;color: #0a0a0a; text-align: start;&quot;&gt;였습니다. DB 커넥션은 안정적으로 유지되었고, 큐도 상한에 닿지 않았으며 drop된 요소도 없었습니다. 결국 처음 마주한 &quot;DB 접근이 잦아서 요청 스레드가 묶이는 문제&quot;는 해소되었고, 그 다음 병목은 인스턴스 자체의 처리 한계로 옮겨갔습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;span style=&quot;color: #0a0a0a; text-align: start;&quot;&gt;이 지점은&amp;nbsp;&lt;/span&gt;&lt;b&gt;t3.micro 인스턴스 자체의 vCPU 2개로는 더 받을 여력이 없다는 신호&lt;/b&gt;&lt;span style=&quot;color: #0a0a0a; text-align: start;&quot;&gt;이므로, 요청의 구조를 변경하거나 인프라 업그레이드 또는 수평 확장이 필요했습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;6. 성능 외 문제&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;정상 흐름 중심으로 진행된 성능 개선과 별도로 문제가 되는 상황들이 있습니다. 여러 문제가 있지만, 사실 playback은 약간의 유실이 허용되는 가벼운? 느낌의 데이터이기 때문에 이를 막고 얻는 &lt;b&gt;이득보다 오히려 복잡도가 높아지는 경우가 많았습니다.&lt;/b&gt; 이득과 복잡도를 따져보며 할 수 있는 것은 하되, 너무 복잡하지 않도록 해결하고자 했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;6-1. 큐가 가득 찬 경우&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;트래픽이 몰릴 때 Timeout 전, Flush 동안 큐에 요소가 BulkSize 이상으로 쌓일 수 있습니다. 큐의 bounded를 정해두었기 때문에 &lt;b&gt;큐가 가득찼을 때 요청이 들어오면 어떻게 처리&lt;/b&gt;할지 정책을 정해야 했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: left; border-collapse: collapse; width: 100.582%; height: 231px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 19.5397%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;대안&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.1115%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 방식 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 13.8372%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 장점 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.5935%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 단점 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 16.2718%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 판단 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px; width: 19.5397%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;A. overflowMap 사용&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 25.1115%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;span style=&quot;color: #0d0d0d; text-align: left;&quot;&gt;- queue full 시&lt;/span&gt;&lt;span style=&quot;color: #0d0d0d; text-align: left;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;color: #0d0d0d; text-align: left;&quot;&gt;overflowMap&lt;/span&gt;&lt;span style=&quot;color: #0d0d0d; text-align: left;&quot;&gt;에 key별 최신값 저장&lt;/span&gt; &lt;br /&gt;- queue drain과 overflow를 합쳐 batch update&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 13.8372%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;queue full 데이터도 flush 가능&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 25.5935%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;- batch size 예측 어려움&lt;br /&gt;- shutdown 처리 복잡&lt;br /&gt;&lt;span style=&quot;color: #0d0d0d; text-align: left;&quot;&gt;- unbounded map 관리 필요&lt;/span&gt; &lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 16.2718%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;보존이 꼭 필요하면 사용&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px; width: 19.5397%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;B. 429 응답&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 25.1115%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;queue full 시 클라이언트에 실패 반환&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 13.8372%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;실패를 명시적으로 알림&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 25.5935%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;클라이언트가 backoff 필요&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 16.2718%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이어보기에 불필요&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px; width: 19.5397%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;C.&amp;nbsp;put()으로 blocking&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 25.1115%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;queue에 공간이 날 때까지 요청 thread 대기&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 13.8372%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;유실 감소&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 25.5935%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;요청 thread가 병목이 됨&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 16.2718%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;write-behind 목적과 충돌&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 42px;&quot;&gt;
&lt;td style=&quot;height: 42px; width: 19.5397%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;D.&amp;nbsp;drop + warn&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 25.1115%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;queue full 시 command를 버리고 로그/지표 기록&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 13.8372%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;가장 단순, 메모리 안전, 장애 전파 적음&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 25.5935%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;마지막 위치 일부 유실 가능&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 42px; width: 16.2718%;&quot;&gt;&lt;span style=&quot;color: #ee2323; background-color: #ffffff;&quot;&gt;&lt;b&gt;선택&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt; &lt;span style=&quot;color: #0a0a0a; text-align: start;&quot;&gt;이어보기는 5초 뒤 같은 사용자의 다음 update가 발생하므로&amp;nbsp;&lt;/span&gt;&lt;b&gt;drop + logging을 선택&lt;/b&gt;&lt;span style=&quot;color: #0a0a0a; text-align: start;&quot;&gt;했습니다.&lt;/span&gt; &lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1780502453133&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;boolean offered = playbackCommandQueue.offer(memberId, contentsId, positionSec);
if (!offered) {
    playbackMetrics.incrementQueueFullDrop();
    log.warn(&quot;Playback queue full, dropping command: memberId={}, contentsId={}&quot;,
        memberId, contentsId);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #0a0a0a; text-align: start;&quot;&gt;대신, &lt;b&gt;메트릭을 수집&lt;/b&gt;하여 큐 지표를 확인하고 &lt;b&gt;적절한 상한 및 큐 개수를 산정&lt;/b&gt;할 수 있도록 했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;6-2. Flush 실패&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;Bulk Update 중 DB 일시 장애, connection timeout 등으로 batchUpdate가 실패할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;background-color: #ffffff; color: #0d0d0d; text-align: left; border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.5814%;&quot;&gt;&lt;span&gt;&lt;b&gt; 대안 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.5116%;&quot;&gt;&lt;span&gt;&lt;b&gt; 방식 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 23.1395%;&quot;&gt;&lt;span&gt;&lt;b&gt; 장점 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.7675%;&quot;&gt;&lt;span&gt;&lt;b&gt; 단점 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 14.8837%;&quot;&gt;&lt;span&gt;&lt;b&gt; 판단 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.5814%;&quot;&gt;&lt;span&gt;&lt;b&gt;A.&amp;nbsp;drop + warn&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.5116%;&quot;&gt;&lt;span&gt;실패 batch를 버리고 warn/metric 기록&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 23.1395%;&quot;&gt;&lt;span&gt;- worker 지연 없음&lt;br /&gt;- 정책 명확&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.7675%;&quot;&gt;&lt;span&gt;실패 순간 batch 유실&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 14.8837%;&quot;&gt;&lt;span style=&quot;color: #ee2323;&quot;&gt; &lt;b&gt;선택&lt;/b&gt; &lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.5814%;&quot;&gt;&lt;span&gt;&lt;b&gt;B. retry&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.5116%;&quot;&gt;&lt;span&gt;짧게 대기 후 한 번 재시도&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 23.1395%;&quot;&gt;&lt;span&gt;deadlock/일시 장애에 효과 가능&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.7675%;&quot;&gt;&lt;span&gt;retry 동안 worker가 멈추고 queue가 쌓임&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 14.8837%;&quot;&gt;&lt;span&gt;실패가 잦은 경우&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.5814%;&quot;&gt;&lt;span&gt;&lt;b&gt;C. retry buffer&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.5116%;&quot;&gt;&lt;span&gt;실패 batch를 메모리에 보관 후 재시도&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 23.1395%;&quot;&gt;&lt;span&gt;유실 감소&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.7675%;&quot;&gt;&lt;span&gt;메모리, 순서, shutdown, 중복 처리 복잡&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 14.8837%;&quot;&gt;&lt;span&gt;현재 과함&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 15.5814%;&quot;&gt;&lt;span&gt;&lt;b&gt;D. WAL&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 21.5116%;&quot;&gt;&lt;span&gt;flush 전 파일에 기록 후 성공 시 삭제&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 23.1395%;&quot;&gt;&lt;span&gt;프로세스 종료에도 복구 가능&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 24.7675%;&quot;&gt;&lt;span&gt;파일 I/O, 복구 로직, 중복 처리 필요&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 14.8837%;&quot;&gt;&lt;span&gt;Playback에는 과설계&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제도 마찬가지로 Playback 특성상 처리하고자 한다면 복잡도가 높아졌습니다. DB 장애는 종종 발생하는 일이지만, 대부분의 사용자는 영상에 오랜 시간 머물고, 계속하여 5초마다 요청이 들어오기 때문에 원인을 기록하고 drop하는 방식을 선택했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공부하면서 느낀 건 RabbitMQ나 Kafka에서 처리해주는 것들과 비슷했습니다. 저는 로컬 큐로 이들을 대신하여 구현했기 때문에 직접 처리해야 했고, 처리 정책이나 방식의 결정에 대해서는 메시지 브로커를 사용했던 프로젝트와 결이 비슷했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;6-3. Graceful&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt; Shutdown&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;서버가 정상 종료될 때, 메모리에 남아 있는 작업들에 대한 처리는 어떻게 할지 고민했습니다. &lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;큐에 남은 command를 그냥 버리면 마지막 5~10초 분량의 모든 사용자 위치가 사라집니다. Graceful Shutdown을 통해&amp;nbsp;&lt;/span&gt;&lt;b&gt;정상 종료 경로에서 남은 작업을 최대한 반영하고 종료하는 정책&lt;/b&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;을 만들었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;앞선 결정들처럼 굳이?하면서 버릴 수 있었지만, 이는 충분히 예측과 처리가 가능한 문제였기 때문입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1171&quot; data-origin-height=&quot;522&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cZLPIc/dJMcaccgozY/SGLmpknkE5oQpkjk1tAUfk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cZLPIc/dJMcaccgozY/SGLmpknkE5oQpkjk1tAUfk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cZLPIc/dJMcaccgozY/SGLmpknkE5oQpkjk1tAUfk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcZLPIc%2FdJMcaccgozY%2FSGLmpknkE5oQpkjk1tAUfk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;780&quot; height=&quot;522&quot; data-origin-width=&quot;1171&quot; data-origin-height=&quot;522&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;Spring의 graceful shutdown을 켜서, 진행 중인 요청이 끝날 시간을 설정했고,&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1780503696589&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;server:
  shutdown: graceful
spring:
  lifecycle:
    timeout-per-shutdown-phase: 30s&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;워커가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;@PreDestroy&lt;span style=&quot;background-color: #fdfdfc; color: #0a0a0a; text-align: start;&quot;&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;시점에 직접 큐를 비웁니다&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1780503794652&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@PreDestroy
void shutdown() {
    running = false;          // 1. 새 flush loop 진입 차단
    waitForLeaderToStop();    // 2. 진행 중인 flush가 끝나길 대기
    drainRemainingQueue();    // 3. 남은 큐 flush
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;주의할 점은 서버를 끌 때 kill -15를 사용하면 안 된다는 점입니다. &lt;b&gt;SIGKILL&lt;/b&gt; 시그널이 발생하여 즉시 강제 종료하므로 설정한 작업이 수행되지 않고 바로 종료될 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;kill -9를 사용하여 &lt;b&gt;SIGTERM&lt;/b&gt;을 주거나 kill -2로 &lt;b&gt;SIGINT&lt;/b&gt;를 통해 프로세스를 종료해야 &lt;b&gt;Graceful Shutdown&lt;/b&gt;이 수행됩니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;7. 나아가&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;5초 주기로 발생하는 Playback에 대해서는 현재까지의 개선 작업으로 충분하다고 생각하지만 더 높은 부하, 정책 변경 혹은 아예 다른 Log나 데이터 사용량같은 Write 트래픽이 쏟아지는 경우에는 현재 구조에서 더 발전시킬 수 있습니다. 이에 대해 생각해 본 내용들을 간단히 적어보겠습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;7-1. 클라이언트 버퍼링&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;클라이언트에서 요청을 모아서 주는 방식&lt;/b&gt;입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 5초 주기로 요청하는데, 이를 30초로 변경하고, 한 번에 6개씩 모아서 List로 요청합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;249&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dYGqBj/dJMcabqUL79/hFmvWjVKZPW3ZCGkTj0Gik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dYGqBj/dJMcabqUL79/hFmvWjVKZPW3ZCGkTj0Gik/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dYGqBj/dJMcabqUL79/hFmvWjVKZPW3ZCGkTj0Gik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdYGqBj%2FdJMcabqUL79%2FhFmvWjVKZPW3ZCGkTj0Gik%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;249&quot; data-origin-width=&quot;873&quot; data-origin-height=&quot;249&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요청 횟수 자체가 현저히 줄어들지만, 그만큼 장애 시 유실 범위(5초 -&amp;gt; 30초)가 커진다는 단점이 있습니다. 텀을 두고 주기적으로 발생하는 Playback보다는 Write가 제한 없이 몰리는 구조에서 유용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드보단 프론트에서 처리해줘야 하는 영역입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;7-2. Queue의 Lock이 병목일 수도&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;LinkedBlockingQueue를 쓰는 상황에서 위 방식을 사용하면 &lt;b&gt;큐에 작업을 넣기 위한 락 경합&lt;/b&gt;이 심해질 수 있습니다. 요청 스레드 간 경합 + 하나의 스레드가 리스트를 순회하며 여러 요소를 하나씩 넣기 때문입니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1094&quot; data-origin-height=&quot;269&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cvCbXq/dJMcadoDuYc/dUhcSkPmezeQ45qyvAW111/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cvCbXq/dJMcadoDuYc/dUhcSkPmezeQ45qyvAW111/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cvCbXq/dJMcadoDuYc/dUhcSkPmezeQ45qyvAW111/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcvCbXq%2FdJMcadoDuYc%2FdUhcSkPmezeQ45qyvAW111%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1094&quot; height=&quot;269&quot; data-origin-width=&quot;1094&quot; data-origin-height=&quot;269&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;Producer - Consumer 구조를 통해 &lt;b&gt;큐에 넣는 스레드를 싱글 스레드로 제한&lt;/b&gt;합니다. Lock이 병목이었다면 큐에 접근하는 스레드를 하나만 두어 락 경합을 없앨 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #1a1c1f;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;저는 테스트 결과 큐에 병목이 발생하려면 현재보다 월등히 높은 부하가 필요했기에 Producer Thread를 도입하지 않았습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;7-3. Multi Queue + Worker Pool&lt;/span&gt; &lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 모든 것을 종합하고 파이프라인을 확장해 &lt;b&gt;큐를 여러 개&lt;/b&gt; 두어 큐에서 &lt;b&gt;작업을 꺼내는 스레드&lt;/b&gt;와 &lt;b&gt;DB Flush 작업을 처리하는 스레드&lt;/b&gt;를 &lt;b&gt;분리&lt;/b&gt;하는 방식입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1519&quot; data-origin-height=&quot;374&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cmFJNL/dJMcaalbXaR/ivKzKKByr5GvDHLkZBTbKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cmFJNL/dJMcaalbXaR/ivKzKKByr5GvDHLkZBTbKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cmFJNL/dJMcaalbXaR/ivKzKKByr5GvDHLkZBTbKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcmFJNL%2FdJMcaalbXaR%2FivKzKKByr5GvDHLkZBTbKk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1519&quot; height=&quot;374&quot; data-origin-width=&quot;1519&quot; data-origin-height=&quot;374&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;큐를 여러 개 두고, 특정 기준(우리의 경우 member_contents 키가 될 수 있음) 혹은 랜덤으로 큐를 선택합니다. 해당 큐의 Producer Thread(Single Thread)가 큐에 작업을 넣고, Consumer Thread가 해당 작업을 Flush 전용 Worker Pool에 제출합니다. 이렇게 각 스레드들의 작업을 분리하여 락을 없애고, 스케일 아웃을 내부적으로 구성할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 그림에선 공간이 부족해서 Worker Pool 하나에서 모든 큐의 작업을 처리하지만, 이 Pool도 격리하여 큐마다 전용 풀을 둘 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;8. 마무리&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최고 &lt;b&gt;TPS가 280/s에 &lt;/b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;b&gt;VU2000을 버거워&lt;/b&gt;하던&lt;/span&gt; 것을 &lt;b&gt;TPS 760/s까지 수용하고, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;VU5000을 견딜 &lt;/span&gt;수 있도록 개선&lt;/b&gt;했습니다. 평균 응답 시간도 예측한대로 거의 즉시 완료되는 모습을 확인하기도 했습니다. 고민한 지점이 많았고, 실제로 적용한 것과 그렇지 못 한 것도 많았습니다. 스레드 풀, 락, 싱글 스레드, 큐 등 이러한 구조들 중에서 현재 서비스의 병목 지점에 알맞은 해결책을 과하지 않게 내고자했고, 이 과정에서 공부가 더 많이 되었던 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히, 다중 서버와 순서가 중요함에도 로컬 큐를 도입하기로 결정한 점이 이전까지의 시도와 달랐습니다. '정말 필요한가'를 따져보며 관리의 복잡성을 줄이면서 문제가 발생하지 않도록 구성하고자 했고, 역시 우리가 해결하려는 문제가 무엇인지 정확히 인식하는 것이 중요했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 작업을 거치며 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;크게&lt;span&gt; &lt;/span&gt;&lt;/span&gt;두 가지를 배웠습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;기본 지식과 원리&lt;/b&gt;를 공부하면서도 이것이 나에게 어떤 쓸모가 있을까 고민한 적이 있습니다. 이번에 큐 구현 및 이로 인해 발생하는 문제들의 해결법을 찾으며 이미 만들어진 메시지 큐들로부터 힌트를 얻을 수 있었습니다. 사용법에 더해 내부 원리를 이해하며 &lt;b&gt;응용할 수 있도록 학습&lt;/b&gt;하는 것이 정말 필요하다고 생각했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;그리고 의심되는 지점에 대한 가설을 세우고 &lt;b&gt;지표로 확인&lt;/b&gt;하는 것이 중요했습니다. 이전까지는 예측한 범위 내에서 실제로 문제가 발생했고, 이번에도 처음에는 그렇게 접근했습니다. 하지만, 락으로 인한 큐 병목을 예상해서 확장된 버전으로 개선을 했었는데 효과가 없었습니다. 그제서야 테스트 및 지표를 통해 큐가 병목이 아님을 확인해서 과한 설계를 적용하지 않게끔 돌아올 수 있었습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;귀찮다고 예측으로 넘겨짚지 말고 기본 지식을 쌓는 것이 중요했습니다!&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Project</category>
      <author>phonil</author>
      <guid isPermaLink="true">https://yestomo.tistory.com/24</guid>
      <comments>https://yestomo.tistory.com/24#entry24comment</comments>
      <pubDate>Wed, 13 May 2026 11:49:22 +0900</pubDate>
    </item>
    <item>
      <title>[O+T] 홈 화면 인기 플레이리스트 API 개선하기</title>
      <link>https://yestomo.tistory.com/23</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;0. 개요&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;프로젝트를 진행하며 사용자가 보게 될 '플레이리스트' 구성에 있어 '시리즈'와 '시리즈가 아닌 콘텐츠' 구분 로직이 복잡했습니다. 특히,&amp;nbsp;OTT 서비스의 홈 화면 API들은 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;자주 호출되기 때문에 &lt;/span&gt;사용자와 데이터가 많다면 병목이 있을 것이라고 예상했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;따라서 K6 테스트 후 병목 지점을 수치로 확인하고, 원인 파악 및 개선 작업을 진행하기로 했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/oEPIY/dJMcabYo8nJ/GGdop1l09nOnrV1T33hjmk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/oEPIY/dJMcabYo8nJ/GGdop1l09nOnrV1T33hjmk/img.png&quot; data-origin-width=&quot;1964&quot; data-origin-height=&quot;1280&quot; data-is-animation=&quot;false&quot; data-filename=&quot;홈1.png&quot; width=&quot;480&quot; height=&quot;313&quot; style=&quot;width: 49.9784%; margin-right: 10px;&quot; data-widthpercent=&quot;50.57&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/oEPIY/dJMcabYo8nJ/GGdop1l09nOnrV1T33hjmk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FoEPIY%2FdJMcabYo8nJ%2FGGdop1l09nOnrV1T33hjmk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1964&quot; height=&quot;1280&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/RXngs/dJMcadhBxAb/GyGRFWcIaxFl8C91WdSlzk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/RXngs/dJMcadhBxAb/GyGRFWcIaxFl8C91WdSlzk/img.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1280&quot; data-is-animation=&quot;false&quot; data-filename=&quot;홈2.png&quot; width=&quot;480&quot; height=&quot;320&quot; style=&quot;width: 48.8588%;&quot; data-widthpercent=&quot;49.43&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/RXngs/dJMcadhBxAb/GyGRFWcIaxFl8C91WdSlzk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FRXngs%2FdJMcadhBxAb%2FGyGRFWcIaxFl8C91WdSlzk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1920&quot; height=&quot;1280&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;홈3.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1280&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/TRcCT/dJMcafNii3r/ZlWE7DzmWFAHq3ESGC1Klk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/TRcCT/dJMcafNii3r/ZlWE7DzmWFAHq3ESGC1Klk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/TRcCT/dJMcafNii3r/ZlWE7DzmWFAHq3ESGC1Klk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FTRcCT%2FdJMcafNii3r%2FZlWE7DzmWFAHq3ESGC1Klk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;426&quot; height=&quot;284&quot; data-filename=&quot;홈3.png&quot; data-origin-width=&quot;1920&quot; data-origin-height=&quot;1280&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;대표적으로&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;인기 차트 플레이리스트 조회&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Top Tag(태그 기반) 플레이리스트 조회&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;시청 이력 플레이리스트 조회&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;위 세 가지 API가 홈 화면에서 호출됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;겹치는 로직이 많아 &lt;b&gt;인기 차트 플레이리스트 조회 중심으로 개선 과정을 정리&lt;/b&gt;했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;테스트 환경&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;다른 요소들은 제외하고, 요청-응답 중심으로 &lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;간단하게&lt;span&gt; &lt;/span&gt;&lt;/span&gt;그림으로 나타내면 아래와 같습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;704&quot; data-origin-height=&quot;598&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vBcW8/dJMcaaL4cdc/257kTpBK86bcyLZ8AojzK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vBcW8/dJMcaaL4cdc/257kTpBK86bcyLZ8AojzK0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vBcW8/dJMcaaL4cdc/257kTpBK86bcyLZ8AojzK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvBcW8%2FdJMcaaL4cdc%2F257kTpBK86bcyLZ8AojzK0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;460&quot; height=&quot;391&quot; data-origin-width=&quot;704&quot; data-origin-height=&quot;598&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;서버 스펙&lt;/b&gt;&lt;/h4&gt;
&lt;div style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 72px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 23.4884%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt; 자원 &lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 20.4651%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt; 타입 &lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 15.6977%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt; vCPU &lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 14.6511%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt; 메모리 &lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.4651%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #1a1c1f; text-align: start;&quot;&gt;디스크/스토리지&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 23.4884%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;user-api EC2&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 20.4651%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;t3.micro&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 15.6977%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 14.6511%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;1 GiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.4651%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;gp3 8GB&lt;/span&gt; &lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 23.4884%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;redis EC2&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 20.4651%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;t3.micro&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 15.6977%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 14.6511%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;1 GiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.4651%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;gp3 8GB&lt;/span&gt; &lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 23.4884%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;RDS MySQL&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 20.4651%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;db.t3.micro&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 15.6977%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 14.6511%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;1 GiB&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.4651%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt; &lt;span style=&quot;background-color: #ffffff; color: #1a1c1f; text-align: start;&quot;&gt;gp3 20GB&lt;/span&gt; &lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;더미 데이터&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;스크립트를 작성하여 규모별로 데이터셋을 생성했습니다. Small/Mideum/Large/XLarge 4개로 구분했으며, 다양한 데이터 규모에서 문제 지점을 파악하기 위해 나눴습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;958&quot; data-origin-height=&quot;302&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfIhmh/dJMcaaSJSPv/cMT9DcmJ0HvGkz27x9ixkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfIhmh/dJMcaaSJSPv/cMT9DcmJ0HvGkz27x9ixkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfIhmh/dJMcaaSJSPv/cMT9DcmJ0HvGkz27x9ixkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfIhmh%2FdJMcaaSJSPv%2FcMT9DcmJ0HvGkz27x9ixkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;958&quot; height=&quot;302&quot; data-origin-width=&quot;958&quot; data-origin-height=&quot;302&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;테스트 도구&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;부하 테스트를 위해 K6를 사용했습니다. 스크립트를 작성해서 VU 규모별로 테스트를 진행했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;기본적으로 Prometheus, Grafana를 사용하여 메트릭을 수집하고, 대시보드에서 확인했습니다.&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;주로 CPU, Heap, HTTP/DB Connection 등 서버 자원을 확인하는 용도로 사용했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;개선 전 테스트 결과&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;852&quot; data-origin-height=&quot;603&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmjiqo/dJMcaiiTWwG/76FJK6K6LjXKpMMsWxrNyk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmjiqo/dJMcaiiTWwG/76FJK6K6LjXKpMMsWxrNyk/img.png&quot; data-alt=&quot;Large 데이터 셋 테스트 결과&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmjiqo/dJMcaiiTWwG/76FJK6K6LjXKpMMsWxrNyk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbmjiqo%2FdJMcaiiTWwG%2F76FJK6K6LjXKpMMsWxrNyk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;425&quot; data-origin-width=&quot;852&quot; data-origin-height=&quot;603&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Large 데이터 셋 테스트 결과&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;XLarge 데이터 셋에서 K6 부하 테스트를 진행해보니 데이터가 많은 상태에서 긴 쿼리 + 많은 쿼리 + 많은 요청으로 인해 타임아웃이 발생하는 경우가 매우 많아 결과를 확인하기 어려웠습니다. 그래서 우선 Large로 부하 테스트 결과를 보고, 단 건 확인은 XLarge로 진행하기로 결정했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;홈 화면의 API는 Large 데이터 셋에 데이터가 그리 많지 않음에도 응답 시간이 꽤 걸렸습니다. VU100 선에서 벌써 평균 응답 시간이 1.5초 이상 나타났습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;더 적은 데이터 셋에서는 모든 API의 Avg가 거의 비슷하게 나타났습니다. 비효율적인 쿼리가 있더라도 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;데이터 수가 적기 때문에 &lt;/span&gt;빠르게 실행되어 대부분 네트워크 왕복 시간만 필요했기 때문입니다.&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;1. 로직 파악&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;API 호출 결과, 생각보다 많은 쿼리가 발생했고, 소수의 긴 쿼리가 존재했습니다. 흐름을 파악하고 쿼리 수를 줄이기 위해 API의 로직을 먼저 정리했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1889&quot; data-origin-height=&quot;488&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cbFNpJ/dJMcafNitE2/2HZurKNGQbuJaw55W9zjKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cbFNpJ/dJMcafNitE2/2HZurKNGQbuJaw55W9zjKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cbFNpJ/dJMcafNitE2/2HZurKNGQbuJaw55W9zjKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcbFNpJ%2FdJMcafNitE2%2F2HZurKNGQbuJaw55W9zjKk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1889&quot; height=&quot;488&quot; data-origin-width=&quot;1889&quot; data-origin-height=&quot;488&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;하나의 흐름으로 보면 위 그림과 같고, 붉은 색으로 표시된 부분에서 가장 긴 두 쿼리와 N+1 문제가 발생합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;첫 번째 쿼리&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;ACTIVE/PUBLIC/COMPLETED 상태 필터링&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;시리즈/단편 콘텐츠 필터링&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;미디어 북마크 순 &lt;b&gt;정렬&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;상위 20개 반환&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;두 번째 쿼리&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;페이지네이션 시 Page 인터페이스 사용으로 인한 &lt;b&gt;count() 쿼리&lt;/b&gt; 발생&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;N+1 문제&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;가져온 콘텐츠가 시리즈일 경우 이어보기 지점 표시를 위해 해당 시리즈의 모든 콘텐츠 중 가장 최근 시청 미디어를 찾아야 함&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;첫 번째 쿼리에서 가져온 미디어 수만큼 반복&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;797&quot; data-origin-height=&quot;332&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mTF7o/dJMcafT2C1A/HJdbknYTme41qMHaQg6LxK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mTF7o/dJMcafT2C1A/HJdbknYTme41qMHaQg6LxK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mTF7o/dJMcafT2C1A/HJdbknYTme41qMHaQg6LxK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmTF7o%2FdJMcafT2C1A%2FHJdbknYTme41qMHaQg6LxK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;480&quot; height=&quot;200&quot; data-origin-width=&quot;797&quot; data-origin-height=&quot;332&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이어보기 지점이라 함은 이렇게 포스터 아래에 표시되는 시청 기록을 말합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2. 쿼리 및 로직 개선&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;인기 차트 플레이리스트 API를 포스트맨으로 호출해 로그를 확인했습니다. 응답 시간은 약 11초 정도 걸렸습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2개의 주요 쿼리와 N+1 쿼리로 인해 상당한 지연이 발생했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2-1. 1번 쿼리: 3096ms&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1778030664410&quot; class=&quot;java&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;INFO 528 --- [io-8080-exec-10] p6spy : [STATEMENT] | 3096 ms |
    select
        m1_0.id,
        m1_0.bookmark_count,
        ...
    from media m1_0
    where 
        m1_0.status='ACTIVE' and m1_0.public_status='PUBLIC' and m1_0.media_status='COMPLETED'
        and (
            m1_0.media_type='SERIES'
            or m1_0.media_type='CONTENTS' 
            and not exists(select 1 from contents c1_0 where c1_0.media_id=m1_0.id and c1_0.series_id is not null)
        )
    order by m1_0.bookmark_count desc
    limit 0, 20&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;쿼리 한 번에 3096ms가 걸렸고, 이를 분석하고자 Explain (+ Analyze)를 통해 실행 계획을 확인해봤습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1874&quot; data-origin-height=&quot;78&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uA4KV/dJMcaiQIuw6/2RcEn2LOElKcOvXdMyiFsk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uA4KV/dJMcaiQIuw6/2RcEn2LOElKcOvXdMyiFsk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uA4KV/dJMcaiQIuw6/2RcEn2LOElKcOvXdMyiFsk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuA4KV%2FdJMcaiQIuw6%2F2RcEn2LOElKcOvXdMyiFsk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1874&quot; height=&quot;78&quot; data-origin-width=&quot;1874&quot; data-origin-height=&quot;78&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1863&quot; data-origin-height=&quot;326&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cKEe7o/dJMcacptqrE/47llEVxnRUk1c1XePMpkPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cKEe7o/dJMcacptqrE/47llEVxnRUk1c1XePMpkPK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cKEe7o/dJMcacptqrE/47llEVxnRUk1c1XePMpkPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcKEe7o%2FdJMcacptqrE%2F47llEVxnRUk1c1XePMpkPK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1863&quot; height=&quot;326&quot; data-origin-width=&quot;1863&quot; data-origin-height=&quot;326&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;크게 두 가지 문제로 인해 쿼리 실행 시간이 길어짐을 확인할 수 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;문제 1. 수많은 서브쿼리 Loop&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;콘텐츠가 시리즈의 에피소드인 경우와 시리즈가 아닌 단편 영화와 같은 일반 콘텐츠인 경우를 확실하게 구분지어야 하는데,&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;시리즈에 소속된 에피소드를 제거하기 위한 조건으로 Where절 안에 OR + 서브쿼리가 필요했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;플레이리스트에 구성되는 콘텐츠는 '더글로리 1화'와 같은 단독 에피소드가 아닌, 시리즈 단위 혹은 단편 영화와 같은 콘텐츠가 되어야 하기 때문입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1070&quot; data-origin-height=&quot;615&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bVRTGB/dJMcafGyrxT/aYlH9QCKH6zif7GKk4sS0k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bVRTGB/dJMcafGyrxT/aYlH9QCKH6zif7GKk4sS0k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bVRTGB/dJMcafGyrxT/aYlH9QCKH6zif7GKk4sS0k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbVRTGB%2FdJMcafGyrxT%2FaYlH9QCKH6zif7GKk4sS0k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;460&quot; height=&quot;264&quot; data-origin-width=&quot;1070&quot; data-origin-height=&quot;615&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Explain Analyze 결과를 보면, NOT EXISTS 서브쿼리의 loop 횟수가 &lt;b&gt;240,070회&lt;/b&gt;에 달합니다.&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #14181f;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;-&amp;gt; Select #2 (subquery in condition; dependent)
    -&amp;gt; Single-row index lookup on c1_0 using uk_contents_media (media_id=m1_0.id)
       (actual time=0.00846..0.00846 rows=1 loops=240070)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;서브쿼리 1회 실행은 유니크 인덱스 lookup이라 약 0.00846ms로 빠르지만, 이것이 24만 번 반복되면 약 2초가 소모됩니다. 전체 쿼리 시간 중 풀스캔이 865ms, 나머지 약 2초가 이 서브쿼리 반복에서 발생한 것입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 상관 서브쿼리와 Anti-Join&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;현재 NOT EXISTS 내부의 서브쿼리는 외부 테이블(Media)의 현재 행을 참조하는 &lt;b&gt;상관 서브쿼리&lt;/b&gt;로, Media의 행마다 Contents를 반복하며 조회합니다. 이는 외부 행이 바뀔 때마다 매번 다시 실행되어야 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;하지만, MySQL 옵티마이저는 NOT EXISTS 상관 서브쿼리를 Anti-Join으로 변환하는 최적화를 지원하기 때문에 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;상관 서브쿼리 자체가 문제는 아닙니다.&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Anti-Join은 두 테이블을 한 번 조인하여 매칭되지 않는 행만 남기는 연산입니다. 행마다 서브쿼리를 반복하는 것이 아니라 media 전체와 contents 전체를 한 번에 매칭하는 하나의 조인 동작으로 바뀌는 것입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이 최적화가 적용되었다면 24만 건의 loop는 발생하지 않았을 것입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;OR 조건&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;문제는 NOT EXISTS가 OR 조건 안에 감싸져 있기 때문에 발생했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;MySQL의 세미조인/안티조인 최적화는 서브쿼리가 WHERE 최상위 레벨에 AND로 연결되어 있을 때 적용됩니다. OR 안에 들어가는 순간 옵티마이저가 Anti-Join 변환을 수행하기 어려워져 의도한 대로 동작하지 않을 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Anti-Join은 이 테이블 전체와 저 테이블 전체를 한 번에 매칭하는 연산입니다. 그런데 OR 조건이 있으면&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;ini&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;media_type = 'SERIES'인 행    &amp;rarr; contents 조인 자체가 필요 없음
media_type = 'CONTENTS'인 행  &amp;rarr; contents 조인이 필요함&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;행마다 조인을 해야 할지 말아야 할지가 달라집니다. 조인을 할지 말지 행 단위로 분기하는 것은 조인 연산의 구조와 맞지 않기 때문에, 옵티마이저는 Anti-Join 변환을 포기하고 원래 의미 그대로 행 하나씩 서브쿼리를 평가하는 &lt;b&gt;Dependent Subquery&lt;/b&gt;로 실행하게 됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;정리&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 요소 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 단독으로 문제인가 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 이유 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;상관 서브쿼리&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;X&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Anti-Join 최적화 가능&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;NOT EXISTS&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;X&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Anti-Join 최적화 가능&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;OR + NOT EXISTS&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;O&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Anti-Join 최적화 차단 &amp;rarr; 행마다 서브쿼리 반복&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;결국 상관 서브쿼리 자체가 아니라, &lt;b&gt;OR 조건&lt;/b&gt;이&lt;b&gt; Anti-Join 최적화를 차단&lt;/b&gt;하여 상관 서브쿼리가 행마다 반복 실행되는 것이 문제입니다. 실행 계획에서는 50만 행 중 media_type='CONTENTS'인 약 24만 행 각각에 대해 서브쿼리가 1번씩 실행되어, loops=240,070이라는 수치가 나타난 것입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;해결 방안 1. 반정규화 ✅ &lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Media 테이블에 단독 콘텐츠임을 나타내는 &lt;b&gt;is_standalone 컬럼을 추가&lt;/b&gt;하여 혼자 노출될 수 있는지(Series/단편 Contents) 표시하는 방법입니다. 이를 통해&amp;nbsp;Media 테이블만 보고도 플레이리스트 포함 여부를 찾아갈 수 있게 됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778468709006&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;WHERE m1_0.status = 'ACTIVE'
  AND m1_0.public_status = 'PUBLIC'
  AND m1_0.media_status = 'COMPLETED'
  AND m1_0.is_standalone = true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;장점:&lt;/b&gt; NOT EXISTS 완전 제거 / 인덱스 포함 가능&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;단점:&lt;/b&gt; 콘텐츠 수정 시 동기화 필요&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;팀에서는 해당 방식인 반정규화를 선택했습니다. 그 이유로 아래 세 가지가 대표적입니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;OR과 NOT EXISTS 근본적 제거&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;필요 시 인덱스 포함 가능&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;백오피스에서 가능한 미디어 수정은 자주 일어나지 않음&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;OR과 NOT EXISTS가 모두 사라지므로, 서브쿼리 루프 240,070회가 &lt;b&gt;0회&lt;/b&gt;가 됩니다. 또한, WHERE 필터링만 남기 때문에 복합 인덱스를 구성하면 풀스캔과 filesort까지 함께 해결할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;단점은 반정규화로 인한&amp;nbsp;&lt;b&gt;동기화&lt;/b&gt;입니다. 콘텐츠가 시리즈에 편입되거나 시리즈에서 빠질 때 is_standalone 값을 함께 갱신해야 합니다. 이 동기화가 누락되면 플레이리스트에 에피소드가 노출되거나, 반대로 단편 콘텐츠가 누락되는 정합성 문제가 발생합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;다만, 이 프로젝트에서는 미디어 수정이 백오피스에서만 이루어지고, 그 빈도가 낮기 때문에 동기화 비용 대비 조회 성능 이득이 더 크다고 판단하여 이 방식을 선택했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Media_Type에 EPISODE를 추가하여 Contents의 타입을 에피소드/단편으로 세분화하는 것(해결 2)이 더 근본적인 해결 방법이라고 생각했습니다. 하지만, 현재 다른 API 로직에서 매우 많이 사용 중이므로 변경 범위가 상당히 컸기 때문에 컬럼을 기존 로직 수정을 최소화 할 수 있도록 컬럼을 추가하는 방식으로 결정했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;해결 방안 2. Media_Type에 EPISODE 추가&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Media_Type을 세분화하여 Contents를 EPISODE/CONTENTS로 분리하는 방식입니다. 현재는 Media_Type이 SERIES/CONTETNS/SHORT_FORM 이렇게 세 가지가 존재하며, CONTENTS 타입에 에피소드와 단일 콘텐츠가 모두 존재합니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778468688778&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;WHERE m1_0.status = 'ACTIVE'
  AND m1_0.public_status = 'PUBLIC'
  AND m1_0.media_status = 'COMPLETED'
  AND m1_0.media_type IN ('SERIES', 'CONTENTS')&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;장점:&lt;/b&gt; 도메인 의미 명확 / NOT EXISTS 완전 제거&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;단점:&lt;/b&gt; 기존 Contents 참조하는 코드 모두 수정 / 영향 범위 큼&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;반정규화와 마찬가지로 OR과 NOT EXISTS가 완전히 사라집니다. 에피소드인지 아닌지가 타입 자체에 표현되어 있는 가장 정석적인 해결 방법입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;하지만, Media_Type은 현재 프로젝트 전반에 걸쳐 매우 광범위하게 참조되고 있었습니다. media_type = 'CONTENTS'로 분기하는 모든 코드에서 EPISODE를 함께 고려해야 하고, QueryDSL 조건, Enum 클래스, 프론트엔드 분기 등 변경 범위가 상당히 컸기 때문에 이번 시점에서는 선택하지 않았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;가장 정석적인 해결 방법이라고 생각하지만, 앞서 정리한 것처럼 변경 사항이 너무 많아지기 때문에 지금 시점에서는 변경 범위가 적은 방안을 선택하고자 해당 방식을 선택하지 않았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;해결 방안 3. Union All 사용&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Series 쿼리와 단독 Contents 쿼리를 분리하여 OR을 제거하고, UNION ALL로 합치는 방식입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;OR 조건을 UNION ALL로 분리하면 각 서브쿼리가 독립적인 WHERE 절에 AND로 연결되므로, Anti-Join 최적화가 적용될 가능성이 생깁니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;장점:&lt;/b&gt; 스키마 변경 x&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;단점:&lt;/b&gt; OR는 분리하지만, NOT EXISTS 자체가 남음 / 쿼리 2개로 복잡함&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778251053352&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- 변경 후 쿼리
(
    SELECT
        m1_0.id, m1_0.bookmark_count, m1_0.created_date, ...
    FROM media m1_0
    WHERE m1_0.status = 'ACTIVE'
      AND m1_0.public_status = 'PUBLIC'
      AND m1_0.media_status = 'COMPLETED'
      AND m1_0.media_type = 'SERIES'
)
UNION ALL
(
    SELECT
        m1_0.id, m1_0.bookmark_count, m1_0.created_date, ...
    FROM media m1_0
    WHERE m1_0.status = 'ACTIVE'
      AND m1_0.public_status = 'PUBLIC'
      AND m1_0.media_status = 'COMPLETED'
      AND m1_0.media_type = 'CONTENTS'
      AND NOT EXISTS (
          SELECT 1 FROM contents c1_0 
          WHERE c1_0.media_id = m1_0.id 
            AND c1_0.series_id IS NOT NULL
      )
)
ORDER BY bookmark_count DESC
LIMIT 0, 20;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;해당 방식은 스키마 변경이 없다는 큰 장점이 있지만, NOT EXISTS의 반복을 줄이지 못 한다는 한계가 있습니다. 현재 해결하고자 하는 문제는 OR 제거라기보단, OR를 비효율적으로 수행하는 서브쿼리 제거이므로 해당 방식은 선택하지 않았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;OR이 사라지면서 두 번째 쿼리의 NOT EXISTS가 WHERE 최상위에 AND로 연결되므로, 옵티마이저가 Anti-Join으로 변환할 수 있는 구조가 됩니다. 스키마 변경 없이 쿼리만으로 개선할 수 있다는 점이 장점입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;하지만, Anti-Join 변환은 보장이 아닌 가능성입니다. 옵티마이저 버전, 테이블 통계 정보, 데이터 분포에 따라 여전히 Dependent Subquery로 실행될 수 있어서 EXPLAIN ANALYZE로 매번 확인이 필요합니다. 또한, NOT EXISTS 자체가 남아있으므로 쿼리 구조의 복잡성이 유지됩니다. 해결하고자 하는 핵심은 OR 제거가 아니라 &lt;b&gt;서브쿼리 반복 실행의 제거&lt;/b&gt;이므로, 이를 구조적으로 보장하지 못하는 이 방식은 선택하지 않았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이와 비슷하게 Left Join으로 풀어내는 방식도 존재하지만, OR가 남아 있게 되어 옵티마이저가 효율적으로 처리하지 못 할 가능성이 존재합니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;결국, or 조건을 없애고, 시리즈/콘텐츠 여부를 확정할 수 있도록 해 주어야 하는 문제였습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;적용&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;변경은 간단했습니다. 우선 컬럼 추가를 위해 Flyway를 작성하고, Media 엔티티에 컬럼을 추가했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;저희는 QueryDSL로 쿼리를 작성하고 있었고, 이를 함수로 분리하여 표현하고 있었기 때문에 기존 로직(주석 부분)에서 새로 추가한 컬럼 확인 로직으로 변경해주면 바로 적용할 수 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778325781871&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;private BooleanExpression isDisplayable() {
//	return media.mediaType.eq(MediaType.SERIES)
//		.or(media.mediaType.eq(MediaType.CONTENTS)
//			.and(JPAExpressions.selectOne()	
//				.from(contents)
//					.where(contents.media.id.eq(media.id)
//						.and(contents.series.isNotNull()))
//							.notExists()));
                return media.isStandalone.isTrue();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;반정규화 후 API 호출을 해 보니 &lt;b&gt;3096ms -&amp;gt; 1323ms&lt;/b&gt;로 감소한 것을 확인할 수 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #0f111a; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;INFO 34772 --- [nio-8080-exec-6] p6spy: [STATEMENT] | 1323 ms |
    select
        m1_0.id,
        m1_0.bookmark_count,
        ...
    from
        media m1_0
    where
        m1_0.status='ACTIVE'
        and m1_0.public_status='PUBLIC'
        and m1_0.media_status='COMPLETED'
        and m1_0.is_standalone=true
    order by
        m1_0.bookmark_count desc
    limit
        0, 20&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;문제 2. 테이블 풀 스캔 + Filesort&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;반정규화를 통해 OR과 서브쿼리를 없앴지만, 여전히 비효율적인 쿼리가 실행되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1715&quot; data-origin-height=&quot;64&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/l3Yha/dJMcabjR2K3/ltJvjapk1fFVZn1KIDZqoK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/l3Yha/dJMcabjR2K3/ltJvjapk1fFVZn1KIDZqoK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/l3Yha/dJMcabjR2K3/ltJvjapk1fFVZn1KIDZqoK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fl3Yha%2FdJMcabjR2K3%2FltJvjapk1fFVZn1KIDZqoK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1715&quot; height=&quot;64&quot; data-origin-width=&quot;1715&quot; data-origin-height=&quot;64&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1259&quot; data-origin-height=&quot;177&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZaEr5/dJMcahdhLwR/leaA4LSRM4DCke7lJZRW4K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZaEr5/dJMcahdhLwR/leaA4LSRM4DCke7lJZRW4K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZaEr5/dJMcahdhLwR/leaA4LSRM4DCke7lJZRW4K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZaEr5%2FdJMcahdhLwR%2FleaA4LSRM4DCke7lJZRW4K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1259&quot; height=&quot;177&quot; data-origin-width=&quot;1259&quot; data-origin-height=&quot;177&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;WHERE 절의 조건들과 ORDER BY 컬럼에 인덱스가 없어 풀스캔을 진행합니다. 데이터를 추린 후에도 북마크 수 기반 정렬이 필요하기 때문에, &lt;b&gt;풀스캔 + filesort&lt;/b&gt;가 발생합니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;모든 데이터에 접근&lt;/b&gt;하여 필터링해야 함&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;남은 데이터에 대해 &lt;b&gt;정렬까지 진행&lt;/b&gt;해야 함&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Where 조건 필터링을 위한 도움과 효율적인 정렬을 위한 도움이 필요했고, 인덱스를 통해 해결 가능했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;선택: 인덱스 추가 ✅&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;idx_media_trending (Status, Public_Status, Media_Status, is_standalone, bookmark_count DESC) 인덱스를 추가하는 방법입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;bookmark_count는 보통 '인기 차트'같이 역순(높은 순)정렬로 쓰입니다. 정방향 인덱스를 걸어도 페이지 간은 양방향으로 연결되어 있기 때문에 역순 정렬에 도움이 되지만, 페이지 내부의 실제 데이터끼리는 단방향으로만 연결되어 있기 때문에 DESC를 걸어줍니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;카디널리티가 낮은 컬럼이지만, 복합 인덱스로 사용되어 필터링에 효과적으로 활용됨&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;인덱스로 인해 정렬된 상태를 유지하므로 매 조회 시 발생하는 File Sort가 사라질 것으로 예상&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;인덱스 용량이 커짐&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;쓰기 비용 발생&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;앞 필터링 조건에 변화는 적지만, 북마크 수가 변할 때마다 bookmark_count가 재정렬되어야 함&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;필터링 조건들은 미디어가 사용자에게 노출되기 위한 최소한의 조건으로, 삭제 여부/공개 여부/트랜스코딩 완료 여부를 확인합니다. 이는 User-API 서버에서 제공하는 미디어들에 대해 대부분 적용하고 있으므로, 인덱스의 사용 범위가 매우 넓어 활용도가 높습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;O+T 서비스에서 북마크는 보고 싶은 목록을 구성하는 일종의 모음 폴더 역할을 합니다. 따라서 북마크는 좋아요, 시청 등보다 훨씬 적게 발생할 것이므로 쓰기 빈도에 비해 조회 빈도가 매우 높아 인덱스 설정이 효과적일 것이라고 판단했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;적용&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;코드 상 변경은 없었고, 인덱스 추가 flyway를 작성했습니다. 따라서 Where절의 모든 컬럼이 인덱스의 선행 컬럼에 해당되며, Order By의 bookmark_count가 후행 컬럼으로 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;인덱스를 타게 됩니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;모든 조건 및 정렬이 인덱스를 활용하여&amp;nbsp;&lt;b&gt;1323ms&lt;/b&gt; &lt;b&gt;-&amp;gt; 68ms&lt;/b&gt;로 감소한 것을 확인할 수 있습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #0f111a; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;INFO 38628 --- [io-8080-exec-10] p6spy: [STATEMENT] | 68 ms |
    select
        m1_0.id,
        m1_0.bookmark_count,
        ...
    where
        m1_0.status='ACTIVE'
        and m1_0.public_status='PUBLIC'
        and m1_0.media_status='COMPLETED'
        and m1_0.is_standalone=true
    order by
        m1_0.bookmark_count desc
	...&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;개선 후 실행계획&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1884&quot; data-origin-height=&quot;58&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bF2VqJ/dJMcahEkoZX/tEvxbUAD3XYKnEpp2HapYk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bF2VqJ/dJMcahEkoZX/tEvxbUAD3XYKnEpp2HapYk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bF2VqJ/dJMcahEkoZX/tEvxbUAD3XYKnEpp2HapYk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbF2VqJ%2FdJMcahEkoZX%2FtEvxbUAD3XYKnEpp2HapYk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1884&quot; height=&quot;58&quot; data-origin-width=&quot;1884&quot; data-origin-height=&quot;58&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1484&quot; data-origin-height=&quot;173&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pfO9W/dJMcaakU2D4/Bh5lgJjI68Snr5Rn2hRSC1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pfO9W/dJMcaakU2D4/Bh5lgJjI68Snr5Rn2hRSC1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pfO9W/dJMcaakU2D4/Bh5lgJjI68Snr5Rn2hRSC1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpfO9W%2FdJMcaakU2D4%2FBh5lgJjI68Snr5Rn2hRSC1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1484&quot; height=&quot;173&quot; data-origin-width=&quot;1484&quot; data-origin-height=&quot;173&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;테이블 풀 스캔 + Filesort에서 Index Condition(ICP)으로 변경되었으며, 전체적인 실행 시간도 매우 빨라졌습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;데이터 수와 환경에 따라 다르겠지만, 이렇게 가장 오래 걸렸던 첫 번째 쿼리가 &lt;b&gt;3096ms -&amp;gt; 1323ms -&amp;gt; 68ms&lt;/b&gt;로 개선되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2-2. 2번 쿼리: 3028ms&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1778030664411&quot; class=&quot;java&quot; style=&quot;background-color: #f8f8f8; color: #383a42;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;INFO 528 --- [io-8080-exec-10] p6spy : [STATEMENT] | 3028 ms |
    select count(m1_0.id) 
    from media m1_0
    where 
        m1_0.status='ACTIVE'
        and (m1_0.public_status='PUBLIC'
            and m1_0.media_status='COMPLETED')
        and (m1_0.media_type='SERIES'
            or m1_0.media_type='CONTENTS'
            and not exists(select 1 from contents c1_0 where c1_0.media_id=m1_0.id and c1_0.series_id is not null))&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1888&quot; data-origin-height=&quot;84&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cWODan/dJMcahEhG0C/uOdR7uXDWOiKxpvlzT49fK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cWODan/dJMcahEhG0C/uOdR7uXDWOiKxpvlzT49fK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cWODan/dJMcahEhG0C/uOdR7uXDWOiKxpvlzT49fK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcWODan%2FdJMcahEhG0C%2FuOdR7uXDWOiKxpvlzT49fK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1888&quot; height=&quot;84&quot; data-origin-width=&quot;1888&quot; data-origin-height=&quot;84&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;두 번째 쿼리는&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;1. Where 절 or 조건 + 서브쿼리&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;2. count() 함수&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;로 인해 시간이 오래 걸립니다. 앞에서 서브쿼리와 조건절 및 정렬에 대한 인덱스를 설정했지만, count 쿼리 자체는 전체 개수를 세는 것이기 때문에 비교적 속도가 느렸습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;count 쿼리 발생 이유는 Page&amp;lt;T&amp;gt;를 반환하려면 총 데이터 수(totalElements) 가 필요한데, Spring Data의 Page 인터페이스가 getTotalPages(), getTotalElements()를 제공하기 때문에 Repository에서 데이터 쿼리와 별도로 동일 조건의 COUNT 쿼리를 한 번 더 날려야 했기 때문입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;전체 요소 개수가 필요 없다면, 그리고 Page 인터페이스 대신 Slice 인터페이스를 사용하면 count 쿼리가 발생하지 않기 때문에 쿼리 개선보다 코드 변경을 통해 쿼리 자체를 없애는 방법을 택했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;또한, 해당 서비스는 번호가 있는 페이지네이션을 제공하는 것이 아닌, 특정 개수만큼만 무한 스크롤 형식으로 전달하면 됐기 때문에 &lt;b&gt;Slice 인터페이스를 사용&lt;/b&gt;할 수 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 89px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 필요한&amp;nbsp;정보 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; Page&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; Slice&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp; &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;현재 페이지 데이터 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;O&amp;nbsp;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;O&amp;nbsp;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 다음&amp;nbsp;페이지&amp;nbsp;존재&amp;nbsp;여부 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;O&amp;nbsp;(COUNT&amp;nbsp;기반)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;O&amp;nbsp;(limit+1&amp;nbsp;기반)&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 총&amp;nbsp;페이지&amp;nbsp;수 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;O&amp;nbsp;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;불필요&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 총&amp;nbsp;데이터&amp;nbsp;수 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;O&amp;nbsp;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;불필요&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&amp;nbsp;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;코드 변경&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #0f111a; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;boolean hasNext = content.size() &amp;gt; pageable.getPageSize();
if (hasNext) {
        content = content.subList(0, pageable.getPageSize());
}
return new SliceImpl&amp;lt;&amp;gt;(content, pageable, hasNext);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Page 대신 Slice로 변경하며, 전체 수가 아닌 다음 페이지가 존재하는지 확인한 후 구현체인 SliceImpl을 응답합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이렇게 다른 플레이리스트는 페이지네이션이 필요한 경우 Page 인터페이스 대신 Slice를 사용할 수 있었고, 인기 차트 플레이리스트 API의 경우에는 페이지네이션이 필요하지 않으므로 List를 사용하여 상위 20개의 Media를 반환합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2-3. N+1 쿼리&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;앞의 가장 큰 두 개의 쿼리 개선을 진행했음에도 &lt;/span&gt;한 번의 API 호출에 최대 60개의 N+1 쿼리가 발생하여 응답 시간이 여전히 300ms 근처에 머물렀습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;왜 발생하는가&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Trending&amp;nbsp;API는&amp;nbsp;인기&amp;nbsp;미디어&amp;nbsp;목록을&amp;nbsp;조회합니다.&amp;nbsp;이&amp;nbsp;목록에는&amp;nbsp;&lt;b&gt;시리즈와&amp;nbsp;단편&amp;nbsp;콘텐츠&lt;/b&gt;가&amp;nbsp;섞여&amp;nbsp;있습니다. &lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;단편 콘텐츠는 그 자체가 재생 대상이므로 추가 조회가 필요 없습니다. 하지만 시리즈는 껍데기입니다. 시리즈 자체를 재생할 수는 없고, 그 안의 에피소드 중 하나를 재생해야 합니다. &lt;/span&gt;&lt;br /&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;그래서 시리즈마다 아래 내용을 결정해야 합니다&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이 유저가 가장 최근에 본 에피소드가 있으면 &amp;rarr; 그 에피소드의 Media ID (이어보기)&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;시청 이력이 아예 없으면 &amp;rarr; 1화의 Media ID (처음부터 보기)&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이 결정을 for문 안에서 시리즈마다 개별 쿼리로 하고 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;따라서 조회된 미디어가 시리즈일 경우 N+1 문제가 발생합니다. 아래 흐름으로 진행됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;시리즈의 경우 이어보기 지점 표시를 위해 시리즈 내 미디어 중 가장 최근에 시청한 미디어를 찾아야 함&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Watch_History -&amp;gt; Contents -&amp;gt; Series -&amp;gt; Media&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;1번 쿼리에서 가져온 Media 개수 (20개)만큼 반복&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;기존 코드&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778340138226&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;for (Media media : mediaPage.getContent()) {
    if (media.getMediaType() == MediaType.SERIES) {
        // (1) 시리즈마다 시청 이력 조회 &amp;rarr; 1회 쿼리
        Long targetId = watchHistoryRepository
            .findLatestContentMediaIdByMemberIdAndSeriesMediaId(memberId, media.getId())
            // (2) 이력 없으면 1화 조회 &amp;rarr; 2회 쿼리 (데이터 + COUNT)
            .orElseGet(() -&amp;gt; getFirstEpisodeMediaId(media.getId()));
        mediaToTargetIdMap.put(media.getId(), targetId);
    } else {
        mediaToTargetIdMap.put(media.getId(), media.getId());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;위 for문이 조회된 Media의 수만큼 반복되는 것이 문제였습니다. 한 페이지에 20개의 미디어가 있고 전부 시리즈라면 루프가 20번 돌면서 매번 DB에 쿼리를 날리게 됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;최대 60개의 쿼리가 발생하는 이유&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 68px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 쿼리 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 발생 조건 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;횟수&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;findLatestContentMediaIdByMemberIdAndSeriesMediaId&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;시리즈마다&amp;nbsp;&quot;이&amp;nbsp;유저가&amp;nbsp;최근에&amp;nbsp;본&amp;nbsp;에피소드가&amp;nbsp;있나?&quot;&amp;nbsp;확인&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;: 시리즈마다 1회&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;20회&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;getFirstEpisodeMediaId: 데이터 쿼리&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;시청&amp;nbsp;이력이&amp;nbsp;없으니&amp;nbsp;1화를&amp;nbsp;가져오러&amp;nbsp;감&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;: 시청 이력 없는 시리즈의 fallback&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;20회&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;getFirstEpisodeMediaId: Count 쿼리&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;fallback이 Page 인터페이스를 반환하므로 COUNT가 따라옴&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;20회&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;합계&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;60회&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;getFirstEpisodeMediaId는 내부적으로 contentsRepository.findBySeries_Media_Id...를 호출하는데, 이 함수가 Page&amp;lt;Contents&amp;gt;를 반환합니다. Page는 totalElements를 알아야 하므로 데이터 쿼리와 별도로 COUNT 쿼리가 한 번 더 나갑니다. 그래서 시청 이력이 없는 시리즈 하나당 2회, 총 40회가 여기서 발생합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;정리&lt;/b&gt;하자면&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;본질적 원인: for문 안에서 시리즈마다 개별 쿼리를 날림&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;악화 요인: 1화 조회가 Page 반환이라 불필요한 COUNT 쿼리까지 추가 발생&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;문제&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;시리즈 20개면 최대 60번 쿼리 발생&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;각 1ms ~ 3ms로 괜찮아 보이지만, 클라우드에서 네트워크 지연이 누적될 수 있음&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;쿼리 수만큼 DB 커넥션을 점유하므로 동시 요청이 몰리면 커넥션 풀 소모가 빨라짐&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;해결: IN절 일괄 조회 &lt;b&gt;✅&lt;/b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;시리즈 ID를 하나씩 불러오던 기존 방식에서 한 번에 불러오는 방식으로 변경하여 해당 문제를 해결하기로 했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;구체적인 단계는 아래와 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;1단계. 시리즈 ID 목록 모으기&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;for문에 들어가기 전에 조회된 미디어 중 시리즈만 골라서 ID 목록을 만듦&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;해당 목록이 IN절에 들어갈 값&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;2단계. 시청 이력 일괄 조회 (1회)&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;시리즈마다 1회씩 호출하던 기존 함수에서 목록을 한 번에 넘기는 함수로 교체하여 한 번에 조회&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Map&amp;lt;시리즈MediaId, 최근시청에피소드MediaId&amp;gt;를 반환&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;쿼리 내부에서는 서브쿼리로 시리즈별 MAX(lastWatchedAt)인 행을 찾아 해당 에피소드의 Media ID를 가져옴&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;3단계. 시청 이력 없는 시리즈 -&amp;gt; 1화 일괄 조회 (1회)&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2단계에서 시청 이력이 없는 시리즈(latestEpisodeMap에 키가 없는 것)를 골라내어 이 시리즈들의 1화를 역시 IN절로 한 번에 조회&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;기존 getFirstEpisodeMediaId는 Page&amp;lt;Contents&amp;gt;를 반환해서 Count 쿼리가 발생했지만, 새 메서드는 Map&amp;lt;Long, Long&amp;gt;을 직접 반환하므로 Count 쿼리가 발생하지 않음&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;4단계. 두 맵을 합쳐 최종 결과 만들기&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이 for문은 이미 조회된 Map에서 값을 꺼내기만 하므로 쿼리가 발생하지 않음&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;시청 이력이 있는 시리즈 &amp;rarr; latestEpisodeMap에서 가져옴&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;시청 이력이 없는 시리즈 &amp;rarr; firstEpisodeMap에서 가져옴&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;단편 콘텐츠 &amp;rarr; 자기 자신의 ID&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;쿼리 수 비교&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 84px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.6124%; height: 21px;&quot;&gt;&lt;b&gt;항목&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 42.0542%; height: 21px;&quot;&gt;&lt;b&gt;Before&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;&lt;b&gt;After&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.6124%; height: 21px;&quot;&gt;&lt;b&gt;시청 이력 조회&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 42.0542%; height: 21px;&quot;&gt;시리즈 수 x 1회 (20회)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;1회 (IN절)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.6124%; height: 21px;&quot;&gt;&lt;b&gt;1화 fallback 조회&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 42.0542%; height: 21px;&quot;&gt;이력 없는 시리즈 x2회 (40회)&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;1회 (IN절, COUNT 없음)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 24.6124%; height: 21px;&quot;&gt;&lt;b&gt;총 쿼리 수&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 42.0542%; height: 21px;&quot;&gt;최대 60회&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 21px;&quot;&gt;2회&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;루프에서 하나하나 조회하는 것을 없애고, 미리 IN절로 한 번에 조회한 후 루프 안에서는 별도의 조회 없도록 변경하여 쿼리 수가 확연히 줄어들었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;대안 비교&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 68px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 26.4728%; height: 17px;&quot;&gt;&lt;b&gt;방법&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.1938%; height: 17px;&quot;&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;&lt;b&gt;이 상황에 적합한지&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 26.4728%; height: 17px;&quot;&gt;&lt;b&gt;IN절 일괄 조회&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.1938%; height: 17px;&quot;&gt;시리즈 ID를 모아서 한 번에 조회&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;적합 - 조회 로직이 커스텀(최근 시청 에피소드)이라 직접 IN절로 제어해야 함&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 26.4728%; height: 17px;&quot;&gt;&lt;b&gt;@BatchSize&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.1938%; height: 17px;&quot;&gt;지연 로딩 시 N개씩 묶어서 IN절 자동 생성&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;부적합 - JPA 연관관계 로딩용, 커스텀 비즈니스 쿼리에는 적용 불가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;width: 26.4728%; height: 17px;&quot;&gt;&lt;b&gt;@EntityGraph / Fetch Join&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 40.1938%; height: 17px;&quot;&gt;연관관계를 즉시 로딩&lt;/td&gt;
&lt;td style=&quot;width: 33.3333%; height: 17px;&quot;&gt;부적합 - '시리즈 -&amp;gt; 최근 시청 에피소드'는 연관관계까 아닌 비즈니스 로직 기반 조회&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이 경우 발생하는 많은 쿼리는 흔히 발생하는 JPA 지연 로딩으로 인한 N+1 문제와 달랐습니다. JPA로 인한 문제의 경우 주로 BatchSize나 Fetch Join 등으로 해결합니다. 하지만 이 경우의 N+1은 for문 안에서 커스텀 쿼리를 반복 호출하는 패턴입니다. '이 시리즈에서 이 유저가 가장 최근에 본 에피소드가 뭔지'는 엔티티 연관관계가 아니라 비즈니스 로직이므로, JPA 레벨의 자동 최적화가 적용되지 않습니다. 직접 IN절 쿼리를 작성하여 모아서 한 번에 쿼리를 날리는 방식으로 해결했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;JPA로 인한 문제인지, 로직 상 쿼리 최적화가 가능한 부분인지를 먼저 살펴보고 그에 대한 적절한 해결책을 세우는 것이 중요했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;3. 개선 결과&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;개선 전 Controller/Service 응답 시간&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1778341131355&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;INFO 528 --- ... .PerformanceLoggingAspect: [SERVICE] PlaylistStrategyService.getPlaylists() 6457ms
INFO 528 --- ... .PerformanceLoggingAspect: [CONTROLLER] PlaylistController.getTrendingPlaylists() 6466ms&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;개선 후 Controller/Service 응답 시간&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;pre id=&quot;code_1778341020126&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;INFO 37396 --- ... .PerformanceLoggingAspect: [SERVICE] PlaylistStrategyService.getPlaylists() 51ms                                 : 
INFO 37396 --- ... .PerformanceLoggingAspect: [CONTROLLER] PlaylistController.getTrendingPlaylists() 64ms&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;AOP로 Controller/Service 계층의 응답 시간을 비교해봤습니다. 대략 6400ms정도 걸리던 API 응답 시간이 작업 후 60~80ms 정도로 개선된 것을 확인할 수 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;또한, 쿼리 수 자체도 64개 -&amp;gt; 5개로 최소화하여, 필요한만큼만 DB에 접근하게 되었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이렇게 쿼리 속도 개선과 쿼리 수 감소가 함께 어우러져 전체 API 응답 시간이 줄어들었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;4. 캐싱 도입&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;4-1. 배경&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;홈 화면은 메인 페이지로, 서비스를 사용하는 모든 사용자가 처음 진입하는 가장 트래픽이 많은 지점입니다. 따라서 해당 페이지의 응답 속도가 곧 사용자 경험과 연결됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;723&quot; data-origin-height=&quot;540&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baAoz2/dJMcagepAOe/3htBDm2AlISGMAEVb9kN31/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baAoz2/dJMcagepAOe/3htBDm2AlISGMAEVb9kN31/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baAoz2/dJMcagepAOe/3htBDm2AlISGMAEVb9kN31/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaAoz2%2FdJMcagepAOe%2F3htBDm2AlISGMAEVb9kN31%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;448&quot; data-origin-width=&quot;723&quot; data-origin-height=&quot;540&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;현재 조회 구조는 단순하게 Client 요청 -&amp;gt; Server -&amp;gt; DB 쿼리 순서로 진행됩니다. 매 요청마다 DB를 거칩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;여기서 DB 쿼리 횟수 자체를 줄인다면 트래픽이 늘어났을 때 응답 속도가 더욱 안정적일 것이라고 생각했고, 캐싱을 도입하기 위한 조건들을 검토해보았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;인기 차트 API는 아래 특성들을 가지고 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;읽기 비중이 높고, 변경에 둔감하다.(&lt;b&gt;약간의 지연 허용&lt;/b&gt;)&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;차트가 변하는 시점은 북마크 수가 변동될 때이고, 조회 대비 쓰기 비율이 낮다.&amp;nbsp;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;북마크 특성 상 쓰기 비중이 극히 낮진 않지만, 이를 인기 차트에 반영하는 것은 변경에 둔감하다. OTT 서비스에서는 오히려 실시간성을 위해 변경 사항을 즉시 반영하는 것보다 주기적으로 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;갱신하는 것이 좋다.(해당 프로젝트의 기획이기도 함)&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;모든 사용자가 동일한 데이터를 본다.&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;개인화가 없기 때문에 유저 A와 유저 B의 응답이 동일하다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;100명이 동시에 요청해도 DB에서 꺼내오는 결과는 하나이다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;쿼리 개선이 완료되었다.&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;앞의 과정을 거쳐 한 번의 요청에서 발생하는 쿼리 수를 줄이고, 속도를 개선했다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;남은 병목은 트래픽에 따른 DB 쿼리 횟수이다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;높은 읽기 비중 + 변경 지연 허용 + 모든 사용자에게 동일한 데이터 제공이라는 조건을 바탕으로 캐싱을 도입하면 응답 속도를 더욱 개선할 수 있다고 생각했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;4-2. 인기 차트 플레이리스트 정의&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;캐싱 방식을 결정하기 전에 인기 차트 플레이리스트에 대한 기획 확정이 필요했습니다. 인기 차트 플레이리스트는 북마크 수가 가장 높은 Media 20개를 제공합니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;1. 북마크 수 기반 인기 차트 실시간 반영 vs 지연 허용&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;또한, 데이터가 즉시 반영되어야 하는지에 따라 구조가 달라질 수 있습니다. 마찬가지로 DB 업데이트에 이어 캐시 데이터도 업데이트 하는지에 대한 문제가 발생하기 때문입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;현재 서비스의 인기 차트 플레이리스트는 북마크 수 기반으로 제공됩니다. 북마크는 사용자 행동으로 인해 발생하는 데이터로, 이 데이터가 발생할 때마다 실시간으로 반영해서 사용자에게 제공하는 방식과 일정 주기마다 데이터를 갱신해서 플레이리스트를 구성하는 방식이 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;넷플릭스나 왓챠같은 OTT 서비스들은 (북마크 기반같은 단순한 로직이 아니겠지만) 즉시 반영하기보다 일정 주기마다 순위를 갱신합니다. OTT 서비스의 특성을 고려했을 때, 실시간 반영보단 한 번에 뭉텅이로 제공하는 것이 더 낫다고 판단했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이렇게 된다면 DB와 캐시는 항상 일관된 데이터를 유지할 필요는 없습니다. DB를 중심으로 모든 데이터를 관리하고, 시간 단위로 데이터를 캐싱하는 방식을 사용할 수 있습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;따라서 '&lt;b&gt;인기 차트&lt;/b&gt; 플레이리스트는 &lt;b&gt;일정 시간의 지연을 허용&lt;/b&gt;한다'로 결정했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2. 데이터 불일치 가능 여부&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;다중 서버 환경에서는 캐시 데이터 불일치 문제가 발생할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;홈 화면에서 새로고침을 할 때마다 인기 차트 플레이리스트가 변경된다면 사용자 입장에서 혼란을 겪을 수 있을 것이라고 생각했습니다. 물건의 수량이나 결제와 같이 완전무결하게 데이터가 유지되어야 하는 서비스는 아니지만, 사용자 경험 측면에서 살펴보면 일관된 데이터를 제공하는 것이 좋습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;따라서 데이터 일치에 관해서 '&lt;b&gt;사용자는 일관된 데이터를 받아야 한다&lt;/b&gt;'로 결정했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;4-3. 캐싱 방식 결정&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;1. 로컬 캐시 단독 사용&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;각 서버마다 로컬 캐시를 두고, 요청 시 각자의 캐시에서 데이터를 확인하고 응답하는 방식입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;756&quot; data-origin-height=&quot;460&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bld6ql/dJMcagFvfcK/U6AwbTnjbe6qPfZ8C69UM0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bld6ql/dJMcagFvfcK/U6AwbTnjbe6qPfZ8C69UM0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bld6ql/dJMcagFvfcK/U6AwbTnjbe6qPfZ8C69UM0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbld6ql%2FdJMcagFvfcK%2FU6AwbTnjbe6qPfZ8C69UM0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;480&quot; height=&quot;460&quot; data-origin-width=&quot;756&quot; data-origin-height=&quot;460&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;가장 큰 장점은 응답속도가 빠릅니다. 네트워크 지연 없이 &lt;b&gt;각 서버의 메모리에서 바로 응답&lt;/b&gt;하기 때문입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;하지만, TTL 범위 내에서 일관된 데이터를 응답하지 않을 수 있습니다. 서버 1의 TTL이 먼저 만료되어 새로운 데이터를 가져오고, 서버 2는 아직 이전 데이터를 가지고 있을 때 응답 데이터의 차이가 나타납니다. 또한, Auto Scaling에 따라 새로운 서버 인스턴스가 생성되고, 캐시 데이터 구성 시 다른 서버들과 다른 데이터를 가지고 있을 수도 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;앞서 정의한 내용에 따르면 '데이터 불일치'를 허용하지 않았기 때문에 이러한 요구사항에서는 추가적인 갱신 방법을 도입하지 않는다면 사용하기 어려운 방식입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;2. 글로벌 캐시 사용 ✅&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;각 서버별 캐시가 아닌 Redis 혹은 Memcached와 같은 글로벌 캐시를 두어 요청이 올 때마다 모든 서버가 해당 캐시를 보고 데이터를 응답하는 방식입니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;875&quot; data-origin-height=&quot;403&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/XQIsX/dJMcaaFhK03/hNVzSOey3H3XzAFnFLInEK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/XQIsX/dJMcaaFhK03/hNVzSOey3H3XzAFnFLInEK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/XQIsX/dJMcaaFhK03/hNVzSOey3H3XzAFnFLInEK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXQIsX%2FdJMcaaFhK03%2FhNVzSOey3H3XzAFnFLInEK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;403&quot; data-origin-width=&quot;875&quot; data-origin-height=&quot;403&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;첫 번째 방식인 로컬 캐시에 비해 응답 속도는 느리지만, DB에 직접 접근하여 쿼리를 날리지 않고, 이미 가공된 &lt;b&gt;데이터가 글로벌 캐시에 존재&lt;/b&gt;하기 때문에 충분히 응답 속도가 빠릅니다. 이 방식은 캐시 자체가 글로벌 캐시 하나만 존재하기 때문에 서버 캐시 간 데이터 불일치 문제가 해결됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;단점은 운영 복잡도가 올라간다는 것입니다. 인프라 하나가 추가되기 때문입니다. 또한, 해당 글로벌 캐시가 SPOF가 된다는 점입니다. 모든 서버가 하나의 캐시를 보기 때문에 해당 서버의 장애 발생 시 모든 서버의 캐시가 동시에 사라지고, 전체 트래픽이 DB로 직행합니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;3. 로컬 캐시 + Redis Pub/Sub&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;1번 방법처럼 각 인스턴스가 로컬 캐시를 유지하되, 데이터 변경 시 &lt;b&gt;Redis Pub/Sub&lt;/b&gt;과 같은 메시징 시스템을 사용하여 &lt;b&gt;모든 인스턴스에 캐시 무효화 이벤트를 발행&lt;/b&gt;하여 &lt;b&gt;캐시를 동기화&lt;/b&gt;하는 방식입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1061&quot; data-origin-height=&quot;477&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uTL8F/dJMcaciI20F/8BDZLU31PZhBpJeJ8oebM1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uTL8F/dJMcaciI20F/8BDZLU31PZhBpJeJ8oebM1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uTL8F/dJMcaciI20F/8BDZLU31PZhBpJeJ8oebM1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FuTL8F%2FdJMcaciI20F%2F8BDZLU31PZhBpJeJ8oebM1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;288&quot; data-origin-width=&quot;1061&quot; data-origin-height=&quot;477&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;첫 번째 방법의 가장 큰 문제였던 데이터 일관성 문제가 개선됩니다. 변경이 발생하면 모든 인스턴스가 거의 동시에 캐시를 갱신하므로 인스턴스 간 차이가 최소화됩니다. 조회 시에는 로컬 캐시를 사용하므로 글로벌 캐시로의 네트워크 비용조차 아껴야 하는 트래픽이 쏟아지며 조회가 매우 빈번하게 발생하는 상황에서 유용하게 쓰입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;단점은 로컬 캐시를 사용함에도 Redis 의존성, 메시지 유실/중복 처리, Pub/Sub 구독 관리와 같은 복잡도가 높아진다는 것입니다. 또한, 일시적으로 구독이 끊기는 등 장애 상황에서의 대비로 캐시의 TTL 설정 또한 필요합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;해당 프로젝트는 앞서 정의한 것처럼 북마크 수의 '실시간 반영'이 필요하지 않고, 주기적인 갱신이 필요합니다. 따라서 사용자의 북마크 혹은 북마크 취소로 인한 랭킹 변동을 캐시 데이터에 즉 반영할 필요가 없습니다. 사실상 TTL 만료 시에만 메시지가 발행됩니다. 이렇게 매우 드물게 발생하는 작업을 위해 새로운 인프라를 추가하고, 관리 포인트를 늘리는 것이 부담될 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;네트워크 지연을 최소화하거나 Redis나 다른 메시징 시스템 등 이미 사용 중인 인프라가 존재하는 경우에는 충분히 고려할 만한 선택지라고 생각합니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;4. 로컬 캐시 (L1) + 글로벌 캐시 (L2)&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;각 서버에 로컬 캐시를 두고, 모든 서버가 사용하는 글로벌 캐시도 두는 방식입니다. 요청이 들어오면 먼저 로컬 캐시(L1)를 확인하고, 미스 시 Redis(L2)를 조회하고, 그것도 미스면 DB를 조회하는 계층 구조입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1103&quot; data-origin-height=&quot;485&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AaG19/dJMcahRTeN4/uHYZN1iKmpD7tdrrSZfYh0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AaG19/dJMcahRTeN4/uHYZN1iKmpD7tdrrSZfYh0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AaG19/dJMcahRTeN4/uHYZN1iKmpD7tdrrSZfYh0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAaG19%2FdJMcahRTeN4%2FuHYZN1iKmpD7tdrrSZfYh0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;640&quot; height=&quot;485&quot; data-origin-width=&quot;1103&quot; data-origin-height=&quot;485&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;로컬 캐시의 속도와 글로벌 캐시의 일관성을 동시에 얻을 수 있습니다. L1 TTL을 짧게(ex. 1분), L2 TTL을 길게(ex. 5분) 설정하면 대부분의 요청은 L1에서 처리되고, L1 미스 시에도 DB까지 가지 않고 L2에서 응답합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;하지만 해당 API에서는 복잡도 대비 이점이 적습니다. TTL 내 적중률이 높은 상황에서 L1 미스가 발생하는 시점은 TTL 만료 직후뿐입니다. 이때 DB를 한 번 조회하는 것과 Redis를 한 번 조회하는 것의 차이는 미미합니다. 앞선 쿼리 개선 작업 이후 쿼리 자체가 큰 병목이 아닌 상황에서 L2 계층이 막아주는 DB 부하가 크지 않습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;결국, L1 TTL 관리, L2 TTL 관리, 계층 간 정합성, Redis 의존성까지 모두 안고 가면서 얻는 실질적 이점이 부족해 보입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;정리&lt;/span&gt;&lt;/h4&gt;
&lt;div&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.3489%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 방안 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.2092%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 응답 속도 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.1163%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 일관성 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.2093%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; 복잡도 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.3489%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;로컬 캐시 단독&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.2092%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;매우 빠름&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.1163%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;TTL 내 불일치&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.2093%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;최소&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.3489%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt; &lt;b&gt;글로벌 캐시 단독&lt;/b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.2092%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;빠름&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.1163%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;거의 일치&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.2093%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;중간&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.3489%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;로컬 캐시 + Redis Pub/Sub&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.2092%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;매우 빠름&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.1163%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;완전 일치&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.2093%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;높음&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.3489%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;L1 + L2 계층&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.2092%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;빠름(TTL 짧음)&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 25.1163%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;TTL 내 불일치&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.2093%;&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;높음&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;팀에서는 데이터 일관성과 구현 및 관리 복잡도를 고려하여 글로벌 캐시와 로컬 캐시 + Redis Pub/Sub 중 고민하다가 &lt;b&gt;글로벌 캐시를 사용&lt;/b&gt;하기로 결정했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;로컬 캐시를 활용하는 방안을 여러 방면으로 고려해봤지만, 데이터 일치 문제와 이를 해결하기 위한 메시징 시스템은 오히려 과하다고 생각했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;4-4. Memcached vs Redis&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;글로벌 캐시에 대표적으로 사용되는 Memcached와 Redis 두 가지를 검토했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Memcached는 &lt;b&gt;단순한 Key-Value 인메모리 캐시&lt;/b&gt;입니다. 멀티스레드 기반으로 동작하며, 단순 캐싱 용도에 매우 빠르고 가볍습니다. 하지만, 자료구조가 단순 Key-Value뿐이며 영속성 기능이 존재하지 않는 등 부가적인 기능이 적습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Redis는 &lt;b&gt;다양한 자료구조를 지원하는 인메모리 데이터 스토어&lt;/b&gt;입니다. 캐시 외에도 다양한 용도로 사용 가능합니다. 풍부한 자료구조 (String, List, Set, Sorted Set, Hash ...)를 제공하며, 영속성 옵션 (AOF) 또한 존재합니다. 하지만, memcached보다 복잡도가 높다는 점도 존재합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;선택: Redis&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Redis를 사용하기로 결정했고, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;아래의 이유들을 고민했습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;확장 가능성&lt;/b&gt;: 현재는 단순 Key-Value 캐싱이지만, 다른 기능에서 Redis를 재사용할 가능성이 높습니다. 또한, 차후 차트 정렬을 Redis 자체에 맡기는 방식(Sorted Set)으로 전환하거나 시간 윈도우 trending을 도입할 경우 Redis의 다양한 자료구조가 적합하기 때문입니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;b&gt;학습&lt;/b&gt;: Spring Data Redis, Redisson, RedisTemplate 등 생태계가 풍부하고 학습 자료가 많습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;4-5. String vs Sorted Set(ZSET)&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;String 타입은 20개의 인기 차트에 대한 응답을 JSON 직렬화된 형태로 통째로 저장합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;미디어 플레이리스트는 응답은 ID만 필요한 게 아니라 제목, 썸네일 등 메타데이터까지 포함됩니다. JSON 형식으로 데이터를 통째로 캐싱하여 그대로 응답할 수 있어, 추가적인 DB 조회가 필요없습니다. 또한, GET/SET 두 가지 명령어를 사용하며 구현이 매우 단순합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Sorted Set은 score로 정렬된 member들의 집합입니다. Media ID를 식별자로 미디어별 score를 기준으로 정렬된 형태를 유지합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;부분 갱신, 페이지네이션, score 범위 조회와 같은 ZSET 기능이 필요하지 않음&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;배치로 TOP 20을 통째 갱신 / 한 콘텐츠만 score 바꾸는 시나리오&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;인기 차트는 TOP 20 고정 노출 -&amp;gt; 11~20위 별도 페이지 x&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;score 범위 조회 불필요 -&amp;gt; '북마크 5000개 이상인 콘텐츠' 등 x&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;메타데이터 분리 운영&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;ZSET에는 contentId만 저장되므로 제목, 썸네일 등의 메타데이터는 별도 캐싱하거나 DB 조회 필요&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;ZSET의 강점이 활용되지 않음&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;자동 정렬은 좋지만, IN절은 순서를 보장하지 않으므로 추려진 데이터에 대해 DB에서 정렬 후 가져옴&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이러한 이유로 String 타입에 JSON 형식으로 응답 페이로드를 모두 캐싱하기로 결정했습니다. 인기 차트 플레이리스트는 20개 뿐이며, 필요한 컬럼만 추려서 저장한다면 메모리에 큰 부담이 없을 것이라고 생각했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;하지만, 실시간성이나 정교한 요청 등 결국 확장을 생각하면 ZSET을 고려하는 것이 좋을 것 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;4-6. 캐싱 전략&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;Cache Aside + Write Around&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;읽기 전략은 Cache Aside, 쓰기 전략은 Write Around를 선택했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;692&quot; data-origin-height=&quot;433&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wWE4W/dJMb997pT2a/pYjacFSpsYEfYxDmYpxdLK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wWE4W/dJMb997pT2a/pYjacFSpsYEfYxDmYpxdLK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wWE4W/dJMb997pT2a/pYjacFSpsYEfYxDmYpxdLK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwWE4W%2FdJMb997pT2a%2FpYjacFSpsYEfYxDmYpxdLK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;580&quot; height=&quot;433&quot; data-origin-width=&quot;692&quot; data-origin-height=&quot;433&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;그림처럼 사용자의 요청이 들어오면 바로 DB를 보는 것이 아닌 캐시를 먼저 확인합니다. 캐시에 데이터가 존재하면 바로 응답을 보내고, 그렇지 않다면 DB에서 데이터를 조회한 후 캐시에 저장하고, 사용자에게 응답 데이터를 보냅니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;사용자의 북마크 설정 및 취소 행동이 발생하면 쓰기 작업이 시작됩니다. 앞서 일정 주기 간격으로 랭킹을 갱신하기로 결정했으므로 쓰기 작업을 바로 캐시에 반영하지 않고, RDB에만 반영합니다. 그리고 설정해 둔 캐시 데이터의 TTL이 만료되면 DB에서 새로운 랭킹을 받아 저장합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;TTL&amp;nbsp;&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;TTL은 1시간으로 잡았습니다. 넷플릭스의 인기 순위를 살펴봐도 그 주기가 더 긴 것 같지만, 해당 프로젝트는 복합적인 요소가 아닌 북마크 수 기반이므로 너무 길게 잡지 않고 적당한 주기로 갱신하기로 결정했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;적용&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;span style=&quot;color: #3a4954; text-align: start;&quot;&gt;Spring의&amp;nbsp;&lt;/span&gt;@Cacheable&lt;span style=&quot;color: #3a4954; text-align: start;&quot;&gt;을 Service 계층에 붙여주어 간단히 적용할 수 있었습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778600195073&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Cacheable(value = &quot;trending&quot;, key = &quot;#top20&quot;)
public TrendingPlaylistResponse getTrending() {
	// 북마크 수 Top 20 조회
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;캐시 스탬피드 문제&lt;/span&gt;&lt;/b&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;TTL 만료 시점에 동시에 여러 트래픽이 발생하는 경우 순간적으로 DB에 부하가 크게 발생할 수 있습니다. 쿼리 개선 후 진행되고 있고, 캐시를 사용하는 지점도 많지 않아 현재는 크게 문제가 되지 않지만, 캐시를 사용하는 부분이 많아진다면 응답 지연의 원인이 될 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;791&quot; data-origin-height=&quot;604&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/PLLzx/dJMcaf0QSrB/cUcFp9HlstCTqUIzixklkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/PLLzx/dJMcaf0QSrB/cUcFp9HlstCTqUIzixklkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/PLLzx/dJMcaf0QSrB/cUcFp9HlstCTqUIzixklkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FPLLzx%2FdJMcaf0QSrB%2FcUcFp9HlstCTqUIzixklkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;560&quot; height=&quot;604&quot; data-origin-width=&quot;791&quot; data-origin-height=&quot;604&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;해결에는 Lock, PER, 논리적 만료 + 비동기 갱신, pre-warming 등 여러 방법이 존재합니다. 저는 간단하게 스케줄러를 통해 TTL보다 아주 약간 짧은 주기로 데이터를 갱신하도록 구성했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이 경우에는 &lt;b&gt;다중 서버에서 스케줄러가 여러 번 실행&lt;/b&gt;되어 중복 갱신이 될 수 있으므로 ShedLock이나 분산 락으로 &lt;b&gt;한 번만 실행&lt;/b&gt;되게 설정해줍니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;5. 결과&lt;/span&gt;&lt;/b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;캐시 히트 시 레디스에서 바로 꺼내오기 때문에 응답시간이 굉장히 빨라진 것을 확인할 수 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1778773816624&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;INFO 37396 --- ... .PerformanceLoggingAspect: [CONTROLLER] PlaylistController.getTrendingPlaylists() 4ms&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;555&quot; data-origin-height=&quot;485&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ehDJvz/dJMcaiQPS3r/u1Au29pLY6tqDVWMMP4sXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ehDJvz/dJMcaiQPS3r/u1Au29pLY6tqDVWMMP4sXK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ehDJvz/dJMcaiQPS3r/u1Au29pLY6tqDVWMMP4sXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FehDJvz%2FdJMcaiQPS3r%2Fu1Au29pLY6tqDVWMMP4sXK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;480&quot; height=&quot;419&quot; data-origin-width=&quot;555&quot; data-origin-height=&quot;485&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;시작 전과 동일한 VU 구성으로 부하 테스트를 해 봤습니다. 데이터 셋은 Large -&amp;gt; XLarge로 변해 개선 후가 훨씬 데이터가 많음에도 응답 속도가 빨라진 것을 확인할 수 있었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1545&quot; data-origin-height=&quot;787&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d0t581/dJMcaa6o4gl/sek3L7FGrlfNhMeZKAqbX1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d0t581/dJMcaa6o4gl/sek3L7FGrlfNhMeZKAqbX1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d0t581/dJMcaa6o4gl/sek3L7FGrlfNhMeZKAqbX1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd0t581%2FdJMcaa6o4gl%2Fsek3L7FGrlfNhMeZKAqbX1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1545&quot; height=&quot;787&quot; data-origin-width=&quot;1545&quot; data-origin-height=&quot;787&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;6. 마무리&lt;/span&gt;&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;다중 서버라고 무작정 인프라를 추가하는 방식보다 최대한 각 인스턴스의 로컬 자원을 활용할 수 있는 방법을 고민해봤습니다. 하나의 서버에서 사용할 수 있는 것을 최대한 사용하는 것이 좋다고 생각했습니다. 결국은 트래픽에 따라 조정할 일이고, 무언가 추가된다면 그에 따른 운영 비용이 발생할 수 있음을 체감했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;기획적으로 결정된 사항에 알맞게 해결 방식을 결정하는 것도 중요했습니다. 프로젝트에서 기능을 구현하면서 시리즈/콘텐츠 쪽에 시간을 많이 사용했고, '인기 차트'를 정의하면서도 나름대로의 기준이 필요했습니다. 기획/설계 단계에서 실시간성이 짙은 인기 차트를 제공하고자 한다면 Redis의 자료구조 중 하나인 ZSET을 사용할 수도 있다는 말이 나왔던 기억이 있습니다. 기획이 모호하면 기술 결정에 어려움이 있을 것이라는 점을 다시 한 번 떠올렸습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;이번 개선 작업은 눈으로 확인할 수 있는 정보를 바탕으로 여러 대안을 하나씩 따져가며 진행했습니다. 마무리 단계에서 돌아보면 간단한 개선 작업이었지만, 선택한 방식 외에도 다른 방식들을 살펴볼 수 있었고, 그들보다 현재 방식을 선택한 이유를 찾고자 고민했기 때문에 생각보다 시간을 많이 사용한 것 같습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Project</category>
      <author>phonil</author>
      <guid isPermaLink="true">https://yestomo.tistory.com/23</guid>
      <comments>https://yestomo.tistory.com/23#entry23comment</comments>
      <pubDate>Sat, 9 May 2026 00:36:55 +0900</pubDate>
    </item>
    <item>
      <title>[O+T] 테스트 준비하기</title>
      <link>https://yestomo.tistory.com/21</link>
      <description>&lt;blockquote data-ke-style=&quot;style3&quot;&gt;병목 지점 탐색 및 개선 결과 확인을 위해 테스트 환경을 구축한 내용입니다.&amp;nbsp;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 내에 병목 지점으로 의심되는 부분은 많지만, 막상 테스트를 진행하지 않아 정말 개선이 필요한 지점인지, 또, 어느 규모에서 얼마나 문제인지 구체적으로 알 수 없었습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의심에 대한 가설을 세우고, 테스트를 진행하여 실제 병목 지점을 파악하고, 이유를 찾아가며 개선 후 검증하는 절차를 거쳐가며 문제 지점부터 해결까지 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;지표 기반으로&lt;span&gt; 진행하고자 합니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;( 가설 -&amp;gt; 테스트 -&amp;gt; 병목 지점 선정 -&amp;gt; 개선 -&amp;gt; 검증 -&amp;gt; 결과 )&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;span&gt;User API 서버의 테스트 및 개선을 위한 환경 구축 과정을 기록합니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;더미 데이터 준비&lt;/h2&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;데이터 규모에 따라 응답 시간이 달라집니다. 너무 작거나 큰 규모의 데이터를 가진 환경에서는 정확한 문제 파악 및 개선 측정이 어렵습니다.&lt;br /&gt;이 규모를 여러 프리셋으로 지정해놓고, 필요한만큼 변경해가며 테스트를 진행하기 위한 목적으로 스크립트를 작성했습니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;데이터 선정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 프로젝트에는 총 26개의 테이블이 존재하는데, 21개의 테이블을 채우고, 관련 없다고 판단한 5개의 테이블은 빈 상태로 두었습니다.&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;시드 대상 (21개)&lt;/h4&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 438px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt; &lt;span style=&quot;text-align: start;&quot;&gt;그룹&amp;nbsp;&lt;/span&gt; &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt; &lt;span style=&quot;text-align: start;&quot;&gt;테이블&amp;nbsp;&lt;/span&gt; &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt; FK 의존 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt; 비고 &lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&lt;b&gt;메타데이터&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;category&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;없음&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;고정 데이터 직접 INSERT (seed-metadata.sql)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;tag&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;category&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;고정 데이터 직접 INSERT (seed-metadata.sql)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;mood_category&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;없음&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;V8에서 8개 시드됨 &amp;rarr; 스킵&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;mood_tag&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;mood_category&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;V8에서 31개 시드됨 &amp;rarr; 스킵&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&lt;b&gt;회원&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;member&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;없음&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;최상위&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;preferred_tag&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;member, tag&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;회원당 3~5개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;member_radar_preference&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;member (1:1)&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;회원당 1개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&lt;b&gt;미디어&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;media&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;member&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;공통 부모&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 11.9767%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 25.2326%;&quot;&gt;series&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 24.3023%;&quot;&gt;media&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 38.4884%;&quot;&gt;media_type='SERIES'&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 11.9767%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 25.2326%;&quot;&gt;contents&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 24.3023%;&quot;&gt;media, series(nullable)&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 38.4884%;&quot;&gt;media_type='CONTENTS'&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 11.9767%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 25.2326%;&quot;&gt;short_form&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 24.3023%;&quot;&gt;media, series/contents(nullable)&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 38.4884%;&quot;&gt;media_type='SHORT_FORM'&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;media_tag&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;media, tag&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;미디어당 2~4개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;media_mood_tag&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;media, mood_tag&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;미디어당 1~3개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;media_metrics&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;media (1:1)&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;미디어당 1개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&lt;b&gt;상호작용&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;bookmark&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;member, media&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 11.9767%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 25.2326%;&quot;&gt;likes&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 24.3023%;&quot;&gt;member, media&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 38.4884%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;comment&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;member, contents&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;contents에만&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&lt;b&gt;시청&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;watch_history&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;member, contents&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;UK(member, contents)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 11.9767%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 25.2326%;&quot;&gt;playback&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 24.3023%;&quot;&gt;member, contents&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 38.4884%;&quot;&gt;UK(member, contents)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 17px;&quot;&gt;
&lt;td style=&quot;height: 17px; width: 11.9767%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 25.2326%;&quot;&gt;click_event&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 24.3023%;&quot;&gt;member, short_form&lt;/td&gt;
&lt;td style=&quot;height: 17px; width: 38.4884%;&quot;&gt;&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;height: 21px; width: 11.9767%;&quot;&gt;&lt;b&gt;분위기&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 25.2326%;&quot;&gt;member_mood_refresh&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 24.3023%;&quot;&gt;member&lt;/td&gt;
&lt;td style=&quot;height: 21px; width: 38.4884%;&quot;&gt;회원당 0~1개&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;시드 제외 (5개)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 5개 테이블은 트랜스코딩 서버 관련 테이블이므로, User-Api 서버의 테스트 대상에서 제외했습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%; height: 126px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 26.9767%; height: 21px;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;테이블&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 72.907%; height: 21px;&quot;&gt;&lt;b&gt; 제외 이유 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 26.9767%; height: 21px;&quot;&gt;&lt;b&gt;ingest_job&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 72.907%; height: 21px;&quot;&gt;트랜스코딩 파이프라인용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 26.9767%; height: 21px;&quot;&gt;&lt;b&gt;ingest_command&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 72.907%; height: 21px;&quot;&gt;위와 동일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 26.9767%; height: 21px;&quot;&gt;&lt;b&gt;transcode_outbox&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 72.907%; height: 21px;&quot;&gt;위와 동일&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 26.9767%; height: 21px;&quot;&gt;&lt;b&gt;flyway_schema_history&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 72.907%; height: 21px;&quot;&gt;Flyway 내부 관리용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 21px;&quot;&gt;
&lt;td style=&quot;width: 26.9767%; height: 21px;&quot;&gt;&lt;b&gt;shedlock&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 72.907%; height: 21px;&quot;&gt;ShedLock 내부 잠금용&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;데이터 규모&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 규모를 프리셋별로 나눠, 점진적으로 테스트해 볼 수 있도록 구분했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;프리셋별 규모&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;xl은 member 10,000 고정, 나머지 테이블만 large 대비 크기를 늘렸습니다. 요청 수는 VU로 조절이 가능하기 때문에, 늘리는 것이 의미 없다고 판단했습니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;small / medium / large / xl로 구분하여 규모별로 데이터를 삽입할 수 있도록 구성했습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt; 테이블 &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;small&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;medium&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;large&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;&lt;b&gt; xl &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;member&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;100&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;1,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;10,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;10,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;category&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;고정&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;고정&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;고정&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;고정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;tag&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;고정&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;고정&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;고정&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;고정&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;media&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;1,000&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;10,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;50,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;500,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;┗ series (10%)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;100&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;1,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;5,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;50,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;┗ contents (60%)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;600&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;6,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;30,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;300,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;┗ short_form (30%)&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;300&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;3,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;15,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;150,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;media_tag&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;3,000&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;30,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;150,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;1,500,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;media_mood_tag&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;2,000&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;20,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;100,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;1,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;media_metrics&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;1,000&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;10,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;50,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;500,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;preferred_tag&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;400&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;4,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;40,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;40,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;member_radar_preference&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;100&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;1,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;10,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;10,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;bookmark&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;5,000&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;50,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;200,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;2,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;likes&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;5,000&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;50,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;200,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;2,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;comment&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;3,000&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;30,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;100,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;1,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;watch_history&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;10,000&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;100,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;500,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;5,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;playback&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;5,000&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;50,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;200,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;2,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;click_event&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;3,000&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;30,000&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;100,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;1,000,000&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 30.4651%;&quot;&gt;&lt;b&gt;member_mood_refresh&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 22.4419%;&quot;&gt;50&lt;/td&gt;
&lt;td style=&quot;width: 15.2326%;&quot;&gt;500&lt;/td&gt;
&lt;td style=&quot;width: 14.5349%;&quot;&gt;5,000&lt;/td&gt;
&lt;td style=&quot;width: 17.093%;&quot;&gt;5,000&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;고정 메타데이터 (category / tag)&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시드 스크립트로 직접 생성. OTT 서비스에 맞는 장르/테마 기반.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;Category&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 10.814%;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;id&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 89.0698%;&quot;&gt;&lt;b&gt; name&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 10.814%;&quot;&gt;&lt;b&gt;1&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 89.0698%;&quot;&gt;장르&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 10.814%;&quot;&gt;&lt;b&gt;2&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 89.0698%;&quot;&gt;분위기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 10.814%;&quot;&gt;&lt;b&gt;3&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 89.0698%;&quot;&gt;시청상황&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 10.814%;&quot;&gt;&lt;b&gt;4&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 89.0698%;&quot;&gt;테마&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;Tag&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.6744%;&quot;&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;category&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 87.2093%;&quot;&gt;&lt;b&gt; tags&amp;nbsp;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.6744%;&quot;&gt;&lt;b&gt;장르&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 87.2093%;&quot;&gt;액션, 로맨스, SF, 스릴러, 코미디, 드라마, 호러, 판타지, 다큐멘터리, 애니메이션&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.6744%;&quot;&gt;&lt;b&gt;분위기&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 87.2093%;&quot;&gt;긴장감, 따뜻한, 유쾌한, 어두운, 감동적인&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.6744%;&quot;&gt;&lt;b&gt;시청상황&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 87.2093%;&quot;&gt;혼자볼때, 연인과, 가족과, 심심할때, 잠들기전&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 12.6744%;&quot;&gt;&lt;b&gt;테마&lt;/b&gt;&lt;/td&gt;
&lt;td style=&quot;width: 87.2093%;&quot;&gt;성장, 복수, 우정, 가족, 사랑, 생존, 범죄, 일상&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;더미 데이터는 프로시저로 작성하고, 호출하는 형식으로 작성했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[더미 데이터 프로시저]&lt;/b&gt;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;-- =============================================
-- OTT 테스트 데이터 시드 프로시저
-- 사용법:
--   mysql -u ott -p ott &amp;lt; scripts/seed-procedures.sql
--   mysql -u ott -p ott -e &quot;CALL seed_all('medium');&quot;
-- =============================================

DELIMITER //

DROP PROCEDURE IF EXISTS seed_all //

CREATE PROCEDURE seed_all(IN p_preset VARCHAR(10))
proc_body: BEGIN

    -- =============================================
    -- CONFIGURATION
    -- =============================================
    IF p_preset NOT IN ('small', 'medium', 'large', 'xl') THEN
        SELECT CONCAT('ERROR: Unknown preset &quot;', p_preset, '&quot;. Use: small, medium, large, xl') AS error;
        LEAVE proc_body;
    END IF;

    -- Verify metadata
    IF (SELECT COUNT(*) FROM category) &amp;lt; 4 OR (SELECT COUNT(*) FROM tag) &amp;lt; 28 THEN
        SELECT 'ERROR: Run seed-metadata.sql first!' AS error;
        LEAVE proc_body;
    END IF;

    -- Base counts
    IF p_preset = 'small' THEN
        SET @v_member = 100, @v_series = 100, @v_contents = 600, @v_sf = 300;
        SET @v_bookmark = 5000, @v_likes = 5000, @v_comment = 3000;
        SET @v_watch = 10000, @v_playback = 5000, @v_click = 3000;
    ELSEIF p_preset = 'medium' THEN
        SET @v_member = 1000, @v_series = 1000, @v_contents = 6000, @v_sf = 3000;
        SET @v_bookmark = 50000, @v_likes = 50000, @v_comment = 30000;
        SET @v_watch = 100000, @v_playback = 50000, @v_click = 30000;
    ELSEIF p_preset = 'large' THEN
        SET @v_member = 10000, @v_series = 5000, @v_contents = 30000, @v_sf = 15000;
        SET @v_bookmark = 200000, @v_likes = 200000, @v_comment = 100000;
        SET @v_watch = 500000, @v_playback = 200000, @v_click = 100000;
    ELSEIF p_preset = 'xl' THEN
        SET @v_member = 10000, @v_series = 50000, @v_contents = 300000, @v_sf = 150000;
        SET @v_bookmark = 2000000, @v_likes = 2000000, @v_comment = 1000000;
        SET @v_watch = 5000000, @v_playback = 2000000, @v_click = 1000000;
    END IF;

    SET @v_media = @v_series + @v_contents + @v_sf;

    -- =============================================
    -- SETUP: Sequence table (1..10000, cross join for larger)
    -- =============================================
    DROP TABLE IF EXISTS _seed_seq;
    CREATE TABLE _seed_seq (n INT UNSIGNED NOT NULL PRIMARY KEY);
    SET @@cte_max_recursion_depth = 10000;
    INSERT INTO _seed_seq
        WITH RECURSIVE seq AS (SELECT 1 AS n UNION ALL SELECT n + 1 FROM seq WHERE n &amp;lt; 10000)
        SELECT n FROM seq;

    SET FOREIGN_KEY_CHECKS = 0;
    SET UNIQUE_CHECKS = 0;
    SET @start_time = NOW();

    SELECT CONCAT('Starting seed: preset=', p_preset, ', members=', @v_member, ', media=', @v_media) AS progress;

    -- =============================================
    -- 1. MEMBER
    -- =============================================
    INSERT INTO member (email, password, nickname, role, provider, provider_id, refresh_token, onboarding_completed, created_date, modified_date, status)
    SELECT
        CONCAT('testuser', rn, '@test.com'),
        NULL,
        CONCAT('TestUser', rn),
        CASE
            WHEN rn &amp;lt;= FLOOR(@v_member * 0.90) THEN 'MEMBER'
            WHEN rn &amp;lt;= FLOOR(@v_member * 0.95) THEN 'EDITOR'
            ELSE 'ADMIN'
        END,
        'KAKAO',
        CONCAT('kakao_test_', rn),
        NULL,
        TRUE,
        DATE_SUB(NOW(), INTERVAL FLOOR(RAND(rn) * 365) DAY),
        NOW(),
        'ACTIVE'
    FROM (SELECT ((a.n-1)*10000+b.n) rn FROM _seed_seq a CROSS JOIN _seed_seq b WHERE a.n &amp;lt;= CEIL(@v_member/10000.0)) nums
    WHERE rn &amp;lt;= @v_member;

    SET @m_start = LAST_INSERT_ID();
    SET @m_end = @m_start + @v_member - 1;
    SET @up_start = @m_start + FLOOR(@v_member * 0.90);
    SET @up_count = @v_member - FLOOR(@v_member * 0.90);
    SELECT CONCAT('[1/16] member: ', @v_member) AS progress;

    -- =============================================
    -- 2. MEMBER_RADAR_PREFERENCE (1:1 with member)
    -- =============================================
    INSERT INTO member_radar_preference (member_id, popularity, immersion, mania, recency, re_watch, created_date, modified_date, status)
    SELECT
        @m_start + rn - 1,
        FLOOR(RAND(rn) * 101),
        FLOOR(RAND(rn + 100000) * 101),
        FLOOR(RAND(rn + 200000) * 101),
        FLOOR(RAND(rn + 300000) * 101),
        FLOOR(RAND(rn + 400000) * 101),
        NOW(), NOW(), 'ACTIVE'
    FROM (SELECT ((a.n-1)*10000+b.n) rn FROM _seed_seq a CROSS JOIN _seed_seq b WHERE a.n &amp;lt;= CEIL(@v_member/10000.0)) nums
    WHERE rn &amp;lt;= @v_member;

    SELECT CONCAT('[2/16] member_radar_preference: ', @v_member) AS progress;

    -- =============================================
    -- 3. PREFERRED_TAG (4 per member, offsets 0/7/14/21 guarantee uniqueness in 28 tags)
    -- =============================================
    INSERT INTO preferred_tag (member_id, tag_id, created_date, modified_date, status)
    SELECT
        @m_start + nums.rn - 1,
        ((nums.rn - 1 + t.offset) % 28) + 1,
        NOW(), NOW(), 'ACTIVE'
    FROM (SELECT ((a.n-1)*10000+b.n) rn FROM _seed_seq a CROSS JOIN _seed_seq b WHERE a.n &amp;lt;= CEIL(@v_member/10000.0)) nums
    CROSS JOIN (SELECT 0 AS offset UNION ALL SELECT 7 UNION ALL SELECT 14 UNION ALL SELECT 21) t
    WHERE nums.rn &amp;lt;= @v_member;

    SELECT CONCAT('[3/16] preferred_tag: ', @v_member * 4) AS progress;

    -- =============================================
    -- 4. MEDIA (SERIES) + SERIES
    -- =============================================
    INSERT INTO media (uploader_id, title, description, poster_url, thumbnail_url, bookmark_count, likes_count, media_type, public_status, media_status, created_date, modified_date, status)
    SELECT
        @up_start + FLOOR(RAND(rn) * @up_count),
        CONCAT('시리즈 ', rn),
        CONCAT('시리즈 ', rn, '의 설명입니다.'),
        CONCAT('/posters/series_', rn, '.jpg'),
        CONCAT('/thumbnails/series_', rn, '.jpg'),
        FLOOR(RAND(rn + 100000) * 200),
        FLOOR(RAND(rn + 200000) * 500),
        'SERIES',
        IF(RAND(rn + 300000) &amp;lt; 0.9, 'PUBLIC', 'PRIVATE'),
        IF(RAND(rn + 400000) &amp;lt; 0.9, 'COMPLETED', 'INIT'),
        DATE_SUB(NOW(), INTERVAL FLOOR(RAND(rn + 500000) * 365) DAY),
        NOW(),
        'ACTIVE'
    FROM (SELECT ((a.n-1)*10000+b.n) rn FROM _seed_seq a CROSS JOIN _seed_seq b WHERE a.n &amp;lt;= CEIL(@v_series/10000.0)) nums
    WHERE rn &amp;lt;= @v_series;

    SET @sm_start = LAST_INSERT_ID();

    INSERT INTO series (media_id, actors, created_date, modified_date, status)
    SELECT
        @sm_start + rn - 1,
        CONCAT('배우', FLOOR(RAND(rn) * 50) + 1, ', 배우', FLOOR(RAND(rn + 100000) * 50) + 51),
        DATE_SUB(NOW(), INTERVAL FLOOR(RAND(rn + 500000) * 365) DAY),
        NOW(),
        'ACTIVE'
    FROM (SELECT ((a.n-1)*10000+b.n) rn FROM _seed_seq a CROSS JOIN _seed_seq b WHERE a.n &amp;lt;= CEIL(@v_series/10000.0)) nums
    WHERE rn &amp;lt;= @v_series;

    SET @s_start = LAST_INSERT_ID();
    SET @s_count = @v_series;
    SELECT CONCAT('[4/16] media(SERIES) + series: ', @v_series) AS progress;

    -- =============================================
    -- 5. MEDIA (CONTENTS) + CONTENTS
    -- =============================================
    INSERT INTO media (uploader_id, title, description, poster_url, thumbnail_url, bookmark_count, likes_count, media_type, public_status, media_status, created_date, modified_date, status)
    SELECT
        @up_start + FLOOR(RAND(rn + 600000) * @up_count),
        CONCAT('콘텐츠 ', rn),
        CONCAT('콘텐츠 ', rn, '의 설명입니다.'),
        CONCAT('/posters/contents_', rn, '.jpg'),
        CONCAT('/thumbnails/contents_', rn, '.jpg'),
        FLOOR(RAND(rn + 700000) * 300),
        FLOOR(RAND(rn + 800000) * 800),
        'CONTENTS',
        IF(RAND(rn + 900000) &amp;lt; 0.9, 'PUBLIC', 'PRIVATE'),
        IF(RAND(rn + 1000000) &amp;lt; 0.9, 'COMPLETED', 'INIT'),
        DATE_SUB(NOW(), INTERVAL FLOOR(RAND(rn + 1100000) * 365) DAY),
        NOW(),
        'ACTIVE'
    FROM (SELECT ((a.n-1)*10000+b.n) rn FROM _seed_seq a CROSS JOIN _seed_seq b WHERE a.n &amp;lt;= CEIL(@v_contents/10000.0)) nums
    WHERE rn &amp;lt;= @v_contents;

    SET @cm_start = LAST_INSERT_ID();

    INSERT INTO contents (media_id, series_id, actors, duration, video_size, origin_url, master_playlist_url, created_date, modified_date, status)
    SELECT
        @cm_start + rn - 1,
        IF(RAND(rn + 1200000) &amp;lt; 0.7, @s_start + FLOOR(RAND(rn + 1300000) * @s_count), NULL),
        CONCAT('배우', FLOOR(RAND(rn + 1400000) * 50) + 1, ', 배우', FLOOR(RAND(rn + 1500000) * 50) + 51),
        IF(RAND(rn + 1600000) &amp;lt; 0.35,
            FLOOR(5400 + RAND(rn + 1700000) * 1800),
            FLOOR(1800 + RAND(rn + 1800000) * 1800)),
        FLOOR(500 + RAND(rn + 1900000) * 2500),
        CONCAT('/videos/contents_', rn, '.mp4'),
        IF(RAND(rn + 1000000) &amp;lt; 0.9, CONCAT('/hls/contents_', rn, '/master.m3u8'), NULL),
        DATE_SUB(NOW(), INTERVAL FLOOR(RAND(rn + 1100000) * 365) DAY),
        NOW(),
        'ACTIVE'
    FROM (SELECT ((a.n-1)*10000+b.n) rn FROM _seed_seq a CROSS JOIN _seed_seq b WHERE a.n &amp;lt;= CEIL(@v_contents/10000.0)) nums
    WHERE rn &amp;lt;= @v_contents;

    SET @c_start = LAST_INSERT_ID();
    SET @c_count = @v_contents;
    SELECT CONCAT('[5/16] media(CONTENTS) + contents: ', @v_contents) AS progress;

    -- =============================================
    -- 6. MEDIA (SHORT_FORM) + SHORT_FORM
    -- =============================================
    INSERT INTO media (uploader_id, title, description, poster_url, thumbnail_url, bookmark_count, likes_count, media_type, public_status, media_status, created_date, modified_date, status)
    SELECT
        @up_start + FLOOR(RAND(rn + 2000000) * @up_count),
        CONCAT('숏폼 ', rn),
        CONCAT('숏폼 ', rn, '의 설명입니다.'),
        CONCAT('/posters/short_', rn, '.jpg'),
        NULL,
        FLOOR(RAND(rn + 2100000) * 100),
        FLOOR(RAND(rn + 2200000) * 300),
        'SHORT_FORM',
        IF(RAND(rn + 2300000) &amp;lt; 0.9, 'PUBLIC', 'PRIVATE'),
        IF(RAND(rn + 2400000) &amp;lt; 0.9, 'COMPLETED', 'INIT'),
        DATE_SUB(NOW(), INTERVAL FLOOR(RAND(rn + 2500000) * 365) DAY),
        NOW(),
        'ACTIVE'
    FROM (SELECT ((a.n-1)*10000+b.n) rn FROM _seed_seq a CROSS JOIN _seed_seq b WHERE a.n &amp;lt;= CEIL(@v_sf/10000.0)) nums
    WHERE rn &amp;lt;= @v_sf;

    SET @sfm_start = LAST_INSERT_ID();

    INSERT INTO short_form (media_id, series_id, contents_id, duration, video_size, origin_url, master_playlist_url, created_date, modified_date, status)
    SELECT
        @sfm_start + rn - 1,
        NULL,
        IF(RAND(rn + 2600000) &amp;lt; 0.5, @c_start + FLOOR(RAND(rn + 2700000) * @c_count), NULL),
        FLOOR(15 + RAND(rn + 2800000) * 45),
        FLOOR(10 + RAND(rn + 2900000) * 100),
        CONCAT('/videos/short_', rn, '.mp4'),
        IF(RAND(rn + 2400000) &amp;lt; 0.9, CONCAT('/hls/short_', rn, '/master.m3u8'), NULL),
        DATE_SUB(NOW(), INTERVAL FLOOR(RAND(rn + 2500000) * 365) DAY),
        NOW(),
        'ACTIVE'
    FROM (SELECT ((a.n-1)*10000+b.n) rn FROM _seed_seq a CROSS JOIN _seed_seq b WHERE a.n &amp;lt;= CEIL(@v_sf/10000.0)) nums
    WHERE rn &amp;lt;= @v_sf;

    SET @sf_start = LAST_INSERT_ID();
    SET @sf_count = @v_sf;

    -- Track overall media range
    SET @media_start = @sm_start;
    SET @media_count = @v_media;
    SELECT CONCAT('[6/16] media(SHORT_FORM) + short_form: ', @v_sf) AS progress;

    -- =============================================
    -- 7. MEDIA_TAG (3 per media, offsets 0/9/19 coprime to 28)
    -- =============================================
    INSERT INTO media_tag (tag_id, media_id, created_date, modified_date, status)
    SELECT
        ((nums.rn - 1 + t.offset) % 28) + 1,
        @media_start + nums.rn - 1,
        NOW(), NOW(), 'ACTIVE'
    FROM (SELECT ((a.n-1)*10000+b.n) rn FROM _seed_seq a CROSS JOIN _seed_seq b WHERE a.n &amp;lt;= CEIL(@v_media/10000.0)) nums
    CROSS JOIN (SELECT 0 AS offset UNION ALL SELECT 9 UNION ALL SELECT 19) t
    WHERE nums.rn &amp;lt;= @v_media;

    SELECT CONCAT('[7/16] media_tag: ', @v_media * 3) AS progress;

    -- =============================================
    -- 8. MEDIA_MOOD_TAG (2 per media, offsets 0/15 coprime to 31, INSERT IGNORE for UK)
    -- =============================================
    INSERT IGNORE INTO media_mood_tag (media_id, mood_tag_id, priority, created_date, modified_date, status)
    SELECT
        @media_start + nums.rn - 1,
        ((nums.rn - 1 + t.offset) % 31) + 1,
        t.pri,
        NOW(), NOW(), 'ACTIVE'
    FROM (SELECT ((a.n-1)*10000+b.n) rn FROM _seed_seq a CROSS JOIN _seed_seq b WHERE a.n &amp;lt;= CEIL(@v_media/10000.0)) nums
    CROSS JOIN (SELECT 0 AS offset, 0 AS pri UNION ALL SELECT 15, 1) t
    WHERE nums.rn &amp;lt;= @v_media;

    SELECT CONCAT('[8/16] media_mood_tag: ~', @v_media * 2) AS progress;

    -- =============================================
    -- 9. MEDIA_METRICS (1:1 with media)
    -- =============================================
    INSERT INTO media_metrics (media_id, popularity, immersion, mania, recency, re_watch, batch_updated_at, created_date, modified_date, status)
    SELECT
        @media_start + rn - 1,
        ROUND(RAND(rn) * 100, 2),
        ROUND(RAND(rn + 100000) * 100, 2),
        ROUND(RAND(rn + 200000) * 100, 2),
        ROUND(RAND(rn + 300000) * 100, 2),
        ROUND(RAND(rn + 400000) * 100, 2),
        NOW(),
        NOW(), NOW(), 'ACTIVE'
    FROM (SELECT ((a.n-1)*10000+b.n) rn FROM _seed_seq a CROSS JOIN _seed_seq b WHERE a.n &amp;lt;= CEIL(@v_media/10000.0)) nums
    WHERE rn &amp;lt;= @v_media;

    SELECT CONCAT('[9/16] media_metrics: ', @v_media) AS progress;

    -- =============================================
    -- 10. BOOKMARK (Fill until target, avoid duplicates)
    -- =============================================
    SET @current_count = 0;
    bookmark_loop: WHILE @current_count &amp;lt; @v_bookmark DO
        SET @needed = @v_bookmark - @current_count;
        INSERT IGNORE INTO bookmark (member_id, media_id, created_date, modified_date, status)
        SELECT t.m_id, t.med_id, DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 180) DAY), NOW(), 'ACTIVE'
        FROM (
            SELECT 
                @m_start + FLOOR(RAND() * @v_member) AS m_id,
                @media_start + FLOOR(POW(RAND(), 2) * @media_count) AS med_id
            FROM _seed_seq LIMIT 2000
        ) t
        LEFT JOIN bookmark b ON t.m_id = b.member_id AND t.med_id = b.media_id
        WHERE b.id IS NULL
        GROUP BY t.m_id, t.med_id
        LIMIT 2000;
        
        SELECT COUNT(*) INTO @current_count FROM bookmark;
    END WHILE bookmark_loop;

    SELECT CONCAT('[10/16] bookmark: ', @v_bookmark) AS progress;

    -- =============================================
    -- 11. LIKES (Fill until target, avoid duplicates)
    -- =============================================
    SET @current_count = 0;
    likes_loop: WHILE @current_count &amp;lt; @v_likes DO
        SET @needed = @v_likes - @current_count;
        INSERT IGNORE INTO likes (member_id, media_id, created_date, modified_date, status)
        SELECT t.m_id, t.med_id, DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 180) DAY), NOW(), 'ACTIVE'
        FROM (
            SELECT 
                @m_start + FLOOR(RAND() * @v_member) AS m_id,
                @media_start + FLOOR(POW(RAND(), 2) * @media_count) AS med_id
            FROM _seed_seq LIMIT 2000
        ) t
        LEFT JOIN likes l ON t.m_id = l.member_id AND t.med_id = l.media_id
        WHERE l.id IS NULL
        GROUP BY t.m_id, t.med_id
        LIMIT 2000;

        SELECT COUNT(*) INTO @current_count FROM likes;
    END WHILE likes_loop;

    SELECT CONCAT('[11/16] likes: ', @v_likes) AS progress;

    -- =============================================
    -- 12. COMMENT (contents only)
    -- =============================================
    INSERT INTO comment (member_id, contents_id, content, is_spoiler, created_date, modified_date, status)
    SELECT
        @m_start + FLOOR(RAND(rn + 6000000) * @v_member),
        @c_start + FLOOR(RAND(rn + 7000000) * @c_count),
        CONCAT('테스트 댓글 ', rn),
        IF(RAND(rn + 8000000) &amp;lt; 0.1, TRUE, FALSE),
        DATE_SUB(NOW(), INTERVAL FLOOR(RAND(rn + 9000000) * 180) DAY),
        NOW(),
        'ACTIVE'
    FROM (SELECT ((a.n-1)*10000+b.n) rn FROM _seed_seq a CROSS JOIN _seed_seq b WHERE a.n &amp;lt;= CEIL(@v_comment/10000.0)) nums
    WHERE rn &amp;lt;= @v_comment;

    SELECT CONCAT('[12/16] comment: ', @v_comment) AS progress;

    -- =============================================
    -- 13. WATCH_HISTORY (Fill until target, avoid duplicates)
    -- =============================================
    SET @current_count = 0;
    WHILE @current_count &amp;lt; @v_watch DO
        SET @needed = @v_watch - @current_count;
        INSERT IGNORE INTO watch_history (member_id, contents_id, last_watched_at, re_watch_count, is_used_for_ml, created_date, modified_date, status)
        SELECT 
            @m_start + FLOOR(RAND() * @v_member),
            @c_start + FLOOR(RAND() * @c_count),
            DATE_SUB(NOW(), INTERVAL FLOOR(POW(RAND(), 2) * 90) DAY),
            CASE WHEN RAND() &amp;lt; 0.80 THEN 0 WHEN RAND() &amp;lt; 0.95 THEN 1 ELSE FLOOR(2 + RAND() * 3) END,
            FALSE,
            DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 365) DAY),
            NOW(), 'ACTIVE'
        FROM _seed_seq LIMIT 5000;

        SELECT COUNT(*) INTO @current_count FROM watch_history;
    END WHILE;

    SELECT CONCAT('[13/16] watch_history: ', @v_watch) AS progress;

    -- =============================================
    -- 14. PLAYBACK (Fill until target, avoid duplicates)
    -- =============================================
    SET @current_count = 0;
    WHILE @current_count &amp;lt; @v_playback DO
        SET @needed = @v_playback - @current_count;
        INSERT IGNORE INTO playback (member_id, contents_id, position_sec, created_date, modified_date, status)
        SELECT 
            @m_start + FLOOR(RAND() * @v_member),
            @c_start + FLOOR(RAND() * @c_count),
            FLOOR(RAND() * 7200),
            DATE_SUB(NOW(), INTERVAL FLOOR(RAND() * 90) DAY),
            NOW(), 'ACTIVE'
        FROM _seed_seq LIMIT 5000;

        SELECT COUNT(*) INTO @current_count FROM playback;
    END WHILE;

    SELECT CONCAT('[14/16] playback: ', @v_playback) AS progress;

    -- =============================================
    -- 15. CLICK_EVENT
    -- =============================================
    INSERT INTO click_event (member_id, short_form_id, click_at, click_type, created_date, modified_date, status)
    SELECT
        @m_start + FLOOR(RAND(rn + 20000000) * @v_member),
        @sf_start + FLOOR(RAND(rn + 21000000) * @sf_count),
        DATE_SUB(NOW(), INTERVAL FLOOR(RAND(rn + 22000000) * 90) DAY),
        IF(RAND(rn + 23000000) &amp;lt; 0.7, 'SHORT_CLICK', 'CTA_CLICK'),
        DATE_SUB(NOW(), INTERVAL FLOOR(RAND(rn + 22000000) * 90) DAY),
        NOW(),
        'ACTIVE'
    FROM (SELECT ((a.n-1)*10000+b.n) rn FROM _seed_seq a CROSS JOIN _seed_seq b WHERE a.n &amp;lt;= CEIL(@v_click/10000.0)) nums
    WHERE rn &amp;lt;= @v_click;

    SELECT CONCAT('[15/16] click_event: ', @v_click) AS progress;

    -- =============================================
    -- 16. MEMBER_MOOD_REFRESH (50% of members)
    -- =============================================
    SET @v_mood_refresh = FLOOR(@v_member * 0.5);

    INSERT INTO member_mood_refresh (member_id, image_id, subtitle, recommended_media_ids, is_hidden, created_date, modified_date, status)
    SELECT
        @m_start + rn - 1,
        FLOOR(RAND(rn + 24000000) * 10) + 1,
        CONCAT('오늘의 무드 ', rn),
        JSON_ARRAY(
            @media_start + FLOOR(RAND(rn + 25000000) * @media_count),
            @media_start + FLOOR(RAND(rn + 25100000) * @media_count),
            @media_start + FLOOR(RAND(rn + 25200000) * @media_count),
            @media_start + FLOOR(RAND(rn + 25300000) * @media_count),
            @media_start + FLOOR(RAND(rn + 25400000) * @media_count)
        ),
        IF(RAND(rn + 26000000) &amp;lt; 0.2, TRUE, FALSE),
        NOW(), NOW(), 'ACTIVE'
    FROM (SELECT ((a.n-1)*10000+b.n) rn FROM _seed_seq a CROSS JOIN _seed_seq b WHERE a.n &amp;lt;= CEIL(@v_mood_refresh/10000.0)) nums
    WHERE rn &amp;lt;= @v_mood_refresh;

    SELECT CONCAT('[16/16] member_mood_refresh: ', @v_mood_refresh) AS progress;

    -- =============================================
    -- 17. SYNC DE-NORMALIZED COUNTS
    -- =============================================
    UPDATE media m 
    SET 
        m.likes_count = (SELECT COUNT(*) FROM likes l WHERE l.media_id = m.id AND l.status = 'ACTIVE'),
        m.bookmark_count = (SELECT COUNT(*) FROM bookmark b WHERE b.media_id = m.id AND b.status = 'ACTIVE');

    SELECT '[17/17] Sync de-normalized counts: media(likes_count, bookmark_count) updated' AS progress;

    -- =============================================
    -- CLEANUP
    -- =============================================
    SET FOREIGN_KEY_CHECKS = 1;
    SET UNIQUE_CHECKS = 1;
    DROP TABLE IF EXISTS _seed_seq;

    SELECT CONCAT('Seed completed! preset=', p_preset,
        ', elapsed=', TIMESTAMPDIFF(SECOND, @start_time, NOW()), 's',
        ', members=', @v_member, ', media=', @v_media,
        ', bookmarks=', @v_bookmark, ', watch_history=', @v_watch) AS result;

END //

DELIMITER ;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Category/Tag 스크립트&lt;/b&gt;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- =============================================
-- OTT 서비스 카테고리/태그 고정 메타데이터
-- 사용법: mysql -u ott -p ott &amp;lt; scripts/seed-metadata.sql
-- =============================================

INSERT INTO category (id, name, created_date, modified_date, status) VALUES
(1, '장르',     NOW(), NOW(), 'ACTIVE'),
(2, '분위기',   NOW(), NOW(), 'ACTIVE'),
(3, '시청상황', NOW(), NOW(), 'ACTIVE'),
(4, '테마',     NOW(), NOW(), 'ACTIVE')
ON DUPLICATE KEY UPDATE name = VALUES(name), modified_date = NOW();

INSERT INTO tag (id, category_id, name, created_date, modified_date, status) VALUES
-- 장르 (category_id = 1)
(1,  1, '액션',       NOW(), NOW(), 'ACTIVE'),
(2,  1, '로맨스',     NOW(), NOW(), 'ACTIVE'),
(3,  1, 'SF',         NOW(), NOW(), 'ACTIVE'),
(4,  1, '스릴러',     NOW(), NOW(), 'ACTIVE'),
(5,  1, '코미디',     NOW(), NOW(), 'ACTIVE'),
(6,  1, '드라마',     NOW(), NOW(), 'ACTIVE'),
(7,  1, '호러',       NOW(), NOW(), 'ACTIVE'),
(8,  1, '판타지',     NOW(), NOW(), 'ACTIVE'),
(9,  1, '다큐멘터리', NOW(), NOW(), 'ACTIVE'),
(10, 1, '애니메이션', NOW(), NOW(), 'ACTIVE'),
-- 분위기 (category_id = 2)
(11, 2, '긴장감',   NOW(), NOW(), 'ACTIVE'),
(12, 2, '따뜻한',   NOW(), NOW(), 'ACTIVE'),
(13, 2, '유쾌한',   NOW(), NOW(), 'ACTIVE'),
(14, 2, '어두운',   NOW(), NOW(), 'ACTIVE'),
(15, 2, '감동적인', NOW(), NOW(), 'ACTIVE'),
-- 시청상황 (category_id = 3)
(16, 3, '혼자볼때', NOW(), NOW(), 'ACTIVE'),
(17, 3, '연인과',   NOW(), NOW(), 'ACTIVE'),
(18, 3, '가족과',   NOW(), NOW(), 'ACTIVE'),
(19, 3, '심심할때', NOW(), NOW(), 'ACTIVE'),
(20, 3, '잠들기전', NOW(), NOW(), 'ACTIVE'),
-- 테마 (category_id = 4)
(21, 4, '성장', NOW(), NOW(), 'ACTIVE'),
(22, 4, '복수', NOW(), NOW(), 'ACTIVE'),
(23, 4, '우정', NOW(), NOW(), 'ACTIVE'),
(24, 4, '가족', NOW(), NOW(), 'ACTIVE'),
(25, 4, '사랑', NOW(), NOW(), 'ACTIVE'),
(26, 4, '생존', NOW(), NOW(), 'ACTIVE'),
(27, 4, '범죄', NOW(), NOW(), 'ACTIVE'),
(28, 4, '일상', NOW(), NOW(), 'ACTIVE')
ON DUPLICATE KEY UPDATE category_id = VALUES(category_id), name = VALUES(name), modified_date = NOW();

SELECT CONCAT('Metadata seeded: ', (SELECT COUNT(*) FROM category), ' categories, ', (SELECT COUNT(*) FROM tag), ' tags') AS result;
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;데이터 삭제 스크립트&lt;/b&gt;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;-- =============================================
-- 테스트 데이터 전체 삭제 (메타데이터 유지)
-- 사용법: mysql -u ott -p ott &amp;lt; scripts/clean-data.sql
-- =============================================

SET FOREIGN_KEY_CHECKS = 0;

TRUNCATE TABLE member_mood_refresh;
TRUNCATE TABLE click_event;
TRUNCATE TABLE playback;
TRUNCATE TABLE watch_history;
TRUNCATE TABLE comment;
TRUNCATE TABLE likes;
TRUNCATE TABLE bookmark;
TRUNCATE TABLE media_metrics;
TRUNCATE TABLE media_mood_tag;
TRUNCATE TABLE media_tag;
TRUNCATE TABLE short_form;
TRUNCATE TABLE contents;
TRUNCATE TABLE series;
TRUNCATE TABLE media;
TRUNCATE TABLE preferred_tag;
TRUNCATE TABLE member_radar_preference;
TRUNCATE TABLE member;

-- 유지: category, tag, mood_category, mood_tag

SET FOREIGN_KEY_CHECKS = 1;

SELECT 'Clean completed. Metadata tables (category, tag, mood_category, mood_tag) preserved.' AS result;
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;테스트 시나리오 작성&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 접근이 잦고, 호출되는 API가 많은 홈 화면에 대한 테스트 시나리오를 작성했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;총 4개의 API가 호출되며, 그 중 한 개의 API는 상황에 따라 세 가지로 분류되어 6개의 API를 시나리오에 포함시켰습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1 iteration == 사용자가 홈 화면에 진입했을 때 호출되는 API 묶음으로 설정했습니다.&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;#&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;API&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;설명&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;예상&lt;span&gt; 부하 특성 &lt;/span&gt;&lt;/span&gt;&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;1&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;GET /playlists/trending&lt;/td&gt;
&lt;td&gt;인기 차트&lt;/td&gt;
&lt;td&gt;가벼움 (전체 사용자 동일, 캐싱 후보)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;2&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;GET /playlists/recommend&lt;/td&gt;
&lt;td&gt;OO님이 좋아할만한&lt;/td&gt;
&lt;td&gt;&lt;b&gt;무거움&lt;/b&gt; (사용자별 동적 CASE WHEN 쿼리)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;3&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;GET /playlists/tags/top?index=0&lt;/td&gt;
&lt;td&gt;선호태그 1순위&lt;/td&gt;
&lt;td&gt;중간 (사용자별 태그 기반 조회)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;4&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;GET /playlists/tags/top?index=1&lt;/td&gt;
&lt;td&gt;선호태그 2순위&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;5&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;GET /playlists/tags/top?index=2&lt;/td&gt;
&lt;td&gt;선호태그 3순위&lt;/td&gt;
&lt;td&gt;중간&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;6&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;GET /playlists/history&lt;/td&gt;
&lt;td&gt;시청이력&lt;/td&gt;
&lt;td&gt;&lt;b&gt;무거움&lt;/b&gt; (조건부 JOIN + GROUP BY)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;K6 스크립트&lt;/b&gt;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre class=&quot;javascript&quot;&gt;&lt;code&gt;import http from 'k6/http';
import { check, group, sleep } from 'k6';
import { BASE_URL, THRESHOLDS, STAGES } from '../config.js';
import { getAuthHeaders } from '../helpers/auth.js';

// -------------------------------------------------------
// 시나리오: 홈 화면 진입
//
// 사용자가 홈 화면에 진입하면 프론트엔드가 6개 API를 호출한다.
// 이 시나리오는 1 iteration = 6 HTTP requests로 실제 동작을 시뮬레이션.
//
// 포함 API:
//   1. GET /playlists/trending         - 인기 차트 (북마크 수 기준 인기순)
//   2. GET /playlists/recommend        - OO님이 좋아할만한 (태그 가중치 기반 추천)
//   3. GET /playlists/tags/top?index=0 - 선호태그 1순위 콘텐츠
//   4. GET /playlists/tags/top?index=1 - 선호태그 2순위 콘텐츠
//   5. GET /playlists/tags/top?index=2 - 선호태그 3순위 콘텐츠
//   6. GET /playlists/history          - 시청이력 (최근 시청 영상 목록)
//
// 사용법:
//   k6 run --env LOAD=smoke k6/scenarios/home-screen.js       # 동작 확인
//   k6 run --env LOAD=vu100 k6/scenarios/home-screen.js       # VU 100
//   k6 run --env LOAD=vu1000 k6/scenarios/home-screen.js      # VU 1,000
//
//   # Grafana 모니터링 연동
//   k6 run --env LOAD=vu1000 --out influxdb=http://localhost:8086/k6 k6/scenarios/home-screen.js
//
//   # 결과 JSON 저장 (Before/After 비교용)
//   k6 run --env LOAD=vu1000 --out json=k6/results/before/home-screen-vu1000.json k6/scenarios/home-screen.js
//
// 주요 지표 해석:
//   http_req_duration            : 개별 API 응답 시간
//   http_req_duration{name:XXX}  : API별 응답 시간 (tag로 분리)
//   group_duration               : 홈 화면 전체 로드 시간 (6 request 묶음)
//   http_reqs                    : 초당 처리량 (RPS)
//   http_req_failed              : 에러율 (1% 미만이어야 정상)
//
// Before/After 비교 포인트:
//   - recommend의 p95가 가장 높을 것으로 예상 (동적 CASE WHEN 쿼리)
//   - history도 조건부 JOIN+GROUP BY로 높을 수 있음
//   - 개선 후 동일 VU로 재측정하여 개선율(%) 산출
// -------------------------------------------------------

export const options = {
    stages: STAGES[__ENV.LOAD || 'vu100'],
    thresholds: {
        ...THRESHOLDS,
        // API별 임계값 (tag name으로 분리 측정)
        // Before에서 이 기준을 넘기는 API = 개선 대상
        'http_req_duration{name:trending}':   ['p(95)&amp;lt;500'],
        'http_req_duration{name:recommend}':  ['p(95)&amp;lt;500'],
        'http_req_duration{name:tags_top_0}': ['p(95)&amp;lt;500'],
        'http_req_duration{name:tags_top_1}': ['p(95)&amp;lt;500'],
        'http_req_duration{name:tags_top_2}': ['p(95)&amp;lt;500'],
        'http_req_duration{name:history}':    ['p(95)&amp;lt;500'],
    },
};

export default function () {
    const params = getAuthHeaders();

    // -------------------------------------------------------
    // group: 홈 화면 전체를 하나의 묶음으로 측정
    // &amp;rarr; group_duration 지표로 &quot;홈 진입 1회 총 소요 시간&quot; 확인 가능
    // &amp;rarr; Grafana에서 group별 차트로 전체 로드 시간 추이 확인
    // -------------------------------------------------------
    group('홈 화면 진입', function () {

        // 1. 인기 차트
        // 북마크 수 기준 인기 콘텐츠. 모든 사용자 동일 결과 &amp;rarr; 캐싱 후보.
        const trending = http.get(
            `${BASE_URL}/playlists/trending?page=0&amp;amp;size=20`,
            Object.assign({}, params, { tags: { name: 'trending' } })
        );
        check(trending, {
            '[trending] status 200': (r) =&amp;gt; r.status === 200,
        });

        // 2. OO님이 좋아할만한 (추천 플레이리스트)
        // 사용자별 선호 태그 가중치 기반. 동적 CASE WHEN 쿼리 &amp;rarr; 가장 무거울 것으로 예상.
        const recommend = http.get(
            `${BASE_URL}/playlists/recommend?page=0&amp;amp;size=20`,
            Object.assign({}, params, { tags: { name: 'recommend' } })
        );
        check(recommend, {
            '[recommend] status 200': (r) =&amp;gt; r.status === 200,
        });

        // 3~5. 선호태그 순위별 top3 (index 0, 1, 2)
        // 사용자의 상위 3개 선호 태그별 콘텐츠 목록.
        // 홈 화면에서 3번 연속 호출됨.
        for (let i = 0; i &amp;lt; 3; i++) {
            const tagsTop = http.get(
                `${BASE_URL}/playlists/tags/top?index=${i}&amp;amp;page=0&amp;amp;size=20`,
                Object.assign({}, params, { tags: { name: `tags_top_${i}` } })
            );
            check(tagsTop, {
                [`[tags_top_${i}] status 200`]: (r) =&amp;gt; r.status === 200,
            });
        }

        // 6. 시청이력 (최근 시청 영상 목록)
        // 조건부 JOIN + GROUP BY + MAX 서브쿼리 &amp;rarr; 복잡 쿼리 개선 후보.
        const history = http.get(
            `${BASE_URL}/playlists/history?page=0&amp;amp;size=20`,
            Object.assign({}, params, { tags: { name: 'history' } })
        );
        check(history, {
            '[history] status 200': (r) =&amp;gt; r.status === 200,
        });
    });

    // think time: 사용자가 홈 화면 결과를 보는 시간 (1초)
    // 제거하면 요청을 쉬지 않고 연속으로 쏨 &amp;rarr; 순수 서버 처리 한계 측정
    sleep(1);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;JWT 토큰 발급&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 프로젝트는 카카오 로그인만을 제공하고, Spring Security + JWT를 사용했기 때문에, 요청 시 토큰이 필요했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'공유 토큰 사용 &amp;rarr; 1명 데이터만 반복 조회 &amp;rarr; 캐시 히트 비정상 &amp;rarr; 결과 왜곡'이라는 문제가 있었기에 각 VU는 다른 사용자의 JWT를 사용하도록 구성했습니다.&amp;nbsp;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style8&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt; VU 수 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 토큰 1000개 기준 &lt;/b&gt;&lt;/td&gt;
&lt;td&gt;&lt;b&gt; 상태 &lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;100&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;100명 각각 고유&lt;/td&gt;
&lt;td&gt;이상적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;500&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;500명 각각 고유&lt;/td&gt;
&lt;td&gt;이상적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;1,000&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;1:1 매핑&lt;/td&gt;
&lt;td&gt;이상적&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;5,000&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;1명당 5 VU 공유&lt;/td&gt;
&lt;td&gt;허용&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;b&gt;10,000&lt;/b&gt;&lt;/td&gt;
&lt;td&gt;1명당 10 VU 공유&lt;/td&gt;
&lt;td&gt;허용&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;부하 테스트 자동화&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;매번 수동으로 데이터 규모별로, VU 프리셋별로 테스트를 진행하기엔 번거로움이 많았습니다. 그래서 테스트를 원하는 데이터 규모와 프리셋을 지정하면 자동으로 K6 테스트를 수행하고 그 결과를 JSON으로 저장하는 자동화 환경을 구성했습니다. 또한, 개선 후에도 비교하기 수월하도록 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;저장된 결과를 MD 문서로 보기 쉽게 정리하는 스크립트를 작성했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;'DB 초기화 -&amp;gt; 데이터 생성 -&amp;gt; VU별 테스트 실행 -&amp;gt; 부하 테스트 결과 JSON 저장' 구조를 자동으로 반복하기에, 필요할 때마다 한 번만 실행시키고, 이를 바탕으로 결과 요약 스크립트를 실행시키면 .md 파일로 변환시킬 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;[파일 구조]&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #0f111a; color: #c3cee3;&quot;&gt;
&lt;pre class=&quot;bash&quot; data-ke-language=&quot;bash&quot;&gt;&lt;code&gt;k6/
├── config.js                          &amp;larr; 공통 설정 (URL, 임계값, VU 단계)
├── helpers/auth.js                    &amp;larr; VU별 개별 JWT 토큰 관리
├── data/tokens.json                   &amp;larr; 시드 사용자 1,000명 JWT (gitignore)
├── scenarios/home-screen.js           &amp;larr; 홈 화면 시나리오 (6 API, 6 requests)
├── docker-compose.monitoring.yml      &amp;larr; InfluxDB (k6 결과 저장)
├── run-all-before.ps1                 &amp;larr; 일괄 측정 (PowerShell)
├── run-all-before.sh                  &amp;larr; 일괄 측정 (Bash)
├── parse-results.ps1                  &amp;larr; 결과 &amp;rarr; md 보고서 (PowerShell)
├── parse-results.sh                   &amp;larr; 결과 &amp;rarr; md 보고서 (Bash)
└── results/                           &amp;larr; 측정 결과
    ├── before/{seed}/summary-{vu}.json
    ├── before/report.md
    ├── after/{seed}/summary-{vu}.json
    └── after/report.md&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[결과 요약 예시]&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;774&quot; data-origin-height=&quot;823&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bDaXO6/dJMcabRxyWx/qROw3rCQY9RnqKB8rTUbzK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bDaXO6/dJMcabRxyWx/qROw3rCQY9RnqKB8rTUbzK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bDaXO6/dJMcabRxyWx/qROw3rCQY9RnqKB8rTUbzK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbDaXO6%2FdJMcabRxyWx%2FqROw3rCQY9RnqKB8rTUbzK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;774&quot; height=&quot;823&quot; data-origin-width=&quot;774&quot; data-origin-height=&quot;823&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;로깅&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;p6spy&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API에서 발생하는 쿼리를 파악하기 위해 p6spy를 사용했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;K6 부하 테스트로 병목 지점을 파악하고, 해당 API를 실행시켜 쿼리가 몇 번 나가고 얼마나 걸리는지, 어떤 쿼리가 나가는지를 확인하기 위함입니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777378461913&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;2026-04-28T18:57:06.791+09:00  INFO 38132 --- [nio-8080-exec-6] p6spy : [STATEMENT] | 1 ms | 
    select
        m1_0.id,
        m1_0.bookmark_count,
        m1_0.created_date,
        ...
    from
        media m1_0 
    where
        m1_0.id=24508&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;AOP&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;p6spy와 더불어 Controller, Service 등 계층별로 소요되는 시간을 함께 파악하고자 AOP를 적용했습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1777378586772&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;INFO 38132 --- [nio-8080-exec-1] c.o.c.web.aop.PerformanceLoggingAspect   : [SERVICE] PlaylistStrategyService.getPlaylists()  690ms
INFO 38132 --- [nio-8080-exec-1] c.o.c.web.aop.PerformanceLoggingAspect   : [CONTROLLER] PlaylistController.getHistoryPlaylists()  792ms&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;당장 쓰이진 않지만, 함수 단위로 호출 횟수와 소요 시간을 파악할 수 있고, 이를 기반으로 Grafana에서 확인 가능합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;모니터링&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prometheus와 Grafana로 지표를 확인하고 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적인 JVM 리소스와 DB 커넥션, HTTP 통계를 확인할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2491&quot; data-origin-height=&quot;871&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dU7qX7/dJMcaaLS7uJ/bZ5WQXkvwqAyzYXBDNyet0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dU7qX7/dJMcaaLS7uJ/bZ5WQXkvwqAyzYXBDNyet0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dU7qX7/dJMcaaLS7uJ/bZ5WQXkvwqAyzYXBDNyet0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdU7qX7%2FdJMcaaLS7uJ%2FbZ5WQXkvwqAyzYXBDNyet0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2491&quot; height=&quot;871&quot; data-origin-width=&quot;2491&quot; data-origin-height=&quot;871&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2485&quot; data-origin-height=&quot;899&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/swUA1/dJMcaiiPiqx/DWBfQL8YO91YCnmQYw313k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/swUA1/dJMcaiiPiqx/DWBfQL8YO91YCnmQYw313k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/swUA1/dJMcaiiPiqx/DWBfQL8YO91YCnmQYw313k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FswUA1%2FdJMcaiiPiqx%2FDWBfQL8YO91YCnmQYw313k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2485&quot; height=&quot;899&quot; data-origin-width=&quot;2485&quot; data-origin-height=&quot;899&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2503&quot; data-origin-height=&quot;932&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bFy2EW/dJMcadhwReO/n7OvIpPcVkhofh2F2eRqWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bFy2EW/dJMcadhwReO/n7OvIpPcVkhofh2F2eRqWK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bFy2EW/dJMcadhwReO/n7OvIpPcVkhofh2F2eRqWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbFy2EW%2FdJMcadhwReO%2Fn7OvIpPcVkhofh2F2eRqWK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2503&quot; height=&quot;932&quot; data-origin-width=&quot;2503&quot; data-origin-height=&quot;932&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 기록은 데이터 규모 small, medium, large / VU 100, 500, 1000으로 3x3 테스트를 진행한 결과입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Project</category>
      <author>phonil</author>
      <guid isPermaLink="true">https://yestomo.tistory.com/21</guid>
      <comments>https://yestomo.tistory.com/21#entry21comment</comments>
      <pubDate>Tue, 28 Apr 2026 21:26:03 +0900</pubDate>
    </item>
    <item>
      <title>다중 기기 환경에서의 푸시 알림</title>
      <link>https://yestomo.tistory.com/19</link>
      <description>&lt;blockquote data-ke-style=&quot;style3&quot;&gt; 전시회 기록 서비스의 푸시 알림 중 다중 디바이스에 관한 내용입니다. 알림이 발생하는 상황들과 현재 상태를 소개하고, 다중 디바이스를 지원할 수 있는 설계로의 고민들을 적어보았습니다. 여러 기기에 알림을 보내는 것을 넘어 추가와 삭제, 업데이트를 고려하여 설계하고자 했습니다.&lt;/blockquote&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;0. 소개&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;해당 서비스는 안드로이드 앱이며, 사용자에게 푸시 알림을 보내고 사용자는 서비스의 알림 탭에서 해당 알림을 조회할 수 있습니다. 푸시 알림이 어떤 상황에서 발생하는지, 단일 디바이스의 한계는 무엇이었는지를 알아본 후 &lt;b&gt;다중 디바이스로의 변경을 중심으로&lt;/b&gt; 예외 상황(앱 삭제, 로그아웃 등)을 어떻게 처리했는지 등의 설계와 구현을 정리했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) 단일 디바이스&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;기존 설계는 User 테이블에 fcm_token 컬럼을 두어 사용자의 기기를 식별하고, 푸시 알림을 보냈습니다. 그렇기에 한 명의 회원은 하나의 기기만을 갖고 있다는 가정으로 구현되어 있습니다. 사용자가 새로운 기기에서 앱을 사용하면 해당 토큰은 업데이트되어 이전 기기로는 푸시 알림이 가지 않게 됩니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #212121; color: #eeffff;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Getter
public class User extends BaseEntity {
	@Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = &quot;user_id&quot;)
    private Long id;

    @Column(name = &quot;nickname&quot;, unique = true, nullable = false)
    private String nickname;

    @Column(name = &quot;fcm_token&quot;)
    private String fcmToken;
   
    //...
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;하지만, 평소에 사용하는 여러 앱들은 하나의 아이디로 여러 기기에서 사용하더라도 알림을 잘 보내곤 했습니다. 기존에는 하나의 사용자 계정이 한 기기만 사용한다고 가정해 알림을 처리했지만, 실제 사용 환경은 그렇지 않다는 점을 인식하고 여러 기기를 사용할 수 있도록 구조를 개선하게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2) 알림 발생&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;서비스는 사용자의 활동에 따라, 그리고 전시회 일정에 따라 크게 두 가지로 분류하여 푸시 알림을 전송합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자는 알림 탭에서 전송받은 푸시 알림의 자세한 내용을 확인할 수 있으며, 각 종류별 알림 수신 여부를 결정할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1171&quot; data-origin-height=&quot;820&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmKG7o/btsNd6SUjo4/7Q22Gt1cF3xYG9G65WYUu0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmKG7o/btsNd6SUjo4/7Q22Gt1cF3xYG9G65WYUu0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmKG7o/btsNd6SUjo4/7Q22Gt1cF3xYG9G65WYUu0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbmKG7o%2FbtsNd6SUjo4%2F7Q22Gt1cF3xYG9G65WYUu0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;504&quot; data-origin-width=&quot;1171&quot; data-origin-height=&quot;820&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;활동 알림&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;먼저, 활동 알림은 &lt;b&gt;유저의 행위에 의해 발생&lt;/b&gt;하며, 4가지 경우로 나누어집니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;타 유저가 나를 팔로우 한 경우&lt;/li&gt;
&lt;li&gt;내가 팔로우 한 유저가 감상평을 남기는 경우&lt;/li&gt;
&lt;li&gt;내가 팔로우 한 유저가 별점을 남기는 경우&lt;/li&gt;
&lt;li&gt;내 감상평에 댓글이 달린 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;전시 알림&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;전시 알림은 &lt;b&gt;매일 정해진 시간에 발생&lt;/b&gt;하며, 2가지 경우로 나누어집니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;관심 전시회로 설정했으며, 전시 시작 7일, 3일, 1일 전인 전시회에 대한 리마인드 알림&lt;/li&gt;
&lt;li&gt;관심 전시회로 설정했으며, 댓글과 별점을 모두 남기지 않은 전시 종료 7일, 3일, 1일 전인 전시회에 대한 리마인드 알림&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;1. 다중 디바이스 지원으로 변경&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;User 테이블에 컬럼으로 fcm_token을 저장했기에, 여러 기기 토큰을 저장하지 못하여 다중 디바이스를 지원하지 못하는 점을 가장 먼저 해결해야 했습니다. 따라서, 우선 테이블 설계를 변경하여 다중 디바이스를 지원할 수 있도록 한 뒤 추가 고려 사항을 이야기 해보겠습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;1) 테이블 설계 변경&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1350&quot; data-origin-height=&quot;289&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/7VFyA/btsNdzoj6Z8/gZRPqrJyKValCMdGKoewwk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/7VFyA/btsNdzoj6Z8/gZRPqrJyKValCMdGKoewwk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/7VFyA/btsNdzoj6Z8/gZRPqrJyKValCMdGKoewwk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F7VFyA%2FbtsNdzoj6Z8%2FgZRPqrJyKValCMdGKoewwk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;154&quot; data-origin-width=&quot;1350&quot; data-origin-height=&quot;289&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;user 테이블에 있던 토큰을 분리하여 별도 테이블로 만들었습니다. &lt;b&gt;1:M 관계&lt;/b&gt;를 설정해, 유저 한 명에 대해 여러 기기의 토큰을 저장할 수 있습니다. &lt;b&gt;token 컬럼은 Unique 제약 조건&lt;/b&gt;을 걸어주어 중복된 기기에 대한 write 작업이 발생하면 update할 수 있도록 해, 하나의 기기에 여러 유저가 존재하는 상황을 방지했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;각 device_token 테이블에 알림 수신 여부를 넣어 기기별로 알림 수신 여부를 결정하도록 할 수도 있지만, 초기 요구사항에 따라 우선 사용자 단위 방식을 유지하기로 했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2) 기본적인 등록 및 알림 요청&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;테이블 설계 변경에 따라 유저가 가지고 있는 모든 기기에 푸시 알림을 보내야 합니다. 기존에 작성된 기기 토큰 저장 로직은 단순히 User 테이블의 fcm_token을 업데이트하는 형식이었는데, 기기 토큰의 존재 여부에 따라 수행할 작업이 변경될 필요가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;변경된 테이블 구조에 따라 필수적인 로직들만 우선 변경하고, 아래에서 상황을 따져보며 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;변경 사항을&lt;span&gt; &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;추가로&lt;span&gt; 반영했습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[토큰 등록]&lt;/b&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #212121; color: #eeffff;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Transactional
public void registerToken(UserPrincipal userPrincipal, DeviceTokenReq deviceTokenReq) {
    User user = userService.validateUserByToken(userPrincipal);
    String token = deviceTokenReq.getDeviceToken();
    deviceTokenRepository.findByDeviceToken(token)
            .ifPresentOrElse(
                    existingToken -&amp;gt; existingToken.updateUser(user),
                    () -&amp;gt; {
                        DeviceToken deviceToken = DeviceToken.builder()
                                .user(user)
                                .deviceToken(token)
                                .build();
                        deviceTokenRepository.save(deviceToken);
                    }
            );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;위와 같이 Unique한 token 컬럼으로 Device Token을 찾은 후에 &lt;b&gt;존재하면 업데이트, 존재하지 않으면 삽입&lt;/b&gt;을 진행했습니다. 동시성을 생각하고 쿼리를 줄이고자 한다면 ON DUPLICATE KEY UPDATE 문으로 한 번에 수행할 수 있지만, 앱 특성 상 매우 드물게 발생하는 로직이기 때문에 도메인을 좀 더 활용하고 가독성을 높일 수 있는 방법으로 구현했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[푸시 알림 생성 요청]&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1744189909072&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 기존 단일 기기 푸시 알림
fcmService.makeActiveAlarm(receiver.getFcmToken(), sender.getNickname() + &quot; 님이 별점을 남겼어요&quot;);

// 여러 기기 푸시 알림
deviceTokenList.stream()
                    .map(DeviceToken::getDeviceToken)
                    .forEach(token -&amp;gt; fcmService.makeActiveAlarm(token, message));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;푸시 알림을 생성할 때에도 마찬가지로 모든 기기에 대해 생성할 수 있도록 변경했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;2. 생각해 볼 문제들&lt;/h2&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;여러 기기를 허용하면 그만큼 사용자의 앱 사용 경우의 수가 늘어나게 됩니다. 이에 따라 여러 상황을 따져보아야 하는데, 계정(회원)과 기기(Device Token)의 관계를 위주로 살펴보면 여러 상황에 대비할 수 있습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;1개의 계정으로 여러 기기에서 접속한 경우&lt;/li&gt;
&lt;li&gt;1개의 기기에서 여러 계정으로 접속한 경우&lt;/li&gt;
&lt;li&gt;로그아웃?&lt;/li&gt;
&lt;li&gt;앱 삭제 시&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 순서로 살펴보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;1) 같은 유저, 다른 토큰&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&amp;nbsp;일반적인 흐름으로,&lt;/span&gt; 여러 기기에서 하나의 계정으로 접속한 경우입니다. 이렇게 되면 푸시 이벤트가 발생했을 때, 등록된 모든 기기에 푸시 알림이 전송됩니다. 이미 토큰을 가지고 있는 회원도, 추가로 등록할 수 있으므로 이상적인 상황입니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;637&quot; data-origin-height=&quot;731&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cd59PN/btsNeTF6p2O/Ywc2p18u39Vb99FtLKUPvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cd59PN/btsNeTF6p2O/Ywc2p18u39Vb99FtLKUPvK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cd59PN/btsNeTF6p2O/Ywc2p18u39Vb99FtLKUPvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcd59PN%2FbtsNeTF6p2O%2FYwc2p18u39Vb99FtLKUPvK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;440&quot; height=&quot;505&quot; data-origin-width=&quot;637&quot; data-origin-height=&quot;731&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&amp;nbsp;앞에서 변경한 테이블 구조에 따라 로그인 시 토큰 등록을 함께 수행하여 &lt;b&gt;하나의 계정에 여러 기기를 등록&lt;/b&gt;하여 서비스를 사용할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;2) 같은 토큰, 다른 유저&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;문제가 될 수 있는 상황으로, 하나의 기기에서 다른 아이디로 접속한 경우입니다. 하나의 기기에 여러 계정을 사용한 경우인데, 내 알림을 다른 사람이 받게 될 수 있다는 심각한 문제를 가지고 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;588&quot; data-origin-height=&quot;661&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/VDQGO/btsNfqXAHyD/MrQ8kUX4rA4zaKBYTqn8C1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/VDQGO/btsNfqXAHyD/MrQ8kUX4rA4zaKBYTqn8C1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/VDQGO/btsNfqXAHyD/MrQ8kUX4rA4zaKBYTqn8C1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FVDQGO%2FbtsNfqXAHyD%2FMrQ8kUX4rA4zaKBYTqn8C1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;440&quot; height=&quot;495&quot; data-origin-width=&quot;588&quot; data-origin-height=&quot;661&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot;&gt;&amp;nbsp;이러한 상황을 방지하기 위해 &lt;b&gt;기기 토큰을 UK로 설정&lt;/b&gt;하여 로그인 시 토큰 등록을 함께 수행하는데, 이미 존재하는 토큰이 들어오는 경우 user_id(fk)를 변경하여 기기를 사용하는 계정이 유일할 수 있게끔 유지해야 합니다. 앞에서 소개한 등록 코드에 포함되어 있는 내용입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;3) 로그아웃&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;사용자가 로그아웃을 하는 경우입니다. 로그아웃 시 앱 관련 소식에 관심이 없다는 것으로 보아 푸시 알림을 보내지 않기로 결정했습니다. 앞에서 변경된 토큰 등록 코드와 등록 시 두 상황을 확인했는데, 이제는 토큰 삭제를 고려해야 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;598&quot; data-origin-height=&quot;744&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dyLx5c/btsNdTmyCDc/2yEYQ57PBnqWYUgClVA9q1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dyLx5c/btsNdTmyCDc/2yEYQ57PBnqWYUgClVA9q1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dyLx5c/btsNdTmyCDc/2yEYQ57PBnqWYUgClVA9q1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdyLx5c%2FbtsNdTmyCDc%2F2yEYQ57PBnqWYUgClVA9q1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;440&quot; height=&quot;547&quot; data-origin-width=&quot;598&quot; data-origin-height=&quot;744&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;로그아웃을 했음에도 푸시 알림을 받게 되는 상황을 방지하기 위해서는 로그아웃 시 토큰 삭제를 함께 수행해야 합니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #212121; color: #eeffff;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Transactional
public void deleteToken(UserPrincipal userPrincipal, DeviceTokenReq deviceTokenReq) {
    User user = userService.validateUserByToken(userPrincipal);
    deviceTokenRepository.deleteByDeviceToken(deviceTokenReq.getDeviceToken());
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;위와 같이 계정이 아닌 기기 단위로 삭제를 수행합니다. 로그아웃 시 서버가 해당 기기를 알림 대상으로 계속 유지하면&lt;span&gt;&amp;nbsp;&lt;/span&gt;개인 정보 노출 등의 문제가 생길 수 있습니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;따라서 로그아웃은 단순한 세션 종료가 아니라 '푸시 수신 중단'이라는 의미도 포함되며,&lt;span&gt;&amp;nbsp;&lt;/span&gt;이에&amp;nbsp;따라&amp;nbsp;기기&amp;nbsp;단위로&amp;nbsp;토큰을&amp;nbsp;제거하는&amp;nbsp;정책을&amp;nbsp;세웠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;로그아웃 뿐만 아니라 회원 탈퇴의 경우에도 마찬가지로 알림을 보내면 안되므로 토큰 삭제가 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4) 앱 삭제&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;기기에서 앱 삭제 시에는 해당 기기로 푸시 알림이 보내지면 안됩니다. 토큰 삭제를 고려해야 한다는 점에서 로그아웃과 비슷한데, 서버는 앱 삭제 여부를 직접 알 수 없다는 충격적인 문제가 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;962&quot; data-origin-height=&quot;655&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/v3AeB/btsNcWDHavT/SZakRd7GJiSUgNrMl5pdQk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/v3AeB/btsNcWDHavT/SZakRd7GJiSUgNrMl5pdQk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/v3AeB/btsNcWDHavT/SZakRd7GJiSUgNrMl5pdQk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fv3AeB%2FbtsNcWDHavT%2FSZakRd7GJiSUgNrMl5pdQk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;490&quot; data-origin-width=&quot;962&quot; data-origin-height=&quot;655&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;하지만, 기기에서 앱 삭제 여부를 알기 위해 푸시 알림을 활용할 수 있습니다. 서버에서 매일 주기적으로 삭제 확인을 위한 &lt;b&gt;Silent Push&lt;/b&gt;를 보낸 후 응답을 통해 삭제 여부를 알아냅니다. FCM에 푸시 전송 시 응답으로 &quot;이 토큰은 무효&quot;라는 메시지가 오면 해당 응답을 기반으로 삭제하는 방법입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;background-color: #212121; color: #eeffff;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 매일 확인
@Scheduled(cron = &quot;0 0 8 * * *&quot;)
public void sendSilentCheckPush() {
    List&amp;lt;DeviceToken&amp;gt; tokenList = deviceTokenRepository.findAll();
    int total = tokenList.size();
    tokenList.stream()
            .map(DeviceToken::getDeviceToken)
            .forEach(fcmService::sendSilentCheck);
}

// silent push 생성
public void sendSilentCheck(String token) {
    JSONObject jsonData = new JSONObject();
    jsonData.put(TYPE, SILENT_CHECK);

    JSONObject jsonMessage = new JSONObject();
    jsonMessage.put(TOKEN, token);
    jsonMessage.put(DATA, jsonData);

    JSONObject message = new JSONObject();
    message.put(MESSAGE, jsonMessage);

    pushAlarm(message);
}

// 만들어진 알림을 받아서 푸시
private void pushAlarm(JSONObject jsonMessage) {
    try {
        OkHttpClient okHttpClient = new OkHttpClient();
        Request request = new Request.Builder()
                .addHeader(&quot;Authorization&quot;, &quot;Bearer &quot; + getAccessToken())
                .addHeader(&quot;Content-Type&quot;, &quot;application/json; UTF-8&quot;)
                .url(API_URL)
                .post(RequestBody.create(jsonMessage.toString(), MediaType.parse(&quot;application/json&quot;)))
                .build();
        Response response = okHttpClient.newCall(request).execute();
        String body = response.body().string();
        if (!response.isSuccessful() &amp;amp;&amp;amp; body.contains(SILENT_PUSH_BODY))
            deleteDeviceToken(jsonMessage.getJSONObject(MESSAGE).getString(TOKEN));
    } catch (Exception e) {
        // ...
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;@Scheduled를 사용하여 매일 Silent Push 알림을 보내도록 합니다. 푸시 알림을 생성하고, 이를 토대로 알림 서버에 요청을 보냅니다. 알림 요청의 응답에 따라 사용자가 앱을 삭제했는지(토큰이 유효한지) 알 수 있고, 만약 앱을 삭제했다면 해당 기기를 더 이상 서비스에서 관리하지 않기 위해 토큰을 삭제해줍니다. (&lt;a title=&quot;Firebase Docs&quot; href=&quot;https://firebase.google.com/docs/cloud-messaging/send-message?hl=ko#rest-error&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Firebase Docs&lt;/a&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;IOS는 푸시를 보내는 순간 Notification이 만들어지므로 추가적인 설정이 필요하다고 하는데, &lt;span style=&quot;background-color: #ffffff; color: #242424; text-align: left;&quot;&gt;IOS와 다르게&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #242424; text-align: left;&quot;&gt;Android는&lt;span&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #242424; text-align: left;&quot;&gt;Notification을 직접 만들기 때문에 이 경우에는 Notification 자체를 만들지 않으면 된다고 합니다. 즉, 안드로이드 개발자와 협의하여 Silent Push인 경우 아무 작업을 하지 않도록 하면 됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;프로젝트를 진행하던 시기에 푸시 알림 기능은 막바지에 추가되어서 많은 사항을 고려하지 못했지만, 팀원들과 어느 정도 이야기를 해 본 부분이라 다중 디바이스를 포함해 푸시 알림에 대한 개선 방안은 마음에 품고 있었습니다. 토큰의 신선도&amp;nbsp; 관리 혹은 다중 디바이스 상황에서도 활성 기기에만 푸시를 보내는 등 더욱 세밀한 관리가 들어가면 좋을 것 같다는 이야기까지 했었는데, 현재 서비스는 스토어에서 내려갔고, 사용자가 없기 때문에 다른 작업들을 먼저 하면 좋을 것 같다고 생각했습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;짧은 시간 동안 개발하기도 했고, 팀원이 개발한 내용이라 어렴풋이 알고 있었는데, 푸시 알림에 대해 어느 정도 알 수 있었습니다. 코드를 보니 개선점이 더 보이기도 하고, 사용자를 고려해서 늘 다양한 상황을 생각한 개발을 할 필요가 있다고 느낍니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;참고 :&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a title=&quot;앱 삭제 여부 확인&quot; href=&quot;https://medium.com/%EB%B0%95%EC%83%81%EA%B6%8C%EC%9D%98-%EC%82%BD%EC%A7%88%EB%B8%94%EB%A1%9C%EA%B7%B8/%EC%95%B1-%EC%82%AD%EC%A0%9C-%EC%B8%A1%EC%A0%95-%EB%B0%A9%EB%B2%95-%EB%82%98%EB%8A%94-%EB%84%A4%EA%B0%80-%EC%96%B4%EC%A0%9C-%EC%95%B1%EC%9D%84-%EC%82%AD%EC%A0%9C-%ED%96%88%EB%8B%A4%EB%8A%94%EA%B2%83%EC%9D%84-%EC%95%8C%EA%B3%A0-%EC%9E%88%EB%8B%A4-653bc7872cb6&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;앱 삭제 여부 확인&lt;/a&gt;&lt;/p&gt;</description>
      <category>Project</category>
      <author>phonil</author>
      <guid isPermaLink="true">https://yestomo.tistory.com/19</guid>
      <comments>https://yestomo.tistory.com/19#entry19comment</comments>
      <pubDate>Thu, 3 Apr 2025 22:49:35 +0900</pubDate>
    </item>
    <item>
      <title>상황별 조회수 성능을 위해 고려할 수 있는 것</title>
      <link>https://yestomo.tistory.com/18</link>
      <description>&lt;blockquote data-ke-style=&quot;style3&quot;&gt; 이 글은 동시성 처리가 필요한 상황에서 서비스와 게시글의 특성을 통해 효율적으로 조회수를 카운팅 할 수 있는 방법에 대해 살펴봅니다. 동시성을 처리하는 가장 기본적인 작업만 되어 있는 상태부터 세분화하여 업데이트를 위한 락을 줄여가는 방식으로 전개됩니다.&amp;nbsp;&lt;/blockquote&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이전 글에서 조회수 카운팅 관련하여 크게 두 가지 문제를 다뤄봤습니다. 하나는 맞춤형 집계를 위한 조회수 카운팅 정책 설정이고, 다른 하나는 여러 요청이 발생할 때의 동시성 문제입니다. &lt;a title=&quot;조회수 정책 관련 글&quot; href=&quot;https://yestomo.tistory.com/16&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;조회수 정책 관련 글&lt;/a&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;과 &lt;a title=&quot;동시성 처리 관련 글&quot; href=&quot;https://yestomo.tistory.com/17&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;동시성 처리 관련 글&lt;/a&gt;에서 각각의 내용을 확인하실 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이번에는 사용자가 많아질 경우 게시글 조회마다 발생하는 조회수 업데이트로 인한 성능에 대한 이야기를 해보겠습니다. 현재, update쿼리를 직접 사용하여 업데이트 할 때, 레코드에 x락을 걸게 됩니다. 락의 발생이 많아질수록 성능은 저하되기 때문에 실시간성과 어느정도 타협하여 성능을 끌어올릴 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;0. 상황 정의&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;현재 서비스는 회원/비회원 모두가 사용 가능한 기술 블로그입니다. 동아리 부원만 회원 가입과 글 작성이 가능하고, 현재 부원은 80명 정도 존재합니다. 따라서 게시글 조회(Read)에 비해 게시글 작성(Write)은 확실히 적게 발생할 것임을 예상할 수 있습니다(요청이 많이 발생한다면!). 이에 따라 &lt;b&gt;게시글 수에 비해 읽기 요청이 많다는 가정&lt;/b&gt;으로 진행하였습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;조회수 집계 정책에 대해 설정해 놓은 것이 있어 더더욱 문제가 발생할 가능성이 적지만, &lt;b&gt;집계 정책과 별도로 &lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;단순 +1 상황을 기준으로 &lt;/span&gt;성능 개선&lt;/b&gt;을 생각해보겠습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;기술 블로그 특성 상 특정 키워드 검색으로 방문을 가장 많이 합니다. 하지만, 이는 사용자마다 다르며, 예측이 매우 어렵기 때문에 배제하고자 합니다. 특정 키워드를 통해 어떤 게시글로 유입된 사용자가 다른 글을 조회하고자 한다면 주로 기술 블로그 메인 화면 &lt;b&gt;최상단에 배치된 게시글&lt;/b&gt;을 조회할 것입니다. 현재는 &lt;b&gt;최신 순&lt;/b&gt;으로 배치하며, 만약 다른 방식을 추가로 도입한다면 조건을 걸어 인기글로 지정된 가장 최근 게시글을 상단에 배치할 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;페이지 단위로 10개씩 메인 화면에 배치가 되므로 게시글 작성 시간을 고려하여 조회수 업데이트 방식을 살펴보겠습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;간단하게 테스트용 프로젝트를 생성하고 코드를 작성한 후 서버를 3개 띄운 뒤 추이 파악을 위한 테스트를 진행해보았습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;1. 모든 게시글 통합&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) 조회 시 DB 업데이트&amp;nbsp;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;동시성 처리를 진행한 현재 상태 그대로 진행하는 방식입니다. 모든 게시글을 조회할 때마다 바로 DB에 +1 업데이트 해줍니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1171&quot; data-origin-height=&quot;333&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6XrMd/btsNag2ODsH/Y29KB2lL9bYTBj5o6C0yGK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6XrMd/btsNag2ODsH/Y29KB2lL9bYTBj5o6C0yGK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6XrMd/btsNag2ODsH/Y29KB2lL9bYTBj5o6C0yGK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F6XrMd%2FbtsNag2ODsH%2FY29KB2lL9bYTBj5o6C0yGK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;205&quot; data-origin-width=&quot;1171&quot; data-origin-height=&quot;333&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;딱히 처리해 줄 것이 없어 구현이 매우 간단하고, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;저희 서비스처럼 사용자가 많지 않은 경우 적합합니다. 하지만 조회 요청이 많은 경우 매번 락 경합으로 인해 성능에 문제가 생길 가능성이 높습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #212121; color: #eeffff;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// repository
public interface PostRepository extends JpaRepository&amp;lt;Post, Long&amp;gt; {
    @Modifying
    @Query(&quot;UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :postId&quot;)
    void incrementViewCount(@Param(&quot;postId&quot;) Long postId);
}

// service
@Transactional
public SuccessResponse&amp;lt;?&amp;gt; getPostDetail(Long postId) {
    Post post = postRepository.findById(postId).orElseThrow();
    postRepository.incrementViewCount(postId);
    // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;딱히 처리해 줄 것이 없어 구현이 매우 간단하고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;저희 서비스처럼 사용자가 많지 않은 경우 적합합니다. 하지만 조회 요청이 많은 경우 매번 락 경합으로 인해 성능에 문제가 생길 가능성이 높습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1622&quot; data-origin-height=&quot;467&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lgRM9/btsNaLaM8HX/iBjuLBuuZTKctRa70ih4sK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lgRM9/btsNaLaM8HX/iBjuLBuuZTKctRa70ih4sK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lgRM9/btsNaLaM8HX/iBjuLBuuZTKctRa70ih4sK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlgRM9%2FbtsNaLaM8HX%2FiBjuLBuuZTKctRa70ih4sK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;760&quot; height=&quot;219&quot; data-origin-width=&quot;1622&quot; data-origin-height=&quot;467&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Jmeter를 사용해 1분 간 테스트를 진행해보았습니다. 다만, 테스트용 프로젝트이며, 로컬 환경이므로 테스트 결과 값보단 다른 방식과의 추이를 비교하는 정도로 사용하면 좋을 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;해당 방식의 경우 사용자 요청 간 텀이 없을 때에 비해 텀이 있는 경우 평균 속도가 높은 것을 볼 수 있습니다. 텀으로 인해 전체 처리 속도는 느려졌지만, 비교적 느슨한 요청으로 인해 락 경합이 줄어들어 평균적인 속도가 빨라진 것입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2) 카운팅 후 DB 업데이트&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;조회수를 바로 업데이트 하지 않고, 어딘가에 쌓아놓은 후 주기적으로 DB에 업데이트 하는 방법입니다. 서버마다 메모리에 조회수를 카운팅하는 방식과 Redis로 대표되는 저장소에 카운팅 후 업데이트 하는 방식이 대표적입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;서버마다 인 메모리 카운팅 -&amp;gt; DB 업데이트&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;게시글 조회 요청이 들어오면 각 서버는 메모리에 게시글 별 조회수를 카운팅하고, 주기적으로 DB에 업데이트 합니다. 이 경우, 각 서버에서 로컬 변수를 사용하거나 로컬 캐시에 저장하는 방법이 있습니다. 조회수는 단순 업데이트하는 간단한 작업이기 때문에 ConcurrentHashMap을 사용해보겠습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1751&quot; data-origin-height=&quot;535&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/braZjq/btsM9W4PeFJ/PftatrC2lkGzsCg5dTg1L1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/braZjq/btsM9W4PeFJ/PftatrC2lkGzsCg5dTg1L1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/braZjq/btsM9W4PeFJ/PftatrC2lkGzsCg5dTg1L1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbraZjq%2FbtsM9W4PeFJ%2FPftatrC2lkGzsCg5dTg1L1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;220&quot; data-origin-width=&quot;1751&quot; data-origin-height=&quot;535&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;div style=&quot;background-color: #212121; color: #eeffff;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// service
@Transactional
public SuccessResponse&amp;lt;?&amp;gt; getPostDetailInMemoryCount(Long postId) {
    Post post = postRepository.findById(postId).orElseThrow();
    inMemoryViewCountStore.increment(postId);
    // ...
}

@Component
public class InMemoryViewCountStore {
    private final ConcurrentHashMap&amp;lt;Long, AtomicInteger&amp;gt; viewCountMap = new ConcurrentHashMap&amp;lt;&amp;gt;();

    public void increment(Long postId) {
        viewCountMap.computeIfAbsent(postId, k -&amp;gt; new AtomicInteger(0)).incrementAndGet();
    }

    public Map&amp;lt;Long, AtomicInteger&amp;gt; getAllCounts() {
        return viewCountMap;
    }
}

// 주기적 update
@Scheduled(fixedRate = 10000)
@Transactional
public void flushToDb() {
    Map&amp;lt;Long, AtomicInteger&amp;gt; countMap = inMemoryViewCountStore.getAllCounts();
    if(countMap.isEmpty()) return;
    int increment = 0;
    for(Map.Entry&amp;lt;Long, AtomicInteger&amp;gt; entry : countMap.entrySet()){
        Long postId = entry.getKey();
        increment = entry.getValue().getAndSet(0);
        if(increment &amp;gt; 0)
            postRepository.incrementViewCount(postId, increment);
    }
    log.info(&quot;인메모리 조회수 반영 완료, 대상 {}건&quot;, increment);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;ConcurrentHashMap과 AtomicInteger를 사용하여 원자적 연산을 가능하게 했습니다. ConcurrentHashMap과 AtomicInteger는 내부적으로 CAS 연산을 수행하기 때문에 여러 스레드가 동시에 값을 변화시켜도 lock-free 상태로 빠르고 안전하게 업데이트를 수행합니다. 정말정말 최악의 경우 스핀락이 걸려 무한히 돌게 될 수 있는데, 이 경우 제한 횟수를 두어 한계에 도달하면 block 후 처리하는 방식으로 해결할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;각 서버마다 자체적으로 조회수를 카운팅하고 주기적으로 update 해주므로 update 쿼리 자체의 발생이 적습니다. 따라서 락 경합이 적어지고 이로 인한 성능 문제가 매우 줄어듭니다. 또한, 추가적인 인프라 구성이 필요 없기 때문에 이 방법 또한 간단하게 구현할 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;하지만, 조회수를 실시간으로 정확하게 제공하지 못한다는 단점이 있습니다. 사용자에게 보여지는 조회수는 중요하지 않은 경우가 많기 때문에 괜찮지만, 실시간성이 높아야 하는 경우 적합하지 않습니다. 또한,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;span&gt;사용 중인 서버가 죽을 때, 데이터 처리가 적절히 이뤄지지 않는다면 일부 누락될 수 있습니다. 데이터가 누락되면 안되는 서비스의 경우 Graceful Shotdown을 고려해야 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1627&quot; data-origin-height=&quot;468&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/boaSUk/btsNcp5fQGq/lNiwJiKrMitSrM28XbGzOk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/boaSUk/btsNcp5fQGq/lNiwJiKrMitSrM28XbGzOk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/boaSUk/btsNcp5fQGq/lNiwJiKrMitSrM28XbGzOk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FboaSUk%2FbtsNcp5fQGq%2FlNiwJiKrMitSrM28XbGzOk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;760&quot; height=&quot;219&quot; data-origin-width=&quot;1627&quot; data-origin-height=&quot;468&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;DB에서 락 경합 자체가 거의 없기 때문에 요청에 텀을 주지 않았을 때 엄청나게 처리량이 많아지는 것을 확인할 수 있습니다. 텀이 있을 때 역시 전반적으로 매우 빠른 속도를 보여줍니다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;Redis&lt;/span&gt; 카운팅 -&amp;gt; DB 업데이트&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 전 방식과 비슷한데, 각 서버에서 조회수를 카운팅하는 것이 아닌 Redis에 카운팅을 하고, 주기적으로 업데이트 하는 방법입니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;990&quot; data-origin-height=&quot;221&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/JuqlC/btsNaOEA2MQ/fHkx8sTKmaGD7Yt4Cpbv51/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/JuqlC/btsNaOEA2MQ/fHkx8sTKmaGD7Yt4Cpbv51/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/JuqlC/btsNaOEA2MQ/fHkx8sTKmaGD7Yt4Cpbv51/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FJuqlC%2FbtsNaOEA2MQ%2FfHkx8sTKmaGD7Yt4Cpbv51%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;161&quot; data-origin-width=&quot;990&quot; data-origin-height=&quot;221&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;작성한 기본적인 코드는 아래와 같습니다. Redis를 사용할 때, 주기적 Update를 스케줄링한다면 여러 서버에서 동시에 실행되지 않도록 방지하는 코드를 추가해야 합니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #212121; color: #eeffff;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// service
@Transactional
public SuccessResponse&amp;lt;?&amp;gt; getPostDetailRedis(Long postId) {
    String key = &quot;post:view:&quot; + postId;
    redisUtil.increment(key);
    // ...
}

@RequiredArgsConstructor
@Component
public class RedisUtil {

    private final StringRedisTemplate stringRedisTemplate;
    
    public Set&amp;lt;String&amp;gt; scanKeys(String pattern) {
        Set&amp;lt;String&amp;gt; keys = new HashSet&amp;lt;&amp;gt;();
        ScanOptions options = ScanOptions.scanOptions().match(pattern).count(1000).build();
        Cursor&amp;lt;byte[]&amp;gt; cursor = stringRedisTemplate.getConnectionFactory().getConnection().scan(options);
        while (cursor.hasNext()) {
            keys.add(new String(cursor.next(), StandardCharsets.UTF_8));
        }
        try {
            cursor.close();
        } catch (Exception e) {
            //
        }
        return keys;
    }

    public Long increment(String key) {
        return stringRedisTemplate.opsForValue().increment(key, 1);
    }
    // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;스케줄링 작업을 구성할 때 &lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;주의해야 할&lt;span&gt;&lt;span&gt; 또 다른 &lt;/span&gt;&lt;/span&gt;&lt;/span&gt;점은 DB 업데이트 시 redis 키를 삭제하면 안 된다는 것입니다. 키를 삭제할 때, 다른 서버에서 조회수 카운팅을 한다면 그만큼 데이터가 손실될 수 있기 때문입니다. 따라서 업데이트 시에는 키를 삭제하지 말고 값을 차감하는 방식으로 구현하는 것이 좋습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1743958463696&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 주기적 update
@Slf4j
@Component
@RequiredArgsConstructor
public class RedisFlushScheduler {

    private final RedisUtil redisUtil;
    private final PostRepository postRepository;

    @Scheduled(fixedRate = 5000)
    @Transactional
    public void flushRedisCountsToDb() {
        Set&amp;lt;String&amp;gt; keys = redisUtil.scanKeys(&quot;post:view:*&quot;);
        if (keys.isEmpty()) return;
        for (String key : keys) {
            String value = redisUtil.getData(key);
            if (value != null) {
                int inc = Integer.parseInt(value);
                String[] parts = key.split(&quot;:&quot;);
                if (parts.length == 3) {
                    try {
                        Long postId = Long.parseLong(parts[2]);
                        if (inc &amp;gt; 0) {
                            postRepository.incrementViewCount(postId, inc);
                            redisUtil.decrement(key, inc);
                            // redisUtil.deleteData(key); 삭제하면 안됨!!
                            log.info(&quot;Redis 키 {}의 조회수 {}를 DB에 반영 완료&quot;, key, inc);
                        }
                    } catch (NumberFormatException e) {
                        log.error(&quot;키 {}에서 postId 파싱 실패&quot;, key, e);
                    }
                }
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;모든 서버에서 레디스에 먼저 조회수를 올리고, DB 업데이트를 한 번만 수행하기 때문에 락으로 인한 충돌은 더욱 적어집니다. 하지만, 추가적인 인프라 구성이 필요하다는 점, 네트워크 병목이 생길 가능성이 높아진다는 점과 게시글이 많아질수록 레디스에서 관리해야 하는 데이터가 많아지기 때문에 자원 소모가 갈수록 커진다는 단점들이 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1606&quot; data-origin-height=&quot;456&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KNlwV/btsNbolMxJK/dlfXXukWjWD6O0BMxPl8hK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KNlwV/btsNbolMxJK/dlfXXukWjWD6O0BMxPl8hK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KNlwV/btsNbolMxJK/dlfXXukWjWD6O0BMxPl8hK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKNlwV%2FbtsNbolMxJK%2FdlfXXukWjWD6O0BMxPl8hK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;760&quot; height=&quot;216&quot; data-origin-width=&quot;1606&quot; data-origin-height=&quot;456&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 방식도 마찬가지로 요청 간 텀이 없을 경우 처리량이 매우 많아집니다. 이전 방식보다 DB 업데이트는 적어지지만, 서버 개수가 3개 뿐이기 때문에 큰 의미는 없을 것 같습니다. 레디스를 사용하는 경우 레디스 자체가 단일 병목 지점이 될 수 있고, 네트워크를 거치는 시간이 길어지기 때문에 해당 사항 역시 고려해야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 메인 페이지 게시글과 일반 게시글 분리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;앞에서 살펴본 방법들을 조합하여 각각 메인 페이지에 노출될 게시글과 일반 게시글에 적용하는 방법을 사용할 수도 있습니다. 이렇게 된다면 실질적인 성능은 개선하고, 불필요한 메모리나 외부 자원 사용을 더욱 줄일 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;메인 페이지 게시글은 카운팅, 일반 게시글은 DB 업데이트&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;975&quot; data-origin-height=&quot;506&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/U7vIN/btsNa7KHfX7/UKbr5chBCUC33c2s9cycd1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/U7vIN/btsNa7KHfX7/UKbr5chBCUC33c2s9cycd1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/U7vIN/btsNa7KHfX7/UKbr5chBCUC33c2s9cycd1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FU7vIN%2FbtsNa7KHfX7%2FUKbr5chBCUC33c2s9cycd1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;374&quot; data-origin-width=&quot;975&quot; data-origin-height=&quot;506&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;조회가 적을 것으로 예상되는 일반 게시글은 따로 처리하지 않고 바로 DB에 업데이트합니다. 메인 페이지에 노출될 최근 게시글은 위에서 살펴본 것처럼 따로 카운팅을 한 후 DB에 주기적으로 업데이트합니다. &lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;이렇게 게시글을 둘로 나누어 작업하면 Redis에 올라갈 데이터의 양이 한정되기 때문에 자원 소모가 적다는 장점도 있습니다.&lt;/span&gt; 조회수가 몰릴 게시글은 당장의 정합성보다 성능이 중요할 수 있기 때문에 이렇게 나누어 적용하는 방식도 좋은 것 같습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; 구현이 더욱 까다롭다는 점과 동기화 시 새로운 글 작성을 고려하여 key를 삭제하고 추가해줘야 한다는 주의점도 있습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;사용자가 많지도 않으며, 기술 블로그라는 서비스 특성 상 이번 글에서 살펴본 내용들을 적용하지는 않고 맨 처음 소개한 조회 시 바로 update 방식으로 유지할 것입니다. 하지만, 다른 서비스 혹은 사용자가 많은 경우라면 고민해 본 내용들을 효과적으로 적용할 수 있을 것 같습니다! &lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;여러 경우를 함께 살펴보면서 &lt;span style=&quot;color: #000000; text-align: start;&quot;&gt;흐릿했던 정보들이&lt;span&gt; 조금은 뚜렷해진 느낌입니다.&lt;/span&gt;&lt;/span&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;</description>
      <category>Project</category>
      <author>phonil</author>
      <guid isPermaLink="true">https://yestomo.tistory.com/18</guid>
      <comments>https://yestomo.tistory.com/18#entry18comment</comments>
      <pubDate>Tue, 1 Apr 2025 18:27:59 +0900</pubDate>
    </item>
    <item>
      <title>조회수 카운팅 시 동시성 문제</title>
      <link>https://yestomo.tistory.com/17</link>
      <description>&lt;blockquote data-pm-slice=&quot;1 1 []&quot; data-ke-style=&quot;style3&quot;&gt; 이 글은 조회수 카운팅 시 동시성 문제에 대한 내용을 담고 있습니다. 동시성 문제를 해결하고 조회수를 정확히 카운팅하기 위해 사용할 수 있는 방법들에 대해 살펴봅니다. 특히, 잠금을 통해 해당 문제를 해결하는 여러 방법을 탐구하고 있습니다. 끝으로, 테스트 결과와 선택한 방식을 소개합니다.&lt;/blockquote&gt;
&lt;p data-end=&quot;544&quot; data-start=&quot;510&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;544&quot; data-start=&quot;510&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;현재, 게시글 조회 시 조회수를 +1 하는 형태로 카운팅하고 있습니다. 하지만, 동시에 여러 요청이 발생한다면 조회수 카운팅이 정확이 이루어지지 않을 수 있습니다. 이는 동일한 Row를 Update하는 과정에서 발생하는 Race Condition으로, 다양한 방법을 통해 해결할 수 있습니다.&lt;/p&gt;
&lt;p data-end=&quot;544&quot; data-start=&quot;510&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;544&quot; data-start=&quot;510&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;자바에서 제공하는 키워드를 사용할 수 있으며, DB를 통해 동시성을 보장할 수도 있습니다. 자바에서 제공하는 &lt;b&gt;synchronized, volatile, Atomic&lt;/b&gt;과 같은 키워드는 다중 서버 환경에서 정확하게 동시성을 보장하지 못 할 수 있다는 한계가 있습니다. 따라서 다중 서버 환경에서도 올바르게 동시성을 처리할 수 있는 방법들에 대해 살펴보고자 합니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;712&quot; data-start=&quot;545&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;577&quot; data-start=&quot;545&quot;&gt;&lt;b&gt;비관적 락(Pessimistic Lock)&lt;/b&gt;,&lt;/li&gt;
&lt;li data-end=&quot;609&quot; data-start=&quot;578&quot;&gt;&lt;b&gt;낙관적 락(Optimistic Lock)&lt;/b&gt;,&lt;/li&gt;
&lt;li data-end=&quot;631&quot; data-start=&quot;610&quot;&gt;&lt;b&gt;직접 Update 쿼리&lt;/b&gt;,&lt;/li&gt;
&lt;li data-end=&quot;712&quot; data-start=&quot;632&quot;&gt;&lt;b&gt;분산 락&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;835&quot; data-start=&quot;714&quot; data-ke-size=&quot;size16&quot;&gt;위 4가지 방법 위주로 살펴보고, 각 방법의 장/단점을 검토해, 서비스 특성에 맞추어 선택하여 적용해보겠습니다.&lt;/p&gt;
&lt;p data-end=&quot;835&quot; data-start=&quot;714&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;조회수 카운팅 정책에 관한 내용은 &lt;a title=&quot;여기&quot; href=&quot;https://yestomo.tistory.com/16&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;여기&lt;/a&gt;에서 볼 수 있습니다!&lt;/p&gt;
&lt;h2 style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size26&quot;&gt;1. 동시성 문제&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;0) 현재 상태&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;비회원을 기준으로 현재 조회수 상승 코드를 살펴보겠습니다.&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;background-color: #212121; color: #eeffff;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Getter
@Table(name = &quot;post&quot;)
public class Post extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;member_id&quot;, nullable = false)
    private Member member;

    @Column(name = &quot;title&quot;)
    private String title;

    @Column(name = &quot;view_count&quot;)
    @Min(value = 0)
    private Integer viewCount;
    
    // ...

    public void incrementViewCount() {
        this.viewCount++;
    }
}

// Service 계층 코드
@Transactional
public SuccessResponse&amp;lt;AnonymousPostDetailRes&amp;gt; getPostDetail(PostDetailParams postDetailParams) {
    Post post = postRepository.findById(postDetailParams.getPostId())
            .orElseThrow(() -&amp;gt; new ResourceNotFoundException());

    String cookieName = POST_COOKIE_NAME_PREFIX + postDetailParams.getPostId();
    Cookie existingCookie = WebUtils.getCookie(postDetailParams.getRequest(), cookieName);
    if (existingCookie == null) {
        post.incrementViewCount();
        Cookie newCookie = new Cookie(cookieName, POST_COOKIE_VALUE);
        postDetailParams.getResponse().addCookie(newCookie);
        // ...
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;nbsp;현재는 단순히 게시글 조회 후 &lt;/span&gt;&lt;span&gt;viewCount++&lt;/span&gt;&lt;span&gt; 연산을 수행하고 저장하는 구조입니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1893&quot; data-origin-height=&quot;709&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/euy5dz/btsM7Yabvnl/dHC9kW4leTH6PF8l4s2nR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/euy5dz/btsM7Yabvnl/dHC9kW4leTH6PF8l4s2nR1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/euy5dz/btsM7Yabvnl/dHC9kW4leTH6PF8l4s2nR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Feuy5dz%2FbtsM7Yabvnl%2FdHC9kW4leTH6PF8l4s2nR1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;820&quot; height=&quot;307&quot; data-origin-width=&quot;1893&quot; data-origin-height=&quot;709&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이렇게 조회 쿼리와 update 쿼리가 각각 발생합니다. 이 경우 여러 요청이 동시에 들어왔을 때, 서로 다른 트랜잭션에서 동일한 viewCount 값을 기준으로 연산하여 조회수 카운팅이 제대로 되지 않을 수 있는 문제가 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;656&quot; data-origin-height=&quot;489&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bOHScu/btsM81RuoXo/7gEWkRmd243H5m6i3fEiiK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bOHScu/btsM81RuoXo/7gEWkRmd243H5m6i3fEiiK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bOHScu/btsM81RuoXo/7gEWkRmd243H5m6i3fEiiK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbOHScu%2FbtsM81RuoXo%2F7gEWkRmd243H5m6i3fEiiK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;550&quot; height=&quot;410&quot; data-origin-width=&quot;656&quot; data-origin-height=&quot;489&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;위 그림처럼 User1의 트랜잭션이 커밋되어 조회수 상승이 DB에 반영되기 전에 새로운 트랜잭션에서 Select를 하게 되고, 이 값을 바탕으로 조회수를 상승시킵니다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;따라서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt; User1의 변경 사항이 먼저 반영되고, User2의 변경 사항(조회수 = 11)이 이를 덮어쓰게 되는 것입니다. 의도한 바는 조회수 12지만, 11이라는 결과가 나타나게 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;758&quot; data-origin-height=&quot;288&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b6AMM4/btsNaMzRk8e/v4zaKDj8C2KzzmwkRs265K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b6AMM4/btsNaMzRk8e/v4zaKDj8C2KzzmwkRs265K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b6AMM4/btsNaMzRk8e/v4zaKDj8C2KzzmwkRs265K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb6AMM4%2FbtsNaMzRk8e%2Fv4zaKDj8C2KzzmwkRs265K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;274&quot; data-origin-width=&quot;758&quot; data-origin-height=&quot;288&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;100번의 조회를 테스트했을 때, 고작 11번만 카운팅되었습니다. 89번의 조회수 카운팅이 손실되었고, 요청이 더 많이 몰린다면 더 많은 양의 손실이 발생할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;성능, 비용과의 조율이 필요하겠지만, 동시성 문제만을 놓고 보았을 때 이를 해결하기 위해서는 락(잠금)이 필요합니다. 다양한 방식이 있으니, 이에 대해 알아보고 적절한 방안을 선택하는 과정을 살펴보겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id=&quot;1)%20%EB%B9%84%EA%B4%80%EC%A0%81%20%EB%9D%BD-1&quot; style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;1) 비관적 락&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;비관적 락은 이름에서 알 수 있듯이 강력한 락을 통해 동시성을 제어합니다. 동일한 데이터에 대해 동시에 여러 작업이 수행되지 않도록 락을 거는 방법입니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;내부적으로 &lt;b&gt;SELECT ... FOR UPDATE 쿼리&lt;/b&gt;를 사용하는데, @Lock 어노테이션을 사용하여 간단하게 구현할 수 있습니다.&lt;/p&gt;
&lt;div style=&quot;background-color: #212121; color: #eeffff;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public interface PostRepository extends JpaRepository&amp;lt;Post, Long&amp;gt; {

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query(&quot;SELECT p FROM Post p WHERE p.id = :postId&quot;)
    Optional&amp;lt;Post&amp;gt; findByIdForUpdate(@Param(&quot;postId&quot;) Long postId);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;@Lock의 LockModeType를 통해 비관적 락으로 설정하고, @Query에 JPQL을 작성해줍니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1189&quot; data-origin-height=&quot;528&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bbhA07/btsM9Y7RtCB/Y2nnu0LSbFw1ktAPhLNfL1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bbhA07/btsM9Y7RtCB/Y2nnu0LSbFw1ktAPhLNfL1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bbhA07/btsM9Y7RtCB/Y2nnu0LSbFw1ktAPhLNfL1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbbhA07%2FbtsM9Y7RtCB%2FY2nnu0LSbFw1ktAPhLNfL1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;820&quot; height=&quot;364&quot; data-origin-width=&quot;1189&quot; data-origin-height=&quot;528&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;실제 쿼리를 살펴보면 이전과는 다르게 for update가 포함되어 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;select ... for update를 사용하면 조회한 데이터(행)에 대해 트랜잭션이 종료될 때까지 &lt;b&gt;X락&lt;/b&gt;을 걸어, 다른 트랜잭션에서 데이터를 읽거나 쓸 수 없게 됩니다. 단순 Select는 S락을 포함하지 않기 때문에 조회가 가능하지만, S락을 포함한 Select가 대기하게 됩니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;만약 다른 트랜잭션이 S락을 가지고 데이터를 읽으려고 한다면 잠금이 해제될 때까지 대기하고, 잠금을 획득한 트랜잭션이 커밋 혹은 롤백되면 잠금이 해제되어 접근이 가능해집니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;비관적 락을 사용하면 각 트랜잭션이 시작되고 조회할 때, 잠금이 걸리기 때문에 커밋 전 조회로 인해 발생하는 덮어쓰기 문제가 해결됩니다. 하지만, 한 번에 하나의 트랜잭션이 온전히 실행되기를 기다려야 하기 때문에 요청이 많은 경우 대기 시간이 길어져, 응답 시간이 길어질 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;데이터 정합성 측면에서는 가장 좋은 방법이라고 생각되나, 그만큼 성능을 포기해야 한다는 단점이 명확합니다. 데이터의 오차에 민감한 경우 성능을 조금 포기하더라도 정합성을 지키기 위해 사용하면 좋은 방법입니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id=&quot;2)%20%EB%82%99%EA%B4%80%EC%A0%81%20%EB%9D%BD-1&quot; style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;2) 낙관적 락&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;비관적 락은 충돌 상황을 허용하나, 이를 감지하고 충돌이 발생했다면 어떻게 할 지 처리를 결정하는 방식입니다. &lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;@Lock 어노테이션과 &lt;/span&gt;Post 엔티티의 version 컬럼을 추가한 후 @Version 어노테이션 붙여 구현할 수 있으며, 데이터의 버전 정보를 사용합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1743748665525&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface PostRepository extends JpaRepository&amp;lt;Post, Long&amp;gt; {

    @Lock(LockModeType.OPTIMISTIC)
    @Query(&quot;SELECT p FROM Post p WHERE p.id = :postId&quot;)
    Optional&amp;lt;Post&amp;gt; findByIdForUpdate(@Param(&quot;postId&quot;) Long postId);
}

// Post 엔티티
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@Entity
@Getter
@Table(name = &quot;post&quot;)
public class Post extends BaseEntity {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = &quot;view_count&quot;)
    @Min(value = 0)
    private Integer viewCount;
    
    @Version
    private Integer version;
    
    // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;낙관적 락은 트랜잭션에서 조회 쿼리를 날릴 때 잠금을 걸지 않고, 트랜잭션이 커밋될 때 버전 정보를 비교하여 충돌 여부를 확인합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1192&quot; data-origin-height=&quot;551&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rtZWa/btsM893GFBo/Ek1gx9tFbTLZvrrhqRlgZ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rtZWa/btsM893GFBo/Ek1gx9tFbTLZvrrhqRlgZ0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rtZWa/btsM893GFBo/Ek1gx9tFbTLZvrrhqRlgZ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrtZWa%2FbtsM893GFBo%2FEk1gx9tFbTLZvrrhqRlgZ0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;820&quot; height=&quot;379&quot; data-origin-width=&quot;1192&quot; data-origin-height=&quot;551&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;쿼리를 살펴보면 update 시 version과 함께 검색하는 것을 볼 수 있습니다. &lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;따로 락을 걸지 않고, 버전 충돌이 발생하면ObjectOptimisticLockingFailureException 예외를 발생시킵니다. 따라서 무시되기를 원치 않는다면 재시도 로직을 작성하여 예외 처리 로직을 작성해주어야 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;&amp;nbsp;낙관적 락을 사용하면 락을 걸지 않기 때문에 비관적 락에 비해 성능이 우수합니다. 하지만, 충돌 시 로직을 직접 작성해야 한다는 번거로움이 존재합니다. 또한, 충돌이 많을 경우 재시도 등 처리 로직을 여러 번 수행하게 되므로 &lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;충돌이 많지 않을 것으로 예상되는 상황일 때 사용하면 좋은 방법입니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id=&quot;3)%20Update%20%EC%BF%BC%EB%A6%AC-1&quot; style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;3) Update 쿼리 직접 사용&lt;/h3&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;update를 쿼리를 직접 날리는 방식으로 현재의 동시성 문제를 해결할 수도 있습니다. 마찬가지로 @Query에 JPQL을 작성해주고, @Modifying 어노테이션을 붙여줍니다.&amp;nbsp;&lt;/p&gt;
&lt;div style=&quot;background-color: #212121; color: #eeffff;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;public interface PostRepository extends JpaRepository&amp;lt;Post, Long&amp;gt; {

    @Modifying
    @Query(&quot;UPDATE Post p SET p.viewCount = p.viewCount + 1 WHERE p.id = :postId&quot;)
    void incrementViewCount(@Param(&quot;postId&quot;) Long postId);
}

// Service 계층 코드
@Transactional
public SuccessResponse&amp;lt;AnonymousPostDetailRes&amp;gt; getPostDetail(PostDetailParams postDetailParams) {
    String cookieName = POST_COOKIE_NAME_PREFIX + postDetailParams.getPostId();
    Cookie existingCookie = WebUtils.getCookie(postDetailParams.getRequest(), cookieName);
    if (existingCookie == null) {
        postRepository.incrementViewCount(postDetailParams.getPostId());
        Cookie newCookie = new Cookie(cookieName, POST_COOKIE_VALUE);
        postDetailParams.getResponse().addCookie(newCookie);
    }
    
	// ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;repository를 통해 바로 DB에 쿼리를 날려줍니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1116&quot; data-origin-height=&quot;487&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/LJ5r4/btsM9JDLIhK/76sKvFy47iQSXioS2IVWiK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/LJ5r4/btsM9JDLIhK/76sKvFy47iQSXioS2IVWiK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/LJ5r4/btsM9JDLIhK/76sKvFy47iQSXioS2IVWiK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLJ5r4%2FbtsM9JDLIhK%2F76sKvFy47iQSXioS2IVWiK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;820&quot; height=&quot;358&quot; data-origin-width=&quot;1116&quot; data-origin-height=&quot;487&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&amp;nbsp;update 순간부터 락을 얻고, 커밋 후에 락을 해제하기 때문에 비교적 락 점유 시간이 짧습니다. 작성한 update 쿼리가 나타나는 것을 확인할 수 있습니다.&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;span style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot;&gt;&amp;nbsp;앞의 두 방법은 게시글 조회 후 애플리케이션 단에서 값을 수정했습니다. 따라서 커밋 전 조회 시 값이 올바르지 않다는 문제가 발생할 수 있었습니다. 하지만, 이 방식은 데이터베이스 단에서 값을 바로 수정하기 때문에 조회로 인한 데이터 충돌 문제가 발생하지 않습니다. 따라서 update 시 레코드에 x락을 걸어 커밋 전까지 사용하고, 이를 통해 효과적으로 동시성 처리를 할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 id=&quot;4)%20%EB%B6%84%EC%82%B0%20%EB%9D%BD-1&quot; style=&quot;background-color: #ffffff; color: #353638; text-align: left;&quot; data-ke-size=&quot;size23&quot;&gt;4) 분산 락&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;분산 락은 중앙 시스템을 사용하여 경쟁 상황(Race Condition)에서의 충돌을 방지하는 방법입니다. 애플리케이션의 규모가 커지면서 DB가 여러 대로 늘어나게 된다면 다시 동시성 문제가 발생할 수 있는데, 이 때 분산락을 통해 해결할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;분산 락은 다양한 방식으로 구현 가능한데, 대표적으로 MySQL의 네임드 락과 Redis를 사용하여 구현할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;네임드 락&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;네임드 락은 데이터베이스를 통해 락을 획득하고, 다른 트랜잭션은 락이 해제된 이후에 획득할 수 있도록 합니다. MySQL에서 제공하는 GET_LOCK(lock_name, timeout) 함수를 사용하여 네임드 락을 획득할 수 있으며, 획득한 락은 RELEASE_LOCK(lock_name) 함수로 해제하거나 세션이 종료되면 자동으로 해제됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;락 획득&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; 락을 획득하기 위해서는 획득하려는 락의 이름과 TIMEOUT을 설정해주어야 합니다.&lt;/p&gt;
&lt;pre id=&quot;code_1743773213680&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Query(value = &quot;select get_lock(:key, 3000)&quot;, nativeQuery = true)
void getLock(String key);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;락 해제&lt;/p&gt;
&lt;div style=&quot;color: #000000;&quot;&gt;&amp;nbsp;락을 해제할 때는 해제할 락의 이름을 사용합니다.&lt;/div&gt;
&lt;div style=&quot;color: #000000;&quot;&gt;
&lt;pre id=&quot;code_1743773345392&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@Query(value = &quot;select release_lock(:key)&quot;, nativeQuery = true)
void releaseLock(@Param(&quot;key&quot;) String key);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;&amp;nbsp;네임드 락은 락을 획득하기 위해 커넥션을 유지해야 하므로 애플리케이션에서 데이터베이스에 대한 커넥션 풀이 부족할 수 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Redis 사용&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;MySQL은 메인 저장소로 사용하는 경우가 많기 때문에 Redis를 사용하여 분산 락을 구현하는 경우도 많습니다.&amp;nbsp;Redis를 사용하는 방법은 Lettuce와 Redisson으로 나뉩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;Lettuce&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;Lecttuce는 &lt;span&gt;Spring에서 기본적으로 사용하는 &lt;/span&gt;&lt;span&gt;Redis 클라이언트입니다.&lt;/span&gt;&amp;nbsp;Lettuce를 사용 시에는 SETNX(SET if Not eXists)명령어를 사용해서 락을 생성하고, 성공한다면 락을 획득합니다. 락이 있는 동안 다른 노드가 똑같은 키로 SETNX를 시도하면 락을 획득하지 못하게 됩니다. 락이 필요한 작업을 마친 후에는 delete로 해당 키를 삭제합니다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1743774752482&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 락 획득
public Boolean lock(Long key) {
    return redisTemplate
            .opsForValue()
            .setIfAbsent(generateKey(key), &quot;lock&quot;, Duration.ofSeconds(3));
}

// 락 해제
public Boolean unlock(Long key) {
    return redisTemplate.delete(generateKey(key));
}

// 락 사용
Boolean isLockSuccess = redisUtil.lock(key);
if (Boolean.TRUE.equals(isLockSuccess)) {
    try {
        postRepository.incrementViewCount(postId);
    } finally {
        redisTemplate.delete(key); 
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;Redisson&lt;/p&gt;
&lt;p data-pm-slice=&quot;1 1 []&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&amp;nbsp;Redisson은 Redis 기반의 고급 Java 클라이언트로, 락 획득, 유지, 연장, 해제 등 다양한 기능을 편리하게 제공합니다. Redisson을 사용하기 위해서는 우선 build.gradle에 redisson 의존성을 추가해야 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1743775079656&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;RLock lock = redissonClient.getLock(&quot;post_view:&quot; + postId);
boolean isLocked = lock.tryLock(1, 3, TimeUnit.SECONDS); // 락 획득
if (isLocked) {
    try {
        postRepository.incrementViewCount(postId);
    } finally {
        if (lock.isLocked() &amp;amp;&amp;amp; lock.isHeldByCurrentThread()) 
		lock.unlock();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;getLock으로 락을 정의한 후, tryLock으로 락 획득을 시도할 시간과 획득 시 점유할 시간을 명시합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;점유한 시간이 끝나기 전에 작업이 종료되면 락을 해제해주어야 합니다. 락을 해제할 때에는 해제하려는 락이 현재 세션에서 사용 중인 것인지 확인하고 해제하는 것이 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 선택&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-spread=&quot;false&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;비관적&lt;/b&gt; &lt;b&gt;락&lt;/b&gt;은 매번 락을 걸기 때문에 데이터 정합성은 훌륭하지만 성능 면에서 가장 떨어집니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;낙관적 락&lt;/b&gt;&lt;/span&gt;&lt;span&gt;은 정합성은 보장되지만, 충돌 시 복잡한 예외 처리 및 재시도 로직이 필요하며, 실시간성이 중요한 조회수에는 다소 부적합합니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;직접 UPDATE 쿼리 방식&lt;/b&gt;&lt;/span&gt;&lt;span&gt;은 명시적 쿼리를 통해 락을 짧게 유지하고, 성능과 정합성의 균형을 모두 갖춘 방식입니다.&lt;/span&gt;&lt;span&gt;&lt;b&gt;&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;&lt;b&gt;분산 락&lt;/b&gt;&lt;/span&gt;&lt;span&gt;은 분산 환경을 고려한 전략이지만, 현재 단일 DB 구조에서는 크게 필요하지 않습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;현재 동시성 처리를 하고자 하는 조회수 데이터는 정합성이 크게 중요하지 않고, 자원을 많이 투입하지 않아도 괜찮은 상태입니다. 따라서 데이터 정합성과 성능을 균형있게 챙길 수 있는 낙관적 락과 직접 Update 쿼리를 작성하는 방식 중 고민하였고, 아래와 같은 이유로 &lt;b&gt;직접 Update 쿼리를 작성하는 방식을 선택&lt;/b&gt;했습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;798&quot; data-origin-height=&quot;344&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/caifeb/btsNabG8tz3/4nCHkCSFtdcCjiwi9LEQCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/caifeb/btsNabG8tz3/4nCHkCSFtdcCjiwi9LEQCk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/caifeb/btsNabG8tz3/4nCHkCSFtdcCjiwi9LEQCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcaifeb%2FbtsNabG8tz3%2F4nCHkCSFtdcCjiwi9LEQCk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;798&quot; height=&quot;344&quot; data-origin-width=&quot;798&quot; data-origin-height=&quot;344&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1410&quot; data-start=&quot;914&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1121&quot; data-start=&quot;914&quot;&gt;&lt;b&gt;낙관적 락&lt;/b&gt;&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1121&quot; data-start=&quot;947&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1041&quot; data-start=&quot;1010&quot;&gt;장점: 평소 충돌이 적으면 락 없이 빠르게 처리함.&lt;/li&gt;
&lt;li data-end=&quot;1121&quot; data-start=&quot;1044&quot;&gt;단점: 충돌이 잦으면 여러 번 재시도해야 하므로 오히려 성능이 저하됨. 또한, 충돌 로직 핸들링(재시도, 예외 처리)을 직접 작성해야 함.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li data-end=&quot;1410&quot; data-start=&quot;1123&quot;&gt;&lt;b&gt;직접 Update 쿼리&lt;/b&gt;&lt;br /&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1410&quot; data-start=&quot;1146&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1332&quot; data-start=&quot;1236&quot;&gt;장점: 구현이 단순하며, lock이 비교적 짧게 점유됨. DB 자체가 &amp;ldquo;view_count = view_count + 1&amp;rdquo; 연산 시 원자성을 보장하여 안전하게 처리함.&lt;/li&gt;
&lt;li data-end=&quot;1410&quot; data-start=&quot;1335&quot;&gt;단점: 트래픽이 많아질 경우 DB 락 경합이 올라갈 수 있음. 그러나 트래픽이 한 번에 몰릴만한 서비스가 아니며,&amp;nbsp; 조회수 증가 정도라면 대체로 문제가 크지 않을 가능성이 높음.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;직접 Update 쿼리&lt;/b&gt; 방식 선택&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;충돌이 적을 것으로 예상되므로 낙관적 락도 괜찮았습니다. 하지만 충돌 시 재시도 및 예외 처리를 직접 작성해야 하며, 조회수를 활용하여 인기 글 노출 기능 등이 추가된다면 특정 글에서 충돌 시 성능 저하가 우려되었습니다. 특히, 최상단에 있는 게시글같이 접근하기 쉬운 경우 충돌 가능성이 높기 때문에 update 쿼리를 작성하는 방식으로 선택했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;데이터베이스 책과 운영체제를 공부했던 기억들과 함께 다시 책을 펼쳐보고 적용해보았습니다. 주로 사용하는 MySQL에서의 S락과 X락 그리고 넥스트 키락과 같이 다른 동시성 제어 방법에 대해서도 살펴볼 수 있는 좋은 시간이었습니다.&lt;/p&gt;</description>
      <category>Project</category>
      <author>phonil</author>
      <guid isPermaLink="true">https://yestomo.tistory.com/17</guid>
      <comments>https://yestomo.tistory.com/17#entry17comment</comments>
      <pubDate>Sun, 23 Mar 2025 13:25:31 +0900</pubDate>
    </item>
    <item>
      <title>회원과 비회원을 고려한 복합 조회수 카운팅 정책</title>
      <link>https://yestomo.tistory.com/16</link>
      <description>&lt;blockquote data-ke-style=&quot;style3&quot;&gt; 이 글은 현재 리팩토링 중인 동아리 기술 블로그 서비스의 조회수 카운팅 정책에 대한 내용입니다. 다양한 정책이 존재할 때, 장/단점을 따져보며 필요한 정책을 채택하는 고민이 담겨있습니다. 회원과 비회원에게 각각 다른 정책을 적용하는 방식을 선택하였습니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 조회수 카운팅 정책&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;조회수를 어떻게 올릴까? 생각해보면 저는 단순히 조회마다 +1하는 방법이 가장 먼저 떠오릅니다. 이 경우 새로고침을 연타하여 조회수를 상승시켜 본 기억도 있습니다. 때에 따라 카운팅 정책을 알맞게 설정하여 예전의 저와 같은 행동으로 인해 발생하는 부정확한 조회수 문제를 방지할 수 있습니다. 아래에 소개하는 것처럼 다양한 방식으로 조회수 카운팅 정책을 설정할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;894&quot; data-origin-height=&quot;283&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bqAAhC/btsNbbHs6fS/qmfWDmkOozdnbXPyr71KI0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bqAAhC/btsNbbHs6fS/qmfWDmkOozdnbXPyr71KI0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bqAAhC/btsNbbHs6fS/qmfWDmkOozdnbXPyr71KI0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbqAAhC%2FbtsNbbHs6fS%2FqmfWDmkOozdnbXPyr71KI0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;650&quot; height=&quot;206&quot; data-origin-width=&quot;894&quot; data-origin-height=&quot;283&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1) 클릭 시 +1&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;현재 조회 수 카운팅 정책으로, MVP 개발을 위해 채택한 구현에 가장 부담없는 정책입니다. 단순히 게시글 조회 시 +1을 해주며, 회원/비회원 모두 동일하게 적용됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 방식은 구현이 쉬우며, 어찌됐든 페이지의 총 조회수를 확인할 수 있다는 장점이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;반면, 새로고침을 계속 하는 등 조회 수 어뷰징 문제가 발생할 수 있으며, 실제 방문자 수와는 거리가 멀어 과대 집계될 수 있다는 문제가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot; data-start=&quot;1774&quot; data-end=&quot;1793&quot;&gt;2) 쿠키 기반 제한&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;브라우저에 쿠키를 저장하여 일정 기간 내 중복 조회 방지하는 방법입니다. 요청 시 쿠키가 없다면 조회수를 상승시키고 쿠키를 발급합니다. 발급된 쿠키는 저장소에 저장하여 추후 요청 시 사용합니다. 요청 시 쿠키가 있다면 조회수를 상승시키지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;따라서 쿠키가 저장되어 있는 동안에는 여러 번 조회하더라도 조회수가 상승하지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1135&quot; data-origin-height=&quot;586&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dvHDic/btsNdjj2JYE/hwuQp1g8JAsshsI34J0oW0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dvHDic/btsNdjj2JYE/hwuQp1g8JAsshsI34J0oW0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dvHDic/btsNdjj2JYE/hwuQp1g8JAsshsI34J0oW0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdvHDic%2FbtsNdjj2JYE%2FhwuQp1g8JAsshsI34J0oW0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;372&quot; data-origin-width=&quot;1135&quot; data-origin-height=&quot;586&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 방식은 회원 유저뿐만 아니라 비회원 유저의 중복 조회도 방지할 수 있으며, IP 기반 제한처럼 다른 사용자임에도 동일한 사용자로 인식되지 않기 때문에 정확하다는 장점이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;반면, 사용자가 브라우저 리셋을 하거나 쿠키를 삭제하는 등 제어할 수 없는 부분으로 인한 부정확한 카운팅이 존재할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;1209&quot; data-start=&quot;1193&quot; data-ke-size=&quot;size23&quot;&gt;3) IP 기반 제한&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;사용자의 IP를 기준으로 일정 시간 내 한 번만 카운팅하는 방법입니다. 사용자의 IP를 통해 해당 게시글의 방문 여부를 확인합니다. 만약 방문한 적이 없다면 저장소에 저장하고, 조회수를 카운팅 합니다. 이전에 방문한 적이 있다면 카운팅하지 않습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1013&quot; data-origin-height=&quot;504&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NoKOt/btsNcfpoR70/oGkVekq9dJMuQaRN9PXKIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NoKOt/btsNcfpoR70/oGkVekq9dJMuQaRN9PXKIK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NoKOt/btsNcfpoR70/oGkVekq9dJMuQaRN9PXKIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNoKOt%2FbtsNcfpoR70%2FoGkVekq9dJMuQaRN9PXKIK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;358&quot; data-origin-width=&quot;1013&quot; data-origin-height=&quot;504&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 방식은 회원과 비회원 모두 중복 조회 방지가 가능하며, 같은 IP를 사용하지 않는 경우 좋은 중복 조회 방지 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;하지만, 여러 사용자임에도 하나의 IP로 묶여 같은 취급을 받기 때문에, 조회수가 카운팅되지 않는 경우가 많이 발생할 수 있습니다. 특히, 교내 동아리 서비스인만큼 같은 IP를 사용하는 학교 내에서 다수의 사용자가 접근할 가능성이 있는데, 이러한 경우 사용이 적합하지 않을 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4) 세션 기반 제한&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;사용자의 세션 단위로 조회수를 카운팅합니다. 사용자가 처음 조회 시 세션 저장소에 세션 ID를 저장하고 조회수를 카운팅합니다. 이미 세션 저장소에 방문 기록이 있다면 카운팅하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이는 세션이 유지되는 동안 중복 조회를 막아주는 효과가 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1193&quot; data-origin-height=&quot;599&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/saxDi/btsNdbGAn7t/6vSqn7L7kywdE2WvBVVne0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/saxDi/btsNdbGAn7t/6vSqn7L7kywdE2WvBVVne0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/saxDi/btsNdbGAn7t/6vSqn7L7kywdE2WvBVVne0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FsaxDi%2FbtsNdbGAn7t%2F6vSqn7L7kywdE2WvBVVne0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;362&quot; data-origin-width=&quot;1193&quot; data-origin-height=&quot;599&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이 방식은 세션을 통해 회원과 비회원 모두 적용 가능하다는 장점이 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;2289&quot; data-start=&quot;2267&quot; data-ke-size=&quot;size23&quot;&gt;5) 사용자(로그인) 기반 제한&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;로그인 된 사용자의 id를 기반으로 조회수를 카운팅하는 방법입니다. 위 방식들과 마찬가지로 사용자의 방문 기록을 저장소에 저장합니다. 저장 여부를 통해 조회수를 카운팅합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방식은 로그인 한 사용자에 한해 정확도 높은 조회수를 측정할 수 있지만, 비회원의 조회수 카운팅 제한할 수 없다는 문제가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;해당 서비스는 동아리 부원만 회원가입하여 로그인 할 수 있기 때문에 조회의 경우 비회원 사용자가 훨씬 많이 존재합니다. 이러한 경우, 비회원의 조회에 더 신경을 써야 하므로 로그인 유저 기반 방식은 다소 부족함이 많습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;3192&quot; data-start=&quot;3175&quot; data-ke-size=&quot;size23&quot;&gt;6) 복합(혼합) 정책&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;여러 정책을 복합적으로 사용하는 방식입니다. 동아리 기술 블로그 서비스는 부원들만 회원 가입이 가능합니다. 비회원은 게시글 조회가 가능하기 때문에 회원과 비회원을 나누어 관리할 수 있습니다. 회원의 경우 ID로 방문 기록을 확인하고, 비회원의 경우 쿠키나 IP 등 앞에서 설명한 방식으로 조회수 중복 상승을 방지할 수 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;827&quot; data-origin-height=&quot;688&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lSQWB/btsNcSAs9CP/CYUGwbbXO4kJLRoXMtiDOK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lSQWB/btsNcSAs9CP/CYUGwbbXO4kJLRoXMtiDOK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lSQWB/btsNcSAs9CP/CYUGwbbXO4kJLRoXMtiDOK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlSQWB%2FbtsNcSAs9CP%2FCYUGwbbXO4kJLRoXMtiDOK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;720&quot; height=&quot;599&quot; data-origin-width=&quot;827&quot; data-origin-height=&quot;688&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&amp;nbsp;이 방식은&lt;/span&gt; 상황에 맞게 조정할 수 있다는 점으로 인해 원하는 조회수 카운팅 방식을 만들기 좋습니다. 하지만, 따져 볼 것이 많기 때문에 구현에 불편함이 다소 존재할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;각각의 사용자 유형(회원/비회원)에 대해 최대한 정확한 집계가 가능하기 때문에 회원에 대한 통계를 바탕으로 동아리 컨텐츠로 사용할 수 있으며, 비회원 역시 다양한 형태로 정보를 사용할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 선택&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;현재 프로젝트에서 글 작성은 회원만 가능하고, 조회는 회원과 비회원 모두 가능합니다. 이미 게시글 상세 조회 시에 회원과 비회원을 나누어 작업하고 있었고, 분리해서 처리한다면 더욱 정확한 조회수 카운팅 할 수 있을 것입니다. 따라서 회원은 ID, 비회원은 쿠키를 사용한 복합 정책을 사용하기로 했습니다. TTL은 2시간으로 설정해놓아 2시간 후 재방문하면 다시 조회수가 카운팅되도록 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;처음에는 회원 - ID 사용 / 비회원 - IP + 쿠키 사용 방식으로 계획했습니다. 쿠키와 IP를 함께 사용하면 IP 사용 방식의 단점인 같은 IP에 속한 다른 사용자의 조회를 무시한다는 점을 쿠키로 해결할 수 있었습니다. 하지만, 쿠키를 단일로 사용할 때의 문제인 사용자가 쿠키를 제어할 수 있다는 문제는 해결되지 않았습니다. 이렇게 되면 IP를 함께 검사하더라도 쿠키 여부에 따라 조회수 카운팅 여부가 결정되었습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;쿠키 o + IP o =&amp;gt; 카운팅 x&lt;/li&gt;
&lt;li&gt;쿠키 o + IP x =&amp;gt; 카운팅 x&lt;/li&gt;
&lt;li&gt;쿠키 x + IP o =&amp;gt; +1 카운팅&amp;nbsp;&lt;/li&gt;
&lt;li&gt;쿠키 x + IP x =&amp;gt; +1 카운팅&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;위와 같이 네 가지 경우로 나누어 따져볼 수 있는데, &lt;b&gt;사용자가 쿠키를 삭제할 수 있는 상황에서 IP를 검사하여 얻을 수 있는 것이 없습니다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;따라서 &lt;b&gt;회원은 ID 사용, 비회원은 쿠키 사용&lt;/b&gt; 방식으로 조회수 카운팅 정책을 결정했습니다. 사용자가 쿠키를 삭제할 수 있다는 문제는 해결되지 않지만, 아직 조회수 집계로 생산하는 컨텐츠가 없기 때문에 비용을 줄이는 방안으로 선택했습니다. 조회수 수집 방식이 조금 더 면밀해져야 한다면 view용 조회수와 분석용 조회수를 나누어 수집하는 것도 좋을 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;회원의 경우 ID를 사용하는 것이 가장 효과적이며, 회원은 동아리 부원에 한정되기 때문에 저장소 공간 차지가 적습니다. 따라서 결정에 어려움이 없었지만, 비회원은&amp;nbsp; IP와 쿠키, 세션 중 선택해야 했습니다. 먼저, IP는 동일한 IP에서 접근하는 모든 조회를 카운팅하지 않기 때문에 학교라는 특징이 있는 서비스 특성 상 무시되는 카운팅이 많아질 것이라고 생각했습니다. 또한, IP와 세션은 별도 저장소를 필요로 하기 때문에 관리하는 데 사용되는 자원이 상대적으로 클 것입니다. 트래픽에 따라 달라지겠지만 회원과 다르게 비회원은 들어오는 요청이 매우 많이 늘어날 수 있기 때문입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구현&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;조회수는 게시글 상세 조회 API를 기준으로 카운팅합니다. 현재 게시글 상세 조회 시 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;회원/비회원의 응답 값이 다르고, 이를 전략 패턴으로 구현하고 있습니다. Factory에서 회원/비회원인지 판별 후 그에 맞는 Service를 반환하고, 게시글 상세 조회 응답을 전달합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;561&quot; data-origin-height=&quot;435&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ba1QKg/btsNagBr1jm/VzPk5dHeAtc2RVkvBsFag0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ba1QKg/btsNagBr1jm/VzPk5dHeAtc2RVkvBsFag0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ba1QKg/btsNagBr1jm/VzPk5dHeAtc2RVkvBsFag0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fba1QKg%2FbtsNagBr1jm%2FVzPk5dHeAtc2RVkvBsFag0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;560&quot; height=&quot;434&quot; data-origin-width=&quot;561&quot; data-origin-height=&quot;435&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;각자에게 맞는 서비스에서 게시글 상세 응답을 구성합니다. 게시글 상세 조회와 함께 조회수를 카운팅하는데, 알맞은 정책에 따라 업데이트 여부를 결정합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&amp;nbsp;비회원&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size18&quot;&gt;[ AnonymousPostDetailServiceImpl.java ]&lt;/p&gt;
&lt;div style=&quot;background-color: #212121; color: #eeffff;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;@Transactional
public SuccessResponse&amp;lt;AnonymousPostDetailRes&amp;gt; getPostDetail(PostDetailParams postDetailParams) {
    Post post = postRepository.findById(postDetailParams.getPostId())
            .orElseThrow(() -&amp;gt; new ResourceNotFoundException());

    String cookieName = POST_COOKIE_NAME_PREFIX + postDetailParams.getPostId();
    Cookie existingCookie = WebUtils.getCookie(postDetailParams.getRequest(), cookieName);
    if (existingCookie == null) {
        post.incrementViewCount();
        Cookie newCookie = new Cookie(cookieName, POST_COOKIE_VALUE);
        newCookie.setPath(COOKIE_PATH);
        newCookie.setMaxAge(COOKIE_AGE);
        newCookie.setHttpOnly(true);
        postDetailParams.getResponse().addCookie(newCookie);
    }

    List&amp;lt;String&amp;gt; tagNameList = taggingRepository.findByPost(post).stream()
            .map(tagging -&amp;gt; tagging.getTag().getName())
            .collect(Collectors.toList());

    Member writer = post.getMember();
    AnonymousPostDetailRes anonymousPostDetailRes = AnonymousPostDetailRes.of(post, writer, tagNameList);
    return SuccessResponse.of(anonymousPostDetailRes);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;앞에서 살펴본 것처럼 요청에 쿠키가 없다면 조회수 +1 후 쿠키를 생성하여 함께 응답합니다. 쿠키와 함께 요청을 한다면 게시글 정보만을 응답합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;&amp;nbsp;회원&lt;/b&gt;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;[ MemberPostDetailService.java ]&lt;/h4&gt;
&lt;div style=&quot;background-color: #212121; color: #eeffff;&quot;&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;    @Transactional
    public SuccessResponse&amp;lt;MemberPostDetailRes&amp;gt; getPostDetail(PostDetailParams postDetailParams) {
        Member member = postDetailParams.getUserDetails().getMember();
        Post post = postRepository.findByIdAndStage(postDetailParams.getPostId(), Stage.PUBLISHED)
                .orElseThrow(() -&amp;gt; new ResourceNotFoundException());

        String key = REDIS_KEY_MEMBER_PREFIX + member.getId() + REDIS_KEY_POST_PREFIX + post.getId();
        if (!redisUtil.hasKey(key)) {
            post.incrementViewCount();
            redisUtil.setDataExpire(key, POST_REDIS_VALUE, REDIS_DURATION);
        }

        List&amp;lt;String&amp;gt; tagNameList = taggingRepository.findByPost(post).stream()
                .map(tagging -&amp;gt; tagging.getTag().getName())
                .collect(Collectors.toList());

        Member writer = post.getMember();
        boolean sameUser = member.equals(writer);
        boolean liked = likesRepository.existsByMemberAndPost(member, post);
        boolean scraped = scrapRepository.existsByMemberAndPost(member, post);


        MemberPostDetailRes memberPostDetailRes = MemberPostDetailRes.of(post, writer, tagNameList, sameUser, liked, scraped);
        return SuccessResponse.of(memberPostDetailRes);
    }&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;마찬가지로 저장소에서 방문 여부를 확인한 후, 조회수 카운팅 여부를 결정합니다. 메일 인증과 JWT 토큰을 위해 Redis를 사용하고 있었으며, 다중 서버 환경에서도 문제 없이 확인할 수 있도록 하기 위해 외부 저장소로 Redis를 사용했습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;개발 당시 해당 주제에 대해 PM과 이야기 한 기억이 있습니다. 정책을 추가하면 좋겠지만, &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;MVP로 빠르게 개발을 완료해야 했기 때문에 잠시 접어두고 단순 +1 방식으로 하기로 결정했었습니다. 남겨두었던 것을 다시 자세히 알아보며 따져보아야 할 것들을 생각해보는 유익한 시간이었습니다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>Project</category>
      <author>phonil</author>
      <guid isPermaLink="true">https://yestomo.tistory.com/16</guid>
      <comments>https://yestomo.tistory.com/16#entry16comment</comments>
      <pubDate>Thu, 20 Mar 2025 21:08:18 +0900</pubDate>
    </item>
  </channel>
</rss>