<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>gotogg</title>
    <link>https://bebetter-forme.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Wed, 15 Apr 2026 10:26:33 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>cheolhyeon</managingEditor>
    <item>
      <title>[학습포인트] RDS 때려치고 EC2로 DB 굴린 후기</title>
      <link>https://bebetter-forme.tistory.com/114</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;요즘 회사 다니면서 정작 손 가는 기술적인 실험을 못 하니까, 사이드 프로젝트에서라도 갈증을 풀어보자고 파고들었다. RDS로 DB만 띄워놨는데, 테이블도 거의 없는데도 슬금슬금 3만 원씩 나가더라. 데이터도 거의 없는데 이건 아니다 싶어서, “그럼 EC2에 DB 직접 올려서 굴려보자”로 방향을 틀었다. 예전에 대충 퍼블릭 열어두고 쓰다 봇들 스캔에 긁히는 걸 본 뒤로는, 이번엔 보안부터 단단히.&lt;br&gt;&lt;br&gt;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;간략한 흐름은 아래와 같다&lt;/h2&gt;&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;&lt;li&gt;&lt;b&gt;DB는 절대 퍼블릭 금지.&lt;/b&gt;&lt;/li&gt;&lt;li&gt; Elastic IP도 안 달고, VPC 안에서만 살게 한다. 비용도 깎이고, 공격면도 줄어든다.&lt;/li&gt;&lt;li&gt;&lt;b&gt;BE → DB는 오직 Private IP.&lt;b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/li&gt;&lt;li&gt; 같은 VPC/서브넷에서만 붙인다. application.yml에는 DB의 &lt;b&gt;프라이빗 IP&lt;/b&gt;만 적는다.&lt;/li&gt;&lt;li&gt;&lt;b&gt;운영툴 접속은 SSH 터널링만.&lt;b&gt;&lt;/b&gt;&lt;/b&gt;&lt;/li&gt;&lt;li&gt; DataGrip 같은 클라이언트는 &lt;b&gt;BE를 점프 서버&lt;/b&gt;로 삼는다. DB 3306은 안 보이게.&lt;/li&gt;&lt;/ol&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;최소 아키텍처를 머릿속에 그려보면 아래와 같다.&lt;/h2&gt;&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;[Client] ──HTTPS──▶ [EC2: BE (Public IP 有)]
                      │
                      ├─(VPC 내부, 3306)─▶ [EC2: DB (Private IP 仅)]
                      │
(운영툴) DataGrip ──SSH Tunnel(22)──▶[BE]──(Private 3306)──▶[DB]

&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;핵심은 “&lt;b&gt;바깥에서 DB로 가는 길은 없다&lt;/b&gt;”. 바깥→BE(22/80/443), BE→DB(3306, 프라이빗)만 존재. &lt;br&gt;&lt;br&gt;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;보안그룹은 이렇게 딱 자른다&lt;/h2&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;SG-BE&lt;/h3&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1119&quot; data-start=&quot;930&quot; data-ke-list-type=&quot;disc&quot;&gt; 
 &lt;li data-end=&quot;1083&quot; data-start=&quot;930&quot;&gt;Inbound 
  &lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1083&quot; data-start=&quot;944&quot; data-ke-list-type=&quot;disc&quot;&gt; 
   &lt;li data-end=&quot;984&quot; data-start=&quot;944&quot;&gt;&lt;b&gt;22/TCP&lt;/b&gt;: 내 노트북 공인 IP/32 &lt;b&gt;딱 하나만&lt;/b&gt;&lt;/li&gt; 
   &lt;li data-end=&quot;1033&quot; data-start=&quot;987&quot;&gt;&lt;b&gt;80, 443/TCP&lt;/b&gt;: 0.0.0.0/0 (또는 ALB SG로 제한)&lt;/li&gt; 
   &lt;li data-end=&quot;1083&quot; data-start=&quot;1036&quot;&gt;(가능하면) &lt;b&gt;8080/TCP 외부 미개방&lt;/b&gt;. Nginx/ALB 뒤에 숨긴다.&lt;/li&gt; 
  &lt;/ul&gt; &lt;/li&gt; 
 &lt;li data-end=&quot;1119&quot; data-start=&quot;1084&quot;&gt;Outbound 
  &lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1119&quot; data-start=&quot;1099&quot; data-ke-list-type=&quot;disc&quot;&gt; 
   &lt;li data-end=&quot;1119&quot; data-start=&quot;1099&quot;&gt;기본 All 허용(필요하면 축소)&lt;/li&gt; 
  &lt;/ul&gt; &lt;/li&gt; 
&lt;/ul&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;SG-DB&lt;/h3&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1237&quot; data-start=&quot;1131&quot; data-ke-list-type=&quot;disc&quot;&gt; 
 &lt;li data-end=&quot;1191&quot; data-start=&quot;1131&quot;&gt;Inbound 
  &lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1191&quot; data-start=&quot;1145&quot; data-ke-list-type=&quot;disc&quot;&gt; 
   &lt;li data-end=&quot;1191&quot; data-start=&quot;1145&quot;&gt;&lt;b&gt;3306/TCP&lt;/b&gt;: &lt;b&gt;Source = SG-BE&lt;/b&gt; (보안그룹 간 참조)&lt;/li&gt; 
  &lt;/ul&gt; &lt;/li&gt; 
 &lt;li data-end=&quot;1237&quot; data-start=&quot;1192&quot;&gt;Outbound 
  &lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1237&quot; data-start=&quot;1207&quot; data-ke-list-type=&quot;disc&quot;&gt; 
   &lt;li data-end=&quot;1237&quot; data-start=&quot;1207&quot;&gt;기본 All 허용(패치/백업 고려. 필요 시 축소)&lt;/li&gt; 
  &lt;/ul&gt; &lt;/li&gt; 
&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;왜 이렇게? &lt;b&gt;SG→SG 참조&lt;/b&gt;로 해두면 BE를 교체하거나 오토스케일해도 DB 접근 규칙이 자동으로 따라온다. 그리고 3306을 퍼블릭에 안 내놓으니 스캐닝·사전대입 봇이 접근할 구멍 자체가 없다.&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;애플리케이션(Spring Boot) 설정&lt;/h2&gt;&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;spring:
datasource:
	url: jdbc:mysql://10.0.1.23:3306/mydb?characterEncoding=UTF-8&amp;amp;serverTimezone=UTC
	username: app
	password: strongPW!
&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;DB 호스트는 &lt;b&gt;프라이빗 IP&lt;/b&gt;. 비밀번호 같은 건 환경변수나 SSM/Secrets Manager로 뺀다. 커넥션풀(HikariCP) 최소/최대도 프로젝트 규모에 맞춰 잡아둔다.&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot;&gt;&lt;h2 data-ke-size=&quot;size26&quot;&gt;DataGrip은 BE를 점프로 쓴다(SSH 터널)&lt;/h2&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1998&quot; data-start=&quot;1688&quot; data-ke-list-type=&quot;disc&quot;&gt; 
 &lt;li data-end=&quot;1865&quot; data-start=&quot;1688&quot;&gt;&lt;b&gt;SSH/Proxy&lt;/b&gt; 
  &lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1865&quot; data-start=&quot;1708&quot; data-ke-list-type=&quot;disc&quot;&gt; 
   &lt;li data-end=&quot;1730&quot; data-start=&quot;1708&quot;&gt;Use SSH tunnel: ON&lt;/li&gt; 
   &lt;li data-end=&quot;1756&quot; data-start=&quot;1733&quot;&gt;Host: &amp;lt;BE 퍼블릭 IP&amp;gt;&lt;/li&gt; 
   &lt;li data-end=&quot;1773&quot; data-start=&quot;1759&quot;&gt;Port: 22&lt;/li&gt; 
   &lt;li data-end=&quot;1829&quot; data-start=&quot;1776&quot;&gt;User: ubuntu(Ubuntu) / ec2-user(Amazon Linux)&lt;/li&gt; 
   &lt;li data-end=&quot;1865&quot; data-start=&quot;1832&quot;&gt;Auth: 키페어 .pem, Keep alive 체크&lt;/li&gt; 
  &lt;/ul&gt; &lt;/li&gt; 
 &lt;li data-end=&quot;1998&quot; data-start=&quot;1866&quot;&gt;&lt;b&gt;General&lt;/b&gt; 
  &lt;ul style=&quot;list-style-type: disc;&quot; data-end=&quot;1998&quot; data-start=&quot;1884&quot; data-ke-list-type=&quot;disc&quot;&gt; 
   &lt;li data-end=&quot;1929&quot; data-start=&quot;1884&quot;&gt;Host: &lt;b&gt;&amp;lt;DB 프라이빗 IP&amp;gt;&lt;/b&gt; (예: 10.0.1.23)&lt;/li&gt; 
   &lt;li data-end=&quot;1948&quot; data-start=&quot;1932&quot;&gt;Port: 3306&lt;/li&gt; 
   &lt;li data-end=&quot;1965&quot; data-start=&quot;1951&quot;&gt;DB: mydb&lt;/li&gt; 
   &lt;li data-end=&quot;1998&quot; data-start=&quot;1968&quot;&gt;User/PW: app / strongPW!&lt;/li&gt; 
  &lt;/ul&gt; &lt;/li&gt; 
&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 SG 전제는 다시 확인:&lt;/p&gt;&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;&lt;li&gt;SG-DB 인바운드 3306 ← &lt;b&gt;Source = SG-BE&lt;/b&gt;&lt;/li&gt;&lt;li&gt;SG-BE 인바운드 22 ← &lt;b&gt;내 공인 IP&lt;/b&gt;&lt;/li&gt;&lt;/ul&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br&gt;&lt;b&gt;이런식으로 설정을 완료하면 DB서버로 연결하지 않고도 터널링을 통해 충분히 DB EC2서버에서 DDL,DML을 작성할 수있다... 후... 네트워크 지식이없으니... 이것도 몰랐다... 다른 분들에게도 도움이 되길&lt;/b&gt;&lt;/p&gt;</description>
      <category>프로젝트 이슈 및 몰랐던점 정리/DiaryAPI</category>
      <category>AWS</category>
      <category>EC2</category>
      <category>RDS</category>
      <category>RDS 퍼블릭IP</category>
      <category>SSH터널링</category>
      <category>vpc</category>
      <category>데이터베이스보안</category>
      <category>보안그룹</category>
      <category>비용절감</category>
      <category>프라이빗IP</category>
      <author>cheolhyeon</author>
      <guid isPermaLink="true">https://bebetter-forme.tistory.com/114</guid>
      <comments>https://bebetter-forme.tistory.com/114#entry114comment</comments>
      <pubDate>Thu, 6 Nov 2025 17:06:10 +0900</pubDate>
    </item>
    <item>
      <title>[트러블 슈팅] ⚠️ 게시글 검색 및 페이지네이션 슬로우 쿼리</title>
      <link>https://bebetter-forme.tistory.com/103</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;자유 게시판 프로젝트 진행 중 검색 기능을 구현한 뒤였다. 해당 프로젝트에는 더미데이터 약 2000천만건이 들어가 있었고, 게시글 첫 페이지 조회 시에만 해도 5초 이상이 걸리며 만약 맨 마지막 페이지의 컨텐츠를 확인하기 위해서 limit 20에 offset 20000000을 주게 될 경우 타임아웃이 발생하는 에러를 마주하게 되었다....&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;이는 MySQL의 물리 디스크에 접근하여 데이터를 찾는데 그만큼 오래 걸린다는 것인데 이를 해결하기 위해서는 인덱스 생성이 필요해 보였다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 원인 파악&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원인을 파악하기 위해서 EXPLAIN 명령어로 해당 쿼리를 분석.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1862&quot; data-origin-height=&quot;141&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bm6lz3/btsM406ccRb/hrnKTO5W3XX6ZV7MpasJ50/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bm6lz3/btsM406ccRb/hrnKTO5W3XX6ZV7MpasJ50/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bm6lz3/btsM406ccRb/hrnKTO5W3XX6ZV7MpasJ50/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbm6lz3%2FbtsM406ccRb%2FhrnKTO5W3XX6ZV7MpasJ50%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;1862&quot; height=&quot;141&quot; data-origin-width=&quot;1862&quot; data-origin-height=&quot;141&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;확인 결과 &lt;code&gt;type&lt;/code&gt;이 ALL인 것을 확인했다. 이는 쿼리가 인덱스를 사용하지 않고 풀 테입블 스캔을 하고 있음을 의미한다.&lt;br /&gt;게다가 총 1987만건의 데이터에 접근하여 데이터 추출 작업을 하는 것이다.&lt;b&gt; 이는 총 데이터 2천만건의 거의 모든 데이터를 훑어보고 데이터를 추출을 하게 되므로 느려지는 것.&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;DB의 성능 최적화를 위해서는 최대한 데이터 접근 수를 낮추고, 쿼리가 인덱스에서 데이터를 찾게끔 해야 물리 디스크로의 접근이 낮춰지면서 디스크 I/O 작업 비용도 낮아질 것이다.&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;해당 쿼리는 가장 최신 게시글 20개를 불러오는 작업을 수행하는 것인데, 결과적으로 인덱스가 없는 원본 테이블에서 가장 늦게 생성된 게시글을 불러오려고하는 것인데, 성능이 안좋을 수 밖에없다. 원본 테이블은 PK를 기준으로 오름차순 정렬이기 때문에 가장 최신의 게시글을 불러온다는 건 가장 늦게 생성된 PK를 뽑아오는 작업이다.&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;그러니 최신 게시글을 불러오기 위해서 거의 모든 데이터 1987만건의 데이터를 훑어보고 데이터를 찾아오는 것이 논리적으로 맞다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;거기에 추가적으로 &lt;code&gt;Extra&lt;/code&gt; 항목을 보면 &lt;code&gt;Using filesort&lt;/code&gt;를 볼 수 있는데 이 또한 데이터를 찾고 나서 데이터를 반환하기 위해 정렬 작업을 별도의 메모리나 디스크 공간을 사용해서 정렬 작업을 수행하고 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결방안&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럼 해결책으로 생성 날짜를 기준으로 내림차순의 인덱스를 생성하면 첫 페이지 검색 시에 상위 첫 20개를 바로 꺼내와서 가져올 수 있다. &lt;code&gt;type&lt;/code&gt; 또한 ALL이 아닌 인덱스를 사용하게 함으로써 index가 사용 될 것이고 데이터 접근도 limit 20에 맞게 20이 될 것이다.&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;인덱스를 created_at desc로 생성 한 뒤에 &lt;code&gt;explain&lt;/code&gt;으로 실행 계획을 살펴본 사진이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;574&quot; data-origin-height=&quot;127&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bYLtwb/btsM44gtbPz/jTmaKQqkGYb82WkVks80gk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bYLtwb/btsM44gtbPz/jTmaKQqkGYb82WkVks80gk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bYLtwb/btsM44gtbPz/jTmaKQqkGYb82WkVks80gk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbYLtwb%2FbtsM44gtbPz%2FjTmaKQqkGYb82WkVks80gk%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;574&quot; height=&quot;127&quot; data-origin-width=&quot;574&quot; data-origin-height=&quot;127&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 &lt;code&gt;type&lt;/code&gt;이 index가 되면서 더 이상 ALL이 아니라 인덱스 스캔을 통해서 데이터를 가져오는 것을 확인할 수 있다. 성능 또한 향상 되었다 원래 기존의 첫 페이지 검색 시에는 응답속도가 5s 41ms 이였는데, 353ms로 단축된걸 확인할 수 있다. 대략 성능이 14배정도 빨라진걸 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;681&quot; data-origin-height=&quot;43&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SYkMo/btsM3Ur7RUk/OSXmHtcMobNiUit9diXc21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SYkMo/btsM3Ur7RUk/OSXmHtcMobNiUit9diXc21/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SYkMo/btsM3Ur7RUk/OSXmHtcMobNiUit9diXc21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSYkMo%2FbtsM3Ur7RUk%2FOSXmHtcMobNiUit9diXc21%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;681&quot; height=&quot;43&quot; data-origin-width=&quot;681&quot; data-origin-height=&quot;43&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생성 날짜를 내림차순으로 정렬한 인덱스를 생성하여 첫 페이지 조회 시 가장 최신의 게시글을 빠른 시간 내에 접할 수 있게 되었지만 문제점이 존재한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;인덱스 생성 후의 문제점&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 맨 마지막 페이지의 게시글을 확인해 보지 않았다. 인덱스 생성 전에는 맨 마지막 페이지를 조회하기 위해 offset을 2천만을 넣었을 때 타임아웃이 발생해 애초에 조회 조차도 해보지 못하게 되어있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;인덱스 생성 후에는 어떻게 나올까??&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;591&quot; data-origin-height=&quot;55&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xkDrc/btsM2YPe5za/YOnwquXNFeDOVvqZFtX6l0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xkDrc/btsM2YPe5za/YOnwquXNFeDOVvqZFtX6l0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xkDrc/btsM2YPe5za/YOnwquXNFeDOVvqZFtX6l0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxkDrc%2FbtsM2YPe5za%2FYOnwquXNFeDOVvqZFtX6l0%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;591&quot; height=&quot;55&quot; data-origin-width=&quot;591&quot; data-origin-height=&quot;55&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;1분 40초... 2분에 근접한 시간이 걸리게 된다. 물론 장족의 발전이라고 볼 수 있다. 애초에 타임아웃이 나오는 쿼리였기 때문이다. 하지만 이 상태의 커뮤니티 서비스를 누가 이용할 것인가....&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 발생한 이유는 &lt;code&gt;explain analyze&lt;/code&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;확인 결과 옵티마이저가 인덱스를 통해 offset을 처리하여 필터링을 먼저 수행하고 필터링의 결과로 row id값을 반환하게 되는데, 인덱스가 반환한 row id 값으로 원본 테이블에서 데이터를 조회하는 작업이 발생하여 늦게 처리가 되는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;created_at desc 인덱스를 통해서 row id를 찾는데, 사용자가 원하는 값은 생성 날짜가 아니다. 생성 날짜를 통해 최신 게시글을 보고싶은것. 즉 게시글 데이터를 필요로 하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;created_at을 통해 row id를 찾았고 이를 통해 원본 테이블에 접근하여 Post에 대한 컬럼을 뽑아서 반환해야 되는 것이다.&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;u&gt;&lt;b&gt;인덱스 접근 -&amp;gt; 필터링 -&amp;gt; 원본 테이블 접근 -&amp;gt; 데이터 조회&lt;/b&gt;&lt;/u&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;u&gt;&lt;b&gt;MySQL의 물리 디스크에 접촉해서 Post에 대한 컬럼을 찾기 시작하는데 여기서 디스크 I/O 비용이 발생하면서 작업 속도가 오래 걸리는 것을 확인했다.&lt;/b&gt;&lt;/u&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;offset이 커짐에 따라 인덱스 트리를 순회하는 것은 어쩔 수 없지만 원본 테이블로의 접근을 막아 디스크 I/O 비용을 줄이기라도 해보자 라는 마음으로 &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;데이터 추출에 필요한 컬럼들은 post_id, title, categoryId, created_at 이였고, 인덱스의 정렬 구조는 항상 첫 번째 컬럼을 기준으로 정렬 되고, 해당 SELECT 쿼리의 ORDER BY가 created_at을 기준으로 하기 때문에 created_at 을 첫 번째 조건으로 멀티컬럼 인덱스를 생성.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2166&quot; data-origin-height=&quot;130&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/pdjJ2/btsM5xJe1X2/kVnKfAykG3TUcdcNG4ck70/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/pdjJ2/btsM5xJe1X2/kVnKfAykG3TUcdcNG4ck70/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/pdjJ2/btsM5xJe1X2/kVnKfAykG3TUcdcNG4ck70/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FpdjJ2%2FbtsM5xJe1X2%2FkVnKfAykG3TUcdcNG4ck70%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;2166&quot; height=&quot;130&quot; data-origin-width=&quot;2166&quot; data-origin-height=&quot;130&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커버링 인덱스 사용으로 인해 원본 테이블에 접근하여 데이터를 조회하는 과정을 건너뜀으로써 모든 작업은 디스크 접근 없이 인덱스 메모리 상에서 처리되고 이로 인한 &lt;u&gt;&lt;b&gt;성능 저하가 최소화 될 것이라 판단 하였다.&lt;/b&gt;&lt;/u&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;인덱스 생성 전에는 offset 2천만 조건으로 조회 시에는 타임 아웃이였다, 그 후 created_at desc로 인덱스 생성 후에는 14984ms 1분 40초 정도 걸리는 시간이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;572&quot; data-origin-height=&quot;50&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/utJ1D/btsM5AlEq02/KGky2vOkFTRWdKe86RCShk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/utJ1D/btsM5AlEq02/KGky2vOkFTRWdKe86RCShk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/utJ1D/btsM5AlEq02/KGky2vOkFTRWdKe86RCShk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FutJ1D%2FbtsM5AlEq02%2FKGky2vOkFTRWdKe86RCShk%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;572&quot; height=&quot;50&quot; data-origin-width=&quot;572&quot; data-origin-height=&quot;50&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;커버링 인덱스 생성 후의 offset 2천만은?&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;394&quot; data-origin-height=&quot;94&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bFxVC5/btsM4JczGhF/cPLaA0MOmNGRoDkztynOvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bFxVC5/btsM4JczGhF/cPLaA0MOmNGRoDkztynOvK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bFxVC5/btsM4JczGhF/cPLaA0MOmNGRoDkztynOvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbFxVC5%2FbtsM4JczGhF%2FcPLaA0MOmNGRoDkztynOvK%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;394&quot; height=&quot;94&quot; data-origin-width=&quot;394&quot; data-origin-height=&quot;94&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;거의 2분걸리던 작업을 3초 내외로 응답을 받을 수 있었다!!&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 쿼리를 튜닝하여 적절한 인덱스를 생성하게 되면 비약적으로 성능이 개서되는 사례를 볼 수 있다. 단점도 있으니 무분별한 인덱스 생성은 좋지 못하다. 해당 테이블을 사용하는 쿼리들을 보고 최적의 인덱스를 생성하는게 바람직하다. 무분별한 인덱스 생성은 오히려 CUD 작업의 성능을 저하시킨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>프로젝트 트러블 슈팅 및 몰랐던점 정리/CommunityAPI</category>
      <category>mysql 쿼리 튜닝</category>
      <category>slow query</category>
      <category>슬로우 쿼리</category>
      <category>인덱스 생성</category>
      <category>커버링 인덱스</category>
      <category>쿼리 튜닝</category>
      <author>cheolhyeon</author>
      <guid isPermaLink="true">https://bebetter-forme.tistory.com/103</guid>
      <comments>https://bebetter-forme.tistory.com/103#entry103comment</comments>
      <pubDate>Tue, 1 Apr 2025 20:42:04 +0900</pubDate>
    </item>
    <item>
      <title>[학습 포인트]  Redis SortedSet을 활용한 실시간 인기글 구현하기</title>
      <link>https://bebetter-forme.tistory.com/100</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;자유 게시판 프로젝트를 진행함에 있어서 실시간 인기글을 구현해야 했다. 인기글 선정은 게시글의 좋아요와, 조회수, 그리고 댓글의 수를 따져가며 인기글인지 아닌지를 선정하게 되는데 여기에 있어서 Redis를 사용했다.&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;인기글 선정을 RDBMS로 선정하고 저장해서 가져오게 될 경우 쿼리를 통해 조회해야 한다. 이 경우 인덱스를 활용하여 정렬과 조회 속도를 개선할 수 있겠지만, 새로운 인기글이 선정될 때마다 정렬 작업이 매번 이루어지게 되고 트래픽이 많아질 수록 DB 부하가 증가할 것이기 때문에 NoSQL DB인 Redis를 택했다.&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;게다가 TTL을 지원하기 때문에, 기간별로 인기글을 저장했다가 지울수도 있고, 여러가지 기능의 확장성으로 보았을 때 적합하다고 생각해서 Redis를 통해서 기능을 구현하기로 했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Redis SortedSet&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;/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; : 점수가 낮은 순서, 내림 차순으로 정렬되며 조회 시 정렬된 상태로 반환한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;빠른 조회 성능&lt;/b&gt; : 점수를 기반으로 조회할 때의 시간복잡도는 O(logN)이다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;범위 검색 지원 :&lt;/b&gt; 점수 또는 순위의 범위를 지정하여 데이터를 효율적으로 검색할 수 있는 함수를 제공한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;데이터 삽입 (ZADD)&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;public void addOrUpdatePost(String key, String postId, double score) {
    redisTemplate.opsForZSet().add(key, postId, score);
    System.out.println(&quot;✅ &quot; + postId + &quot; 추가됨 (점수: &quot; + score + &quot;)&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;postId를 원소로 지정하고 score를 통해 해당 key값으로 내부적으로 내림차순으로 정렬을 수행한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;점수 증가 (ZINCRBY)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게시물의 점수를 증가시키는 방법이다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;public void incrementPostScore(String key, String postId, double increment) {
    redisTemplate.opsForZSet().incrementScore(key, postId, increment);
    System.out.println(&quot;  &quot; + postId + &quot;의 점수가 &quot; + increment + &quot;만큼 증가했습니다.&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;점수 변경 후에도 자동으로 정렬된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;인기글 조회 (ZREVRANGE)&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;public Set&amp;lt;String&amp;gt; **getTopNPosts**(String key, long start, long end) {
    return redisTemplate.opsForZSet().reverseRange(key, start, end);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;start 0, end 9의 값을 넣게되면 상위 10개의 인기글을 꺼내오게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ZREVRANGE 명령어는 점수가 높은 순서로 반환한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 함수들과 더불어 더 많은 기능을 제공한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 해당 프로젝트에서 실시간 인기글 반영 기능을 구현해야 했기 때문에, SpringScheduler로 매 30분 마다 update 해주는 식으로 인기글을 반영하였다. 사실 실시간이라고 볼 순 없긴하다... 그런데 여기서 Redis Stream을 붙이기에는 더 많은 시간이 소요될 것 같아서.... 나중에 추가로 개발 해보는 것으로 일단은 마무우리~&lt;/p&gt;</description>
      <category>프로젝트 트러블 슈팅 및 몰랐던점 정리/CommunityAPI</category>
      <category>redis sortedset</category>
      <category>redis로 인기글 구현하기</category>
      <category>인기글 구현하기</category>
      <author>cheolhyeon</author>
      <guid isPermaLink="true">https://bebetter-forme.tistory.com/100</guid>
      <comments>https://bebetter-forme.tistory.com/100#entry100comment</comments>
      <pubDate>Sun, 30 Mar 2025 17:20:32 +0900</pubDate>
    </item>
    <item>
      <title>[학습 포인트]  Redis를 활용한 toggle(좋아요)기능 구현하기</title>
      <link>https://bebetter-forme.tistory.com/99</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;자유 게시판 프로젝트를 진행함에 있어서 게시글의 좋아요 기능과 댓글의 좋아요 기능을 구현함에 있어서 적절한 방법은 무엇일지 고민했었다. 기본적으로 한번 누르면 좋아요가 증가하고 한번 더 누르면 좋아요가 감소해야된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반적으로 MySQL과 같은 RDBMS들은 쿼리가 발생하는 DB에 게시글의 좋아요 기능 혹은 댓글의 좋아요 기능을 구현함에 있어서는 &lt;b&gt;select 쿼리와 update 쿼리가 발생하게 될 것인데&lt;/b&gt;, 이에 따른 동시성 문제를 어떻게 해결하고, 응답속도 최적화를 하기 위해서는 어떤 방법이 있을지 고민하다가 NoSQL인 Redis를 활용하기로 결정하였다.&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;Redis를 사용하면 단일 스레드로 동작하기 때문에 증가, 감소, 삭제와 같은 명령어는 모두 Atomic하게 실행되므로 동시성 문제를 해결하기 위해서 적절하다고 판단했다. 더불어 트랜잭션 처리가 필요하지 성능 적인 부분도 함께 가져갈 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Redis를 활용하여 Toggle 기능 구현하기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis는 Key-Value 형태로 값을 저장하고 Key 값으로 Value를 찾아오기 때문에 조회 부분에서 가장 좋은 성능을 보여준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;toggle 기능을 구현하기 위한 기본적인 로직은 아래와 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;해당 요청을 보낸 특정 유저의 Id와 Post의 Id 혹은 Comment의 Id를 조합하여 키를 생성.&lt;/li&gt;
&lt;li&gt;이를 기반으로 Key값이 이미 Redis에 존재한다면 좋아요 감소, 없다면 좋아요 증가를 하면 된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 코드는 Post의 좋아요 기능을 기반으로 작성된 코드이다.&lt;/p&gt;
&lt;pre class=&quot;gradle&quot;&gt;&lt;code&gt;@Repository
@RequiredArgsConstructor
public class PostLikeQueryRedisRepository {
    private static final String TOGGLE_KEY = &quot;post:%s:users:%s:toggle&quot;;
    private final StringRedisTemplate postLikeRedisTemplate;

    public boolean isLikedByUserIdAndPostId(Long postId, Long userId) {
        String key = generateKey(postId, userId);
        return Boolean.TRUE.equals(postLikeRedisTemplate.hasKey(key));
    }
    public void insert(Long postId, Long userId) {
        String key = generateKey(postId, userId);
        postLikeRedisTemplate.opsForValue().set(key, &quot;&quot;);
    }
    public void delete(Long postId, Long userId) {
        String key = generateKey(postId, userId);
        postLikeRedisTemplate.delete(key);
    }

    private String generateKey(Long postId, Long userId) {
        return TOGGLE_KEY.formatted(postId, userId);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;Key-Value 저장 방식:&lt;/b&gt; post:%s:users:%s:toggle 라는 형식으로 Redis의 Key를 구성.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;isLikedByUserIdAndPostId() :&lt;/b&gt; hasKey() 메서드를 사용하여 특정 키가 존재하는 지 확인한다. O(1)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;insert()&lt;/b&gt; : opsForValue().set(key, &amp;ldquo;&amp;rdquo;)로 Redis에 key를 추가한다. 이 때 Value는 별로 중요치 않고, Key의 존재 여부만 체크할 것이므로 빈값으로 넣기로 하였다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;delete()&lt;/b&gt; : Key가 이미 존재한다면 좋아요를 취소하는 것으로 간주하고 Key를 삭제한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@Service
@RequiredArgsConstructor
public class PostLikeService {
    private final PostLikeRedisRepository likeRedisRepository;
    private final PostLikeQueryRedisRepository queryRedisRepository;

    public Long toggleLike(Long postId, Long userId) {
        boolean isAlreadyLiked = queryRedisRepository.isLikedByUserIdAndPostId(postId, userId);
        if (isAlreadyLiked) {
            return unlikePost(postId, userId);
        }
        return likePost(postId, userId);
    }

    public Long getCurrentPostLikeCount(Long postId) {
        return likeRedisRepository.getCurrentPostLikeCount(postId);
    }

    private Long likePost(Long postId, Long userId) {
        queryRedisRepository.insert(postId, userId);
        return likeRedisRepository.increment(postId);
    }

    private Long unlikePost(Long postId, Long userId) {
        queryRedisRepository.delete(postId, userId);
        return likeRedisRepository.decrement(postId);
    }
}
&lt;/code&gt;&lt;/pre&gt;</description>
      <category>프로젝트 트러블 슈팅 및 몰랐던점 정리/CommunityAPI</category>
      <category>redis로 좋아요</category>
      <category>게시글 좋아요</category>
      <category>좋아요</category>
      <author>cheolhyeon</author>
      <guid isPermaLink="true">https://bebetter-forme.tistory.com/99</guid>
      <comments>https://bebetter-forme.tistory.com/99#entry99comment</comments>
      <pubDate>Sun, 30 Mar 2025 16:59:53 +0900</pubDate>
    </item>
    <item>
      <title>[학습 포인트]  Mockito Matchers와 실제 값을 이용한 테스트의 차이</title>
      <link>https://bebetter-forme.tistory.com/102</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;레이어드 아키텍처의 단위 테스트 코드를 작성하다보면 Mock과 AssertJ를 사용하여 값을 검증하는 테스트 코드를 작성하게 된다. 이 때 테스트 코드 입문과정 시 헷갈리는 부분들을 정리하여 작성하게 되었다. 해당 테스트 코드는 BDD 스타일로 작성된 코드이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;메서드의 호출 여부가 중요한 경우 - then()&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;입력값이 중요하지 않고 메서드의 호출 여부만 확인하고 싶을 때가 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Test
    @DisplayName(&quot;해당 메서드가 실제로 호출 되었는지 확인할 때&quot;)
    void callMethod() {
        //given
        given(mockService.process(anyString(), anyInt()))
                .willReturn(mock(MockingString.class));
        //when
        MockingString mocked = mockService.process(&quot;Mocked&quot;, 42);

        //then
        then(mockService).should().process(anyString(), anyInt());
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그럴 때는 값이 중요한게 아니기 때문에 matchers를 통해 인자의 타입을 정해주고, then구문 을 통해 해당 메서드가 호출 되었는지 되지 않았는지 확인할 수 있다.&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;⛔️ willReturn구문에는 matchers를 사용할 수 없기 때문에 객체만 Mocking 해주거나 실제 객체의 인스턴스를 넣어주어야 한다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;값 검증이 중요한 경우 - AssertJ&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;입력값이 중요하여 내가 예상한 객체 혹은 값이 반환이 잘 되는 것이 중요한 경우가 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Test
    @DisplayName(&quot;내가 예상한 데이터가 정확히 반환되는지가 중요할 때&quot;)
    void callMethod() {
        //given
        given(mockService.process(&quot;Mocked&quot;, 42))
                .willReturn(new MockingString(&quot;Mocked&quot;, 42));
        //when
        MockingString mocked = mockService.process(&quot;Mocked&quot;, 42);

        //then
        assertThat(mocked).extracting(
                MockingString::getNumber,
                MockingString::getRequest
        ).containsExactly(42, &quot;Mocked&quot;);
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;번외로 두 인자중에 하나의 인자에 any() matchers를 사용하고 하나의 인자에는 실제 값을 사용할 때에는 eq() matchers를 사용하여 실제 값을 감싸준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;즉 일부 인자는 정확한 값이어야 하고, 일부 인자는 조건만 만족하면 되는 경우에 사용한다.&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;    void callMethod() {
        //given
        given(mockService.process(anyString(), eq(42)))
                .willReturn(new MockingString(&quot;Mocked&quot;, 42));
        //when
        MockingString mocked = mockService.process(&quot;Mocked&quot;, 42);
        
        //then
        assertThat(mocked).extracting(
                MockingString::getNumber,
                MockingString::getRequest
        ).containsExactly(42, &quot;Mocked&quot;);
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;값 검증과 메서드의 호출 둘다 검증이 필요한 경우&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;값 검증과 해당 메서드의 호출 여부 둘 다 검증이 필요한 경우의 테스트가 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;void callMethod() {
        //given
        given(mockService.process(&quot;Mocked&quot;, 42))
                .willReturn(new MockingString(&quot;Mocked&quot;, 42));
        //when
        MockingString mocked = mockService.process(&quot;Mocked&quot;, 42);

        //then
        assertThat(mocked).extracting(
                MockingString::getNumber,
                MockingString::getRequest
        ).containsExactly(42, &quot;Mocked&quot;);
        then(mockService).should().process(anyString(), anyInt());
    }
&lt;/code&gt;&lt;/pre&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;그리고 메서드 호출 여부는 then 메서드를 통해 검증할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&amp;nbsp;&lt;b&gt;shouldHaveNoInteractions&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;목적&lt;/b&gt; : 해당 Mock 객체에 대해 하나도 호출이 이루어지지 않았음을 검증하는 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예시&lt;/b&gt; : 테스트 시작 후 특정 mock 객체가 전혀 사용되지 않아야 한다&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;then(mock).shouldHaveNoInteractions();
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 호출은 mock에 대해 단 한건의 메서드 호출이 없었음을 증명하기 위해서 사용&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;shouldHaveNoMoreInteractions&lt;/h2&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;b&gt;예시&lt;/b&gt; : 여러 메서드의 호출을 검증한 후에 mock에 예기치 않은 호출이 없었는지 마지막으로 점검하고 싶을 때 사용한다.&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;  void reportWhenKeyExists() {
        // given
        String key = &quot;report:users:1&quot;; // generateKey(userId)가 이 값을 반환한다고 가정
        Duration ttl = Duration.ofDays(1);
        given(template.opsForValue()).willReturn(operations);
        given(template.hasKey(key)).willReturn(true);
        given(operations.increment(key)).willReturn(2L);

        // when
        Long result = reportRedisRepository.report(1L, ttl);

        // then
        assertThat(result).isEqualTo(2L);
        // key가 존재하므로, increment()만 호출되어야 함.
        then(operations).should(times(1)).increment(key);
        then(operations).should(never()).set(anyString(), any(String.class), any(Duration.class));
        // template에서는 hasKey와 opsForValue()가 각각 한 번씩 호출되어야 함.
        then(template).should(times(1)).hasKey(key);
        then(template).should(times(1)).opsForValue();

        // 추가 검증: 위에서 검증한 외에 다른 호출은 없어야 함.
        then(operations).shouldHaveNoMoreInteractions();
        then(template).shouldHaveNoMoreInteractions();
        
        // 만약 어떤 mock은 전혀 사용되지 않아야 한다면,
        // then(someOtherMock).shouldHaveNoInteractions();
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;정리&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt; &lt;/span&gt;&amp;bull;&lt;span&gt; &lt;/span&gt;&lt;span&gt;AssertJ&lt;/span&gt;와 &lt;span&gt;Mockito.verify()&lt;/span&gt; / &lt;span&gt;then()&lt;/span&gt;은 서로 다른 검증 목적을 가지고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt; &lt;/span&gt;&amp;bull;&lt;span&gt; &lt;/span&gt;값 검증은 &lt;span&gt;AssertJ&lt;/span&gt;를, 메서드 호출 검증은 &lt;span&gt;Mockito.verify()/then() &lt;/span&gt;를 사용해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span&gt;&lt;span&gt; &lt;/span&gt;&amp;bull;&lt;span&gt; &lt;/span&gt;둘 다 필요할 경우, &lt;/span&gt;&lt;b&gt;두 가지 방법을 함께 사용하여 검증하는 게 좋다.&lt;/b&gt;&lt;/p&gt;</description>
      <category>프로젝트 트러블 슈팅 및 몰랐던점 정리/CommunityAPI</category>
      <category>assertj</category>
      <category>bdd</category>
      <category>Mock</category>
      <category>mockito</category>
      <category>Then</category>
      <category>단위 테스트 작성</category>
      <category>유닛 테스트 작성</category>
      <author>cheolhyeon</author>
      <guid isPermaLink="true">https://bebetter-forme.tistory.com/102</guid>
      <comments>https://bebetter-forme.tistory.com/102#entry102comment</comments>
      <pubDate>Sun, 30 Mar 2025 16:52:52 +0900</pubDate>
    </item>
    <item>
      <title>[학습 포인트]  MockMvc 컨트롤러 단위 테스트 시 인증정보 부재</title>
      <link>https://bebetter-forme.tistory.com/98</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;SpringSecurity를 사용하는 프로젝트에서 만약 Contrller의 단위 테스트 작업을 진행할 경우, 사용자의 인증정보가 필요하게 될 것이다. 해당 인증정보를 어떻게 Mocking하는지에 대해서 알아보자&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제원인&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;MockMvc로 Controller Layer 단위 테스트시에 SpringSecurity가 활성화된 상태에서 MockMvc.perform() 호출 시 인증 정보가 주입되지 않으면 기본적으로 403 Forbidden 오류가 발생한다.&lt;/li&gt;
&lt;li&gt;TestingAuthenticationToken 이나 커스텀 토큰으로 인증 객체 생성시에 필요한 권한 정보가 설정되지 않으면 Security 인가 과정이 실패한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결 방안&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;@WithMockUser 또는 SecurityMockMvcRequestPostProcessors 사용&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Test
@WithMockUser(username = &quot;testUser&quot;, roles = {&quot;USER&quot;})
void testController_withMockUser() throws Exception {
    mockMvc.perform(get(&quot;/v3/api/me/playtime&quot;)
            .param(&quot;year&quot;, &quot;2025&quot;))
            .andExpect(status().isOk())
            .andExpect(jsonPath(&quot;$&quot;, hasSize(1)))
            .andExpect(jsonPath(&quot;$[0].totalPlayTime&quot;).value(&quot;8760시간 0분&quot;))
            .andExpect(jsonPath(&quot;$[0].totalParticipant&quot;).value(50))
            .andExpect(jsonPath(&quot;$[0].totalFollowers&quot;).value(0))
            .andDo(print());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 메서드에 @WithMockUser(username = &quot;testUser&quot;, roles = {&quot;USER&quot;}) 해당 기능을 사용하면 기본값으로 사용자 이름과 권한을 가진 인증 객체를 생성한다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. SecurityMockMvcRequestPostProcessors 사용하기&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;    mockMvc.perform(get(&quot;/v3/api/me/playtime&quot;)
                        .param(&quot;year&quot;, String.valueOf(year))
                        .with(authentication(new TestingAuthenticationToken(dummyUser, null, &quot;ROLE_USER&quot;))))
                .andExpect(status().isOk())
                .andExpect(jsonPath(&quot;$&quot;, hasSize(1)))
                .andExpect(jsonPath(&quot;$[0].totalPlayTime&quot;).value(&quot;8760시간 0분&quot;))
                .andExpect(jsonPath(&quot;$[0].totalParticipant&quot;).value(50))
                .andExpect(jsonPath(&quot;$[0].totalFollowers&quot;).value(0))
                .andDo(print());

&lt;/code&gt;&lt;/pre&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;TestingAuthenticationToken에 권한 추가하기&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;import org.springframework.security.authentication.TestingAuthenticationToken;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.authentication;

@Test
void testController_withTestingAuthenticationToken() throws Exception {
    TestingAuthenticationToken dummyUser = new TestingAuthenticationToken(&quot;testUser&quot;, null, &quot;ROLE_USER&quot;);

    mockMvc.perform(get(&quot;/v3/api/me/playtime&quot;)
            .param(&quot;year&quot;, &quot;2025&quot;)
            .with(authentication(dummyUser)))
            .andExpect(status().isOk())
            .andExpect(jsonPath(&quot;$&quot;, hasSize(1)))
            .andExpect(jsonPath(&quot;$[0].totalPlayTime&quot;).value(&quot;8760시간 0분&quot;))
            .andExpect(jsonPath(&quot;$[0].totalParticipant&quot;).value(50))
            .andExpect(jsonPath(&quot;$[0].totalFollowers&quot;).value(0))
            .andDo(print());
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Security의 TestingAuthenticationToken을 사용하여 인증 객체를 직접 생성하고 권한을 추가할 수 있다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;학습 포인트&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;MockMvc와 Spring Security의 연동&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;MockMvc로 Controller를 테스트 때도 SpringSecurity 필터 체인이 동작한다는 점을 이해해야한다.&lt;/li&gt;
&lt;li&gt;인증이 필요한 API라면 반드시 Mock 또는 실제 인증과정을 시뮬레이션 해야 테스트가 성공한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;테스트 인증 방법의 다양성&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;@WithMockUser, .with(user(...)), TestingAuthenticationToken 등 다양한 방법이 존재한다.&lt;/li&gt;
&lt;li&gt;상황에 따라 원하는 사용자 정보(이메일, 권한, roles 등)를 세부적으로 설정할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;프로덕션 환경 vs 테스트 환경 구분&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;실제 배포 환경에서는 OAuth2, JWT, 세션 등 다양한 방식으로 인증이 이루어진다.&lt;/li&gt;
&lt;li&gt;하지만 테스트 시에는 인증 로직을 간소화 하거나 Mock을 사용해, Controller나 Service 로직 검증에 집중하는 것이 중요하다.&lt;/li&gt;
&lt;li&gt;필요하다면 Spring Security Test 라이브러리가 제공하는 전용 애노테이션(@WithMockUser, @WithSecurityContext)또는 SecurityMockMvcRequestPostProcessors를 적극 활용할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>프로젝트 트러블 슈팅 및 몰랐던점 정리/CommunityAPI</category>
      <category>mock springsecurity</category>
      <category>Springsecurity</category>
      <category>springsecurity 단위 테스트</category>
      <author>cheolhyeon</author>
      <guid isPermaLink="true">https://bebetter-forme.tistory.com/98</guid>
      <comments>https://bebetter-forme.tistory.com/98#entry98comment</comments>
      <pubDate>Sun, 30 Mar 2025 16:29:08 +0900</pubDate>
    </item>
    <item>
      <title>[학습 포인트]  Redis 단위 테스트 작성하기 feat.ValueOperations, Operations</title>
      <link>https://bebetter-forme.tistory.com/101</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;ValueOperation이란?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ValueOperations&amp;lt;K,V&amp;gt;는 SpringDataRedis에서 StringRedisTemplate를 통해 Redis의 &amp;ldquo;Value&amp;rdquo;관련 연산을 수행하는 인터페이스이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Redis에서 key-value형식으로 데이터를 저장하는데 이 때 ValueOperations는 Redis의 &amp;ldquo;String 타입&amp;rdquo; 데이터를 다루는데 사용된다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;ValueOperations의 주요 역할&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;데이터 읽기 작업&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;arduino&quot;&gt;&lt;code&gt;String value = redisTemplate.opsForValue().get(&quot;key&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;데이터저장&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;gams&quot;&gt;&lt;code&gt;redisTemplate.opsForValue().set(&quot;key&quot;, &quot;value&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;숫자증가&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;redisTemplate.opsForValue().increment(&quot;counter&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;숫자감소&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;redisTemplate.opsForValue().decrement(&quot;counter&quot;);
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;왜 ValueOperations를 반환해서 사용하는가?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;RedisTemplate 자체는 다양한 자료구조를 지원하지만, 각 자료구조를 다루는 방식은 다르다.&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;RedisTemplate의 각 opsForXXX()메서드는 해당 자료구조에 맞는 Operation 인터페이스를 반환해야한다. 해당 자료구조에 맞는 API를 사용할 수 있게 해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터 타입 인터페이스 사용 메서드&lt;/p&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;String (단일 값)&lt;/td&gt;
&lt;td&gt;ValueOperations&lt;/td&gt;
&lt;td&gt;opsForValue()&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Hash&lt;/td&gt;
&lt;td&gt;HashOperations&lt;/td&gt;
&lt;td&gt;opsForHash()&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;List&lt;/td&gt;
&lt;td&gt;ListOperations&lt;/td&gt;
&lt;td&gt;opsForList()&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Set&lt;/td&gt;
&lt;td&gt;SetOperations&lt;/td&gt;
&lt;td&gt;opsForSet()&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;SortedSet&lt;/td&gt;
&lt;td&gt;ZSetOperations&lt;/td&gt;
&lt;td&gt;opsForZSet()&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;HyperLogLog&lt;/td&gt;
&lt;td&gt;HyperLogLogOperations&lt;/td&gt;
&lt;td&gt;opsForHyperLogLog()&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Stream&lt;/td&gt;
&lt;td&gt;StreamOperations&lt;/td&gt;
&lt;td&gt;opsForStream()&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Redis의 데이터 구조별로 Operation 인터페이스가 존재하기 때문에&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;RedisTemplate은 다양한 Redis 자료구조를 다룰 수 있게 설계되어 있다.&lt;/li&gt;
&lt;li&gt;특정 자료구조를 사용하려면, 자료구조에 해당하는 인터페이스를 사용해야 한다.&lt;/li&gt;
&lt;li&gt;ValueOperations는 Redis의 기본 자료구조인 &lt;b&gt;String 타입 데이터를 다루기 위한 전용 인터페이스&lt;/b&gt; 입니다.&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;RedisTemplate에서 opsForValue() 메서드를 호출하면 ValueOperations 객체가 반환된다.&lt;/li&gt;
&lt;li&gt;ValueOperations 인터페이스에는 String 타입 데이터를 다루기 위한 다양한 메서드가 포함되어 있.&lt;/li&gt;
&lt;li&gt;다른 자료구조(Hash, List, Set, SortedSet)를 사용하려면 각 자료구조에 맞는 인터페이스를 사용해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;테스트 코드 실사용 예제&lt;/h3&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;@ExtendWith(MockitoExtension.class)
class ViewCountRedisRepositoryTest {

    @Mock
    StringRedisTemplate redisTemplate;

    @Mock
    ValueOperations&amp;lt;String, String&amp;gt; valueOperations;

    @InjectMocks
    ViewCountRedisRepository viewCountRedisRepository;

    @Test
    @DisplayName(&quot;특정 게시글 조회수 조회&quot;)
    void readPostViewCount() {
        // Given
        String key = &quot;post:1:view_count&quot;;

        given(redisTemplate.opsForValue()).willReturn(valueOperations);
        given(valueOperations.get(key)).willReturn(&quot;100&quot;);

        // When
        Long result = viewCountRedisRepository.read(1L);

        // Then
        assertThat(result).isEqualTo(100L);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;given(redisTemplate.opsForValue()).willReturn(valueOperations)&lt;/b&gt;; 해당 구문을 통해 redisTemplate.opsForValue 호출 될 때 ValueOperations 객체를 반환하도록 한 설정이다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;redisTemplate.opsForValue()는 ValueOperations&amp;lt;K, V&amp;gt; 인터페이스를 반환하는데, 이는 기본적으로 null이므로 테스트 실행 시 NullPointerException이 발생할 수 있습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Redis - ValueOperaion 테스트 코드 작성 시&lt;/h2&gt;
&lt;pre class=&quot;processing&quot;&gt;&lt;code&gt;    public void insert(Long postId, Long userId) {
        String key = generateKey(postId, userId);
        postLikeRedisTemplate.opsForValue().set(key, &quot;&quot;);
    }
    public void delete(Long postId, Long userId) {
        String key = generateKey(postId, userId);
        postLikeRedisTemplate.delete(key);
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;insert메서드는 opsForValue를 통해 set 함수를 호춯함&lt;/li&gt;
&lt;li&gt;delete메서드는 delete를 호출할 때 opsForValue를 호출하지 않음&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이유&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;insert()는 &lt;b&gt;Redis에 key-value 형태로 데이터를 저장해야 하므로 opsForValue()가 필요하다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;delete()는 &lt;b&gt;key 자체를 삭제하면 되므로 opsForValue() 없이postLikeRedisTemplate.delete(key)만 호출&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;두 메서드는 각기 다른 Redis 동작을 수행하므로, opsForValue() 사용 여부가 다름!&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;    @Test
    @DisplayName(&quot;특정 유저가 특정 게시글에 좋아요를 처음 눌렀을 경우 key를 insert한다.&quot;)
    void insert() {
        //given
        given(template.opsForValue()).willReturn(valueOperations);
        //when
        repository.insert(1L, 1L);
        //then
        then(valueOperations).should(times(1)).set(anyString(), anyString());
    }
    @Test
    @DisplayName(&quot;특정 유저가 특정 게시글에 좋아요를 처음 눌렀을 경우 key를 insert한다.&quot;)
    void delete() {
        //given
        //when
        repository.delete(1L, 1L);
        //then
        then(template).should(times(1)).delete(anyString());
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇기 때문에 opsForValue를 사용한 함수 호출 시 에는valueOperation을 반환해주고, delete는 바로 Redis로 직접 접근하면 된다.&lt;/p&gt;</description>
      <category>프로젝트 트러블 슈팅 및 몰랐던점 정리/CommunityAPI</category>
      <category>redis 단위 테스트</category>
      <category>redis 단위테스트 코드 작성</category>
      <category>valueoperation</category>
      <author>cheolhyeon</author>
      <guid isPermaLink="true">https://bebetter-forme.tistory.com/101</guid>
      <comments>https://bebetter-forme.tistory.com/101#entry101comment</comments>
      <pubDate>Sun, 30 Mar 2025 16:23:48 +0900</pubDate>
    </item>
    <item>
      <title>[트러블 슈팅] ⚠️A component required a bean named 에러 원인</title>
      <link>https://bebetter-forme.tistory.com/97</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;A component required a bean named 'redisTemplate' that could not be found.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자유 게시판 프로젝트를 진행하던 중 나는 조회수와 댓글의 좋아요를 관리하기 위해 NoSQL 데이터베이스인 Redis를 사용했다. 사용하던 와중에 Redis의 데이터베이스를 관리하던 도중 위와 같은 에러가 발생했다.&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;왜 redisTemplate 오류가 났었는가?&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;해당 에러는 Spring이 redisTemplate라는 이름의 bean을 찾지 못했기 때문에 나타나는 에러이다.&lt;/li&gt;
&lt;li&gt;혹시 해당 프로젝트에서 redis의 데이터베이스를 나눠서 사용하는 프로젝트라면 해당 문제가 발생할 수도 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;문제 상황&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트에서 조회수를 관리하는 viewCount와 댓글의 좋아요를 commentLike가 존재하는데, viewCount는 redis의 0번째 데이터베이스, commentLike는 1번째 데이터베이스에서 관리하도록 설정을 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[문제가 발생한 코드]&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Configuration
public class RedisConfig {

    @Bean
    public RedisConnectionFactory viewCountRedisConnectionFactory() {
        RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
        configuration.setDatabase(0);
        return new LettuceConnectionFactory(configuration);
    }

    @Bean
    public StringRedisTemplate viewCountRedisTemplate() {
        return new StringRedisTemplate(viewCountRedisConnectionFactory());
    }

    @Bean
    public RedisConnectionFactory commentLikeRedisConnectionFactory() {
        RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
        configuration.setDatabase(1);
        return new LettuceConnectionFactory(configuration);
    }

    @Bean
    public StringRedisTemplate commentLikeRedisTemplate() {
        return new StringRedisTemplate(commentLikeRedisConnectionFactory());
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;[결과]&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;armasm&quot;&gt;&lt;code&gt;A component required a bean named 'redisTemplate' that could not be found.
&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;문제 원인&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는 SpringBoot에서 spring-boot-data-redis 라이브러리 의존성을 주입받으면 Spring은 기본적으로 &lt;b&gt;RedisTemplate&amp;lt;String, String&amp;gt; Bean을 제공하려고 시도&lt;/b&gt;하고, 찾게 되는데 &lt;b&gt;이때 기본적으로 redisTemplate라는 이름으로 등록된다.&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;현재 RedisConfig 설정에서 수동으로 Redis를 제어하고 있기 때문에 SpringBoot의 자동설정에서(RedisAutoConfiguration)이 동작하지 않는다.&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;RedisTemplate를 제어하려고 하니 redisTemplate라는 이름의 Bean을 명시적으로 등록하지 않았으므로 해당 문제가 발생하게 되는것이다. &lt;u&gt;&lt;b&gt;이와 같은 이유로 해당 에러가 발생한 것이다.&lt;/b&gt;&lt;/u&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;위에 문제의 코드에서 StringRedisTemplate가 있지만 redisTemplate는 아니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;StringRedisTemplate는 RedisTemplate&amp;lt;String,String&amp;gt;을 상속받지만 Spring은 기본적으로 이름이 &amp;ldquo;redisTemplate&amp;rdquo;인 RedisTemplate Bean을 찾고있기 때문에 위에 에러가 나타난다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;문제원인 정리&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;spring-boot-data-redis를 사용하면 스프링 부트가 자동으로 RedisTemplate를 제공하고, 런타임시 해당 클래스를 주입하려고 시도한다.&lt;/li&gt;
&lt;li&gt;하지만 해당 프로젝트에서 RedisConfig를 통해 직접 수동으로 제어하다보니 스프링부트의 자동설정작업이 동작하지 않는다.&lt;/li&gt;
&lt;li&gt;위와 같은 이유로 redisTemplate를 찾을 수 없다고 나오는 것.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;해결방법&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 RedisTemplate (Spring이 찾을 수 있도록 제공)&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot;&gt;&lt;code&gt;   
    @Bean
    public RedisConnectionFactory redisConnectionFactoryForSpring() {
        RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
        return new LettuceConnectionFactory(configuration);
    }

    @Bean
    public RedisTemplate&amp;lt;String, Object&amp;gt; **redisTemplate**(@Qualifier(&quot;redisConnectionFactoryForSpring&quot;) RedisConnectionFactory redisConnectionFactoryForSpring) {
        RedisTemplate&amp;lt;String, Object&amp;gt; template = new RedisTemplate&amp;lt;&amp;gt;();
        template.setConnectionFactory(redisConnectionFactoryForSpring);
        template.setKeySerializer(new StringRedisSerializer());
        template.setValueSerializer(new StringRedisSerializer());
        template.setHashKeySerializer(new StringRedisSerializer());
        template.setHashValueSerializer(new StringRedisSerializer());
        return template;
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본적으로 스프링부트가 찾을 수 있는 RedisTemplate&amp;lt;String, String&amp;gt; 를 redisTemplate라는 이름으로 등록한다.&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;이렇게 하면 런타임시 스프링부트는 해당 빈을 찾아 주입을 하고, 실제 프로덕션 코드에서는 우리가 실제로 사용할 Redis를 레포지토리 레이어에서 의존성을 주입받아 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;    //   View Count 전용 StringRedisTemplate
    @Bean
    public RedisConnectionFactory viewCountRedisConnectionFactory() {
        RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
        configuration.setDatabase(0);
        return new LettuceConnectionFactory(configuration);
    }

    @Bean
    public StringRedisTemplate viewCountRedisTemplate(@Qualifier(&quot;viewCountRedisConnectionFactory&quot;) RedisConnectionFactory viewCountRedisConnectionFactory) {
        return new StringRedisTemplate(viewCountRedisConnectionFactory);
    }
    //   Comment Like 전용 StringRedisTemplate

    @Bean
    public RedisConnectionFactory commentLikeRedisConnectionFactory() {
        RedisStandaloneConfiguration configuration = new RedisStandaloneConfiguration();
        configuration.setDatabase(1);
        return new LettuceConnectionFactory(configuration);
    }

    @Bean
    public StringRedisTemplate commentLikeRedisTemplate(@Qualifier(&quot;commentLikeRedisConnectionFactory&quot;) RedisConnectionFactory commentLikeRedisConnectionFactory) {
        return new StringRedisTemplate(commentLikeRedisConnectionFactory);
    }
&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;RedisConnectionFactory를 직접 주입하면?&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;Spring이 기본적으로 관리하는 RedisConnectionFactory 가 자동으로 주입받는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&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;&lt;b&gt;Spring Boot는 기본적으로 RedisTemplate&amp;lt;String, String&amp;gt;을 redisTemplate이라는 이름으로 자동 등록하려고 한다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;하지만, &lt;b&gt;수동으로 RedisConfig를 작성하는 순간 자동 설정이 무효화된다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;그런데 RedisTemplate을 만들긴 했지만, &lt;b&gt;Spring이 기대하는 redisTemplate이라는 이름으로 등록하지 않았다.&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;그래서 Spring이 redisTemplate을 찾을 수 없어서 오류가 발생한 것!&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;b&gt;redisTemplate이라는 이름을 직접 등록하면 Spring이 이를 찾을 수 있게 된다.&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>프로젝트 트러블 슈팅 및 몰랐던점 정리/CommunityAPI</category>
      <author>cheolhyeon</author>
      <guid isPermaLink="true">https://bebetter-forme.tistory.com/97</guid>
      <comments>https://bebetter-forme.tistory.com/97#entry97comment</comments>
      <pubDate>Sun, 30 Mar 2025 15:07:46 +0900</pubDate>
    </item>
    <item>
      <title>[인프런 워밍업 클럽 스터디] 4주차 회고</title>
      <link>https://bebetter-forme.tistory.com/96</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Layered Architecture 구조의 레이어별 테스트 정리&lt;/b&gt;&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Persistence Layer&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;특징:&lt;/b&gt; 데이터를 직접 접근하고 관리하는 계층.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;테스트 방법:&lt;/b&gt; CRUD 중심으로 테스트하되, 비즈니스 로직은 포함하지 않는다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유의점:&lt;/b&gt; 테스트 수행 후 데이터 정리(clean-up)를 철저히 해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;2&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Business Layer&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;특징:&lt;/b&gt; 비즈니스 로직이 전개되는 중심 계층.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;테스트 방법:&lt;/b&gt; 통합 테스트로 비즈니스 로직의 정확성을 검증하며, 예외 상황 처리에 더 많은 집중이 필요하다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유의점:&lt;/b&gt; 예외 케이스를 잘 다루는 것이 개발자의 역량이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;3&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;Presentation Layer&lt;/b&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;특징:&lt;/b&gt; 외부 요청을 처리하는 계층.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;테스트 방법:&lt;/b&gt; 입력 값의 유효성을 검증하며, 하위 레이어는 모킹 처리하여 독립적으로 테스트한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;유의점:&lt;/b&gt; 유효성 검증이 어느 레이어에서 이루어져야 하는지 신중하게 고민해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Mocking에 대한 이해와 활용&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mock 객체를 활용하는 것이 단위 테스트의 핵심이라고 할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Mock 객체는 실제 객체의 행위를 대신할 수 있는 가짜 객체로, &lt;b&gt;Mockito&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;중요한 것은 Mock 객체를 사용할 때, 실제 객체의 동작을 완벽하게 대체한다고 생각하지 말고 &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;특히, Mock 객체의 역할과 Stub의 역할을 동시에 수행할 수 있다는 점을 배웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, Mocking이 들어가는 순간 그것은 단위 테스트라는 개념을 다시 한번 되새길 수 있었습니다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;@Mock, @MockBean, @Spy, @SpyBean, @InjectMocks의 차이&lt;/b&gt;&lt;/h3&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;@Mock:&lt;/b&gt; Mockito에서 단위 테스트를 위한 Mock 객체 생성. 행위 검증이 가능하다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@MockBean:&lt;/b&gt; Spring Boot 테스트 환경에서 Mock 객체를 사용하기 위해 Bean을 Mock으로 교체한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@Spy:&lt;/b&gt; 실제 객체를 사용하면서 일부만 Stubbing을 할 수 있다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@SpyBean:&lt;/b&gt; Spring Boot 테스트 환경에서 Bean을 Spy 객체로 교체한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@InjectMocks:&lt;/b&gt; @Mock과 @Spy로 생성된 객체를 테스트 대상 객체에 자동으로 주입해준다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;테스트 환경의 독립성 유지에 대한 고민&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트의 독립성을 유지하는 것이 얼마나 중요한지를 다시 한번 느낄 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트 데이터가 @BeforeEach로 설정되면 모든 테스트에서 공유되는 데이터로 사용될 수 있다.&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 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 data-ke-size=&quot;size16&quot;&gt;특히 예외적인 상황을 테스트하거나, 특정 요구사항을 검증할 때 명시적으로 데이터를 정의하는 것이 더 적합하다고 판단했다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Spring REST Docs와 Swagger&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring REST Docs와 Swagger는 문서화를 위한 좋은 도구들이다.&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;b&gt;Spring REST Docs:&lt;/b&gt; 코드 기반으로 문서를 자동으로 생성하며, 정확성과 신뢰도가 높다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;Swagger:&lt;/b&gt; UI 기반의 문서화가 가능하고 사용자가 직접 API를 테스트할 수 있는 장점이 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-style=&quot;style6&quot; data-ke-type=&quot;horizontalRule&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;회고와 느낀 점&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 강의를 통해 테스트의 중요성과 모킹의 활용 방법을 깊게 배울 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특히, 단위 테스트와 통합 테스트를 구분해서 작성하는 방법을 익힌 것이 큰 성과라고 생각한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;테스트 코드를 작성할 때 &lt;b&gt;각 레이어의 특징을 이해하고, 적절한 테스트 방법을 적용하는 것&lt;/b&gt;이 중요하다는 것을 느꼈다.&lt;/li&gt;
&lt;li&gt;또한, Mock 객체를 사용할 때는 항상 &lt;b&gt;&amp;lsquo;정말 잘 모킹이 되었는가?&amp;rsquo;&lt;/b&gt; 라는 의심을 가지고 테스트를 설계해야 한다고 생각한다.&lt;/li&gt;
&lt;li&gt;앞으로는 테스트를 작성할 때도 더 효율적이고 의미 있게 작성하도록 노력해봐야겠다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출처 : &lt;a href=&quot;https://www.inflearn.com/course/practical-testing-%EC%8B%A4%EC%9A%A9%EC%A0%81%EC%9D%B8-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B0%80%EC%9D%B4%EB%93%9C/dashboard&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.inflearn.com/course/practical-testing-%EC%8B%A4%EC%9A%A9%EC%A0%81%EC%9D%B8-%ED%85%8C%EC%8A%A4%ED%8A%B8-%EA%B0%80%EC%9D%B4%EB%93%9C/dashboard&lt;/a&gt;&lt;/p&gt;</description>
      <category>스터디/읽기 좋은 코드를 작성하는사고</category>
      <author>cheolhyeon</author>
      <guid isPermaLink="true">https://bebetter-forme.tistory.com/96</guid>
      <comments>https://bebetter-forme.tistory.com/96#entry96comment</comments>
      <pubDate>Sun, 30 Mar 2025 12:35:47 +0900</pubDate>
    </item>
    <item>
      <title>[트러블 슈팅] ⚠️ 읽기 전용 트랜잭션 내 쓰기 작업 문제와 REQUIRES_NEW 전파 전략 활용(self invocation)</title>
      <link>https://bebetter-forme.tistory.com/95</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;문제 상황&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;Spring Boot + Spring Data JPA 환경에서 다음과 같은 코드가 있었다&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;&lt;pre class=&quot;livescript&quot;&gt;&lt;code&gt;@Transactional(readOnly = true)
public Post readById(Long postId, Long userId) {
		PostEntity post = postRepository.findById(postId).orElseThrow(() -&amp;gt; throw new RuntimeException(&quot;...&quot;))
    viewCountService.increase(post.getPostId, userId);
    ...
}
&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;클라이언트가 게시글을 조회할 때 조회수가 증가하는 로직이다. 해당 메서드에서는 읽기 전용 @Transactional 이 발생한다.&lt;/span&gt;&lt;br&gt;&amp;nbsp;&lt;br&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;그리고 increase 메서드 내부&lt;/span&gt;&lt;/p&gt;&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;viewCountBackUpService.backUp(postId, viewCount);
&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;조회수는 Redis를 통해서 조회해오는 상황이다. Redis에서의 조회와 더불어 특정 조회수에 도달하게 되면 DB에 백업을 하기 위해서 update 및 insert 쿼리를 발생시키도록 로직을 작성했다.&lt;/span&gt;&lt;br&gt;&amp;nbsp;&lt;/p&gt;&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional
public void backUp(Long postId, Long viewCount) {
       repository.save(new ViewCountEntity(postId, viewCount)); // ❌ INSERT 쿼리 미발생
    }
}
&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;하지만 현재 save 쿼리메서드 호출 시 insert 쿼리가 발생하지 않는 문제가 발생했다.&lt;/span&gt;&lt;/p&gt;&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;문제 원인&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;해당 현상은 외부 메서드인 readById 메서드에서 읽기 전용 트랜잭션인 @Transactional(readOnly = true)가 발생했고 내부에서는 쓰기 트랜잭션이 발생했다.&lt;/span&gt;&lt;/b&gt;&lt;br&gt;&amp;nbsp;&lt;br&gt;&lt;b&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;이는 외부 메서드 readById에서 발생한 트랜잭션이 내부 메서드인 backUp까지 전파 되면서 쓰기 작업 트랜잭션이 발생하지 않은 것이다.&lt;/span&gt;&lt;/b&gt;&lt;br&gt;&amp;nbsp;&lt;br&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;&lt;b&gt;스프링의 트랜잭션 전파 기본 전략은 하나의 메서드에서 이미 발생한 트랜잭션이 존재하다면 해당 트랜잭션에 참여하는 것이 기본 전략&lt;/b&gt;인데, 이 때 외부 메서드의 읽기 전용 트랜잭션에 내부 메서드가 참여하게 되면서 &lt;b&gt;해당 로직의 전체는 읽기 전용 트랜잭션에 참여하게 되면서 쓰기 트랜잭션이 작동하지 않은 것이다.&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;789&quot; data-origin-height=&quot;475&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yMPXn/btsM1qdi2Kn/bhiQfkPr2tpygsRWN7K8Y1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yMPXn/btsM1qdi2Kn/bhiQfkPr2tpygsRWN7K8Y1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yMPXn/btsM1qdi2Kn/bhiQfkPr2tpygsRWN7K8Y1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyMPXn%2FbtsM1qdi2Kn%2FbhiQfkPr2tpygsRWN7K8Y1%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;789&quot; height=&quot;475&quot; data-origin-width=&quot;789&quot; data-origin-height=&quot;475&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;해결 방법&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;&lt;hr data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot;&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;트랜잭션 전파 전략을 REQUIRES_NEW 로 설정함으로써 외부 트랜잭션과는 별개의 트랜잭션을 생성하여 참여 해줄 수 있다.&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@Transactional(propagation = Propagation.REQUIRES_NEW)
public void backUp(Long postId, Long viewCount) {
       repository.save(new ViewCountEntity(postId, viewCount)); // INSERT 쿼리 발생
    }
}
&lt;/code&gt;&lt;/pre&gt;&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;이렇게 하면 외부 메서드에서 호출 된 읽기 트랜잭션과는 별개의 트랜잭션을 생성함으로써 문제를 해결 할 수 있다.&lt;/span&gt;&lt;br&gt;&amp;nbsp;&lt;br&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;해당 방식은 트랜잭션을 분리해줌으로써 외부 메서드의 트랜잭션과는 별개로 커밋과 롤백을 관리해준다. &lt;/span&gt;&lt;br&gt;&amp;nbsp;&lt;br&gt;&lt;b&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;즉 외부 메서드에서 에러가 발생해도 내부 트랜잭션까지는 전파가 안되므로 내부 트랜잭션은 마치 별도의 트랜잭션 처럼 작동하며 커밋과 롤백을 보장해준다는 장점이 있다.&lt;/span&gt;&lt;/b&gt;&lt;br&gt;&amp;nbsp;&lt;br&gt;&lt;u&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;&lt;b&gt;단점으로는 커넥션 풀 부족 및 커넥션의 누수를 야기시킬 수 있다.&lt;/b&gt; &lt;b&gt;하나의 요청에 동시에 많은 트랜잭션이 발생하면 커넥션 풀이 부족해질 수 있으며, 트랜잭션이 끝나지 않고 있다면 커넥션 풀에 반환되지 않기 때문에 누수를 야기할 수 있다.&lt;/b&gt;&lt;/span&gt;&lt;/u&gt;&lt;br&gt;&amp;nbsp;&lt;br&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;새로운 트랜잭션을 만든다는 것은 새로운 DB 커넥션을 커넥션 풀에 꺼내서 사용하게 되는 것이고, 기존 외부 메서드에서 발생한 트랜잭션을 보류가 되기 때문에 누수가 될 수 있다. 하나의 요청안에서 추가적인 N개의 트랜잭션을 새롭게 발생하게 되면 N개의 커넥션을 사용하게 되는 것이다.&lt;/span&gt;&lt;br&gt;&amp;nbsp;&lt;br&gt;&lt;b&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;이는 대량 요청시 병목 현상이 발생하고 전체 성능이 저하된다.&lt;/span&gt;&lt;/b&gt;&lt;br&gt;&lt;b&gt;&lt;span style=&quot;font-family: Nanum Gothic;&quot;&gt;그래서 대용량 트래픽 환경에서는 해당 방식 보다는 트랜잭션을 분리해서 우회하는 방식을 통해 해결하는 방식이 더 바람직한 방법이 될 것 같다.&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;</description>
      <category>프로젝트 트러블 슈팅 및 몰랐던점 정리/CommunityAPI</category>
      <category>Propagation</category>
      <category>REQUIRES_NEW</category>
      <category>트랜잭션</category>
      <category>트랜잭션 작동안함</category>
      <category>트랜잭션 전파</category>
      <author>cheolhyeon</author>
      <guid isPermaLink="true">https://bebetter-forme.tistory.com/95</guid>
      <comments>https://bebetter-forme.tistory.com/95#entry95comment</comments>
      <pubDate>Sun, 30 Mar 2025 01:07:39 +0900</pubDate>
    </item>
  </channel>
</rss>