<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>남건욱's 공부기록</title>
    <link>https://ngwdeveloper.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Mon, 8 Jun 2026 06:56:23 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>남건욱</managingEditor>
    <image>
      <title>남건욱's 공부기록</title>
      <url>https://tistory1.daumcdn.net/tistory/6321236/attach/f62bd00365f34807a51b4b08a921720e</url>
      <link>https://ngwdeveloper.tistory.com</link>
    </image>
    <item>
      <title>[트러블슈팅] 유튜브 영상 삭제 감지는 API 없이 불가능할까?</title>
      <link>https://ngwdeveloper.tistory.com/209</link>
      <description>&lt;p data-end=&quot;345&quot; data-start=&quot;226&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;345&quot; data-start=&quot;226&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;a style=&quot;color: #333333;&quot; href=&quot;https://devnote.kr&quot;&gt;https://devnote.kr&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1769485221185&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;DevNote&quot; data-og-description=&quot;개발 관련 YouTube/News Aggregator - 최신 개발 트렌드와 뉴스를 한 곳에서&quot; data-og-host=&quot;devnote.kr&quot; data-og-source-url=&quot;https://devnote.kr&quot; data-og-url=&quot;https://devnote.kr&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://devnote.kr&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://devnote.kr&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&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;DevNote&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;개발 관련 YouTube/News Aggregator - 최신 개발 트렌드와 뉴스를 한 곳에서&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;devnote.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-end=&quot;494&quot; data-start=&quot;347&quot; data-ke-size=&quot;size16&quot;&gt;나는 내가 만든 서비스인 DevNote를 습관처럼 접속해 상태를 확인한다.&lt;/p&gt;
&lt;p data-end=&quot;494&quot; data-start=&quot;347&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;수만 개의 유튜브 영상을 수집하고 분류하는 서비스인 만큼, 데이터가 최신 상태로 유지되고 있는지가 가장 중요하기 때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;494&quot; data-start=&quot;347&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;942&quot; data-origin-height=&quot;349&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dvKPJv/dJMb996hb9Z/cIINMMSb3Kzq5lCMUxamv0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dvKPJv/dJMb996hb9Z/cIINMMSb3Kzq5lCMUxamv0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dvKPJv/dJMb996hb9Z/cIINMMSb3Kzq5lCMUxamv0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdvKPJv%2FdJMb996hb9Z%2FcIINMMSb3Kzq5lCMUxamv0%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;942&quot; height=&quot;349&quot; data-origin-width=&quot;942&quot; data-origin-height=&quot;349&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;494&quot; data-start=&quot;347&quot; data-ke-size=&quot;size16&quot;&gt;어느 날 평소처럼 사이트를 확인하던 중, 목록 일부의 썸네일이 회색 아이콘으로 표시된 것을 발견했다&lt;/p&gt;
&lt;p data-end=&quot;494&quot; data-start=&quot;347&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;862&quot; data-origin-height=&quot;492&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baT37m/dJMcabJOE8i/mhtKGrf2cCqrKV4FYohxH1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baT37m/dJMcabJOE8i/mhtKGrf2cCqrKV4FYohxH1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baT37m/dJMcabJOE8i/mhtKGrf2cCqrKV4FYohxH1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaT37m%2FdJMcabJOE8i%2FmhtKGrf2cCqrKV4FYohxH1%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;615&quot; height=&quot;351&quot; data-origin-width=&quot;862&quot; data-origin-height=&quot;492&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-end=&quot;494&quot; data-start=&quot;347&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;해당 항목을 클릭해 보니 &amp;ldquo;동영상을 재생할 수 없습니다&amp;rdquo;라는 메시지가 노출되고 있었고, 해당 유튜버가 채널을 닫아 삭제처리된 영상이었다&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;494&quot; data-start=&quot;347&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;620&quot; data-start=&quot;496&quot; data-ke-size=&quot;size16&quot;&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;/p&gt;
&lt;p data-end=&quot;620&quot; data-start=&quot;496&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;620&quot; data-start=&quot;496&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;문제는 데이터의 규모였다. 시간이 지날수록 영상 수가 상승하면서, 모든 영상을 주기적으로 API로 확인하는 방식은 비용과 할당량 측면에서 현실적이지 않았다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;620&quot; data-start=&quot;496&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그래서 유튜브 API를 사용하지 않고 유튜브 영상의 삭제, 비공개 여부를 감지할 수 있는 방법이 있는지 고민하게 되었고, 그 과정을 정리해 보려 한다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;1. 가장 먼저 생각해본 방법&lt;/span&gt;&lt;/h2&gt;
&lt;p data-end=&quot;300&quot; data-start=&quot;195&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;가장 먼저 떠올린 방법은 유튜브에서 제공하는 공식 API(&lt;span style=&quot;text-align: start;&quot; data-darkreader-inline-color=&quot;&quot;&gt;videos.list&lt;/span&gt;)를 사용하는 것이었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;300&quot; data-start=&quot;195&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;영상의 상태를 확인하는 가장 확실한 방법이기도 하고, 구현 자체도 어렵지 않다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;300&quot; data-start=&quot;195&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;하지만 실제 환경에 대입해 보니 이 방식은 초기에 제외할 수밖에 없었다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;1.1. 매일 전체 영상을 확인하는 방식 &lt;/span&gt;&lt;/h3&gt;
&lt;p data-end=&quot;475&quot; data-start=&quot;387&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;처음에는 &amp;ldquo;매일 한 번 전체 영상을 점검하면 되지 않을까&amp;rdquo;라는 생각을 했다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;하지만 할당량을 계산해 보자마자 이 접근은 현실적이지 않다는 결론에 도달했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;475&quot; data-start=&quot;387&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;589&quot; data-start=&quot;477&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;유튜브 Data API의 무료 할당량은 일일 10,000 유닛이다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;videos.list를 50개 단위로 호출하더라도, 현재는 모두 소모하지는 않지만 몇달 후 수집한 유튜브 영상이 늘어나게 된다면 할당량을 크게 초과하게 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;589&quot; data-start=&quot;477&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;678&quot; data-start=&quot;591&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;수집 로직에서도 이미 API 할당량을 사용하고 있는 상황에서, 삭제 여부 확인을 위해 추가로 할당량을 소모하는 구조는 장기적으로 유지하기 어렵다고 판단했다.&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;span style=&quot;color: #333333;&quot;&gt;1.2. 사용자가 요청한 영상만 확인하는 방식&lt;/span&gt;&lt;/h3&gt;
&lt;p data-end=&quot;846&quot; data-start=&quot;716&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;다음으로 떠올린 것은 사용자가 실제로 클릭한 영상만 확인하는 방식이었다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;예를 들어 last_verified_at 컬럼을 두고, 마지막 확인 시점으로부터 24시간이 지난 경우에만 상세 페이지 진입 시 API를 호출하는 구조다. 이 방식은 단기적으로는 문제없이 동작할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;846&quot; data-start=&quot;716&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;하지만 서비스가 성장하고 사용자 수가 늘어날수록 API 호출 횟수도 함께 증가하게 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;846&quot; data-start=&quot;716&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;오히려 1번 방식보다 호출 횟수가 증가 할 수도 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;846&quot; data-start=&quot;716&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1012&quot; data-start=&quot;929&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;결국 사용량 증가가 곧 비용 증가로 이어지는 구조라는 생각이 들었고, 사용자가 늘어날수록 비용 부담이 커지는 설계는 장기 운영 관점에서 받아들이기 어려웠다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;2. 실제 동작에서 단서 찾기 &lt;/span&gt;&lt;/h2&gt;
&lt;p data-end=&quot;306&quot; data-start=&quot;227&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;API 문서에서 답을 찾기보다는 삭제되거나 비공개 처리된 영상이 브라우저에서 실제로 어떻게 표시되는지를 먼저 확인해 보기로 했다. 여러 삭제된 영상을 직접 열어보며 개발자 도구의 Network 탭을 관찰하던 중, 공통적인 패턴 하나를 발견했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;306&quot; data-start=&quot;227&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;487&quot; data-start=&quot;308&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;영상이 비공개 또는 삭제 상태가 되면, 썸네일 이미지의 URL 자체는 여전히 정상적으로 응답을 반환한다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;하지만 실제로 내려오는 이미지는 우리가 흔히 보는 회색아이콘으로 대체된다. 이때 주의깊게 본 것은 해당 이미지의 고유 크기였다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;487&quot; data-start=&quot;308&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;487&quot; data-start=&quot;308&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 회색 아이콘 이미지의 크기는 예외 없이 120&amp;times;90 px로 고정되어 있었다. 반면 정상적인 영상 썸네일은 최소 480&amp;times;360 px 이상의 해상도를 가진다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;723&quot; data-start=&quot;643&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;즉, 영상의 상태와 관계없이 썸네일 요청은 성공하지만 응답으로 내려오는 이미지의 크기만으로도 정상 여부를 구분할 수 있는 신호가 존재했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;723&quot; data-start=&quot;643&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;816&quot; data-start=&quot;725&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이것을 바탕으로 API를 호출하지 않고도 이미지 로딩 과정에서 크기만 확인하면 삭제, 비공개 여부를 판단할 수 있지 않을까라는 가설을 세우게 되었다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;3. 네트워크 문제 가능성 검토&lt;/span&gt;&lt;/h2&gt;
&lt;p data-end=&quot;259&quot; data-start=&quot;174&quot; data-ke-size=&quot;size16&quot;&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;/p&gt;
&lt;p data-end=&quot;437&quot; data-start=&quot;261&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;결론부터 말하자면 그 가능성은 낮다고 판단했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;437&quot; data-start=&quot;261&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&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;/p&gt;
&lt;p data-end=&quot;437&quot; data-start=&quot;261&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;535&quot; data-start=&quot;439&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;즉, 브라우저가 120&amp;times;90 크기의 이미지를 정상적으로 수신했다는 것은&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;네트워크 오류의 결과가 아니라, 유튜브 서버가 의도적으로 해당 이미지를 반환했다는 의미에 가깝다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;651&quot; data-start=&quot;537&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그래서 썸네일이 120&amp;times;90으로 확인되는 경우는 단순한 통신 문제라기보다 영상이 삭제되었거나 비공개 상태임을 나타내는 의미 있는 신호로 판단할 수 있다고 생각했다.&lt;/span&gt;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;4. 브라우저를&amp;nbsp; 활용하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 방식의 핵심은 서버가 고생하는 대신, 사용자의 브라우저 성능을 빌려 실시간으로 데이터를&amp;nbsp;정제하는 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;사용자가 목록 페이지를 스크롤하는 순간 각 카드의 썸네일이 로드되고 그때 영상의 상태를 검토한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1769701873013&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// (화면)
// 썸네일 감지 로직

// 삭제, 비공개 영상 감지 API 호출
const reportUnavailableContent = async (contentId: number) =&amp;gt; {
    try {
        await fetch(`/api/v1/contents/${contentId}/report-unavailable`, {
            method: 'POST',
            credentials: 'include',
        });
    } catch (error) {
        console.error('Failed to report unavailable content:', error);
    }
};

// 썸네일 로드 완료 시 크기 체크
const handleThumbnailLoad = (event: React.SyntheticEvent&amp;lt;HTMLImageElement&amp;gt;) =&amp;gt; {
    const { naturalWidth, naturalHeight } = event.currentTarget;
    
    // 유튜브 삭제/비공개 placeholder 크기: 120x90
    if (naturalWidth === 120 &amp;amp;&amp;amp; naturalHeight === 90) {
        // 서버에 비동기 호출
        reportUnavailableContent(content.id);
        // 즉시 화면에서 숨김 처리
        setIsVisible(false);
    }
};

// 숨겨진 카드는 렌더링하지 않음
if (!isVisible) return null;

return (
    &amp;lt;article&amp;gt;
        &amp;lt;Image
            src={content.thumbnailUrl}
            onLoad={handleThumbnailLoad}
            // ...
        /&amp;gt;
    &amp;lt;/article&amp;gt;
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;사용자 입장에서는 평소처럼 유튜브 영상 목록을 조회하지만 백그라운드에서는 영상이 자동으로 감지되어 서버를 호출하고 해당 유튜브 영상은 즉시 화면에서 사라진다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1769702004166&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// (서버)
// ContentEntity.java - 상태 필드

@Enumerated(EnumType.STRING)
@Column(length = 20, nullable = false)
@Builder.Default
private ContentStatus status = ContentStatus.ACTIVE;

// ContentStatus.java
public enum ContentStatus {
    ACTIVE,  // 정상
    HIDDEN   // 삭제/비공개 감지되어 숨김 처리된 영상
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1769702010450&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// (서버)
// ContentService.java - 숨김 처리 로직

@Transactional
public boolean hideContent(Long id) {
    return contentRepository.findById(id)
            .map(entity -&amp;gt; {
                if (entity.getStatus() == ContentStatus.HIDDEN) {
                    return false; // 이미 HIDDEN이면 스킵
                }
                
                entity.setStatus(ContentStatus.HIDDEN);
                contentRepository.save(entity);
                
                // Elasticsearch에서도 삭제 (검색 결과에서 제외)
                esContentRepository.deleteById(id);
                
                return true;
            })
            .orElse(false);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이후 목록 조회나 검색시에는 ACTIVE 상태인 콘텐츠만 필터링해서 반환해 준다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1769702117681&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// (서버)

// 조회 시 ACTIVE 상태만 필터링
private Specification&amp;lt;ContentEntity&amp;gt; buildSpecification(...) {
    return (root, query, cb) -&amp;gt; {
        List&amp;lt;Predicate&amp;gt; predicates = new ArrayList&amp;lt;&amp;gt;();
        
        // HIDDEN 콘텐츠 제외
        predicates.add(cb.equal(root.get(&quot;status&quot;), ContentStatus.ACTIVE));
        
        // ... 기타 필터 조건
        return cb.and(predicates.toArray(new Predicate[0]));
    };
}

// 상세 조회 시에도 HIDDEN이면 404 반환
public ContentDto getContentById(Long id) {
    return contentRepository.findById(id)
            .filter(entity -&amp;gt; entity.getStatus() != ContentStatus.HIDDEN)
            .map(this::toDto)
            .orElseThrow(() -&amp;gt; new ResponseStatusException(
                    HttpStatus.NOT_FOUND, &quot;Content not found: &quot; + id
            ));
}&lt;/code&gt;&lt;/pre&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;span style=&quot;color: #333333;&quot;&gt;5. 더 안전한 운영을 위한 설계 &lt;/span&gt;&lt;/h2&gt;
&lt;p data-end=&quot;303&quot; data-start=&quot;191&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;앞선 방식으로 영상의 상태를 비교적 높은 신뢰도로 판단할 수 있었지만, 그렇다고 감지 즉시 데이터를 삭제하는 것은 여전히 리스크가 있다고 보았다. 오탐 가능성을 완전히 배제할 수 없기 때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;361&quot; data-start=&quot;305&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;그래서 최종적으로 데이터를 삭제하기 전 한번 더 체크할 수 있는 구조를 생각했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;361&quot; data-start=&quot;305&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;494&quot; data-start=&quot;363&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;- 즉시 숨김 처리&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;썸네일이 120 &amp;times;90으로 감지되면, 해당 영상의 status를 DB에서 즉시 HIDDEN으로 변경한다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 단계에서 다른 사용자에게는 더 이상 노출되지 않는다. 하지만 데이터 자체는 삭제하지 않고 보존해 준다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;496&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;619&quot; data-start=&quot;496&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;- 재검증&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;743&quot; data-start=&quot;621&quot; data-ke-size=&quot;size16&quot;&gt;HIDDEN 상태로 분류된 영상들만 별도로 모아서, 필요한 경우 주기적으로 유튜브 API를 통해 최종 상태를 확인할 수 있다.&lt;/p&gt;
&lt;p data-end=&quot;743&quot; data-start=&quot;621&quot; data-ke-size=&quot;size16&quot;&gt;실제로 삭제되거나 비공개 처리된 영상만 영구 삭제 대상으로 확정하는 방식이다.&lt;/p&gt;
&lt;p data-end=&quot;743&quot; data-start=&quot;621&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;다만 현재 운영 중인 시스템에서는 오탐률이 낮다고 생각되었다.&lt;/p&gt;
&lt;p data-end=&quot;743&quot; data-start=&quot;621&quot; data-ke-size=&quot;size16&quot;&gt;그래서 별도의 검증 배치 없이 HIDDEN 처리만으로도 충분히 안정적으로 운영할 수 있다고 판단이 들었다.&lt;/p&gt;
&lt;p data-end=&quot;743&quot; data-start=&quot;621&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;743&quot; data-start=&quot;621&quot; data-ke-size=&quot;size16&quot;&gt;만약 오탐이 발생하더라도 데이터가 삭제된 것이 아니라 숨김 처리된 것이기 때문에,&lt;/p&gt;
&lt;p data-end=&quot;743&quot; data-start=&quot;621&quot; data-ke-size=&quot;size16&quot;&gt;필요하면 언제든 복구할 수 있다는 점도 이 구조의 장점이다.&lt;/p&gt;
&lt;p data-end=&quot;743&quot; data-start=&quot;621&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이 구조를 통해 전체 데이터를 주기적으로 확인하는 방식과 비교해 API 호출 수를 99.5% 이상 줄일 수 있었고 오탐으로 인한 데이터 손실 위험도 최소화할 수 있었다.&lt;/p&gt;
&lt;p data-end=&quot;800&quot; data-start=&quot;745&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;800&quot; data-start=&quot;745&quot; data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;6. 데이터 10만 건 기준 운영 비용 비교&lt;/span&gt;&lt;/h2&gt;
&lt;p data-end=&quot;996&quot; data-start=&quot;880&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 접근이 실제로 얼마나 효과가 있을지 가정해보기 위해,&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;996&quot; data-start=&quot;880&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;YouTube Data API v3의 무료 할당량을 기준으로 10만 개의 영상을 운영하는 상황을 가정해 비교해 보았다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1049&quot; data-start=&quot;998&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;(가정: 사용자 1,000명, 1인당 하루 평균 10개 영상 조회, 삭제/비공개 비율 5%)&lt;/span&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-end=&quot;1450&quot; data-start=&quot;1051&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody data-end=&quot;1450&quot; data-start=&quot;1194&quot;&gt;
&lt;tr data-end=&quot;1255&quot; data-start=&quot;1194&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1202&quot; data-start=&quot;1194&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;핵심 로직&lt;/span&gt;&lt;/td&gt;
&lt;td data-end=&quot;1217&quot; data-start=&quot;1202&quot; data-col-size=&quot;sm&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;매일 전체 API 호출&lt;/span&gt;&lt;/td&gt;
&lt;td data-end=&quot;1235&quot; data-start=&quot;1217&quot; data-col-size=&quot;sm&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;사용자 요청 시 API 호출&lt;/span&gt;&lt;/td&gt;
&lt;td data-end=&quot;1255&quot; data-start=&quot;1235&quot; data-col-size=&quot;sm&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이미지 감지 + 주 1회 배치&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1297&quot; data-start=&quot;1256&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1268&quot; data-start=&quot;1256&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;일일 API 호출&lt;/span&gt;&lt;/td&gt;
&lt;td data-end=&quot;1279&quot; data-start=&quot;1268&quot; data-col-size=&quot;sm&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;약 2,000회&lt;/span&gt;&lt;/td&gt;
&lt;td data-end=&quot;1291&quot; data-start=&quot;1279&quot; data-col-size=&quot;sm&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;약 10,000회&lt;/span&gt;&lt;/td&gt;
&lt;td data-end=&quot;1297&quot; data-start=&quot;1291&quot; data-col-size=&quot;sm&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;0회&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1350&quot; data-start=&quot;1298&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1310&quot; data-start=&quot;1298&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;일일 할당량 소모&lt;/span&gt;&lt;/td&gt;
&lt;td data-end=&quot;1324&quot; data-start=&quot;1310&quot; data-col-size=&quot;sm&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;2,000 Units&lt;/span&gt;&lt;/td&gt;
&lt;td data-end=&quot;1339&quot; data-start=&quot;1324&quot; data-col-size=&quot;sm&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;10,000 Units&lt;/span&gt;&lt;/td&gt;
&lt;td data-end=&quot;1350&quot; data-start=&quot;1339&quot; data-col-size=&quot;sm&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;0 Units&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1413&quot; data-start=&quot;1351&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1363&quot; data-start=&quot;1351&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;월간 할당량 소모&lt;/span&gt;&lt;/td&gt;
&lt;td data-end=&quot;1380&quot; data-start=&quot;1363&quot; data-col-size=&quot;sm&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;약 60,000 Units&lt;/span&gt;&lt;/td&gt;
&lt;td data-end=&quot;1398&quot; data-start=&quot;1380&quot; data-col-size=&quot;sm&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;약 300,000 Units&lt;/span&gt;&lt;/td&gt;
&lt;td data-end=&quot;1413&quot; data-start=&quot;1398&quot; data-col-size=&quot;sm&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;약 280 Units&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr data-end=&quot;1450&quot; data-start=&quot;1414&quot;&gt;
&lt;td data-col-size=&quot;sm&quot; data-end=&quot;1423&quot; data-start=&quot;1414&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;운영 가능성&lt;/span&gt;&lt;/td&gt;
&lt;td data-end=&quot;1429&quot; data-start=&quot;1423&quot; data-col-size=&quot;sm&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;제한적&lt;/span&gt;&lt;/td&gt;
&lt;td data-end=&quot;1439&quot; data-start=&quot;1429&quot; data-col-size=&quot;sm&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;사실상 불가능&lt;/span&gt;&lt;/td&gt;
&lt;td data-end=&quot;1450&quot; data-start=&quot;1439&quot; data-col-size=&quot;sm&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;충분히 안정적&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;h3 data-end=&quot;1516&quot; data-start=&quot;1480&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;6-1. 사용량 증가가 비용 증가로 이어지는 구조의 위험성&lt;/span&gt;&lt;/h3&gt;
&lt;p data-end=&quot;1655&quot; data-start=&quot;1518&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;클릭 시 API를 호출하는 방식은 초기에는 단순해 보이지만 사용자가 늘어날수록 API 소모량이 그대로 증가한다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;사용자 1,000명만으로도 무료 할당량 한계에 도달한다는 점에서 장기적으로는 적합하지 않은 구조라고 판단했다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-end=&quot;1683&quot; data-start=&quot;1657&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;6-2. 최소 호출로 최대 효과를 내는 구조&lt;/span&gt;&lt;/h3&gt;
&lt;p data-end=&quot;1777&quot; data-start=&quot;1685&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이미지 감지 방식을 적용하면 일반적인 서비스 이용 과정에서는 API를 전혀 사용하지 않는다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;일주일간 누적된 삭제 의심 영상만 모아 검증하면 되기 때문이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1777&quot; data-start=&quot;1685&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;1777&quot; data-start=&quot;1685&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;예를 들어 하루 500건의 의심 데이터가 쌓인다고 가정하더라도 주 1회 검증 시 필요한 API 호출은 약 70회에 불과하다. &lt;/span&gt;&lt;span style=&quot;color: #333333;&quot;&gt;결과적으로 월 수십만 유닛이 필요하던 구조를 수백 유닛 수준으로 줄일 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;1777&quot; data-start=&quot;1685&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-end=&quot;1928&quot; data-start=&quot;1903&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;6-3. 사용자 경험과 운영 안정성의 균형&lt;/span&gt;&lt;/h3&gt;
&lt;p data-end=&quot;2024&quot; data-start=&quot;1930&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;감지 즉시 HIDDEN 처리하는 방식은 사용자에게는 죽은 링크를 노출하지 않는다는 장점이 있고,&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;시스템 입장에서는 실시간 API 호출 부담을 제거할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-end=&quot;2093&quot; data-start=&quot;2026&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;이 구조를 통해 비용을 최소화하면서도 데이터 정합성을 유지하는 시스템을 구성할 수 있었다.&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;425&quot; data-origin-height=&quot;750&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ealgnU/dJMb996itPs/Agjxv2lIIBHDb46AA1Jpo1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ealgnU/dJMb996itPs/Agjxv2lIIBHDb46AA1Jpo1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ealgnU/dJMb996itPs/Agjxv2lIIBHDb46AA1Jpo1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FealgnU%2FdJMb996itPs%2FAgjxv2lIIBHDb46AA1Jpo1%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;425&quot; height=&quot;750&quot; data-origin-width=&quot;425&quot; data-origin-height=&quot;750&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;7. 결론&lt;/span&gt;&lt;/h2&gt;
&lt;p data-end=&quot;565&quot; data-start=&quot;428&quot; data-ke-size=&quot;size16&quot;&gt;이번 문제를 해결하면서 느낀 점은 항상 공식 문서나 정석적인 접근만이 유일한 해답은 아니라는 것이었다. &lt;br /&gt;처음에는 API 할당량을 어떻게 늘릴 수 있을지에만 집중했다.&lt;/p&gt;
&lt;p data-end=&quot;565&quot; data-start=&quot;428&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;565&quot; data-start=&quot;428&quot; data-ke-size=&quot;size16&quot;&gt;하지만 시선을 조금 바꿔 실제로 돌아가는 동작을 관찰해 보니 이미지의 크기라는 비교적 단순한 신호만으로도 영상의 상태를 구분할 수 있었다. 그 결과 추가 비용 없이도 문제를 해결할 수 있는 방법을 찾을 수 있었다.&lt;/p&gt;
&lt;p data-end=&quot;565&quot; data-start=&quot;428&quot; data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이번 경험을 통해 좋은 설계란 단순히 기능을 구현하는 것이 아니라 주어진 제약 조건 안에서 어떤 신호를 활용할 수 있는지 판단하고 비용과 리스크를 함께 고려해 현실적인 해결방안을 선택하는 과정이라는 점을 다시 한번 깨닫게 되었다.&lt;/p&gt;</description>
      <category>[데브노트] 웹 프로젝트 (devnote.kr)</category>
      <category>MSA</category>
      <category>Next.js</category>
      <category>spring boot</category>
      <category>youtube api</category>
      <category>데이터</category>
      <category>백엔드</category>
      <category>백엔드 개발</category>
      <category>스프링</category>
      <category>스프링 부트</category>
      <category>트러블 슈팅</category>
      <author>남건욱</author>
      <guid isPermaLink="true">https://ngwdeveloper.tistory.com/209</guid>
      <comments>https://ngwdeveloper.tistory.com/209#entry209comment</comments>
      <pubDate>Fri, 30 Jan 2026 01:11:09 +0900</pubDate>
    </item>
    <item>
      <title>[JPA] DTO 없이 엔티티로 응답하면 안될까?</title>
      <link>https://ngwdeveloper.tistory.com/208</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;JPA로 개발을 하다 보면 repository.findById()로 엔티티를 조회하고 서비스 계층에서 DTO로 변환하는 코드를 습관처럼 작성한다.&lt;/p&gt;
&lt;pre id=&quot;code_1762410671769&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@GetMapping(&quot;/users/{id}&quot;)
public UserResponseDto getUserById(@PathVariable Long id) {
    return userService.findUser(id); // 서비스가 DTO를 반환
}&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;/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;&quot;그냥 엔티티를 반환하면 편한데 굳이 DTO를 만들어야 하나?&quot;&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;물론 지금은 &quot;당연히 안 되지&quot;라고 생각하겠지만 &quot;내가 생각하지 못했던 다른 이유가 더 있지는 않을까&quot; 라는 생각이 들어 찾아보게 되었다. 엔티티를 직접 반환하면 정확히 어떤 문제가 발생하는지 알아보자.&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;1. 계층 분리의 핵심&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링의 계층형 아키텍처(Controller - Service - Repository)는 각자의 책임이 명확히 분리되어 있다. DTO는 계층 사이, 특히 외부와 내부사이의 데이터를 운반하는 객체다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티는 DB 테이블과 1:1로 매핑되는 말 그대로 데이터베이스 그 자체다. 반면 DTO는 API의 명세서다. 많은 개발자가 이 둘을 혼용하면서 문제가 시작된다. 엔티티를 API 응답으로 쓴다는 것은 DB 스키마와 API 명세가 하나로 합쳐진다는 뜻이다.&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;엔티티 직접 반환의 위험성을 이해하기 위해 이 방식이 적용되지 않은 DTO의 필요성을 확인해보자.&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.1. 예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티를 API 응답으로 그대로 반환하는 것은 편의점 알바생이 &quot;나이 확인을 위해 신분증 보여주세요&quot;라고 하자, 내 지갑을 통째로 넘겨버리는 것과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알바생(클라이언트)은 이름과 생년월일만 확인하면 된다. 하지만 알바생은 내 지갑(엔티티)을 통해 아래 정보를 전부 알게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 민감 정보: 내 주민등록번호 뒷자리, 카드 비밀번호 메모 (password, socialSecurityNumber 등)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 불필요한 정보: 내 주소, 명함, 현금 보유액(address, internalId, userLevel 등)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 관계: 내 가족사진 (@OneToMany 연관 엔티티)&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;- 강한 결합: 내가 지갑(엔티티) 구조를 바꾸면 알바생(클라이언트)도 내 지갑을 보는 방식을 바꿔야 한다. 또한 API 명세가 DB 스키마에 종속된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 보안 취약성: 알바생이 알아서는 안 될 민감 정보(password)까지 노출된다.&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.2. 강하게 결합된 코드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;User와 Post가 1:N 관계를 맺고 있는 상황을 코드로 구현해 보자.&lt;/p&gt;
&lt;pre id=&quot;code_1762411086676&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 회원
@Entity
public class User {
    @Id @GeneratedValue
    private Long id;
    
    private String name;
    private String email;
    private String password; // 민감 정보

    // 연관관계
    @OneToMany(mappedBy = &quot;user&quot;, fetch = FetchType.LAZY) 
    private List&amp;lt;Post&amp;gt; posts = new ArrayList&amp;lt;&amp;gt;();
    
    // ... Getters ...
}

// 게시글
@Entity
public class Post {
    @Id @GeneratedValue
    private Long id;
    private String title;

    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = &quot;user_id&quot;)
    private User user; // 상호 참조
    
    // ... Getters ...
}&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;(DTO가 없는 컨트롤러)&lt;/p&gt;
&lt;pre id=&quot;code_1762411100441&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
@RequiredArgsConstructor
public class UserController {
    private final UserRepository userRepository;

    @GetMapping(&quot;/users/{id}&quot;)
    public User getUserById(@PathVariable Long id) {
        // [문제점]
        // User 엔티티가 API 명세 그 자체가 되었다.
        return userRepository.findById(id).orElse(null);
    }
}&lt;/code&gt;&lt;/pre&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;문제점 1: JSON 변환 라이브러리가 User를 JSON으로 바꾸려다 StackOverflowError를 만난다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. Jackson이 User 객체 변환 시작&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. posts 필드 발견 -&amp;gt; Post 객체 변환 시도&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. Post 객체 내부의 user 필드 발견 -&amp;gt; User 객체 변환 시도&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. User 객체 내부의 posts 필드 발견... (무한 반복) @JsonIgnore 등으로 자리를 채울수는 있지만 엔티티가 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;문제점 2: 만약 @JsonIgnore로 무한 루프를 피했더라도 지연 로딩 때문에 문제가 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 컨트롤러가 userRepository.findById(id) 호출&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. @Transactional이 없으므로 User 엔티티를 조회한 직후 DB 세션이 종료된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 컨트롤러가 User 객체를 반환하면 Jackson이 JSON으로 변환 시작&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. user.getPosts()에 접근 (이 필드가 LAZY 로딩일 경우)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. 이 시점에는 트랜잭션이 이미 종료되어 세션이 닫혀 있다. 즉 컨트롤러 단에서 Jackson이 지연 로딩된 필드(posts)에 접근하면서 이미 닫힌 세션에서 데이터를 가져오라고 하게 되어 LazyInitializationException이 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고로 서비스 계층에 @Transactional(readOnly = true)가 걸려 있을 경우 DTO 변환까지는 세션이 열려 있으므로 이 예외는 발생하지 않는다. FetchType.EAGER로 바꾸면 이 예외는 피할 수 있지만 N+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;문제점 3: API 구조의 DB 종속&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;User 엔티티에는 password처럼 외부에 노출되면 안되는 민감 정보가 포함된다. @JsonIgnore를 깜빡하는 순간 사용자 비밀번호가 API 응답에 그대로 노출되는 사고가 발생한다. 또한 DB 컬럼명을 name에서 username으로 바꾸면 별도의 DTO 없이 엔티티를 직렬화하는 API는 그대로 영향을 받아 JSON 필드명까지 함께 바뀌게 된다. @JsonProperty 등으로 일시적으로 제어할 수는 있지만 근본적으로 DB 스키마와 API 응답이 강하게 결합된 상태다.&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;3. DTO를 통한 문제 해결&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DTO는 API 명세라는 중간계층을 도입해 이 모든 문제를 해결한다.&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;3.1. 명함만 건네기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제를 해결한 방식은 지갑을 통째로 주는 대신 '이름: 홍길동, 연락처: 010-xxxx-xxxx' 라고 필요한 정보만 적은 명함(DTO)을 건네는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 내 지갑 구조가 바뀌어도(DB 스키마 변경) 명함 정보만 유지되면 알바생(클라이언트)은 아무 문제 없이 일할 수 있다.&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;3.2. DTO를 통한 결합도 낮추기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1단계: API 명세(DTO) 정의&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 클라이언트에게 정확히 필요한 데이터만 정의한 클래스를 만든다.&lt;/p&gt;
&lt;pre id=&quot;code_1762411529186&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 1. API 응답 규격(DTO) 정의
@Getter
public class UserResponseDto {
    private String name;
    private String email;
    // (password, posts 등 민감/불필요한 정보는 아예 없음)

    // 2. 엔티티를 DTO로 변환하는 책임은 보통 DTO나 서비스 계층이 가진다.
    public UserResponseDto(User user) {
        this.name = user.getName();
        this.email = user.getEmail();
    }
}&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;2단계: 서비스 계층에서 변환 및 반환&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 컨트롤러는 엔티티를 몰라도 된다. 서비스 계층이 엔티티 조회와 DTO 변환을 모두 책임진다.&lt;/p&gt;
&lt;pre id=&quot;code_1762411557053&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Service
@Service
@RequiredArgsConstructor
public class UserService {
    private final UserRepository userRepository;

    @Transactional(readOnly = true) // Lazy 로딩을 위해
    public UserResponseDto findUser(Long id) {
        // 1. 엔티티 조회 (DB 세션 유지)
        User user = userRepository.findById(id)
                .orElseThrow(() -&amp;gt; new IllegalArgumentException(&quot;User not found&quot;));
        
        // 2. 엔티티 -&amp;gt; DTO 변환 (세션이 살아있을 때 필요한 데이터 조회)
        return new UserResponseDto(user);
    }
}

// Controller
@RestController
@RequiredArgsConstructor
public class UserController {
    private final UserService userService;

    @GetMapping(&quot;/users/{id}&quot;)
    public UserResponseDto getUserById(@PathVariable Long id) {
        // 1. 서비스에게 DTO를 요청
        // 2. 받은 DTO를 그대로 반환
        return userService.findUser(id);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;UserService는 이제 비즈니스 로직과 API 응답 준비라는 책임에만 집중한다. 엔티티는 DB와의 통신에만 사용되며 컨트롤러 밖으로 나가지 않는다.&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;4. DTO 변환&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DTO를 사용하기로 결정했다면 어떻게 변환할지 효율적인 방법을 선택해야 한다.&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.1. (기본) 생성자 또는 빌더 패턴&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 예시처럼 서비스 계층에서 new UserResponseDto(user)를 호출하는 가장 단순하고 명확한 방법이다. 대부분의 경우 이 방법으로 충분하며 코드가 명시적이다.&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.2. (최적화) DTO 프로젝션&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조회 성능에 더 민감한 경우 아예 DB에서부터 DTO 형태로 조회하는 DTO 프로젝션을 사용할 수 있다. DTO 프로젝션은 필요한 컬럼만 조회해 전송량을 줄여주지만 연관 엔티티를 함께 접근할 경우 N+1 문제는 여전히 발생할 수 있다. 따라서 성능 최적화를 위해서는 fetch join이나 @BatchSize 설정 등을 함께 사용하는 것이 좋다.&lt;/p&gt;
&lt;pre id=&quot;code_1762411657135&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Repository
@Query(&quot;SELECT new com.example.dto.UserResponseDto(u.name, u.email) &quot; +
       &quot;FROM User u WHERE u.id = :id&quot;)
Optional&amp;lt;UserResponseDto&amp;gt; findUserDtoById(@Param(&quot;id&quot;) Long id);

// Service
public UserResponseDto findUser(Long id) {
    return userRepository.findUserDtoById(id)
            .orElseThrow(() -&amp;gt; new IllegalArgumentException(&quot;User not found&quot;));
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리하자면 DTO를 도입하는것은 단순한 개인 선택의 문제가 아니라 구조적 안정성과 확장성을 확보하기 위한 설계라고 생각한다. DTO를 통해 아래와 같은 이점을 얻을 수 있다.&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;- 관심사의 분리: 엔티티(DB 영속성)와 DTO(API 응답)의 책임을 명확히 분리한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 낮은 결합도: API 명세가 DB 스키마 변경에 영향을 받지 않는다. (API 안정성)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 보안성 확보: password 같은 민감 정보 노출을 차단한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 오류 방지: 무한 루프나 LazyInitializationException 같은 JPA의 예외를 컴파일 시점에 방지할 수 있다.&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;결국 DTO의 사용은 단순히 귀찮은 추가 작업이 아니라 안정적이고 유지보수가 가능한 설계를 하기 위해 필요한 핵심 요소라고 생각한다.&lt;/p&gt;</description>
      <category>공부메모 &amp;amp; 오류해결/Spring Boot</category>
      <category>dto</category>
      <category>Entity</category>
      <category>entity vs dto</category>
      <category>JPA</category>
      <category>LazyInitializationException</category>
      <category>spring</category>
      <category>spring boot</category>
      <category>spring boot dto</category>
      <category>Spring boot JPA</category>
      <category>아키텍처</category>
      <author>남건욱</author>
      <guid isPermaLink="true">https://ngwdeveloper.tistory.com/208</guid>
      <comments>https://ngwdeveloper.tistory.com/208#entry208comment</comments>
      <pubDate>Fri, 7 Nov 2025 19:52:07 +0900</pubDate>
    </item>
    <item>
      <title>[JPA] N+1 문제의 원인과 3가지 해결 방안</title>
      <link>https://ngwdeveloper.tistory.com/207</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;JPA는 SQL을 직접 작성하지 않고도 데이터베이스 작업을 처리할 수 있게 해주는 기술이다. repository.findById(1L) 같은 간단한 메서드 호출만으로도 복잡한 SQL이 실행되어 객체를 가져다준다. 하지만 이 편리함 뒤에는 'N+1 문제'라는 함정이 숨겨져 있다. 당연하게 사용하던 JPA가 어떻게 데이터베이스에 과부하를 주는지, 이 문제를 어떻게 해결할 수 있는지 비유를 통해 알아보자.&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;1. N+1 문제란 무엇인가?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;N+1 문제는 JPA를 통해 연관 관계가 설정된 엔티티를 조회할 때 발생하는 대표적인 성능 문제다. 이름 그대로 처음 1번의 쿼리로 원하는 엔티티 목록을 가져왔지만, 이 엔티티들이 각자 연관된 하위 엔티티에 접근할 때마다 추가적인 쿼리가 N번 발생하는 현상을 말한다.&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;N+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;(N+1 상황: 비효율적인 주문)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 총무팀 직원이 식당에 가서 &quot;우리 팀 5명(N=5) 식사 주문할게요&quot;라고 요청한다. (1번의 쿼리)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 식당은 &quot;알겠습니다&quot; 하고 일단 5개의 메인 메뉴만 준비해서 가져다준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 그런데 5명의 팀원이 각자 자기 메뉴를 받고 나서야, &quot;저 숟가락이 없네요&quot;, &quot;저도 젓가락이 없어요&quot; 라고 개별적으로(N번) 식당에 요청하기 시작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 식당은 팀원 1에게 숟가락을 가져다주고, 팀원 2에게 젓가락을 가져다준다. 이 과정을 5번 반복한다.&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번과 각 팀원이 수저를 요청하는 쿼리 5번, 총 1+5=6번의 요청(쿼리)을 식당(DB)에 보낸다.&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.2. 문제 상황&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 상황을 팀과 멤버의 1:N 관계로 구현해 보자. Team 엔티티는 List&amp;lt;Member&amp;gt;를 가지고 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1762307944484&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Team 엔티티
@Entity
public class Team {
    @Id @GeneratedValue
    private Long id;
    private String name;

    // 1:N 관계 Lazy Loading(지연 로딩)
    @OneToMany(mappedBy = &quot;team&quot;)
    private List&amp;lt;Member&amp;gt; members = new ArrayList&amp;lt;&amp;gt;();
    // ... getters
}

// Member 엔티티
@Entity
public class Member {
    @Id @GeneratedValue
    private Long id;
    private String name;

    @ManyToOne
    private Team team;
    // ...
}&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;/p&gt;
&lt;pre id=&quot;code_1762307978389&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// N+1 문제가 발생하는 로직
@Service
@Transactional
public class TeamService {
    private final TeamRepository teamRepository;
    // ... 생성자 ...

    public void printAllTeamMembers() {
        // 1. [쿼리 1번 발생] 모든 팀 조회
        // (예: 5개의 팀이 조회됨)
        // SELECT * FROM team;
        List&amp;lt;Team&amp;gt; teams = teamRepository.findAll();

        // 2. [쿼리 N번 발생] 각 팀의 멤버 목록에 접근
        for (Team team : teams) {
            System.out.println(&quot;팀: &quot; + team.getName());
            
            // 3. (지연 로딩) 실제 members 리스트에 접근하는 순간,
            //    팀별로 멤버를 조회하는 추가 쿼리 발생
            // SELECT * FROM member WHERE team_id = 1; (team A)
            // SELECT * FROM member WHERE team_id = 2; (team B)
            // SELECT * FROM member WHERE team_id = 3; (team C)
            // SELECT * FROM member WHERE team_id = 4; (team D)
            // SELECT * FROM member WHERE team_id = 5; (team E)
            for (Member member : team.getMembers()) {
                System.out.println(&quot;  멤버: &quot; + member.getName());
            }
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;findAll()로 팀을 조회하는 쿼리 1번과 5개의 팀에 대한 멤버를 조회하는 쿼리 5번이 추가로 발생하여 총 6번의 쿼리가 실행된다. 만약 팀이 100개라면 1+100=101번의 쿼리가 데이터베이스를 조회를 요청하게 된다.&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.3. 문제 원인&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 문제는 JPA의 @OneToMany나 @ManyToMany의 기본 fetch 설정인 지연 로딩 때문에 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지연 로딩은 엔티티를 조회할 때 연관된 엔티티를 즉시 가져오지 않고 team.getMembers()처럼 실제로 해당 엔티티에 접근하는 시점에 SQL을 실행해 가져오는 전략이다. 이는 당장 필요하지 않은 데이터까지 모두 불러오는 낭비를 막기 위한 효율적인 방법이지만&amp;nbsp;위 예시처럼 연관 데이터를 반복문 안에서 사용할 경우 N+1 문제를 유발하는 주된 이유가 된다.&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. N+1 문제 해결 방안&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;N+1 문제는 연관된 엔티티를 '어떻게 한 번에 잘 가져올 것인가'의 문제다. 식당 비유로 돌아가서 비효율적인 주문을 개선해 보자.&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.1. 해결책 1: Fetch Join&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 고전적이고 확실한 방법이다. JPQL을 사용하여 조인 쿼리를 명시적으로 작성하는 것이다.&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;비유: &quot;수저까지 미리 챙겨주세요&quot;&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;총무팀 직원이 주문할 때 &quot;우리 팀 5명 메인 메뉴 5개 주시고요, 모두 수저 세트도 같이 챙겨서 한 번에 가져다주세요&quot;라고 명확하게 요청한다. 식당은 1번의 요청(쿼리)으로 메인 메뉴 5개와 수저 5세트를 모두 준비해 한 번에 서빙한다.&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;코드 구현: TeamRepository에 JPQL을 직접 작성한다.&lt;/p&gt;
&lt;pre id=&quot;code_1762315477461&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// TeamRepository.java
public interface TeamRepository extends JpaRepository&amp;lt;Team, Long&amp;gt; {

    // JPQL을 사용해 Team과 Member를 함께 가져온다.
    @Query(&quot;SELECT t FROM Team t JOIN FETCH t.members&quot;)
    List&amp;lt;Team&amp;gt; findAllWithMembers();
}

// TeamService.java
public void printAllTeamMembers() {
    // 1. [쿼리 1번 발생]
    // SELECT ... FROM team t INNER JOIN member m ON t.id = m.team_id;
    // 단 한 번의 조인 쿼리로 Team과 Member 데이터를 모두 가져온다.
    List&amp;lt;Team&amp;gt; teams = teamRepository.findAllWithMembers();

    for (Team team : teams) { // (추가 쿼리 발생 안 함)
        System.out.println(&quot;팀: &quot; + team.getName());
        for (Member member : team.getMembers()) { // (추가 쿼리 발생 안 함)
            System.out.println(&quot;  멤버: &quot; + member.getName());
        }
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점: 강력하고 확실하다. 원하는 시점에 원하는 데이터만 골라서 조인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단점: JPQL을 직접 작성해야 하는 번거로움이 있다. 1:N 관계에서 Fetch Join을 사용하면 데이터가 뻥튀기되거나 중복될 수 있으며 페이징 처리 시 문제가 발생할 수 있다. (JPA는 경고 로그를 띄우며 메모리에서 페이징을 시도한다.)&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.2. 해결책 2: @EntityGraph&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPQL 작성 없이 필요한 연관 관계를 즉시 로딩하도록 지정하는 어노테이션이다.&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;비유: &quot;단체 주문서 옵션 체크&quot;&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;총무팀 직원이 단체 주문서(Repository 메서드)에 있는 수저 포함'옵션에 체크(@EntityGraph)만 한다. 식당(JPA)이 이 옵션을 보고 알아서 수저를 챙겨다 준다.&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;코드 구현: Repository 메서드에 @EntityGraph를 추가하고 함께 가져올 필드 이름을 attributePaths에 지정한다.&lt;/p&gt;
&lt;pre id=&quot;code_1762315550698&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// TeamRepository.java
public interface TeamRepository extends JpaRepository&amp;lt;Team, Long&amp;gt; {

    // findAll 메서드를 실행할 때 members 속성은 EAGER처럼 가져오게 함
    @EntityGraph(attributePaths = {&quot;members&quot;})
    @Query(&quot;SELECT t FROM Team t&quot;) // JPQL은 단순하게 유지 (JpaRepository의 findAll()에도 적용 가능)
    List&amp;lt;Team&amp;gt; findAllWithMembersUsingEntityGraph();

    // JpaRepository 기본 메서드에도 적용 가능
    @Override
    @EntityGraph(attributePaths = {&quot;members&quot;})
    List&amp;lt;Team&amp;gt; findAll();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;장점: JPQL 없이 깔끔하게 조인을 적용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단점: Fetch Join과 마찬가지로 페이징 처리 시 문제가 발생할 수 있다.&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.3. 해결책 3: 배치 사이즈&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;N+1 자체를 없애는 것이 아니라 N번의 추가 쿼리를 한 번 또는 몇 번의 IN 쿼리로 최적화하는 방식이다.&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;비유: &quot;수저 주문 모아서 한 번에 가져오기&quot;&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. 총무팀이 메인 메뉴 5개를 주문한다. (쿼리 1)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 팀원 5명이 각자 수저를 요청한다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 식당 직원이 &quot;아, 5명이 다 수저가 필요하구나&quot;라고 인지하고, 주방에 가서 5개의 수저 세트를 한 번에 트레이(IN 절)에 담아 가져온다. (쿼리 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;코드 구현: application.yml (또는 properties)에 글로벌 설정을 추가한다.&lt;/p&gt;
&lt;pre id=&quot;code_1762315599092&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;spring:
  jpa:
    properties:
      hibernate:
        default_batch_fetch_size: 100 # (100 ~ 1000 사이의 값을 권장)&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;이 설정을 추가하면, team.getMembers()로 지연 로딩이 발생하는 첫 시점에 JPA는 나머지 Team 객체들의 ID를 모아서 IN 절 쿼리를 날린다.&lt;/p&gt;
&lt;pre id=&quot;code_1762315655442&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;-- 1. findAll() 쿼리
SELECT * FROM team; (Team A, B, C, D, E 반환)

-- 2. Team A의 getMembers() 호출 시
-- (batch_size 만큼 ID를 모아서 IN 쿼리를 날림)
SELECT * FROM member WHERE team_id IN (1, 2, 3, 4, 5);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 100개의 팀이 있더라도 쿼리는 단 2번(1 + 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;장점: 코드를 전혀 수정하지 않고 설정 하나로 N+1 문제를 꽤나 효과적으로 해결한다. 지연 로딩의 이점을 살리면서 페이징 처리에도 문제가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단점: 조인으로 가져오는 것보다 쿼리가 1번 더 나간다. (1+1)&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;3. (비권장) 즉시 로딩은 왜 답이 아닐까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;N+1 문제를 피하기 위해 FetchType을 EAGER로 설정하는 방법도 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1762316234083&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Team.java
@OneToMany(mappedBy = &quot;team&quot;, fetch = FetchType.EAGER) // 비권장
private List&amp;lt;Member&amp;gt; members = new ArrayList&amp;lt;&amp;gt;();&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비유: &quot;묻지도 따지지도 않고 항상 풀세트&quot;&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;총무팀 직원이 몇 명을 주문하든, 심지어 &quot;커피 1잔만 주세요&quot;라고 해도 식당이 &quot;저희는 무조건 메인 메뉴와 수저 풀세트를 함께 드려요&quot;라며 항상 모든 것을 다 가져다준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EAGER 전략은 해당 엔티티를 조회할 때 항상 연관된 엔티티를 조인해서 가져온다. N+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;- 성능 저하: Team 정보만 필요한 순간에도 불필요한 Member 조인 쿼리가 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 예측 불가능성: 여러 EAGER 관계가 얽히면 개발자가 의도하지 않은 조인 쿼리가 발생하여 애플리케이션의 전체적인 성능을 망가뜨릴 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 유연성 부재: N+1은 Fetch Join 등으로 해결여부를 선택할 수 있지만 EAGER는 선택의 여지가 없다.&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;4. 결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JPA의 N+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;- 관심사 분리: JPA를 사용하더라도 실행되는 SQL에는 항상 관심을 가져야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 전략적 선택: 모든 연관 관계는 기본적으로 지연 로딩으로 설정하는 것이 원칙이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 문제 해결: N+1이 발생하는 특정 로직에는 Fetch Join이나 @EntityGraph를 사용해 즉시 로딩을 적용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이징 처리가 필요하거나 애플리케이션 전반의 N+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;DI가 객체 간의 결합도를 낮추는 설계의 핵심인것처럼 N+1 문제의 해결은 데이터 접근의 효율성을 높여주는 JPA 활용의 중요한 부분이라고 생각된다.&lt;/p&gt;</description>
      <category>공부메모 &amp;amp; 오류해결/Spring Boot</category>
      <category>EntityGraph</category>
      <category>fetch join</category>
      <category>Hibernate</category>
      <category>JPA</category>
      <category>JPA N+1</category>
      <category>lazy loading</category>
      <category>n+1</category>
      <category>성능최적화</category>
      <category>지연 로딩</category>
      <category>쿼리최적화</category>
      <author>남건욱</author>
      <guid isPermaLink="true">https://ngwdeveloper.tistory.com/207</guid>
      <comments>https://ngwdeveloper.tistory.com/207#entry207comment</comments>
      <pubDate>Thu, 6 Nov 2025 19:28:15 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] 제어의 역전(Inversion of Control, IoC) 이란 무엇일까?</title>
      <link>https://ngwdeveloper.tistory.com/206</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. DI와 IoC&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ngwdeveloper.tistory.com/205&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://ngwdeveloper.tistory.com/205&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1762304922218&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;[Spring] 의존성 주입(Dependency injection, DI) 이란 무엇일까?&quot; data-og-description=&quot;spring을 사용해 개발을 하다보면 @Autowired나 생성자로 객체를 주입받아 쓴다. 너무나 당연하게 써왔었다. 그런데 문득 궁금해졌다. &amp;quot;그냥 내가 만들어 쓰면 안되나?&amp;quot; 라는 생각이 들었다. 이 의존&quot; data-og-host=&quot;ngwdeveloper.tistory.com&quot; data-og-source-url=&quot;https://ngwdeveloper.tistory.com/205&quot; data-og-url=&quot;https://ngwdeveloper.tistory.com/205&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bhaTL2/hyZNdR5amg/ZqwUckkxwAmQgkpoBS3nxK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/QuPYq/hyZM0SKNOA/kinKzTAOMG3KdkNirNSlg0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bWN0TX/hyZNewGQBr/9bttpmffCBAb0AflrMmTYk/img.jpg?width=600&amp;amp;height=600&amp;amp;face=0_0_600_600&quot;&gt;&lt;a href=&quot;https://ngwdeveloper.tistory.com/205&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://ngwdeveloper.tistory.com/205&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bhaTL2/hyZNdR5amg/ZqwUckkxwAmQgkpoBS3nxK/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/QuPYq/hyZM0SKNOA/kinKzTAOMG3KdkNirNSlg0/img.png?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800,https://scrap.kakaocdn.net/dn/bWN0TX/hyZNewGQBr/9bttpmffCBAb0AflrMmTYk/img.jpg?width=600&amp;amp;height=600&amp;amp;face=0_0_600_600');&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;[Spring] 의존성 주입(Dependency injection, DI) 이란 무엇일까?&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;spring을 사용해 개발을 하다보면 @Autowired나 생성자로 객체를 주입받아 쓴다. 너무나 당연하게 써왔었다. 그런데 문득 궁금해졌다. &quot;그냥 내가 만들어 쓰면 안되나?&quot; 라는 생각이 들었다. 이 의존&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;ngwdeveloper.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;p data-ke-size=&quot;size16&quot;&gt;이전 글에서 DI가 객체 간의 결합도를 낮추는 기술임을 알게됐다. 이전 글 3.3 섹션에서 &quot;객체의 생성과 관계 설정의 제어권이 개발자로부터 프레임워크로 넘어간 것을 제어의 역전(IoC)이라 부르며, DI는 이 IoC를 구현하는 핵심 방식이다.&quot;라고 말했었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DI에 대해서는 이해했지만, 제어의 역전이라는 개념은 여전히 헷갈리게 느껴질 수 있다. 제어권이 정확히 어떻게 역전되었다는 것이며 이것이 어떤 의미가 있는지 알아보자.&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. IoC가 없던 시절&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IoC를 이해하는 가장 빠른 방법은 IoC가 없던 옛 프로그래밍 방식을 찾아보는것이다.&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;span style=&quot;color: #000000;&quot; data-darkreader-inline-color=&quot;&quot;&gt;2.1. 예시 (&lt;span style=&quot;text-align: start; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;내가 직접 운전하는 자동차)&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot; data-darkreader-inline-color=&quot;&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;- 시동: main 메서드에서 내가 직접 시동을 건다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 객체 생성: 운전에 필요한 엔진, 핸들 객체를 내가 직접 new 키워드로 생성한다.&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;2.2. 모든 제어권을 가진 main 메서드&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 글의 Store와 Pencil 예시를 다시 보자. 스프링 같은 프레임워크가 없다면 우리는 애플리케이션의 시작점에서 이렇게 코드를 작성해야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1762231777549&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;public interface Product { void use(); }
public class Pencil implements Product { /* ... */ }

// 상점
public class Store {
    private final Product product;
    public Store(Product product) { this.product = product; }
    public void doBusiness() { product.use(); }
}

// 애플리케이션의 시작점 (모든 제어권을 가짐)
public class Application {

    public static void main(String[] args) {
        // 1. 개발자가 Pencil이라는 구체적인 부품을 직접 선택하고 생성한다.
        Product pencil = new Pencil();

        // 2. 개발자가 Store 객체를 직접 생성하며 부품을 주입한다.
        Store store = new Store(pencil);

        // 3. 개발자가 Store의 기능을 직접 호출한다.
        store.doBusiness();
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드에서 모든 객체의 생성(new), 관계 설정(Store에 Pencil 주입), 실행(doBusiness)까지 모든 제어권은 Application.main 메서드, 즉 개발자에게 있다.&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;3. IoC의 등장&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IoC는 이 패러다임을 크게 바꿔놨다.&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;3.1&lt;span style=&quot;color: #000000;&quot; data-darkreader-inline-color=&quot;&quot;&gt;. 예시 (&lt;span style=&quot;text-align: start; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;내가 탑승한 KTX)&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IoC가 적용된 프레임워크 환경은 '내가 운전하는 자동차'가 아니라 '내가 탑승한 KTX'와 같다.&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;- 시동: 내가 KTX의 시동을 걸지 않는다. 코레일(스프링 컨테이너)이 알아서 열차를 준비시킨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 객체 생성: 나는 그저 승객(Bean)으로서 좌석에 앉아있을 뿐이다. 열차(애플리케이션) 운행에 필요한 모든 것(기관사, 전력 등)은 코레일이 알아서 배치(주입)한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 실행: 내가 &quot;출발&quot;이라고 외치지 않는다. 정해진 스케줄(프레임워크의 로직)에 따라 코레일이 열차를 출발시킨다.&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;3.2. IoC 컨테이너의 역할&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코레일의 역할을 하는 것이 바로 IoC 컨테이너(Spring Container)다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자는 Store나 Pencil 같은 객체를 @Service, @Repository 등으로 스프링 컨테이너에 등록만 한다. 그러면 IoC 컨테이너가 애플리케이션의 전반적인 제어권을 넘겨받아 다음을 수행한다.&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;- 객체의 생성: 개발자가 new Store()를 하지 않는다. 컨테이너가 설정(@Component 등)을 읽어 직접 객체(Bean)를 생성하고 초기화한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 의존성 해결: 컨테이너가 Store 객체를 만들 때 Store가 Product를 필요로 함을 인지하고, 미리 만들어 둔 Pencil 객체를 찾아 스스로 주입(DI)한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 실행: 웹 요청이 들어오면 개발자가 만든 Store 컨트롤러를 직접 호출하는 것이 아니라, 스프링의 DispatcherServlet이 요청을 받아 해당 컨트롤러의 메서드를 호출해준다.&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;h2 data-ke-size=&quot;size26&quot;&gt;4. IoC와 DI의 관계&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 IoC와 DI의 관계가 더 명확해졌다.&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;- IoC (제어의 역전): &quot;제어권을 프레임워크가 갖는다&quot;는 더 넓고 추상적인 개념이다. 객체의 생성, 생명주기 관리, 실행까지 모든 제어권이 역전되는 현상을 말한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- DI (의존성 주입): IoC를 구현하기 위한 방법 중 하나다. IoC 컨테이너가 객체들의 관계를 설정 해주는 방식에 초점을 맞춘 용어이다.&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;IoC는 DI를 포함하는 상위 개념이다. 스프링 컨테이너는 IoC 컨테이너이며 이 컨테이너가 IoC를 구현하기 위해 사용하는 핵심 메커니즘이 바로 DI 인것이다.&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;5. 결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IoC를 통해 개발자는 객체 생성, 의존성 설정, 생명주기 관리라는 복잡하고 귀찮은 제어권을 포기한다.&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 낮은 결합도: DI를 통해 객체들이 느슨하게 연결되므로(지난 글 참고), 유연하고 확장성 높은 구조를 가진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 테스트 용이성: 의존성을 쉽게 격리하고 가짜(Mock) 객체를 주입할 수 있어 단위 테스트가 용이해진다.&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;IoC는 개발자가 비즈니스 로직에 집중하도록 도와주는 모든&amp;nbsp; Spring 기술의 출발점이라고 생각된다.&lt;/p&gt;</description>
      <category>공부메모 &amp;amp; 오류해결/Spring Boot</category>
      <category>di</category>
      <category>IOC</category>
      <category>IoC 컨테이너</category>
      <category>spring</category>
      <category>spring boot</category>
      <category>Spring IoC</category>
      <category>스프링</category>
      <category>스프링부트 ioc</category>
      <category>의존성주입</category>
      <category>제어의 역전</category>
      <author>남건욱</author>
      <guid isPermaLink="true">https://ngwdeveloper.tistory.com/206</guid>
      <comments>https://ngwdeveloper.tistory.com/206#entry206comment</comments>
      <pubDate>Wed, 5 Nov 2025 19:54:46 +0900</pubDate>
    </item>
    <item>
      <title>[Spring] 의존성 주입(Dependency injection, DI) 이란 무엇일까?</title>
      <link>https://ngwdeveloper.tistory.com/205</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;spring을 사용해 개발을 하다보면 @Autowired나 생성자로 객체를 주입받아 쓴다. 너무나 당연하게 써왔었다. 그런데 문득 궁금해졌다. &quot;그냥 내가 만들어 쓰면 안되나?&quot; 라는 생각이 들었다. 이 의존성 주입이 왜 스프링의 핵심 개념인지 비유를 통해 이해해보자.&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;1. 스프링의 핵심중 하나인 DI&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스프링 프레임워크를 사용한다는 것은 의존성 주입(Dependency Injection, DI)의 개념 위에서 개발한다는 것과 같다. DI는 스프링의 3대 핵심 프로그래밍 모델 중 하나이며, 현대적인 객체지향 설계를 가능하게 하는 기반 기술이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;많은 개발자가 DI를 단순히 어노테이션(@Autowired)을 통해 객체를 받아오는 기술 정도로 이해하지만, DI의 본질은 객체 간의 결합도를 낮추고 유연성을 극대화하는 설계 패턴에 있다. DI는 정확히 무엇인지, 왜 반드시 필요한지, 그리고 스프링을 통해 어떻게 올바르게 구현하는지 알아보자.&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. DI는 왜 필요할까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DI의 필요성을 이해하기 위해, DI가 적용되지 않은 코드를 먼저 살펴보자.&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.1. 예시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DI가 없는 코드는 납땜된 일체형 컴퓨터와 같다. 컴퓨터(하나의 객체)가 그래픽카드(다른 객체)를 사용해야 할 때, 메인보드에 그래픽카드를 납땜해서 고정해 버린것과 같다.&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;/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;2.2. 강하게 결합된 클래스&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상점(Store)이 연필(Pencil)을 판매하는 상황을 코드로 구현해 보자.&lt;/p&gt;
&lt;pre id=&quot;code_1762220774772&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(DI 적용 전)

// 연필 클래스
public class Pencil {
    public void write() {
        System.out.println(&quot;연필 사용&quot;);
    }
}

// 상점 클래스
public class Store {

    // 1. Store가 Pencil이라는 구체적인 클래스에 직접 의존한다.
    private Pencil pencil;

    public Store() {
        // 2. Store가 직접 Pencil 객체를 생성한다.
        this.pencil = new Pencil();
    }

    public void doBusiness() {
        pencil.write();
    }
}&lt;/code&gt;&lt;/pre&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;- 강한 결합도: Store 클래스는 Pencil이라는 구체적인 클래스와 강하게 결합되어 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 유연성 및 확장성 저하: 만약 Store가 Pencil이 아닌 Eraser를 판매하도록 변경해야 한다면,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Store 클래스의 생성자(new Pencil())를 포함한 내부 코드 전체를 수정해야 한다. 이것은 객체지향 설계의 핵심 원칙인 OCP(개방-폐쇄 원칙)를 위반하게 된다.&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;3. DI를 통한 문제 해결&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DI는 이 납땜을 표준 슬롯 방식으로 바꾸는 해결책이다.&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;3.1. 표준 슬롯의 도입&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제를 해결한 컴퓨터는 메인보드에 PCIe라는 표준 규격 슬롯을 제공한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 제조사(NVIDIA, AMD)는 이 표준 규격에 맞는 그래픽카드를 생산한다.&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;/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;3.2. DI를 통한 결합도 낮추기&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;1단계: 표준 규격(인터페이스) 정의&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Pencile과&lt;span style=&quot;color: #000000; text-align: start; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot; data-darkreader-inline-color=&quot;&quot;&gt; Eraser&lt;/span&gt;를 합칠 수 있는 제품(Product)이라는 표준 규격을 만든다.&lt;/p&gt;
&lt;pre id=&quot;code_1762222059150&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 1. 표준 규격(인터페이스) 정의
public interface Product {
    void use();
}

// 2. 규격에 맞는 구현체(부품)들
public class Pencil implements Product {
    @Override
    public void use() {
        System.out.println(&quot;연필 사용&quot;);
    }
}

public class Eraser implements Product {
    @Override
    public void use() {
        System.out.println(&quot;지우개 사용&quot;);
    }
}&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;2단계: 슬롯을 만들고 외부에서 주입&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Store 클래스는 더 이상 Pencil 같은 구체적인 부품을 몰라도 된다. Product라는 표준 슬롯만 알면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1762222070736&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(DI 적용 후)

// 상점 클래스
public class Store {

    // 1. Product라는 표준 규격(인터페이스)에만 의존한다. (결합도 낮춤)
    private final Product product;

    // 2. 객체를 직접 생성(new)하지 않는다.
    //    외부에서 생성된 객체를 생성자를 통해 주입 받는다.
    public Store(Product product) {
        this.product = product;
    }

    public void doBusiness() {
        product.use(); // 주입받은 부품의 기능을 사용
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Store는 이제 판매 라는 자신의 핵심 책임에만 집중한다. 어떤 제품을 팔지에 대한 결정(객체 생성 및 선택)은 Store의 관심사에서 제외된다.&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;3.3. 스프링(DI 컨테이너)의 역할&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 예시에서 외부의 역할을 하는 것이 바로 스프링 컨테이너(DI Container)다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발자가 Pencil이나 Eraser 같은 부품(@Component, @Service 등)을 스프링 컨테이너에 등록해두면, 스프링은 Store가 필요로 할 때(Store의 생성자를 호출할 때) 적절한 부품을 찾아 자동으로 주입해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 객체의 생성과 관계 설정의 제어권이 개발자(Store)로부터 프레임워크(스프링)로 넘어간 것을 제어의 역전(Inversion of Control, IoC)이라고 부르며, DI는 이 IoC를 구현하는 핵심 방식이다.&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;span style=&quot;color: #000000;&quot;&gt;4. &lt;span style=&quot;text-align: start; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot; data-darkreader-inline-color=&quot;&quot;&gt;생성자 주입&lt;/span&gt;&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;-&amp;nbsp; 스프링에서 의존성을 주입하는 방법은 필드 주입, 수정자(Setter) 주입, 생성자 주입 등이 있다. 스프링 공식 문서에서는 생성자 주입을 권장한다.&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;4.1.&amp;nbsp;(비권장)&amp;nbsp;필드&amp;nbsp;주입&lt;/h3&gt;
&lt;pre id=&quot;code_1762222871667&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(권장하지 않음)

// 필드 주입 방식
@Service
public class StoreService {
    @Autowired
    private ProductRepository productRepository; // &amp;lt;-- 필드에 바로 주입
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드는 간결하지만 final 선언이 불가능해 불변성이 깨진다. 또한 스프링 컨테이너 없이는 객체를 생성할 수 없어 단위 테스트가 상당히 불편하다. 추가로 순환 참조 같은 문제를 발견하기 어렵다는 큰 단점도 있다.&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.2. (권장) 생성자 주입&lt;/h3&gt;
&lt;pre id=&quot;code_1762222881740&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;(가장 권장됨)

// 생성자 주입 방식
@Service
public class StoreService {

    // 1. final 키워드로 불변성을 보장한다.
    private final ProductRepository productRepository;

    // 2. 생성자를 통해 의존성을 주입받는다.
    // (스프링 4.3 이후 생성자가 하나면 @Autowired 생략 가능)
    public StoreService(ProductRepository productRepository) {
        this.productRepository = productRepository;
    }
}&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 필수 의존성 보장: 객체 생성 시점에 반드시 필요한 의존성이 누락되는 것을 컴파일 시점에 방지한다. &lt;span style=&quot;color: #ffffff; text-align: start; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot; data-darkreader-inline-color=&quot;&quot;&gt;(NPE 방지)&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 테스트 용이성: 스프링 컨테이너 없이도, 순수 자바 코드로 new StoreService(new MockRepository())처럼 가짜(Mock) 객체를 주입하며 쉽게 테스트할 수 있다.&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;5. 결론&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DI를 적용하는 이유는 명확하다.&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;DI는 단순한 기술이 아니라, 유지보수하기 좋고 유연하며 테스트하기 쉬운 코드를 작성하기 위한 객체지향 설계의 핵심 원칙이다. 스프링을 사용한다면 생성자 주입 방식을 통해 이 이점들을 극대화하는 것이 바람직하다고 생각된다.&lt;/p&gt;</description>
      <category>공부메모 &amp;amp; 오류해결/Spring Boot</category>
      <category>Dependency Injection</category>
      <category>Java</category>
      <category>spring</category>
      <category>Spring DI</category>
      <category>생성자 주입</category>
      <category>스프링</category>
      <category>스프링 의존성</category>
      <category>스프링부트</category>
      <category>의존성 주입</category>
      <category>자바</category>
      <author>남건욱</author>
      <guid isPermaLink="true">https://ngwdeveloper.tistory.com/205</guid>
      <comments>https://ngwdeveloper.tistory.com/205#entry205comment</comments>
      <pubDate>Tue, 4 Nov 2025 19:23:42 +0900</pubDate>
    </item>
    <item>
      <title>ELK 스택으로 MSA 로그 한곳에 모으기</title>
      <link>https://ngwdeveloper.tistory.com/201</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;1. 왜 중앙화된 로그 시스템이 필요할까?&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;마이크로서비스 환경에서 장애가 발생하면 곤란한 상황에 빠질 수 있다. 현재 나의 devnote를 예로 들면 &lt;span style=&quot;text-align: start; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;요청 하나를 추적하기 위해&amp;nbsp;&lt;/span&gt; 6개의 서비스 컨테이너에 각각 접속해 로그 파일을 뒤지는 것은 상당히 비효율적일것이다. 이 문제를 해결하기 위해 모든 서비스의 로그를 한곳으로 모아 검색하고 분석할 수 있는 중앙화된 로그 시스템으로 ELK 스택을 구축했다.&lt;/span&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;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;2. Devnote 프로젝트의 로그 아키텍처&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;로그는 Filebeat &amp;rarr; Logstash &amp;rarr; Elasticsearch &amp;rarr; Kibana 순서로 흐르도록 구성했다. 각 컴포넌트는 아래처럼 명확한 역할을 수행한다.&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;&quot; data-darkreader-inline-color=&quot;&quot;&gt;MSA 서비스 &amp;rarr; 호스트의 로그 파일 &amp;rarr; Filebeat (수집) &amp;rarr; Logstash (가공) &amp;rarr; Elasticsearch (저장/색인) &amp;rarr; Kibana (시각화/검색)&lt;/span&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;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;3. 1단계: 로그 포맷 통일하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;먼저 6개 서비스가 모두 동일한 형식으로 로그를 남기도록 application.yml의 로깅 패턴을 통일했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;특히 [${spring.application.name}] 부분을 추가하여 로그 한 줄만 봐도 어떤 서비스에서 발생했는지 명확히 알 수 있도록 했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1758010662529&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;logging:
  pattern:
    file: &quot;%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - [${spring.application.name}] %msg%n&quot;&lt;/code&gt;&lt;/pre&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;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;4. 2단계: Filebeat로 로그 수집하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;Filebeat는 각 서버에 설치되어 로그 파일의 변경을 감지하고 수집하는 경량 에이전트다. docker-compose.yml의 volumes 설정을 통해 서버의 로그 디렉터리(/home/gunwook/app/devnote/logs)를 Filebeat 컨테이너의 /var/log/msa로 연결했다. filebeat.yml에는 이 경로의 모든 *.log 파일을 감시하도록 설정하여 모든 서비스의 로그를 수집했다.&lt;/span&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;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;5. 3단계: Logstash로 로그 파싱 및 정제하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;Filebeat가 보낸 텍스트 로그는 그 자체로 분석하기는 어렵다. Logstash는 이 비정형 데이터를 구조화된 JSON 데이터로 가공하는 역할을 한다. logstash.conf의 filter 플러그인에 grok 패턴을 사용하여, 1단계에서 정의한 로그 포맷에 맞춰 로그를 파싱했다. 이것을 통해서 service_name, log_level 등 의미 있는 필드를 추출하여 Elasticsearch에 저장할 수 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1758010683975&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# logstash.conf
filter {
  grok {
    match =&amp;gt; { &quot;message&quot; =&amp;gt; &quot;%{...} - \[%{DATA:service_name}\] %{GREEDYDATA:log_message}&quot; }
  }
}&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;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;6. 4단계: Kibana에서 로그 검색 및 시각화하기&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;이제 Kibana 대시보드에 접속하여 msa-logs-* 인덱스 패턴을 생성했다. 이제 단일 검색창에서 service_name: &quot;user-service&quot; AND log_level: &quot;ERROR&quot; 와 같은 KQL 쿼리를 통해 수많은 로그 중에서 원하는 내용을 곧바로 찾아낼 수 있게 되었다.&lt;/span&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;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;7. 느낀점&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;ELK 스택을 구축하기 전에는 장애 추적이 쉽지 않은 작업이었지만 이제는 오히려 간단한 작업이 되었다. 중앙화된 로그 시스템은 단순히 편리함을 넘어서 MSA의 안정성과 운영 효율성을 향상시켜주는 필수적인 기술이라고 느껴졌다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>[데브노트] 웹 프로젝트 (devnote.kr)</category>
      <category>DevOps</category>
      <category>elasticsearch</category>
      <category>Elk</category>
      <category>filebeat</category>
      <category>Kibana</category>
      <category>logstash</category>
      <category>MSA</category>
      <category>로깅</category>
      <category>모니터링</category>
      <category>백엔드</category>
      <author>남건욱</author>
      <guid isPermaLink="true">https://ngwdeveloper.tistory.com/201</guid>
      <comments>https://ngwdeveloper.tistory.com/201#entry201comment</comments>
      <pubDate>Tue, 16 Sep 2025 21:34:21 +0900</pubDate>
    </item>
    <item>
      <title>Kafka와 AI로 콘텐츠 자동 수집 및 분류 시스템 만들기</title>
      <link>https://ngwdeveloper.tistory.com/200</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;figure id=&quot;og_1758009836358&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;DevNote&quot; data-og-description=&quot;개발 관련 YouTube/News Aggregator - 최신 개발 트렌드와 뉴스를 한 곳에서&quot; data-og-host=&quot;devnote.kr&quot; data-og-source-url=&quot;http://www.devnote.kr&quot; data-og-url=&quot;https://devnote.kr&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;http://www.devnote.kr&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;http://www.devnote.kr&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&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;DevNote&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;개발 관련 YouTube/News Aggregator - 최신 개발 트렌드와 뉴스를 한 곳에서&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;devnote.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;1. 왜 이벤트 기반 데이터 파이프라인인가?&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;devnote 프로젝트의 핵심은 외부 콘텐츠를 가져와 가공하는 것이다. 만약 데이터 수집, AI 분류, DB 저장을 하나의 동기적 흐름으로 짰다면 AI 분류 API가 느려지거나 실패했을 때 전체 데이터 수집 프로세스가 멈춰버리는 문제가 발생했을 것이다. 이러한 문제를 해결하고 각 기능의 독립성을 보장하기 위해, Kafka를 이용한 이벤트 기반 비동기 데이터 파이프라인을 구축했다.&lt;/span&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;&lt;span style=&quot;color: #333333;&quot;&gt;2. 전체 데이터 파이프라인 아키텍처&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&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;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;1. 데이터 생산 (Produce): news-youtube-service가 외부 소스에서 데이터를 수집하여 Kafka 토픽으로 발행한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;2. 메시지 브로커 (Broker): Kafka가 중간에서 메시지를 보관하고 전달하는 역할을 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;3. 데이터 소비 (Consume): processor-service가 Kafka 토픽의 메시지를 구독하여 가져온 뒤 데이터를 가공하고 최종적으로 저장한다.&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;&quot; data-darkreader-inline-color=&quot;&quot;&gt;외부 소스 &amp;rarr; News-Youtube-Service &amp;rarr; Kafka (raw.content 토픽) &amp;rarr; Processor-Service &amp;rarr; MariaDB / Elasticsearch&lt;/span&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;&lt;span style=&quot;color: #333333;&quot;&gt;3. 데이터 수집 및 발행 (news-youtube-service)&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;이 서비스의 책임은 &quot;외부 데이터를 수집하여 Kafka에 전달하는 것&quot; 이었다. application.yml에 정의된 뉴스 RSS 피드와 유튜브 채널 목록을 기반으로, @Scheduled 스케줄러가 주기적으로 데이터를 수집했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;수집된 모든 데이터는 표준화된 ContentMessageDto로 변환된 후, raw.content라는 이름의 Kafka 토픽으로 발행되었다. 이 서비스의 역할은 데이터를 발행하는 순간 끝난다.&lt;/span&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;&lt;span style=&quot;color: #333333;&quot;&gt;4. 데이터 소비, 가공, 저장 (processor-service)&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;processor-service는 데이터 파이프라인의 핵심 처리 허브 역할을 수행했다.&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;span style=&quot;color: #333333; text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;4-1. Kafka 메시지 소비&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;ContentService 내의 @KafkaListener가 raw.content 토픽을 실시간으로 구독하여 새로운 콘텐츠 데이터를 받아왔다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;4-2. AI 기반 자동 분류&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;받아온 데이터의 category 필드가 'TBC(To Be Classified)' 상태일 경우, CategoryClassificationService를 통해&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;Google Gemini AI API&lt;/span&gt;&lt;span style=&quot;text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;를 호출했다. AI는 콘텐츠의 제목을 분석하여 '백엔드', '보안' 등 가장 적합한 카테고리를 반환하도록 했다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;4-3. Polyglot Persistence (이중 저장)&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;&lt;span style=&quot;text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;최종적으로 가공된 데이터는 두 곳에 저장했다. 안정적인 트랜잭션 관리가 필요한 원본 데이터는&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;MariaDB&lt;/span&gt;&lt;span style=&quot;text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;에, 그리고 검색 기능을 위해서는&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;Elasticsearch&lt;/span&gt;&lt;span style=&quot;text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;에 동시에 색인하여 목적에 따라 DB를 분리했다.&lt;/span&gt;&lt;/span&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;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;5. 느낀점&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot;&gt;Kafka를 통해 생산자와 소비자를 분리함으로써, AI 서비스의 응답이 지연되거나 processor-service에 일시적인 장애가 발생하더라도 news-youtube-service는 아무런 영향 없이 계속해서 데이터를 수집할 수 있었다. 이러한 비동기 파이프라인은 시스템 전체의 회복탄력성과 확장성을 향상시키는 핵심적인 설계였다고 생각된다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>[데브노트] 웹 프로젝트 (devnote.kr)</category>
      <category>geminiai</category>
      <category>KAFKA</category>
      <category>MSA</category>
      <category>SpringBoot</category>
      <category>개발기</category>
      <category>데이터파이프라인</category>
      <category>백엔드</category>
      <category>비동기</category>
      <category>이벤트기반아키텍처</category>
      <category>포트폴리오</category>
      <author>남건욱</author>
      <guid isPermaLink="true">https://ngwdeveloper.tistory.com/200</guid>
      <comments>https://ngwdeveloper.tistory.com/200#entry200comment</comments>
      <pubDate>Tue, 16 Sep 2025 20:14:00 +0900</pubDate>
    </item>
    <item>
      <title>개인 프로젝트에 MSA를 도입한 이유</title>
      <link>https://ngwdeveloper.tistory.com/198</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;891&quot; data-origin-height=&quot;490&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yXmMa/btsQpzZRvh9/Bew5QvbxLz6VxvK4kULF8K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yXmMa/btsQpzZRvh9/Bew5QvbxLz6VxvK4kULF8K/img.png&quot; data-alt=&quot;Devnote 프로젝트 구성&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yXmMa/btsQpzZRvh9/Bew5QvbxLz6VxvK4kULF8K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyXmMa%2FbtsQpzZRvh9%2FBew5QvbxLz6VxvK4kULF8K%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;891&quot; height=&quot;490&quot; data-origin-width=&quot;891&quot; data-origin-height=&quot;490&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Devnote 프로젝트 구성&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;figure id=&quot;og_1758009806508&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;DevNote&quot; data-og-description=&quot;개발 관련 YouTube/News Aggregator - 최신 개발 트렌드와 뉴스를 한 곳에서&quot; data-og-host=&quot;devnote.kr&quot; data-og-source-url=&quot;http://www.devnote.kr&quot; data-og-url=&quot;https://devnote.kr&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;http://www.devnote.kr&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;http://www.devnote.kr&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&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;DevNote&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;개발 관련 YouTube/News Aggregator - 최신 개발 트렌드와 뉴스를 한 곳에서&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;devnote.kr&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 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;1. 개인 프로젝트에서 MSA는 오버 엔지니어링일까?&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;포트폴리오를 위한 개인 프로젝트를 시작할 때면 늘 같은 고민에 빠진다. &quot;어떤 아키텍처를 선택할 것인가?&quot; 가장 익숙하고 빠른 방법은 모든 기능을 하나의 프로젝트에 담는 모놀리식 방식이다. 하지만 MSA를 경험해보고 싶은 마음을 무시할 수 없었다. 과연 학습 목적의 개인 프로젝트에 오버 엔지니어링은 아닐까?&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;devnote 프로젝트를 시작하며 나는 이 질문에 &quot;그럼에도 불구하고 MSA로 가자&quot;고 답했다. 이번 글에서는 그 결정의 이유와 초기 설계 과정을 작성했다.&lt;/span&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;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;2. 이번 프로젝트에 MSA를 선택한 이유&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;이번에 진행한 개인 프로젝트 Devnote는 개발자를 위한 콘텐츠(유튜브 영상, 뉴스)를 수집하고 댓글, 통계, 랭킹 등 기능을 제공하는 플랫폼이다. 단순히 CRUD만 있는 웹사이트가 아닌, 여러 독립적인 기능들이 유기적으로 연결되어야 했다.&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;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;2-1. 독립적인 확장과 장애 격리&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;news-youtube-service는 외부 API에 의존적이므로 언제든 지연되거나 실패할 수 있다. 만약 YouTube 데이터 수집 로직이 중단되더라도, 로그인이나 댓글 작성 같은 기능에는 아무런 영향이 없어야 했다. 각 기능의 장애가 다른 기능으로 전파되는 것을 막기 위해 서비스 분리는 필수적이었다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;2-2. 다양한 기술 스택의 실험&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;콘텐츠 검색 기능에는&amp;nbsp;&lt;/span&gt;Elasticsearch&lt;span style=&quot;text-align: left;&quot;&gt;를, 자동 분류에는&amp;nbsp;&lt;/span&gt;Google Gemini AI&lt;span style=&quot;text-align: left;&quot;&gt;를, 실시간 통계에는&amp;nbsp;&lt;/span&gt;Redis&lt;span style=&quot;text-align: left;&quot;&gt;를 적극적으로 활용하고 싶었다. 각 서비스에 대해 가장 적합한 기술을 채택해 구성했다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;2-3. 학습과 경험&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;무엇보다 MSA 환경에서 발생하는 다양한 문제들(서비스 간 통신, 데이터 일관성, 중앙화된 설정 등)을 직접 경험하고 해결해나가는 과정 자체가 이 프로젝트의 가장 큰 학습 목표였다&lt;/span&gt;&lt;b&gt;&lt;span style=&quot;text-align: left;&quot;&gt;.&lt;/span&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;3. 어떻게 서비스를 나눌 것인가?&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;서비스를 분리하는 기준은 명확했다. 도메인 주도 설계(DDD) 관점에서, 비즈니스 도메인을 기준으로 각 서비스가 하나의 명확한 책임을 갖도록 6개의 마이크로서비스로 분리했다.&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;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;3-1. User Service&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;회원가입, 로그인 등 사용자 인증/인가와 댓글, 찜 등 모든 커뮤니티 활동을 총괄한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;3-2. News-Youtube Service&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;외부 RSS와 YouTube API를 통해 새로운 개발 콘텐츠를 수집하는 역할만 전담한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;3-3. Processor Service&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;수집된 원시 데이터를 받아 AI로 분류하고 DB에 저장하며, 검색 API를 제공하는 데이터 처리의 핵심이다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;3-4. Stats Service&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;모든 서비스에서 발생하는 이벤트를 수집하여 트래픽, 랭킹 등 의미 있는 통계를 계산하고 제공한다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;3-5. API Gateway&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;외부의 모든 요청을 받아 담당 서비스로 라우팅하는 단일 진입점이다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;3-6. Eureka Server&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&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;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;4. 왜 Spring Cloud, Kafka인가?&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&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;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;4-1. Spring Cloud&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot; data-darkreader-inline-color=&quot;&quot;&gt;서비스들이 유동적인 환경에서도 서로를 이름으로 찾을 수 있게 해주는 Eureka와, 클라이언트가 각 서비스의 주소를 알 필요 없이 하나의 입구를 통해 모든 기능에 접근하게 해주는 Spring Cloud Gateway는 MSA의 필수적인 뼈대였다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;4-2. Kafka&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;User Service에서 콘텐츠에 대한 '찜'이 발생했을 때 Processor Service가 직접 API를 호출하는 동기 방식은 시스템 간의 강한 결합을 만든다. 이를 해결하기 위해&amp;nbsp;&lt;/span&gt;Kafka&lt;span style=&quot;text-align: left;&quot;&gt;를 도입했다. User Service는 &quot;찜이 추가됐다&quot;는 이벤트만 Kafka에 발행하고, Processor Service와 Stats Service는 이 이벤트를 구독하여 각자 필요한 작업을 비동기적으로 처리한다. 이로써 서비스 간 의존성이 제거되고 유연성이 극대화되었다.&lt;/span&gt;&lt;/span&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;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;5. 느낀점&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;개인 프로젝트에 MSA를 도입하는 것은 분명 큰 도전이었다. 하지만 서비스별 독립성을 확보하고 다양한 기술을 실험하며 이벤트 기반 아키텍처의 장점을 체감하는 소중한 경험이었다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>[데브노트] 웹 프로젝트 (devnote.kr)</category>
      <category>docker</category>
      <category>KAFKA</category>
      <category>microservices</category>
      <category>MSA</category>
      <category>spring cloud</category>
      <category>개발기</category>
      <category>개인 프로젝트</category>
      <category>백엔드</category>
      <category>아키텍처</category>
      <category>포트폴리오</category>
      <author>남건욱</author>
      <guid isPermaLink="true">https://ngwdeveloper.tistory.com/198</guid>
      <comments>https://ngwdeveloper.tistory.com/198#entry198comment</comments>
      <pubDate>Wed, 10 Sep 2025 14:29:31 +0900</pubDate>
    </item>
    <item>
      <title>Devnote 프로젝트</title>
      <link>https://ngwdeveloper.tistory.com/197</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;862&quot; data-origin-height=&quot;862&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/o0lBm/btsQtZbhMbY/UgbaEW09BKzVKt0dO2q121/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/o0lBm/btsQtZbhMbY/UgbaEW09BKzVKt0dO2q121/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/o0lBm/btsQtZbhMbY/UgbaEW09BKzVKt0dO2q121/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fo0lBm%2FbtsQtZbhMbY%2FUgbaEW09BKzVKt0dO2q121%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;66&quot; height=&quot;66&quot; data-origin-width=&quot;862&quot; data-origin-height=&quot;862&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;figure id=&quot;og_1758009780895&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;DevNote&quot; data-og-description=&quot;개발 관련 YouTube/News Aggregator - 최신 개발 트렌드와 뉴스를 한 곳에서&quot; data-og-host=&quot;devnote.kr&quot; data-og-source-url=&quot;http://www.devnote.kr&quot; data-og-url=&quot;https://devnote.kr&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;http://www.devnote.kr&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;http://www.devnote.kr&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&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;DevNote&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;개발 관련 YouTube/News Aggregator - 최신 개발 트렌드와 뉴스를 한 곳에서&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;devnote.kr&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;1. 개발자 콘텐츠 플랫폼&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;흩어져 있는 최신 개발 소식과 유튜브 영상을 한곳에서 모아볼 수 있는 개발자 콘텐츠 허브 플랫폼으로 Devnote 프로젝트를 기획했다. 단순히 링크를 모아두는 것을 넘어, AI를 통해 모든 콘텐츠를 알맞은 카테고리별로 자동 분류하고 검색 기능과 커뮤니티 기능을 통해 사용자가 원하는 정보를 쉽고 깊이 있게 탐색할 수 있도록 설계했다.&lt;/span&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;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;2. 주요 기능&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&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;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;2-1. &lt;span style=&quot;text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;콘텐츠 자동 수집 및 분류&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot; data-darkreader-inline-color=&quot;&quot;&gt;외부 뉴스 사이트의 RSS와 YouTube API를 통해 개발 관련 콘텐츠를 주기적으로 수집하고, Google Gemini AI가 콘텐츠를 분석하여 유튜브 콘텐츠는 'AI', '백엔드', '프론트엔드', '데이터베이스', '보안', '인프라', '기타' 뉴스 콘텐츠는 'AI', '보안', '해킹', 'IT/테크', '인프라', '정책', '기타' 카테고리로 자동 분류했다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;2-2. &lt;span style=&quot;text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;검색 및 필터링&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot; data-darkreader-inline-color=&quot;&quot;&gt;Elasticsearch 기반의 검색 엔진을 통해 제목, 본문, 필터링 등 다양한 검색 옵션을 구현했다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;2-3. &lt;span style=&quot;text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;사용자 활동 및 커뮤니티&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot; data-darkreader-inline-color=&quot;&quot;&gt;소셜 로그인(OAuth2)을 지원하며, 사용자는 관심 있는 콘텐츠나 채널을 찜하거나 댓글을 통해 의견을 나눌 수 있도록 구현했다.&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;2-4. &lt;span style=&quot;text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;통계 및 로깅 시스템&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot; data-darkreader-inline-color=&quot;&quot;&gt;ELK 스택을 활용해 모든 서비스의 로그를 중앙에서 관리하고, 실시간 랭킹 및 서비스 통계 대시보드를 통해 시스템 현황을 한눈에 파악할 수 있도록 구축했다.&lt;/span&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;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;3. 사용 기술 스택&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;3-1. &lt;span style=&quot;text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;Backend&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;span style=&quot;text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;Java 17, Spring Boot 3, Spring Cloud, Kafka, Redis, Elasticsearch, MariaDB&lt;/span&gt;&lt;span style=&quot;text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;3-2. &lt;span style=&quot;text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;Frontend&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;span style=&quot;text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;Next.js 15&lt;/span&gt;&lt;span style=&quot;text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;3-3. &lt;span style=&quot;text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;DevOps&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;&lt;span style=&quot;text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;Docker, Jenkins, nginx&lt;/span&gt;&lt;span style=&quot;text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;3-4. &lt;span style=&quot;text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot;&gt;Infrastructure &amp;amp; Tools&lt;/span&gt;&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left; --darkreader-inline-color: var(--darkreader-text-ffffff, #ffffff);&quot; data-darkreader-inline-color=&quot;&quot;&gt;Eureka Server, Spring Cloud Gateway, ELK Stack, Google Gemini AI&lt;/span&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;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;4. 느낀점&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;Devnote는 각 기술을 왜 선택했고 어떻게 유기적으로 연결했는지에 대한 고민을 담은 프로젝트다. 특히 MSA 환경에서 각 데이터의 특성에 맞는 저장소를 선택하고, Kafka를 통해 서비스를 비동기적으로 연결하는 경험은 시스템 전체의 안정성과 확장성을 깊이 이해하는 계기가 되었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333;&quot; data-darkreader-inline-color=&quot;&quot;&gt;다음 글에서는 Devnote의 MSA 백엔드 아키텍처를 어떻게 설계하고 구축했는지에 대해 더 자세히 다뤄볼 예정이다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>[데브노트] 웹 프로젝트 (devnote.kr)</category>
      <category>elasticsearch</category>
      <category>Java</category>
      <category>KAFKA</category>
      <category>MSA</category>
      <category>redis</category>
      <category>SpringBoot</category>
      <category>springcloud</category>
      <category>백엔드</category>
      <category>아키텍처</category>
      <category>포트폴리오</category>
      <author>남건욱</author>
      <guid isPermaLink="true">https://ngwdeveloper.tistory.com/197</guid>
      <comments>https://ngwdeveloper.tistory.com/197#entry197comment</comments>
      <pubDate>Wed, 10 Sep 2025 10:07:29 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Boot] 실시간검색어 구현하기</title>
      <link>https://ngwdeveloper.tistory.com/195</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 요구사항 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 실시간 집계&lt;br /&gt;사용자가 검색 즉시 &amp;ldquo;인기 검색어&amp;rdquo; 점수에 반영&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;- 조작 방지&lt;br /&gt;동일&amp;nbsp;IP(또는&amp;nbsp;로그인&amp;nbsp;유저)가&amp;nbsp;같은&amp;nbsp;키워드를&amp;nbsp;반복&amp;nbsp;검색해&amp;nbsp;점수를&amp;nbsp;부당하게&amp;nbsp;올리는&amp;nbsp;행위&amp;nbsp;차단 &lt;br /&gt;&lt;br /&gt;- 시간 가중치 적용&lt;br /&gt;최근&amp;nbsp;검색일수록&amp;nbsp;더&amp;nbsp;큰&amp;nbsp;가중치를&amp;nbsp;부여 &lt;br /&gt;&lt;br /&gt;- 시간대 보정&lt;br /&gt;새벽 시간대에는 보정값을 높여 점수를 낮게, 오후 시간대엔 보정값을 낮춰 점수를 높게 &lt;br /&gt;&lt;br /&gt;- 전체 검색량 보정&lt;br /&gt;평소&amp;nbsp;검색량(과거&amp;nbsp;일주일&amp;nbsp;등)이&amp;nbsp;많은&amp;nbsp;키워드는&amp;nbsp;보정값으로&amp;nbsp;점수를&amp;nbsp;낮춤 &lt;br /&gt;&lt;br /&gt;- 지속성 확보&lt;br /&gt;Redis 인메모리 휘발성 대비 RDBMS 백업&lt;br /&gt;&lt;br /&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;h2 data-ke-size=&quot;size26&quot;&gt;2. 구현&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-1. 새로운 로직 추가 (Trending)&lt;/h3&gt;
&lt;pre id=&quot;code_1752411387400&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.footprint.trending.controller;

import com.example.footprint.trending.dto.TrendingDto;
import com.example.footprint.trending.service.TrendingService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;

@RestController
@RequestMapping(&quot;/api/trending&quot;)
@RequiredArgsConstructor
public class TrendingController {
    private final TrendingService trendSvc;

    /**
     * 검색 호출 시마다 기록 &amp;amp; 실시간 top10 반환
     */
    @PostMapping(&quot;/record&quot;)
    public ResponseEntity&amp;lt;List&amp;lt;TrendingDto&amp;gt;&amp;gt; record(@RequestParam String keyword,
                                                    HttpServletRequest req) {
        String ip = req.getRemoteAddr();
        return ResponseEntity.ok(trendSvc.record(keyword, ip));
    }

    /** 단순 조회: 실시간 top10 */
    @GetMapping
    public ResponseEntity&amp;lt;List&amp;lt;TrendingDto&amp;gt;&amp;gt; getTop() {
        return ResponseEntity.ok(trendSvc.getTop());
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1752411791163&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.footprint.trending.dto;

import lombok.*;

@Getter @Setter @AllArgsConstructor @NoArgsConstructor @Builder
public class TrendingDto {
    private int    rank;         // 1~10
    private String keyword;
    private String change;       // &quot;up&quot; | &quot;down&quot; | &quot;same&quot;
    private int    changeValue;  // 변동 폭
    private double score;        // 내부 점수
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1752411800366&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.footprint.trending.entity;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Table(name = &quot;search_event&quot;)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class SearchEvent {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String keyword;

    @Column(name = &quot;client_ip&quot;, length = 45)
    private String clientIp;

    @Column(name = &quot;user_id&quot;)
    private Long userId;

    @Column(name = &quot;event_time&quot;, nullable = false)
    private LocalDateTime eventTime;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1752411806663&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.footprint.trending.entity;

import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Table(name = &quot;trending_backup&quot;)
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class TrendingBackup {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, length = 100)
    private String keyword;

    @Column(nullable = false)
    private double score;

    @Column(name = &quot;snapshot_at&quot;, nullable = false)
    private LocalDateTime snapshotAt;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1752411812786&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.footprint.trending.repository;

import com.example.footprint.trending.entity.SearchEvent;
import org.springframework.data.repository.query.Param;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.time.LocalDateTime;
import java.util.Optional;

public interface SearchEventRepository extends JpaRepository&amp;lt;SearchEvent, Long&amp;gt; {

    // 특정 키워드의 시간 범위 내 검색 횟수
    @Query(&quot;SELECT COUNT(e) FROM SearchEvent e &quot; +
            &quot;WHERE e.keyword = :kw &quot; +
            &quot;  AND e.eventTime BETWEEN :start AND :end&quot;)
    long countByKeywordBetween(
            @Param(&quot;kw&quot;) String keyword,
            @Param(&quot;start&quot;) LocalDateTime start,
            @Param(&quot;end&quot;)   LocalDateTime end
    );

    // 최근 이벤트 시간 조회 (시간 가중치용)
    @Query(&quot;SELECT MAX(e.eventTime) FROM SearchEvent e WHERE e.keyword = :kw&quot;)
    Optional&amp;lt;LocalDateTime&amp;gt; findLastEventTime(@Param(&quot;kw&quot;) String keyword);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1752411818158&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.footprint.trending.repository;

import com.example.footprint.trending.entity.TrendingBackup;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

import java.time.LocalDateTime;
import java.util.List;

public interface TrendingBackupRepository extends JpaRepository&amp;lt;TrendingBackup, Long&amp;gt; {
    /** 가장 최신 snapshotAt 을 직접 JPQL 로 조회 */
    @Query(&quot;SELECT MAX(t.snapshotAt) FROM TrendingBackup t&quot;)
    LocalDateTime findLatestSnapshotAt();

    /** 해당 timestamp 에 찍힌 레코드 중 score 내림차순 TOP10 */
    List&amp;lt;TrendingBackup&amp;gt; findTop10BySnapshotAtOrderByScoreDesc(LocalDateTime snapshotAt);
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1752411824255&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.footprint.trending.scheduler;

import com.example.footprint.trending.entity.TrendingBackup;
import com.example.footprint.trending.repository.SearchEventRepository;
import com.example.footprint.trending.repository.TrendingBackupRepository;
import com.example.footprint.trending.service.TrendingService;
import lombok.RequiredArgsConstructor;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Set;

@Service
@RequiredArgsConstructor
public class TrendingScheduler {

    private final RedisTemplate&amp;lt;String, String&amp;gt; redis;
    private final SearchEventRepository evRepo;
    private final TrendingBackupRepository backupRepo;
    private final TrendingService trendSvc;

    /**
     * 매시간 재계산: 시간 가중치, 시간대 보정, 전체 검색량 보정
     */
    @Scheduled(cron = &quot;0 0 * * * *&quot;)
    @Transactional
    public void recompute() {
        String key = &quot;trending:keywords&quot;;
        Set&amp;lt;String&amp;gt; keywords = redis.opsForZSet().range(key, 0, -1);
        if (keywords == null) return;

        LocalDateTime now = LocalDateTime.now();
        for (String kw : keywords) {
            // 1h, 24h, 1w 통계
            double h1 = evRepo.countByKeywordBetween(kw, now.minusHours(1), now);
            double h24= evRepo.countByKeywordBetween(kw, now.minusDays(1), now);
            double w7 = evRepo.countByKeywordBetween(kw, now.minusWeeks(1), now);
            double avgW = w7 / (7 * 24);

            // 시간대 보정 x(time)
            double x = biasByHour(now.getHour());

            // 시간 가중치 w(time) = 1/(지연된 분 +1)
            double w = evRepo.findLastEventTime(kw)
                    .map(t -&amp;gt; Duration.between(t, now).toMinutes() + 1)
                    .map(d -&amp;gt; 1.0 / d)
                    .orElse(0.0);

            // 전체 검색량 보정
            double vAdj = Math.pow(2, -Math.log(w7 + 1));

            // 최종 점수
            double dayScore  = h1 / (h24 == 0 ? 1 : h24);
            double weekScore = h1 / (avgW == 0 ? 1 : avgW);
            double totalScore = (dayScore * weekScore - x) * vAdj * w;

            // Redis 덮어쓰기
            redis.opsForZSet().add(key, kw, totalScore);
        }
    }

    /**
     * 매시간 top10 RDB 백업 (지속성 확보)
     */
    @Scheduled(cron = &quot;0 0 * * * *&quot;)
    @Transactional
    public void backup() {
        LocalDateTime ts = LocalDateTime.now();
        trendSvc.getTop().forEach(dto -&amp;gt; {
            backupRepo.save(TrendingBackup.builder()
                    .keyword(dto.getKeyword())
                    .score(dto.getScore())
                    .snapshotAt(ts)
                    .build());
        });
    }

    private double biasByHour(int hour) {
        if (hour &amp;lt; 6)   return 0.2;  // 새벽
        if (hour &amp;lt; 12)  return 0.1;  // 오전
        if (hour &amp;lt; 18)  return 0.05; // 오후
        return 0.1;                  // 저녁
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1752411830668&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.footprint.trending.service;

import com.example.footprint.trending.dto.TrendingDto;
import com.example.footprint.trending.entity.SearchEvent;
import com.example.footprint.trending.entity.TrendingBackup;
import com.example.footprint.trending.repository.SearchEventRepository;
import com.example.footprint.trending.repository.TrendingBackupRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.redis.core.RedisTemplate;
import org.springframework.data.redis.core.ZSetOperations;
import org.springframework.data.redis.core.script.DefaultRedisScript;
import org.springframework.stereotype.Service;

import java.time.LocalDateTime;
import java.util.*;

@Service
@RequiredArgsConstructor
public class TrendingService {
    private static final String TREND_KEY  = &quot;trending:keywords&quot;;
    private static final long   IP_TTL_SEC = 60;

    private final RedisTemplate&amp;lt;String, String&amp;gt; redis;
    private final DefaultRedisScript&amp;lt;List&amp;lt;String&amp;gt;&amp;gt; trendingScript;
    private final TrendingBackupRepository backupRepo;
    private final SearchEventRepository evRepo;

    public List&amp;lt;TrendingDto&amp;gt; record(String keyword, String clientIp) {
        // 1) Redis Lua 스크립트 실행
        String ipKey = &quot;search:ip:&quot; + clientIp;
        List&amp;lt;String&amp;gt; raw = redis.execute(
                trendingScript,
                List.of(ipKey),
                keyword, String.valueOf(IP_TTL_SEC)
        );
        if (raw == null) return Collections.emptyList();

        SearchEvent event = SearchEvent.builder()
                .keyword(keyword)
                .clientIp(clientIp)
                .eventTime(LocalDateTime.now())
                .build();
        evRepo.save(event);

        // 2) 이전 스냅샷 Top10 가져오기
        LocalDateTime latestTs = backupRepo.findLatestSnapshotAt();
        List&amp;lt;TrendingBackup&amp;gt; prev = latestTs == null
                ? Collections.emptyList()
                : backupRepo.findTop10BySnapshotAtOrderByScoreDesc(latestTs);

        Map&amp;lt;String, Integer&amp;gt; prevRank = new HashMap&amp;lt;&amp;gt;();
        for (int i = 0; i &amp;lt; prev.size(); i++) {
            prevRank.put(prev.get(i).getKeyword(), i + 1);
        }

        // 3) 결과 DTO 변환
        List&amp;lt;TrendingDto&amp;gt; out = new ArrayList&amp;lt;&amp;gt;();
        for (int i = 0, rank = 1; i &amp;lt; raw.size(); i += 2, rank++) {
            String kw = raw.get(i);
            double sc = Double.parseDouble(raw.get(i + 1));
            int old = prevRank.getOrDefault(kw, 11);
            int diff = old - rank;
            String change = diff &amp;gt; 0 ? &quot;up&quot; : diff &amp;lt; 0 ? &quot;down&quot; : &quot;same&quot;;
            int changeValue = Math.abs(diff);

            out.add(TrendingDto.builder()
                    .rank(rank)
                    .keyword(kw)
                    .change(change)
                    .changeValue(changeValue)
                    .score(sc)
                    .build());
        }
        return out;
    }

    public List&amp;lt;TrendingDto&amp;gt; getTop() {
        // 1) Redis 에서 실시간 Top10
        Set&amp;lt;ZSetOperations.TypedTuple&amp;lt;String&amp;gt;&amp;gt; tuples =
                redis.opsForZSet().reverseRangeWithScores(TREND_KEY, 0, 9);

        // 2) 이전 스냅샷 Top10 가져오기 (같은 ts)
        LocalDateTime latestTs = backupRepo.findLatestSnapshotAt();
        List&amp;lt;TrendingBackup&amp;gt; prev = latestTs == null
                ? Collections.emptyList()
                : backupRepo.findTop10BySnapshotAtOrderByScoreDesc(latestTs);

        Map&amp;lt;String, Integer&amp;gt; prevRank = new HashMap&amp;lt;&amp;gt;();
        for (int i = 0; i &amp;lt; prev.size(); i++) {
            prevRank.put(prev.get(i).getKeyword(), i + 1);
        }

        // 3) DTO 변환
        List&amp;lt;TrendingDto&amp;gt; out = new ArrayList&amp;lt;&amp;gt;();
        int rank = 1;
        if (tuples != null) {
            for (var t : tuples) {
                String kw = t.getValue();
                double sc = t.getScore();
                int old = prevRank.getOrDefault(kw, 11);
                int diff = old - rank;
                String change = diff &amp;gt; 0 ? &quot;up&quot; : diff &amp;lt; 0 ? &quot;down&quot; : &quot;same&quot;;
                int changeValue = Math.abs(diff);

                out.add(TrendingDto.builder()
                        .rank(rank++)
                        .keyword(kw)
                        .change(change)
                        .changeValue(changeValue)
                        .score(sc)
                        .build());
            }
        }
        return out;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1752411860987&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#application.yml

spring:
  task:
    scheduling:
      pool:
        size: 5&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1752411871111&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package com.example.footprint.redis;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.redis.core.script.DefaultRedisScript;

import java.util.List;

@Configuration
public class RedisLuaConfig {

    /**
     * KEYS[1] = &quot;search:ip:{ip}&quot;
     * ARGV[1] = keyword
     * ARGV[2] = ttlSeconds
     *
     * 1) IP별 SET에 keyword가 없으면
     *    - ZINCRBY trending:keywords 1 keyword   (실시간 집계)
     *    - SADD    search:ip:{ip} keyword        (조작 방지)
     *    - EXPIRE  search:ip:{ip} ttlSeconds     (중복 차단 TTL)
     * 2) ZREVRANGE top 10 WITHSCORES 반환
     */
    @Bean
    public DefaultRedisScript&amp;lt;List&amp;lt;String&amp;gt;&amp;gt; trendingScript() {
        String lua =
                &quot;if redis.call('SISMEMBER', KEYS[1], ARGV[1]) == 0 then\n&quot; +
                        &quot;  redis.call('ZINCRBY','trending:keywords',1,ARGV[1])\n&quot; +
                        &quot;  redis.call('SADD',     KEYS[1],ARGV[1])\n&quot; +
                        &quot;  redis.call('EXPIRE',   KEYS[1],tonumber(ARGV[2]))\n&quot; +
                        &quot;end\n&quot; +
                        &quot;return redis.call('ZREVRANGE','trending:keywords',0,9,'WITHSCORES')&quot;;
        DefaultRedisScript&amp;lt;List&amp;lt;String&amp;gt;&amp;gt; script = new DefaultRedisScript&amp;lt;&amp;gt;();
        script.setScriptText(lua);

        @SuppressWarnings(&quot;unchecked&quot;)
        Class&amp;lt;List&amp;lt;String&amp;gt;&amp;gt; resultType = (Class&amp;lt;List&amp;lt;String&amp;gt;&amp;gt;) (Class&amp;lt;?&amp;gt;) List.class;
        script.setResultType(resultType);

        return script;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1752412115666&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; @GetMapping(&quot;/posts/search&quot;)
    public ResponseEntity&amp;lt;Page&amp;lt;PostResponseDto&amp;gt;&amp;gt; searchPosts(
            // 기존 파라미터,
            HttpServletRequest request
    ) {
        try {
            // 실시간 급상승 검색어 집계
            String clientIp = request.getRemoteAddr();
            trendingService.record(keyword, clientIp);

            // 기존 로직
            return ResponseEntity.ok(postResponseDtoPage);
        } catch (Exception e) {
            // 기존 로직
        }
    }&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;19&quot; data-start=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-end=&quot;19&quot; data-start=&quot;3&quot; data-ke-size=&quot;size26&quot;&gt;3. 핵심 기능 정리&lt;/h2&gt;
&lt;p data-end=&quot;19&quot; data-start=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;전체 아키텍처 개요&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;372&quot; data-start=&quot;23&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;78&quot; data-start=&quot;23&quot;&gt;사용자가 검색 키워드를 입력하면 TrendingService.record()가 호출된다.&lt;/li&gt;
&lt;li data-end=&quot;163&quot; data-start=&quot;82&quot;&gt;이 서비스는 먼저 Redis Lua 스크립트로 실시간 집계를 수행하고, 검색 이벤트를 RDB의 SearchEvent 테이블에 저장한다.&lt;/li&gt;
&lt;li data-end=&quot;211&quot; data-start=&quot;167&quot;&gt;Lua 스크립트 실행 결과로 실시간 Top10 키워드와 점수를 반환한다.&lt;/li&gt;
&lt;li data-end=&quot;372&quot; data-start=&quot;215&quot;&gt;별도의 스케줄러가 매시간 두 가지 작업을 실행한다
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;372&quot; data-start=&quot;253&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;299&quot; data-start=&quot;253&quot;&gt;recompute() &amp;ndash; Redis ZSET 점수를 최신 통계로 재계산&lt;/li&gt;
&lt;li data-end=&quot;372&quot; data-start=&quot;305&quot;&gt;backup() &amp;ndash; 재계산된 Top10을 RDB의 TrendingBackup 테이블에 스냅샷으로 저장&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;657&quot; data-start=&quot;635&quot; data-ke-size=&quot;size16&quot;&gt;검색 기록(record) 흐름&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-end=&quot;923&quot; data-start=&quot;661&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li data-end=&quot;745&quot; data-start=&quot;661&quot;&gt;Redis Lua 스크립트를 실행해 IP별 중복 집계를 차단하고, Sorted Set에 점수를 누적한다.&lt;/li&gt;
&lt;li data-end=&quot;815&quot; data-start=&quot;749&quot;&gt;검색 이벤트를 RDB에 즉시 저장해, 이후 스케줄러가 통계 집계를 할 때 원시 데이터를 사용할 수 있게 한다.&lt;/li&gt;
&lt;li data-end=&quot;923&quot; data-start=&quot;819&quot;&gt;Lua 스크립트가 반환한 [키워드, 점수]를 받아 이전 스냅샷 순위와 비교하여 순위 변동 정보(상승, 하락, 변동폭)를 계산한 뒤 TrendingDto 리스트로 반환한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-end=&quot;954&quot; data-start=&quot;928&quot; data-ke-size=&quot;size16&quot;&gt;점수 재계산(recompute) 흐름&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1235&quot; data-start=&quot;958&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1002&quot; data-start=&quot;958&quot;&gt;매시간 실행 시점에 Redis ZSET에 저장된 모든 키워드를 조회한다.&lt;/li&gt;
&lt;li data-end=&quot;1084&quot; data-start=&quot;1006&quot;&gt;각 키워드에 대해 SearchEvent 테이블에서 최근 1시간(h1), 24시간(h24), 7일(w7)간의 검색 건수를 집계한다.&lt;/li&gt;
&lt;li data-end=&quot;1150&quot; data-start=&quot;1088&quot;&gt;시간대별 보정값(x), 마지막 검색 후 경과 시간 가중치(w), 전체 검색량 보정(vAdj)을 계산한다.&lt;/li&gt;
&lt;li data-end=&quot;1235&quot; data-start=&quot;1154&quot;&gt;최종 점수는 (h1/h24 * h1/(w7/168) - x) * vAdj * w 공식을 통해 산출하며, Redis ZSET에 덮어쓴다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-end=&quot;1263&quot; data-start=&quot;1240&quot; data-ke-size=&quot;size16&quot;&gt;스냅샷 백업(backup) 흐름&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1430&quot; data-start=&quot;1267&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li data-end=&quot;1302&quot; data-start=&quot;1267&quot;&gt;매시간 재계산 직후 또는 별도 스케줄러에 의해 호출된다.&lt;/li&gt;
&lt;li data-end=&quot;1372&quot; data-start=&quot;1306&quot;&gt;Redis에서 실시간 Top10을 조회해, 각 키워드와 점수를 TrendingBackup 엔티티로 저장한다.&lt;/li&gt;
&lt;li data-end=&quot;1430&quot; data-start=&quot;1376&quot;&gt;이력 데이터를 보관함으로써 다양한 곳에 활용할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>공부메모 &amp;amp; 오류해결/Spring Boot</category>
      <category>RDBMS</category>
      <category>redis</category>
      <category>spring boot 실시간</category>
      <category>spring boot 실시간 검색어</category>
      <category>가중치</category>
      <category>시간 가중치</category>
      <category>실시간 검색어</category>
      <category>실시간 검색어 로직</category>
      <category>실시간 검색어 알고리즘</category>
      <category>알고리즘</category>
      <author>남건욱</author>
      <guid isPermaLink="true">https://ngwdeveloper.tistory.com/195</guid>
      <comments>https://ngwdeveloper.tistory.com/195#entry195comment</comments>
      <pubDate>Sun, 13 Jul 2025 22:20:55 +0900</pubDate>
    </item>
  </channel>
</rss>