<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>minwoo95 님의 블로그</title>
    <link>https://minwoo95.tistory.com/</link>
    <description>minwoo95 님의 블로그 입니다.</description>
    <language>ko</language>
    <pubDate>Sat, 1 Aug 2026 02:19:37 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>minwoo95</managingEditor>
    <item>
      <title>캠프 수료 후기!!</title>
      <link>https://minwoo95.tistory.com/220</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;긴&amp;nbsp;여정의&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;마지막&amp;nbsp;프로젝트를&amp;nbsp;시작할&amp;nbsp;때만&amp;nbsp;해도,&amp;nbsp;주어진&amp;nbsp;기간에&amp;nbsp;비해&amp;nbsp;우리가&amp;nbsp;너무&amp;nbsp;욕심을&amp;nbsp;부린&amp;nbsp;건&amp;nbsp;아닐까&amp;nbsp;하는&amp;nbsp;걱정이&amp;nbsp;컸습니다.&amp;nbsp;그&amp;nbsp;걱정을&amp;nbsp;메워준&amp;nbsp;건&amp;nbsp;결국&amp;nbsp;팀원들이었습니다.&amp;nbsp;평일&amp;nbsp;늦은&amp;nbsp;밤에도,&amp;nbsp;주말에도&amp;nbsp;누구&amp;nbsp;먼저&amp;nbsp;손을&amp;nbsp;놓지&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;결과물의&amp;nbsp;완성도를&amp;nbsp;떠나,&amp;nbsp;우리가&amp;nbsp;함께&amp;nbsp;성장했고&amp;nbsp;그&amp;nbsp;과정을&amp;nbsp;즐겁게&amp;nbsp;마무리할&amp;nbsp;수&amp;nbsp;있었다는&amp;nbsp;것.&amp;nbsp;저는&amp;nbsp;그것이&amp;nbsp;이번&amp;nbsp;프로젝트가&amp;nbsp;저에게&amp;nbsp;남겨준&amp;nbsp;가장&amp;nbsp;큰&amp;nbsp;자산이라고&amp;nbsp;생각합니다.&amp;nbsp;물론&amp;nbsp;튜터님들께서&amp;nbsp;주신&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;늘&amp;nbsp;적극적이고&amp;nbsp;긍정적인&amp;nbsp;에너지로&amp;nbsp;임해&amp;nbsp;주신&amp;nbsp;팀원분들과&amp;nbsp;함께할&amp;nbsp;수&amp;nbsp;있어&amp;nbsp;영광이었고,&amp;nbsp;부족한&amp;nbsp;팀장을&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;그리고&amp;nbsp;이&amp;nbsp;자리를&amp;nbsp;빌려,&amp;nbsp;항상&amp;nbsp;열정으로&amp;nbsp;이끌어&amp;nbsp;주신&amp;nbsp;성규&amp;nbsp;튜터님,&amp;nbsp;원빈&amp;nbsp;튜터님,&amp;nbsp;석훈&amp;nbsp;튜터님,&amp;nbsp;선용&amp;nbsp;튜터님께&amp;nbsp;진심&amp;nbsp;어린&amp;nbsp;감사의&amp;nbsp;인사를&amp;nbsp;드립니다.&amp;nbsp;또&amp;nbsp;늘&amp;nbsp;옆에서&amp;nbsp;묵묵히&amp;nbsp;서포트해&amp;nbsp;주신&amp;nbsp;주희&amp;nbsp;매니저님께도&amp;nbsp;정말&amp;nbsp;감사드립니다.&amp;nbsp;튜터님들과&amp;nbsp;매니저님이&amp;nbsp;들&amp;nbsp;계셔서&amp;nbsp;이탈&amp;nbsp;없이&amp;nbsp;끝까지&amp;nbsp;완주할&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;진심으로&amp;nbsp;감사드립니다!&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/220</guid>
      <comments>https://minwoo95.tistory.com/220#entry220comment</comments>
      <pubDate>Tue, 19 May 2026 20:14:12 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 20</title>
      <link>https://minwoo95.tistory.com/219</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt;&lt;/h2&gt;
&lt;figure id=&quot;og_1779110954309&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&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;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1779110960358&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cAzkk3/dJMb8Rj7uXl/5rG9ECk4zXuW6ZBMvvwCWK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cAzkk3/dJMb8Rj7uXl/5rG9ECk4zXuW6ZBMvvwCWK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_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;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;최종 프로젝트 마지막 TIL &amp;mdash; 멘토님 피드백을 받고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6주간 달려온 최종 프로젝트가 드디어 마무리됐다. 발표가 끝나고 멘토님께 받은 피드백을 곱씹어보면서, 단순히 &quot;잘했다, 못했다&quot;를 넘어서 &lt;b&gt;앞으로 어떤 개발자로 성장해야 하는지&lt;/b&gt;에 대한 방향을 잡을 수 있었다. 오늘은 그 피드백을 정리하고, 내가 무엇을 느꼈는지를 기록으로 남겨두려고 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;칭찬받았던 부분들 &amp;mdash; 무엇이 잘 통했나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 멘토님이 좋게 봐주신 지점들이 있다. 단순히 기분 좋자고 적어두는 게 아니라, &lt;b&gt;&quot;왜 이 부분이 좋게 평가받았는지&quot;를 정확히 인지해야 다음 프로젝트에서도 재현할 수 있기 때문&lt;/b&gt;에 정리해둔다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 트러블슈팅의 서술 구조&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 인상 깊었던 피드백은 &quot;트러블슈팅이 '문제 &amp;rarr; 원인 &amp;rarr; 해결 &amp;rarr; 결과'로 빠짐없이 정리되어 있다&quot;는 점이었다. 사실 이건 처음부터 의도한 건 아니었고, 매번 문제가 터질 때마다 노션에 디버깅 로그를 쌓다 보니 자연스럽게 굳어진 포맷이었다. 이게 면접&amp;middot;발표에서 강력하게 작동한다는 걸 이번에 확실히 체감했다. &lt;b&gt;앞으로 어떤 프로젝트를 하든 이 포맷은 가져가야겠다.&lt;/b&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. &quot;캐시가 항상 정답은 아니다&quot;를 직접 겪은 경험&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;벌크 캐싱을 도입했더니 오히려 21배 느려진 사례. 캐시 히트율은 100%인데 왜 느릴까를 추적하다가 결국 원인은 &lt;b&gt;역직렬화 오버헤드&lt;/b&gt;였다는 걸 발견했고, 핫키 임계값 기반 선별 캐싱으로 전환했다. 멘토님은 이 과정에서 &quot;캐시가 항상 정답은 아니다&quot;를 몸으로 배웠다는 점을 좋게 봐주셨다. 책에서 읽은 지식과 직접 깨진 경험은 무게가 완전히 다르다는 걸 새삼 느꼈다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. @Transactional + 분산락 해제 순서 버그&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;200명 동시 요청에 101명 참여 성공이 떨어졌던 그 버그. 락 해제 시점과 트랜잭션 커밋 시점 불일치를 원인으로 짚고, 3가지 선택지를 비교한 뒤 A안(트랜잭션 책임 분리)을 선택한 의사결정 과정 자체를 칭찬해주셨다. &lt;b&gt;&quot;해결책이 여러 개일 때 비교하고 근거를 적은 흔적이 남아 있는가&quot;&lt;/b&gt; &amp;mdash; 이게 단순 구현자와 엔지니어를 가르는 지점이라는 걸 다시 한번 느꼈다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;4. 결제 재검증 배치 설계&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;웹훅은 best-effort 이벤트이므로 수신 보장을 전제로 설계하면 SPOF가 된다&quot;는 문제 정의가 좋았다고 해주셨다. 단순히 &quot;웹훅이 안 와서 폴링 붙였다&quot;가 아니라, &lt;b&gt;외부 의존성을 어떻게 방어할 것인가&lt;/b&gt;라는 설계 원칙으로 추상화해서 설명할 수 있다는 점이 핵심이었다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;5. AI 리뷰 v1 &amp;rarr; v2 비동기 전환&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;v1 실패율 92%의 원인을 &quot;Mock 응답 딜레이에 의한 톰캣 스레드풀 고갈&quot;로 분석한 것, 비동기 전환 후에도 분산락 + Kafka 멱등성 + Spring Retry로 안정성을 쌓은 구조까지 좋게 봐주셨다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;6. Chaos Engineering 기반 Redis Sentinel 검증&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;K6 + Grafana 데이터로 뒷받침한 점, 그리고 Cluster vs Sentinel 중에서 Sentinel을 선택한 근거가 &quot;적정 기술 선택의 좋은 예시&quot;라고 평가받았다. chaos-fallback-test에서 &quot;Redis가 죽어도 무조건 200이 떨어져야 한다&quot;는 시나리오까지 작성해둔 점도 언급해주셨다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;받은 지적 &amp;mdash; 그리고 내가 놓치고 있었던 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기서부터가 진짜다. 칭찬은 기분 좋지만, &lt;b&gt;성장은 결국 지적받은 곳에서 일어난다.&lt;/b&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. 정량 목표(SLO)가 없었다&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 가장 뼈아픈 지적이었다. 멘토님 말씀을 그대로 옮기자면,&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;SLO 없는 부하테스트는 채점 기준 없이 시험만 본 것과 같다.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 한 문장에 머리를 한 대 맞은 기분이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생각해보면 나는 부하테스트를 정말 열심히 돌렸다. 레포에 K6 스크립트만 수십 개고, 시나리오도 다양하게 짰다. 그런데 정작 &quot;P95 293ms가 합격인가 불합격인가&quot;를 판단할 기준이 없었다. 그냥 &quot;전보다 빨라졌으니 좋아진 것&quot;이라는 식으로만 결과를 해석했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;SLI (Service Level Indicator)&lt;/b&gt;: 측정 가능한 지표 (예: P95 응답시간, 에러율)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SLO (Service Level Objective)&lt;/b&gt;: 우리가 보장하려는 목표치 (예: P95 &amp;le; 200ms, 가용성 99.9%)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;SLA (Service Level Agreement)&lt;/b&gt;: 외부와 맺는 계약&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 셋 중에서 부하테스트의 합격선이 되는 건 SLO다. SLO가 있어야:&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;테스트 결과가 합격인지 불합격인지 판단할 수 있고&lt;/li&gt;
&lt;li&gt;어떤 API부터 최적화해야 하는지 우선순위가 정해지고&lt;/li&gt;
&lt;li&gt;이미 SLO를 충족한 부분은 더 이상 시간을 쓰지 않아도 된다는 판단이 선다&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 프로젝트에서는 기획 단계에서부터 &quot;우리 서비스가 사용자에게 보장하는 성능 기준이 무엇인가&quot;를 먼저 정의하고 들어가야겠다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 비즈니스 맥락이 빠져 있었다&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 번째 지적은 더 근본적인 문제였다. &lt;b&gt;&quot;AI 리뷰를 왜 도입했는가&quot;에 대한 비즈니스 맥락이 없다&lt;/b&gt;는 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멘토님의 예시가 강력했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AI 리뷰의 목적이 &quot;멘토 피드백의 시간 지연을 보완하는 것&quot;이라면 &amp;rarr; 품질보다 속도, 가벼운 모델 + 비동기가 맞다&lt;/li&gt;
&lt;li&gt;AI 리뷰의 목적이 &quot;멘토를 대체하는 것&quot;이라면 &amp;rarr; 속도보다 품질, 무거운 모델 + 동기 처리도 감수 가능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;같은 기능인데 비즈니스 목적이 달라지면 아키텍처 선택이 완전히 달라진다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;면접에서 &quot;왜 Kafka를 썼나요?&quot;라는 질문에,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;비즈니스 맥락 없는 답변: &quot;비동기가 필요해서요.&quot;&lt;/li&gt;
&lt;li&gt;비즈니스 맥락 있는 답변: &quot;멘토 피드백 대기 시간을 줄이는 게 핵심이라 즉시 응답이 필요했는데, AI 호출이 6초 걸려서 동기로 가면 UX가 깨졌습니다. 그래서 Kafka로 비동기 처리하고 분산락 + 멱등성으로 안정성을 확보했습니다.&quot;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전달되는 무게가 완전히 다르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멘토님이 마지막에 정리해주신 말씀이 계속 머릿속에 남는다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;AI 시대에 CS&amp;middot;코딩 역량은 기본기일 뿐이고, 그 위에서 차이를 만드는 것은 자기가 만든 서비스의 비즈니스를 얼마나 깊이 이해했는가다.&quot;&lt;/p&gt;
&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기술적 의사결정의 품질은 비즈니스 이해도에 비례한다는 말. 6주간 쌓은 기술적 의사결정들을 비즈니스 맥락과 다시 한번 연결해서 정리하는 작업이 남았다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;그리고 &amp;mdash; 더 큰 규모로 부하테스트를 해보고 싶다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 이번 프로젝트에서 가장 아쉬웠던 부분 중 하나가 &lt;b&gt;부하테스트 규모&lt;/b&gt;였다. 솔직히 말하면, 10만 명, 20만 명, 그 이상으로 쭉쭉 올려서 시스템이 어디서 무너지는지 직접 보고 싶었다. &quot;이론적으로는 이 정도 트래픽까지 버틸 수 있다&quot;가 아니라 &quot;실제로 돌려봤더니 N만 RPS에서 X라는 병목이 터졌다&quot;까지 가보고 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 두 가지 벽에 막혔다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;AWS 비용&lt;/b&gt;: 부하 생성기 인스턴스를 충분히 띄우고, 그걸 받아낼 백엔드 인프라까지 스케일업하면 시간당 비용이 만만치 않았다. 학생 프로젝트 예산에서는 감당이 어려웠다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;로컬 장비의 한계&lt;/b&gt;: 그렇다고 로컬에서 K6를 돌리자니, 부하 생성기 자체가 먼저 죽어버렸다. 부하를 받는 쪽이 아니라 &lt;b&gt;부하를 주는 쪽이 먼저 병목이 되는 상황&lt;/b&gt;이 반복됐다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 적당히 타협한 수치 안에서 테스트를 마무리했는데, 멘토님 피드백을 받고 나니 이 미련이 다시 살아났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히 SLO 얘기를 들으면서 깨달은 게 있다. &lt;b&gt;목표 기준이 명확하면, 그 기준을 넘어서는 지점까지 부하를 올려보는 것 자체가 의미 있는 실험이 된다.&lt;/b&gt; 단순히 &quot;더 많은 트래픽을 견뎠다&quot;가 아니라, &quot;SLO를 깨뜨리는 변곡점이 어디인가&quot;, &quot;그 변곡점에서 무엇이 먼저 무너지는가&quot;, &quot;그걸 해결하면 다음 변곡점은 어디로 이동하는가&quot;를 추적하는 실험.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이건 단순한 자랑용 수치가 아니라 &lt;b&gt;시스템을 이해하는 도구&lt;/b&gt;다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비용 문제는 AWS 프리티어 + 스팟 인스턴스 + 짧은 시간 집중 실행 같은 방식으로 어느 정도 우회할 수 있을 것 같고, 부하 생성기 한계는 K6의 분산 실행 모드(k6-operator on k8s)나 여러 EC2를 워커로 묶는 방식으로 풀어볼 수 있을 것 같다. &lt;b&gt;이번 프로젝트를 사이드로 계속 끌고 가면서 한번 도전해봐야겠다.&lt;/b&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6주를 마치며&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;피드백을 곱씹어볼수록, 멘토님이 하신 말씀의 핵심은 결국 하나로 모인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&quot;기술 그 자체에 매몰되지 말고, 그 기술이 풀려는 문제와 그 문제 뒤의 비즈니스를 봐라.&quot;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 6주 동안 나는 분산락을 걸고, Kafka를 붙이고, Redis Sentinel을 도입하고, 비동기 전환을 했다. 각각의 기술적 의사결정은 분명히 근거가 있었다. 그런데 그 근거들이 &quot;어떤 비즈니스 문제를 풀기 위해&quot;라는 한 줄로 꿰어져 있었는지는 다시 돌아봐야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SLO를 세우는 것도 같은 맥락이다. SLO는 결국 &quot;사용자에게 어떤 경험을 보장할 것인가&quot;라는 비즈니스적 약속의 수치화다. 기술과 비즈니스가 만나는 가장 명확한 지점.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 프로젝트에서는 코드를 한 줄 짜기 전에 이걸 먼저 정의하고 시작하려고 한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이 서비스의 핵심 사용자 가치는 무엇인가&lt;/li&gt;
&lt;li&gt;그 가치를 지키기 위한 SLO는 무엇인가&lt;/li&gt;
&lt;li&gt;각 기술 선택은 어떤 SLO를 어떻게 지키기 위함인가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6주간 정말 많이 배웠다. 좋은 팀원들, 좋은 멘토님 만나서 운이 좋았다.&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;이제 정말 마지막 TIL이지만, 진짜 공부는 여기서부터 시작인 것 같다.&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;1.아키텍쳐 새로 그리기&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;3.이력서 작성하기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4.포트폴리오 작성하기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5.대규모 트래픽 테스트 진행하기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6.인프라 기능 추가하여 고도화하기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;7.AI 고도화 다시한번 확인하기&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/219</guid>
      <comments>https://minwoo95.tistory.com/219#entry219comment</comments>
      <pubDate>Mon, 18 May 2026 22:23:48 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 19</title>
      <link>https://minwoo95.tistory.com/218</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt; &lt;/p&gt;
&lt;figure id=&quot;og_1778587313176&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/qMzRr/dJMb9bv6YvF/mye0cjFAMgHC23CK3pKgGk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/qMzRr/dJMb9bv6YvF/mye0cjFAMgHC23CK3pKgGk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_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;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778587317162&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&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;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 오늘 한 일 (추가)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어제 배포 파이프라인 구축에 이어 오늘은 App EC2의 안정성을 높이기 위한 스왑 메모리 설정과, 팀원 코드 업데이트에 따른 재배포 작업을 진행했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;App EC2 스왑 메모리 2GB 설정&lt;/li&gt;
&lt;li&gt;팀원 docker-compose.yml 업데이트 반영 (Redis Sentinel 재구성)&lt;/li&gt;
&lt;li&gt;Jenkinsfile에 kafka-2, kafka-3 추가&lt;/li&gt;
&lt;li&gt;전체 컨테이너 초기화 후 재배포&lt;/li&gt;
&lt;li&gt;헬스체크 최종 확인&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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. 스왑 메모리 설정 중 디스크 100% 초과&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;t3.small은 RAM이 2GB밖에 안 돼서 컨테이너가 많아지면 메모리 부족으로 서버가 먹통이 되는 현상이 반복됐다. 이를 해결하기 위해 스왑 메모리를 설정하려 했는데, 처음에 4GB로 설정하려다 디스크가 100%가 되어버렸다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #eaecf0;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;dd: error writing '/swapfile': No space left on device&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App EC2 디스크가 8GB인데 Docker 이미지, 시스템 파일 등으로 이미 5GB 이상 사용 중이었다. 여기에 4GB 스왑 파일을 만들려다 디스크가 꽉 찼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 스왑 파일을 삭제하고 2GB로 다시 설정했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;bash&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;jboss-cli&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;# 기존 스왑 제거
sudo swapoff /swapfile
sudo rm /swapfile
sudo sed -i '/swapfile/d' /etc/fstab

# 2GB 스왑 재설정
sudo dd if=/dev/zero of=/swapfile bs=128M count=16
sudo chmod 600 /swapfile
sudo mkswap /swapfile
sudo swapon /swapfile
echo '/swapfile swap swap defaults 0 0' | sudo tee -a /etc/fstab&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정 후 free -h로 확인하면 Swap 2GB가 추가된 것을 볼 수 있다. 재부팅 후에도 /etc/fstab에 등록했기 때문에 자동으로 스왑이 활성화된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-2. 팀원 코드 업데이트 후 Redis Sentinel 재배포 필요&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀원이 Redis Sentinel 관련 설정을 변경해서 기존 컨테이너를 전부 내리고 새로 올려야 했다. 기존에 떠있던 컨테이너/볼륨에 오래된 데이터가 남아 있어 충돌이 발생할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App EC2에서 전체 컨테이너와 볼륨을 깔끔하게 정리하고 재배포했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;bash&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;sudo docker stop $(sudo docker ps -aq) 2&amp;gt;/dev/null
sudo docker rm $(sudo docker ps -aq) 2&amp;gt;/dev/null
sudo docker volume prune -f
sudo docker system prune -af&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정리 후 Jenkins 빌드 트리거로 새로 배포했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-3. kafka-2, kafka-3 미실행으로 Kafka 연결 실패&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포 후 앱 로그에 아래와 같은 경고가 반복됐다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;Connection to node -1 (localhost/127.0.0.1:9092) could not be established.
Bootstrap broker localhost:9093 (id: -2 rack: null) disconnected
Bootstrap broker localhost:9094 (id: -3 rack: null) disconnected&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jenkinsfile의 docker-compose up 명령에 kafka-1만 포함되어 있었는데, 코드에서 KAFKA_BOOTSTRAP_SERVERS에 9092, 9093, 9094 세 개의 브로커가 등록되어 있어 kafka-2, kafka-3에도 연결을 시도했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App EC2에서 직접 kafka-2, kafka-3을 올리고, Jenkinsfile도 수정해서 다음 배포부터는 자동으로 올라오게 했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;bash&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;sudo docker-compose up -d kafka-2 kafka-3&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;groovy&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;lsl&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;# Jenkinsfile 수정
docker-compose up -d redis-master redis-sentinel-1 redis-sentinel-2 redis-sentinel-3 kafka-1 kafka-2 kafka-3 postgres-vector&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 최종 배포 구성&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;헬스체크 결과&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;json&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;json&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;{
  &quot;status&quot;: &quot;UP&quot;,
  &quot;components&quot;: {
    &quot;db&quot;: {&quot;status&quot;: &quot;UP&quot;, &quot;details&quot;: {&quot;database&quot;: &quot;MySQL&quot;}},
    &quot;diskSpace&quot;: {&quot;status&quot;: &quot;UP&quot;},
    &quot;ping&quot;: {&quot;status&quot;: &quot;UP&quot;},
    &quot;redis&quot;: {&quot;status&quot;: &quot;UP&quot;, &quot;details&quot;: {&quot;version&quot;: &quot;7.4.9&quot;}}
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;App EC2 최종 실행 컨테이너&lt;/h3&gt;
&lt;div&gt;컨테이너포트역할
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;redis-master&lt;/td&gt;
&lt;td&gt;6379&lt;/td&gt;
&lt;td&gt;Redis 마스터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;redis-sentinel-1&lt;/td&gt;
&lt;td&gt;26379&lt;/td&gt;
&lt;td&gt;Sentinel (HA)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;redis-sentinel-2&lt;/td&gt;
&lt;td&gt;26380&lt;/td&gt;
&lt;td&gt;Sentinel (HA)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;redis-sentinel-3&lt;/td&gt;
&lt;td&gt;26381&lt;/td&gt;
&lt;td&gt;Sentinel (HA)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kafka-1&lt;/td&gt;
&lt;td&gt;9092&lt;/td&gt;
&lt;td&gt;Kafka 브로커 1&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kafka-2&lt;/td&gt;
&lt;td&gt;9093&lt;/td&gt;
&lt;td&gt;Kafka 브로커 2&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kafka-3&lt;/td&gt;
&lt;td&gt;9094&lt;/td&gt;
&lt;td&gt;Kafka 브로커 3&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres-vector&lt;/td&gt;
&lt;td&gt;5432&lt;/td&gt;
&lt;td&gt;PGVector (AI 벡터 저장소)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;novelcraft&lt;/td&gt;
&lt;td&gt;8080&lt;/td&gt;
&lt;td&gt;Spring Boot 앱&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;메모리 현황&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;Mem:  1.9Gi total, 스왑 2.0Gi 추가
Disk: 8GB 중 약 5.8GB 사용 (2.3GB 여유)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 느낀 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스왑 메모리 설정은 RAM이 부족한 서버에서 임시방편으로 사용할 수 있는 좋은 방법이지만, 디스크 용량도 함께 고려해야 한다는 걸 오늘 직접 겪으면서 배웠다. 스왑을 너무 크게 잡으면 디스크가 꽉 차고, 너무 작게 잡으면 메모리 부족 문제가 해결이 안 되니 적절한 크기를 산정하는 것이 중요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀 프로젝트에서 한 사람이 인프라 설정을 바꾸면 다른 팀원들의 배포 환경에도 영향이 가기 때문에, 변경 사항을 팀원들과 공유하고 재배포 절차를 함께 맞춰가는 것이 중요하다는 것도 다시 한번 느꼈다.&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/218</guid>
      <comments>https://minwoo95.tistory.com/218#entry218comment</comments>
      <pubDate>Tue, 12 May 2026 22:26:16 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 18</title>
      <link>https://minwoo95.tistory.com/217</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778508629325&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bZlT8f/dJMb82eRCVG/f8sKxgpKnprYDkhMKOFks0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/3REgj/dJMb84X3l8h/jPgRUPskocz66casFPaF5K/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bZlT8f/dJMb82eRCVG/f8sKxgpKnprYDkhMKOFks0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/3REgj/dJMb84X3l8h/jPgRUPskocz66casFPaF5K/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_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;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778508638469&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/yyp15/dJMb84X3l8v/CMgKrCntk4nBj8I6gBKtKk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/S0R6r/dJMb86n2cxT/hBKElt1XrzLkk4vsK93nfK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/yyp15/dJMb84X3l8v/CMgKrCntk4nBj8I6gBKtKk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/S0R6r/dJMb86n2cxT/hBKElt1XrzLkk4vsK93nfK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_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;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 오늘 한 일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 개인 배포 환경(AWS EC2)에서 Jenkins + Docker를 통한 CI/CD 파이프라인을 구축하고, 실제로 애플리케이션을 배포하는 작업을 진행했다. 단순히 배포 스크립트를 작성하는 수준이 아니라, 발생하는 문제들을 하나씩 직접 추적하고 해결하는 과정이었다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Jenkins EC2 / App EC2 2대 구성으로 CI/CD 파이프라인 설계&lt;/li&gt;
&lt;li&gt;Jenkinsfile 작성 (Docker Build &amp;rarr; Docker Push &amp;rarr; SSH 배포)&lt;/li&gt;
&lt;li&gt;docker-compose.yml로 Redis Sentinel, Kafka, PGVector 인프라 구성&lt;/li&gt;
&lt;li&gt;App EC2 보안그룹 인바운드 규칙 설정&lt;/li&gt;
&lt;li&gt;GitHub Webhook 연동으로 push 시 자동 빌드 트리거&lt;/li&gt;
&lt;li&gt;EC2 t3.small 메모리 부족으로 인한 컨테이너 최소화 작업&lt;/li&gt;
&lt;li&gt;총 40여 회의 빌드 시도 끝에 배포 파이프라인 완성&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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. scp 전송 stuck 문제 (20분 이상 멈춤)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jenkins에서 App EC2로 docker-compose.yml, monitoring, init 폴더를 scp로 전송하는 단계에서 20분 이상 아무 응답 없이 멈추는 현상이 반복됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App EC2 자체가 메모리 과부하 상태였다. Redis Sentinel 3개 + Redis Slave 2개 + Kafka 3개 + JMX Exporter 3개 + Prometheus + Grafana + PGVector + 앱까지 t3.small(RAM 2GB)에 올리려다 보니 컨테이너들이 CPU/메모리를 전부 점유해 SSH 연결 자체를 처리할 수 없는 상태가 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS 콘솔에서 App EC2를 재부팅하고, Jenkinsfile의 docker-compose up 명령에서 모니터링 관련 컨테이너(Prometheus, Grafana, JMX Exporter 3개)를 제외하고 핵심 인프라만 올리도록 변경했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;groovy&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;crmsh&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;# 변경 전
docker-compose up -d

# 변경 후
docker-compose up -d redis-master redis-sentinel-1 redis-sentinel-2 redis-sentinel-3 kafka-1 postgres-vector&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-2. App EC2 IP 변경으로 SSH 연결 실패&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App EC2를 재부팅하자 퍼블릭 IP가 기존 15.165.232.231에서 43.202.163.88로 변경됐다. Jenkinsfile의 APP_EC2_IP 값이 이전 IP를 바라보고 있어서 SSH 연결이 계속 타임아웃됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AWS EC2는 탄력적 IP(Elastic IP)를 붙이지 않으면 재부팅 시 퍼블릭 IP가 바뀐다. 또한 App EC2 보안그룹에 등록된 Jenkins EC2 IP도 함께 업데이트하지 않아 이중으로 연결이 막혔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Jenkinsfile의 APP_EC2_IP를 43.202.163.88로 수정&lt;/li&gt;
&lt;li&gt;App EC2 보안그룹 인바운드 규칙에서 Jenkins EC2 IP를 새 IP(15.165.208.252)로 업데이트&lt;/li&gt;
&lt;li&gt;GitHub Webhook Payload URL도 새 Jenkins IP로 업데이트&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-3. monitoring 폴더가 파일 대신 디렉토리로 생성된 문제&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;scp로 monitoring 폴더를 전송할 때 아래 에러가 반복됐다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;vhdl&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;dest open(&quot;/home/ec2-user/monitoring/prometheus.yml&quot;): Failure
Uploading of file monitoring/prometheus.yml to /home/ec2-user/monitoring/prometheus.yml failed!
Couldn't setstat on &quot;/home/ec2-user/monitoring&quot;: Permission denied&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App EC2의 ~/monitoring/ 폴더 안에 prometheus.yml과 kafka-jmx-config.yml이 파일이 아니라 디렉토리로 생성되어 있었다. 이전에 잘못된 방식으로 폴더가 만들어진 것이 원인이었다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;bash&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;# 실제 상태 (파일이 아니라 디렉토리)
drwxr-xr-x. 2 root root 6 May 11 10:37 kafka-jmx-config.yml
drwxr-xr-x. 2 root root 6 May 11 10:37 prometheus.yml&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App EC2에서 해당 폴더를 삭제하고 Jenkins 재빌드로 정상 파일로 다시 전송했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;bash&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;jboss-cli&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;sudo rm -rf ~/monitoring
sudo rm -rf ~/init&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-4. EC2 재부팅 후 redis6 서비스 자동 시작으로 6379 포트 충돌&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;EC2 재부팅 후 docker-compose up 실행 시 아래 에러가 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;subunit&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Error response from daemon: driver failed programming external connectivity on endpoint redis-master:
Error starting userland proxy: listen tcp4 0.0.0.0:6379: bind: address already in use&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Amazon Linux 2023에는 redis6 패키지가 기본 설치되어 있으며, systemd 서비스로 등록되어 있다. EC2 재부팅 시 redis6 서비스가 자동으로 시작되면서 6379 포트를 선점하고, docker-compose의 redis-master 컨테이너가 같은 포트를 사용하려다 충돌이 발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;redis6 서비스를 중지하고 자동 시작을 비활성화했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;bash&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;gauss&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;sudo systemctl stop redis6
sudo systemctl disable redis6&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-5. BankVerificationClient 빈 누락으로 앱 기동 실패&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;배포 후 애플리케이션 로그에 아래 에러가 반복됐다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;No qualifying bean of type 'BankVerificationClient' available:
expected at least 1 bean which qualifies as autowire candidate.&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;LocalBankVerificationClient 클래스에 @Profile({&quot;dev&quot;, &quot;local&quot;, &quot;test&quot;})가 붙어있었는데, prod 프로파일이 누락되어 있었다. prod 환경에서는 해당 빈이 등록되지 않아 BankAccountService 생성에 실패했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;groovy&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// 문제 코드
@Profile({&quot;dev&quot;, &quot;local&quot;, &quot;test&quot;})
public class LocalBankVerificationClient implements BankVerificationClient {&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Profile에 prod를 추가했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;groovy&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// 수정 후
@Profile({&quot;dev&quot;, &quot;local&quot;, &quot;test&quot;, &quot;prod&quot;})
public class LocalBankVerificationClient implements BankVerificationClient {&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-6. 환경변수 없이 컨테이너 수동 실행으로 JWT 키 누락&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jenkins 빌드가 충돌 문제로 컨테이너를 올리지 못해 수동으로 docker run을 실행했는데, -e 옵션 없이 실행해버려 아래 에러가 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;livecodeserver&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Could not resolve placeholder 'jwt.secret.key' in value &quot;${jwt.secret.key}&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jenkinsfile에는 JWT, DB, Redis, Kafka 등 28개의 환경변수가 -e 옵션으로 주입되는데, 수동으로 컨테이너를 올릴 때는 환경변수가 전혀 없는 상태였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수동으로 올린 컨테이너를 삭제하고 Jenkins 빌드를 다시 트리거해서 환경변수가 모두 포함된 상태로 컨테이너를 올렸다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;bash&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;properties&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;sudo docker stop novelcraft
sudo docker rm novelcraft
# Jenkins 지금 빌드 클릭&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-7. PGVector(postgres-vector) 미실행으로 vectorStore 연결 실패&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앱 로그에 아래 에러가 반복됐다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;subunit&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Error creating bean with name 'vectorStore': Failed to obtain JDBC Connection&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jenkinsfile의 docker-compose up 명령에서 postgres-vector를 누락했다. AI 채팅 기능의 vectorStore가 PGVector에 의존하는데, 해당 컨테이너가 떠있지 않아 JDBC 연결에 실패했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;App EC2에서 직접 postgres-vector를 올리고 앱을 재시작했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;bash&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;maxima&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;sudo docker-compose up -d postgres-vector
sudo docker restart novelcraft&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 주요 구현 내용&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;CI/CD 파이프라인 아키텍처&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;GitHub push
    &amp;darr; Webhook
Jenkins EC2 (15.165.208.252)
    ├─ Checkout (GitHub)
    ├─ Docker Build (gradle:8.5-jdk17 &amp;rarr; eclipse-temurin:17-jdk-jammy)
    ├─ Docker Push (DockerHub: jmw1995/novelcraft)
    └─ Deploy to App EC2 (SSH)
            ├─ scp: docker-compose.yml, monitoring, init 전송
            ├─ docker-compose up -d (핵심 인프라만)
            ├─ docker stop/rm novelcraft
            ├─ docker pull jmw1995/novelcraft:latest
            └─ docker run (환경변수 28개 주입)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;App EC2 최종 실행 컨테이너 구성&lt;/h3&gt;
&lt;div&gt;컨테이너포트역할
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;redis-master&lt;/td&gt;
&lt;td&gt;6379&lt;/td&gt;
&lt;td&gt;Redis 마스터&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;redis-sentinel-1&lt;/td&gt;
&lt;td&gt;26379&lt;/td&gt;
&lt;td&gt;Sentinel (HA)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;redis-sentinel-2&lt;/td&gt;
&lt;td&gt;26380&lt;/td&gt;
&lt;td&gt;Sentinel (HA)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;redis-sentinel-3&lt;/td&gt;
&lt;td&gt;26381&lt;/td&gt;
&lt;td&gt;Sentinel (HA)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;kafka-1&lt;/td&gt;
&lt;td&gt;9092&lt;/td&gt;
&lt;td&gt;Kafka 브로커&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;postgres-vector&lt;/td&gt;
&lt;td&gt;5432&lt;/td&gt;
&lt;td&gt;PGVector (AI 벡터 저장소)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;novelcraft&lt;/td&gt;
&lt;td&gt;8080&lt;/td&gt;
&lt;td&gt;Spring Boot 앱&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Prometheus, Grafana, JMX Exporter, Redis Slave, kafka-2, kafka-3은 t3.small 메모리 한계로 제외했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Jenkinsfile 환경변수 주입 구성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Jenkins Credentials에 등록된 28개의 민감정보를 배포 시 docker run에 -e 옵션으로 주입했다. DB URL/계정, Redis Sentinel 노드, Kafka Bootstrap Servers, JWT/AES 키, OAuth 클라이언트 ID/Secret, PortOne, CoolSMS, 외부 API 키 등이 포함됐다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 느낀 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘 하루 동안 40회가 넘는 빌드를 시도하면서 배포가 이렇게 험난한 과정인지 처음으로 체감했다. 로컬에서 잘 돌아가는 코드가 운영 환경에서는 전혀 다른 문제들을 만난다는 것을 직접 겪었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 고통스러웠던 부분은 t3.small 메모리 한계였다. RAM 2GB에 Redis, Kafka, PGVector, 모니터링까지 올리려 하다 보니 서버가 과부하 상태에서 SSH 연결조차 안 되는 상황이 반복됐다. 결국 모니터링 컨테이너를 제외하고 핵심 인프라만 올리는 방식으로 타협했는데, 운영 환경에서 스펙 산정이 얼마나 중요한지 뼈저리게 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 배포 자동화 스크립트는 작은 실수 하나가 큰 문제로 이어진다는 것도 배웠다. 수동으로 컨테이너를 올리면서 환경변수를 빠뜨린 것처럼, 자동화된 파이프라인이 아닌 수동 조작은 항상 실수의 여지가 있다. CI/CD 파이프라인의 중요성을 다시 한번 실감했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 단계로는 postgres-vector도 Jenkinsfile의 docker-compose up 명령에 포함시켜 완전 자동화하고, EC2 재부팅 시에도 컨테이너가 자동으로 올라올 수 있도록 restart 정책을 정비할 예정이다.&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/217</guid>
      <comments>https://minwoo95.tistory.com/217#entry217comment</comments>
      <pubDate>Mon, 11 May 2026 23:12:39 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 17</title>
      <link>https://minwoo95.tistory.com/216</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778245183449&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&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;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778245190227&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&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;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 오늘 한 일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 AI 이미지 생성 SDK 연동과 Kafka 설정이 완료된 상태에서, 빠져있던 Grafana 모니터링 환경을 처음부터 끝까지 구축했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Spring Boot Actuator + Micrometer Prometheus 의존성 추가 및 application.yml 설정&lt;/li&gt;
&lt;li&gt;docker-compose.yml에 Kafka JMX Exporter 3개 추가 (kafka-1 / kafka-2 / kafka-3)&lt;/li&gt;
&lt;li&gt;Kafka 브로커 3개에 JMX 포트 환경변수 설정 (KAFKA_JMX_PORT: 9999, KAFKA_JMX_HOSTNAME)&lt;/li&gt;
&lt;li&gt;monitoring/kafka-jmx-config.yml 작성 (JMX 메트릭 수집 룰 정의)&lt;/li&gt;
&lt;li&gt;monitoring/prometheus.yml Kafka 스크랩 타겟 3개 추가&lt;/li&gt;
&lt;li&gt;Prometheus &amp;rarr; Grafana 데이터소스 연결 (http://prometheus:9090)&lt;/li&gt;
&lt;li&gt;Grafana 대시보드 Import (Spring Boot 19004, Kafka 7589)&lt;/li&gt;
&lt;li&gt;Prometheus Targets 전체 UP 상태 확인 (novelcraft / kafka-1 / kafka-2 / kafka-3)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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. Spring Boot 3.3.5에서 management.endpoint.prometheus.enabled 빨간불&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;application.yml에 아래와 같이 Prometheus 설정을 작성했더니 IDE에서 빨간불이 들어왔다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;management:
  endpoint:
    prometheus:
      enabled: true&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 3.3.x에서는 enabled 속성이 deprecated 되어 해당 키 자체가 제거됐다. micrometer-registry-prometheus 의존성이 classpath에 있으면 /actuator/prometheus 엔드포인트가 자동으로 활성화되기 때문에 별도로 enabled를 선언할 필요가 없어졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;enabled 관련 설정을 전부 제거하고 아래와 같이 정리했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;management:
  endpoints:
    web:
      exposure:
        include: health, prometheus, metrics, info
  endpoint:
    health:
      show-details: always
  metrics:
    tags:
      application: novelcraft&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의존성만 있으면 자동 활성화된다는 점을 다시 한번 확인했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-2. GRAFANA_ADMIN_PASSWORD 환경변수 누락으로 docker-compose 실행 실패&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;docker-compose up -d 실행 시 아래 에러가 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;subunit&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;error while interpolating services.grafana.environment.GF_SECURITY_ADMIN_PASSWORD:
required variable GRAFANA_ADMIN_PASSWORD is missing a value: set GRAFANA_ADMIN_PASSWORD&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 docker-compose.yml의 Grafana 설정이 아래와 같이 환경변수를 필수로 강제하는 방식이었다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;avrasm&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:?set GRAFANA_ADMIN_PASSWORD}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬에 .env 파일이 없어서 해당 변수를 찾지 못해 실행 자체가 차단됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 루트에 .env 파일을 생성하고 값을 추가했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;ini&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=admin&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.env 파일은 민감정보가 포함될 수 있으므로 .gitignore에 등록되어 있는지 반드시 확인해야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-3. Prometheus Targets에서 Kafka 타겟이 보이지 않음&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;http://localhost:9095/targets 에 접속했을 때 novelcraft 타겟만 UP 상태로 보이고 Kafka 타겟은 아예 표시되지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;prometheus.yml의 Kafka 스크랩 타겟을 아래와 같이 host.docker.internal로 설정했는데, Kafka JMX Exporter는 도커 컨테이너 내부에 떠있기 때문에 host.docker.internal로는 접근이 불가능했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;1c&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;# 잘못된 설정
- job_name: 'kafka'
  static_configs:
    - targets: ['host.docker.internal:5556']&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Boot 앱은 도커 외부(로컬)에서 실행되므로 host.docker.internal이 맞지만, JMX Exporter는 도커 네트워크 내부 서비스이므로 컨테이너 서비스명으로 접근해야 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;prometheus.yml을 아래와 같이 컨테이너 서비스명 기준으로 수정하고 Prometheus를 재시작했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;scrape_configs:
  - job_name: 'novelcraft'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['host.docker.internal:8080']   # 앱은 로컬 실행이므로 host.docker.internal

  - job_name: 'kafka-1'
    static_configs:
      - targets: ['kafka-1-jmx-exporter:5556']   # JMX Exporter는 도커 내부 서비스명

  - job_name: 'kafka-2'
    static_configs:
      - targets: ['kafka-2-jmx-exporter:5556']

  - job_name: 'kafka-3'
    static_configs:
      - targets: ['kafka-3-jmx-exporter:5556']&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;bash&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;ebnf&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;docker-compose restart prometheus&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재시작 후 Targets 페이지에서 kafka-1 / kafka-2 / kafka-3 모두 UP 상태를 확인했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 주요 구현 내용&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;최종 모니터링 아키텍처&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Spring Boot App (로컬:8080)
    └─ /actuator/prometheus
            &amp;darr;
        Prometheus (도커:9095)
            ├─ novelcraft job   &amp;rarr; host.docker.internal:8080
            ├─ kafka-1 job      &amp;rarr; kafka-1-jmx-exporter:5556
            ├─ kafka-2 job      &amp;rarr; kafka-2-jmx-exporter:5556
            └─ kafka-3 job      &amp;rarr; kafka-3-jmx-exporter:5556
                &amp;darr;
            Grafana (도커:3000)
                ├─ Spring Boot 3.x Statistics (ID: 19004)
                └─ Kafka Exporter Overview    (ID: 7589)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;docker-compose.yml 변경 사항 (팀원 영향 최소화)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 구조를 최대한 유지하면서 아래 항목만 추가했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;kafka-1 / kafka-2 / kafka-3 환경변수에 KAFKA_JMX_PORT: 9999, KAFKA_JMX_HOSTNAME 2줄 추가&lt;/li&gt;
&lt;li&gt;kafka-1-jmx-exporter, kafka-2-jmx-exporter, kafka-3-jmx-exporter 서비스 3개 신규 추가&lt;/li&gt;
&lt;li&gt;Prometheus depends_on에 JMX Exporter 3개 추가&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;신규 오픈 포트: 5556, 5557, 5558&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;monitoring/ 폴더 구조&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;arduino&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;monitoring/
├── prometheus.yml
└── kafka-jmx-config.yml&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;kafka-jmx-config.yml은 JMX 메트릭 수집 룰을 정의하는 파일로, 이 파일이 없으면 JMX Exporter 컨테이너가 뜨지 않는다. 팀원들이 docker-compose up 할 때 반드시 해당 파일이 있어야 하므로 함께 커밋했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Grafana 대시보드 결과&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Spring Boot 3.x Statistics&lt;/b&gt;: Uptime, Heap Used(2.9%), Non-Heap Used(17.8%), CPU Usage, Load Average 등 실시간 수집 확인&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Kafka Exporter Overview&lt;/b&gt;: 현재 메시지 발행/소비가 없어 No data 상태이며, 실제 트래픽 발생 시 데이터가 채워질 예정&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 느낀 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모니터링 환경 구축은 처음이라 설정 하나하나가 낯설었는데, 막상 Grafana 대시보드에 메트릭이 실시간으로 찍히는 걸 보니 뿌듯했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 헷갈렸던 부분은 도커 네트워크 개념이었다. Spring Boot 앱처럼 도커 외부에서 실행되는 서비스는 host.docker.internal로 접근해야 하고, JMX Exporter처럼 도커 내부 컨테이너는 서비스명으로 접근해야 한다는 점이 처음엔 직관적으로 와닿지 않았다. 타겟이 안 보이는 현상을 보고 직접 원인을 추적하면서 도커 네트워크 구조를 제대로 이해하게 됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;팀 프로젝트에서 docker-compose.yml을 수정할 때는 기존 구조를 얼마나 건드리지 않을 수 있느냐가 중요하다는 것도 느꼈다. 처음에는 과감하게 수정했다가 팀원 영향을 고려해서 최소한의 변경만 남기는 방향으로 다시 정리했는데, 공유 인프라 파일을 다룰 때는 항상 팀원 관점에서 먼저 생각해야 한다는 걸 다시 한번 깨달았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음 단계로는 실제로 Kafka 메시지가 발행/소비될 때 Grafana 대시보드에서 Consumer Lag, Message In/Out per second 등이 어떻게 보이는지 확인해볼 예정이다.&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/216</guid>
      <comments>https://minwoo95.tistory.com/216#entry216comment</comments>
      <pubDate>Fri, 8 May 2026 22:00:21 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 16</title>
      <link>https://minwoo95.tistory.com/215</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778154528074&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cPT8Fe/dJMb9jOq1Q3/k3nqCfozbnD57Nu2PIjVk0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bhvxq8/dJMb9lMfAw4/2NCjGLStKCrKuNA7kKTexk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cPT8Fe/dJMb9jOq1Q3/k3nqCfozbnD57Nu2PIjVk0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/bhvxq8/dJMb9lMfAw4/2NCjGLStKCrKuNA7kKTexk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_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;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778154531857&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&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;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 오늘 한 일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 NovelCraft 프로젝트에서 AI 소설 표지 생성 기능을 동기 방식에서 Kafka 기반 비동기 처리로 고도화하고, 코드래빗 PR 리뷰를 반영하여 방어 로직을 강화했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Google Gemini API 연동 기반 AI 소설 표지 생성 기능 구현&lt;/li&gt;
&lt;li&gt;동기 방식(30초 블로킹) &amp;rarr; Kafka 비동기 처리로 개선 (응답 시간 30초 &amp;rarr; 150ms)&lt;/li&gt;
&lt;li&gt;CoverJob 엔티티 추가 (PENDING &amp;rarr; PROCESSING &amp;rarr; COMPLETED/FAILED 상태 추적)&lt;/li&gt;
&lt;li&gt;CoverGenerationProducer 구현 (Kafka 토픽 발행)&lt;/li&gt;
&lt;li&gt;CoverGenerationConsumer 구현 (Gemini 재시도 3회, S3 재시도 3회, 포인트 환불)&lt;/li&gt;
&lt;li&gt;CoverJobEventRelay 구현 (@TransactionalEventListener(AFTER_COMMIT) 적용)&lt;/li&gt;
&lt;li&gt;표지 생성 상태 조회 API 추가 (GET /api/ai/novels/cover/status/{jobId})&lt;/li&gt;
&lt;li&gt;작가 권한 + 본인 소설 검증 + 300포인트 차감 로직 구현&lt;/li&gt;
&lt;li&gt;코드래빗 리뷰 5개 항목 반영 (중복 이벤트 방어, 포인트 차감 플래그, 상태 전이 가드 등)&lt;/li&gt;
&lt;li&gt;CoverService, CoverGenerationConsumer, CoverJob 단위 테스트 작성 (커버리지 100%)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. AI 소설 표지 생성 기능 구현&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;기술 선택 - Gemini vs DALL-E 3&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 DALL-E 3로 구현했으나 한국어 텍스트 렌더링이 깨지는 문제가 있었다. Google Gemini(gemini-2.5-flash-image)로 교체하자 한국어 제목과 작가명이 이미지에 완벽하게 렌더링됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gemini API는 이미지를 바이트 배열로 반환하는 구조라 DALL-E 3처럼 URL을 바로 반환하지 않는다. 따라서 S3에 직접 업로드하고 영구 URL을 반환하는 방식으로 구현했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프롬프트 설계&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;소설 DB에서 제목, 작가 닉네임, 장르, 줄거리를 자동으로 조회해 프롬프트에 주입한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #eaecf0;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;String.format(
    &quot;Create a professional Korean novel cover image. &quot; +
    &quot;The title '%s' must be written clearly and legibly at the top in large, stylish Korean typography. &quot; +
    &quot;Below the title, write the author name '%s 지음' in smaller Korean text. &quot; +
    &quot;Genre: %s. Story summary: %s. &quot; +
    &quot;Style: cinematic, high quality book cover art.&quot;,
    novel.getTitle(), authorName, novel.getGenre(), novel.getDescription()
)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. Kafka 비동기 처리 고도화&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 동기 방식이 문제였나&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gemini API 호출은 평균 20~30초가 소요된다. 동기 방식으로 처리하면 해당 스레드가 30초간 블로킹되어 톰캣 스레드 풀이 고갈될 위험이 있고, 다른 API 요청에도 영향을 미친다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 @Async가 아닌 Kafka인가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순 비동기 처리라면 @Async로도 가능하지만 Kafka를 선택한 이유는 세 가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;첫째, 메시지 영속성이다. 서버가 재시작되더라도 Kafka 브로커에 메시지가 남아있어 처리가 재개된다. @Async는 서버 재시작 시 처리 중인 작업이 유실된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘째, 프로젝트에 Kafka가 이미 알림 시스템에 도입되어 있어 추가 인프라 없이 일관된 아키텍처를 유지할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;셋째, Consumer를 독립적으로 스케일아웃할 수 있는 구조다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 AWS Lambda가 아닌 Kafka인가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lambda도 좋은 선택지지만 이 프로젝트에서 Kafka를 선택한 이유는 다음과 같다. Lambda는 Cold Start 문제가 있어 첫 요청 시 수초의 지연이 발생할 수 있는데, Gemini API 자체가 이미 30초가 걸리는 상황에서 추가 지연은 부담이었다. 또한 Lambda + API Gateway + SQS 조합은 설정 복잡도가 높아 팀 프로젝트 일정상 Kafka가 현실적인 선택이었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;처리 흐름&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #eaecf0;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;POST /api/ai/novels/{novelId}/cover
    &amp;darr; 즉시 jobId 반환 (150ms)
CoverService &amp;rarr; CoverJob 저장 &amp;rarr; ApplicationEventPublisher.publishEvent()
    &amp;darr; 트랜잭션 커밋 후
CoverJobEventRelay (@TransactionalEventListener AFTER_COMMIT)
    &amp;darr;
CoverGenerationProducer &amp;rarr; Kafka 토픽 발행
    &amp;darr;
CoverGenerationConsumer
    &amp;rarr; PENDING 상태 가드 (중복 이벤트 방어)
    &amp;rarr; PROCESSING 상태 전이
    &amp;rarr; Gemini 호출 (재시도 3회)
    &amp;rarr; S3 업로드 (재시도 3회)
    &amp;rarr; 포인트 차감 (isDeducted 플래그)
    &amp;rarr; COMPLETED 상태 전이
    &amp;rarr; 실패 시 FAILED + 포인트 환불 (차감된 경우에만)

GET /api/ai/novels/cover/status/{jobId}
    &amp;rarr; PENDING / PROCESSING / COMPLETED / FAILED 조회&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;폴링 방식&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 클라이언트가 주기적으로 상태 조회 API를 호출하는 폴링 방식으로 구현했다. 개선 방향으로는 프로젝트에 이미 WebSocket이 알림 시스템에 도입되어 있으므로 서버에서 완료 시점에 Push하는 방식으로 전환할 수 있다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 코드래빗 리뷰 반영&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-1. 중복 Kafka 이벤트 방어 (Major)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 jobId에 대한 중복 메시지가 도착할 경우 상태 검증 없이 모든 작업을 반복 실행하는 문제가 있었다. 포인트가 여러 번 차감될 수 있는 치명적인 버그다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #eaecf0;&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;// 수정 전 - 가드 없음
job.processing();

// 수정 후 - PENDING 상태에서만 처리
if (job.getStatus() != CoverJobStatus.PENDING) {
    log.warn(&quot;[Cover] 이미 처리된 Job 스킵 jobId={} status={}&quot;, event.jobId(), job.getStatus());
    return;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-2. 포인트 차감 전 실패 시 환불 방지 (Critical)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;포인트 차감은 Gemini/S3 성공 후에 이루어지는데, 기존 catch 블록에서 차감 여부와 무관하게 무조건 charge()를 호출하고 있었다. Gemini/S3 단계에서 실패하면 차감도 안 됐는데 포인트가 그냥 증가하는 버그였다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;cs&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;boolean isDeducted = false;

pointService.deduct(event.userId(), COVER_COST);
isDeducted = true;

// catch 블록에서
if (isDeducted) {
    pointService.charge(event.userId(), COVER_COST); // 차감된 경우에만 환불
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-3. complete/fail 시 반대 필드 정리 (Major)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;complete() 호출 시 이전에 설정된 errorMessage가 남아있거나, fail() 호출 시 coverImageUrl이 남아있어 조회 응답에 혼합 상태가 노출될 수 있는 문제였다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;kotlin&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;public void complete(String coverImageUrl) {
    this.status = CoverJobStatus.COMPLETED;
    this.coverImageUrl = coverImageUrl;
    this.errorMessage = null; // 추가
}

public void fail(String errorMessage) {
    this.status = CoverJobStatus.FAILED;
    this.errorMessage = errorMessage;
    this.coverImageUrl = null; // 추가
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-4. Kafka 발행 실패 시 PENDING 영구 정체 (Major)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Kafka 발행이 실패하면 Job이 PENDING 상태로 영구히 남는 문제가 있었다. 발행 실패를 상위로 전파하거나 즉시 FAILED로 전환해야 한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;cos&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;try {
    eventPublisher.publishEvent(new CoverJobCreatedEvent(jobId, novelId, userId));
} catch (Exception e) {
    job.fail(&quot;Kafka 발행 실패&quot;);
    coverJobRepository.save(job);
    throw e;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4-5. 트랜잭션 커밋 전 Kafka 발행 (Major)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CoverJob을 저장한 직후 Kafka를 발행하면, Consumer가 메시지를 소비했을 때 트랜잭션이 아직 커밋되지 않아 DB에서 jobId를 찾지 못하는 경우가 발생할 수 있다. 알림 시스템의 NotificationEventRelay 패턴을 참고해 @TransactionalEventListener(AFTER_COMMIT)으로 해결했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onCoverJobCreated(CoverJobCreatedEvent event) {
    coverGenerationProducer.publish(
            new CoverGenerationEvent(event.jobId(), event.novelId(), event.userId())
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 트러블슈팅&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-1. Gemini 1.5 Flash로 변경했으나 404 오류&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gemini AI에게 &quot;이미지 생성이 가능한 모델을 알려달라&quot;고 물어보니 gemini-1.5-flash를 추천했다. 변경 후 테스트했으나 아래 오류가 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;404 Not Found. models/gemini-1.5-flash is not found for API version v1beta, 
or is not supported for generateContent.&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Gemini 1.5 Flash는 텍스트 전용 모델이라 이미지 생성 자체가 불가능하다. 이미지 생성은 gemini-2.5-flash-image 모델을 사용해야 하며, 해당 모델은 무료 API 티어에서는 호출이 불가하다. AI Studio 웹 UI에서는 무료로 사용 가능하지만 API 호출은 별도 과금 구조다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt; Google AI Studio에서 결제 등록 후 gemini-2.5-flash-image 모델 사용&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-2. S3 업로드 후 Access Denied&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;S3 업로드는 성공했으나 이미지 URL 접근 시 Access Denied XML 응답이 반환됐다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;xml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;xml&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;&amp;lt;Error&amp;gt;
  &amp;lt;Code&amp;gt;AccessDenied&amp;lt;/Code&amp;gt;
  &amp;lt;Message&amp;gt;Access Denied&amp;lt;/Message&amp;gt;
&amp;lt;/Error&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인:&lt;/b&gt; 버킷 정책에 퍼블릭 GetObject 허용 설정이 누락됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt; S3 버킷 정책에 아래 내용 추가&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;json&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;json&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;{
    &quot;Version&quot;: &quot;2012-10-17&quot;,
    &quot;Statement&quot;: [
        {
            &quot;Effect&quot;: &quot;Allow&quot;,
            &quot;Principal&quot;: &quot;*&quot;,
            &quot;Action&quot;: &quot;s3:GetObject&quot;,
            &quot;Resource&quot;: &quot;arn:aws:s3:::hot6-novelcraft-minwoo/*&quot;
        }
    ]
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-3. point_histories.type 컬럼 Data truncated 오류&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI_COVER 타입을 PointHistoryType enum에 추가했으나 실제 API 호출 시 500 에러가 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;SQL Error: 1265, SQLState: 01000
Data truncated for column 'type' at row 1&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인:&lt;/b&gt; point_histories.type 컬럼이 VARCHAR(10)으로 정의되어 있어 8자인 AI_COVER가 저장되지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;sql&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;ALTER TABLE point_histories MODIFY COLUMN type VARCHAR(20) NOT NULL;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엔티티에도 명시적으로 length 추가&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;@Column(nullable = false, length = 20)
@Enumerated(value = EnumType.STRING)
private PointHistoryType type;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-4. 기존 CoverService 코드와 포인트 중복 차감&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Consumer에서 포인트 차감을 처리하는데 CoverService에도 포인트 선차감 로직이 남아있어 이중 차감이 발생할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt; CoverService에서 포인트 차감 로직 전체 제거. 포인트 처리는 Consumer에서만 담당하도록 단일 책임 원칙을 적용했다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5-5. CoverServiceTest - ArgumentCaptor가 값을 캡처하지 못하는 문제&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;fortran&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;No argument value was captured!
You might have forgotten to use argument.capture() in verify()&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인:&lt;/b&gt; CoverService가 CoverGenerationProducer를 직접 호출하는 방식에서 ApplicationEventPublisher를 통한 이벤트 발행 방식으로 변경됐는데, 테스트 코드의 Mock 및 검증 대상이 업데이트되지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;java&quot; style=&quot;color: #eaecf0;&quot;&gt;&lt;code&gt;// 기존
@Mock private CoverGenerationProducer coverGenerationProducer;
verify(coverGenerationProducer).publish(any(CoverGenerationEvent.class));

// 수정
@Mock private ApplicationEventPublisher eventPublisher;
verify(eventPublisher).publishEvent(any(CoverJobCreatedEvent.class));&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. 오늘의 회고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘 하루 동안 단순한 AI API 연동에서 시작해 Kafka 비동기 처리까지 고도화하면서 여러 가지를 배웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;설계 관점에서&lt;/b&gt; 외부 API 호출처럼 시간이 오래 걸리는 작업은 동기 방식으로 처리하면 안 된다는 것을 다시 한번 체감했다. 30초 블로킹이 단순히 사용자 경험의 문제가 아니라 서버 자원 고갈로 이어질 수 있다는 점이 핵심이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Kafka와 @Async의 차이&lt;/b&gt;에 대해서도 명확하게 정리됐다. @Async는 구현이 간단하지만 서버 재시작 시 메시지 유실이 발생한다. Kafka는 복잡도가 높지만 메시지가 브로커에 영속화되어 장애 상황에도 재처리가 가능하다. 어떤 것이 절대적으로 옳은 것이 아니라 상황에 따라 트레이드오프를 이해하고 선택하는 것이 중요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;코드래빗 리뷰&lt;/b&gt;에서 다섯 가지 문제를 지적받았는데, 그중 Critical 항목인 &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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;오늘의 AI 이미지 생성 연결 결과물!&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;프롬포트를 작성하는게 아니라, 이미 가지고 있는 소설 정보를 바탕으로 알아서 자동생성해주는 기능으로 구현완료!&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;a88e6a52-3bff-4d99-8356-f8b52896b979.png&quot; data-origin-width=&quot;896&quot; data-origin-height=&quot;1200&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rvknb/dJMcaaFeXI7/Bm1BVkwx0Gv2VTzyzrS8bk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rvknb/dJMcaaFeXI7/Bm1BVkwx0Gv2VTzyzrS8bk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rvknb/dJMcaaFeXI7/Bm1BVkwx0Gv2VTzyzrS8bk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Frvknb%2FdJMcaaFeXI7%2FBm1BVkwx0Gv2VTzyzrS8bk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;896&quot; height=&quot;1200&quot; data-filename=&quot;a88e6a52-3bff-4d99-8356-f8b52896b979.png&quot; data-origin-width=&quot;896&quot; data-origin-height=&quot;1200&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/215</guid>
      <comments>https://minwoo95.tistory.com/215#entry215comment</comments>
      <pubDate>Thu, 7 May 2026 20:53:19 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 15</title>
      <link>https://minwoo95.tistory.com/214</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778072596746&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bMF2sa/dJMb86n1xzl/ZREiJxyaqBc92qKCELw7W0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/Rp5MF/dJMb887cMfF/32G6cedDVmzofP485bRkA0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bMF2sa/dJMb86n1xzl/ZREiJxyaqBc92qKCELw7W0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/Rp5MF/dJMb887cMfF/32G6cedDVmzofP485bRkA0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_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;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778072602698&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/0gqvv/dJMb9hC5bzN/i2oVUTAed4aiKLcMxTM2DK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/51ZOQ/dJMb84X2GnU/hlD4SWFVmghrY2PI4Niz41/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/0gqvv/dJMb9hC5bzN/i2oVUTAed4aiKLcMxTM2DK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/51ZOQ/dJMb84X2GnU/hlD4SWFVmghrY2PI4Niz41/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_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;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. 오늘 한 일&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘은 NovelCraft 프로젝트에서 AI 도메인 중 소설 표지 생성 기능을 담당하여 OpenAI DALL-E 3 API를 Spring Boot에 처음으로 도입했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;AI 도메인 패키지 구조 설계 (ai/cover 독립 패키지)&lt;/li&gt;
&lt;li&gt;OpenAI Java SDK 의존성 추가 및 API 키 설정&lt;/li&gt;
&lt;li&gt;DallEClient 구현 (DALL-E 3 API 호출 전담)&lt;/li&gt;
&lt;li&gt;CoverService 구현 (소설 정보 DB 조회 &amp;rarr; 프롬프트 자동 생성 &amp;rarr; 이미지 생성)&lt;/li&gt;
&lt;li&gt;CoverController 구현 (POST /api/ai/novels/{novelId}/cover)&lt;/li&gt;
&lt;li&gt;CoverExceptionEnum 구현 (공통 예외 패턴 적용)&lt;/li&gt;
&lt;li&gt;Postman으로 로컬 테스트 완료 (DALL-E 응답 성공 확인)&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&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. Optional.get()에 인덱스 인자를 넣어 컴파일 에러 발생&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DallEClient에서 DALL-E 응답의 이미지 URL을 꺼내는 코드를 아래와 같이 작성했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;css&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;return response.data().get(0).url()
        .orElseThrow(() -&amp;gt; CoverExceptionEnum.IMAGE_GENERATION_FAILED.toException());&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴파일 시 아래 에러가 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;oxygene&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;method get in class Optional&amp;lt;T&amp;gt; cannot be applied to given types;
required: no arguments
found: int&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;OpenAI Java SDK에서 response.data()의 반환 타입이 List&amp;lt;Image&amp;gt;가 아닌 Optional&amp;lt;List&amp;lt;Image&amp;gt;&amp;gt;였다. Optional의 get()은 인자를 받지 않는데 인덱스 0을 넣어버리니 타입 불일치 에러가 발생한 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;response.data() 자체가 Optional이므로 먼저 orElseThrow()로 꺼낸 뒤 리스트 인덱스로 접근하도록 수정했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;livescript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;return response.data()
        .orElseThrow(() -&amp;gt; CoverExceptionEnum.IMAGE_GENERATION_FAILED.toException())
        .get(0)
        .url()
        .orElseThrow(() -&amp;gt; CoverExceptionEnum.IMAGE_GENERATION_FAILED.toException());&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SDK를 처음 사용할 때는 반환 타입을 IDE에서 직접 확인하는 습관이 필요하다는 것을 느꼈다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2-2. S3 업로드 403 에러 (The request signature does not match)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DALL-E 호출은 성공했지만 이후 S3 업로드 단계에서 아래 에러가 발생했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;S3Exception: The request signature we calculated does not match the signature you provided.
Status Code: 403&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;원인 분석&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 테스트 환경에서 S3 액세스 키 권한 문제로 서명 불일치가 발생한 것이었다. DALL-E가 반환하는 임시 URL을 S3에 업로드하는 흐름이었는데, 로컬에서는 S3 연동 자체가 불필요한 상황이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로컬 테스트 단계에서는 S3 업로드 없이 DALL-E가 반환한 임시 URL을 그대로 반환하도록 수정했다. DALL-E URL은 1시간 후 만료되지만 로컬 테스트 목적으로는 충분하다. 실제 배포 시에는 S3 업로드 로직을 복구할 예정이다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;public CoverCreateResponse generateCover(Long novelId) {
    Novel novel = novelRepository.findByIdAndIsDeletedFalse(novelId)
            .orElseThrow(() -&amp;gt; CoverExceptionEnum.NOVEL_NOT_FOUND.toException());

    String prompt = buildPrompt(novel);
    String imageUrl = dallEClient.generateImage(prompt);  // DALL-E URL 바로 반환

    return CoverCreateResponse.of(novelId, imageUrl);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 주요 구현 내용&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;패키지 구조&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;vbscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;ai/cover/
├── controller/  CoverController
├── service/     CoverService
├── client/      DallEClient
├── dto/
│   ├── request/   CoverCreateRequest
│   └── response/  CoverCreateResponse
└── exception/   CoverExceptionEnum&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;프롬프트 자동 생성 전략&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;클라이언트에서 별도 요청 파라미터 없이 novelId만 받아서 DB에서 소설 정보를 조회한 뒤 프롬프트를 자동으로 구성했다. no text, no letters 지시어를 반드시 포함해야 이미지에 깨진 글자가 생성되지 않는다는 점을 알게 됐다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;private String buildPrompt(Novel novel) {
    return String.format(
            &quot;Create a professional Korean novel cover image. &quot; +
            &quot;Title: %s, Genre: %s. &quot; +
            &quot;Story summary: %s. &quot; +
            &quot;Style: cinematic, high quality book cover art, no text, no letters.&quot;,
            novel.getTitle(),
            novel.getGenre(),
            novel.getDescription()
    );
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;API 엔드포인트&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;dts&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;POST /api/ai/novels/{novelId}/cover
Authorization: Bearer {JWT}

&amp;rarr; 응답
{
    &quot;success&quot;: true,
    &quot;status&quot;: &quot;200&quot;,
    &quot;message&quot;: &quot;소설 표지가 성공적으로 생성되었습니다&quot;,
    &quot;data&quot;: {
        &quot;novelId&quot;: 1,
        &quot;coverImageUrl&quot;: &quot;https://...&quot;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 느낀 점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI API를 Spring Boot에 처음 도입해봤는데 생각보다 구조 자체는 단순했다. 외부 API 클라이언트를 별도 client 레이어로 분리하니 서비스 코드가 깔끔하게 유지됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;DALL-E 3는 응답 시간이 평균 20초 이상 걸린다는 점이 인상적이었다. 실서비스라면 비동기 처리나 로딩 UX가 반드시 필요하다는 걸 체감했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미지에 제목이나 작가명을 자연스럽게 넣는 것은 DALL-E만으로는 한계가 있다는 점도 알게 됐다. 실제 서비스에서는 배경 이미지 생성 후 프론트엔드에서 텍스트를 합성하는 방식이 가장 현실적이라는 결론을 팀원들과 공유했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI 기능은 구현 자체보다 &lt;b&gt;어떤 프롬프트를 어떻게 구성하느냐&lt;/b&gt;가 품질을 좌우한다는 것을 느꼈다. 앞으로 프롬프트 엔지니어링도 백엔드 개발자가 알아야 할 중요한 역량이 될 것 같다.&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/214</guid>
      <comments>https://minwoo95.tistory.com/214#entry214comment</comments>
      <pubDate>Wed, 6 May 2026 22:03:57 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 14</title>
      <link>https://minwoo95.tistory.com/213</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778045337212&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bMF2sa/dJMb86n1xzl/ZREiJxyaqBc92qKCELw7W0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/Rp5MF/dJMb887cMfF/32G6cedDVmzofP485bRkA0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bMF2sa/dJMb86n1xzl/ZREiJxyaqBc92qKCELw7W0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/Rp5MF/dJMb887cMfF/32G6cedDVmzofP485bRkA0/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_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;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778045345451&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/0gqvv/dJMb9hC5bzN/i2oVUTAed4aiKLcMxTM2DK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/51ZOQ/dJMb84X2GnU/hlD4SWFVmghrY2PI4Niz41/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/0gqvv/dJMb9hC5bzN/i2oVUTAed4aiKLcMxTM2DK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/51ZOQ/dJMb84X2GnU/hlD4SWFVmghrY2PI4Niz41/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_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;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;오늘 작업 요약&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NovelCraft 웹소설 창작 플랫폼 포트폴리오 증거 자료 확보를 위해 다음 항목들을 진행했다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;Prometheus + Grafana 모니터링 구축&lt;/li&gt;
&lt;li&gt;이벤트 참여 동시성 부하테스트 (Redisson 분산락)&lt;/li&gt;
&lt;li&gt;수익 환전 동시성 부하테스트 (Redis SETNX 분산락)&lt;/li&gt;
&lt;li&gt;멘토링 멘티 수락 동시성 테스트 (낙관적 락)&lt;/li&gt;
&lt;li&gt;멘토링 피드백 V1/V2 동시성 비교 (비관적 락)&lt;/li&gt;
&lt;li&gt;revenues 인덱스 성능 비교&lt;/li&gt;
&lt;li&gt;캐시 성능 비교 (이벤트 목록 / 수익 통계 / 도서 검색)&lt;/li&gt;
&lt;li&gt;Redis Record 클래스 역직렬화 버그 수정&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;1. Prometheus + Grafana 모니터링 구축&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 참여 부하 테스트를 돌리면서 Grafana 대시보드로 실시간 지표를 캡처해 포트폴리오 증거 자료로 남기려고 구축했다. &quot;동시 200명 요청, 정합성 100%, 평균 응답 Xms&quot;를 Grafana 대시보드 스크린샷 한 장으로 증명하는 게 말 백 마디보다 강력하다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;구성도&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Spring Boot (IntelliJ 실행)
    &amp;darr; /actuator/prometheus 노출
Prometheus (Docker, 포트 9095)
    &amp;darr; 15초마다 scrape
Grafana (Docker, 포트 3000)
    &amp;darr; 시각화 대시보드&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;1단계: build.gradle 의존성 추가&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;gradle&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;sml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;implementation 'org.springframework.boot:spring-boot-starter-actuator'
runtimeOnly 'io.micrometer:micrometer-registry-prometheus'&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2단계: application.yml 설정 추가&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;management:
  endpoints:
    web:
      exposure:
        include: health, prometheus, metrics
  endpoint:
    health:
      show-details: always
  metrics:
    tags:
      application: novelcraft&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3단계: SecurityConfig + JwtFilter 수정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Actuator 엔드포인트가 Security 필터와 JwtFilter에 막혀 있어서 두 곳 모두 수정이 필요했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SecurityConfig .requestMatchers 부분에 추가:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;1c&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;, &quot;/actuator/**&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JwtFilter shouldNotFilter 메서드에 추가:&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;1c&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;|| path.startsWith(&quot;/actuator&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;4단계: docker-compose.yml에 Prometheus + Grafana 추가&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 Kafka 3노드 클러스터가 있는 docker-compose.yml에 두 컨테이너를 추가했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;prometheus:
  image: prom/prometheus:latest
  container_name: prometheus
  ports:
    - &quot;9095:9090&quot;   # Kafka-2가 9093 포트를 사용해 9095로 설정
  volumes:
    - ./monitoring/prometheus.yml:/etc/prometheus/prometheus.yml
    - prometheus_data:/prometheus
  networks:
    - kafka-net

grafana:
  image: grafana/grafana:latest
  container_name: grafana
  ports:
    - &quot;3000:3000&quot;
  environment:
    GF_SECURITY_ADMIN_USER: admin
    GF_SECURITY_ADMIN_PASSWORD: admin
  volumes:
    - grafana_data:/var/lib/grafana
  depends_on:
    - prometheus
  networks:
    - kafka-net&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;5단계: prometheus.yml 설정&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;yaml&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;global:
  scrape_interval: 15s

scrape_configs:
  - job_name: 'novelcraft'
    metrics_path: '/actuator/prometheus'
    static_configs:
      - targets: ['host.docker.internal:8080']&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;host.docker.internal은 Docker 컨테이너에서 Mac 로컬의 Spring Boot 앱에 접근할 수 있게 해주는 주소다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;6단계: Grafana 대시보드 구성&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;http://localhost:3000 접속 (admin/admin)&lt;/li&gt;
&lt;li&gt;Connections &amp;rarr; Data Sources &amp;rarr; Prometheus 추가&lt;/li&gt;
&lt;li&gt;URL: http://prometheus:9090 (컨테이너 내부 통신이라 localhost가 아닌 컨테이너 이름 사용)&lt;/li&gt;
&lt;li&gt;Dashboards &amp;rarr; Import &amp;rarr; ID 4701 (Spring Boot 전용 대시보드) 임포트&lt;/li&gt;
&lt;li&gt;Application 변수: Query에 label_values(application) 입력 &amp;rarr; novelcraft 확인&lt;/li&gt;
&lt;li&gt;Instance 변수: Query에 label_values(jvm_info, instance) 입력&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;트러블슈팅: Prometheus 포트 충돌&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 포트 9090이 Kafka-2 브로커와 충돌할 수 있어 호스트 포트를 9095로 변경했다. Grafana에서 Prometheus 연결 시에는 내부 컨테이너 통신이라 prometheus:9090을 사용한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;트러블슈팅: Grafana Application 변수 설정&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4701 대시보드 임포트 후 Application 드롭다운에 아무것도 안 떴다. 변수 설정에서 Target data source가 비어 있어서 발생. Variables &amp;rarr; Application &amp;rarr; Target data source를 Prometheus로 선택하고 Query에 label_values(application) 입력 후 novelcraft가 나타나면 성공이다. Instance 변수도 동일하게 label_values(jvm_info, instance) 입력.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;1332&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsJ7pt/dJMcadBPtGr/4RcP7hNXGjd9KiN3elUCKK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsJ7pt/dJMcadBPtGr/4RcP7hNXGjd9KiN3elUCKK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsJ7pt/dJMcadBPtGr/4RcP7hNXGjd9KiN3elUCKK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsJ7pt%2FdJMcadBPtGr%2F4RcP7hNXGjd9KiN3elUCKK%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;2914&quot; height=&quot;1332&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;1332&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;2. 이벤트 참여 동시성 부하테스트 (Redisson 분산락)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;maxParticipants=100인 이벤트에 &lt;b&gt;200명이 동시에&lt;/b&gt; 참여 신청. 정확히 100명만 참여가 성공하는지 데이터 정합성 검증.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;k6 시나리오 구성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 가지 시나리오를 동시에 실행했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;javascript&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;export const options = {
    scenarios: {
        // 시나리오 1: 동시 참여 신청 (200명 동시 요청)
        spike_participate: {
            executor: 'shared-iterations',
            vus: 200,        // 동시 가상 유저 200명
            iterations: 200, // 총 200번 요청 (유저당 1번)
            maxDuration: '60s',
            exec: 'participateTest',
        },
        // 시나리오 2: 조회 API 지속 부하 (Grafana 수치 확보용)
        sustained_read: {
            executor: 'constant-vus',
            vus: 50,         // 동시 가상 유저 50명
            duration: '30s', // 30초 동안 지속
            startTime: '0s',
            exec: 'readTest',
        },
    },
};&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;shared-iterations: 200개의 iteration을 200개의 VU가 나눠서 처리. 각 VU는 로그인 후 이벤트 참여 요청 1회 실행.&lt;/li&gt;
&lt;li&gt;constant-vus: 50명이 30초 동안 계속 이벤트 목록/상세 조회를 반복해 Grafana에 의미 있는 트래픽 생성.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 유저: loadtest1~&lt;a href=&quot;mailto:200@test.com&quot;&gt;200@test.com&lt;/a&gt; (200명, BCrypt 해싱 후 DB에 사전 삽입)&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Before (분산락 버그 있는 버전)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제:&lt;/b&gt; @Transactional과 Redisson 분산락 해제 순서 버그&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;cs&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// 잘못된 코드: 트랜잭션 커밋 전에 락이 해제됨
@Transactional
public void participate(Long eventId, Long userId) {
    RLock lock = redissonClient.getLock(&quot;event:&quot; + eventId);
    lock.lock();
    try {
        // DB 로직...
    } finally {
        lock.unlock(); // 여기서 락 해제 &amp;rarr; 트랜잭션은 아직 커밋 안 됨
    }
    // 메서드 종료 시 @Transactional이 커밋 &amp;rarr; 이미 락 해제 후라 다른 요청이 진입 가능
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결과:&lt;/b&gt; 101명 참여 &amp;rarr; 정합성 깨짐&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1682&quot; data-origin-height=&quot;528&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/5jeH1/dJMcadolBe7/aNSvOX3DAMk0eEpmoVotXK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/5jeH1/dJMcadolBe7/aNSvOX3DAMk0eEpmoVotXK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/5jeH1/dJMcadolBe7/aNSvOX3DAMk0eEpmoVotXK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F5jeH1%2FdJMcadolBe7%2FaNSvOX3DAMk0eEpmoVotXK%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;1682&quot; height=&quot;528&quot; data-origin-width=&quot;1682&quot; data-origin-height=&quot;528&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2172&quot; data-origin-height=&quot;548&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cDUBmv/dJMcadolBg7/oZRIkOGCQVWifXidYkREW1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cDUBmv/dJMcadolBg7/oZRIkOGCQVWifXidYkREW1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cDUBmv/dJMcadolBg7/oZRIkOGCQVWifXidYkREW1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcDUBmv%2FdJMcadolBg7%2FoZRIkOGCQVWifXidYkREW1%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;2172&quot; height=&quot;548&quot; data-origin-width=&quot;2172&quot; data-origin-height=&quot;548&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;After (분산락 Fix)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt; @Transactional 제거 후 락 안에서 별도 서비스 메서드 호출&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;cs&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// UserEventService - @Transactional 제거
public void participate(Long eventId, Long userId) {
    RLock lock = redissonClient.getLock(&quot;event:&quot; + eventId);
    // leaseTime을 3초에서 10초로 증가
    boolean acquired = lock.tryLock(5, 10, TimeUnit.SECONDS);
    if (!acquired) throw new CustomException(ErrorCode.TOO_MANY_REQUESTS);
    try {
        eventParticipateService.execute(eventId, userId); // 별도 @Transactional 서비스
    } finally {
        lock.unlock(); // 트랜잭션 커밋 완료 후 락 해제
    }
}

// EventParticipateService - @Transactional 적용
@Transactional
public void execute(Long eventId, Long userId) {
    // DB 로직 처리 후 커밋 완료
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2200&quot; data-origin-height=&quot;538&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cNM90F/dJMcac3260d/QqKwE5JawKxbC2JnBcFGJk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cNM90F/dJMcac3260d/QqKwE5JawKxbC2JnBcFGJk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cNM90F/dJMcac3260d/QqKwE5JawKxbC2JnBcFGJk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcNM90F%2FdJMcac3260d%2FQqKwE5JawKxbC2JnBcFGJk%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;2200&quot; height=&quot;538&quot; data-origin-width=&quot;2200&quot; data-origin-height=&quot;538&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;부하테스트 결과 (After)&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;총 요청:    200명
참여 성공:  100명 (✅ maxParticipants와 정확히 일치)
참여 실패:  100명 (maxParticipants 초과)
정합성:     100% PASS&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Grafana 관측 수치 (k6 실행 중)&lt;/h3&gt;
&lt;div&gt;지표수치
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;RPS (초당 요청 수)&lt;/td&gt;
&lt;td&gt;약 40 req/s&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;CPU 사용률&lt;/td&gt;
&lt;td&gt;74.3%&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;JVM 스레드 수&lt;/td&gt;
&lt;td&gt;295개&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;GC Pause 시간&lt;/td&gt;
&lt;td&gt;1.72ms&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실행전&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2796&quot; data-origin-height=&quot;1340&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b2s9YE/dJMcad2VIIz/MhwJaKxSnt5rpFWE0N6sGK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b2s9YE/dJMcad2VIIz/MhwJaKxSnt5rpFWE0N6sGK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b2s9YE/dJMcad2VIIz/MhwJaKxSnt5rpFWE0N6sGK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb2s9YE%2FdJMcad2VIIz%2FMhwJaKxSnt5rpFWE0N6sGK%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;2796&quot; height=&quot;1340&quot; data-origin-width=&quot;2796&quot; data-origin-height=&quot;1340&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2810&quot; data-origin-height=&quot;1306&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/qikb2/dJMcaiDcSug/fLYdoFCJbv8HbkQO0ek0KK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/qikb2/dJMcaiDcSug/fLYdoFCJbv8HbkQO0ek0KK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/qikb2/dJMcaiDcSug/fLYdoFCJbv8HbkQO0ek0KK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fqikb2%2FdJMcaiDcSug%2FfLYdoFCJbv8HbkQO0ek0KK%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;2810&quot; height=&quot;1306&quot; data-origin-width=&quot;2810&quot; data-origin-height=&quot;1306&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;실행후&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;1332&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rrRZ3/dJMb990Cilz/HjPcIkYdNuxX7itVdlPSp1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rrRZ3/dJMb990Cilz/HjPcIkYdNuxX7itVdlPSp1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rrRZ3/dJMb990Cilz/HjPcIkYdNuxX7itVdlPSp1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrrRZ3%2FdJMb990Cilz%2FHjPcIkYdNuxX7itVdlPSp1%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;2914&quot; height=&quot;1332&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;1332&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2902&quot; data-origin-height=&quot;1336&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/51yJN/dJMcaicbhzb/YGvouw7dPwdEf12vAEliKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/51yJN/dJMcaicbhzb/YGvouw7dPwdEf12vAEliKk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/51yJN/dJMcaicbhzb/YGvouw7dPwdEf12vAEliKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F51yJN%2FdJMcaicbhzb%2FYGvouw7dPwdEf12vAEliKk%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;2902&quot; height=&quot;1336&quot; data-origin-width=&quot;2902&quot; data-origin-height=&quot;1336&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;3. 수익 환전 동시성 부하테스트 (Redis 분산락)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;잔액 100만원인 작가가 동시에 10번 전액 환전 요청. 정확히 1건만 성공해야 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;k6 시나리오 구성&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;javascript&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;spike_withdrawal: {
    executor: 'shared-iterations',
    vus: 10,        // 동시 가상 유저 10명 (같은 유저가 10번)
    iterations: 10,
    maxDuration: '30s',
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;방어 메커니즘&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1차 방어: Redis SETNX 분산락&lt;/b&gt;&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;reasonml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;String lockKey = &quot;lock:withdrawal:&quot; + authorId;
String lockValue = UUID.randomUUID().toString();
Boolean acquired = redisTemplate.opsForValue()
    .setIfAbsent(lockKey, lockValue, 5, TimeUnit.SECONDS);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순 get 후 delete는 비원자적이라 다른 요청의 락을 실수로 해제할 수 있다. Lua Script로 비교와 삭제를 하나의 원자적 연산으로 처리했다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;lua&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;if redis.call('get', KEYS[1]) == ARGV[1] then
    return redis.call('del', KEYS[1])
else
    return 0
end&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2차 방어:&lt;/b&gt; PENDING 상태 중복 체크&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결과&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;총 요청:    10건
환전 성공:  1건 (✅)
환전 실패:  9건 (잔액 부족)
잔액:       0원
정합성:     100% PASS&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;1424&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MmhZZ/dJMcadolBMg/X6mTA4tHPefKBQqrt11Du0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MmhZZ/dJMcadolBMg/X6mTA4tHPefKBQqrt11Du0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MmhZZ/dJMcadolBMg/X6mTA4tHPefKBQqrt11Du0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMmhZZ%2FdJMcadolBMg%2FX6mTA4tHPefKBQqrt11Du0%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;2914&quot; height=&quot;1424&quot; data-origin-width=&quot;2914&quot; data-origin-height=&quot;1424&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2912&quot; data-origin-height=&quot;1414&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/KRHSq/dJMcadolBNE/D6hItf6nkrwapKTmDyLw41/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/KRHSq/dJMcadolBNE/D6hItf6nkrwapKTmDyLw41/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/KRHSq/dJMcadolBNE/D6hItf6nkrwapKTmDyLw41/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FKRHSq%2FdJMcadolBNE%2FD6hItf6nkrwapKTmDyLw41%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;2912&quot; height=&quot;1414&quot; data-origin-width=&quot;2912&quot; data-origin-height=&quot;1414&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1370&quot; data-origin-height=&quot;442&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNNnIO/dJMcadolBOK/LDR0NLhdHjrj3ZbjMlNLCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNNnIO/dJMcadolBOK/LDR0NLhdHjrj3ZbjMlNLCk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNNnIO/dJMcadolBOK/LDR0NLhdHjrj3ZbjMlNLCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNNnIO%2FdJMcadolBOK%2FLDR0NLhdHjrj3ZbjMlNLCk%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;1370&quot; height=&quot;442&quot; data-origin-width=&quot;1370&quot; data-origin-height=&quot;442&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;4. 멘토링 멘티 수락 동시성 테스트 (낙관적 락)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멘토 1명(maxMentees=3)에게 PENDING 상태의 멘토십 10개에 대해 동시에 수락 요청.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;k6 설정&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;javascript&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;spike_accept: {
    executor: 'shared-iterations',
    vus: 10,
    iterations: 10,
    maxDuration: '30s',
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;동작 원리 (낙관적 락)&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;
&lt;div&gt;&amp;nbsp;&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;d&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;@Entity
public class Mentor {
    @Version
    private Integer version;
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;10건이 동시에 같은 Mentor 엔티티의 version(0)을 읽고, 첫 번째 커밋만 성공하면서 version이 1로 증가한다. 나머지 9건은 version 불일치로 OptimisticLockingFailureException이 발생해 실패한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결과&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;ACCEPTED: 1건 (✅)
PENDING:  9건
maxMentees: 2 (1 차감됨)
version: 1 (낙관적 락 버전 증가 확인)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;재시도 로직이 없어 1건만 성공했지만 슬롯 초과 없이 정합성이 100% 유지됐다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1586&quot; data-origin-height=&quot;568&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bSjjGR/dJMcacbV4qd/OEIwji5boRFqQvXu9hcem0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bSjjGR/dJMcacbV4qd/OEIwji5boRFqQvXu9hcem0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bSjjGR/dJMcacbV4qd/OEIwji5boRFqQvXu9hcem0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbSjjGR%2FdJMcacbV4qd%2FOEIwji5boRFqQvXu9hcem0%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;1586&quot; height=&quot;568&quot; data-origin-width=&quot;1586&quot; data-origin-height=&quot;568&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;포트폴리오 스토리&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동시 10건 수락 요청에서 낙관적 락(@Version)이 동시성 충돌을 감지하여 1건만 성공. maxMentees 초과 없이 정합성 100% 유지.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;5. 멘토링 피드백 V1/V2 동시성 비교 (비관적 락)&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;시나리오&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;멘토가 ACCEPTED 상태의 멘토십에 동시에 5건 피드백 작성. sessionNumber(회차 번호) 중복 없이 순서대로 저장되는지 확인.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;k6 설정&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;javascript&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;spike_feedback: {
    executor: 'shared-iterations',
    vus: 5,
    iterations: 5,
    maxDuration: '30s',
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;V1 결과 (동시성 보호 없음)&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;총 요청: 5건
피드백 성공: 1건
피드백 실패: 4건 (유니크 제약 위반으로 400 에러)
오류 메시지: &quot;데이터 저장에 실패하였습니다&quot; (모호한 에러)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제:&lt;/b&gt; 5건이 모두 같은 sessionNumber(1)로 저장 시도. DB 유니크 제약에 의해 첫 번째만 통과하고 나머지는 모호한 에러가 반환된다. 사용자 입장에서 왜 실패했는지 알 수 없다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;V2 결과 (비관적 락 적용)&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;yaml&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;총 요청: 5건
피드백 성공: 5건 (✅)
sessionNumber: 1, 2, 3, 4, 5 (중복 없이 순서대로)
totalSessions: 5&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;해결:&lt;/b&gt; findByIdWithLock()으로 멘토링 row에 SELECT FOR UPDATE(비관적 락)를 걸어 요청을 직렬화. 각 요청이 이전 요청의 커밋 후 totalSessions를 읽어 다음 sessionNumber를 계산한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Before/After 비교&lt;/h3&gt;
&lt;div&gt;항목V1V2
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;성공 건수&lt;/td&gt;
&lt;td&gt;1&lt;/td&gt;
&lt;td&gt;5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;실패 이유&lt;/td&gt;
&lt;td&gt;유니크 제약 위반 &amp;rarr; 모호한 에러&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;sessionNumber 처리&lt;/td&gt;
&lt;td&gt;중복 시도 &amp;rarr; DB가 막음&lt;/td&gt;
&lt;td&gt;비관적 락으로 직렬화&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;사용자 경험&lt;/td&gt;
&lt;td&gt;4건 &quot;데이터 저장 실패&quot;&lt;/td&gt;
&lt;td&gt;모두 정상 처리&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;Before 결과&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2720&quot; data-origin-height=&quot;638&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cjeToE/dJMb99M0ZfV/KXwt7f4u7hdczFWMBh0391/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cjeToE/dJMb99M0ZfV/KXwt7f4u7hdczFWMBh0391/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cjeToE/dJMb99M0ZfV/KXwt7f4u7hdczFWMBh0391/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcjeToE%2FdJMb99M0ZfV%2FKXwt7f4u7hdczFWMBh0391%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;2720&quot; height=&quot;638&quot; data-origin-width=&quot;2720&quot; data-origin-height=&quot;638&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2630&quot; data-origin-height=&quot;604&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ddRVgC/dJMcaa6g3LQ/towHpcLSKyUeUKAnfhyUv0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ddRVgC/dJMcaa6g3LQ/towHpcLSKyUeUKAnfhyUv0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ddRVgC/dJMcaa6g3LQ/towHpcLSKyUeUKAnfhyUv0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FddRVgC%2FdJMcaa6g3LQ%2FtowHpcLSKyUeUKAnfhyUv0%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;2630&quot; height=&quot;604&quot; data-origin-width=&quot;2630&quot; data-origin-height=&quot;604&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;After 결과&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2660&quot; data-origin-height=&quot;780&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EbBqE/dJMb99TN6Au/Ztdnryh9azArGvK7y5ZX3K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EbBqE/dJMb99TN6Au/Ztdnryh9azArGvK7y5ZX3K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EbBqE/dJMb99TN6Au/Ztdnryh9azArGvK7y5ZX3K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEbBqE%2FdJMb99TN6Au%2FZtdnryh9azArGvK7y5ZX3K%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;2660&quot; height=&quot;780&quot; data-origin-width=&quot;2660&quot; data-origin-height=&quot;780&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2756&quot; data-origin-height=&quot;684&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b2k1az/dJMcaa6g3Nx/kIYk9fw7RK1vNfQ4VMddUK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b2k1az/dJMcaa6g3Nx/kIYk9fw7RK1vNfQ4VMddUK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b2k1az/dJMcaa6g3Nx/kIYk9fw7RK1vNfQ4VMddUK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb2k1az%2FdJMcaa6g3Nx%2FkIYk9fw7RK1vNfQ4VMddUK%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;2756&quot; height=&quot;684&quot; data-origin-width=&quot;2756&quot; data-origin-height=&quot;684&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;6. revenues 인덱스 성능 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;목적&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;revenues 테이블에 10만 건 더미 데이터를 삽입하고, 인덱스 추가 전후 쿼리 성능을 EXPLAIN ANALYZE로 비교한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;더미 데이터 생성&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;loadtest 유저 50명 &amp;times; 각 2000건 = 총 10만 건 삽입&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;추가한 인덱스&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;sql&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;n1ql&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;CREATE INDEX idx_revenue_author_type ON revenues (author_id, type);
CREATE INDEX idx_revenue_author_type_date ON revenues (author_id, type, created_at);&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결과 비교&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;쿼리 1: 수익 현황 집계&lt;/h4&gt;
&lt;div&gt;항목BeforeAfter
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;실행 시간&lt;/td&gt;
&lt;td&gt;24.7ms&lt;/td&gt;
&lt;td&gt;1.15ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;스캔 방식&lt;/td&gt;
&lt;td&gt;Full Table Scan (100,015건)&lt;/td&gt;
&lt;td&gt;Index Lookup&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;개선율&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;&lt;b&gt;약 21배&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;쿼리 2: 월별 수익 통계&lt;/h4&gt;
&lt;div&gt;항목BeforeAfter
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;실행 시간&lt;/td&gt;
&lt;td&gt;16.2ms&lt;/td&gt;
&lt;td&gt;1.29ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;스캔 방식&lt;/td&gt;
&lt;td&gt;Full Table Scan&lt;/td&gt;
&lt;td&gt;Index Range Scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;개선율&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;&lt;b&gt;약 12배&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;쿼리 3: 수익 TOP 10 집계 (트레이드오프)&lt;/h4&gt;
&lt;div&gt;항목BeforeAfter
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;실행 시간&lt;/td&gt;
&lt;td&gt;32.3ms&lt;/td&gt;
&lt;td&gt;54ms&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;결과&lt;/td&gt;
&lt;td&gt;-&lt;/td&gt;
&lt;td&gt;&lt;b&gt;오히려 느려짐&lt;/b&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 테이블을 type 기준으로 집계하는 쿼리는 어차피 모든 행을 읽어야 하기 때문에 인덱스가 도움이 안 된다. 오히려 인덱스 스캔 오버헤드가 추가돼 더 느려졌다. 인덱스는 선택도(selectivity)가 높은 컬럼에 효과적이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;적용전&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2756&quot; data-origin-height=&quot;654&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQS9aV/dJMcaakSHbE/STuyt8PQFM6ITZNXakmwPk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQS9aV/dJMcaakSHbE/STuyt8PQFM6ITZNXakmwPk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQS9aV/dJMcaakSHbE/STuyt8PQFM6ITZNXakmwPk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQS9aV%2FdJMcaakSHbE%2FSTuyt8PQFM6ITZNXakmwPk%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;2756&quot; height=&quot;654&quot; data-origin-width=&quot;2756&quot; data-origin-height=&quot;654&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;적용후&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2734&quot; data-origin-height=&quot;1006&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cROEUz/dJMcagrRz55/7JDTiqNZoZOxTHuUhm2zY0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cROEUz/dJMcagrRz55/7JDTiqNZoZOxTHuUhm2zY0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cROEUz/dJMcagrRz55/7JDTiqNZoZOxTHuUhm2zY0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcROEUz%2FdJMcagrRz55%2F7JDTiqNZoZOxTHuUhm2zY0%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;2734&quot; height=&quot;1006&quot; data-origin-width=&quot;2734&quot; data-origin-height=&quot;1006&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;7. 캐시 성능 비교&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7-1. 이벤트 목록 조회 캐시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API: GET /api/events?status=ONGOING&amp;amp;page=0&amp;amp;size=10&lt;br /&gt;캐시: RedisTemplate, TTL 5분&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Cache Miss (DB 조회): 33.5ms
Cache Hit  (Redis):   6.6ms
개선율: 약 5배 (80.3%)
checks: 10/10 성공&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2730&quot; data-origin-height=&quot;1392&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EUi4V/dJMcabKUkO0/wy7Krcb6DZoemeNUGUkqw1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EUi4V/dJMcabKUkO0/wy7Krcb6DZoemeNUGUkqw1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EUi4V/dJMcabKUkO0/wy7Krcb6DZoemeNUGUkqw1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEUi4V%2FdJMcabKUkO0%2Fwy7Krcb6DZoemeNUGUkqw1%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;2730&quot; height=&quot;1392&quot; data-origin-width=&quot;2730&quot; data-origin-height=&quot;1392&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2740&quot; data-origin-height=&quot;1396&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lnT1g/dJMcah5oYeB/PuftOktKxAxZRklJ4mUQWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lnT1g/dJMcah5oYeB/PuftOktKxAxZRklJ4mUQWK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lnT1g/dJMcah5oYeB/PuftOktKxAxZRklJ4mUQWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlnT1g%2FdJMcah5oYeB%2FPuftOktKxAxZRklJ4mUQWK%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;2740&quot; height=&quot;1396&quot; data-origin-width=&quot;2740&quot; data-origin-height=&quot;1396&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7-2. 수익 통계 조회 캐시&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API: GET /api/revenues/me/statistics?period=MONTHLY&amp;amp;year=2026&lt;br /&gt;캐시: RedisTemplate, TTL 1시간&lt;br /&gt;데이터: revenues 10만 건 + GROUP BY 집계 쿼리&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Cache Miss (DB 집계): 181.5ms
Cache Hit  (Redis):    6.1ms
개선율: 약 7.4배 (86.5%)
checks: 10/10 성공&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GROUP BY + 집계 쿼리는 데이터가 많을수록 DB 조회가 느리기 때문에 캐시 효과가 가장 극적으로 나왔다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2742&quot; data-origin-height=&quot;1410&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MQ9F4/dJMcajvlfmT/sV12akGc71VtrVktMKb69k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MQ9F4/dJMcajvlfmT/sV12akGc71VtrVktMKb69k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MQ9F4/dJMcajvlfmT/sV12akGc71VtrVktMKb69k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMQ9F4%2FdJMcajvlfmT%2FsV12akGc71VtrVktMKb69k%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;2742&quot; height=&quot;1410&quot; data-origin-width=&quot;2742&quot; data-origin-height=&quot;1410&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2762&quot; data-origin-height=&quot;1400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bZd0YE/dJMcaaSKcP8/j5UvJV7w9oUM9HNKCPNkY1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bZd0YE/dJMcaaSKcP8/j5UvJV7w9oUM9HNKCPNkY1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bZd0YE/dJMcaaSKcP8/j5UvJV7w9oUM9HNKCPNkY1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbZd0YE%2FdJMcaaSKcP8%2Fj5UvJV7w9oUM9HNKCPNkY1%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;2762&quot; height=&quot;1400&quot; data-origin-width=&quot;2762&quot; data-origin-height=&quot;1400&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;7-3. 도서 검색 캐시 (국립도서관 외부 API)&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;API: GET /api/v1/national-library/books/search?query=스프링&amp;amp;page=1&amp;amp;size=10&lt;br /&gt;캐시: RedisTemplate, TTL 10분&lt;br /&gt;특징: 국립도서관 외부 API 호출&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;Cache Miss (외부 API): 231.9ms
Cache Hit  (Redis):     43.8ms
개선율: 약 2배 (53.1%)
checks: 10/10 성공&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Cache Hit이 43ms로 다른 API에 비해 느린 이유는 응답 크기가 27kB로 크기 때문이다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;캐시 성능 비교 종합&lt;/h3&gt;
&lt;div&gt;APICache MissCache Hit개선율
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;이벤트 목록 조회&lt;/td&gt;
&lt;td&gt;33.5ms&lt;/td&gt;
&lt;td&gt;6.6ms&lt;/td&gt;
&lt;td&gt;약 5배&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;수익 통계 조회&lt;/td&gt;
&lt;td&gt;181.5ms&lt;/td&gt;
&lt;td&gt;6.1ms&lt;/td&gt;
&lt;td&gt;약 7.4배&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;도서 검색&lt;/td&gt;
&lt;td&gt;231.9ms&lt;/td&gt;
&lt;td&gt;43.8ms&lt;/td&gt;
&lt;td&gt;약 2배&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2744&quot; data-origin-height=&quot;1400&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsVqhH/dJMcajov6dx/3fV4OlloGmavwVHDBFc2k0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsVqhH/dJMcajov6dx/3fV4OlloGmavwVHDBFc2k0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsVqhH/dJMcajov6dx/3fV4OlloGmavwVHDBFc2k0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsVqhH%2FdJMcajov6dx%2F3fV4OlloGmavwVHDBFc2k0%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;2744&quot; height=&quot;1400&quot; data-origin-width=&quot;2744&quot; data-origin-height=&quot;1400&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2776&quot; data-origin-height=&quot;1410&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dhJx39/dJMcaiiUfWn/rOeIo1k8IlgEeYYUowaJ81/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dhJx39/dJMcaiiUfWn/rOeIo1k8IlgEeYYUowaJ81/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dhJx39/dJMcaiiUfWn/rOeIo1k8IlgEeYYUowaJ81/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdhJx39%2FdJMcaiiUfWn%2FrOeIo1k8IlgEeYYUowaJ81%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;2776&quot; height=&quot;1410&quot; data-origin-width=&quot;2776&quot; data-origin-height=&quot;1410&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;8. Redis Record 클래스 역직렬화 버그 수정&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 발견&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;수익 현황 조회 캐시 테스트 중 두 번째 요청부터 500 에러 발생.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;SerializationException: Could not read JSON:
Unexpected token (START_OBJECT), expected START_ARRAY:
need Array value to contain `As.WRAPPER_ARRAY` type information&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;원인 분석&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GenericJackson2JsonRedisSerializer가 Redis에 저장할 때 타입 정보 없이 순수 JSON으로 저장한다. 꺼낼 때 Jackson이 타입을 알 수 없어 LinkedHashMap으로 역직렬화한다. 이를 RevenueOverviewResponse로 강제 캐스팅하면 ClassCastException이 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;activateDefaultTyping(NON_FINAL) 옵션으로 해결하려 했지만 &lt;b&gt;Java Record 클래스는 final로 컴파일&lt;/b&gt;되기 때문에 해당 옵션이 적용되지 않는다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결 방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ObjectMapper로 직접 직렬화/역직렬화한다.&lt;/p&gt;
&lt;div&gt;
&lt;div&gt;java&lt;/div&gt;
&lt;div&gt;
&lt;pre class=&quot;dart&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;// 저장 시 (JSON 문자열로 직렬화)
String json = objectMapper.writeValueAsString(response);
redisTemplate.opsForValue().set(cacheKey, json, CACHE_TTL);

// 조회 시 (JSON 문자열로 역직렬화)
Object cached = redisTemplate.opsForValue().get(cacheKey);
if (cached instanceof String jsonStr) {
    return objectMapper.readValue(jsonStr, RevenueOverviewResponse.class);
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RevenueService와 StatisticsService 두 곳에 적용했다. RedisConfig는 팀원 영향 없이 원래대로 복구했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2732&quot; data-origin-height=&quot;1374&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bEpJK5/dJMcafsWY4N/ENpPtxfVKb0RUoTjgfzWa1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bEpJK5/dJMcafsWY4N/ENpPtxfVKb0RUoTjgfzWa1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bEpJK5/dJMcafsWY4N/ENpPtxfVKb0RUoTjgfzWa1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbEpJK5%2FdJMcafsWY4N%2FENpPtxfVKb0RUoTjgfzWa1%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;2732&quot; height=&quot;1374&quot; data-origin-width=&quot;2732&quot; data-origin-height=&quot;1374&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2720&quot; data-origin-height=&quot;1378&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bZI6Nd/dJMcafsWY5v/j0kMcMJTZ9lz9RuMYTXEeK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bZI6Nd/dJMcafsWY5v/j0kMcMJTZ9lz9RuMYTXEeK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bZI6Nd/dJMcafsWY5v/j0kMcMJTZ9lz9RuMYTXEeK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbZI6Nd%2FdJMcafsWY5v%2Fj0kMcMJTZ9lz9RuMYTXEeK%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;2720&quot; height=&quot;1378&quot; data-origin-width=&quot;2720&quot; data-origin-height=&quot;1378&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;핵심 교훈&lt;/h3&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis에 Java Record 클래스를 캐싱할 때는 GenericJackson2JsonRedisSerializer의 자동 역직렬화가 동작하지 않는다. ObjectMapper로 명시적으로 직렬화/역직렬화해야 한다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;9. k6 VUs(Virtual Users) 설정 개념 정리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;shared-iterations:&lt;/b&gt; 총 N개의 iteration을 M개의 VU가 나눠서 처리. 동시 참여처럼 정확히 N번의 요청을 보내야 할 때 사용.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;constant-vus:&lt;/b&gt; M명의 VU가 duration 동안 계속 반복 요청. 지속적인 부하를 주고 싶을 때 사용. Grafana에 의미 있는 트래픽 패턴을 만들기 위해 사용.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 참여 부하테스트에서 두 시나리오를 동시에 실행한 이유:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;spike_participate(VUs 200, iterations 200): 정합성 검증 목적&lt;/li&gt;
&lt;li&gt;sustained_read(VUs 50, duration 30s): Grafana 수치 확보 목적&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 시나리오가 동시에 실행되어 Grafana에서 RPS 40 req/s, CPU 74.3%, 스레드 295개, GC 1.72ms를 관찰했다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;10. 트러블슈팅 모음&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;트러블슈팅 1: MySQL @변수 세션 문제&lt;/b&gt;&lt;br /&gt;SQL 파일 실행 시 세션 변수가 초기화되지 않아 데이터 미삽입. Subquery 방식으로 변경해 해결.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;트러블슈팅 2: k6 한글 파라미터 인코딩&lt;/b&gt;&lt;br /&gt;query=스프링 직접 입력 시 인코딩 문제로 checks 0% 발생. URL 인코딩 값 %EC%8A%A4%ED%94%84%EB%A7%81 사용으로 해결.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;트러블슈팅 3: Redis Record 역직렬화&lt;/b&gt;&lt;br /&gt;섹션 8 참고.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;트러블슈팅 4: NovelStatus enum 값 불일치&lt;/b&gt;&lt;br /&gt;더미 SQL에서 'SERIALIZING' 사용 &amp;rarr; 실제 enum은 'ONGOING'. SELECT DISTINCT status FROM novels;로 확인 후 수정.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;트러블슈팅 5: Grafana 변수 Target data source 미설정&lt;/b&gt;&lt;br /&gt;4701 대시보드 임포트 후 Application 드롭다운이 비어 있었음. Variables 편집에서 Target data source를 Prometheus로 수동 선택해야 한다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;11. 최종 포트폴리오 증거 자료 정리&lt;/h2&gt;
&lt;div&gt;&lt;br /&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;모니터링 구축&lt;/td&gt;
&lt;td&gt;Prometheus + Grafana 대시보드 캡처&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이벤트 참여 동시성 (분산락)&lt;/td&gt;
&lt;td&gt;k6 결과 + DB 100건 정확 + Grafana 수치 (RPS 40, CPU 74.3%)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;수익 환전 동시성 (Redis 분산락)&lt;/td&gt;
&lt;td&gt;k6 결과 + DB 1건 정확&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;멘토링 수락 동시성 (낙관적 락)&lt;/td&gt;
&lt;td&gt;k6 결과 + DB ACCEPTED 1건 + version=1 증가&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;피드백 V1/V2 비교 (비관적 락)&lt;/td&gt;
&lt;td&gt;k6 결과 + DB sessionNumber 1~5&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;revenues 인덱스 성능&lt;/td&gt;
&lt;td&gt;EXPLAIN ANALYZE Before/After (21배, 12배, 트레이드오프)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;이벤트 목록 캐시&lt;/td&gt;
&lt;td&gt;k6 결과 (33ms &amp;rarr; 6.6ms, 5배)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;수익 통계 캐시&lt;/td&gt;
&lt;td&gt;k6 결과 (181ms &amp;rarr; 6.1ms, 7.4배)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;도서 검색 캐시&lt;/td&gt;
&lt;td&gt;k6 결과 (231ms &amp;rarr; 43ms, 2배)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;12. Kafka 도입 스토리 정리&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;도입 배경&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 이벤트 참여, 멘토링 수락 등 여러 도메인에서 알림 발송을 동기로 처리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제점:&lt;/b&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;강결합:&lt;/b&gt; 알림 발송 실패 시 핵심 비즈니스 로직도 함께 실패&lt;/li&gt;
&lt;li&gt;&lt;b&gt;성능 저하:&lt;/b&gt; 알림 처리 시간이 API 응답 시간에 포함됨&lt;/li&gt;
&lt;li&gt;&lt;b&gt;OOM 위험:&lt;/b&gt; 이벤트 생성 시 전체 READER 유저를 한 번에 조회하면 수만 명일 경우 OOM 발생 위험&lt;/li&gt;
&lt;/ol&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Kafka 도입으로 해결한 것&lt;/h3&gt;
&lt;div&gt;
&lt;div&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #14181f;&quot;&gt;&lt;code&gt;기존: 이벤트 참여 &amp;rarr; [알림 발송] &amp;rarr; 응답 반환  (동기, 실패 시 롤백)

도입 후: 이벤트 참여 &amp;rarr; 응답 반환 (빠름)
                  &amp;darr; ApplicationEventPublisher
              @TransactionalEventListener(AFTER_COMMIT)
                  &amp;darr; 트랜잭션 커밋 후 Kafka 발행
              알림 Consumer (비동기)&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대량 알림 발송 시에는 전체 READER 유저를 1000건씩 페이지 단위로 배치 조회하여 순차적으로 이벤트를 발행한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&amp;nbsp;&lt;/h3&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;왜 @Async가 아닌 Kafka인가?&lt;/h3&gt;
&lt;div&gt;항목@AsyncKafka
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;메시지 유실&lt;/td&gt;
&lt;td&gt;서버 재시작 시 유실&lt;/td&gt;
&lt;td&gt;디스크 영속화로 보존&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;재처리&lt;/td&gt;
&lt;td&gt;별도 구현 필요&lt;/td&gt;
&lt;td&gt;Consumer 재시작으로 재처리&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;확장성&lt;/td&gt;
&lt;td&gt;JVM 내부 스레드 풀 한계&lt;/td&gt;
&lt;td&gt;Consumer Group으로 수평 확장&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;모니터링&lt;/td&gt;
&lt;td&gt;어려움&lt;/td&gt;
&lt;td&gt;토픽/파티션/오프셋으로 추적 가능&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알림 유실 없이 안정적으로 처리해야 하고, 향후 여러 Consumer(이메일, 앱 푸시, SMS 등)로 확장 가능한 구조가 필요했기 때문에 Kafka를 선택했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-path-to-node=&quot;2&quot; data-ke-size=&quot;size23&quot;&gt;안정성과 확장성을 위한 고도화 고민&lt;/h3&gt;
&lt;p data-path-to-node=&quot;3&quot; data-ke-size=&quot;size16&quot;&gt;Kafka 도입으로 비동기 처리와 도메인 간 결합도 해제라는 큰 성과를 얻었지만, 시스템의 신뢰도를 100%로 끌어올리기 위해 다음의 고도화 전략들을 고민하고 있다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;4&quot; data-ke-size=&quot;size20&quot;&gt;1. 데이터 유실 제로를 위한 'Transactional Outbox Pattern'&lt;/h4&gt;
&lt;p data-path-to-node=&quot;5&quot; data-ke-size=&quot;size16&quot;&gt;현재는 AFTER_COMMIT 시점에 이벤트를 발행하여 정합성을 맞추고 있다. 하지만 아주 희박한 확률로 &lt;b data-index-in-node=&quot;59&quot; data-path-to-node=&quot;5&quot;&gt;DB 커밋은 성공했으나 Kafka 발행 직전 네트워크 장애가 발생&lt;/b&gt;할 경우 메시지가 유실될 수 있다. 이를 완벽히 방지하기 위해 비즈니스 데이터와 메시지를 하나의 트랜잭션으로 DB(Outbox Table)에 저장하고, 별도 프로세스가 발행을 보장하는 패턴을 검토 중이다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;6&quot; data-ke-size=&quot;size20&quot;&gt;2. 예외 상황의 격리와 재처리를 위한 'DLT(Dead Letter Topic)'&lt;/h4&gt;
&lt;p data-path-to-node=&quot;7&quot; data-ke-size=&quot;size16&quot;&gt;비동기 처리 중 Consumer에서 예상치 못한 에러가 발생했을 때, 무한 재시도로 인해 전체 시스템이 지연되는 것을 막아야 한다. 실패한 메시지는 별도의 &lt;b data-index-in-node=&quot;87&quot; data-path-to-node=&quot;7&quot;&gt;Dead Letter Topic&lt;/b&gt;으로 격리하여 장애 원인을 분석하고, 정상화 이후 해당 메시지만 선별적으로 재처리할 수 있는 운영 프로세스를 구상하고 있다.&lt;/p&gt;
&lt;h4 data-path-to-node=&quot;8&quot; data-ke-size=&quot;size20&quot;&gt;3. 대규모 트래픽 대비 'Cursor 기반 페이징 &amp;amp; 병렬 처리'&lt;/h4&gt;
&lt;p data-path-to-node=&quot;9&quot; data-ke-size=&quot;size16&quot;&gt;수만 명의 유저에게 알림을 보낼 때, 현재의 페이지 단위 조회를 더욱 최적화하기 위해 &lt;b data-index-in-node=&quot;48&quot; data-path-to-node=&quot;9&quot;&gt;ID 기반 Cursor 페이징&lt;/b&gt;을 적용하여 조회 성능을 유지하고자 한다. 또한, 알림량이 늘어날 경우 Kafka의 Partition을 확장하고 Consumer Group의 인스턴스를 수평 확장(Scale-out)하여 처리량을 극대화하는 설계를 염두에 두고 있다.&lt;/p&gt;
&lt;blockquote data-path-to-node=&quot;10&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-path-to-node=&quot;10,0&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b data-index-in-node=&quot;0&quot; data-path-to-node=&quot;10,0&quot;&gt;마치며&lt;/b&gt; 모든 프로젝트에 이런 고도로 복잡한 패턴이 정답은 아닐 것이다. 하지만 서비스가 성장함에 따라 마주칠 병목 구간을 미리 예측하고, 그에 맞는 아키텍처를 제시할 수 있는 능력이 진정한 기술적 깊이라고 믿는다. 단순히 기술을 '사용'하는 것을 넘어, 시스템의 &lt;b data-index-in-node=&quot;147&quot; data-path-to-node=&quot;10,0&quot;&gt;전체적인 흐름과 운영의 안정성&lt;/b&gt;을 책임지는 개발자로 성장해 나가고자 한다.&lt;/p&gt;
&lt;/blockquote&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/213</guid>
      <comments>https://minwoo95.tistory.com/213#entry213comment</comments>
      <pubDate>Mon, 4 May 2026 21:22:39 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 13</title>
      <link>https://minwoo95.tistory.com/212</link>
      <description>&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: #0593d3;&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;br /&gt;&lt;br /&gt;POST&amp;nbsp;/api/calendars/me/records&amp;nbsp;&amp;mdash;&amp;nbsp;독서&amp;nbsp;기록&amp;nbsp;등록 &lt;br /&gt;적용&amp;nbsp;기술 &lt;br /&gt;CalendarExceptionEnum:&amp;nbsp;PLATFORM&amp;nbsp;출처인데&amp;nbsp;서재&amp;nbsp;미등록&amp;nbsp;시&amp;nbsp;BOOK_NOT_IN_LIBRARY&amp;nbsp;예외&amp;nbsp;발생&amp;nbsp;&amp;rarr;&amp;nbsp;400&amp;nbsp;반환 &lt;br /&gt;PK&amp;nbsp;직접&amp;nbsp;참조:&amp;nbsp;Library&amp;nbsp;테이블에서&amp;nbsp;userId&amp;nbsp;+&amp;nbsp;novelId로&amp;nbsp;서재&amp;nbsp;등록&amp;nbsp;여부&amp;nbsp;확인,&amp;nbsp;연관관계&amp;nbsp;없이&amp;nbsp;PK로&amp;nbsp;조회 &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/calendars/me/records&amp;nbsp;&amp;mdash;&amp;nbsp;독서&amp;nbsp;기록&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술 &lt;br /&gt;QueryDSL&amp;nbsp;동적&amp;nbsp;쿼리:&amp;nbsp;date,&amp;nbsp;novelId가&amp;nbsp;선택값이므로&amp;nbsp;BooleanExpression으로&amp;nbsp;null&amp;nbsp;조건&amp;nbsp;자동&amp;nbsp;제외 &lt;br /&gt;PageResponse:&amp;nbsp;페이징&amp;nbsp;처리하여&amp;nbsp;content,&amp;nbsp;totalElements,&amp;nbsp;totalPages&amp;nbsp;등&amp;nbsp;반환 &lt;br /&gt;PK&amp;nbsp;직접&amp;nbsp;참조:&amp;nbsp;novelId,&amp;nbsp;userId를&amp;nbsp;외래키로&amp;nbsp;직접&amp;nbsp;참조해&amp;nbsp;조회 &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/calendars/me&amp;nbsp;&amp;mdash;&amp;nbsp;독서&amp;nbsp;캘린더&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술 &lt;br /&gt;CalendarExceptionEnum:&amp;nbsp;조회&amp;nbsp;범위&amp;nbsp;1년&amp;nbsp;초과&amp;nbsp;시&amp;nbsp;DATE_RANGE_TOO_LARGE&amp;nbsp;&amp;rarr;&amp;nbsp;400&amp;nbsp;반환 &lt;br /&gt;GlobalExceptionHandler&amp;nbsp;+&amp;nbsp;MissingServletRequestParameterException&amp;nbsp;핸들러:&amp;nbsp;startDate&amp;nbsp;누락&amp;nbsp;시&amp;nbsp;500&amp;nbsp;대신&amp;nbsp;400&amp;nbsp;반환 &lt;br /&gt;&lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/calendars/me/statistics&amp;nbsp;&amp;mdash;&amp;nbsp;월간&amp;nbsp;통계&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술 &lt;br /&gt;CalendarExceptionEnum:&amp;nbsp;미래&amp;nbsp;달&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;INVALID_STAT_DATE&amp;nbsp;&amp;rarr;&amp;nbsp;400&amp;nbsp;반환 &lt;br /&gt;JPQL&amp;nbsp;@Query:&amp;nbsp;월별&amp;nbsp;집계(총&amp;nbsp;페이지,&amp;nbsp;완독&amp;nbsp;수,&amp;nbsp;독서일&amp;nbsp;수&amp;nbsp;등)&amp;nbsp;단순&amp;nbsp;집계&amp;nbsp;쿼리는&amp;nbsp;JPA로&amp;nbsp;처리 &lt;br /&gt;MonthlyStatResponse&amp;nbsp;Record:&amp;nbsp;집계&amp;nbsp;결과를&amp;nbsp;Record&amp;nbsp;클래스&amp;nbsp;DTO로&amp;nbsp;반환 &lt;br /&gt;&lt;br /&gt;이유 &lt;br /&gt;통계&amp;nbsp;조회는&amp;nbsp;복잡한&amp;nbsp;동적&amp;nbsp;조건보다는&amp;nbsp;month,&amp;nbsp;year가&amp;nbsp;고정된&amp;nbsp;단순&amp;nbsp;집계이므로&amp;nbsp;QueryDSL&amp;nbsp;대신&amp;nbsp;JPQL&amp;nbsp;@Query로&amp;nbsp;처리했습니다.&amp;nbsp;복잡도에&amp;nbsp;맞는&amp;nbsp;기술을&amp;nbsp;선택하는&amp;nbsp;것이&amp;nbsp;팀&amp;nbsp;컨벤션이고,&amp;nbsp;오버엔지니어링을&amp;nbsp;방지합니다.&amp;nbsp;미래&amp;nbsp;달&amp;nbsp;조회&amp;nbsp;제한은&amp;nbsp;존재하지&amp;nbsp;않는&amp;nbsp;데이터에&amp;nbsp;대한&amp;nbsp;불필요한&amp;nbsp;쿼리를&amp;nbsp;사전&amp;nbsp;차단하기&amp;nbsp;위한&amp;nbsp;방어&amp;nbsp;로직입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;N+1,&amp;nbsp;반복&amp;nbsp;쿼리&amp;nbsp;발생을&amp;nbsp;방지,&amp;nbsp;불필요한&amp;nbsp;조인&amp;nbsp;줄이고&amp;nbsp;응답&amp;nbsp;일관성&amp;nbsp;확보,&amp;nbsp;복합&amp;nbsp;유니크&amp;nbsp;제약과&amp;nbsp;어플리케이션&amp;nbsp;중복검사&amp;nbsp;==&amp;gt;&amp;nbsp;중복&amp;nbsp;서재&amp;nbsp;등록&amp;nbsp;방지&lt;/span&gt; &lt;br /&gt;&lt;br /&gt;-------------------------------------------------------------------------------------------------------- &lt;br /&gt;&lt;br /&gt;[내서제] &lt;br /&gt;POST&amp;nbsp;/api/libraries&amp;nbsp;&amp;mdash;&amp;nbsp;내서재&amp;nbsp;담기 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;메타데이터&amp;nbsp;스냅샷&amp;nbsp;저장 &lt;br /&gt;문제:&amp;nbsp;소설&amp;nbsp;정보(제목,&amp;nbsp;작가명,&amp;nbsp;표지)는&amp;nbsp;작가가&amp;nbsp;수정할&amp;nbsp;수&amp;nbsp;있어서&amp;nbsp;담기&amp;nbsp;시점&amp;nbsp;이후에&amp;nbsp;변경될&amp;nbsp;수&amp;nbsp;있습니다.&amp;nbsp;Novel&amp;nbsp;테이블을&amp;nbsp;매번&amp;nbsp;Join하면&amp;nbsp;수정된&amp;nbsp;데이터가&amp;nbsp;담기&amp;nbsp;시점과&amp;nbsp;다르게&amp;nbsp;노출되고&amp;nbsp;불필요한&amp;nbsp;Join&amp;nbsp;비용이&amp;nbsp;발생합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;담기&amp;nbsp;시점의&amp;nbsp;novelTitle,&amp;nbsp;authorNickname,&amp;nbsp;coverImageUrl을&amp;nbsp;Library&amp;nbsp;테이블에&amp;nbsp;직접&amp;nbsp;저장합니다.&amp;nbsp;이후&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;Novel&amp;nbsp;테이블&amp;nbsp;Join&amp;nbsp;없이&amp;nbsp;Library&amp;nbsp;단독으로&amp;nbsp;응답이&amp;nbsp;가능합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;DB&amp;nbsp;유니크&amp;nbsp;제약&amp;nbsp;+&amp;nbsp;애플리케이션&amp;nbsp;중복&amp;nbsp;검사&amp;nbsp;이중&amp;nbsp;방어 &lt;br /&gt;문제:&amp;nbsp;애플리케이션&amp;nbsp;레벨의&amp;nbsp;existsByUserIdAndNovelId()&amp;nbsp;단독&amp;nbsp;검사만으로는&amp;nbsp;동시&amp;nbsp;요청&amp;nbsp;시&amp;nbsp;race&amp;nbsp;condition으로&amp;nbsp;중복&amp;nbsp;insert가&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Library&amp;nbsp;테이블에&amp;nbsp;userId&amp;nbsp;+&amp;nbsp;novelId&amp;nbsp;복합&amp;nbsp;유니크&amp;nbsp;인덱스를&amp;nbsp;추가하여&amp;nbsp;DB&amp;nbsp;레벨에서도&amp;nbsp;중복을&amp;nbsp;차단합니다.&amp;nbsp;애플리케이션&amp;nbsp;레벨&amp;nbsp;검사는&amp;nbsp;409&amp;nbsp;응답을&amp;nbsp;위해&amp;nbsp;유지하고&amp;nbsp;DB&amp;nbsp;제약은&amp;nbsp;동시&amp;nbsp;요청&amp;nbsp;방어의&amp;nbsp;최후&amp;nbsp;방어선으로&amp;nbsp;이중&amp;nbsp;적용하였습니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/libraries/me&amp;nbsp;&amp;mdash;&amp;nbsp;내서재&amp;nbsp;목록&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;QueryDSL&amp;nbsp;동적&amp;nbsp;쿼리 &lt;br /&gt;문제:&amp;nbsp;전체/읽는중/완독/찜/구매&amp;nbsp;탭&amp;nbsp;필터와&amp;nbsp;최신순/제목순&amp;nbsp;정렬을&amp;nbsp;JPQL로&amp;nbsp;구현하면&amp;nbsp;조건&amp;nbsp;조합마다&amp;nbsp;별도&amp;nbsp;메서드가&amp;nbsp;필요해&amp;nbsp;코드&amp;nbsp;중복이&amp;nbsp;발생합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;QueryDSL을&amp;nbsp;사용하여&amp;nbsp;libraryType이&amp;nbsp;null이면&amp;nbsp;전체,&amp;nbsp;값이&amp;nbsp;있으면&amp;nbsp;해당&amp;nbsp;타입만&amp;nbsp;필터링하는&amp;nbsp;동적&amp;nbsp;쿼리를&amp;nbsp;단일&amp;nbsp;메서드로&amp;nbsp;처리합니다.&amp;nbsp;정렬&amp;nbsp;조건도&amp;nbsp;resolveOrder()로&amp;nbsp;분기하여&amp;nbsp;확장에&amp;nbsp;유연한&amp;nbsp;구조로&amp;nbsp;설계하였습니다. &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;N+1&amp;nbsp;문제&amp;nbsp;해결&amp;nbsp;&amp;mdash;&amp;nbsp;일괄&amp;nbsp;집계&amp;nbsp;쿼리 &lt;br /&gt;&lt;br /&gt;문제:&amp;nbsp;각&amp;nbsp;소설의&amp;nbsp;총&amp;nbsp;회차&amp;nbsp;수를&amp;nbsp;구하기&amp;nbsp;위해&amp;nbsp;항목마다&amp;nbsp;countByNovelId()를&amp;nbsp;호출하면&amp;nbsp;페이지&amp;nbsp;크기만큼&amp;nbsp;추가&amp;nbsp;쿼리가&amp;nbsp;발생하는&amp;nbsp;N+1&amp;nbsp;문제가&amp;nbsp;생깁니다.&amp;nbsp;페이지&amp;nbsp;크기가&amp;nbsp;12라면&amp;nbsp;최대&amp;nbsp;13번의&amp;nbsp;쿼리가&amp;nbsp;실행됩니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;조회된&amp;nbsp;Library&amp;nbsp;목록에서&amp;nbsp;novelId&amp;nbsp;집합을&amp;nbsp;추출한&amp;nbsp;뒤&amp;nbsp;countByNovelIds()로&amp;nbsp;단일&amp;nbsp;쿼리에서&amp;nbsp;GROUP&amp;nbsp;BY&amp;nbsp;novelId로&amp;nbsp;일괄&amp;nbsp;집계합니다.&amp;nbsp;결과를&amp;nbsp;Map으로&amp;nbsp;변환하여&amp;nbsp;항목별로&amp;nbsp;매핑하므로&amp;nbsp;쿼리가&amp;nbsp;항상&amp;nbsp;1회로&amp;nbsp;고정됩니다.&amp;nbsp;또한&amp;nbsp;isDeleted&amp;nbsp;=&amp;nbsp;false&amp;nbsp;조건으로&amp;nbsp;soft-delete된&amp;nbsp;회차를&amp;nbsp;제외하여&amp;nbsp;실제&amp;nbsp;공개된&amp;nbsp;회차&amp;nbsp;수만&amp;nbsp;집계합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;엔티티&amp;nbsp;간&amp;nbsp;연관관계&amp;nbsp;제거&amp;nbsp;+&amp;nbsp;ID&amp;nbsp;직접&amp;nbsp;참조 &lt;br /&gt;문제:&amp;nbsp;JPA&amp;nbsp;연관관계를&amp;nbsp;사용하면&amp;nbsp;즉시/지연&amp;nbsp;로딩에&amp;nbsp;따른&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;해결:&amp;nbsp;Library&amp;nbsp;엔티티가&amp;nbsp;User,&amp;nbsp;Novel을&amp;nbsp;객체로&amp;nbsp;참조하지&amp;nbsp;않고&amp;nbsp;userId,&amp;nbsp;novelId를&amp;nbsp;컬럼으로만&amp;nbsp;보유하도록&amp;nbsp;설계하였습니다.&amp;nbsp;도메인&amp;nbsp;간&amp;nbsp;결합을&amp;nbsp;끊고&amp;nbsp;필요한&amp;nbsp;시점에만&amp;nbsp;QueryDSL&amp;nbsp;Join으로&amp;nbsp;처리합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/libraries/me&amp;nbsp;-&amp;nbsp;내&amp;nbsp;서재&amp;nbsp;통합&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1&amp;nbsp;-&amp;nbsp;단일&amp;nbsp;API&amp;nbsp;통합&amp;nbsp;응답 &lt;br /&gt;문제:&amp;nbsp;소설&amp;nbsp;서재와&amp;nbsp;국립도서관&amp;nbsp;도서&amp;nbsp;서재를&amp;nbsp;별도&amp;nbsp;API로&amp;nbsp;분리하면&amp;nbsp;프론트에서&amp;nbsp;두&amp;nbsp;번&amp;nbsp;호출해야&amp;nbsp;하고&amp;nbsp;로딩&amp;nbsp;타이밍이&amp;nbsp;달라져&amp;nbsp;UX가&amp;nbsp;저하됩니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;기존&amp;nbsp;GET&amp;nbsp;/api/libraries/me&amp;nbsp;하나에서&amp;nbsp;소설&amp;nbsp;서재(novels)와&amp;nbsp;국립도서관&amp;nbsp;도서&amp;nbsp;서재(nationalLibraryBooks)를&amp;nbsp;MyLibraryResponse&amp;nbsp;하나로&amp;nbsp;통합하여&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;적용&amp;nbsp;기술&amp;nbsp;2&amp;nbsp;-&amp;nbsp;N+1&amp;nbsp;방지를&amp;nbsp;위한&amp;nbsp;일괄&amp;nbsp;조회&amp;nbsp;(findAllById&amp;nbsp;+&amp;nbsp;Map&amp;nbsp;매핑) &lt;br /&gt;문제:&amp;nbsp;연관관계&amp;nbsp;없이&amp;nbsp;ID로만&amp;nbsp;관리하는&amp;nbsp;구조에서&amp;nbsp;UserBook&amp;nbsp;목록을&amp;nbsp;순회하며&amp;nbsp;Book을&amp;nbsp;개별&amp;nbsp;조회하면&amp;nbsp;N+1&amp;nbsp;문제가&amp;nbsp;발생하여&amp;nbsp;쿼리&amp;nbsp;횟수가&amp;nbsp;데이터&amp;nbsp;수에&amp;nbsp;비례하여&amp;nbsp;증가합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;UserBook&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;후&amp;nbsp;bookId&amp;nbsp;목록을&amp;nbsp;추출하여&amp;nbsp;findAllById로&amp;nbsp;Book을&amp;nbsp;단&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;일괄&amp;nbsp;조회하고,&amp;nbsp;Map으로&amp;nbsp;변환하여&amp;nbsp;O(1)로&amp;nbsp;매핑하는&amp;nbsp;방식을&amp;nbsp;적용하였습니다.&amp;nbsp;조회&amp;nbsp;쿼리가&amp;nbsp;항상&amp;nbsp;2번으로&amp;nbsp;고정되어&amp;nbsp;데이터&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;구현한&amp;nbsp;API&amp;nbsp;및&amp;nbsp;적용&amp;nbsp;기술&amp;nbsp;정리 &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v1/national-library/books/search&amp;nbsp;-&amp;nbsp;도서&amp;nbsp;검색 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;-&amp;nbsp;Redis&amp;nbsp;캐싱&amp;nbsp;(RedisTemplate) &lt;br /&gt;문제:&amp;nbsp;국립중앙도서관은&amp;nbsp;외부&amp;nbsp;API라&amp;nbsp;매&amp;nbsp;요청마다&amp;nbsp;호출하면&amp;nbsp;네트워크&amp;nbsp;지연으로&amp;nbsp;응답&amp;nbsp;속도가&amp;nbsp;느려지고&amp;nbsp;API&amp;nbsp;호출&amp;nbsp;횟수&amp;nbsp;제한&amp;nbsp;리스크가&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;해결:&amp;nbsp;검색어&amp;nbsp;+&amp;nbsp;페이지&amp;nbsp;+&amp;nbsp;사이즈를&amp;nbsp;조합한&amp;nbsp;키로&amp;nbsp;검색&amp;nbsp;결과를&amp;nbsp;Redis에&amp;nbsp;10분간&amp;nbsp;캐싱하여&amp;nbsp;캐시&amp;nbsp;히트&amp;nbsp;시&amp;nbsp;외부&amp;nbsp;API를&amp;nbsp;호출하지&amp;nbsp;않도록&amp;nbsp;하였습니다.&amp;nbsp;이를&amp;nbsp;통해&amp;nbsp;응답&amp;nbsp;속도를&amp;nbsp;개선하고&amp;nbsp;외부&amp;nbsp;API&amp;nbsp;호출&amp;nbsp;횟수를&amp;nbsp;최소화하였습니다. &lt;br /&gt;&lt;br /&gt;POST&amp;nbsp;/api/v1/national-library/books/shelf&amp;nbsp;-&amp;nbsp;내&amp;nbsp;서재&amp;nbsp;도서&amp;nbsp;저장 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1&amp;nbsp;-&amp;nbsp;books&amp;nbsp;테이블&amp;nbsp;upsert&amp;nbsp;패턴 &lt;br /&gt;문제:&amp;nbsp;검색&amp;nbsp;결과에서&amp;nbsp;선택한&amp;nbsp;도서를&amp;nbsp;저장할&amp;nbsp;때&amp;nbsp;동일한&amp;nbsp;ISBN의&amp;nbsp;도서가&amp;nbsp;여러&amp;nbsp;유저에&amp;nbsp;의해&amp;nbsp;중복으로&amp;nbsp;저장될&amp;nbsp;수&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;해결:&amp;nbsp;도서&amp;nbsp;저장&amp;nbsp;요청&amp;nbsp;시&amp;nbsp;ISBN&amp;nbsp;기준으로&amp;nbsp;books&amp;nbsp;테이블에&amp;nbsp;이미&amp;nbsp;존재하는지&amp;nbsp;먼저&amp;nbsp;확인하고,&amp;nbsp;없을&amp;nbsp;때만&amp;nbsp;저장하고&amp;nbsp;있으면&amp;nbsp;기존&amp;nbsp;레코드를&amp;nbsp;재사용하는&amp;nbsp;방식을&amp;nbsp;적용하였습니다.&amp;nbsp;도서&amp;nbsp;데이터&amp;nbsp;중복을&amp;nbsp;방지하고&amp;nbsp;user_books&amp;nbsp;테이블에는&amp;nbsp;유저와&amp;nbsp;도서의&amp;nbsp;관계만&amp;nbsp;저장하도록&amp;nbsp;설계하였습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2&amp;nbsp;-&amp;nbsp;엔티티&amp;nbsp;간&amp;nbsp;연관관계&amp;nbsp;제거&amp;nbsp;+&amp;nbsp;ID&amp;nbsp;직접&amp;nbsp;참조 &lt;br /&gt;문제:&amp;nbsp;JPA&amp;nbsp;연관관계를&amp;nbsp;사용하면&amp;nbsp;즉시/지연&amp;nbsp;로딩에&amp;nbsp;따른&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;해결:&amp;nbsp;UserBook&amp;nbsp;엔티티가&amp;nbsp;User,&amp;nbsp;Book을&amp;nbsp;객체로&amp;nbsp;참조하지&amp;nbsp;않고&amp;nbsp;userId,&amp;nbsp;bookId를&amp;nbsp;컬럼으로만&amp;nbsp;보유하도록&amp;nbsp;설계하였습니다.&amp;nbsp;도메인&amp;nbsp;간&amp;nbsp;결합을&amp;nbsp;끊고&amp;nbsp;필요한&amp;nbsp;시점에만&amp;nbsp;ID&amp;nbsp;기반으로&amp;nbsp;일괄&amp;nbsp;조회하여&amp;nbsp;처리합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;Redis&amp;nbsp;캐시&amp;nbsp;활용&amp;nbsp;네트워크&amp;nbsp;지연율&amp;nbsp;감소,&amp;nbsp;외부&amp;nbsp;API&amp;nbsp;호출&amp;nbsp;결과&amp;nbsp;저장&amp;nbsp;시&amp;nbsp;upsert&amp;nbsp;중복적재&amp;nbsp;방지,&amp;nbsp;외부&amp;nbsp;API&amp;nbsp;장애나&amp;nbsp;지연&amp;nbsp;가능성을&amp;nbsp;고려&lt;/span&gt; &lt;br /&gt;-------------------------------------------------------------------------------------------------------- &lt;br /&gt;&lt;br /&gt;[멘토&amp;nbsp;도메인] &lt;br /&gt;POST&amp;nbsp;/api/mentors&amp;nbsp;&amp;mdash;&amp;nbsp;멘토&amp;nbsp;등록&amp;nbsp;신청 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;careerLevel&amp;nbsp;기준&amp;nbsp;자동&amp;nbsp;승인&amp;nbsp;처리 &lt;br /&gt;문제:&amp;nbsp;멘토&amp;nbsp;등록&amp;nbsp;신청&amp;nbsp;시&amp;nbsp;모든&amp;nbsp;신청을&amp;nbsp;관리자가&amp;nbsp;수동으로&amp;nbsp;검토하면&amp;nbsp;처리&amp;nbsp;지연이&amp;nbsp;발생하고&amp;nbsp;관리자&amp;nbsp;부담이&amp;nbsp;커집니다.&amp;nbsp;하지만&amp;nbsp;등급별로&amp;nbsp;이미&amp;nbsp;플랫폼&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;해결:&amp;nbsp;신청&amp;nbsp;시점에&amp;nbsp;Novel,&amp;nbsp;Episode&amp;nbsp;테이블을&amp;nbsp;조회해&amp;nbsp;PUBLISHED&amp;nbsp;에피소드&amp;nbsp;수와&amp;nbsp;좋아요&amp;nbsp;합산을&amp;nbsp;검증합니다.&amp;nbsp;INTRODUCTION은&amp;nbsp;에피소드&amp;nbsp;50회&amp;nbsp;이상,&amp;nbsp;ELEMENTARY는&amp;nbsp;에피소드&amp;nbsp;50회&amp;nbsp;이상&amp;nbsp;+&amp;nbsp;좋아요&amp;nbsp;50개&amp;nbsp;이상,&amp;nbsp;INTERMEDIATE는&amp;nbsp;에피소드&amp;nbsp;100회&amp;nbsp;이상&amp;nbsp;+&amp;nbsp;좋아요&amp;nbsp;100개&amp;nbsp;이상이면&amp;nbsp;즉시&amp;nbsp;APPROVED로&amp;nbsp;저장합니다.&amp;nbsp;PROFICIENT는&amp;nbsp;수상/출간&amp;nbsp;경력&amp;nbsp;검증이&amp;nbsp;필요하므로&amp;nbsp;관리자&amp;nbsp;수동&amp;nbsp;승인으로&amp;nbsp;PENDING&amp;nbsp;유지합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;리스트&amp;nbsp;필드&amp;nbsp;JSON&amp;nbsp;직렬화&amp;nbsp;저장 &lt;br /&gt;문제:&amp;nbsp;mainGenres,&amp;nbsp;specialFields,&amp;nbsp;mentoringStyles는&amp;nbsp;복수의&amp;nbsp;값을&amp;nbsp;가지는&amp;nbsp;필드입니다.&amp;nbsp;별도&amp;nbsp;테이블로&amp;nbsp;분리하면&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;항상&amp;nbsp;Join이&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;해결:&amp;nbsp;ObjectMapper.writeValueAsString()으로&amp;nbsp;List를&amp;nbsp;JSON&amp;nbsp;문자열로&amp;nbsp;직렬화해&amp;nbsp;단일&amp;nbsp;컬럼에&amp;nbsp;저장합니다.&amp;nbsp;조회&amp;nbsp;시에는&amp;nbsp;objectMapper.readValue()로&amp;nbsp;역직렬화해&amp;nbsp;List로&amp;nbsp;반환합니다.&amp;nbsp;이&amp;nbsp;필드들은&amp;nbsp;단순&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;외에&amp;nbsp;복잡한&amp;nbsp;검색&amp;nbsp;쿼리가&amp;nbsp;필요&amp;nbsp;없고&amp;nbsp;변경&amp;nbsp;빈도도&amp;nbsp;낮아&amp;nbsp;Join&amp;nbsp;없이&amp;nbsp;처리할&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;JSON&amp;nbsp;직렬화&amp;nbsp;방식이&amp;nbsp;적합하다고&amp;nbsp;판단했습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;DB&amp;nbsp;유니크&amp;nbsp;제약&amp;nbsp;+&amp;nbsp;DataIntegrityViolationException&amp;nbsp;처리로&amp;nbsp;동시성&amp;nbsp;방어 &lt;br /&gt;문제:&amp;nbsp;멘토&amp;nbsp;등록&amp;nbsp;시&amp;nbsp;PENDING,&amp;nbsp;APPROVED&amp;nbsp;상태를&amp;nbsp;사전&amp;nbsp;조회한&amp;nbsp;후&amp;nbsp;저장하는&amp;nbsp;구조라&amp;nbsp;동시에&amp;nbsp;두&amp;nbsp;요청이&amp;nbsp;들어오면&amp;nbsp;둘&amp;nbsp;다&amp;nbsp;조회를&amp;nbsp;통과해&amp;nbsp;중복&amp;nbsp;저장이&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있습니다.&amp;nbsp;check-then-act&amp;nbsp;패턴의&amp;nbsp;전형적인&amp;nbsp;race&amp;nbsp;condition입니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Mentor&amp;nbsp;엔티티의&amp;nbsp;userId&amp;nbsp;컬럼에&amp;nbsp;@Column(unique&amp;nbsp;=&amp;nbsp;true)를&amp;nbsp;걸어&amp;nbsp;DB&amp;nbsp;레벨에서&amp;nbsp;중복을&amp;nbsp;막습니다.&amp;nbsp;동시&amp;nbsp;요청으로&amp;nbsp;두&amp;nbsp;건이&amp;nbsp;저장을&amp;nbsp;시도할&amp;nbsp;경우&amp;nbsp;DB에서&amp;nbsp;DataIntegrityViolationException이&amp;nbsp;발생하는데,&amp;nbsp;save&amp;nbsp;호출부를&amp;nbsp;try-catch로&amp;nbsp;감싸&amp;nbsp;해당&amp;nbsp;예외를&amp;nbsp;MENTOR_ALREADY_APPROVED로&amp;nbsp;변환해&amp;nbsp;409로&amp;nbsp;응답합니다.&amp;nbsp;애플리케이션&amp;nbsp;레벨&amp;nbsp;검증만으로는&amp;nbsp;race&amp;nbsp;condition을&amp;nbsp;막을&amp;nbsp;수&amp;nbsp;없어&amp;nbsp;DB&amp;nbsp;제약과&amp;nbsp;예외&amp;nbsp;처리를&amp;nbsp;함께&amp;nbsp;구성했습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;4.&amp;nbsp;정적&amp;nbsp;팩토리&amp;nbsp;패턴으로&amp;nbsp;엔티티&amp;nbsp;생성 &lt;br /&gt;문제:&amp;nbsp;생성자를&amp;nbsp;열어두면&amp;nbsp;팀원이&amp;nbsp;엔티티를&amp;nbsp;생성할&amp;nbsp;때&amp;nbsp;필드&amp;nbsp;순서를&amp;nbsp;실수하거나&amp;nbsp;필수&amp;nbsp;초기값&amp;nbsp;설정을&amp;nbsp;빠뜨릴&amp;nbsp;수&amp;nbsp;있습니다.&amp;nbsp;특히&amp;nbsp;Mentor는&amp;nbsp;생성&amp;nbsp;시점에&amp;nbsp;반드시&amp;nbsp;status가&amp;nbsp;결정되어야&amp;nbsp;하는데&amp;nbsp;이를&amp;nbsp;강제할&amp;nbsp;방법이&amp;nbsp;없습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;생성자를&amp;nbsp;private으로&amp;nbsp;막고&amp;nbsp;Mentor.create()라는&amp;nbsp;정적&amp;nbsp;팩토리&amp;nbsp;메서드만&amp;nbsp;열어두었습니다.&amp;nbsp;자동&amp;nbsp;승인&amp;nbsp;여부를&amp;nbsp;결정한&amp;nbsp;initialStatus를&amp;nbsp;파라미터로&amp;nbsp;받아&amp;nbsp;생성&amp;nbsp;시점에&amp;nbsp;반드시&amp;nbsp;status가&amp;nbsp;설정되도록&amp;nbsp;강제합니다.&amp;nbsp;객체&amp;nbsp;생성&amp;nbsp;의도를&amp;nbsp;메서드&amp;nbsp;이름으로&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;PUT&amp;nbsp;/api/mentors/me&amp;nbsp;&amp;mdash;&amp;nbsp;멘토&amp;nbsp;정보&amp;nbsp;수정 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;부분&amp;nbsp;업데이트&amp;nbsp;-&amp;nbsp;null&amp;nbsp;필드&amp;nbsp;기존&amp;nbsp;값&amp;nbsp;유지 &lt;br /&gt;문제:&amp;nbsp;수정&amp;nbsp;API에서&amp;nbsp;전체&amp;nbsp;필드를&amp;nbsp;항상&amp;nbsp;보내도록&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;해결:&amp;nbsp;Mentor.update()&amp;nbsp;메서드에서&amp;nbsp;null&amp;nbsp;필드는&amp;nbsp;기존&amp;nbsp;값을&amp;nbsp;유지하도록&amp;nbsp;구현했습니다.&amp;nbsp;introduction,&amp;nbsp;careerHistory&amp;nbsp;같은&amp;nbsp;단순&amp;nbsp;문자열&amp;nbsp;필드는&amp;nbsp;null&amp;nbsp;여부를&amp;nbsp;체크하고,&amp;nbsp;mainGenres,&amp;nbsp;specialFields,&amp;nbsp;mentoringStyles&amp;nbsp;리스트&amp;nbsp;필드는&amp;nbsp;등록용&amp;nbsp;toJson()과&amp;nbsp;수정용&amp;nbsp;toJsonForUpdate()를&amp;nbsp;분리했습니다.&amp;nbsp;toJsonForUpdate()는&amp;nbsp;null이면&amp;nbsp;null을&amp;nbsp;반환해&amp;nbsp;기존&amp;nbsp;값을&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;GET&amp;nbsp;/api/mentors/me&amp;nbsp;&amp;mdash;&amp;nbsp;내&amp;nbsp;멘토&amp;nbsp;프로필&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;JSON&amp;nbsp;역직렬화로&amp;nbsp;리스트&amp;nbsp;필드&amp;nbsp;복원 &lt;br /&gt;문제:&amp;nbsp;DB에&amp;nbsp;JSON&amp;nbsp;문자열로&amp;nbsp;저장된&amp;nbsp;mainGenres,&amp;nbsp;specialFields,&amp;nbsp;mentoringStyles를&amp;nbsp;응답&amp;nbsp;시&amp;nbsp;List로&amp;nbsp;변환해야&amp;nbsp;합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;MentorProfileResponse의&amp;nbsp;parseJson()&amp;nbsp;메서드에서&amp;nbsp;objectMapper.readValue()로&amp;nbsp;JSON&amp;nbsp;문자열을&amp;nbsp;List로&amp;nbsp;역직렬화합니다.&amp;nbsp;파싱&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;배치&amp;nbsp;스케줄러&amp;nbsp;&amp;mdash;&amp;nbsp;매일&amp;nbsp;자정&amp;nbsp;멘토&amp;nbsp;등급&amp;nbsp;자동&amp;nbsp;조정 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;Spring&amp;nbsp;@Scheduled&amp;nbsp;배치 &lt;br /&gt;문제:&amp;nbsp;멘토&amp;nbsp;등급은&amp;nbsp;에피소드&amp;nbsp;회차와&amp;nbsp;좋아요&amp;nbsp;수에&amp;nbsp;따라&amp;nbsp;자동으로&amp;nbsp;올라가야&amp;nbsp;합니다.&amp;nbsp;에피소드&amp;nbsp;발행이나&amp;nbsp;좋아요&amp;nbsp;발생&amp;nbsp;시마다&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;해결:&amp;nbsp;Spring의&amp;nbsp;@Scheduled(cron&amp;nbsp;=&amp;nbsp;&quot;0&amp;nbsp;0&amp;nbsp;0&amp;nbsp;*&amp;nbsp;*&amp;nbsp;*&quot;)로&amp;nbsp;매일&amp;nbsp;자정에&amp;nbsp;한&amp;nbsp;번&amp;nbsp;APPROVED&amp;nbsp;상태&amp;nbsp;멘토&amp;nbsp;전체를&amp;nbsp;순회하며&amp;nbsp;조건을&amp;nbsp;검사합니다.&amp;nbsp;각&amp;nbsp;도메인이&amp;nbsp;서로를&amp;nbsp;몰라도&amp;nbsp;되고&amp;nbsp;구현이&amp;nbsp;단순합니다.&amp;nbsp;현재&amp;nbsp;트래픽&amp;nbsp;규모에서&amp;nbsp;실시간&amp;nbsp;반영이&amp;nbsp;필수가&amp;nbsp;아니라&amp;nbsp;판단해&amp;nbsp;별도&amp;nbsp;인프라&amp;nbsp;없이&amp;nbsp;Spring&amp;nbsp;내장&amp;nbsp;스케줄러로&amp;nbsp;충분하다고&amp;nbsp;결정했습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;등급&amp;nbsp;변경&amp;nbsp;이력&amp;nbsp;별도&amp;nbsp;테이블&amp;nbsp;저장 &lt;br /&gt;문제:&amp;nbsp;배치로&amp;nbsp;등급이&amp;nbsp;변경될&amp;nbsp;때&amp;nbsp;Mentor&amp;nbsp;테이블에&amp;nbsp;덮어쓰기만&amp;nbsp;하면&amp;nbsp;언제&amp;nbsp;어떤&amp;nbsp;이유로&amp;nbsp;등급이&amp;nbsp;바뀌었는지&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;해결:&amp;nbsp;MentorCareerHistory&amp;nbsp;테이블에&amp;nbsp;변경&amp;nbsp;전&amp;nbsp;등급,&amp;nbsp;변경&amp;nbsp;후&amp;nbsp;등급,&amp;nbsp;변경&amp;nbsp;사유,&amp;nbsp;변경&amp;nbsp;시각을&amp;nbsp;함께&amp;nbsp;저장합니다.&amp;nbsp;데이터가&amp;nbsp;누적되는&amp;nbsp;append-only&amp;nbsp;구조라&amp;nbsp;변경&amp;nbsp;흐름&amp;nbsp;전체를&amp;nbsp;추적할&amp;nbsp;수&amp;nbsp;있고&amp;nbsp;삭제나&amp;nbsp;수정이&amp;nbsp;없어&amp;nbsp;관리가&amp;nbsp;단순합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;중복 sessionNumber 발생을 방지하기 위해 비관적 락과 DB 유니크 제약을 함께 적용, 낙관적 락 적용, 상태전이 모델, 성능을 점진적으로 개선 해나 났다 +배치 스케줄러&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;배치&amp;nbsp;스케줄러--&amp;gt;&amp;nbsp;도메인 &lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;멘토 등급 자동 조정 청크 단위 고민한거--&amp;gt; 메모리 부하, 영속성 컨텍스트 누적 문제 해결, 배치 페이지네이션 pk 정렬 고정&lt;/span&gt;&lt;br /&gt;&lt;br /&gt;--------------------------------------------------------------------------------------------------------&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;[멘토&amp;nbsp;도메인] &lt;br /&gt;1.&amp;nbsp;GET&amp;nbsp;/api/mentorings/received&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;접수&amp;nbsp;목록&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;페이지네이션&amp;nbsp;(Spring&amp;nbsp;Data&amp;nbsp;Pageable) &lt;br /&gt;문제:&amp;nbsp;멘토에게&amp;nbsp;접수된&amp;nbsp;멘토링&amp;nbsp;신청이&amp;nbsp;많아질수록&amp;nbsp;전체&amp;nbsp;목록을&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;반환하면&amp;nbsp;응답&amp;nbsp;크기가&amp;nbsp;커지고&amp;nbsp;DB&amp;nbsp;부하가&amp;nbsp;증가합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Pageable을&amp;nbsp;적용해&amp;nbsp;페이지&amp;nbsp;단위로&amp;nbsp;조회하고&amp;nbsp;PageResponse로&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;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;엔티티&amp;nbsp;ID&amp;nbsp;참조&amp;nbsp;방식&amp;nbsp;+&amp;nbsp;개별&amp;nbsp;조회&amp;nbsp;(N+1&amp;nbsp;인지) &lt;br /&gt;문제:&amp;nbsp;엔티티&amp;nbsp;간&amp;nbsp;연관관계를&amp;nbsp;맺지&amp;nbsp;않는&amp;nbsp;설계&amp;nbsp;원칙상&amp;nbsp;Mentorship에서&amp;nbsp;멘티&amp;nbsp;이름(User)과&amp;nbsp;소설&amp;nbsp;제목(Novel)을&amp;nbsp;바로&amp;nbsp;JOIN할&amp;nbsp;수&amp;nbsp;없습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;userRepository.findByIdAndIsDeletedFalse(),&amp;nbsp;novelRepository.findById()로&amp;nbsp;개별&amp;nbsp;조회합니다.&amp;nbsp;현재는&amp;nbsp;N+1이&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있으나&amp;nbsp;탈퇴한&amp;nbsp;멘티는&amp;nbsp;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;사용자,&amp;nbsp;삭제된&amp;nbsp;소설은&amp;nbsp;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;소설로&amp;nbsp;graceful하게&amp;nbsp;처리합니다.&amp;nbsp;고도화&amp;nbsp;시&amp;nbsp;QueryDSL&amp;nbsp;JOIN으로&amp;nbsp;교체할&amp;nbsp;위치를&amp;nbsp;TODO&amp;nbsp;주석으로&amp;nbsp;명시해두었습니다. &lt;br /&gt;&lt;br /&gt;2.&amp;nbsp;PATCH&amp;nbsp;/api/mentorings/{mentoringId}/mentees/{menteeId}/accept&amp;nbsp;&amp;mdash;&amp;nbsp;멘티&amp;nbsp;수락 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;낙관적&amp;nbsp;락&amp;nbsp;(@Version) &lt;br /&gt;문제:&amp;nbsp;멘토가&amp;nbsp;여러&amp;nbsp;멘티를&amp;nbsp;동시에&amp;nbsp;수락하는&amp;nbsp;경우&amp;nbsp;슬롯&amp;nbsp;차감(decreaseSlot())이&amp;nbsp;동시에&amp;nbsp;실행되면&amp;nbsp;슬롯이&amp;nbsp;0&amp;nbsp;이하로&amp;nbsp;내려가는&amp;nbsp;race&amp;nbsp;condition이&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있습니다 &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Mentor&amp;nbsp;엔티티에&amp;nbsp;@Version을&amp;nbsp;적용해&amp;nbsp;낙관적&amp;nbsp;락을&amp;nbsp;걸었습니다.&amp;nbsp;동시에&amp;nbsp;두&amp;nbsp;요청이&amp;nbsp;슬롯을&amp;nbsp;차감하려&amp;nbsp;하면&amp;nbsp;나중에&amp;nbsp;커밋된&amp;nbsp;트랜잭션이&amp;nbsp;OptimisticLockException을&amp;nbsp;발생시켜&amp;nbsp;중복&amp;nbsp;차감을&amp;nbsp;방지합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;상태&amp;nbsp;검증&amp;nbsp;(PENDING&amp;nbsp;체크) &lt;br /&gt;문제:&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;해결:&amp;nbsp;mentorship.getStatus()&amp;nbsp;!=&amp;nbsp;MentorshipStatus.PENDING&amp;nbsp;체크로&amp;nbsp;이미&amp;nbsp;처리된&amp;nbsp;요청에는&amp;nbsp;MENTORING_ALREADY_PROCESSED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;3.&amp;nbsp;PATCH&amp;nbsp;/api/mentorings/{mentoringId}/mentees/{menteeId}/reject&amp;nbsp;&amp;mdash;&amp;nbsp;멘티&amp;nbsp;거절 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;상태&amp;nbsp;검증&amp;nbsp;(PENDING&amp;nbsp;체크) &lt;br /&gt;문제:&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;해결:&amp;nbsp;수락&amp;nbsp;API와&amp;nbsp;동일하게&amp;nbsp;PENDING&amp;nbsp;상태&amp;nbsp;체크를&amp;nbsp;통해&amp;nbsp;중복&amp;nbsp;처리를&amp;nbsp;방지하고&amp;nbsp;MENTORING_ALREADY_PROCESSED&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;4.&amp;nbsp;GET&amp;nbsp;/api/mentorings/{mentoringId}/documents&amp;nbsp;&amp;mdash;&amp;nbsp;원고&amp;nbsp;다운로드&amp;nbsp;URL&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;다운로드&amp;nbsp;횟수&amp;nbsp;트래킹 &lt;br /&gt;문제:&amp;nbsp;멘토가&amp;nbsp;원고를&amp;nbsp;몇&amp;nbsp;번&amp;nbsp;다운로드했는지&amp;nbsp;추적이&amp;nbsp;필요합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;URL&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;mentorship.increaseManuscriptDownloadCount()를&amp;nbsp;호출해&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;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;S3&amp;nbsp;Presigned&amp;nbsp;URL&amp;nbsp;연동&amp;nbsp;준비&amp;nbsp;(TODO) &lt;br /&gt;문제:&amp;nbsp;원고&amp;nbsp;파일&amp;nbsp;URL을&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;해결:&amp;nbsp;현재는&amp;nbsp;manuscriptUrl을&amp;nbsp;직접&amp;nbsp;반환하지만&amp;nbsp;S3&amp;nbsp;연동&amp;nbsp;후&amp;nbsp;s3Service.generatePresignedUrl()로&amp;nbsp;교체할&amp;nbsp;위치를&amp;nbsp;TODO&amp;nbsp;주석으로&amp;nbsp;명시해&amp;nbsp;두었습니다. &lt;br /&gt;&lt;br /&gt;5.&amp;nbsp;PATCH&amp;nbsp;/api/mentorings/{mentoringId}/complete&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;종료 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;낙관적&amp;nbsp;락&amp;nbsp;(@Version)&amp;nbsp;+&amp;nbsp;슬롯&amp;nbsp;반환 &lt;br /&gt;문제:&amp;nbsp;멘토링&amp;nbsp;종료&amp;nbsp;시&amp;nbsp;슬롯을&amp;nbsp;반환(increaseSlot())하는데&amp;nbsp;동시에&amp;nbsp;여러&amp;nbsp;멘토링이&amp;nbsp;종료되면&amp;nbsp;슬롯&amp;nbsp;값이&amp;nbsp;꼬일&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Mentor&amp;nbsp;엔티티의&amp;nbsp;@Version으로&amp;nbsp;슬롯&amp;nbsp;반환&amp;nbsp;시에도&amp;nbsp;낙관적&amp;nbsp;락이&amp;nbsp;적용되어&amp;nbsp;동시&amp;nbsp;수정&amp;nbsp;충돌을&amp;nbsp;방지합니다.&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;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;상태&amp;nbsp;검증&amp;nbsp;(ACCEPTED&amp;nbsp;체크) &lt;br /&gt;문제:&amp;nbsp;PENDING이나&amp;nbsp;REJECTED&amp;nbsp;상태의&amp;nbsp;멘토링을&amp;nbsp;종료&amp;nbsp;처리하려는&amp;nbsp;잘못된&amp;nbsp;요청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;mentorship.getStatus()&amp;nbsp;!=&amp;nbsp;MentorshipStatus.ACCEPTED&amp;nbsp;체크로&amp;nbsp;진행&amp;nbsp;중인&amp;nbsp;멘토링만&amp;nbsp;종료할&amp;nbsp;수&amp;nbsp;있도록&amp;nbsp;제한하고&amp;nbsp;MENTORING_NOT_ACCEPTED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;6.&amp;nbsp;GET&amp;nbsp;/api/mentorings/{mentoringId}&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;상세&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;엔티티&amp;nbsp;ID&amp;nbsp;참조&amp;nbsp;방식&amp;nbsp;+&amp;nbsp;Soft&amp;nbsp;Delete&amp;nbsp;고려&amp;nbsp;조회 &lt;br /&gt;문제:&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;해결:&amp;nbsp;userRepository.findByIdAndIsDeletedFalse()로&amp;nbsp;조회해&amp;nbsp;탈퇴한&amp;nbsp;유저는&amp;nbsp;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;사용자로&amp;nbsp;graceful하게&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;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;피드백&amp;nbsp;목록&amp;nbsp;시간순&amp;nbsp;정렬&amp;nbsp;조회 &lt;br /&gt;문제:&amp;nbsp;피드백이&amp;nbsp;여러&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;해결:&amp;nbsp;mentorFeedbackRepository.findAllByMentorshipIdOrderByCreatedAtAsc()로&amp;nbsp;피드백을&amp;nbsp;시간&amp;nbsp;오름차순으로&amp;nbsp;조회합니다. &lt;br /&gt;&lt;br /&gt;7.&amp;nbsp;POST&amp;nbsp;/api/mentorings/{mentoringId}/feedbacks&amp;nbsp;&amp;mdash;&amp;nbsp;피드백&amp;nbsp;작성 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;세션&amp;nbsp;횟수&amp;nbsp;트래킹 &lt;br /&gt;문제:&amp;nbsp;멘토링이&amp;nbsp;몇&amp;nbsp;회&amp;nbsp;진행되었는지&amp;nbsp;추적이&amp;nbsp;필요합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;피드백&amp;nbsp;저장&amp;nbsp;시&amp;nbsp;mentorship.increaseSession()을&amp;nbsp;함께&amp;nbsp;호출해&amp;nbsp;총&amp;nbsp;세션&amp;nbsp;수를&amp;nbsp;증가시킵니다.&amp;nbsp;피드백&amp;nbsp;저장과&amp;nbsp;세션&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;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;상태&amp;nbsp;검증&amp;nbsp;(ACCEPTED&amp;nbsp;체크) &lt;br /&gt;문제:&amp;nbsp;진행&amp;nbsp;중이&amp;nbsp;아닌&amp;nbsp;멘토링(PENDING,&amp;nbsp;REJECTED,&amp;nbsp;COMPLETED)에&amp;nbsp;피드백을&amp;nbsp;작성하는&amp;nbsp;잘못된&amp;nbsp;요청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;mentorship.getStatus()&amp;nbsp;!=&amp;nbsp;MentorshipStatus.ACCEPTED&amp;nbsp;체크로&amp;nbsp;수락된&amp;nbsp;멘토링에만&amp;nbsp;피드백&amp;nbsp;작성이&amp;nbsp;가능하도록&amp;nbsp;제한하고&amp;nbsp;MENTORING_FEEDBACK_ONLY_ACCEPTED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;-------------------------------------------------------------------------------------------------------- &lt;br /&gt;&lt;br /&gt;[고도화&amp;nbsp;진행한&amp;nbsp;내용] &lt;br /&gt;POST&amp;nbsp;/api/v1/mentorings/{mentoringId}/feedbacks&amp;nbsp;&amp;mdash;&amp;nbsp;피드백&amp;nbsp;작성&amp;nbsp;(V1) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;세션&amp;nbsp;횟수&amp;nbsp;트래킹 &lt;br /&gt;문제:&amp;nbsp;멘토링이&amp;nbsp;몇&amp;nbsp;회&amp;nbsp;진행되었는지&amp;nbsp;추적이&amp;nbsp;필요합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;피드백&amp;nbsp;저장&amp;nbsp;시&amp;nbsp;mentorship.increaseSession()을&amp;nbsp;함께&amp;nbsp;호출해&amp;nbsp;총&amp;nbsp;세션&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;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;상태&amp;nbsp;검증&amp;nbsp;(ACCEPTED&amp;nbsp;체크) &lt;br /&gt;문제:&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;해결:&amp;nbsp;mentorship.getStatus()&amp;nbsp;!=&amp;nbsp;MentorshipStatus.ACCEPTED&amp;nbsp;체크로&amp;nbsp;수락된&amp;nbsp;멘토링에만&amp;nbsp;피드백&amp;nbsp;작성이&amp;nbsp;가능하도록&amp;nbsp;제한하고&amp;nbsp;MENTORING_FEEDBACK_ONLY_ACCEPTED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;회차별&amp;nbsp;피드백&amp;nbsp;이력&amp;nbsp;관리 &lt;br /&gt;문제:&amp;nbsp;피드백이&amp;nbsp;몇&amp;nbsp;회차인지,&amp;nbsp;어떤&amp;nbsp;제목의&amp;nbsp;피드백인지&amp;nbsp;구분할&amp;nbsp;방법이&amp;nbsp;없었습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;MentorFeedback에&amp;nbsp;title과&amp;nbsp;sessionNumber를&amp;nbsp;추가해&amp;nbsp;회차별&amp;nbsp;피드백&amp;nbsp;이력을&amp;nbsp;명확히&amp;nbsp;관리합니다.&amp;nbsp;sessionNumber는&amp;nbsp;totalSessions&amp;nbsp;+&amp;nbsp;1로&amp;nbsp;계산해&amp;nbsp;자동&amp;nbsp;부여합니다. &lt;br /&gt;&lt;br /&gt;POST&amp;nbsp;/api/v2/mentorings/{mentoringId}/feedbacks&amp;nbsp;&amp;mdash;&amp;nbsp;피드백&amp;nbsp;작성&amp;nbsp;(V2) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;비관적&amp;nbsp;락&amp;nbsp;(동시성&amp;nbsp;보호) &lt;br /&gt;문제:&amp;nbsp;동시에&amp;nbsp;두&amp;nbsp;요청이&amp;nbsp;들어오면&amp;nbsp;totalSessions&amp;nbsp;+&amp;nbsp;1을&amp;nbsp;동시에&amp;nbsp;읽어&amp;nbsp;같은&amp;nbsp;sessionNumber가&amp;nbsp;중복&amp;nbsp;저장될&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;findByIdWithLock()으로&amp;nbsp;멘토링&amp;nbsp;row에&amp;nbsp;비관적&amp;nbsp;락을&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;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;DB&amp;nbsp;유니크&amp;nbsp;제약&amp;nbsp;(이중&amp;nbsp;방어) &lt;br /&gt;문제:&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;해결:&amp;nbsp;mentorship_feedbacks&amp;nbsp;테이블에&amp;nbsp;(mentorship_id,&amp;nbsp;session_number)&amp;nbsp;유니크&amp;nbsp;제약을&amp;nbsp;추가해&amp;nbsp;DB&amp;nbsp;레벨에서도&amp;nbsp;중복을&amp;nbsp;차단합니다.&amp;nbsp;충돌&amp;nbsp;시&amp;nbsp;DataIntegrityViolationException을&amp;nbsp;MENTORING_SESSION_CONFLICT&amp;nbsp;예외로&amp;nbsp;변환해&amp;nbsp;409를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;입력값&amp;nbsp;길이&amp;nbsp;검증 &lt;br /&gt;문제:&amp;nbsp;title이&amp;nbsp;200자를&amp;nbsp;초과하면&amp;nbsp;DB&amp;nbsp;예외로&amp;nbsp;500이&amp;nbsp;반환될&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;@Size(max&amp;nbsp;=&amp;nbsp;200)을&amp;nbsp;추가해&amp;nbsp;요청&amp;nbsp;단계에서&amp;nbsp;400으로&amp;nbsp;끊어&amp;nbsp;사용자에게&amp;nbsp;명확한&amp;nbsp;오류&amp;nbsp;메시지를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v1/mentorings/received&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;접수&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(V1) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;페이지네이션 &lt;br /&gt;문제:&amp;nbsp;멘토링&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;해결:&amp;nbsp;Pageable을&amp;nbsp;적용해&amp;nbsp;페이지&amp;nbsp;단위로&amp;nbsp;조회합니다.&amp;nbsp;@Min,&amp;nbsp;@Max로&amp;nbsp;page,&amp;nbsp;size&amp;nbsp;파라미터를&amp;nbsp;검증해&amp;nbsp;잘못된&amp;nbsp;입력으로&amp;nbsp;인한&amp;nbsp;500&amp;nbsp;에러를&amp;nbsp;방지합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;탈퇴&amp;nbsp;회원/삭제&amp;nbsp;소설&amp;nbsp;Fallback&amp;nbsp;처리 &lt;br /&gt;문제:&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;해결:&amp;nbsp;Optional의&amp;nbsp;orElse()로&amp;nbsp;탈퇴한&amp;nbsp;멘티는&amp;nbsp;&quot;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;사용자&quot;,&amp;nbsp;삭제된&amp;nbsp;소설은&amp;nbsp;&quot;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;소설&quot;을&amp;nbsp;반환해&amp;nbsp;안정적으로&amp;nbsp;목록을&amp;nbsp;제공합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v2/mentorings/received&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;접수&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(V2) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;soft-delete&amp;nbsp;적용 &lt;br /&gt;문제:&amp;nbsp;V1의&amp;nbsp;findById()는&amp;nbsp;soft-delete&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;해결:&amp;nbsp;findByIdAndIsDeletedFalse()로&amp;nbsp;교체해&amp;nbsp;삭제된&amp;nbsp;소설은&amp;nbsp;&quot;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;소설&quot;로&amp;nbsp;처리합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v1/mentorings/{mentoringId}&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;상세&amp;nbsp;조회&amp;nbsp;(V1) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;권한&amp;nbsp;검증 &lt;br /&gt;문제:&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;해결:&amp;nbsp;mentorship.getMentorId()와&amp;nbsp;로그인한&amp;nbsp;멘토의&amp;nbsp;ID를&amp;nbsp;비교해&amp;nbsp;본인의&amp;nbsp;멘토링만&amp;nbsp;조회&amp;nbsp;가능하도록&amp;nbsp;제한하고&amp;nbsp;MENTORING_UNAUTHORIZED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;회차별&amp;nbsp;피드백&amp;nbsp;이력&amp;nbsp;조회 &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;해결:&amp;nbsp;findAllByMentorshipIdOrderByCreatedAtAsc()로&amp;nbsp;피드백을&amp;nbsp;생성&amp;nbsp;순서대로&amp;nbsp;조회하고&amp;nbsp;FeedbackInfo에&amp;nbsp;title,&amp;nbsp;sessionNumber를&amp;nbsp;포함해&amp;nbsp;회차별&amp;nbsp;내용을&amp;nbsp;명확히&amp;nbsp;제공합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v2/mentorings/{mentoringId}&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;상세&amp;nbsp;조회&amp;nbsp;(V2) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;soft-delete&amp;nbsp;적용 &lt;br /&gt;문제:&amp;nbsp;V1의&amp;nbsp;findById()는&amp;nbsp;삭제된&amp;nbsp;소설&amp;nbsp;제목이&amp;nbsp;그대로&amp;nbsp;노출될&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;findByIdAndIsDeletedFalse()로&amp;nbsp;교체해&amp;nbsp;삭제된&amp;nbsp;소설은&amp;nbsp;&quot;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;소설&quot;로&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;PATCH&amp;nbsp;/api/v1,v2/mentorings/{mentoringId}/mentees/{menteeId}/accept&amp;nbsp;&amp;mdash;&amp;nbsp;멘티&amp;nbsp;수락 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;슬롯&amp;nbsp;관리 &lt;br /&gt;문제:&amp;nbsp;멘토가&amp;nbsp;수락할&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;멘티&amp;nbsp;수는&amp;nbsp;최대&amp;nbsp;5명으로&amp;nbsp;제한됩니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;수락&amp;nbsp;시&amp;nbsp;mentor.decreaseSlot()으로&amp;nbsp;잔여&amp;nbsp;슬롯을&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;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;중복&amp;nbsp;처리&amp;nbsp;방지 &lt;br /&gt;문제:&amp;nbsp;이미&amp;nbsp;수락/거절된&amp;nbsp;멘토링에&amp;nbsp;다시&amp;nbsp;수락&amp;nbsp;요청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;mentorship.getStatus()&amp;nbsp;!=&amp;nbsp;MentorshipStatus.PENDING&amp;nbsp;체크로&amp;nbsp;PENDING&amp;nbsp;상태에서만&amp;nbsp;수락이&amp;nbsp;가능하도록&amp;nbsp;제한하고&amp;nbsp;MENTORING_ALREADY_PROCESSED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;PATCH&amp;nbsp;/api/v1,v2/mentorings/{mentoringId}/mentees/{menteeId}/reject&amp;nbsp;&amp;mdash;&amp;nbsp;멘티&amp;nbsp;거절 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;중복&amp;nbsp;처리&amp;nbsp;방지 &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;해결:&amp;nbsp;PENDING&amp;nbsp;상태에서만&amp;nbsp;거절&amp;nbsp;가능하도록&amp;nbsp;제한하고&amp;nbsp;MENTORING_ALREADY_PROCESSED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;거절&amp;nbsp;시각&amp;nbsp;기록 &lt;br /&gt;문제:&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;해결:&amp;nbsp;mentorship.reject()&amp;nbsp;호출&amp;nbsp;시&amp;nbsp;rejectedAt을&amp;nbsp;LocalDateTime.now()로&amp;nbsp;기록합니다.&amp;nbsp;이를&amp;nbsp;기반으로&amp;nbsp;countRejectedThisMonth()&amp;nbsp;쿼리에서&amp;nbsp;월별&amp;nbsp;거절&amp;nbsp;통계를&amp;nbsp;정확히&amp;nbsp;집계합니다. &lt;br /&gt;&lt;br /&gt;PATCH&amp;nbsp;/api/v1,v2/mentorings/{mentoringId}/complete&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;종료 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;슬롯&amp;nbsp;반환 &lt;br /&gt;문제:&amp;nbsp;멘토링이&amp;nbsp;종료되면&amp;nbsp;해당&amp;nbsp;슬롯을&amp;nbsp;다시&amp;nbsp;다른&amp;nbsp;멘티에게&amp;nbsp;열어줘야&amp;nbsp;합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;종료&amp;nbsp;시&amp;nbsp;mentor.increaseSlot()으로&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;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;상태&amp;nbsp;검증&amp;nbsp;(ACCEPTED&amp;nbsp;체크) &lt;br /&gt;문제:&amp;nbsp;PENDING이나&amp;nbsp;REJECTED&amp;nbsp;상태의&amp;nbsp;멘토링을&amp;nbsp;종료하는&amp;nbsp;잘못된&amp;nbsp;요청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;ACCEPTED&amp;nbsp;상태에서만&amp;nbsp;종료&amp;nbsp;가능하도록&amp;nbsp;제한하고&amp;nbsp;MENTORING_NOT_ACCEPTED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v1,v2/mentorings/{mentoringId}/documents&amp;nbsp;&amp;mdash;&amp;nbsp;원고&amp;nbsp;다운로드&amp;nbsp;URL&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;다운로드&amp;nbsp;횟수&amp;nbsp;트래킹 &lt;br /&gt;문제:&amp;nbsp;멘토가&amp;nbsp;원고를&amp;nbsp;몇&amp;nbsp;번&amp;nbsp;다운로드했는지&amp;nbsp;추적이&amp;nbsp;필요합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;URL&amp;nbsp;반환&amp;nbsp;시&amp;nbsp;mentorship.increaseManuscriptDownloadCount()를&amp;nbsp;함께&amp;nbsp;호출해&amp;nbsp;다운로드&amp;nbsp;횟수를&amp;nbsp;기록합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;원고&amp;nbsp;존재&amp;nbsp;여부&amp;nbsp;검증 &lt;br /&gt;문제:&amp;nbsp;원고&amp;nbsp;파일이&amp;nbsp;없는&amp;nbsp;멘토링에&amp;nbsp;다운로드&amp;nbsp;요청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;manuscriptUrl이&amp;nbsp;null인&amp;nbsp;경우&amp;nbsp;MENTORING_MANUSCRIPT_NOT_FOUND&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;내용] &lt;br /&gt;GET&amp;nbsp;/api/mentors/me/mentees&amp;nbsp;&amp;mdash;&amp;nbsp;내&amp;nbsp;멘티&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(v1) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;탈퇴/삭제&amp;nbsp;데이터&amp;nbsp;방어 &lt;br /&gt;문제:&amp;nbsp;멘티가&amp;nbsp;탈퇴하거나&amp;nbsp;소설이&amp;nbsp;삭제된&amp;nbsp;경우&amp;nbsp;응답에&amp;nbsp;null이&amp;nbsp;들어갈&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;userRepository.findByIdAndIsDeletedFalse()&amp;nbsp;조회&amp;nbsp;후&amp;nbsp;orElse(&quot;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;사용자&quot;),&amp;nbsp;novelRepository.findById()&amp;nbsp;조회&amp;nbsp;후&amp;nbsp;orElse(&quot;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;소설&quot;)로&amp;nbsp;null을&amp;nbsp;방어합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;최근&amp;nbsp;피드백&amp;nbsp;날짜&amp;nbsp;조회 &lt;br /&gt;문제:&amp;nbsp;멘티별로&amp;nbsp;마지막&amp;nbsp;피드백&amp;nbsp;날짜를&amp;nbsp;보여줘야&amp;nbsp;합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;mentorFeedbackRepository.findTopByMentorshipIdOrderByCreatedAtDesc()로&amp;nbsp;최신&amp;nbsp;1건만&amp;nbsp;조회해서&amp;nbsp;createdAt을&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v2/mentors/me/mentees&amp;nbsp;&amp;mdash;&amp;nbsp;내&amp;nbsp;멘티&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(v2) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;N+1&amp;nbsp;문제&amp;nbsp;해결 &lt;br /&gt;문제:&amp;nbsp;v1은&amp;nbsp;반복문&amp;nbsp;안에서&amp;nbsp;멘티,&amp;nbsp;소설,&amp;nbsp;피드백을&amp;nbsp;각각&amp;nbsp;개별&amp;nbsp;조회해&amp;nbsp;멘티가&amp;nbsp;N명이면&amp;nbsp;쿼리가&amp;nbsp;1+3N번&amp;nbsp;나가는&amp;nbsp;구조였습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;QueryDSL로&amp;nbsp;mentorship&amp;nbsp;&amp;rarr;&amp;nbsp;menteeUser&amp;nbsp;&amp;rarr;&amp;nbsp;novel&amp;nbsp;&amp;rarr;&amp;nbsp;feedback을&amp;nbsp;단일&amp;nbsp;JOIN&amp;nbsp;쿼리로&amp;nbsp;처리해&amp;nbsp;쿼리를&amp;nbsp;1번으로&amp;nbsp;줄였습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;soft-delete&amp;nbsp;방어 &lt;br /&gt;문제:&amp;nbsp;탈퇴한&amp;nbsp;유저나&amp;nbsp;삭제된&amp;nbsp;소설&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;LEFT&amp;nbsp;JOIN&amp;nbsp;결과&amp;nbsp;null이&amp;nbsp;응답에&amp;nbsp;그대로&amp;nbsp;들어갈&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;Expressions.cases()로&amp;nbsp;쿼리&amp;nbsp;단에서&amp;nbsp;직접&amp;nbsp;null을&amp;nbsp;방어합니다. &lt;br /&gt;javaExpressions.cases() &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.when(menteeUser.nickname.isNull()) &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.then(&quot;알&amp;nbsp;수&amp;nbsp;없는&amp;nbsp;사용자&quot;) &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.otherwise(menteeUser.nickname) &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;v1&amp;nbsp;호환성&amp;nbsp;유지 &lt;br /&gt;문제:&amp;nbsp;기존&amp;nbsp;v1&amp;nbsp;API를&amp;nbsp;바로&amp;nbsp;교체하면&amp;nbsp;롤백이&amp;nbsp;어렵습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;v1&amp;nbsp;엔드포인트는&amp;nbsp;그대로&amp;nbsp;유지하고&amp;nbsp;/api/v2/mentors/me/mentees를&amp;nbsp;신규&amp;nbsp;추가해&amp;nbsp;점진적&amp;nbsp;전환이&amp;nbsp;가능하도록&amp;nbsp;했습니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/v2/mentorings/received&amp;nbsp;&amp;mdash;&amp;nbsp;멘토링&amp;nbsp;접수&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(v2) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;N+1&amp;nbsp;문제&amp;nbsp;해결 &lt;br /&gt;문제:&amp;nbsp;v1은&amp;nbsp;멘토십&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;해결:&amp;nbsp;QueryDSL로&amp;nbsp;mentorship&amp;nbsp;&amp;rarr;&amp;nbsp;menteeUser&amp;nbsp;&amp;rarr;&amp;nbsp;novel을&amp;nbsp;단일&amp;nbsp;LEFT&amp;nbsp;JOIN&amp;nbsp;쿼리로&amp;nbsp;처리했습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;soft-delete&amp;nbsp;적용 &lt;br /&gt;문제:&amp;nbsp;탈퇴한&amp;nbsp;멘티나&amp;nbsp;삭제된&amp;nbsp;소설의&amp;nbsp;경우&amp;nbsp;null이&amp;nbsp;응답에&amp;nbsp;들어갈&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;JOIN&amp;nbsp;조건에&amp;nbsp;isDeleted=false를&amp;nbsp;추가하고&amp;nbsp;Expressions.cases()로&amp;nbsp;null&amp;nbsp;fallback을&amp;nbsp;처리했습니다. &lt;br /&gt;java.leftJoin(menteeUser).on(menteeUser.id.eq(mentorship.menteeId) &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.and(menteeUser.isDeleted.eq(false))) &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;페이지네이션 &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;해결:&amp;nbsp;Pageable을&amp;nbsp;적용해&amp;nbsp;페이지&amp;nbsp;단위로&amp;nbsp;조회하고&amp;nbsp;PageImpl로&amp;nbsp;응답합니다. &lt;br /&gt;&lt;br /&gt;GET&amp;nbsp;/api/mentorships/v2/me/history&amp;nbsp;&amp;mdash;&amp;nbsp;내&amp;nbsp;멘토링&amp;nbsp;이력&amp;nbsp;조회&amp;nbsp;(v2) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;N+1&amp;nbsp;문제&amp;nbsp;해결 &lt;br /&gt;문제:&amp;nbsp;v1은&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;해결:&amp;nbsp;QueryDSL로&amp;nbsp;mentorship&amp;nbsp;&amp;rarr;&amp;nbsp;mentor&amp;nbsp;&amp;rarr;&amp;nbsp;user를&amp;nbsp;단일&amp;nbsp;JOIN&amp;nbsp;쿼리로&amp;nbsp;처리했습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;soft-delete&amp;nbsp;적용 &lt;br /&gt;문제:&amp;nbsp;탈퇴한&amp;nbsp;멘토의&amp;nbsp;닉네임이&amp;nbsp;inner&amp;nbsp;join&amp;nbsp;구조에서&amp;nbsp;그대로&amp;nbsp;노출될&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;inner&amp;nbsp;join&amp;nbsp;&amp;rarr;&amp;nbsp;leftJoin으로&amp;nbsp;변경하고&amp;nbsp;isDeleted=false&amp;nbsp;조건과&amp;nbsp;null&amp;nbsp;fallback을&amp;nbsp;추가했습니다. &lt;br /&gt;java.leftJoin(user).on(user.id.eq(mentor.userId) &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;.and(user.isDeleted.eq(false))) &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;상태&amp;nbsp;필터링 &lt;br /&gt;문제:&amp;nbsp;전체&amp;nbsp;이력이&amp;nbsp;아닌&amp;nbsp;특정&amp;nbsp;상태(ACCEPTED,&amp;nbsp;COMPLETED&amp;nbsp;등)의&amp;nbsp;이력만&amp;nbsp;조회할&amp;nbsp;수&amp;nbsp;있어야&amp;nbsp;합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;BooleanBuilder로&amp;nbsp;status가&amp;nbsp;null이면&amp;nbsp;전체,&amp;nbsp;값이&amp;nbsp;있으면&amp;nbsp;해당&amp;nbsp;상태만&amp;nbsp;필터링합니다. &lt;br /&gt;javaif&amp;nbsp;(status&amp;nbsp;!=&amp;nbsp;null)&amp;nbsp;{ &lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;where.and(mentorship.status.eq(status)); &lt;br /&gt;} &lt;br /&gt;&lt;br /&gt;PATCH&amp;nbsp;/api/mentors/me&amp;nbsp;&amp;mdash;&amp;nbsp;멘토&amp;nbsp;정보&amp;nbsp;수정 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;부분&amp;nbsp;업데이트&amp;nbsp;(null&amp;nbsp;허용) &lt;br /&gt;문제:&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;해결:&amp;nbsp;MentorUpdateRequest의&amp;nbsp;각&amp;nbsp;필드가&amp;nbsp;null이면&amp;nbsp;기존&amp;nbsp;값을&amp;nbsp;유지하고,&amp;nbsp;값이&amp;nbsp;있을&amp;nbsp;때만&amp;nbsp;업데이트합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;careerHistory&amp;nbsp;빈&amp;nbsp;값&amp;nbsp;검증 &lt;br /&gt;문제:&amp;nbsp;careerHistory를&amp;nbsp;빈&amp;nbsp;문자열로&amp;nbsp;보내면&amp;nbsp;의미없는&amp;nbsp;데이터가&amp;nbsp;저장됩니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;null은&amp;nbsp;기존&amp;nbsp;값&amp;nbsp;유지,&amp;nbsp;빈&amp;nbsp;문자열은&amp;nbsp;MENTOR_CAREER_REQUIRED&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;POST&amp;nbsp;/api/mentors/me&amp;nbsp;&amp;mdash;&amp;nbsp;멘토&amp;nbsp;등록&amp;nbsp;신청 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;등급별&amp;nbsp;자동&amp;nbsp;승인&amp;nbsp;로직 &lt;br /&gt;문제:&amp;nbsp;등급마다&amp;nbsp;멘토&amp;nbsp;승인&amp;nbsp;조건이&amp;nbsp;다릅니다.&amp;nbsp;INTRODUCTION은&amp;nbsp;에피소드&amp;nbsp;50개,&amp;nbsp;ELEMENTARY는&amp;nbsp;에피소드&amp;nbsp;50개&amp;nbsp;+&amp;nbsp;좋아요&amp;nbsp;50개,&amp;nbsp;PROFICIENT는&amp;nbsp;항상&amp;nbsp;관리자&amp;nbsp;수동&amp;nbsp;승인입니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;resolveInitialStatus()에서&amp;nbsp;등급별&amp;nbsp;조건을&amp;nbsp;switch로&amp;nbsp;분기해&amp;nbsp;자동&amp;nbsp;승인&amp;nbsp;여부를&amp;nbsp;결정합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;동시&amp;nbsp;요청&amp;nbsp;방어 &lt;br /&gt;문제:&amp;nbsp;동시에&amp;nbsp;여러&amp;nbsp;요청이&amp;nbsp;들어오면&amp;nbsp;중복&amp;nbsp;등록이&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;DataIntegrityViolationException&amp;nbsp;발생&amp;nbsp;시&amp;nbsp;MENTOR_ALREADY_APPROVED&amp;nbsp;예외로&amp;nbsp;변환해&amp;nbsp;처리합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;중복&amp;nbsp;신청&amp;nbsp;방어 &lt;br /&gt;문제:&amp;nbsp;이미&amp;nbsp;PENDING이거나&amp;nbsp;APPROVED&amp;nbsp;상태인&amp;nbsp;경우&amp;nbsp;중복&amp;nbsp;신청이&amp;nbsp;들어올&amp;nbsp;수&amp;nbsp;있습니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;existsByUserIdAndStatus()로&amp;nbsp;사전&amp;nbsp;체크&amp;nbsp;후&amp;nbsp;이미&amp;nbsp;존재하면&amp;nbsp;예외를&amp;nbsp;반환합니다. &lt;br /&gt;&lt;br /&gt;스케줄러&amp;nbsp;&amp;mdash;&amp;nbsp;멘토&amp;nbsp;등급&amp;nbsp;자동&amp;nbsp;조정&amp;nbsp;(매일&amp;nbsp;자정) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;청크&amp;nbsp;단위&amp;nbsp;페이지네이션 &lt;br /&gt;문제:&amp;nbsp;승급&amp;nbsp;대상&amp;nbsp;멘토를&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;전부&amp;nbsp;List로&amp;nbsp;가져오면&amp;nbsp;데이터가&amp;nbsp;많을수록&amp;nbsp;메모리&amp;nbsp;부하가&amp;nbsp;커집니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;100명씩&amp;nbsp;페이지&amp;nbsp;단위로&amp;nbsp;나눠서&amp;nbsp;처리합니다. &lt;br /&gt;javaPageRequest.of(pageNumber,&amp;nbsp;CHUNK_SIZE,&amp;nbsp;Sort.by(&quot;id&quot;).ascending()) &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;persistence&amp;nbsp;context&amp;nbsp;정리 &lt;br /&gt;문제:&amp;nbsp;@Transactional&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;해결:&amp;nbsp;청크&amp;nbsp;처리&amp;nbsp;후&amp;nbsp;entityManager.flush()&amp;nbsp;/&amp;nbsp;entityManager.clear()를&amp;nbsp;호출해&amp;nbsp;청크마다&amp;nbsp;메모리를&amp;nbsp;비웁니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;정렬&amp;nbsp;기준&amp;nbsp;고정 &lt;br /&gt;문제:&amp;nbsp;정렬&amp;nbsp;기준이&amp;nbsp;없으면&amp;nbsp;배치&amp;nbsp;실행&amp;nbsp;중&amp;nbsp;다른&amp;nbsp;트랜잭션이&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;해결:&amp;nbsp;Sort.by(&quot;id&quot;).ascending()으로&amp;nbsp;PK&amp;nbsp;기준&amp;nbsp;정렬을&amp;nbsp;고정합니다. &lt;br /&gt;&lt;br /&gt;-------------------------------------------------------------------------------------------------------- &lt;br /&gt;[배포] &lt;br /&gt;젠킨스&amp;nbsp;aws &lt;br /&gt;&lt;br /&gt;-------------------------------------------------------------------------------------------------------- &lt;br /&gt;[수익환전] &lt;br /&gt;계좌&amp;nbsp;등록&amp;nbsp;+&amp;nbsp;1원&amp;nbsp;인증 &lt;br /&gt;POST&amp;nbsp;/api/revenues/me/account/verify&amp;nbsp;&amp;mdash;&amp;nbsp;계좌&amp;nbsp;등록&amp;nbsp;+&amp;nbsp;1원&amp;nbsp;인증&amp;nbsp;코드&amp;nbsp;발송 &lt;br /&gt;POST&amp;nbsp;/api/revenues/me/account/verify/confirm&amp;nbsp;&amp;mdash;&amp;nbsp;인증코드&amp;nbsp;검증 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;AES/GCM&amp;nbsp;계좌번호&amp;nbsp;암호화 &lt;br /&gt;문제:&amp;nbsp;계좌번호를&amp;nbsp;평문으로&amp;nbsp;DB에&amp;nbsp;저장하면&amp;nbsp;DB&amp;nbsp;탈취&amp;nbsp;시&amp;nbsp;금융&amp;nbsp;정보가&amp;nbsp;그대로&amp;nbsp;노출됩니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;AES/GCM&amp;nbsp;양방향&amp;nbsp;암호화로&amp;nbsp;계좌번호를&amp;nbsp;암호화하여&amp;nbsp;저장하고,&amp;nbsp;응답&amp;nbsp;시에는&amp;nbsp;뒤&amp;nbsp;4자리만&amp;nbsp;노출하는&amp;nbsp;마스킹&amp;nbsp;처리를&amp;nbsp;적용했습니다.&amp;nbsp;복호화는&amp;nbsp;1원&amp;nbsp;인증&amp;nbsp;성공&amp;nbsp;후&amp;nbsp;계좌&amp;nbsp;상세&amp;nbsp;조회&amp;nbsp;시에만&amp;nbsp;수행합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;BankVerificationClient&amp;nbsp;인터페이스&amp;nbsp;분리 &lt;br /&gt;문제:&amp;nbsp;실제&amp;nbsp;은행&amp;nbsp;API(useB/CODEF)는&amp;nbsp;금융규제상&amp;nbsp;테스트/개발&amp;nbsp;환경에서&amp;nbsp;연동이&amp;nbsp;불가능합니다.&amp;nbsp;개발&amp;nbsp;중&amp;nbsp;매번&amp;nbsp;실제&amp;nbsp;API를&amp;nbsp;호출하면&amp;nbsp;비용이&amp;nbsp;발생하고&amp;nbsp;환경에&amp;nbsp;따라&amp;nbsp;동작이&amp;nbsp;달라집니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;BankVerificationClient&amp;nbsp;인터페이스를&amp;nbsp;분리하고&amp;nbsp;@Profile({&quot;dev&quot;,&amp;nbsp;&quot;local&quot;,&amp;nbsp;&quot;test&quot;})가&amp;nbsp;붙은&amp;nbsp;LocalBankVerificationClient&amp;nbsp;시뮬레이션&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;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;인증코드&amp;nbsp;만료/시도&amp;nbsp;횟수&amp;nbsp;검증 &lt;br /&gt;문제:&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;해결:&amp;nbsp;AccountVerification&amp;nbsp;엔티티에&amp;nbsp;expiredAt(5분),&amp;nbsp;attemptCount(최대&amp;nbsp;5회)를&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;수익&amp;nbsp;현황&amp;nbsp;조회 &lt;br /&gt;GET&amp;nbsp;/api/revenues/me&amp;nbsp;&amp;mdash;&amp;nbsp;총&amp;nbsp;누적&amp;nbsp;수익&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;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;Redis&amp;nbsp;캐싱&amp;nbsp;(TTL&amp;nbsp;30분) &lt;br /&gt;문제:&amp;nbsp;수익&amp;nbsp;현황은&amp;nbsp;Revenue&amp;nbsp;테이블&amp;nbsp;전체를&amp;nbsp;집계하는&amp;nbsp;무거운&amp;nbsp;쿼리입니다.&amp;nbsp;작가가&amp;nbsp;대시보드를&amp;nbsp;반복&amp;nbsp;조회할&amp;nbsp;때마다&amp;nbsp;집계&amp;nbsp;쿼리가&amp;nbsp;실행되면&amp;nbsp;DB&amp;nbsp;부하가&amp;nbsp;커집니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;첫&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;집계&amp;nbsp;결과를&amp;nbsp;Redis에&amp;nbsp;30분간&amp;nbsp;캐싱합니다.&amp;nbsp;이후&amp;nbsp;동일&amp;nbsp;요청은&amp;nbsp;DB를&amp;nbsp;거치지&amp;nbsp;않고&amp;nbsp;Redis에서&amp;nbsp;바로&amp;nbsp;반환합니다.&amp;nbsp;환전&amp;nbsp;신청&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;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;Revenue&amp;nbsp;타입&amp;nbsp;분리&amp;nbsp;집계 &lt;br /&gt;문제:&amp;nbsp;수익(EPISODE_SALE,&amp;nbsp;SUBSCRIPTION),&amp;nbsp;환전(WITHDRAWAL),&amp;nbsp;환불(REFUND)이&amp;nbsp;하나의&amp;nbsp;Revenue&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;해결:&amp;nbsp;sumAmountByAuthorIdAndTypeIn()으로&amp;nbsp;수익+환불을&amp;nbsp;합산하고&amp;nbsp;sumAmountByAuthorIdAndType()으로&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;환전&amp;nbsp;신청 &lt;br /&gt;POST&amp;nbsp;/api/revenues/me/exchanges&amp;nbsp;&amp;mdash;&amp;nbsp;환전&amp;nbsp;신청 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;Redis&amp;nbsp;분산락&amp;nbsp;(UUID&amp;nbsp;토큰&amp;nbsp;기반) &lt;br /&gt;문제:&amp;nbsp;동시에&amp;nbsp;여러&amp;nbsp;환전&amp;nbsp;요청이&amp;nbsp;들어오면&amp;nbsp;잔액&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;해결:&amp;nbsp;Redis&amp;nbsp;SETNX로&amp;nbsp;lock:withdrawal:{authorId}&amp;nbsp;키에&amp;nbsp;UUID&amp;nbsp;토큰을&amp;nbsp;5초간&amp;nbsp;점유합니다.&amp;nbsp;락&amp;nbsp;해제&amp;nbsp;시에는&amp;nbsp;get&amp;nbsp;후&amp;nbsp;delete의&amp;nbsp;비원자성&amp;nbsp;문제를&amp;nbsp;방지하기&amp;nbsp;위해&amp;nbsp;Lua&amp;nbsp;Script로&amp;nbsp;비교와&amp;nbsp;삭제를&amp;nbsp;하나의&amp;nbsp;원자적&amp;nbsp;연산으로&amp;nbsp;처리합니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;FeePolicy&amp;nbsp;Enum으로&amp;nbsp;수수료&amp;nbsp;정책&amp;nbsp;분리 &lt;br /&gt;문제:&amp;nbsp;수수료율(3.3%)과&amp;nbsp;최소&amp;nbsp;환전&amp;nbsp;금액(10,000원)을&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;해결:&amp;nbsp;FeePolicy&amp;nbsp;Enum에&amp;nbsp;수수료율,&amp;nbsp;최소&amp;nbsp;환전&amp;nbsp;금액,&amp;nbsp;수수료&amp;nbsp;계산,&amp;nbsp;실수령액&amp;nbsp;계산&amp;nbsp;로직을&amp;nbsp;캡슐화했습니다.&amp;nbsp;정책&amp;nbsp;변경&amp;nbsp;시&amp;nbsp;Enum&amp;nbsp;값만&amp;nbsp;수정하면&amp;nbsp;됩니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;Revenue&amp;nbsp;+&amp;nbsp;Withdrawal&amp;nbsp;한&amp;nbsp;트랜잭션&amp;nbsp;처리 &lt;br /&gt;문제:&amp;nbsp;Revenue(WITHDRAWAL)&amp;nbsp;차감&amp;nbsp;기록과&amp;nbsp;Withdrawal&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;해결:&amp;nbsp;잔액&amp;nbsp;확인&amp;nbsp;&amp;rarr;&amp;nbsp;Revenue(WITHDRAWAL)&amp;nbsp;저장&amp;nbsp;&amp;rarr;&amp;nbsp;Withdrawal&amp;nbsp;저장을&amp;nbsp;하나의&amp;nbsp;@Transactional&amp;nbsp;안에서&amp;nbsp;처리하여&amp;nbsp;원자성을&amp;nbsp;보장합니다. &lt;br /&gt;&lt;br /&gt;환전&amp;nbsp;내역&amp;nbsp;조회 &lt;br /&gt;GET&amp;nbsp;/api/revenues/me/exchanges&amp;nbsp;&amp;mdash;&amp;nbsp;환전&amp;nbsp;목록&amp;nbsp;(상태/기간&amp;nbsp;필터&amp;nbsp;+&amp;nbsp;페이징) &lt;br /&gt;GET&amp;nbsp;/api/revenues/me/exchanges/{id}&amp;nbsp;&amp;mdash;&amp;nbsp;환전&amp;nbsp;상세&amp;nbsp;조회 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;QueryDSL&amp;nbsp;동적&amp;nbsp;쿼리 &lt;br /&gt;문제:&amp;nbsp;상태&amp;nbsp;필터,&amp;nbsp;기간&amp;nbsp;필터가&amp;nbsp;선택적으로&amp;nbsp;적용되어야&amp;nbsp;하는데&amp;nbsp;JPQL로&amp;nbsp;작성하면&amp;nbsp;조건&amp;nbsp;조합마다&amp;nbsp;별도&amp;nbsp;쿼리가&amp;nbsp;필요하고&amp;nbsp;null&amp;nbsp;체크&amp;nbsp;분기가&amp;nbsp;복잡해집니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;QueryDSL&amp;nbsp;BooleanExpression으로&amp;nbsp;각&amp;nbsp;필터&amp;nbsp;조건을&amp;nbsp;null&amp;nbsp;체크&amp;nbsp;후&amp;nbsp;동적으로&amp;nbsp;조합합니다.&amp;nbsp;또한&amp;nbsp;requestedAt&amp;nbsp;단일&amp;nbsp;정렬&amp;nbsp;시&amp;nbsp;동일&amp;nbsp;시각&amp;nbsp;레코드에서&amp;nbsp;페이지&amp;nbsp;중복/누락이&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있어&amp;nbsp;id&amp;nbsp;DESC를&amp;nbsp;tie-breaker로&amp;nbsp;추가했습니다. &lt;br /&gt;&lt;br /&gt;수익&amp;nbsp;분석&amp;nbsp;통계 &lt;br /&gt;GET&amp;nbsp;/api/revenues/me/statistics&amp;nbsp;&amp;mdash;&amp;nbsp;월별/주별&amp;nbsp;수익&amp;nbsp;집계 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;Redis&amp;nbsp;캐싱&amp;nbsp;+&amp;nbsp;SCAN&amp;nbsp;기반&amp;nbsp;패턴&amp;nbsp;무효화&amp;nbsp;(TTL&amp;nbsp;1시간) &lt;br /&gt;문제:&amp;nbsp;통계는&amp;nbsp;GROUP&amp;nbsp;BY&amp;nbsp;집계&amp;nbsp;쿼리로&amp;nbsp;부하가&amp;nbsp;크고,&amp;nbsp;월별/주별&amp;nbsp;등&amp;nbsp;기간&amp;nbsp;단위로&amp;nbsp;캐시&amp;nbsp;키가&amp;nbsp;달라집니다.&amp;nbsp;수익&amp;nbsp;발생&amp;nbsp;시&amp;nbsp;해당&amp;nbsp;작가의&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;해결:&amp;nbsp;statistics:{authorId}:*&amp;nbsp;패턴으로&amp;nbsp;캐시&amp;nbsp;키를&amp;nbsp;설계하고,&amp;nbsp;무효화&amp;nbsp;시&amp;nbsp;KEYS&amp;nbsp;대신&amp;nbsp;SCAN으로&amp;nbsp;패턴&amp;nbsp;매칭&amp;nbsp;키를&amp;nbsp;순회&amp;nbsp;삭제합니다.&amp;nbsp;KEYS는&amp;nbsp;싱글스레드&amp;nbsp;Redis를&amp;nbsp;블로킹하지만&amp;nbsp;SCAN은&amp;nbsp;커서&amp;nbsp;기반으로&amp;nbsp;점진적으로&amp;nbsp;탐색하여&amp;nbsp;운영&amp;nbsp;환경에서도&amp;nbsp;안전합니다. &lt;br /&gt;&lt;br /&gt;관리자&amp;nbsp;환전&amp;nbsp;승인/거절 &lt;br /&gt;GET&amp;nbsp;/api/admin/exchanges&amp;nbsp;&amp;mdash;&amp;nbsp;전체&amp;nbsp;환전&amp;nbsp;목록&amp;nbsp;조회 &lt;br /&gt;GET&amp;nbsp;/api/admin/exchanges/{id}&amp;nbsp;&amp;mdash;&amp;nbsp;환전&amp;nbsp;상세&amp;nbsp;조회 &lt;br /&gt;PUT&amp;nbsp;/api/admin/exchanges/{id}/approve&amp;nbsp;&amp;mdash;&amp;nbsp;환전&amp;nbsp;승인 &lt;br /&gt;PUT&amp;nbsp;/api/admin/exchanges/{id}/reject&amp;nbsp;&amp;mdash;&amp;nbsp;환전&amp;nbsp;거절 &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;WithdrawalStatus&amp;nbsp;상태&amp;nbsp;전이&amp;nbsp;검증 &lt;br /&gt;문제:&amp;nbsp;이미&amp;nbsp;완료(COMPLETED)되거나&amp;nbsp;거절(REJECTED)된&amp;nbsp;환전&amp;nbsp;건을&amp;nbsp;다시&amp;nbsp;승인/거절하면&amp;nbsp;데이터&amp;nbsp;정합성이&amp;nbsp;깨집니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;WithdrawalStatus&amp;nbsp;Enum에&amp;nbsp;VALID_TRANSITIONS&amp;nbsp;Map으로&amp;nbsp;허용된&amp;nbsp;전이만&amp;nbsp;정의하고,&amp;nbsp;Withdrawal&amp;nbsp;엔티티의&amp;nbsp;상태&amp;nbsp;변경&amp;nbsp;메서드에서&amp;nbsp;validateTransition()을&amp;nbsp;호출하여&amp;nbsp;허용되지&amp;nbsp;않은&amp;nbsp;전이&amp;nbsp;시&amp;nbsp;IllegalStateException을&amp;nbsp;발생시킵니다.&amp;nbsp;PENDING&amp;nbsp;&amp;rarr;&amp;nbsp;PROCESSING&amp;nbsp;&amp;rarr;&amp;nbsp;COMPLETED,&amp;nbsp;PENDING&amp;nbsp;&amp;rarr;&amp;nbsp;REJECTED만&amp;nbsp;허용됩니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;거절&amp;nbsp;시&amp;nbsp;REFUND&amp;nbsp;타입으로&amp;nbsp;잔액&amp;nbsp;복구 &lt;br /&gt;문제:&amp;nbsp;환전&amp;nbsp;신청&amp;nbsp;시&amp;nbsp;Revenue(WITHDRAWAL)로&amp;nbsp;잔액이&amp;nbsp;차감됩니다.&amp;nbsp;거절&amp;nbsp;시&amp;nbsp;해당&amp;nbsp;WITHDRAWAL&amp;nbsp;레코드를&amp;nbsp;삭제하거나&amp;nbsp;수정하면&amp;nbsp;이력&amp;nbsp;추적이&amp;nbsp;불가능합니다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;거절&amp;nbsp;시&amp;nbsp;Revenue를&amp;nbsp;삭제하지&amp;nbsp;않고&amp;nbsp;동일&amp;nbsp;금액의&amp;nbsp;Revenue(REFUND)를&amp;nbsp;새로&amp;nbsp;생성합니다.&amp;nbsp;가용&amp;nbsp;잔액&amp;nbsp;계산&amp;nbsp;시&amp;nbsp;typeIn(EPISODE_SALE,&amp;nbsp;SUBSCRIPTION,&amp;nbsp;REFUND)로&amp;nbsp;합산하면&amp;nbsp;자연스럽게&amp;nbsp;복구된&amp;nbsp;금액이&amp;nbsp;반영되고,&amp;nbsp;환전/거절&amp;nbsp;이력이&amp;nbsp;모두&amp;nbsp;Revenue&amp;nbsp;테이블에&amp;nbsp;남습니다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;@Secured&amp;nbsp;+&amp;nbsp;역할&amp;nbsp;분리 &lt;br /&gt;문제:&amp;nbsp;일반&amp;nbsp;작가나&amp;nbsp;독자가&amp;nbsp;관리자&amp;nbsp;API에&amp;nbsp;접근하면&amp;nbsp;안&amp;nbsp;됩니다. &lt;br /&gt;해결:&amp;nbsp;@Secured({&quot;ADMIN&quot;,&amp;nbsp;&quot;SUPER_ADMIN&quot;})을&amp;nbsp;컨트롤러&amp;nbsp;클래스&amp;nbsp;레벨에&amp;nbsp;적용하여&amp;nbsp;ADMIN과&amp;nbsp;SUPER_ADMIN&amp;nbsp;역할만&amp;nbsp;접근&amp;nbsp;가능하도록&amp;nbsp;제한했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;RedisTTL&amp;nbsp;효과,&amp;nbsp;키&amp;nbsp;설계,&amp;nbsp;Redis&amp;nbsp;기반&amp;nbsp;락,&amp;nbsp;상태전이,&amp;nbsp;민감한&amp;nbsp;금융정보에&amp;nbsp;대한&amp;nbsp;핸들링&lt;/span&gt; &lt;br /&gt;&lt;br /&gt;-------------------------------------------------------------------------------------------------------- &lt;br /&gt;[이벤트] &lt;br /&gt;1.&amp;nbsp;POST&amp;nbsp;/api/admin/events&amp;nbsp;&amp;mdash;&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;(관리자) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;페이지&amp;nbsp;단위&amp;nbsp;배치&amp;nbsp;조회&amp;nbsp;+&amp;nbsp;Kafka&amp;nbsp;기반&amp;nbsp;비동기&amp;nbsp;알림&amp;nbsp;발송 &lt;br /&gt;문제:&amp;nbsp;이벤트가&amp;nbsp;생성되면&amp;nbsp;전체&amp;nbsp;독자(READER)에게&amp;nbsp;알림을&amp;nbsp;발송해야&amp;nbsp;한다. &lt;br /&gt;단순하게&amp;nbsp;userRepository.findAllByRole(READER)로&amp;nbsp;전체&amp;nbsp;유저를&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;조회하면 &lt;br /&gt;유저&amp;nbsp;수가&amp;nbsp;수만&amp;nbsp;명이&amp;nbsp;될&amp;nbsp;경우&amp;nbsp;메모리에&amp;nbsp;전부&amp;nbsp;올라가&amp;nbsp;OOM이&amp;nbsp;발생할&amp;nbsp;수&amp;nbsp;있고, &lt;br /&gt;이벤트&amp;nbsp;생성&amp;nbsp;트랜잭션&amp;nbsp;자체가&amp;nbsp;지연되는&amp;nbsp;문제가&amp;nbsp;생긴다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;전체&amp;nbsp;READER를&amp;nbsp;1000건씩&amp;nbsp;페이지&amp;nbsp;단위로&amp;nbsp;배치&amp;nbsp;조회하여&amp;nbsp;순차적으로&amp;nbsp;이벤트를&amp;nbsp;발행했다. &lt;br /&gt;알림&amp;nbsp;발송은&amp;nbsp;ApplicationEventPublisher를&amp;nbsp;통해&amp;nbsp;이벤트를&amp;nbsp;등록하고, &lt;br /&gt;@TransactionalEventListener(AFTER_COMMIT)으로&amp;nbsp;트랜잭션&amp;nbsp;커밋&amp;nbsp;이후에&amp;nbsp;Kafka에&amp;nbsp;발행되는&amp;nbsp;구조를&amp;nbsp;활용했다. &lt;br /&gt;이로&amp;nbsp;인해&amp;nbsp;알림&amp;nbsp;발송&amp;nbsp;실패가&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;트랜잭션에&amp;nbsp;영향을&amp;nbsp;주지&amp;nbsp;않고,&amp;nbsp;READER에게만&amp;nbsp;선택적으로&amp;nbsp;발송된다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;Redis&amp;nbsp;캐시&amp;nbsp;Evict &lt;br /&gt;문제:&amp;nbsp;사용자&amp;nbsp;이벤트&amp;nbsp;목록&amp;nbsp;조회에&amp;nbsp;Redis&amp;nbsp;캐싱이&amp;nbsp;적용되어&amp;nbsp;있기&amp;nbsp;때문에 &lt;br /&gt;새&amp;nbsp;이벤트가&amp;nbsp;생성되어도&amp;nbsp;캐시가&amp;nbsp;살아있으면&amp;nbsp;사용자에게&amp;nbsp;새&amp;nbsp;이벤트가&amp;nbsp;노출되지&amp;nbsp;않는다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;시&amp;nbsp;사용자용&amp;nbsp;목록&amp;nbsp;캐시&amp;nbsp;전체를&amp;nbsp;즉시&amp;nbsp;evict한다. &lt;br /&gt;이후&amp;nbsp;사용자가&amp;nbsp;목록을&amp;nbsp;조회하면&amp;nbsp;캐시&amp;nbsp;miss가&amp;nbsp;발생해&amp;nbsp;DB에서&amp;nbsp;최신&amp;nbsp;데이터를&amp;nbsp;읽어온&amp;nbsp;뒤&amp;nbsp;다시&amp;nbsp;캐싱된다. &lt;br /&gt;&lt;br /&gt;2.&amp;nbsp;GET&amp;nbsp;/api/admin/events&amp;nbsp;&amp;mdash;&amp;nbsp;이벤트&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(관리자) &lt;br /&gt;적용&amp;nbsp;기술.&amp;nbsp;캐싱&amp;nbsp;미적용&amp;nbsp;-&amp;nbsp;실시간&amp;nbsp;DB&amp;nbsp;직접&amp;nbsp;조회 &lt;br /&gt;문제:&amp;nbsp;관리자는&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;캐싱을&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;해결:&amp;nbsp;관리자&amp;nbsp;목록&amp;nbsp;조회는&amp;nbsp;캐싱을&amp;nbsp;전혀&amp;nbsp;적용하지&amp;nbsp;않고&amp;nbsp;항상&amp;nbsp;DB를&amp;nbsp;직접&amp;nbsp;조회한다. &lt;br /&gt;관리자는&amp;nbsp;소수&amp;nbsp;인원이라&amp;nbsp;요청&amp;nbsp;빈도가&amp;nbsp;낮으므로&amp;nbsp;DB&amp;nbsp;부하&amp;nbsp;문제가&amp;nbsp;없다. &lt;br /&gt;사용자&amp;nbsp;API와&amp;nbsp;관리자&amp;nbsp;API를&amp;nbsp;별도&amp;nbsp;Service로&amp;nbsp;분리해&amp;nbsp;캐싱&amp;nbsp;전략을&amp;nbsp;독립적으로&amp;nbsp;관리했다. &lt;br /&gt;&lt;br /&gt;3.&amp;nbsp;GET&amp;nbsp;/api/admin/events/{eventId}&amp;nbsp;&amp;mdash;&amp;nbsp;이벤트&amp;nbsp;상세&amp;nbsp;조회&amp;nbsp;(관리자) &lt;br /&gt;적용&amp;nbsp;기술.&amp;nbsp;캐싱&amp;nbsp;미적용&amp;nbsp;-&amp;nbsp;실시간&amp;nbsp;DB&amp;nbsp;직접&amp;nbsp;조회 &lt;br /&gt;문제:&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;캐싱된&amp;nbsp;데이터를&amp;nbsp;보여주면&amp;nbsp;관리&amp;nbsp;목적으로&amp;nbsp;사용할&amp;nbsp;수&amp;nbsp;없다. &lt;br /&gt;해결:&amp;nbsp;캐싱&amp;nbsp;없이&amp;nbsp;항상&amp;nbsp;DB를&amp;nbsp;직접&amp;nbsp;조회한다. &lt;br /&gt;사용자용&amp;nbsp;상세&amp;nbsp;조회와&amp;nbsp;달리&amp;nbsp;관리자용은&amp;nbsp;별도&amp;nbsp;Service&amp;nbsp;메서드로&amp;nbsp;분리해&amp;nbsp;캐싱&amp;nbsp;로직이&amp;nbsp;섞이지&amp;nbsp;않도록&amp;nbsp;했다. &lt;br /&gt;&lt;br /&gt;4.&amp;nbsp;GET&amp;nbsp;/api/admin/events/{eventId}/participants&amp;nbsp;&amp;mdash;&amp;nbsp;이벤트별&amp;nbsp;참여자&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(관리자) &lt;br /&gt;적용&amp;nbsp;기술.&amp;nbsp;연관관계&amp;nbsp;미사용&amp;nbsp;+&amp;nbsp;FK&amp;nbsp;컬럼&amp;nbsp;직접&amp;nbsp;조회 &lt;br /&gt;문제:&amp;nbsp;프로젝트&amp;nbsp;컨벤션상&amp;nbsp;엔티티&amp;nbsp;간&amp;nbsp;연관관계(@ManyToOne&amp;nbsp;등)를&amp;nbsp;사용하지&amp;nbsp;않고&amp;nbsp;FK를&amp;nbsp;Long&amp;nbsp;컬럼으로만&amp;nbsp;관리한다. &lt;br /&gt;EventParticipant에서&amp;nbsp;Event나&amp;nbsp;User로의&amp;nbsp;Join이&amp;nbsp;필요한&amp;nbsp;경우&amp;nbsp;연관관계&amp;nbsp;없이&amp;nbsp;처리해야&amp;nbsp;한다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;EventParticipant&amp;nbsp;테이블에&amp;nbsp;event_id,&amp;nbsp;user_id를&amp;nbsp;Long&amp;nbsp;컬럼으로&amp;nbsp;보유하고 &lt;br /&gt;Repository에서&amp;nbsp;findAllByEventId()로&amp;nbsp;페이징&amp;nbsp;조회한다. &lt;br /&gt;추가&amp;nbsp;정보가&amp;nbsp;필요한&amp;nbsp;경우&amp;nbsp;QueryDSL&amp;nbsp;Join으로&amp;nbsp;처리할&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;구조로&amp;nbsp;설계했다. &lt;br /&gt;&lt;br /&gt;5.&amp;nbsp;GET&amp;nbsp;/api/events&amp;nbsp;&amp;mdash;&amp;nbsp;이벤트&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(사용자) &lt;br /&gt;적용&amp;nbsp;기술.&amp;nbsp;RedisTemplate&amp;nbsp;캐싱&amp;nbsp;-&amp;nbsp;TTL&amp;nbsp;5분 &lt;br /&gt;문제:&amp;nbsp;이벤트&amp;nbsp;목록&amp;nbsp;조회는&amp;nbsp;다수의&amp;nbsp;사용자가&amp;nbsp;반복적으로&amp;nbsp;요청하는&amp;nbsp;API다. &lt;br /&gt;매&amp;nbsp;요청마다&amp;nbsp;DB를&amp;nbsp;조회하면&amp;nbsp;이벤트&amp;nbsp;오픈&amp;nbsp;직후처럼&amp;nbsp;트래픽이&amp;nbsp;몰리는&amp;nbsp;상황에서&amp;nbsp;DB&amp;nbsp;부하가&amp;nbsp;심해진다. &lt;br /&gt;이벤트&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;해결:&amp;nbsp;RedisTemplate을&amp;nbsp;사용해&amp;nbsp;status,&amp;nbsp;페이지&amp;nbsp;번호,&amp;nbsp;페이지&amp;nbsp;크기,&amp;nbsp;정렬&amp;nbsp;조건을&amp;nbsp;조합한&amp;nbsp;키로&amp;nbsp;캐싱한다. &lt;br /&gt;TTL은&amp;nbsp;5분으로&amp;nbsp;설정해&amp;nbsp;짧은&amp;nbsp;주기로&amp;nbsp;갱신되게&amp;nbsp;하고,&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;시&amp;nbsp;캐시를&amp;nbsp;evict해&amp;nbsp;정합성을&amp;nbsp;유지한다. &lt;br /&gt;캐시&amp;nbsp;역직렬화&amp;nbsp;실패&amp;nbsp;시&amp;nbsp;자동으로&amp;nbsp;DB&amp;nbsp;조회로&amp;nbsp;전환하는&amp;nbsp;장애&amp;nbsp;대응&amp;nbsp;로직도&amp;nbsp;포함했다. &lt;br /&gt;&lt;br /&gt;6.&amp;nbsp;GET&amp;nbsp;/api/events/{eventId}&amp;nbsp;&amp;mdash;&amp;nbsp;이벤트&amp;nbsp;상세&amp;nbsp;조회&amp;nbsp;(사용자) &lt;br /&gt;적용&amp;nbsp;기술.&amp;nbsp;이벤트&amp;nbsp;상태별&amp;nbsp;차등&amp;nbsp;캐싱&amp;nbsp;전략 &lt;br /&gt;문제:&amp;nbsp;진행&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;반면&amp;nbsp;종료된&amp;nbsp;이벤트는&amp;nbsp;결과가&amp;nbsp;확정되어&amp;nbsp;더&amp;nbsp;이상&amp;nbsp;변경되지&amp;nbsp;않는데도&amp;nbsp;매&amp;nbsp;요청마다&amp;nbsp;DB를&amp;nbsp;조회하면 &lt;br /&gt;이벤트&amp;nbsp;종료&amp;nbsp;직후&amp;nbsp;결과&amp;nbsp;확인&amp;nbsp;트래픽이&amp;nbsp;몰릴&amp;nbsp;때&amp;nbsp;DB에&amp;nbsp;불필요한&amp;nbsp;부하가&amp;nbsp;생긴다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;진행&amp;nbsp;중인&amp;nbsp;이벤트는&amp;nbsp;캐싱&amp;nbsp;없이&amp;nbsp;DB를&amp;nbsp;직접&amp;nbsp;조회해&amp;nbsp;실시간&amp;nbsp;정확성을&amp;nbsp;보장한다. &lt;br /&gt;종료된&amp;nbsp;이벤트는&amp;nbsp;첫&amp;nbsp;조회&amp;nbsp;시&amp;nbsp;Redis에&amp;nbsp;TTL&amp;nbsp;7일로&amp;nbsp;캐싱한다. &lt;br /&gt;이후&amp;nbsp;동일&amp;nbsp;이벤트&amp;nbsp;조회는&amp;nbsp;전부&amp;nbsp;Redis에서&amp;nbsp;응답해&amp;nbsp;DB&amp;nbsp;부하&amp;nbsp;없이&amp;nbsp;처리된다. &lt;br /&gt;&lt;br /&gt;7.&amp;nbsp;POST&amp;nbsp;/api/events/{eventId}/participants&amp;nbsp;&amp;mdash;&amp;nbsp;이벤트&amp;nbsp;참여&amp;nbsp;신청&amp;nbsp;(사용자) &lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;1.&amp;nbsp;Redisson&amp;nbsp;분산락&amp;nbsp;-&amp;nbsp;선착순&amp;nbsp;동시성&amp;nbsp;제어 &lt;br /&gt;문제:&amp;nbsp;이벤트&amp;nbsp;참여&amp;nbsp;신청은&amp;nbsp;짧은&amp;nbsp;시간에&amp;nbsp;수백&amp;nbsp;건의&amp;nbsp;요청이&amp;nbsp;동시에&amp;nbsp;몰린다. &lt;br /&gt;동시성&amp;nbsp;제어&amp;nbsp;없이&amp;nbsp;처리하면&amp;nbsp;maxParticipants를&amp;nbsp;초과해&amp;nbsp;참여자가&amp;nbsp;저장되거나 &lt;br /&gt;같은&amp;nbsp;유저가&amp;nbsp;중복&amp;nbsp;참여하는&amp;nbsp;레이스&amp;nbsp;컨디션이&amp;nbsp;발생한다. &lt;br /&gt;DB&amp;nbsp;비관적&amp;nbsp;락은&amp;nbsp;트래픽&amp;nbsp;집중&amp;nbsp;시&amp;nbsp;DB&amp;nbsp;커넥션을&amp;nbsp;오래&amp;nbsp;점유해&amp;nbsp;병목이&amp;nbsp;생기므로&amp;nbsp;부적합하다. &lt;br /&gt;이미&amp;nbsp;Redis&amp;nbsp;인프라가&amp;nbsp;구성되어&amp;nbsp;있으므로&amp;nbsp;Redisson&amp;nbsp;분산락을&amp;nbsp;선택했다. &lt;br /&gt;TTL(leaseTime)&amp;nbsp;기반으로&amp;nbsp;서버&amp;nbsp;장애&amp;nbsp;시에도&amp;nbsp;데드락이&amp;nbsp;발생하지&amp;nbsp;않고, &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;해결:&amp;nbsp;이벤트&amp;nbsp;ID&amp;nbsp;단위로&amp;nbsp;Redisson&amp;nbsp;분산락을&amp;nbsp;걸어&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;하나의&amp;nbsp;요청만&amp;nbsp;처리한다. &lt;br /&gt;waitTime&amp;nbsp;5초(최대&amp;nbsp;대기&amp;nbsp;시간),&amp;nbsp;leaseTime&amp;nbsp;3초(자동&amp;nbsp;해제&amp;nbsp;시간)로&amp;nbsp;설정했다. &lt;br /&gt;락&amp;nbsp;획득&amp;nbsp;실패&amp;nbsp;시&amp;nbsp;TOO_MANY_REQUESTS(429)를&amp;nbsp;반환해&amp;nbsp;사용자에게&amp;nbsp;재시도를&amp;nbsp;안내한다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;2.&amp;nbsp;DB&amp;nbsp;UniqueConstraint&amp;nbsp;-&amp;nbsp;이중&amp;nbsp;동시성&amp;nbsp;방어 &lt;br /&gt;문제:&amp;nbsp;Redisson&amp;nbsp;락의&amp;nbsp;leaseTime이&amp;nbsp;초과되는&amp;nbsp;극한&amp;nbsp;상황에서 &lt;br /&gt;동시에&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;애플리케이션&amp;nbsp;레벨의&amp;nbsp;중복&amp;nbsp;체크만으로는&amp;nbsp;완전한&amp;nbsp;방어가&amp;nbsp;불가능하다. &lt;br /&gt;&lt;br /&gt;해결:&amp;nbsp;EventParticipant&amp;nbsp;테이블에&amp;nbsp;(event_id,&amp;nbsp;user_id)&amp;nbsp;복합&amp;nbsp;UniqueConstraint를&amp;nbsp;적용했다. &lt;br /&gt;Redisson&amp;nbsp;락이&amp;nbsp;1차&amp;nbsp;방어,&amp;nbsp;DB&amp;nbsp;제약조건이&amp;nbsp;2차&amp;nbsp;방어로&amp;nbsp;동작하는&amp;nbsp;이중&amp;nbsp;구조로&amp;nbsp;완전한&amp;nbsp;중복&amp;nbsp;참여를&amp;nbsp;차단한다. &lt;br /&gt;&lt;br /&gt;적용&amp;nbsp;기술&amp;nbsp;3.&amp;nbsp;포인트&amp;nbsp;즉시&amp;nbsp;지급&amp;nbsp;-&amp;nbsp;도메인&amp;nbsp;간&amp;nbsp;서비스&amp;nbsp;직접&amp;nbsp;호출 &lt;br /&gt;문제:&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;지급이&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;해결:&amp;nbsp;참여자&amp;nbsp;저장&amp;nbsp;직후&amp;nbsp;같은&amp;nbsp;트랜잭션&amp;nbsp;내에서&amp;nbsp;PointService.chargeEventReward()를&amp;nbsp;호출한다. &lt;br /&gt;PointHistory에&amp;nbsp;타입을&amp;nbsp;EVENT로&amp;nbsp;기록해&amp;nbsp;이벤트&amp;nbsp;보상&amp;nbsp;이력을&amp;nbsp;명확히&amp;nbsp;남긴다. &lt;br /&gt;같은&amp;nbsp;트랜잭션이므로&amp;nbsp;참여자&amp;nbsp;저장과&amp;nbsp;포인트&amp;nbsp;지급이&amp;nbsp;원자적으로&amp;nbsp;처리되어 &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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;Redis 캐싱, 분산락, 유니크제약, kafka를 활용한 이벤트 발행, --&amp;gt; 메모리 고민 대량 사용자 알림 발송시&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;포폴을위해&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;1.조회시 성능 지표 가지기&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;2.동시성이나 데이터 정합성 -&amp;gt;동시성 데이터 정합성 테스트에 관한 수치 자료&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;3.외부 연동 잘 챙겨가기&lt;/span&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;/span&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;4.카프카를 통해 대량 알림 전송 관련 -&amp;gt; 왜 카프카로 넘어오게된 스토리가 필요하고 관련 수치 자료가 필요하다&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;-&amp;gt;카프카 실패시에 대한 재시도 고민, 알림중복발송 을 위한 방지를 위한 고민&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;5.부하테스트 해보기&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;6.인프라 쪽으로 더해보던지&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;7.카프카쪽으로 고도화 해보던지&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #0593d3;&quot;&gt;8.대용량 쿠폰 발행 고민해보기-&amp;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: #0593d3;&quot;&gt;포인트 -&amp;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: #0593d3;&quot;&gt;증거 잘 정리하면 바로 지원서 넣어도 될정도&lt;/span&gt;&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/212</guid>
      <comments>https://minwoo95.tistory.com/212#entry212comment</comments>
      <pubDate>Fri, 1 May 2026 14:10:29 +0900</pubDate>
    </item>
    <item>
      <title>[파이널 과제] NovelCraft 웹소설 창작 플랫폼 개발 프로젝트 Day 12</title>
      <link>https://minwoo95.tistory.com/211</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777546968662&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-description=&quot;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bDfBaL/dJMb9eTSXxn/6dl00KbIKkctPIvlnu9Ir1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/GdakC/dJMb9hC4Bil/jpzVKi2KKf4bwtxRmlBO11/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/Hot6-NovelCraft/Hot6-NovelCraft&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bDfBaL/dJMb9eTSXxn/6dl00KbIKkctPIvlnu9Ir1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/GdakC/dJMb9hC4Bil/jpzVKi2KKf4bwtxRmlBO11/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_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;GitHub - Hot6-NovelCraft/Hot6-NovelCraft&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to Hot6-NovelCraft/Hot6-NovelCraft development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.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;개인별 배포용 레퍼지토리 [ AWS ]&lt;/p&gt;
&lt;p style=&quot;color: #222222; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot;&gt;https://github.com/MinWoo1995/Hot6-NovelCraft-local&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777546975011&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - MinWoo1995/Hot6-NovelCraft-local&quot; data-og-description=&quot;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/MinWoo1995/Hot6-NovelCraft-local&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;GitHub - MinWoo1995/Hot6-NovelCraft-local&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;Contribute to MinWoo1995/Hot6-NovelCraft-local development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1.&amp;nbsp;오늘&amp;nbsp;한&amp;nbsp;일&lt;/b&gt;&lt;br /&gt;오늘은&amp;nbsp;NovelCraft&amp;nbsp;프로젝트에서&amp;nbsp;이벤트&amp;nbsp;도메인&amp;nbsp;전체를&amp;nbsp;설계부터&amp;nbsp;구현까지&amp;nbsp;완성했다.&lt;br /&gt;관리자와&amp;nbsp;사용자&amp;nbsp;권한을&amp;nbsp;명확히&amp;nbsp;분리하고,&amp;nbsp;선착순&amp;nbsp;동시성&amp;nbsp;제어와&amp;nbsp;Redis&amp;nbsp;캐싱&amp;nbsp;전략을&amp;nbsp;적용했으며,&lt;br /&gt;포인트&amp;nbsp;즉시&amp;nbsp;지급&amp;nbsp;및&amp;nbsp;Kafka&amp;nbsp;기반&amp;nbsp;전체&amp;nbsp;회원&amp;nbsp;알림&amp;nbsp;발송까지&amp;nbsp;연동했다.&lt;br /&gt;마지막으로&amp;nbsp;CodeRabbit&amp;nbsp;리뷰&amp;nbsp;반영&amp;nbsp;후&amp;nbsp;컨트롤러/서비스&amp;nbsp;테스트&amp;nbsp;코드를&amp;nbsp;작성해&amp;nbsp;커버리지&amp;nbsp;100%를&amp;nbsp;달성했다.&lt;br /&gt;&lt;b&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;구현&amp;nbsp;API&amp;nbsp;목록&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;POST&amp;nbsp;/api/admin/events&amp;nbsp;-&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;(관리자)&lt;br /&gt;GET&amp;nbsp;/api/admin/events&amp;nbsp;-&amp;nbsp;이벤트&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(관리자,&amp;nbsp;실시간)&lt;br /&gt;GET&amp;nbsp;/api/admin/events/{eventId}&amp;nbsp;-&amp;nbsp;이벤트&amp;nbsp;상세&amp;nbsp;조회&amp;nbsp;(관리자)&lt;br /&gt;GET&amp;nbsp;/api/admin/events/{eventId}/participants&amp;nbsp;-&amp;nbsp;이벤트별&amp;nbsp;참여자&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(관리자)&lt;br /&gt;GET&amp;nbsp;/api/events&amp;nbsp;-&amp;nbsp;이벤트&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;(사용자,&amp;nbsp;Redis&amp;nbsp;캐싱)&lt;br /&gt;GET&amp;nbsp;/api/events/{eventId}&amp;nbsp;-&amp;nbsp;이벤트&amp;nbsp;상세&amp;nbsp;조회&amp;nbsp;(사용자,&amp;nbsp;종료&amp;nbsp;이벤트&amp;nbsp;캐싱)&lt;br /&gt;POST&amp;nbsp;/api/events/{eventId}/participants&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;b&gt;추가&amp;nbsp;작업&lt;/b&gt;&lt;br /&gt;&lt;br /&gt;AdminEventController&amp;nbsp;/&amp;nbsp;UserEventController&amp;nbsp;권한별&amp;nbsp;분리&lt;br /&gt;AdminEventService&amp;nbsp;/&amp;nbsp;UserEventService&amp;nbsp;역할별&amp;nbsp;분리&lt;br /&gt;EventExceptionEnum&amp;nbsp;구현&amp;nbsp;(ErrorCode&amp;nbsp;인터페이스&amp;nbsp;구현)&lt;br /&gt;EventParticipant&amp;nbsp;테이블&amp;nbsp;(event_id,&amp;nbsp;user_id)&amp;nbsp;UniqueConstraint&amp;nbsp;적용&lt;br /&gt;SecurityConfig&amp;nbsp;/api/admin/**&amp;nbsp;SUPER_ADMIN,&amp;nbsp;ADMIN&amp;nbsp;권한&amp;nbsp;체크&amp;nbsp;추가&lt;br /&gt;RedisConfig&amp;nbsp;JavaTimeModule&amp;nbsp;적용&amp;nbsp;(LocalDateTime&amp;nbsp;직렬화&amp;nbsp;지원)&lt;br /&gt;PointService&amp;nbsp;chargeEventReward()&amp;nbsp;메서드&amp;nbsp;추가&lt;br /&gt;NotificationType&amp;nbsp;EVENT_CREATED&amp;nbsp;추가&lt;br /&gt;NotificationEvent&amp;nbsp;eventCreated()&amp;nbsp;정적&amp;nbsp;팩토리&amp;nbsp;메서드&amp;nbsp;추가&lt;br /&gt;CodeRabbit&amp;nbsp;리뷰&amp;nbsp;7건&amp;nbsp;반영&lt;br /&gt;컨트롤러/서비스&amp;nbsp;테스트&amp;nbsp;코드&amp;nbsp;4개&amp;nbsp;파일&amp;nbsp;48개&amp;nbsp;케이스&amp;nbsp;작성&lt;br /&gt;&lt;br /&gt;&lt;br /&gt;&lt;b&gt;2.&amp;nbsp;기술&amp;nbsp;선정&amp;nbsp;이유&lt;/b&gt;&lt;br /&gt;&lt;b&gt;2-1.&amp;nbsp;Redisson&amp;nbsp;분산락&amp;nbsp;-&amp;nbsp;선착순&amp;nbsp;동시성&amp;nbsp;제어&lt;/b&gt;&lt;br /&gt;이벤트&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;세&amp;nbsp;가지&amp;nbsp;방식을&amp;nbsp;비교했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;DB&amp;nbsp;비관적&amp;nbsp;락&lt;/b&gt;은&amp;nbsp;트래픽이&amp;nbsp;집중될&amp;nbsp;때&amp;nbsp;DB&amp;nbsp;커넥션을&amp;nbsp;오래&amp;nbsp;점유하고&amp;nbsp;병목이&amp;nbsp;생긴다.&lt;br /&gt;선착순&amp;nbsp;이벤트처럼&amp;nbsp;순간적으로&amp;nbsp;수백&amp;nbsp;건이&amp;nbsp;몰리는&amp;nbsp;상황에서는&amp;nbsp;적합하지&amp;nbsp;않다고&amp;nbsp;판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;Lua&amp;nbsp;스크립트는&lt;/b&gt;&amp;nbsp;Redis에서&amp;nbsp;원자적으로&amp;nbsp;처리가&amp;nbsp;가능하지만,&lt;br /&gt;카운터&amp;nbsp;관리&amp;nbsp;로직이&amp;nbsp;복잡해지고&amp;nbsp;디버깅이&amp;nbsp;어렵다는&amp;nbsp;단점이&amp;nbsp;있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;Redisson&amp;nbsp;분산락&lt;/b&gt;은&amp;nbsp;이미&amp;nbsp;프로젝트에&amp;nbsp;Redis&amp;nbsp;인프라가&amp;nbsp;구성되어&amp;nbsp;있어&amp;nbsp;추가&amp;nbsp;비용이&amp;nbsp;없고,&lt;br /&gt;TTL&amp;nbsp;기반으로&amp;nbsp;데드락을&amp;nbsp;방지하며&amp;nbsp;멀티&amp;nbsp;인스턴스&amp;nbsp;환경에서도&amp;nbsp;동작한다.&lt;br /&gt;구현도&amp;nbsp;직관적이어서&amp;nbsp;가장&amp;nbsp;적합하다고&amp;nbsp;판단했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;RLock lock = redissonClient.getLock(&quot;lock:event:participate:&quot; + eventId);&lt;br /&gt;boolean&amp;nbsp;acquired&amp;nbsp;=&amp;nbsp;lock.tryLock(5,&amp;nbsp;3,&amp;nbsp;TimeUnit.SECONDS);&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;waitTime&amp;nbsp;5초는&amp;nbsp;대기&amp;nbsp;허용&amp;nbsp;시간이고,&amp;nbsp;leaseTime&amp;nbsp;3초는&amp;nbsp;락&amp;nbsp;자동&amp;nbsp;해제&amp;nbsp;시간이다.&lt;br /&gt;서버&amp;nbsp;장애로&amp;nbsp;락&amp;nbsp;해제가&amp;nbsp;안&amp;nbsp;되는&amp;nbsp;상황을&amp;nbsp;TTL이&amp;nbsp;자동으로&amp;nbsp;막아준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;추가로&amp;nbsp;EventParticipant&amp;nbsp;테이블에&amp;nbsp;(event_id,&amp;nbsp;user_id)&amp;nbsp;UniqueConstraint를&amp;nbsp;걸어&lt;br /&gt;Redisson 락이 leaseTime 초과로 뚫리는 극한 케이스에서도 DB 레벨에서 최종 방어하는 이중&amp;nbsp;동시성&amp;nbsp;제어&amp;nbsp;구조로&amp;nbsp;설계했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;1차&amp;nbsp;방어&amp;nbsp;-&amp;nbsp;Redisson&amp;nbsp;분산락&amp;nbsp;:&amp;nbsp;순차&amp;nbsp;처리,&amp;nbsp;선착순&amp;nbsp;인원&amp;nbsp;제어&lt;br /&gt;2차&amp;nbsp;방어&amp;nbsp;-&amp;nbsp;DB&amp;nbsp;UniqueConstraint&amp;nbsp;:&amp;nbsp;중복&amp;nbsp;참여&amp;nbsp;최종&amp;nbsp;방어&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;2-2.&amp;nbsp;Redis&amp;nbsp;캐싱&amp;nbsp;전략&amp;nbsp;-&amp;nbsp;조회&amp;nbsp;API&amp;nbsp;부하&amp;nbsp;분산&lt;/b&gt;&lt;br /&gt;이벤트&amp;nbsp;조회&amp;nbsp;API는&amp;nbsp;참여&amp;nbsp;신청과&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;캐싱&amp;nbsp;대상과&amp;nbsp;TTL을&amp;nbsp;역할에&amp;nbsp;따라&amp;nbsp;명확히&amp;nbsp;나눴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;사용자 이벤트 목록 조회 - TTL 5분 &lt;/b&gt;제목,&amp;nbsp;기간&amp;nbsp;등&amp;nbsp;메타&amp;nbsp;정보는&amp;nbsp;자주&amp;nbsp;바뀌지&amp;nbsp;않으므로&amp;nbsp;단기&amp;nbsp;캐싱을&amp;nbsp;적용했다.&lt;br /&gt;관리자가&amp;nbsp;이벤트를&amp;nbsp;생성하면&amp;nbsp;캐시를&amp;nbsp;즉시&amp;nbsp;evict해&amp;nbsp;정합성을&amp;nbsp;유지했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;종료된 이벤트 상세 조회 - TTL 7일 &lt;/b&gt;이벤트가&amp;nbsp;종료되면&amp;nbsp;결과가&amp;nbsp;확정되어&amp;nbsp;더&amp;nbsp;이상&amp;nbsp;변경이&amp;nbsp;없다.&lt;br /&gt;종료&amp;nbsp;시점에&amp;nbsp;Redis에&amp;nbsp;캐싱하면&amp;nbsp;이후&amp;nbsp;결과&amp;nbsp;조회&amp;nbsp;트래픽이&amp;nbsp;몰려도&amp;nbsp;DB에&amp;nbsp;부하가&amp;nbsp;없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;진행 중 이벤트 / 참여 API - 캐싱 없음 &lt;/b&gt;실시간&amp;nbsp;참여&amp;nbsp;현황과&amp;nbsp;선착순&amp;nbsp;결과는&amp;nbsp;정확성이&amp;nbsp;중요하므로&amp;nbsp;캐싱을&amp;nbsp;적용하지&amp;nbsp;않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;처음에는&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;이벤트&amp;nbsp;종료&amp;nbsp;후에는&amp;nbsp;결과가&amp;nbsp;고정되므로&amp;nbsp;종료&amp;nbsp;시점에&amp;nbsp;캐싱하면&amp;nbsp;된다는&amp;nbsp;방향으로&amp;nbsp;정리됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;2-3.&amp;nbsp;Kafka&amp;nbsp;기반&amp;nbsp;알림&amp;nbsp;발송&amp;nbsp;-&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;시&amp;nbsp;독자&amp;nbsp;전체&amp;nbsp;공지&lt;/b&gt;&lt;br /&gt;알림&amp;nbsp;팀원이&amp;nbsp;이미&amp;nbsp;ApplicationEventPublisher&amp;nbsp;+&amp;nbsp;Kafka&amp;nbsp;기반&amp;nbsp;알림&amp;nbsp;인프라를&amp;nbsp;구축해놓았다.&lt;br /&gt;@TransactionalEventListener(phase&amp;nbsp;=&amp;nbsp;AFTER_COMMIT)로&amp;nbsp;트랜잭션&amp;nbsp;커밋&amp;nbsp;이후에&lt;br /&gt;Kafka에&amp;nbsp;메시지를&amp;nbsp;발행하는&amp;nbsp;구조라&amp;nbsp;알림&amp;nbsp;발송&amp;nbsp;실패가&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;트랜잭션에&amp;nbsp;영향을&amp;nbsp;주지&amp;nbsp;않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이&amp;nbsp;인프라를&amp;nbsp;그대로&amp;nbsp;활용해&amp;nbsp;이벤트&amp;nbsp;생성&amp;nbsp;시&amp;nbsp;READER&amp;nbsp;역할을&amp;nbsp;가진&amp;nbsp;사용자에게만&amp;nbsp;알림을&amp;nbsp;발송했다.&lt;br /&gt;전체&amp;nbsp;유저를&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;메모리에&amp;nbsp;올리면&amp;nbsp;OOM&amp;nbsp;위험이&amp;nbsp;있어&amp;nbsp;1000건씩&amp;nbsp;페이지&amp;nbsp;단위로&amp;nbsp;배치&amp;nbsp;조회하는&amp;nbsp;방식을&amp;nbsp;적용했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;javaint&amp;nbsp;page&amp;nbsp;=&amp;nbsp;0;&lt;br /&gt;int&amp;nbsp;batchSize&amp;nbsp;=&amp;nbsp;1000;&lt;br /&gt;while&amp;nbsp;(true)&amp;nbsp;{&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;PageRequest&amp;nbsp;pageRequest&amp;nbsp;=&amp;nbsp;PageRequest.of(page,&amp;nbsp;batchSize);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;List&amp;lt;User&amp;gt;&amp;nbsp;readers&amp;nbsp;=&amp;nbsp;userRepository.findAllByRole(UserRole.READER,&amp;nbsp;pageRequest).getContent();&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;(readers.isEmpty())&amp;nbsp;break;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;readers.forEach(user&amp;nbsp;-&amp;gt;&amp;nbsp;eventPublisher.publishEvent(&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;NotificationEvent.eventCreated(user.getId(),&amp;nbsp;saved.getTitle(),&amp;nbsp;saved.getId())&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;));&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;page++;&lt;br /&gt;}&lt;br /&gt;&lt;br /&gt;&lt;b&gt;3.&amp;nbsp;트러블슈팅&lt;/b&gt;&lt;br /&gt;&lt;b&gt;3-1.&amp;nbsp;LocalDateTime&amp;nbsp;Redis&amp;nbsp;직렬화&amp;nbsp;실패&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;사용자&amp;nbsp;이벤트&amp;nbsp;목록&amp;nbsp;조회&amp;nbsp;API를&amp;nbsp;호출하자&amp;nbsp;500&amp;nbsp;에러가&amp;nbsp;발생했다.&lt;br /&gt;에러&amp;nbsp;메시지는&amp;nbsp;다음과&amp;nbsp;같았다.&lt;br /&gt;SerializationException:&amp;nbsp;Could&amp;nbsp;not&amp;nbsp;write&amp;nbsp;JSON:&amp;nbsp;Java&amp;nbsp;8&amp;nbsp;date/time&amp;nbsp;type&lt;br /&gt;`java.time.LocalDateTime`&amp;nbsp;not&amp;nbsp;supported&amp;nbsp;by&amp;nbsp;default&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;RedisConfig를&amp;nbsp;확인하니&amp;nbsp;JavaTimeModule이&amp;nbsp;적용된&amp;nbsp;ObjectMapper로&amp;nbsp;serializer를&amp;nbsp;만들었는데&lt;br /&gt;실제&amp;nbsp;setValueSerializer()에는&amp;nbsp;기본&amp;nbsp;생성자로&amp;nbsp;새로&amp;nbsp;만든&amp;nbsp;serializer를&amp;nbsp;넣고&amp;nbsp;있었다.&lt;br /&gt;JavaTimeModule이&amp;nbsp;적용된&amp;nbsp;serializer&amp;nbsp;변수가&amp;nbsp;완전히&amp;nbsp;무시되고&amp;nbsp;있었던&amp;nbsp;것이다.&lt;br /&gt;java//&amp;nbsp;문제&amp;nbsp;코드&amp;nbsp;-&amp;nbsp;serializer&amp;nbsp;변수가&amp;nbsp;있지만&amp;nbsp;새&amp;nbsp;객체를&amp;nbsp;넣음&lt;br /&gt;GenericJackson2JsonRedisSerializer&amp;nbsp;serializer&amp;nbsp;=&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;new&amp;nbsp;GenericJackson2JsonRedisSerializer(objectMapper);&amp;nbsp;//&amp;nbsp;이&amp;nbsp;변수가&amp;nbsp;무시됨&lt;br /&gt;&lt;br /&gt;template.setValueSerializer(new&amp;nbsp;GenericJackson2JsonRedisSerializer());&amp;nbsp;//&amp;nbsp;기본&amp;nbsp;생성자&lt;br /&gt;template.setHashValueSerializer(new&amp;nbsp;GenericJackson2JsonRedisSerializer());&amp;nbsp;//&amp;nbsp;기본&amp;nbsp;생성자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;팀원들과&amp;nbsp;협의&amp;nbsp;후&amp;nbsp;RedisConfig에서&amp;nbsp;두&amp;nbsp;줄을&amp;nbsp;수정했다.&lt;br /&gt;기본&amp;nbsp;생성자&amp;nbsp;대신&amp;nbsp;JavaTimeModule이&amp;nbsp;적용된&amp;nbsp;serializer&amp;nbsp;변수를&amp;nbsp;그대로&amp;nbsp;사용하도록&amp;nbsp;변경했다.&lt;br /&gt;기존&amp;nbsp;도메인에는&amp;nbsp;LocalDateTime을&amp;nbsp;Redis에&amp;nbsp;캐싱하는&amp;nbsp;곳이&amp;nbsp;없어&amp;nbsp;사이드이펙트가&amp;nbsp;없었다.&lt;br /&gt;java//&amp;nbsp;수정&amp;nbsp;후&lt;br /&gt;template.setValueSerializer(serializer);&lt;br /&gt;template.setHashValueSerializer(serializer);&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-2.&amp;nbsp;SecurityConfig&amp;nbsp;권한&amp;nbsp;설정&amp;nbsp;-&amp;nbsp;SUPER_ADMIN&amp;nbsp;vs&amp;nbsp;ADMIN&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;이벤트&amp;nbsp;생성&amp;nbsp;API를&amp;nbsp;Postman으로&amp;nbsp;테스트하니&amp;nbsp;403&amp;nbsp;Forbidden이&amp;nbsp;떨어졌다.&lt;br /&gt;DB를&amp;nbsp;확인하니&amp;nbsp;테스트&amp;nbsp;계정의&amp;nbsp;role이&amp;nbsp;SUPER_ADMIN이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;SecurityConfig에&amp;nbsp;/api/admin/**&amp;nbsp;에&amp;nbsp;대해&amp;nbsp;hasAuthority(&quot;SUPER_ADMIN&quot;)으로&amp;nbsp;설정했는데,&lt;br /&gt;이게&amp;nbsp;SUPER_ADMIN&amp;nbsp;단독으로만&amp;nbsp;열려있는&amp;nbsp;상태였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;실무상&amp;nbsp;SUPER_ADMIN&amp;nbsp;외에도&amp;nbsp;담당&amp;nbsp;ADMIN도&amp;nbsp;이벤트&amp;nbsp;관리가&amp;nbsp;가능해야&amp;nbsp;한다는&amp;nbsp;판단으로&lt;br /&gt;hasAnyAuthority(&quot;ADMIN&quot;,&amp;nbsp;&quot;SUPER_ADMIN&quot;)으로&amp;nbsp;변경했다.&lt;br /&gt;CodeRabbit이&amp;nbsp;권한&amp;nbsp;범위가&amp;nbsp;넓다고&amp;nbsp;지적했지만&amp;nbsp;기획&amp;nbsp;의도에&amp;nbsp;맞는&amp;nbsp;설정이라&amp;nbsp;의견을&amp;nbsp;답변으로&amp;nbsp;달고&amp;nbsp;유지했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-3.&amp;nbsp;CodeRabbit&amp;nbsp;리뷰&amp;nbsp;반영&amp;nbsp;-&amp;nbsp;7건&lt;/b&gt;&lt;br /&gt;&lt;b&gt;3-3-1.&amp;nbsp;evictEventListCache()&amp;nbsp;중복&amp;nbsp;호출&amp;nbsp;(Minor)&lt;/b&gt;&lt;br /&gt;AdminEventService의&amp;nbsp;createEvent()에서&amp;nbsp;동일한&amp;nbsp;evictEventListCache()를&amp;nbsp;두&amp;nbsp;번&amp;nbsp;호출하고&amp;nbsp;있었다.&lt;br /&gt;불필요한 Redis 스캔/삭제가 2배 발생하는 문제였다. 한&amp;nbsp;번만&amp;nbsp;호출하도록&amp;nbsp;수정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-3-2.&amp;nbsp;캐시&amp;nbsp;키에&amp;nbsp;size/sort&amp;nbsp;누락&amp;nbsp;(Major)&lt;/b&gt;&lt;br /&gt;UserEventService의&amp;nbsp;getEventList()에서&amp;nbsp;캐시&amp;nbsp;키를&amp;nbsp;status:pageNumber로만&amp;nbsp;구성했다.&lt;br /&gt;같은&amp;nbsp;페이지&amp;nbsp;번호라도&amp;nbsp;size나&amp;nbsp;sort가&amp;nbsp;다른&amp;nbsp;요청이&amp;nbsp;동일&amp;nbsp;캐시를&amp;nbsp;재사용하는&amp;nbsp;문제가&amp;nbsp;있었다.&lt;br /&gt;캐시&amp;nbsp;키에&amp;nbsp;pageSize와&amp;nbsp;sort를&amp;nbsp;추가했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;java//&amp;nbsp;수정&amp;nbsp;전&lt;br /&gt;String&amp;nbsp;cacheKey&amp;nbsp;=&amp;nbsp;EVENT_LIST_CACHE_KEY&amp;nbsp;+&amp;nbsp;status&amp;nbsp;+&amp;nbsp;&quot;:&quot;&amp;nbsp;+&amp;nbsp;pageable.getPageNumber();&lt;br /&gt;&lt;br /&gt;//&amp;nbsp;수정&amp;nbsp;후&lt;br /&gt;String&amp;nbsp;cacheKey&amp;nbsp;=&amp;nbsp;EVENT_LIST_CACHE_KEY&amp;nbsp;+&amp;nbsp;status&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;+&amp;nbsp;&quot;:&quot;&amp;nbsp;+&amp;nbsp;pageable.getPageNumber()&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;+&amp;nbsp;&quot;:&quot;&amp;nbsp;+&amp;nbsp;pageable.getPageSize()&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;+&amp;nbsp;&quot;:&quot;&amp;nbsp;+&amp;nbsp;pageable.getSort();&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-3-3.&amp;nbsp;UPCOMING&amp;nbsp;default&amp;nbsp;처리&amp;nbsp;불일치&amp;nbsp;(Major)&lt;/b&gt;&lt;br /&gt;EventStatus의&amp;nbsp;switch문에서&amp;nbsp;UPCOMING이&amp;nbsp;default로&amp;nbsp;처리되어&amp;nbsp;findAll()이&amp;nbsp;호출됐다.&lt;br /&gt;status=UPCOMING&amp;nbsp;요청이&amp;nbsp;들어오면&amp;nbsp;진행&amp;nbsp;중/종료&amp;nbsp;이벤트가&amp;nbsp;섞여서&amp;nbsp;반환되는&amp;nbsp;버그였다.&lt;br /&gt;EventRepository에&amp;nbsp;findAllUpcoming()&amp;nbsp;쿼리를&amp;nbsp;추가하고&amp;nbsp;switch&amp;nbsp;case를&amp;nbsp;명시적으로&amp;nbsp;분리했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;// 수정 후&lt;br /&gt;Page&amp;lt;Event&amp;gt;&amp;nbsp;events&amp;nbsp;=&amp;nbsp;switch&amp;nbsp;(status)&amp;nbsp;{&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;case&amp;nbsp;UPCOMING&amp;nbsp;-&amp;gt;&amp;nbsp;eventRepository.findAllUpcoming(now,&amp;nbsp;pageable);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;case&amp;nbsp;ONGOING&amp;nbsp;&amp;nbsp;-&amp;gt;&amp;nbsp;eventRepository.findAllOngoing(now,&amp;nbsp;pageable);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;case&amp;nbsp;ENDED&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;-&amp;gt;&amp;nbsp;eventRepository.findAllEnded(now,&amp;nbsp;pageable);&lt;br /&gt;};&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-3-4.&amp;nbsp;전체&amp;nbsp;READER&amp;nbsp;일괄&amp;nbsp;로드&amp;nbsp;OOM&amp;nbsp;위험&amp;nbsp;(Major)&lt;/b&gt;&lt;br /&gt;userRepository.findAllByRole(READER)로&amp;nbsp;전체&amp;nbsp;독자를&amp;nbsp;한&amp;nbsp;번에&amp;nbsp;메모리에&amp;nbsp;올리는&amp;nbsp;방식이었다.&lt;br /&gt;독자&amp;nbsp;수가&amp;nbsp;늘어날수록&amp;nbsp;생성&amp;nbsp;API&amp;nbsp;지연과&amp;nbsp;메모리&amp;nbsp;압박이&amp;nbsp;커지는&amp;nbsp;구조였다.&lt;br /&gt;1000건씩&amp;nbsp;페이지&amp;nbsp;단위로&amp;nbsp;배치&amp;nbsp;조회하도록&amp;nbsp;변경했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-3-5.&amp;nbsp;EventCreateRequest&amp;nbsp;기간&amp;nbsp;검증&amp;nbsp;없음&amp;nbsp;(Major)&lt;/b&gt;&lt;br /&gt;DTO에서&amp;nbsp;startedAt과&amp;nbsp;endedAt의&amp;nbsp;선후&amp;nbsp;관계&amp;nbsp;검증이&amp;nbsp;없어&lt;br /&gt;startedAt&amp;nbsp;&amp;gt;&amp;nbsp;endedAt인&amp;nbsp;비정상&amp;nbsp;이벤트가&amp;nbsp;생성될&amp;nbsp;수&amp;nbsp;있었다.&lt;br /&gt;@AssertTrue로&amp;nbsp;DTO&amp;nbsp;레벨에서&amp;nbsp;검증을&amp;nbsp;추가했다.&lt;br /&gt;서비스&amp;nbsp;레벨의&amp;nbsp;중복&amp;nbsp;검증은&amp;nbsp;제거하고&amp;nbsp;EventExceptionEnum.EVENT_INVALID_PERIOD는&amp;nbsp;향후를&amp;nbsp;위해&amp;nbsp;남겨뒀다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;@AssertTrue(message = &quot;이벤트 시작일은 종료일보다 이전이어야 합니다&quot;)&lt;br /&gt;public&amp;nbsp;boolean&amp;nbsp;isValidPeriod()&amp;nbsp;{&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;if&amp;nbsp;(startedAt&amp;nbsp;==&amp;nbsp;null&amp;nbsp;||&amp;nbsp;endedAt&amp;nbsp;==&amp;nbsp;null)&amp;nbsp;return&amp;nbsp;true;&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;startedAt.isBefore(endedAt);&lt;br /&gt;}&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-3-6.&amp;nbsp;maxParticipants&amp;nbsp;null&amp;nbsp;허용&amp;nbsp;(Major)&lt;/b&gt;&lt;br /&gt;@Min만&amp;nbsp;있고&amp;nbsp;@NotNull이&amp;nbsp;없어&amp;nbsp;null&amp;nbsp;값이&amp;nbsp;통과될&amp;nbsp;수&amp;nbsp;있었다.&lt;br /&gt;포인트&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;@NotNull을&amp;nbsp;추가해&amp;nbsp;필수값으로&amp;nbsp;변경하고,&amp;nbsp;서비스의&amp;nbsp;null&amp;nbsp;체크&amp;nbsp;분기도&amp;nbsp;제거했다.&lt;br /&gt;DB의&amp;nbsp;max_participants&amp;nbsp;컬럼도&amp;nbsp;NOT&amp;nbsp;NULL로&amp;nbsp;변경했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;3-4.&amp;nbsp;테스트&amp;nbsp;코드&amp;nbsp;-&amp;nbsp;JPA&amp;nbsp;엔티티&amp;nbsp;mock()&amp;nbsp;사용&amp;nbsp;불가&amp;nbsp;문제&lt;/b&gt;&lt;br /&gt;&lt;b&gt;문제&amp;nbsp;상황&lt;/b&gt;&lt;br /&gt;UserEventServiceTest에서&amp;nbsp;mock(Event.class)으로&amp;nbsp;Event&amp;nbsp;객체를&amp;nbsp;만들고&lt;br /&gt;given(event.getId()).willReturn(EVENT_ID)로&amp;nbsp;stubbing을&amp;nbsp;하니&lt;br /&gt;UnfinishedStubbingException과&amp;nbsp;UnnecessaryStubbingException이&amp;nbsp;동시에&amp;nbsp;터졌다.&lt;br /&gt;UnfinishedStubbingException:&lt;br /&gt;-&amp;gt;&amp;nbsp;at&amp;nbsp;UserEventServiceTest.mockOngoingEvent(UserEventServiceTest.java:59)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;원인&amp;nbsp;분석&lt;/b&gt;&lt;br /&gt;JPA&amp;nbsp;엔티티에&amp;nbsp;@Getter,&amp;nbsp;@NoArgsConstructor&amp;nbsp;등&amp;nbsp;Lombok&amp;nbsp;어노테이션이&amp;nbsp;붙어있으면&lt;br /&gt;Mockito가&amp;nbsp;final&amp;nbsp;메서드로&amp;nbsp;인식하는&amp;nbsp;케이스가&amp;nbsp;있다.&lt;br /&gt;특히&amp;nbsp;getId()&amp;nbsp;같은&amp;nbsp;메서드는&amp;nbsp;내부적으로&amp;nbsp;stubbing이&amp;nbsp;완료되지&amp;nbsp;않은&amp;nbsp;상태에서&lt;br /&gt;다른&amp;nbsp;메서드&amp;nbsp;호출이&amp;nbsp;중간에&amp;nbsp;끼어들어&amp;nbsp;UnfinishedStubbingException이&amp;nbsp;발생했다.&lt;br /&gt;또한&amp;nbsp;각&amp;nbsp;테스트에서&amp;nbsp;사용하지&amp;nbsp;않는&amp;nbsp;stubbing이&amp;nbsp;포함되어&amp;nbsp;UnnecessaryStubbingException도&amp;nbsp;함께&amp;nbsp;발생했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;해결&lt;/b&gt;&lt;br /&gt;mock(Event.class)&amp;nbsp;대신&amp;nbsp;실제&amp;nbsp;Event.create()&amp;nbsp;정적&amp;nbsp;팩토리&amp;nbsp;메서드로&amp;nbsp;실제&amp;nbsp;객체를&amp;nbsp;생성하도록&amp;nbsp;변경했다.&lt;br /&gt;엔티티는&amp;nbsp;mock()을&amp;nbsp;피하고&amp;nbsp;실제&amp;nbsp;객체를&amp;nbsp;만드는&amp;nbsp;것이&amp;nbsp;올바른&amp;nbsp;테스트&amp;nbsp;패턴이다.&lt;br /&gt;java//&amp;nbsp;수정&amp;nbsp;전&amp;nbsp;-&amp;nbsp;mock()&amp;nbsp;사용&amp;nbsp;(문제&amp;nbsp;발생)&lt;br /&gt;private&amp;nbsp;Event&amp;nbsp;mockOngoingEvent()&amp;nbsp;{&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;Event&amp;nbsp;event&amp;nbsp;=&amp;nbsp;mock(Event.class);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;given(event.getId()).willReturn(EVENT_ID);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;given(event.isOngoing()).willReturn(true);&lt;br /&gt;&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;객체&amp;nbsp;사용&lt;br /&gt;private&amp;nbsp;Event&amp;nbsp;ongoingEvent()&amp;nbsp;{&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;Event.create(1L,&amp;nbsp;&quot;테스트&amp;nbsp;이벤트&quot;,&amp;nbsp;&quot;설명&quot;,&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;5000L,&amp;nbsp;100L,&amp;nbsp;NOW.minusDays(1),&amp;nbsp;FUTURE);&lt;br /&gt;}&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;RLock은&amp;nbsp;인터페이스라&amp;nbsp;@Mock&amp;nbsp;필드&amp;nbsp;선언이&amp;nbsp;불가능했다.&lt;br /&gt;setupLock()&amp;nbsp;헬퍼&amp;nbsp;메서드&amp;nbsp;내부에서&amp;nbsp;직접&amp;nbsp;mock(RLock.class)로&amp;nbsp;생성하는&amp;nbsp;방식으로&amp;nbsp;해결했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;javaprivate&amp;nbsp;RLock&amp;nbsp;setupLock(boolean&amp;nbsp;acquired)&amp;nbsp;throws&amp;nbsp;InterruptedException&amp;nbsp;{&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;RLock&amp;nbsp;mockLock&amp;nbsp;=&amp;nbsp;mock(RLock.class);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;given(redissonClient.getLock(anyString())).willReturn(mockLock);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;given(mockLock.tryLock(anyLong(),&amp;nbsp;anyLong(),&amp;nbsp;any(TimeUnit.class))).willReturn(acquired);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;lenient().when(mockLock.isHeldByCurrentThread()).thenReturn(acquired);&lt;br /&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;return&amp;nbsp;mockLock;&lt;br /&gt;}&lt;br /&gt;&lt;br /&gt;&lt;b&gt;4.&amp;nbsp;느낀&amp;nbsp;점&lt;/b&gt;&lt;br /&gt;오늘은&amp;nbsp;단순히&amp;nbsp;CRUD를&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;선착순&amp;nbsp;이벤트&amp;nbsp;참여라는&amp;nbsp;요구사항&amp;nbsp;하나에서&amp;nbsp;Redisson&amp;nbsp;분산락,&amp;nbsp;DB&amp;nbsp;UniqueConstraint&amp;nbsp;이중&amp;nbsp;방어,&lt;br /&gt;Redis&amp;nbsp;캐싱&amp;nbsp;전략,&amp;nbsp;페이지&amp;nbsp;단위&amp;nbsp;배치&amp;nbsp;알림&amp;nbsp;발송까지&amp;nbsp;여러&amp;nbsp;기술이&amp;nbsp;자연스럽게&amp;nbsp;연결됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;기술은&amp;nbsp;문제를&amp;nbsp;해결하기&amp;nbsp;위해&amp;nbsp;선택하는&amp;nbsp;것이고,&amp;nbsp;각&amp;nbsp;선택에는&amp;nbsp;명확한&amp;nbsp;이유가&amp;nbsp;있어야&amp;nbsp;한다는&amp;nbsp;것을&amp;nbsp;다시&amp;nbsp;한번&amp;nbsp;체감했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;CodeRabbit&amp;nbsp;리뷰에서&amp;nbsp;캐시&amp;nbsp;키에&amp;nbsp;size/sort가&amp;nbsp;빠진&amp;nbsp;것과&amp;nbsp;UPCOMING&amp;nbsp;default&amp;nbsp;처리&amp;nbsp;불일치&amp;nbsp;버그를&amp;nbsp;잡아줬다.&lt;br /&gt;혼자&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;특히&amp;nbsp;전체&amp;nbsp;READER&amp;nbsp;일괄&amp;nbsp;로드&amp;nbsp;OOM&amp;nbsp;위험&amp;nbsp;지적은&amp;nbsp;지금은&amp;nbsp;유저&amp;nbsp;수가&amp;nbsp;적어&amp;nbsp;문제가&amp;nbsp;없지만&lt;br /&gt;서비스가&amp;nbsp;커지면&amp;nbsp;치명적인&amp;nbsp;문제가&amp;nbsp;될&amp;nbsp;수&amp;nbsp;있는&amp;nbsp;부분이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;코드를&amp;nbsp;작성할&amp;nbsp;때&amp;nbsp;현재&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;테스트&amp;nbsp;코드에서&amp;nbsp;JPA&amp;nbsp;엔티티를&amp;nbsp;mock()으로&amp;nbsp;쓰면&amp;nbsp;안&amp;nbsp;된다는&amp;nbsp;것도&amp;nbsp;직접&amp;nbsp;겪으면서&amp;nbsp;알게&amp;nbsp;됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;이론으로는&amp;nbsp;&quot;엔티티는&amp;nbsp;실제&amp;nbsp;객체를&amp;nbsp;써야&amp;nbsp;한다&quot;는&amp;nbsp;걸&amp;nbsp;알고&amp;nbsp;있었지만,&lt;br /&gt;UnfinishedStubbingException이&amp;nbsp;왜&amp;nbsp;발생하는지&amp;nbsp;원인을&amp;nbsp;파악하고&amp;nbsp;해결하는&amp;nbsp;과정에서&amp;nbsp;더&amp;nbsp;깊이&amp;nbsp;이해하게&amp;nbsp;됐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;테스트&amp;nbsp;코드도&amp;nbsp;설계가&amp;nbsp;필요하고,&amp;nbsp;올바른&amp;nbsp;패턴을&amp;nbsp;지켜야&amp;nbsp;유지보수가&amp;nbsp;편하다는&amp;nbsp;것을&amp;nbsp;다시&amp;nbsp;느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;오후에&amp;nbsp;알림&amp;nbsp;팀원이&amp;nbsp;Kafka&amp;nbsp;인프라를&amp;nbsp;완성하면&amp;nbsp;eventCreated()&amp;nbsp;연동만&amp;nbsp;하면&amp;nbsp;전체&amp;nbsp;흐름이&amp;nbsp;완성된다.&lt;br /&gt;오늘&amp;nbsp;설계한&amp;nbsp;구조가&amp;nbsp;팀원의&amp;nbsp;코드와&amp;nbsp;깔끔하게&amp;nbsp;붙을&amp;nbsp;수&amp;nbsp;있도록&amp;nbsp;인터페이스를&amp;nbsp;잘&amp;nbsp;맞춰둔&amp;nbsp;것&amp;nbsp;같아&amp;nbsp;뿌듯했다.&lt;/p&gt;</description>
      <category>spring_2기[본캠프]/과제</category>
      <author>minwoo95</author>
      <guid isPermaLink="true">https://minwoo95.tistory.com/211</guid>
      <comments>https://minwoo95.tistory.com/211#entry211comment</comments>
      <pubDate>Thu, 30 Apr 2026 20:11:01 +0900</pubDate>
    </item>
  </channel>
</rss>