<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>Goyummy</title>
    <link>https://iwbdev.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Tue, 21 Jul 2026 09:32:59 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>gogi masidda</managingEditor>
    <image>
      <title>Goyummy</title>
      <url>https://tistory1.daumcdn.net/tistory/5338317/attach/d135aa34d5dd4a3d85e83aabdb53ac82</url>
      <link>https://iwbdev.tistory.com</link>
    </image>
    <item>
      <title>브라우저 자동화를 이용한 구독 자동 취소 구현하기</title>
      <link>https://iwbdev.tistory.com/entry/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EC%9E%90%EB%8F%99%ED%99%94%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EA%B5%AC%EB%8F%85-%EC%9E%90%EB%8F%99-%EC%B7%A8%EC%86%8C-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bsJD1e/dJMcabwvRrT/4hBcn4y1n93B26H0WeJ5B0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bsJD1e/dJMcabwvRrT/4hBcn4y1n93B26H0WeJ5B0/img.png&quot; data-origin-width=&quot;820&quot; data-origin-height=&quot;761&quot; data-is-animation=&quot;false&quot; width=&quot;526&quot; height=&quot;488&quot; style=&quot;width: 40.596%; margin-right: 10px;&quot; data-widthpercent=&quot;41.07&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bsJD1e/dJMcabwvRrT/4hBcn4y1n93B26H0WeJ5B0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbsJD1e%2FdJMcabwvRrT%2F4hBcn4y1n93B26H0WeJ5B0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;820&quot; height=&quot;761&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/XH1bV/dJMcahDuqG1/wAZpdKw3qI0hAZv5KjOtF0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/XH1bV/dJMcahDuqG1/wAZpdKw3qI0hAZv5KjOtF0/img.png&quot; data-origin-width=&quot;1314&quot; data-origin-height=&quot;850&quot; data-is-animation=&quot;false&quot; style=&quot;width: 58.2412%;&quot; data-widthpercent=&quot;58.93&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/XH1bV/dJMcahDuqG1/wAZpdKw3qI0hAZv5KjOtF0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FXH1bV%2FdJMcahDuqG1%2FwAZpdKw3qI0hAZv5KjOtF0%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;1314&quot; height=&quot;850&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;봄봄은 뉴스레터 구독 목록을 모아주는 서비스이기 때문에,&amp;nbsp;&lt;b&gt;구독 관리의 편리함&lt;/b&gt;을 제공해야 합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;'둘러보기' 페이지에서 위 이미지와 같이 각 뉴스레터의 구독 여부와 구독 뉴스레터 목록을 제공하고 있습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;하지만, 사용자가 원할 때 그만 보고 싶은 뉴스레터의 구독을 취소할 수 있는 기능은 존재하지 않았습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;물론 서비스 내에서 제공하는 기능이 아닌 아티클 내부의 '수신 거부' 링크를 이용할 수 있었지만, &lt;b&gt;서비스 내에서 실제 구독 해지 여부를 추적하거나 관리할 수 없다는 문제&lt;/b&gt;가 있었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;구독 취소 자동화 기능은 사용자의 전반적인 쉬운 구독 관리 경험을 위해서도, 서비스의 구독 여부 추적을 위해서도 필요한 기능이었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;구독 취소 자동화를 구현하기 위한 사전 작업&lt;/span&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;아티클 내부에서 구독 취소 URL 파싱하기&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;대부분의 아티클 본문 하단에는 '구독 취소', '구독 해지', '수신 거부' 등의 텍스트로 구독 취소 링크가 포함되어 있습니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;해당 링크의 &lt;b&gt;HTML 태그에 &quot;unsubscribe&quot; 속성이 포함&lt;/b&gt;되어 있음을 확인하여, 아티클 수신 시마다 &lt;b&gt;정규식으로 href 속성에서 구독 취소 URL을 추출해 업데이트&lt;/b&gt;하도록 구현했습니다.&lt;/span&gt;&lt;br /&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&amp;nbsp;&amp;nbsp;&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;구독 취소 페이지 케이스 정리&lt;/span&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/I3I8I/dJMcajnIUDK/AT1Z9wchr3lQnwe9v9Msq0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/I3I8I/dJMcajnIUDK/AT1Z9wchr3lQnwe9v9Msq0/img.png&quot; data-origin-width=&quot;518&quot; data-origin-height=&quot;257&quot; data-is-animation=&quot;false&quot; style=&quot;width: 65.3839%; margin-right: 10px;&quot; data-widthpercent=&quot;66.15&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/I3I8I/dJMcajnIUDK/AT1Z9wchr3lQnwe9v9Msq0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FI3I8I%2FdJMcajnIUDK%2FAT1Z9wchr3lQnwe9v9Msq0%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;518&quot; height=&quot;257&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bnNyNc/dJMcaaRWOrw/GzvUI8WQFTZEqlwiZAR7j0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bnNyNc/dJMcaaRWOrw/GzvUI8WQFTZEqlwiZAR7j0/img.png&quot; data-origin-width=&quot;231&quot; data-origin-height=&quot;224&quot; data-is-animation=&quot;false&quot; style=&quot;width: 33.4533%;&quot; data-widthpercent=&quot;33.85&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bnNyNc/dJMcaaRWOrw/GzvUI8WQFTZEqlwiZAR7j0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbnNyNc%2FdJMcaaRWOrw%2FGzvUI8WQFTZEqlwiZAR7j0%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;231&quot; height=&quot;224&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bUNt7S/dJMcadgN5i6/eX3mDDcVVPTT91ytKH5ur1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bUNt7S/dJMcadgN5i6/eX3mDDcVVPTT91ytKH5ur1/img.png&quot; data-origin-width=&quot;550&quot; data-origin-height=&quot;360&quot; data-is-animation=&quot;false&quot; style=&quot;width: 33.6929%; margin-right: 10px;&quot; data-widthpercent=&quot;34.09&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bUNt7S/dJMcadgN5i6/eX3mDDcVVPTT91ytKH5ur1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbUNt7S%2FdJMcadgN5i6%2FeX3mDDcVVPTT91ytKH5ur1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;550&quot; height=&quot;360&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GdVcx/dJMcaiCovFS/ZkHSMaMAiE4YoKszfz63YK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GdVcx/dJMcaiCovFS/ZkHSMaMAiE4YoKszfz63YK/img.png&quot; data-origin-width=&quot;641&quot; data-origin-height=&quot;217&quot; data-is-animation=&quot;false&quot; style=&quot;width: 65.1443%;&quot; data-widthpercent=&quot;65.91&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GdVcx/dJMcaiCovFS/ZkHSMaMAiE4YoKszfz63YK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGdVcx%2FdJMcaiCovFS%2FZkHSMaMAiE4YoKszfz63YK%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;641&quot; height=&quot;217&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;뉴스레터 발행사마다 발행을 위해 이용하는 서비스가 다르고, 자체 서비스로 발행하는 곳도 있습니다. 그렇게 때문에 위 이미지와 같이 구독 취소 페이지가 다양하게 구성되어 있습니다. 물론 구독 취소를 하는 표준 API도 없습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;그렇기 때문에 &lt;b&gt;브라우저 자동화 툴을 사용하여 구독 취소 자동화를 구현&lt;/b&gt;해야 했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;구현 전략: Saga 패턴을 이용한 트랜잭션 분리&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;구독 취소는 &lt;b&gt;외부 시스템과 통신하며 건당 5~15초가 소요&lt;/b&gt;되는 브라우저 자동화 작업입니다. 이를 사용자의 API 요청 트랜잭션 범위 내에서 처리하면 &lt;b&gt;긴 대기 시간뿐만 아니라 DB 커넥션 점유로 인한 시스템 가용성 저하 문제&lt;/b&gt;가 발생할 수 있습니다. 따라서 외부 요청의 특성을 고려해 &lt;b&gt;비동기 처리와 트랜잭션 분리를 설계의 기본 방향&lt;/b&gt;으로 정했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;작업을 독립적인 단계로 쪼개고 즉시 응답하는&amp;nbsp;&lt;b&gt;이벤트 기반 Saga 패턴&lt;/b&gt;을 도입했습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;핵심은&amp;nbsp;&lt;b&gt;외부 시스템과의 통신 시간 동안 DB 커넥션을 들고 있지 않도록 트랜잭션 경계를 명확히 나누는 것&lt;/b&gt;이었습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;5105&quot; data-origin-height=&quot;2930&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bIiyN8/dJMb996ylzb/zaEzXGUd7G5lbWBrrK4PHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bIiyN8/dJMb996ylzb/zaEzXGUd7G5lbWBrrK4PHK/img.png&quot; data-alt=&quot;전체 흐름도&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bIiyN8/dJMb996ylzb/zaEzXGUd7G5lbWBrrK4PHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbIiyN8%2FdJMb996ylzb%2FzaEzXGUd7G5lbWBrrK4PHK%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;5105&quot; height=&quot;2930&quot; data-origin-width=&quot;5105&quot; data-origin-height=&quot;2930&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;전체 흐름도&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1794&quot; data-origin-height=&quot;1680&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/IddAW/dJMcabQOJjB/nNCkI3fFPyLlS2XHsoBZL0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/IddAW/dJMcabQOJjB/nNCkI3fFPyLlS2XHsoBZL0/img.png&quot; data-alt=&quot;전체 상태 전이 표&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/IddAW/dJMcabQOJjB/nNCkI3fFPyLlS2XHsoBZL0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FIddAW%2FdJMcabQOJjB%2FnNCkI3fFPyLlS2XHsoBZL0%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;596&quot; height=&quot;558&quot; data-origin-width=&quot;1794&quot; data-origin-height=&quot;1680&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;전체 상태 전이 표&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;1. 중간 상태(UNSUBSCRIBING)&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;사용자의 요청이 들어오면 우선 DB의 상태를 'UNSUBSCRIBING'으로 변경합니다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이는 일종의&amp;nbsp;&lt;b&gt;소프트 락(Soft Lock)&lt;/b&gt; 역할을 하여, 실제 자동화 작업이 진행되는 동안 발생할 수 있는 &lt;b&gt;중복 요청을 방지&lt;/b&gt;하고&lt;b&gt; 멱등성을 보장&lt;/b&gt;합니다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;또한, 사용자는 &quot;취소 중&quot;이라는 명확한 피드백을 즉시 받게 되어 긴 대기를 하지 않아도 됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;user-content-3-트랜잭션의-연쇄chaining와-독립성&quot; style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;2. 분산 트랜잭션&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;전체 과정은 두 개의 독립된 트랜잭션으로 나뉩니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;첫 번째 트랜잭션&lt;/b&gt;: 사용자의 요청을 받고 &lt;b&gt;상태를 'UNSUBSCRIBING'&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;b&gt;으로 변경 후 즉시 응답.&lt;/b&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;두 번째 트랜잭션&lt;/b&gt;: &lt;b&gt;외부 자동화(Lambda)&lt;/b&gt; 완료 후, 그 결과에 따라 데이터를 삭제하거나 'FAILED'&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;상태로 업데이트.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 두 트랜잭션 사이, Lambda 호출 구간에는 &lt;b&gt;열린 트랜잭션이 없어 DB 커넥션을 점유하지 않습니다.&lt;/b&gt; 다만 추후 다른 컨텍스트에서 Lambda 호출 메서드가 &lt;b&gt;트랜잭션 안에서 잘못 호출되는 상황을 방어하기 위해&lt;/b&gt;, &lt;b&gt;Propagation.NOT_SUPPORTED&lt;/b&gt;를 명시적으로 선언해 &lt;b&gt;커넥션이 점유되지 않도록 보장&lt;/b&gt;했습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이 구조를 통해 외부 요청-응답 속도가 전체 API 서버의 성능을 저하시키는 문제를 해결했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt; 또한 비동기 작업 전용 스레드풀을 별도로 분리하여, 브라우저 자동화 작업이 API 서버의 기본 스레드풀을 점유하지 않도록 리소스를 격리했습니다. &lt;/span&gt;&lt;/p&gt;
&lt;h4 id=&quot;user-content-3단계-자동-재시도를-통한-회복탄력성-확보&quot; style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;3. 예외 처리 및 자동 재시도 설계&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;자동화 로직에서 발생하는 예외는 성격에 따라 두 가지로 구분하여 처리합니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;RetryableException&amp;nbsp;(재시도 가능)&lt;/b&gt;: 일시적 네트워크 오류, 페이지 타임아웃 등 외부 환경 불안정으로 인한 실패입니다. &lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이 경우에는 UnsubscribeRetry&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt; 테이블&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;에 재시도 일정을 기록하고,&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;5분 주기 스케줄러&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;가 자동으로 감지해 최대 3회까지 재실행합니다. &lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&lt;b&gt;모든 재시도에도 실패할 경우에는 4단계 보상 트랜잭션으로&lt;/b&gt; 넘어갑니다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;AutoUnsubscribeFailedException (영구 실패)&lt;/b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;: 버튼/링크를 찾지 못하거나 HTTP 4xx 에러를 받는 경우입니다. &lt;/span&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;미리 설정해둔 버튼/링크에 대한 정규식이 해당 케이스를 커버하지 못하거나, 우리 서버에서 요청값이 부적절한 경우입니다. 따라서 &lt;b&gt;재시도해도 자동 취소가 성공할 확률이 낮다&lt;/b&gt;고 판단했습니다. 따라서, &lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;이 경우에는 &lt;b&gt;4단계 보상 트랜잭션으로&lt;/b&gt; 넘어갑니다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;이처럼, 예외 타입을 분리함으로써 일시적 실패와 영구 실패를 구별하고, 불필요한 재시도 비용을 최소화했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;user-content-3단계-실패에-대응하는-보상-트랜잭션-forward-compensation&quot; style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;4. 보상 트랜잭션&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;분산 트랜잭션에서 예외가 발생할 경우에는 원자적인 롤백이 불가능&lt;/b&gt;합니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이에 따라 보상 트랜잭션을 통해 기존 상태로 돌리는 방법을 택할 수도 있습니다. 하지만, 구독 취소 자동화가 실패하는 경우는 패턴 매칭에 실패하거나, 여러번의 재시도에도 모두 실패하는 경우입니다. 이 경우들은 사용자가 자동 취소를 다시 요청하여도 실패할 가능성이 크다고 생각했습니다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;그래서 실패 시 이전 상태로 되돌리는 대신, '&lt;b&gt;FAILED' 상태로 갱신하여 사용자의 수동 취소를 유도하는 방향으로&lt;/b&gt;&amp;nbsp;&lt;b&gt;보상 트랜잭션 로직&lt;/b&gt;을 설계했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;실패 기록&lt;/b&gt;: 실패 흔적을 FAILED로 기록하여 투명하게 관리하고, 사용자의 구독 수동 취소를 유도했습니다.&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;아래 이미지에서 볼 수 있듯이, '직접 취소하기' 버튼을 통해 직접 취소할 수 있도록 했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1074&quot; data-origin-height=&quot;397&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Odukx/dJMcaf6M7od/N7PIBNzP8FMgo03mOcd4wk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Odukx/dJMcaf6M7od/N7PIBNzP8FMgo03mOcd4wk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Odukx/dJMcaf6M7od/N7PIBNzP8FMgo03mOcd4wk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOdukx%2FdJMcaf6M7od%2FN7PIBNzP8FMgo03mOcd4wk%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;1074&quot; height=&quot;397&quot; data-origin-width=&quot;1074&quot; data-origin-height=&quot;397&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;자동 재시도와 알림&lt;/b&gt;: 네트워크 오류 등 일시적 실패는&amp;nbsp;&lt;b&gt;5분 주기 스케줄러&lt;/b&gt;가 자동으로 감지해 최대 3회까지 재처리합니다. 최종 실패 시에는&amp;nbsp;&lt;b&gt;Discord&amp;nbsp;알림&lt;/b&gt;을 통해 관리자가 즉시 인지하고 수동 대응할 수 있도록 했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;338&quot; data-origin-height=&quot;302&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bmZwaR/dJMcacvsDyi/1zUPNmDEqK9cKrFtCCoi01/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bmZwaR/dJMcacvsDyi/1zUPNmDEqK9cKrFtCCoi01/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bmZwaR/dJMcacvsDyi/1zUPNmDEqK9cKrFtCCoi01/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbmZwaR%2FdJMcacvsDyi%2F1zUPNmDEqK9cKrFtCCoi01%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;338&quot; height=&quot;302&quot; data-origin-width=&quot;338&quot; data-origin-height=&quot;302&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;5. &lt;span style=&quot;color: #000000;&quot;&gt;비동기 결과를 화면에 적용&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;구독 취소 자동화는 비동기로 이루어지기 때문에, 클라이언트에서는 새로고침 없이는 해당 작업이 끝났는지, 성공했는지 실패했는지를 알 수 없습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&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;b&gt;&lt;span style=&quot;color: #000000;&quot;&gt;고려한 방안&lt;/span&gt;&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로 저희는 각 방식의 장단점과 시스템 아키텍처 결합도를 분석하여, 최종적으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;단기 폴링(Short Polling)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;방식을 채택했습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;❌ SSE (Server-Sent Events) / &lt;span style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot;&gt;WebSocket&lt;/span&gt; &lt;/b&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;: 비동기 결과를 가볍게 밀어넣기에는 기술적으로 적합했습니다. 하지만 다중 인스턴스 환경에서 이벤트를 특정 클라이언트로 라우팅하려면 Redis Pub/Sub 같은 메시지 브로커와 같은 다중 인스턴스간 동기화하는 로직이 필요했습니다.&amp;nbsp;&lt;/li&gt;
&lt;li&gt;WebSocket도 같은 맥락에서 부적절하다고 판단했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;✅ Polling (단기 폴링)&lt;/b&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;: 별도의 인프라(Redis 등) 추가 없이 기존 HTTP 아키텍처를 그대로 유지할 수 있는 가장 직관적인 방식이었습니다. 웹 플랫폼 위주인 현재 상황에서, 사용자가 당장 바라보고 있는 화면을 갱신하는 데 가장 빠르고 확실한 방법이었습니다. 또한 프론트엔드 최적화(활성화되지 않은 탭에서는 폴링 일시 정지, 최대 제한)를 통해 폴링의 단점인 리소스 낭비 우려도 제어할 수 있다고 판단했습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;⏳ FCM 푸시를 통한 알림 및 서비스 내 알림창 (추후 도입 고려)&lt;/b&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;: 서버 ➡️ 앱 외부(푸시) 또는 웹 내 알림(Notification)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;보류 사유&lt;/b&gt;: 알림 자체는 비동기 결과를 확실하게 전달할 수 있는 좋은 수단입니다. 특히 &lt;b&gt;여러 번 재시도(Retry)하여 시간이 오래 걸리는 케이스&lt;/b&gt;나, 최종 실패하여&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;사용자의 수동 취소가 필요한 경우&lt;/b&gt;에 자연스럽게 안내하기 좋은 방법이라고 생각했습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;현재 기각 이유&lt;/b&gt;: 현재 저희 서비스는 앱보다&lt;span&gt;&amp;nbsp;&lt;/span&gt;웹(Web)&lt;span&gt;&amp;nbsp;&lt;/span&gt;사용자의 비율이 높습니다. 웹 환경에서는 앱 푸시(FCM)의 도달률과 효과가 떨어지기 때문에 현시점에서는 도입 대비 효용이 크지 않다고 판단했습니다. &lt;br /&gt;추후 웹 내 알림창(Notification Center)과 함께 도입하여 롱테일 비동기 작업의 결과를 보완하는 방향으로 남겨두었습니다.&lt;/li&gt;
&lt;/ul&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;&lt;span style=&quot;color: #000000;&quot;&gt;인프라 설계: Playwright + AWS Lambda&lt;/span&gt;&lt;/h2&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;브라우저 자동화 툴 선정: Selenium vs Playwright&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;고려한 툴은 Selenium과 Playwright 입니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;복잡한 웹을 가장 빠르고 안정적으로 제어할 수 있다는 점이 Playwright를 선택한 결정적 이유&lt;/b&gt;였습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px; color: #000000;&quot;&gt;Selenium과 Playwright에 대한 비교는 아래에 담아뒀습니다.&lt;/span&gt;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Selenium&lt;/span&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;span style=&quot;color: #000000;&quot;&gt;오래된 표준 도구&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;레퍼런스 많음&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;다양한 언어/브라우저 지원&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;표준 프로토콜 &amp;rArr; 호환성이 좋음&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;명령 하나하나가 HTTP 요청으로 전송&lt;/b&gt;되어 오버헤드가 큼&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;페이지 로딩 타이밍을 잘 못 맞춰서 Element not found 에러가 빈번함 개발자가 직접 Wait 코드를 붙여야 함&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;브라우저 버전이 업데이트 될 때마다 드라이버 바이너리도 같이 업데이트해줘야 함&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Playwright&lt;/span&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;span style=&quot;color: #000000;&quot;&gt;2020년에 MS가 공개함&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;복잡한 모던 웹 애플리케이션을 빠르고 안정적으로 제어함&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;WebSocket을 통해 브라우저 엔진(CDP)와 직접 통신하여&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;지연이 거의 없음&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;버튼 클릭이 가능해질 때까지&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;스스로 기다림. &amp;rArr; 코드 양이 크게 줄고 안정성이 높음&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;라이브러리가 전용 브라우저 바이너리를 관리하여&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;버전 충돌 문제 없음&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;실행 과정 비디오 녹화, 네트워크 요청 가로채는 기능이 기본 내장되어&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;디버깅 쉬움&lt;/b&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;단점&lt;/b&gt;&lt;/span&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;상대적으로 적은 자료&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;구형 브라우저 미지원&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;605&quot; data-origin-height=&quot;278&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ck5M3k/dJMcag5GvFq/u2KDgCDMwgOqk37UuvNpdk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ck5M3k/dJMcag5GvFq/u2KDgCDMwgOqk37UuvNpdk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ck5M3k/dJMcag5GvFq/u2KDgCDMwgOqk37UuvNpdk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fck5M3k%2FdJMcag5GvFq%2Fu2KDgCDMwgOqk37UuvNpdk%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;605&quot; height=&quot;278&quot; data-origin-width=&quot;605&quot; data-origin-height=&quot;278&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;AWS Lambda로 분리한 이유&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff; color: #000000; text-align: start;&quot;&gt;앞에 설명드린대로 구현 후에, EC2에 배포를 시도했습니다. 하지만 &lt;b&gt;Playwright는 Chromium 등 수백 MB에 달하는 브라우저 바이너리를 직접 관리&lt;/b&gt;하기 때문에 Docker 이미지가 크게 불어났고, &lt;b&gt;초기 배포 시에 설치도 서버에 큰 부담&lt;/b&gt;이었습니다. 또한, &lt;b&gt;실행 시점마다 CPU와 메모리를 크게 점유&lt;/b&gt;하여 같은 컨테이너에서 동작하는 API 서버의 성능에 영향을 주었습니다. 브라우저 프로세스와 API 서버가 자원을 공유한다는 구조적 문제였습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Playwright와 거대한 브라우저 바이너리(Chromium 등)를 이미지에 포함하니 서버 용량이 급증했고, 실행 시 발생하는 CPU와 메모리 점유율이 API 서버의 성능을 갉아먹는 현상이 발생했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이 문제를 해결하기 위해 무거운 브라우저 로직을 &lt;b&gt;AWS Lambda&lt;/b&gt;로 완전히 격리했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;5152&quot; data-origin-height=&quot;1130&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bAC2Rn/dJMcaaRWPcZ/yDkf9IZdEk4r5AbPKrcO51/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bAC2Rn/dJMcaaRWPcZ/yDkf9IZdEk4r5AbPKrcO51/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bAC2Rn/dJMcaaRWPcZ/yDkf9IZdEk4r5AbPKrcO51/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbAC2Rn%2FdJMcaaRWPcZ%2FyDkf9IZdEk4r5AbPKrcO51%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;5152&quot; height=&quot;1130&quot; data-origin-width=&quot;5152&quot; data-origin-height=&quot;1130&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;이미지 최적화&lt;/b&gt;: &lt;b&gt;API 서버에서 모든 브라우저 자동화 관련 의존성을 제거&lt;/b&gt;하여 부팅 속도와 용량을 개선했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt; &lt;b&gt;병렬 처리&lt;/b&gt;: 각 구독 취소 요청이 독립된 Lambda 인스턴스에서 처리되므로, 요청이 몰리는 상황에서도 API 서버에 영향을 주지 않고 안전하게 병렬 처리됩니다. &lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;커넥션 보호&lt;/b&gt;: 앞서 설명한 Propagation.NOT_SUPPORTED로 커넥션을 보호했습니다.&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Lambda 내부 휴리스틱 로직 기반 성공 판별&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;앞서 언급했듯 사이트마다 UI가 제각각이기 때문에, Lambda는 단순히 버튼을 클릭하는 것을 넘어 다음 세 가지 채널을 동시에 분석하는&amp;nbsp;&lt;b&gt;휴리스틱(Heuristic) 방식&lt;/b&gt;으로 성공을 판별합니다.&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;텍스트 매칭&lt;/b&gt;: 페이지 본문에서 'success', 'unsubscribed', '완료', '되었습니다' 등 성공 키워드를 탐지&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;네트워크 응답 분석&lt;/b&gt;: XHR/Fetch 요청 중 unsubscribe&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;경로의&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;2xx&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;응답을 가로채 성공으로 판정&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;URL 변경 감지&lt;/b&gt;: 취소 버튼 클릭 후 페이지가 이동되면 취소 완료로 판단&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이 중 하나라도 충족되면 성공으로 처리합니다. 물론 모든 사이트에서 완벽히 동작한다는 보장은 없습니다. 엣지 케이스가 발견될 때마다 패턴을 보완하는 방식으로 점진적으로 개선해나가고 있습니다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 id=&quot;user-content-개발자가-관리해야-하는-것-unsubscribepattern&quot; style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;개발자가 관리해야 하는 리소스: UnsubscribePattern&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;Lambda는 버튼과 성공 문구를 찾을 때 &lt;b&gt;정규식(Regex) 패턴을 사용&lt;/b&gt;합니다. &lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;이 패턴들은 UnsubscribePattern&amp;nbsp;테이블에 key-value 형태로 저장되어, DB에서 읽어와 Lambda에 전달됩니다.&lt;/span&gt;&lt;/p&gt;
&lt;div style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot;&gt;
&lt;table style=&quot;color: #000000; border-collapse: collapse; width: 63.9555%; height: 79px;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style13&quot;&gt;
&lt;tbody&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 20px; width: 23.0233%;&quot;&gt;
&lt;div&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;&lt;span style=&quot;text-align: start;&quot;&gt;patternKey&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/td&gt;
&lt;td style=&quot;text-align: center; height: 20px; width: 37.3484%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;&lt;span style=&quot;text-align: start;&quot;&gt;patternValue&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt; (예)&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 19px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 19px; width: 23.0233%;&quot;&gt;
&lt;div&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: -apple-system, BlinkMacSystemFont, 'Helvetica Neue', 'Apple SD Gothic Neo', Arial, sans-serif; letter-spacing: 0px;&quot;&gt;unsubscribe&lt;/span&gt;&lt;/b&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/td&gt;
&lt;td style=&quot;text-align: left; height: 19px; width: 37.3484%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;unsubscribe|구독.?취소|해지|수신.?거부&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 20px; width: 23.0233%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;success&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: left; height: 20px; width: 37.3484%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;success|unsubscribed|완료|되었습니다&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr style=&quot;height: 20px;&quot;&gt;
&lt;td style=&quot;text-align: center; height: 20px; width: 23.0233%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;error&lt;/b&gt;&lt;/span&gt;&lt;/td&gt;
&lt;td style=&quot;text-align: left; height: 20px; width: 37.3484%;&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;error|failed|실패|오류&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;/div&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;새로운 뉴스레터 서비스가 생기거나, 기존 사이트의 UI가 변경되어 &lt;b&gt;인식률이 떨어지면, 패턴을 업데이트&lt;/b&gt;해야 합니다. 이를 코드 배포 없이 즉시 반영할 수 있도록&amp;nbsp;&lt;b&gt;어드민 관리 페이지&lt;/b&gt;를 구축했습니다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;959&quot; data-origin-height=&quot;473&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/L5xxf/dJMcaa5suK2/qrS1JWuf3q7r51W8e2Bmfk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/L5xxf/dJMcaa5suK2/qrS1JWuf3q7r51W8e2Bmfk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/L5xxf/dJMcaa5suK2/qrS1JWuf3q7r51W8e2Bmfk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FL5xxf%2FdJMcaa5suK2%2FqrS1JWuf3q7r51W8e2Bmfk%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;959&quot; height=&quot;473&quot; data-origin-width=&quot;959&quot; data-origin-height=&quot;473&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;최종 실패 시에는&lt;/span&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Discord Webhook&lt;/b&gt;&lt;span style=&quot;text-align: start;&quot;&gt;&amp;nbsp;&lt;/span&gt;&lt;span style=&quot;background-color: #ffffff; text-align: start;&quot;&gt;알림이 발송되어 &lt;b&gt;관리자가 상황을 인지하고 패턴을 수정하는 방식으로 대응&lt;/b&gt;합니다. 완벽하지 않지만, &lt;b&gt;엣지 케이스를 발견할 때마다 빠르게 보완할 수 있는 구조&lt;/b&gt;를 목표로 했습니다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;패턴 수정이 필요할 때마다 배포를 거치는 것은 비효율적입니다. 어드민의&amp;nbsp;'리소스 관리'&amp;nbsp;메뉴에서 패턴을 실시간으로 조회하고 수정할 수 있습니다. &lt;b&gt;Lambda 코드 역시 어드민에서 직접 수정하고 ECR에 배포할 수 있도록 구성&lt;/b&gt;했습니다.&lt;/span&gt;&lt;/p&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;&lt;span style=&quot;color: #000000;&quot;&gt;마무리&lt;/span&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #616161; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;서버 가용성 확보&lt;/b&gt;: 15초가 소요되는 외부 작업을 비동기 분산 처리(Saga 패턴, Lambda)하여 API 응답 시간이 0.1초 수준으로 유지되도록 설계했습니다.&lt;/span&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #000000;&quot;&gt;&lt;b&gt;적정 기술 도입&lt;/b&gt;: 비동기 처리에 따른 결과 지연(UX) 문제는 오버엔지니어링(SSE)을 지양하고, 복잡도가 낮고 제어 가능한 폴링을 채택해 해결했습니다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;color: #000000;&quot;&gt;참고&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://playwright.dev/java/docs/browser-contexts&quot;&gt;https://playwright.dev/java/docs/browser-contexts&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://ray.run/blog/migrating-from-selenium-to-playwright&quot;&gt;https://ray.run/blog/migrating-from-selenium-to-playwright&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.baeldung.com/orkes-conductor-saga-pattern-spring-boot&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.baeldung.com/orkes-conductor-saga-pattern-spring-boot&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://refactoring.guru/ko/design-patterns/state&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://refactoring.guru/ko/design-patterns/state&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-Polling-Long-Polling-Server-Sent-Event-WebSocket-%EC%9A%94%EC%95%BD-%EC%A0%95%EB%A6%AC&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-Polling-Long-Polling-Server-Sent-Event-WebSocket-%EC%9A%94%EC%95%BD-%EC%A0%95%EB%A6%AC&lt;/a&gt;&lt;/p&gt;</description>
      <category>BomBom 프로젝트</category>
      <category>LAMBDA</category>
      <category>playwright</category>
      <category>Saga 패턴</category>
      <category>보상 트랜잭션</category>
      <category>봄봄</category>
      <author>gogi masidda</author>
      <guid isPermaLink="true">https://iwbdev.tistory.com/492</guid>
      <comments>https://iwbdev.tistory.com/entry/%EB%B8%8C%EB%9D%BC%EC%9A%B0%EC%A0%80-%EC%9E%90%EB%8F%99%ED%99%94%EB%A5%BC-%EC%9D%B4%EC%9A%A9%ED%95%9C-%EA%B5%AC%EB%8F%85-%EC%9E%90%EB%8F%99-%EC%B7%A8%EC%86%8C-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0#entry492comment</comments>
      <pubDate>Thu, 5 Mar 2026 18:05:43 +0900</pubDate>
    </item>
    <item>
      <title>봄봄 &amp;lsquo;이달의 독서왕&amp;rsquo; 랭킹: 데드락 원인 분석과 ShedLock 적용기</title>
      <link>https://iwbdev.tistory.com/entry/%EB%B4%84%EB%B4%84-%E2%80%98%EC%9D%B4%EB%8B%AC%EC%9D%98-%EB%8F%85%EC%84%9C%EC%99%95%E2%80%99-%EB%9E%AD%ED%82%B9-%EB%8D%B0%EB%93%9C%EB%9D%BD-%EC%9B%90%EC%9D%B8-%EB%B6%84%EC%84%9D%EA%B3%BC-ShedLock-%EC%A0%81%EC%9A%A9%EA%B8%B0</link>
      <description>&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;배경&lt;/b&gt;&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;이달의 독서왕 랭킹 시스템 설계와 구현&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;동기부여 장치로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;&amp;lsquo;이달의 독서왕&amp;rsquo; 월간 랭킹&lt;/b&gt;을 도입했습니다. 랭킹은 사용자의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;월간 읽기 개수&lt;/b&gt;를 기준으로 계산되며,&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;상위 10명의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;닉네임&amp;middot;월간 읽기 개수&lt;/b&gt;를 공개해 성취감을 제공하고,&lt;/li&gt;
&lt;li&gt;모든 사용자에게&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;현재 순위, 월간 읽기 개수, 바로 앞 순위와의 격차&lt;/b&gt;를 보여 &amp;ldquo;조금만 더 읽으면 순위를 올릴 수 있다&amp;rdquo;는 즉각적인 동기를 제공합니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;운영 방안은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;10분 주기 배치 갱신&lt;/b&gt;입니다. 요청 시점마다 실시간 계산을 수행하면 데이터베이스 부하가 크기 때문에,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Spring Scheduler&lt;/b&gt;의&lt;span&gt;&amp;nbsp;&lt;/span&gt;@Scheduled로 10분마다 일괄 집계합니다. 순위와 격차 계산은 애플리케이션에서 반복 처리하지 않고, **데이터베이스(윈도우 함수&amp;middot;집계 쿼리)**로 일괄 계산해 네트워크 왕복을 줄이고 일관성을 확보했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;스케줄러 예시 코드&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;pgsql&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@Scheduled(cron = &quot;0 */10 * * * *&quot;, zone = &quot;Asia/Seoul&quot;)
public void tenMinutelyCalculateMemberRank() {
    log.info(&quot;이달의 독서왕 순위 업데이트&quot;);
    readingService.updateMonthlyRanking();
    log.info(&quot;이달의 독서왕 순위 업데이트 완료&quot;);
}
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 구조로 사용자는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;항상 10분 이내의 최신 랭킹&lt;/b&gt;을 확인할 수 있고, 시스템은 불필요한 연산 부하를 피하면서 안정적으로 집계할 수 있습니다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;분산 환경이란&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;분산 환경&lt;/b&gt;은 하나의 시스템을 여러 서버로 나누어 운영하는 구조입니다. 단일 서버가 모든 요청을 처리하는 방식과 달리 여러 서버가 네트워크를 통해 협력하므로, 트래픽 증가 시&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;수평 확장&lt;/b&gt;이 가능하고 일부 서버 장애에도 서비스 전체가 중단되지 않습니다. 다만 동일 데이터베이스를 공유하면&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;동시성 제어와 데이터 일관성&lt;/b&gt;이 중요 과제가 됩니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;봄봄은 전면에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;로드 밸런서&lt;/b&gt;가, 후면에 여러 대의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;EC2(Amazon Elastic Compute Cloud)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;애플리케이션 인스턴스가 있는 구조입니다. 모든 인스턴스는 하나의&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;MySQL(InnoDB)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;데이터베이스를 공유합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;문제 상황&lt;/b&gt;&lt;/h2&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1303&quot; data-origin-height=&quot;85&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMKzSy/dJMcajAxxlU/NSScVOfcevizItDy7IbeR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMKzSy/dJMcajAxxlU/NSScVOfcevizItDy7IbeR1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMKzSy/dJMcajAxxlU/NSScVOfcevizItDy7IbeR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMKzSy%2FdJMcajAxxlU%2FNSScVOfcevizItDy7IbeR1%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;1303&quot; height=&quot;85&quot; data-origin-width=&quot;1303&quot; data-origin-height=&quot;85&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;1275&quot; data-origin-height=&quot;401&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Lz9GM/dJMb99Sd15v/PiPFuwLjLKY7j7PgIhy7ik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Lz9GM/dJMb99Sd15v/PiPFuwLjLKY7j7PgIhy7ik/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Lz9GM/dJMb99Sd15v/PiPFuwLjLKY7j7PgIhy7ik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FLz9GM%2FdJMb99Sd15v%2FPiPFuwLjLKY7j7PgIhy7ik%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;1275&quot; height=&quot;401&quot; data-origin-width=&quot;1275&quot; data-origin-height=&quot;401&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;모니터링에서 새로 추가한 트레이스를 확인하던 중, 주기적으로&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Deadlock found when trying to get lock&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;예외가 발생하는 것을 발견했습니다. 트레이스에 따르면 집계 요청의 전체 실행 시간은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;약 1.31초&lt;/b&gt;, 그중&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;965ms 구간의 UPDATE&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;쿼리에서 데드락이 발생했습니다. 스케줄러&lt;span&gt;&amp;nbsp;&lt;/span&gt;hourlyCalculateMemberRank&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;updateMonthlyRanking&lt;span&gt;&amp;nbsp;&lt;/span&gt;&amp;rarr;&lt;span&gt;&amp;nbsp;&lt;/span&gt;UPDATE monthly_reading&lt;span&gt;&amp;nbsp;&lt;/span&gt;순으로 실행되며, 예외 지점을 명확히 확인했습니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예외 로그(요약)&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;JDBC exception executing SQL [ ... 이달의 독서왕 쿼리 ... ]
[Deadlock found when trying to get lock; try restarting transaction] [n/a]; SQL [n/a]
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 메시지는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;InnoDB가 데드락을 감지해 트랜잭션을 롤백&lt;/b&gt;했음을 의미합니다. 다만 어떤 트랜잭션끼리 충돌했는지, 어느 행/인덱스에서 잠금 경합이 있었는지는 애플리케이션 로그만으로 확인하기 어렵습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;원인 가설 수립&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;초기 가설은 다음과 같습니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;쿼리 구조의 복잡성&lt;/b&gt;: 단일 테이블을 여러 번 조인하는 집계/순위 쿼리는 실행 시간이 길어지고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Next-Key Lock&lt;/b&gt;으로 인접 인덱스 범위까지 잠금이 확장될 수 있습니다. 잠금 범위가 넓어지면 데드락 가능성이 커집니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;사용자 요청과의 타이밍 충돌&lt;/b&gt;: 스케줄러가 UPDATE를 수행하는 시점에 사용자가 읽기 이벤트를 발생시키면 동일 테이블에서 쓰기 충돌이 날 수 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;분산 환경의 동시 실행&lt;/b&gt;: 여러 EC2 인스턴스에서 스케줄러가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;같은 분 단위로 동시 실행&lt;/b&gt;되면, 동일 테이블을 동시에 갱신하려다 데드락이 발생할 수 있습니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 실제 관측은 가설을 다음처럼 좁혔습니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;데드락은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;매 10분마다가 아니라 간헐적&lt;/b&gt;으로만 발생했습니다. 쿼리 자체의 구조 문제가 주원인이었다면 매 실행마다 재현되어야 합니다.&lt;/li&gt;
&lt;li&gt;데드락 예외는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;스케줄러 경로에서만&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;관찰되었고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;사용자 요청 처리 경로에서는 관찰되지 않았습니다&lt;/b&gt;. 사용자 UPDATE와 충돌했을 가능성은 낮습니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;분산 환경에서 다중 인스턴스의 스케줄러 동시 실행&lt;/b&gt;이 주원인일 것이라는 가설이 가장 설득력 있었습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;원인 검증: InnoDB 내부 상태 분석&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;원인을 확정하기 위해&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;SHOW ENGINE INNODB STATUS&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;결과를 분석했습니다. 이 명령은 최근 데드락에 대한&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;참여 트랜잭션, 보유/대기 잠금, 롤백된 트랜잭션&lt;/b&gt;을 상세히 제공합니다.&lt;/p&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;발췌 결과&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;asciidoc&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;------------------------
LATEST DETECTED DEADLOCK
------------------------
*** (1) TRANSACTION:
TRANSACTION 351844299710552, ACTIVE 0 sec fetching rows
10.0.0.115 bombom_admin executing
UPDATE monthly_reading ...
*** (1) HOLDS THE LOCK(S):
... lock mode S ...
*** (1) WAITING FOR THIS LOCK TO BE GRANTED:
... lock mode S waiting ...

*** (2) TRANSACTION:
TRANSACTION 158951, ACTIVE 0 sec starting index read
10.0.20.26 bombom_admin executing
UPDATE monthly_reading ...
*** (2) HOLDS THE LOCK(S):
... lock_mode X locks rec but not gap ...
*** (2) WAITING FOR THIS LOCK TO BE GRANTED:
... lock_mode X ... waiting ...

*** WE ROLL BACK TRANSACTION (1)
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;해석:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;b&gt;트랜잭션 (1)&lt;/b&gt;:&lt;span&gt;&amp;nbsp;&lt;/span&gt;monthly_reading의 여러 행에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;공유 잠금(S, Shared)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;보유. 추가 S 잠금을 획득하려고 대기.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;트랜잭션 (2)&lt;/b&gt;: 특정 행에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;배타 잠금(X, eXclusive)&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;보유. 트랜잭션 (1)이 보유한 다른 행에 대해 X 잠금을 요청하며 대기.&lt;/li&gt;
&lt;li&gt;두 트랜잭션이 서로의 잠금을 기다리며&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;교착 상태&lt;/b&gt;가 되었고, InnoDB가 (1)을 롤백해 해소.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;서로 다른 사설 IP(10.0.0.115 / 10.0.20.26)&lt;/b&gt;에서 실행되어,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;서로 다른 EC2 인스턴스&lt;/b&gt;의 스케줄러가 동시에 실행되었음을 확인.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;결론적으로, 문제는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;단일 서버의 동시성&lt;/b&gt;이 아니라&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;분산 환경에서의 중복 스케줄링&lt;/b&gt;이었습니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;해결 대안 비교&lt;/b&gt;&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;쿼리 최적화/범위 축소&lt;/b&gt;&lt;br /&gt;읽기용 테이블과 통계용 테이블을 분리하고, 갱신 범위를 최소화하면 잠금 경합을 줄일 수 있습니다. 다만&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;재설계&amp;middot;재작성&amp;middot;테스트 비용&lt;/b&gt;이 크고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;동시 실행 자체를 막지는 못합니다&lt;/b&gt;.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;외부 분산 락 도입(Redis 등)&lt;/b&gt;&lt;br /&gt;한 인스턴스만 실행하도록 강제할 수 있으나,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;외부 인프라 의존성&lt;/b&gt;과&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;운영 복잡도&lt;/b&gt;가 상승합니다. 현재 규모에서는 과합니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;설정 파일로 실행 인스턴스 고정&lt;/b&gt;&lt;br /&gt;단기 우회는 가능하나, 지정 인스턴스 장애 시 스케줄러가 멈춰&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;가용성 위험&lt;/b&gt;이 있습니다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;ShedLock 적용(채택)&lt;/b&gt;&lt;br /&gt;&lt;b&gt;DB 기반 애플리케이션 레벨 락&lt;/b&gt;으로, 먼저 락을 얻은 인스턴스만 작업을 실행하고 나머지는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;조용히 스킵&lt;/b&gt;합니다. 별도 인프라 없이 Spring과 자연스럽게 통합됩니다.&lt;/li&gt;
&lt;/ol&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;ShedLock의 원리&lt;/b&gt;&lt;/h2&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ShedLock&lt;/b&gt;은 분산 환경에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;동일 스케줄러의 중복 실행을 방지&lt;/b&gt;하는 라이브러리입니다. 스케줄러가 시작되면 각 인스턴스는 DB의&lt;span&gt;&amp;nbsp;&lt;/span&gt;shedlock&lt;span&gt;&amp;nbsp;&lt;/span&gt;테이블에&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;락 레코드&lt;/b&gt;를 쓰려고 합니다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;선점한 인스턴스는&lt;span&gt;&amp;nbsp;&lt;/span&gt;lock_until을&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;미래 시각&lt;/b&gt;으로 설정해 락을 보유하고,&lt;/li&gt;
&lt;li&gt;다른 인스턴스는 같은&lt;span&gt;&amp;nbsp;&lt;/span&gt;name의 락이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;아직 유효함&lt;/b&gt;을 보고 실행을 건너뜁니다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;락은 다음 중 하나로 해제됩니다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;작업 종료 시 명시 해제,&lt;/li&gt;
&lt;li&gt;&lt;b&gt;lockAtMostFor&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;경과 시 자동 만료(비정상 종료 대비).&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;시간 값은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;ISO‑8601 Duration&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;형식(예:&lt;span&gt;&amp;nbsp;&lt;/span&gt;PT3S=3초,&lt;span&gt;&amp;nbsp;&lt;/span&gt;PT10M=10분,&lt;span&gt;&amp;nbsp;&lt;/span&gt;PT1H=1시간)이어야 합니다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;1s/3sec처럼 비표준 문자열은&lt;span&gt;&amp;nbsp;&lt;/span&gt;DateTimeParseException을 유발합니다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;적용 과정&lt;/b&gt;&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;의존성 추가&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;css&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;implementation(&quot;net.javacrumbs.shedlock:shedlock-spring:6.9.2&quot;)
implementation(&quot;net.javacrumbs.shedlock:shedlock-provider-jdbc-template:6.9.2&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;테이블 생성&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;routeros&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;CREATE TABLE IF NOT EXISTS shedlock (
    name        VARCHAR(64)  NOT NULL PRIMARY KEY,
    lock_until  DATETIME(3)  NOT NULL,
    locked_at   DATETIME(3)  NOT NULL,
    locked_by   VARCHAR(255) NOT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;설정 클래스&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@Configuration
@EnableScheduling
@EnableSchedulerLock(defaultLockAtMostFor = &quot;PT30S&quot;)
public class SchedulerConfig {

    @Bean
    public LockProvider lockProvider(DataSource dataSource) {
        return new JdbcTemplateLockProvider(
            JdbcTemplateLockProvider.Configuration.builder()
                .withJdbcTemplate(new JdbcTemplate(dataSource))
                .usingDbTime() // DB 시간을 기준으로 락 만료 판단
                .build()
        );
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;스케줄러에 락 적용&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;집계 평균&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;1.3초&lt;/b&gt;, 최악&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;5초&lt;/b&gt;를 기준으로:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;lockAtLeastFor&lt;span&gt;&amp;nbsp;&lt;/span&gt;=&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;PT3S&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(평균보다 약간 긴 값으로 빠른 재진입 방지)&lt;/li&gt;
&lt;li&gt;lockAtMostFor&lt;span&gt;&amp;nbsp;&lt;/span&gt;=&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;PT15S&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;(최악의 3배 수준으로 비정상 종료 시 자동 만료 보장)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;less&quot; style=&quot;color: #000000; text-align: start;&quot;&gt;&lt;code&gt;@Component
@RequiredArgsConstructor
public class ReadingScheduler {

    private final ReadingService readingService;

    @Scheduled(cron = &quot;0 */10 * * * *&quot;, zone = &quot;Asia/Seoul&quot;)
    @SchedulerLock(
        name = &quot;ten_minutely_calculate_member_rank&quot;,
        lockAtLeastFor = &quot;PT3S&quot;,
        lockAtMostFor  = &quot;PT15S&quot;
    )
    public void tenMinutelyCalculateMemberRank() {
        log.info(&quot;이달의 독서왕 집계 시작&quot;);
        readingService.updateMonthlyRanking();
        log.info(&quot;이달의 독서왕 집계 완료&quot;);
    }
}
&lt;/code&gt;&lt;/pre&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;적용 결과&lt;/b&gt;&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;운영 환경에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;데드락 재발 없음&lt;/b&gt;(관측 기간 내 0건).&lt;/li&gt;
&lt;li&gt;스케줄러&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;단일 실행 보장&lt;/b&gt;: 여러 EC2 인스턴스가 있어도 한 번에 한 인스턴스만 집계 수행.&lt;/li&gt;
&lt;li&gt;사용자 경험:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;항상 10분 이내 최신 랭킹&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;제공(기획 의도 충족).&lt;/li&gt;
&lt;li&gt;운영 편의성: 외부 인프라 없이&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;DB 테이블 하나로 분산 락&lt;/b&gt;&lt;span&gt;&amp;nbsp;&lt;/span&gt;관리.&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote style=&quot;color: #000000; text-align: start;&quot; data-ke-style=&quot;style1&quot;&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고: 스케줄러 실행 시간이 평균/최악 값에서 벗어나면&lt;span&gt;&amp;nbsp;&lt;/span&gt;lockAtLeastFor/lockAtMostFor를 재산정해야 합니다. 배포/쿼리 변경 후에는 반드시&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;실측&lt;/b&gt;으로 보정해야 합니다.&lt;/p&gt;
&lt;/blockquote&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;배운 점&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;분산 환경에서 스케줄링의 핵심은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;기능 구현&lt;/b&gt;보다&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;실행 동기화&lt;/b&gt;다.&lt;/li&gt;
&lt;li&gt;애플리케이션 로그만으로는 한계가 있다. &lt;b&gt;SHOW ENGINE INNODB STATUS&lt;/b&gt;이 전환점이 되었다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;작은 변경(ShedLock)&lt;/b&gt;으로 &lt;b&gt;큰 안정성(중복 실행 제거, 데드락 제거)&lt;/b&gt;을 얻을 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;참고&lt;/h2&gt;
&lt;ul style=&quot;list-style-type: disc; color: #000000; text-align: start;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;ShedLock GitHub:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://github.com/lukas-krecan/ShedLock&quot;&gt;https://github.com/lukas-krecan/ShedLock&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;Baeldung:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://www.baeldung.com/shedlock-spring&quot;&gt;https://www.baeldung.com/shedlock-spring&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;MariaDB&lt;span&gt;&amp;nbsp;&lt;/span&gt;SHOW ENGINE INNODB STATUS&lt;span&gt;&amp;nbsp;&lt;/span&gt;문서:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/show/show-engine-innodb-status&quot;&gt;https://mariadb.com/docs/server/reference/sql-statements/administrative-sql-statements/show/show-engine-innodb-status&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;데드락 원인 정리:&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;a href=&quot;https://mrxx.tistory.com/entry/MySQL-%EB%8D%B0%EB%93%9C%EB%9D%BDDeadLock%EC%9D%B4-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-%EC%9B%90%EC%9D%B8&quot;&gt;https://mrxx.tistory.com/entry/MySQL-%EB%8D%B0%EB%93%9C%EB%9D%BDDeadLock%EC%9D%B4-%EB%B0%9C%EC%83%9D%ED%95%98%EB%8A%94-%EC%9B%90%EC%9D%B8&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>BomBom 프로젝트</category>
      <category>shedlock</category>
      <category>spring scheduler</category>
      <category>뉴스레터</category>
      <category>봄봄</category>
      <author>gogi masidda</author>
      <guid isPermaLink="true">https://iwbdev.tistory.com/491</guid>
      <comments>https://iwbdev.tistory.com/entry/%EB%B4%84%EB%B4%84-%E2%80%98%EC%9D%B4%EB%8B%AC%EC%9D%98-%EB%8F%85%EC%84%9C%EC%99%95%E2%80%99-%EB%9E%AD%ED%82%B9-%EB%8D%B0%EB%93%9C%EB%9D%BD-%EC%9B%90%EC%9D%B8-%EB%B6%84%EC%84%9D%EA%B3%BC-ShedLock-%EC%A0%81%EC%9A%A9%EA%B8%B0#entry491comment</comments>
      <pubDate>Wed, 5 Nov 2025 14:52:15 +0900</pubDate>
    </item>
    <item>
      <title>Querydsl, 왜 SQL Injection 경고가 뜰까?</title>
      <link>https://iwbdev.tistory.com/entry/Querydsl-%EC%99%9C-SQL-Injection-%EA%B2%BD%EA%B3%A0%EA%B0%80-%EB%9C%B0%EA%B9%8C</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;Querydsl을 프로젝트에 적용하면서 보안 툴에서 다음과 같은 경고 문구를 확인했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;447&quot; data-origin-height=&quot;156&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZBzx5/btsQcp3CTep/Hkf0OGkcnNKYSxyuDs7tJK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZBzx5/btsQcp3CTep/Hkf0OGkcnNKYSxyuDs7tJK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZBzx5/btsQcp3CTep/Hkf0OGkcnNKYSxyuDs7tJK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZBzx5%2FbtsQcp3CTep%2FHkf0OGkcnNKYSxyuDs7tJK%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;447&quot; height=&quot;156&quot; data-origin-width=&quot;447&quot; data-origin-height=&quot;156&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Querydsl의 버전을 낮춰도 동일한 경고 문구가 나타난다. 구글링을 해보니, &lt;b&gt;&lt;code&gt;OrderBy&lt;/code&gt; 절에서 사용자 입력을 검증하지 않을 경우 SQL Injection가 발생할 수 있음&lt;/b&gt;을 알 수 있었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;  문제가 되는 코드 예시&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;stata&quot;&gt;&lt;code&gt;@GetMapping
public List&amp;lt;Test&amp;gt; getProducts(@RequestParam(&quot;orderBy&quot;) String orderBy) {
    JPAQuery&amp;lt;Test&amp;gt; query = new JPAQuery&amp;lt;Test&amp;gt;(entityManager).from(test);
    PathBuilder&amp;lt;Test&amp;gt; pathBuilder = new PathBuilder&amp;lt;&amp;gt;(Test.class, &quot;test&quot;);

    OrderSpecifier order = new OrderSpecifier(Order.ASC, pathBuilder.get(orderBy));
    JPAQuery&amp;lt;Test&amp;gt; orderedQuery = query.orderBy(order);
    return orderedQuery.fetch();
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드에서는 &lt;code&gt;orderBy&lt;/code&gt; 파라미터로 전달된 문자열을 그대로 &lt;code&gt;pathBuilder.get(orderBy)&lt;/code&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;u&gt;&lt;code&gt;http://example.com/products?orderBy=name&lt;/code&gt;&lt;/u&gt; &amp;rarr; &lt;b&gt;정상 동작&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;&lt;u&gt;&lt;code&gt;http://example.com/products?orderBy=name desc, (select password from member)&lt;/code&gt;&lt;/u&gt; &amp;rarr; &lt;b&gt;SQL Injection 발생 가능&lt;/b&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위와 같이 SQL 구문에 SQL 조각을 주입하여 Querydsl이 이를 파싱하지 못하거나, 개발자가 의도하지 않은 결과를 나오게 할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 이 경우는 &lt;b&gt;개발자가 충분히 입력 값을 검증하고 사용하면 예방할 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt; ️ Querydsl의 SQL Injection 문제는 OrderBy절에서만 있을까?&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Querydsl은 &lt;b&gt;PreparedStatement&lt;/b&gt;를 활용한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;  PreparedStatement란?&lt;/b&gt;&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;JDBC에서 제공하는 &lt;b&gt;미리 컴파일된 SQL 구문 객체&lt;/b&gt;&lt;/li&gt;
&lt;li&gt;일반 Statement와 달리, SQL 구문을 한 번 파싱/컴파일해두고 &lt;b&gt;값(파라미터)만 나중에 바인딩&lt;/b&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;&lt;b&gt;일반 Statement (취약)&lt;/b&gt;
&lt;pre class=&quot;sql&quot;&gt;&lt;code&gt;String sql = &quot;SELECT * FROM member WHERE name = '&quot; + userInput + &quot;'&quot;;&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;사용자의 입력이 SQL 구문과 그대로 합쳐진다. 문자열 결합으로 SQL 구문이 변조될 수 있다.&lt;/li&gt;
&lt;li&gt;이 때, 사용자가 &lt;b&gt;userInput = &amp;ldquo;&amp;rsquo; OR &amp;lsquo;1&amp;rsquo; = &amp;lsquo;1&amp;rdquo;(OR 구문을 통해 항상 참으로 만드는 것)&lt;/b&gt;을 입력하면 SQL Injection이 발생한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;&lt;b&gt;PreparedStatement (안전)&lt;/b&gt;
&lt;pre class=&quot;java&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;String sql = &quot;SELECT * FROM member WHERE name = ?&quot;; 
PreparedStatement ps = conn.prepareStatement(sql);
ps.setString(1,userInput);&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;SQL 구문은 미리 컴파일되어 ? 자리에는 값만 올 수 있다.&lt;/li&gt;
&lt;li&gt;앞과 동일하게 사용자가 &lt;b&gt;&amp;ldquo;&amp;rsquo; OR &amp;lsquo;1&amp;rsquo; = &amp;lsquo;1&amp;rdquo;&lt;/b&gt;을 입력했을 때, &lt;b&gt;' OR '1'='1은 문자열 값으로만 전달&lt;/b&gt;한다.&lt;/li&gt;
&lt;li&gt;즉, 사용자의 입력인 &lt;b&gt;&amp;ldquo;&amp;rsquo; OR &amp;lsquo;1&amp;rsquo; = &amp;lsquo;1&amp;rdquo;를 SQL 구문으로 취급하는 것이 아니라, &amp;ldquo;&amp;rsquo; OR &amp;lsquo;1&amp;rsquo; = &amp;lsquo;1&amp;rdquo;로 검색/필터링 하게 되는 것&lt;/b&gt;이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서, &lt;b&gt;PreparedStatement를 기본적으로 사용하는 Querydsl 구문은 SQL Injection이 차단&lt;/b&gt;된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;⚠️ 왜 OrderBy 절에서만 SQL Injection에 대한 취약점이 있다고 할까?&lt;/b&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;Placeholder(?)의 한계&lt;/b&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;PreparedStatement의 &lt;code&gt;?&lt;/code&gt;는 &lt;b&gt;값 자리에만 사용 가능&lt;/b&gt;하다.&lt;/li&gt;
&lt;li&gt;즉, WHERE, HAVING, INSERT/UPDATE VALUES 같은 &lt;b&gt;리터럴 값이 들어가는 위치에는 안전하게 사용&lt;/b&gt;할 수 있지만, &lt;b&gt;컬럼명이나 테이블명 같은 구문 요소에는 적용할 수 없다.&lt;/b&gt;&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;Querydsl에서 제공하는 PathBuilder.get(userInput), Expressions.path(&amp;hellip;) 등의 문자열 기반의 메서드를 사용하면 사용자의 입력이 SQL에 그대로 삽입되므로 PreparedStatement에 의해 보호되지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제로 대부분의 값 비교는 &lt;code&gt;.eq(param)&lt;/code&gt;, &lt;code&gt;.in(list)&lt;/code&gt;처럼 안전한 메서드를 쓰기 때문에 문제가 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 &lt;b&gt;OrderBy는 사용자가 직접 선택하는 경우가 많고, PreparedStatement의 Placeholder를 쓸 수 없는 영역이기 때문에 대표적인 취약점으로 언급되는 것&lt;/b&gt;이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;➡️ 결국, &lt;b&gt;OrderBy만 위험한 것이 아니라, 문자열로 SQL 구문을 조립하는 모든 절에서 Injection 문제가 생길 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&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;봄봄에서는 정렬 입력을 안전하게 처리하기 위해 &lt;code&gt;Pageable&lt;/code&gt; 객체를 활용한다. &lt;code&gt;Pageable&lt;/code&gt;은 &lt;code&gt;sort&lt;/code&gt;와 &lt;code&gt;direction&lt;/code&gt; 값을 받아 확장성 있게 정렬 기준을 정의할 수 있다. 새로운 정렬 필드가 추가되더라도 구조 변경이 최소화된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의도치 않은 정렬 키 입력을 차단하기 위해 &lt;b&gt;화이트리스트를 적용&lt;/b&gt;했다. 화이트리스트에 포함된 키만 허용하며, 등록되지 않은 키가 들어오면 예외를 반환한다. 이를 통해 SQL Injection을 예방한다.&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;code&gt;Pageable&lt;/code&gt;을 받고, 내부에서 &lt;code&gt;pageable.getSort()&lt;/code&gt;를 순회한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;화이트리스트 검증&lt;/b&gt;: 정렬 키는 &lt;code&gt;SORT_FIELD_WHITELIST_MAP&lt;/code&gt;에 등록된 값만 허용한다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;정규화/형식 검사&lt;/b&gt;: 입력 키는 &lt;code&gt;strip()&lt;/code&gt;으로 공백을 제거하고, 비어있거나 형식에 맞지 않으면 예외를 던진다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;매핑 단위&lt;/b&gt;: 화이트리스트는 &lt;b&gt;Q클래스의 필드(&lt;code&gt;Path&amp;lt;?&amp;gt;&lt;/code&gt;)&lt;/b&gt; 와 직접 매핑된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;정렬 생성&lt;/b&gt;: 검증된 키와 방향을 기반으로 &lt;code&gt;OrderSpecifier(order, targetPath)&lt;/code&gt;를 생성해 Querydsl 쿼리에 반영한다.&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;style5&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;처음에는 단순히 보안 경고 문구가 보이니 &amp;ldquo;버전을 바꿔야 하나?&amp;rdquo;라는 생각부터 들었다. 하지만 조사해보면서, 취약점이 있다는 것이 라이브러리 자체가 위험하다는 의미가 아닐 수도 있다는 것을 알게 됐다.&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;b&gt;PreparedStatement가 막아주는 영역(값)과 그렇지 못한 영역(구문)&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 data-ke-size=&quot;size16&quot;&gt;앞으로도 비슷한 이슈를 마주했을 때, &lt;b&gt;원리를 이해하고 보완하는 방식으로 해결&lt;/b&gt;해야 한다는 점을 느꼈다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;Reference&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2024-49203?utm_source=chatgpt.com&quot;&gt;NVD - CVE-2024-49203: SQL Injection vulnerability in Querydsl&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/advisories/GHSA-6q3q-6v5j-h6vg?utm_source=chatgpt.com&quot;&gt;GitHub Advisory - GHSA-6q3q-6v5j-h6vg: SQL Injection in Querydsl&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://chained-tech-interview-questions.gitbook.io/ctiq/02-spring/02-02-querydsl-sql-injection?utm_source=chatgpt.com&quot;&gt;CTIQ: Spring + Querydsl에서 SQL Injection 사례&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://medium.com/@rlaeorua369/openfeign-querydsl-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98-%EC%B4%9D%EC%A0%95%EB%A6%AC-dee89cb3ec05?utm_source=chatgpt.com&quot;&gt;Medium: OpenFeign + Querydsl 매핑 시 최적화 및 보완&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>BomBom 프로젝트</category>
      <author>gogi masidda</author>
      <guid isPermaLink="true">https://iwbdev.tistory.com/490</guid>
      <comments>https://iwbdev.tistory.com/entry/Querydsl-%EC%99%9C-SQL-Injection-%EA%B2%BD%EA%B3%A0%EA%B0%80-%EB%9C%B0%EA%B9%8C#entry490comment</comments>
      <pubDate>Fri, 29 Aug 2025 18:07:20 +0900</pubDate>
    </item>
    <item>
      <title>봄봄의 보안 강화하기 (Fail2ban, ModSecurity)</title>
      <link>https://iwbdev.tistory.com/entry/%EB%B4%84%EB%B4%84%EC%9D%98-%EB%B3%B4%EC%95%88-%EA%B0%95%ED%99%94%ED%95%98%EA%B8%B0-Fail2ban-ModSecurity</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;운영 서버를 직접 다루다 보니, 생각보다 보안에 취약할 수 있다는 점을 깨닫게 되었다. 당장 Nginx 로그에서도 부적절한 접근 로그가 쏟아지는 것을 보기도 했다. 그래서, 단순히 서비스가 잘 동작하는 것만으로는 충분하지 않았고, 외부에서 들어오는 수많은 요청 속에서 악성 접근을 어떻게 차단할지 고민하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &lt;b&gt;로그를 기반으로 비정상적인 접근을 차단하는 Fail2ban&lt;/b&gt;만으로도 충분하다고 생각했다. 하지만 팀원이 &lt;b&gt;ModSecurity&lt;/b&gt;를 함께 적용해야 한다고 강하게 주장했고, 조사해 보니 두 도구가 방어하는 범위가 다르다는 사실을 알게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Fail2ban과 ModSecurity를 적용해 보안을 강화하기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; &lt;b&gt;Fail2ban&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Fail2ban은 &lt;b&gt;로그 파일&lt;/b&gt;(예: &lt;code&gt;/var/log/nginx/error.log&lt;/code&gt;, &lt;code&gt;/var/log/nginx/access.log&lt;/code&gt;)을 감시하다가 특정 패턴이 일정 횟수 이상 발생하면 해당 IP를 차단한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 애플리케이션(Nginx, 웹 서버)이 요청을 처리하기 전에 &lt;b&gt;서버 레벨에서 비정상적인 접근 자체를 차단&lt;/b&gt;하는 역할을 한다.&lt;/p&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;b&gt;1. [nginx-botsearch]&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;차단 대상&lt;/b&gt;: 존재하지 않는 URL을 무작위로 탐색하는 봇/공격자&lt;/li&gt;
&lt;li&gt;&lt;b&gt;탐지 방식&lt;/b&gt;: &lt;code&gt;404 Not Found&lt;/code&gt; 에러나 &lt;code&gt;No such file or directory&lt;/code&gt; 로그 패턴을 기반으로 탐지&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. [nginx-http-auth]&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;차단 대상&lt;/b&gt;: 인증 시도 공격(무차별 대입, 잘못된 인증 반복)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;동작 모드&lt;/b&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;b&gt;auth 모드&lt;/b&gt; &amp;rarr; 잘못된 비밀번호 입력 시도 탐지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;fallback 모드&lt;/b&gt; &amp;rarr; SSL/TLS 핸드셰이크 오류 탐지&lt;/li&gt;
&lt;li&gt;&lt;b&gt;normal 모드(기본)&lt;/b&gt; &amp;rarr; auth 모드와 동일, 로그인 실패 방어&lt;/li&gt;
&lt;li&gt;&lt;b&gt;aggressive 모드&lt;/b&gt; &amp;rarr; auth + fallback 규칙 모두 적용&lt;/li&gt;
&lt;/ol&gt;
&lt;/li&gt;
&lt;/ul&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;b&gt;[nginx-ratelimit.conf]&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;차단 대상&lt;/b&gt;: 특정 IP에서 비정상적으로 많은 요청 발생&lt;/li&gt;
&lt;li&gt;&lt;b&gt;예외 처리&lt;/b&gt;: &lt;code&gt;/actuator/prometheus&lt;/code&gt; 요청은 모니터링 용도로 허용&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(이 필터 이름은 제가 붙인 네이밍 입니다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;적용된 Jail&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위에서 작성한 세가지의 필터를 이용하여 Fail2ban 보안 설정을 구성했다.&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;[nginx-http-auth] &amp;rarr;&lt;/b&gt; 로그인 실패 공격 방어&lt;/li&gt;
&lt;li&gt;&lt;b&gt;[nginx-botsearch] &amp;rarr;&lt;/b&gt; 취약점 탐색 공격 방어&lt;/li&gt;
&lt;li&gt;&lt;b&gt;[nginx-fast-ratelimit] &amp;rarr;&lt;/b&gt; 단기간 대량 요청 방어 (DDoS 대응)&lt;/li&gt;
&lt;li&gt;&lt;b&gt;[nginx-long-ratelimit] &amp;rarr;&lt;/b&gt; 저속/지속적 공격 방어&lt;/li&gt;
&lt;/ol&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;b&gt; ModSecurity&lt;/b&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ModSecurity는 &lt;b&gt;Nginx 모듈로 동작&lt;/b&gt;하며, HTTP 요청과 응답을 실시간으로 검사해 다양한 웹 공격을 방어한다. 기본적으로 제공하는 여러 차단 / 방지 기능을 적절히 활용하여 웹 서버 보안을 강화할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ModSecurity는 &lt;b&gt;Nginx 애플리케이션 내부에서 요청과 응답을 분석&lt;/b&gt;하기 때문에, 애플리케이션 레벨에서 취약점을 탐지하고 차단하는 역할을 한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;기본적으로 제공하는 차단 / 방지&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;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;SQL Injection 차단&lt;/li&gt;
&lt;li&gt;XSS(Cross-Site Scripting) 차단&lt;/li&gt;
&lt;li&gt;파일 업로드 취약점 방어&lt;/li&gt;
&lt;li&gt;DDoS 공격 완화&lt;/li&gt;
&lt;li&gt;세션 하이재킹 방지&lt;/li&gt;
&lt;li&gt;상세 로그 및 모니터링 제공&lt;/li&gt;
&lt;/ul&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;b&gt;XSS란?&lt;/b&gt;&lt;br /&gt;웹 페이지에 악성 스크립트를 주입하는 공격. 성공 시 공격자가 사용자의 세션 정보(쿠키), 개인 정보 등을 탈취하거나 악성 사이트로 이동시킬 수 있음.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, ModSecurity의 규칙을 많이 활성화할수록 요청 처리 속도가 느려지고 서버 부하가 증가한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 Nginx 서버는 &lt;b&gt;t4g-micro&lt;/b&gt;를 사용하고 있어 모든 보안 기능을 켜두는 것은 어렵다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 프로젝트 환경에서 가장 위험도가 높고, 실제 서비스에 치명적인 영향을 줄 수 있는 공격을 우선적으로 방어하기 위해 &lt;b&gt;SQL Injection 차단&lt;/b&gt;과 &lt;b&gt;XSS 차단&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;SQL Injection&lt;/b&gt;: 데이터베이스 노출이나 데이터 변조로 이어질 수 있어 백엔드 서비스의 안정성 측면에서 반드시 차단해야 한다고 생각했다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;XSS&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;style5&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; 마무리&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 Fail2ban만으로도 충분하다고 생각했지만, 팀원의 제안으로 ModSecurity를 도입하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;직접 적용해보니 두 도구가 막아내는 범위가 전혀 다르다는 걸 알 수 있었다. Fail2ban은 &lt;b&gt;비정상적인 접근 자체를 차단&lt;/b&gt;해 서버를 보호하는 것이고, ModSecurity는 &lt;b&gt;요청 내용을 분석&lt;/b&gt;해 웹 애플리케이션 취약점을 방어하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재는 리소스가 제한적인 환경이라 ModSecurity가 제공하는 모든 기능을 켜둘 수는 없지만, &lt;b&gt;SQL Injection&lt;/b&gt;과 &lt;b&gt;XSS 차단&lt;/b&gt;만으로도 훨씬 안정적이게 되었다고 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇보다 이번 경험을 통해, 보안은 단순히 &amp;ldquo;좋다고 다 켜는 것&amp;rdquo;이 아니라 &lt;b&gt;우리 환경에 맞는 위험을 우선적으로 줄이는 과정&lt;/b&gt;이라는 것을 배울 수 있었다. 앞으로 서비스가 성장하고 서버 자원이 여유로워진다면 더 많은 보안 규칙을 적용해보고 싶다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Reference&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a href=&quot;https://www.digitalocean.com/community/tutorials/how-to-protect-an-nginx-server-with-fail2ban-on-ubuntu-20-04?utm_source=chatgpt.com&quot;&gt;DigitalOcean - Protect an Nginx Server with Fail2ban&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://monkeybusiness.tistory.com/696?utm_source=chatgpt.com&quot;&gt;MonkeyBusiness 블로그 - Fail2ban 설정&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://github.com/owasp-modsecurity/ModSecurity?utm_source=chatgpt.com&quot;&gt;GitHub - OWASP ModSecurity&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href=&quot;https://docs.redhat.com/ko/documentation/red_hat_enterprise_linux/8/html/deploying_different_types_of_servers/assembly_securing-web-applications-on-a-web-server-using-modsecurity_setting-apache-http-server?utm_source=chatgpt.com&quot;&gt;Red Hat Docs - Securing web applications with ModSecurity&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>BomBom 프로젝트</category>
      <author>gogi masidda</author>
      <guid isPermaLink="true">https://iwbdev.tistory.com/489</guid>
      <comments>https://iwbdev.tistory.com/entry/%EB%B4%84%EB%B4%84%EC%9D%98-%EB%B3%B4%EC%95%88-%EA%B0%95%ED%99%94%ED%95%98%EA%B8%B0-Fail2ban-ModSecurity#entry489comment</comments>
      <pubDate>Thu, 28 Aug 2025 15:29:17 +0900</pubDate>
    </item>
    <item>
      <title>어지럽던 동적쿼리에 Querydsl을 도입하여 개선하기</title>
      <link>https://iwbdev.tistory.com/entry/%EC%96%B4%EC%A7%80%EB%9F%BD%EB%8D%98-%EB%8F%99%EC%A0%81%EC%BF%BC%EB%A6%AC%EC%97%90-Querydsl%EC%9D%84-%EB%8F%84%EC%9E%85%ED%95%98%EC%97%AC-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</link>
      <description>&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Querydsl 도입 이유&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;현재 Article 목록 조회에서 category, date 등을 바탕으로 필터링이 필요하다. 이 필터링 로직을 위해서는 동적 쿼리를 작성해야 하는데, Querydsl을 사용하지 않고 동적 쿼리를 작성하기 위해서는 JPQL이나 SQL을 직접 조합하여야 한다. 이는 가독성이 좋지 않다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;또한, StringBuilder를 사용하여 JPQL을 조합하고 있기 때문에, 엔티티명이 바뀌거나, 필드명이 바뀌면 컴파일 타임에 에러가 나지 않고 런타임 에러가 발생하여 예방이 불가능하다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서, Querydsl을 도입하여,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;메서드 체이닝으로 작성된 쿼리를 더 쉽게 읽을 수 있고, 잘못된 쿼리 작성 시에 컴파일 타임에 에러를 잡을 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Q 클래스 or Q(쿼리) 타입&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Querydsl로 쿼리를 작성할 때 Q클래스를 사용함으로써 JPQL이나 SQL을 직접 문자열로 작성하는 것보다&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Type-safe하게 쿼리를 작성&lt;/b&gt;할 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Q클래스는 Querydsl의 의존성을 추가하고 빌드하면,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;build/generated/sources/annotationProcessor/&amp;hellip; 폴더에 위치&lt;/b&gt;한다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;적용에 앞서: Repository 구현 클래스 이름은 반드시 Impl로 끝나야 한다?&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Spring Data JPA에서 Querydsl을 사용하여 사용자 정의 쿼리를 구현하려면, JpaRepository를 상속하는 기본 Repository 인터페이스가 사용자 정의 인터페이스도 함께 상속받아야 한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이때, 사용자 정의 인터페이스를 구현한 클래스의 이름은 반드시 다음 규칙을 따라야 한다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&amp;lt;Custom 인터페이스 이름&amp;gt; + Impl 예: CustomMemberRepository &amp;rarr; CustomMemberRepositoryImpl&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 규칙을 따라야만 Spring Data JPA가 자동으로 해당 구현체를 스캔하고 MemberRepository에 주입할 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서 다음과 같은 구조가 되어야 정상 작동한다:&lt;/p&gt;
&lt;pre class=&quot;angelscript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;public interface MemberRepository extends JpaRepository&amp;lt;Member, Long&amp;gt;, CustomMemberRepository {
    // ...
}

public interface CustomMemberRepository {
    // 사용자 정의 메서드
}

@Repository
public class CustomMemberRepositoryImpl implements CustomMemberRepository {
    // 사용자 정의 구현
}
&lt;/code&gt;&lt;/pre&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;&lt;b&gt;Querydsl 적용하기&lt;/b&gt;&lt;/h2&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;의존성 추가&lt;/b&gt;&lt;/h3&gt;
&lt;pre class=&quot;stylus&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;//Querydsl Jpa API를 사용하기 위한 실행 의존성
implementation(&quot;com.querydsl:querydsl-jpa:5.1.0:jakarta&quot;)
//JPA Entity로부터 Q클래스를 자동 생성
annotationProcessor(&quot;com.querydsl:querydsl-apt:5.1.0:jakarta&quot;)
//Querydsl-apt가 Q클래스를 만드는데 사용하는 애노테이션 제공
annotationProcessor(&quot;jakarta.annotation:jakarta.annotation-api&quot;)
//Querydsl-apt가 JPA 애노테이션을 해석해 Q클래스를 만들기 위해
annotationProcessor(&quot;jakarta.persistence:jakarta.persistence-api&quot;)
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;아래 세개의 의존성은 빌드 시점에만 필요하기 때문에 annotationProcessor로 들어가야 한다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;JPAQueryFactory란?&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Querydsl에서&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;JPA 기반 쿼리를 생성할 수 있는 빌더 객체&lt;/b&gt;이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;JPAQueryFactory에서 내부적으로 쿼리를 실행하기 위해 EntityManager를 사용한다. 그래서 JPAQueryFactory를 생성하기 위한 의존성으로 EntityManager가 필요하다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서, Querydsl을 사용하려면 JPAQueryFactory 빈이 필요하다. 프로젝트 전역에서 사용하도록 하려면 Config에서 등록해야 한다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Querydsl을 이용한 DTO Projection 방식 비교&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. Projections.bean()&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Spring Bean을 Projection을 통해 만드는 방법이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;만들어지는 방식은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;기본 생성자를 통해 객체를 만들어 setter를 통해 필드 값이 설정&lt;/b&gt;된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;DTO는 보통 record로 만들어진다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;record는 불변이므로 기본 생성자와 setter를 지원하지 않는다. 그래서 Projections.bean()을 사용하기에 적절하지 않다&lt;/b&gt;.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서 Projections.bean()은 일반 class에는 적절할 수도 있지만, record를 사용하는 DTO에는 적절하지 않다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. Projections.constructor()&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;생성자를 직접 호출해서 객체를 만드는 방식&lt;/b&gt;으로, 기본 생성자나 setter가 필요없다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;생성자의 파라미터 순서대로 쿼리에서 넣어주면 된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;record로 만들어진 DTO를 사용할 때, 기본 생성자나 setter를 필요로 하지 않아 이 방식을 사용하기 좋다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;파라미터를 잘못 넣어도 컴파일 타임에 오류가 나지 않는다&lt;/b&gt;.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. Projections.fields()&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;리플렉션을 사용하여 필드에 직접 접근하는 방식&lt;/b&gt;이다. 파라미터 순서에 상관없이, 필드명을 기반으로 매칭하므로, 파라미터 순서에 주의하지 않아도 된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;record의 필드는 final 필드이기 때문에 직접 접근할 수 없어 이 방식을 사용할 수 없다.&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. @QueryProjection&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;생성자에 @QueryProjection 애노테이션을 붙여 사용&lt;/b&gt;한다. 쿼리에서는 new Qclass(&amp;hellip;)의 형태로 select문에서 사용하면 된다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 방식을 사용하면,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;컴파일 타임에 타입 체크가 가능하고, IDE 자동 완성이 지원&lt;/b&gt;된다.&lt;/p&gt;
&lt;pre class=&quot;arduino&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot;&gt;&lt;code&gt;// Record에서 사용
public record UserDto(String name, int age) {
    @QueryProjection
    public UserDto {}
}

// 일반 클래스에서 사용
public class UserDto {
    private String name;
    private int age;
    
    @QueryProjection
    public UserDto(String name, int age) {
        this.name = name;
        this.age = age;
    }
}

// 사용법
return jpaQueryFactory
                .select(new QUserDto(member.name, member.age)  // Q클래스 사용!
                .from(member)
                .fetch();
&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;처음에는 Projections.constructor() 를 사용했다. 생성자에 잘못된 컬럼을 넣어도 아무런 경고나 에러가 발생하지 않았다. 이 경우에 런타임에서만 에러가 발생하였다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;@QueryProjection 방식은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;일반 생성자처럼 new를 사용&lt;/b&gt;하여 쿼리 결과를 받을 수 있고,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;생성자 필드가 정확한지 컴파일 타임에 잡을 수 있다&lt;/b&gt;. 쿼리 결과로 사용할 DTO나 클래스의 생성자에 애노테이션을 추가하는 것 말고는 Projections.constructor() 방식과 사용 방법이 거의 비슷하다.&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;사용하기 어렵지 않으면서 컴파일 타임 안정성이 높다&lt;/b&gt;고 생각하여&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;@QueryProjection 방식을 채택&lt;/b&gt;하게 되었다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Querydsl과 Paging을 통해 동적 정렬하기&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;우선, Controller에서@PageableDefault 를 이용하여 정렬에 대한 기본값을 설정했다. 아무런 값을 입력하지 않았을 때는 기본값이 Pageable 객체에 담겨 하위 계층으로 전달된다. 사용자가 sort=정렬기준,방향을 입력하면 이 값이 Pageable 객체에 담긴다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Pageable 객체를 가지고&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Querydsl과 함께 정렬을 수행하려면, Pageable 객체로부터 정렬에 대한 요청 값을 파싱해야 한다&lt;/b&gt;.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Querydsl 도입 전에는 SortOption이라는 record를 만들어 정렬 값을 받아 파싱했다. Querydsl을 도입함으로써 더욱 간편하게 동적 정렬을 구현할 수 있었다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Pageable 객체 파싱하여 정렬 객체 만들기&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;정렬 기준은 여러개가 될 수 있기 때문에, Pageable 객체 내부의 Sort는 리스트로 들어있다. 그래서, Pageable 객체의 sort를 가져와 반복문을 돌며 정렬 옵션을 가져와야 한다. 반복문을 돌며 아래와 같은 과정을 반복하면 된다. 아래는 봄봄 서비스에서 구현한 예시이다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1개의 정렬 옵션을 파싱한 과정&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;Sort에서 정렬 방향을 가져온다. 현재 서비스에서는 내림차순이 디폴트이기 때문에, pageable에 담긴 값이 Order.ASC인 경우만 체크하고, 나머지 경우는 모두 Order.DESC로 간주한다.&lt;/li&gt;
&lt;li&gt;Sort에서 정렬 기준 필드를 가져온다.&lt;/li&gt;
&lt;li&gt;Q클래스 내부를 보고 앞 과정에서 가져온 필드를 찾아 Path 객체를 생성한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;만약 Path 객체를 찾지 못하면 예외가 발생한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;정렬 기준 필드와 방향으로 OrderSpecifier 객체 생성
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;order by 절에 들어갈 표현식&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;Path 객체를 생성하는 과정에서,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;의도하지 않은 필드 기준으로 정렬이 일어날 수 있음&lt;/b&gt;을 알게되었다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;원치 않은 동작을 막기 위해,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;정렬 기준으로 삼고 싶은 필드를 정하고, 그 필드가 아닐 경우 예외 처리도 추가&lt;/b&gt;하였다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;⭐️&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;@Embeddable 필드로 Record를 사용하면 Q클래스가 생성되지 않는다&lt;/b&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;QueryDSL의 Q클래스 생성 방식&lt;/b&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;QueryDSL은 annotation processor를 기반으로 작동하는 APT(annotation processing tool)를 통해 컴파일 타임에 Q클래스를 생성한다. 이 과정에서 @Entity, @Embeddable 등의 애노테이션이 붙은 클래스를 분석하며,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;getter 메서드&lt;/b&gt;와&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;기본 생성자&lt;/b&gt;,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;필드 정보&lt;/b&gt;를 활용하여 메타 모델을 생성한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;특히 getter 메서드는 필드의 이름과 타입을 추론하는 데 핵심 역할을 한다. APT는 getName()처럼 **JavaBean 명명 규약(getX())**을 따르는 메서드를 통해 name이라는 필드를 인식한다. 또한 일부 구현에서는 기본 생성자가 없거나, 필드가 final인 경우 해당 필드를 무시하기도 한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;하지만 record는 다음과 같은 이유로 이러한 조건을 만족하지 못한다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모든 필드는 private final이며, setter가 없다 (immutable)&lt;/li&gt;
&lt;li&gt;기본 생성자가 없다&lt;/li&gt;
&lt;li&gt;getter가 getX()가 아니라 x() 형식이다&lt;/li&gt;
&lt;/ul&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이러한 특성은 JavaBean 규약과 불일치하며, QueryDSL APT가 Q타입을 생성하는 데 필요한 정보를 제공하지 못한다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;따라서, QueryDSL을 사용할 때 @Embeddable에 record를 사용하는 것은&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;b&gt;Q클래스 생성이 불가능하며, 권장되지 않는다&lt;/b&gt;.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;직접 문자열을 조합하던 기존 방식의 한계를 겪은 뒤, Querydsl을 적용하면서 타입 안정성과 가독성을 모두 만족시킬 수 있었다.&lt;/b&gt;&lt;/p&gt;</description>
      <category>BomBom 프로젝트</category>
      <author>gogi masidda</author>
      <guid isPermaLink="true">https://iwbdev.tistory.com/487</guid>
      <comments>https://iwbdev.tistory.com/entry/%EC%96%B4%EC%A7%80%EB%9F%BD%EB%8D%98-%EB%8F%99%EC%A0%81%EC%BF%BC%EB%A6%AC%EC%97%90-Querydsl%EC%9D%84-%EB%8F%84%EC%9E%85%ED%95%98%EC%97%AC-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0#entry487comment</comments>
      <pubDate>Fri, 8 Aug 2025 17:36:45 +0900</pubDate>
    </item>
    <item>
      <title>우테코 레벨 2까지의 회고</title>
      <link>https://iwbdev.tistory.com/entry/%EC%9A%B0%ED%85%8C%EC%BD%94-%EB%A0%88%EB%B2%A8-2%EA%B9%8C%EC%A7%80%EC%9D%98-%ED%9A%8C%EA%B3%A0</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;불안 속에 피어난 확신&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;이전의 나&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;학습 방법&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개념을 공부한 후에 적용하는 것이 나에게 잘맞는 학습 방법이라고 생각했다. 그래서 인터넷 강의나 학교 수업의 정해진 커리큘럼을 따라가기만 하였고, 학습 자료 내용만 이해하면 충분하다고 생각했다. 이해하고 내 것으로 만드는 시간이나 학습 키워드에 대해 더 깊이 고민하는 시간은 가지지 않아 학습했던 키워드를 다시 만났을 때, 내용을 제대로 설명하지 못하는 경우가 많았다. 지금 생각해보면, 학습 후에 제대로 적용해보지 않아 더 깊이 못나아간 것이라는 생각이 든다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;대인 관계&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 사람에 대해 궁금한 점이 없었다. 다른 사람이 나에 대해 질문해도, 답만 하고 되묻지 않았다. 첫인상이 차갑고 무섭다는 얘기도 들었다. 그러면서도, 나에 대해 안좋게 생각하는 것이 너무 두려웠고 친한 사람들이 멀어질까봐 두려웠다. 몸이 힘들어도, 시간적 여유가 없어도 억지로 만났고 집에서 쉬고 싶어도 억지로 만났다. 친구들은 나를 왜 좋아하지라는 불안도 있었다.&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;h4 data-ke-size=&quot;size20&quot;&gt;학습 방식의 전환&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;  생각 정리와 확장&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨 1과 레벨 2를 지내며 다른 크루들에 비해 생각의 깊이가 얕다는 생각이 들었다. 페어 프로그래밍이나 스터디에서 다른 크루들은 내가 알지 못했던 주제를 자연스럽게 언급했고, 나는 하고 싶은 말이 있어도 근거가 부족해 말하지 못하는 경험을 반복했다. 문제라고 하기 싫지만, 이 문제의 이유 중 하나는 기반 지식이 부족해서라고 생각했다. 그래서, 처음으로 개발 서적을 읽어보았다. 책을 읽어도 바로 대화나 토론에서 영향력을 펼치기는 어려웠지만, 기반 지식이 어느정도 쌓여 대화나 토론에서 얻어갈 수 있는 것은 더욱 많아졌다. 이 과정에서 정리가 안된 채로 생각을 말하는 것에 대한 고민이 생겼다. 머릿 속으로 생각 정리를 한 후에 말하기를 목표로 정했다. 하지만, 머릿 속으로 정리한 생각들이 금방 잊혀지고, 질문이 오거나 그러면 하고 싶은 말을 전부 할 수 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것에 대한 나의 해결책은 PR 메시지를 작성하는 중에 나왔다. 여러 설계 방식 중 왜 이 방식을 택했는지 리뷰어 분이 알아보기 쉽게 정리하며 생각이 쉽게 정리되는 느낌을 받았다. 글로 내 생각을 정리하며 스스로에게 '왜?'라는 질문이 자연스럽게 떠올랐고, 그것에 답하면서 내 생각에 확신을 점점 생기게 되었다. 더욱 확신을 갖기 위해 생각이 모두 정리됐다고 생각되면, 그 글을 AI를 통해 내 생각을 검토받고 잘한 점과 부족한 점을 알게되며 확신을 쌓을 수 있었다. 또, AI가 제시하는 추가 질문을 통해 생각을 확장할 수 있었다. 크루들과 대화를 하면서 크루들이 내가 모르는 것에 대해 알고, 더 깊은 생각을 하는 것에 불안해하는 것이 아니라, 그 &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;/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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 개발자가 되고 싶은 것이 아니라, 어떤 사람이 되고 싶은지 생각했다. '&lt;b&gt;함께 일하고 싶은 사람&lt;/b&gt;'이 되고 싶다.&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;미션을 수행하다가 갑자기 흥미로운 주제에 빠지고, 실험을 하며 시간을 보내는 것에도 불안이 있었다. 예상치 못하게 미용실에서 머리를 자르다가 해결됐다. 미용사 선생님이 요즘엔 어떤 고민이 있냐, 우테코에서는 잘하고 있냐 물어보셨다. 학습의 깊이를 정하지 못해 오랜 시간 빠져있다가 진짜 알아야 하는 것은 알지 못하고 넘어가는 것에 대한 고민을 말씀드렸다. 학습의 깊이를 정하지 못하는 것은 해결해야 하는게 맞지만, 아직은 일하는 것이 아니라 학습자이기 때문에 하고 싶은거를 하는 것도 나쁘지 않다고 말씀해주셨다.&amp;nbsp; 맞다. 우리는 학습자다. 오히려 지금 실험해보고 오랜 시간 한 주제에 빠져 고민해봐야 나중에 더 잘할 수 있을 것이라는 생각을 가지게 되었다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;대인관계&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우테코를 지내며 너무 바빠서 '친구들을 못만나다가 점점 멀어지면 어떡하지'라는 불안이 생겼다. 친구들은 내가 없는 추억을 쌓는 것 같고, 만나면 내가 없는 추억에 대해 대화해서 더이상 어울릴 수 없을 것 같았다. 하지만, 2월부터 6월까지 지나온 지금 시점에서 친구들은 여전히 나를 찾아준다. 놀자고 5번 부르면 거의 1번밖에 못가지만, 다들 나의 일정을 이해해주고, 언제 볼수 있냐고 찾는 것에서 안심할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;맺는 말&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;레벨2까지의 시간은 단순히 기술을 익히는 시간이 아니었다.&lt;br /&gt;나는 생각을 정리하고 표현하는 법을 배웠고, 불안을 마주하는 방식도 조금씩 바꿔나갔다.&lt;br /&gt;실험과 실패도 성장의 일부로 받아들이게 되었다.&lt;br /&gt;이제 나는 단순히 &amp;lsquo;개발을 잘하는 사람&amp;rsquo;이 아니라, &lt;b&gt;&amp;lsquo;함께 일하고 싶은 사람&amp;rsquo;&lt;/b&gt;이 되기 위해 성장하고 있다.&lt;/p&gt;</description>
      <category>우테코</category>
      <author>gogi masidda</author>
      <guid isPermaLink="true">https://iwbdev.tistory.com/486</guid>
      <comments>https://iwbdev.tistory.com/entry/%EC%9A%B0%ED%85%8C%EC%BD%94-%EB%A0%88%EB%B2%A8-2%EA%B9%8C%EC%A7%80%EC%9D%98-%ED%9A%8C%EA%B3%A0#entry486comment</comments>
      <pubDate>Fri, 13 Jun 2025 00:29:07 +0900</pubDate>
    </item>
    <item>
      <title>로깅 개선하기</title>
      <link>https://iwbdev.tistory.com/entry/%EB%A1%9C%EA%B9%85-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개발 초기에는 로그를 보기 쉽게 여러 줄로 출력했습니다. 각 항목을 줄마다 구분해두면 사람이 읽기에는 편하다고 생각했기 때문입니다. 하지만 리뷰어인 로키의 피드백을 통해 &lt;span&gt;&lt;b&gt;로그는 사람이 아닌 도구가 읽기 쉬워야 한다는 점&lt;/b&gt;&lt;/span&gt;을 알게 되었고, 이후 로그를 한 줄로 기록하는 방향으로 개선하게 되었습니다.&lt;/p&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. 로그는 왜 한 줄이어야 할까?&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;사람이 아닌 시스템이 로그를 읽는다&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 다음과 같은 식으로 로그를 찍었습니다:&lt;/p&gt;
&lt;pre class=&quot;yaml&quot;&gt;&lt;code&gt;[ReservationService.createReservation]
요청 사용자 ID: 123
요청 날짜: 2025-06-01
요청 테마 ID: 7&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 로그 분석 도구나 CLI 기반 필터링 도구 입장에서는 이 로그는 4줄짜리 로그가 아니라, 4개의 독립적인 이벤트처럼 인식될 수 있습니다. 이는 다음과 같은 문제를 유발합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;한 줄 로그의 장점&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span&gt;grep&lt;/span&gt;, &lt;span&gt;awk&lt;/span&gt;, &lt;span&gt;sed&lt;/span&gt; 등 텍스트 도구로 한 이벤트 단위 필터링이 쉬움&lt;/li&gt;
&lt;li&gt;ELK, Splunk 등의 로그 분석 툴은 줄바꿈을 기준으로 레코드를 나누기 때문에, 여러 줄 로그는 쪼개져서 저장됨&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;/p&gt;
&lt;pre class=&quot;routeros&quot;&gt;&lt;code&gt;[ReservationService.createReservation] userId=123, date=2025-06-01, themeId=7&lt;/code&gt;&lt;/pre&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. 구조화된 로깅으로 한 걸음 더&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순히 한 줄로 만드는 것에 그치지 않고, 로그를 구조화하면 운영 효율성이 훨씬 높아집니다. 구조화된 로깅은 로그 메시지를 JSON이나 키-값 쌍 형식으로 구성하는 방식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;예시&lt;/b&gt;&lt;/h4&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;timestamp&quot;: &quot;2025-06-01T10:15:00+09:00&quot;,
  &quot;level&quot;: &quot;INFO&quot;,
  &quot;event&quot;: &quot;createReservation&quot;,
  &quot;userId&quot;: 123,
  &quot;themeId&quot;: 7,
  &quot;date&quot;: &quot;2025-06-01&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;장점&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;검색, 필터링, 집계를 로그 도구에서 손쉽게 수행 가능&lt;/li&gt;
&lt;li&gt;정형화된 구조 덕분에 운영 자동화 및 모니터링 시스템 연동이 쉬움&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;3. 민감 정보는 마스킹하자&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그에 사용자 이메일이나 카드 번호 같은 민감 정보가 포함되면 보안 이슈가 발생할 수 있습니다. 이를 방지하기 위해 로그 마스킹 기능을 직접 구현했습니다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;적용 방식&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;logger-spring.xml&lt;span&gt; 설정 추가&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span&gt;MaskingPatternLayout&lt;/span&gt; 클래스를 만들어 정규식 기반으로 민감 정보 자동 마스킹&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;lsl&quot;&gt;&lt;code&gt;// 예: 이메일 주소 마스킹
&quot;[userEmail=\\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\\.[A-Z|a-z]{2,}\\b]&quot;
&amp;rarr; &quot;[userEmail=***]&quot;&lt;/code&gt;&lt;/pre&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;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;4. 불필요한 로그 줄이기 &amp;ndash; 애노테이션 기반 적용&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;초기에는 모든 메서드에 로그를 남기려 했지만, 조회(read) 요청은 운영상 로그가 불필요한 경우가 많다는 점을 고려하여, 필요한 경우에만 로그를 남기도록 변경했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;적용 전략&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;방법&lt;/b&gt;&lt;b&gt;설명&lt;/b&gt;&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;1. &lt;span&gt;@PointCut&lt;/span&gt;에 메서드 직접 명시&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;유연성이 낮고 관리 비용 증가&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;2. 커스텀 애노테이션 사용&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;유지보수가 쉽고, 의도를 코드에 명확히 드러냄&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 2번 방법을 선택해 &lt;span&gt;@Loggable&lt;/span&gt; 애노테이션을 만들어, 생성/수정/삭제 메서드에만 적용하였습니다.&lt;/p&gt;
&lt;pre class=&quot;aspectj&quot;&gt;&lt;code&gt;@Loggable
public void createReservation(...) {
    ...
}&lt;/code&gt;&lt;/pre&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;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;마무리하며&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;개선 항목&lt;/b&gt;&lt;b&gt;내용&lt;/b&gt;&lt;/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;&lt;span&gt;로그 한 줄 출력&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;텍스트 처리 도구와 로그 분석 툴에 적합&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;구조화된 로깅&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;JSON 포맷으로 자동화와 검색 용이&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;민감 정보 마스킹&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;사용자 개인정보 보호&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;span&gt;필요한 로그만 남기기&lt;/span&gt;&lt;/td&gt;
&lt;td&gt;&lt;span&gt;&lt;span&gt;@Loggable&lt;/span&gt;로 관리 포인트 최소화&lt;/span&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 로그 개선 작업은 단순히 로그를 잘 찍는 것을 넘어, 운영 환경과 로그 분석 도구를 고려한 실질적인 설계 개선 경험이었습니다. 로그 역시 설계의 일부라는 사실을 실감했고, 이후 프로젝트에서도 이를 적극 반영할 수 있을 것 같습니다.&lt;/p&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;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;참고 자료&lt;/b&gt;&lt;/h4&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;a style=&quot;color: #0070d1;&quot; href=&quot;https://www.baeldung.com/logback-mask-sensitive-data&quot;&gt;Baeldung - Logback Masking&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;검프의 테코톡: 로깅 발표&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Spring</category>
      <author>gogi masidda</author>
      <guid isPermaLink="true">https://iwbdev.tistory.com/485</guid>
      <comments>https://iwbdev.tistory.com/entry/%EB%A1%9C%EA%B9%85-%EA%B0%9C%EC%84%A0%ED%95%98%EA%B8%B0#entry485comment</comments>
      <pubDate>Tue, 10 Jun 2025 01:38:38 +0900</pubDate>
    </item>
    <item>
      <title>데이터베이스에 종속되지 않는 Repository 테스트를 작성할 수는 없을까?</title>
      <link>https://iwbdev.tistory.com/entry/%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4%EC%97%90-%EC%A2%85%EC%86%8D%EB%90%98%EC%A7%80-%EC%95%8A%EB%8A%94-Repository-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%EC%9E%91%EC%84%B1%ED%95%A0-%EC%88%98%EB%8A%94-%EC%97%86%EC%9D%84%EA%B9%8C</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;JDBC에서 JPA로 전환하며 느낀 테스트 설계의 고민&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 고민은 방탈출 예약 대기 미션으로 넘어오면서 시작되었다.&lt;br /&gt;요구사항 중 하나는 기존 JDBC 기반의 구현을 Spring Data JPA로 전환하는 것이었다.&lt;br /&gt;전환 이후, 이전에 작성해둔 테스트 코드들이 대거 실패하면서 꽤나 당황스러운 경험을 했다.&lt;br /&gt;물론, 테스트를 처음부터 깔끔하게 설계하지 못한 내 책임도 있었겠지만, JdbcTemplate에서 EntityManager로 바뀐 기술 간의 차이 역시 그 원인 중 하나였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 경험은 나에게 새로운 질문을 던지게 했다:&lt;br /&gt;&amp;ldquo;Spring Data JPA를 쓰는데, Repository 테스트를 굳이 작성해야 할까?&amp;rdquo;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;내가 선택한 방향&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 고민이 많았다.&lt;br /&gt;하지만 레벨 2 수업 시트에서 @DataJpaTest라는 힌트를 보고, JPA 기반 Repository 테스트를 일단 작성해보기로 했다.&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;Query Method 기반 메서드&lt;br /&gt;Spring Data JPA가 자동 구현하는 메서드&lt;/li&gt;
&lt;li&gt;@Query 기반 JPQL/SQL 메서드&lt;br /&gt;직접 쿼리를 작성한 복잡 로직&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단순한 CRUD는 테스트하지 않았고, 위와 같은 &amp;ldquo;내가 작성한 로직이 포함된 쿼리&amp;rdquo;만 테스트 대상으로 삼았다.&lt;br /&gt;하지만 테스트를 작성할수록 고민이 들었다.&lt;br /&gt;&amp;ldquo;정말 모든 메서드를 테스트하지 않아도 괜찮을까?&amp;rdquo;&lt;br /&gt;이 고민의 바닥에는 Repository 테스트를 왜 작성하는가라는 근본적인 질문이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;테스트의 목적은 무엇인가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 JPA 자체의 기능을 믿는 것이 목적이라면, Query Method는 굳이 테스트하지 않아도 될 것이다.&lt;br /&gt;그러나 내가 설정한 테스트의 목적은 달랐다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;ldquo;데이터베이스 접근 기술(JDBC &amp;rarr; JPA &amp;rarr; 다른 기술)이 바뀌더라도, Repository가 여전히 내가 원하는 동작을 하는지를 검증하고 싶다.&amp;rdquo;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;즉, 내가 만든 추상화가 바뀐 구현체에서도 동일하게 작동하는지 확인하고 싶었다.&lt;br /&gt;이는 결과적으로 Service가 데이터베이스 기술에 종속되지 않고, 변경에도 안정성을 유지하는 기반이 된다.&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;데이터베이스에 종속되지 않는 Repository 테스트를 위해 필요한 것&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스에 종속되지 않는 테스트와 Service에서의 변경을 최소화하는 것은 공통의 목적을 공유한다고 생각한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Service는 특정 기술(JPA, JDBC)에 의존하면 안 된다.&lt;br /&gt;&amp;rarr; 인터페이스 추상화(MemberRepository)를 두고 구현체에서 기술을 캡슐화해야 한다.&lt;/li&gt;
&lt;li&gt;Repository 구현체는 Service 입장에서 행위만 보장하면 된다.&lt;br /&gt;&amp;rarr; 어떤 기술로 구현되었든 상관없이 &amp;ldquo;동일한 결과&amp;rdquo;를 내는 것이 중요하다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;내가 작성한 테스트&lt;/h3&gt;
&lt;pre class=&quot;reasonml&quot;&gt;&lt;code&gt;@ActiveProfiles(&quot;test&quot;)
@DataJpaTest
class JpaMemberRepositoryTest {

    @PersistenceContext
    private EntityManager entityManager;

    @Autowired
    private MemberRepository memberRepository;

    @Test
    void findByEmail() {
        //given
        Member member = TestFixture.createDefaultMember();
        entityManager.persist(member);

        //when
        Optional&amp;lt;Member&amp;gt; result = memberRepository.findByEmail(member.getEmail());

        //then
        assertThat(result.isPresent()).isTrue();
        assertThat(result.get().getEmail()).isEqualTo(member.getEmail());
    }
}&lt;/code&gt;&lt;/pre&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;MemberRepository 인터페이스를 통해 검증하므로 행위 기준으로는 기술 독립적이다.&lt;/li&gt;
&lt;li&gt;하지만 @DataJpaTest, EntityManager 사용은 기술 종속적이다.&lt;br /&gt;&amp;rarr; 기술이 변경되면 @JdbcTest, JdbcTemplate으로 다시 작성해야 한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면 테스트 준비 과정(given)에 사용하는 entityManager.persist()를 memberRepository.save()로 바꾸면 기술 종속성을 줄일 수 있을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가능은 하지만, save() 자체도 테스트 대상이기 때문에 &amp;ldquo;검증되지 않은 것을 검증에 사용한다&amp;rdquo;는 문제가 생긴다.&lt;br /&gt;결국, 하나의 메서드를 검증하기 위해 또 다른 검증되지 않은 메서드를 사용하는 것은 위험할 수 있다고 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른 테스트에서 충분한 검증이 이루어지면 괜찮겠지만, 아직은 충분함의 기준도 모르겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결론&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터베이스 접근 기술에 완전히 독립적인 Repository 테스트를 작성하는 것은 쉽지 않다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;테스트 환경(@DataJpaTest, @JdbcTest)&lt;/li&gt;
&lt;li&gt;테스트 준비 코드(EntityManager, JdbcTemplate)&lt;br /&gt;이 모두가 기술에 종속되기 때문이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 Repository 인터페이스 기준으로 테스트 대상을 삼고, 기술은 내부로 감추었다면, 우리는 기술 변경에 영향을 덜 받는 구조를 설계한 것이다.&lt;br /&gt;궁극적으로는 테스트의 기술 독립성보다는 Repository의 계약이 보장되는지를 검증하는 데 집중하는 것이 더 현실적인 목표라고 생각한다.&lt;/p&gt;</description>
      <category>우테코</category>
      <author>gogi masidda</author>
      <guid isPermaLink="true">https://iwbdev.tistory.com/484</guid>
      <comments>https://iwbdev.tistory.com/entry/%EB%8D%B0%EC%9D%B4%ED%84%B0%EB%B2%A0%EC%9D%B4%EC%8A%A4%EC%97%90-%EC%A2%85%EC%86%8D%EB%90%98%EC%A7%80-%EC%95%8A%EB%8A%94-Repository-%ED%85%8C%EC%8A%A4%ED%8A%B8%EB%A5%BC-%EC%9E%91%EC%84%B1%ED%95%A0-%EC%88%98%EB%8A%94-%EC%97%86%EC%9D%84%EA%B9%8C#entry484comment</comments>
      <pubDate>Wed, 4 Jun 2025 11:25:44 +0900</pubDate>
    </item>
    <item>
      <title>Spring Bean 등록 방법 비교</title>
      <link>https://iwbdev.tistory.com/entry/Spring-Bean-%EB%93%B1%EB%A1%9D-%EB%B0%A9%EB%B2%95-%EB%B9%84%EA%B5%90</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;Spring Bean이란?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring에서 IoC 컨테이너(Spring Context, Spring Container)가 관리하는 객체를 Bean이라고 부른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IoC는 제어의 역전을 이르는 말인데, 개발자가 직접 객체를 생성하고 관리하지 않고, Spring Container가 이 역할을 대신 수행하는 것처럼, 개발자가 관리하던 것을 외부에서 관리해주는다는 것이다.&lt;br /&gt;IoC를 통해 가져올 수 있는 장점은,&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Spring Container (Spring Context)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Bean의 생명 주기를 관리하며, 생성된 Spring Bean들에게 추가적인 기능을 제공한다.&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;Bean의 생명 주기 관리&lt;/li&gt;
&lt;li&gt;&amp;nbsp;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한, IoC와 DI의 원리가 스프링 컨테이너에 적용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;IoC와 DI의 원리&lt;/b&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Spring Container의 구조&lt;/h3&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;등록 방법&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Spring Context에 Spring Bean을 등록하기 위한 방법은 아래와 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;@Configuration과 @Bean을 사용하는 방법&lt;br /&gt;@Configuration을 붙인 클래스에서 Spring Bean들 간의 의존성 주입과 함께 Bean을 등록할 수 있다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;java&quot;&gt;&lt;code&gt;@Configuration
public class AppConfig {

  @Bean
  public MemberRepository memberRepository() {
    return new MemoryMemberRepository();
  }
  ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 개발자가 직접 Spring Bean의 생성 및 설정을 직접 제어하고, 의존성 주입까지 제어할 수 있게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Bean이 붙은 메서드는 Spring Container에 의해 호출되어 해당 메서드가 반환하는 객체가 Spring Container에 빈으로 등록된다.&lt;br /&gt;따로 설정하지 않으면, 메서드의 이름은 해당 빈의 고유한 식별자로 사용된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따로 설정하기 위해서는, @Bean(&quot;newName&quot;)처럼 사용하면 된다.&lt;/p&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;@ComponentScan과 @Componenet를 사용하는 방법&lt;br /&gt;클래스 위에 @Component를 붙이면 Spring Bean으로 자동으로 추가된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@Component나 @Service등을 클래스에 붙이지 않고, Spring Bean인 클래스에 의존성 주입을 시도하면, 예외가 발생한다.&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;&lt;b&gt;@Component&lt;/b&gt;&lt;br /&gt;기본적으로 컴포넌트 스캔이 되도록 하기 위해 사용하는 어노테이션이다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 어노테이션은 내부 구현(코드)에 @Component가 붙어 있어 컴포넌트 스캔의 대상이 된다.&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;@Controller&lt;/b&gt;&lt;br /&gt;컴포넌트 스캔의 대상 + Spring 내부 로직에서 요청을 처리하기 위한 적절한 Handler(Controller)를 찾는데에 사용된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@Service&lt;/b&gt;&lt;br /&gt;컴포넌트 스캔의 대상이 되는 것 이외에 특별한 기능이나 처리가 없다. 명시적으로 비즈니스 계층임을 나타내기 위해 사용한다. 또한, 추후에 추가적인 기능이 생길 경우를 대비하여 만든 것이다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@Repository&lt;/b&gt;&lt;br /&gt;Spring 데이터 접근 계층으로 인식되고, 추가적으로 의존성을 더하면, 다양한 데이터베이스 관련 예외들을 Spring에서 사용하는 예외로 감싸준다.&lt;br /&gt;또한, @JdbcTest를 사용할 경우, 데이터베이스 접근 계층만 컴포넌트 스캔하도록 하는데, 여기서 사용된다.&lt;/li&gt;
&lt;li&gt;&lt;b&gt;@Configuration&lt;/b&gt;&lt;br /&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;@Component와 @Bean&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;@ComponentScan&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Target({ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Indexed
public @interface Component&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위는 @Component의 구현 코드다. @Target에 ElementType.TYPE 설정은 Class나 Interface에 붙일 수 있다는 것이다. 따라서 Bean은 클래스나 인터페이스만을 대상으로 한다는 것을 알 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;@Bean&lt;/h3&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;@Target({ElementType.METHOD, ElementType.ANNOTATION_TYPE})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Bean &lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위는 @Bean의 구현 코드다. @Component와는 달리, &lt;code&gt;ElementType.METHOD&lt;/code&gt;, &lt;code&gt;ElementType.ANNOTATION_TYPE&lt;/code&gt;가 @Target에 적혀있다.&lt;br /&gt;따라서, @Bean은 메서드나 어노테이션에만 붙을 수 있다는 것이다.&lt;br /&gt;메서드에 붙일 수 있어 객체 생성 로직이 복잡하거나 팩토리 메서드를 사용하여 생성하거나, 외부 라이브러리의 객체를 Spring Container에 Bean으로 등록하고 싶을 때 사용하면 좋다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;등록되는 내부 과정이 다른가?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 어노테이션은 붙일 수 있는 곳이 다르다. 그러면 Spring Container(Context)에 등록되는 과정도 다를까?&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;@ComponentScan&lt;/b&gt;&lt;br /&gt;Spring Container에게 특정 패키지 및 하위 패키지를 스캔하여 @Component와 파생 어노테이션(@Service, @Repository, ...)이 붙은 클래스르 자동으로 찾아 빈을 등록하도록 한다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;haml&quot;&gt;&lt;code&gt;**핵심 원리**

- 자동 탐색: 지정된 경로(지정하지 않았을 경우엔 전체 패키지)를 탐색하여 특정 어노테이션이 붙은 클래스를 식별한다.
-  기본 빈 이름 생성: 클래스 이름의 첫 글자를 소문자로 변경한 이름을 기본 빈 이름으로 사용한다. 
-  의존성 자동 주입: 등록된 빈들 간의 의존성은 @Autowired 등의 어노테이션을 통해 자동으로 주입된다.
-  설정 방식: @SpringBootApplication 또는 @Configuration이 붙은 클래스에 @ComponentScan 어노테이션을 사용하여 스캔할 패키지 경로를 지정한다.
      - @SpringBootApplication 내부에 @ComponentScan이 붙어 있지만, @SpringBootApplication 만으로는 컴포넌트 스캔할 하위 패키지를 지정할 수 없고 제외할 패키지? 클래스?만 지정 가능하다. &lt;/code&gt;&lt;/pre&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;@Configuration과 @Bean&lt;/b&gt;&lt;br /&gt;@Configuration은 해당 클래스가 Spring의 설정 클래스임을 명시하는 것이다. 이 어노테이션이 붙은 클래스 내에서 @Bean이 붙은 메서드들은 Spring Container에 등록될 Spring Bean을 정의한다. 즉, @Bean 메서드들을 모아두는 설정 클래스임을 명시하는 것이다.&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;명시적 빈 정의: 개발자가 직접 빈 객체의 생성 로직을 메서드 내에 정의하고, 해당 메서드를 @Bean으로 등록하여 Spring Container에 빈을 제공한다.&lt;/li&gt;
&lt;li&gt;빈 간의 의존성 관리: @Bean 메서드 내에서 다른 빈을 주입 받아 사용할 수 있다.&lt;/li&gt;
&lt;li&gt;유연한 빈 설정: 객체 생성 로직을 개발자가 직접 제어할 수 있다. 그래서 외부 라이브러리 빈 등록, 팩토리 메서드 패턴, 빈 초기화/해제 로직 설정 등 다양한 방식으로 빈을 설정할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;@Bean은 보통 @Configuration 클래스 내의 메서드에 붙어 해당 메서드가 반환하는 객체를 Spring Container에 등록되도록 한다. 따로 지정하지 않으면 메서드의 이름이 빈의 이름이 된다.&lt;/li&gt;
&lt;/ol&gt;
&lt;pre class=&quot;less&quot;&gt;&lt;code&gt;**@ComponentScan과 다르게 의존성 관리가 되는건가?**
ComponentScan에서는 생성자 주입을 통해 의존성 관리가 되는데, @Configuration, @Bean을 이용한 방식에서는 수동으로 하는 것이 다른거 같다. 
 **@Bean을 @Configuration이 아닌 클래스에서 사용하면?**

 ```
public class MyConfig {
    @Bean
    public MyService myService() {
        return new MyService();
    }
}
```
위와 같이 MyConfig에는 @Configuration이라는 어노테이션이 붙어있지 않지만, 내부에 존재하는 메서드에는 @Bean이 붙어있다.
Spring에서는 MyConfig를 일반 Java 클래스로 취급하기 때문에 Spring Container는 내부에 @Bean이 붙은 메서드가 존재하는지 알지 못해서 Spring Bean으로 등록되지 않는다. &lt;/code&gt;&lt;/pre&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;/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;Spring Container에 빈을 등록하는 방식&lt;/li&gt;
&lt;li&gt;Spring이 관리하는 객체가 된다. =&amp;gt; 필요할 때 의존성 주입 가능, 생명 주기 관리&lt;/li&gt;
&lt;li&gt;최종적으로 빈 메타정보 (BeanDefinition)으로 등록된다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;이후 singleton 혹은 다른 Scope에 맞춰 객체를 생성한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; start=&quot;4&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&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;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;@Component: 클래스 단위로 등로&lt;/li&gt;
&lt;li&gt;@Configuration + @Bean: 클래스(@Configuration) + 메서드(@Bean) 단위로 등록&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;li&gt;탐지 방식
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;@Component: 컴포넌트 스캐닝&lt;/li&gt;
&lt;li&gt;@Configuration + @Bean: @Configuration 클래스를 명시적으로 등록하거나, @SpringBootApplication에 의해 자동 등록&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;</description>
      <category>Spring</category>
      <author>gogi masidda</author>
      <guid isPermaLink="true">https://iwbdev.tistory.com/483</guid>
      <comments>https://iwbdev.tistory.com/entry/Spring-Bean-%EB%93%B1%EB%A1%9D-%EB%B0%A9%EB%B2%95-%EB%B9%84%EA%B5%90#entry483comment</comments>
      <pubDate>Wed, 21 May 2025 14:45:10 +0900</pubDate>
    </item>
    <item>
      <title>@RequestBody의 동작 원리와 @ModelAttribute</title>
      <link>https://iwbdev.tistory.com/entry/RequestBody%EC%9D%98-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC%EC%99%80-ModelAttribute</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;@RequestBody&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Request 메시지나 Response 메시지에 담기는 Body의 대표적인 데이터 형식은 JSON이다.&lt;br /&gt;&lt;b&gt;@RequestBody는 JSON 형식의 데이터를 자바 객체로 변환한다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;```
@Controller
public class UserController {

  @PostMapping(&quot;/users&quot;)
  @ResponseBody
  public ResponseEntity&amp;lt;User&amp;gt; createUser(@RequestBody User user) {
      users.add(user);
      return ResponseEntity.ok(user);
  }
}

//요청 예시
{
  &quot;name&quot;: &quot;홍길동&quot;,
  &quot;age&quot;: 30
}

-&amp;gt;

위 요청이 들어오면, @RequestBody에 의해 name은 &quot;홍길동&quot;, age는 30이라는 값을 가지는 User 객체를 받게 된다. 
```&lt;/code&gt;&lt;/pre&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;HttpMessageConverter의 동작 원리&lt;/h3&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청 / 응답의 Body와 Java 객체 간의 직렬화 / 역직렬화를 담당한다.
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;요청: Http Body -&amp;gt; Java 객체 (역직렬화)&lt;/li&gt;
&lt;li&gt;응답: Java 객체 -&amp;gt; Http Body (직렬화)&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;직렬화/역직렬화를 위해, Spring에 등록된 여러 HttpMessageConverter 중에서 어떤 Converter가 동작해야 하는지 결정되어야 한다. 이것은 어떻게 이루어질까?&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;code&gt;canRead()&lt;/code&gt;, 직렬화일 경우 &lt;code&gt;canWrite()&lt;/code&gt; 메서드로 해당 Converter가 Content-Type과 변환하려는 객체의 타입을 지원하는지 확인한다.&lt;br /&gt;가장 먼저 true를 반환하는 Converter를 사용하여 역직렬화일 경우 &lt;code&gt;read()&lt;/code&gt;, 직렬화일 경우 &lt;code&gt;write()&lt;/code&gt; 메서드를 사용하여 변환을 수행한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러 HttpMessageConverter 중에서 기본 문자 처리는 &lt;code&gt;StringHttpMessageConverter&lt;/code&gt;가, 기본 객체 처리(JSON)는 &lt;code&gt;MappingJackson2HttpMessageConverter&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;추가로, &lt;b&gt;역직렬화시, JSON에 명시된 필드명 Key를 자바 객체의 필드명과 매핑시킬 때, 리플렉션을 사용한다.&lt;/b&gt;&lt;br /&gt;이때, 기본 생성자와 getter나 setter를 사용한다.&lt;br /&gt;getter나 setter 메서드를 직접 이용하는 것이 아니라, getter나 setter의 이름을 이용하는 것이다.&lt;br /&gt;접두사 get이나 set을 지우고, 나머지 문자의 첫문자를 소문자로 변환한 문자열을 참조한다. 이렇게 필드명을 알아내어, 적절한 필드에 값을 넣어 객체를 만들게 된다.&lt;br /&gt;만약 getter와 setter가 모두 존재하지 않는다면 &lt;code&gt;HttpMessageNotWritableException&lt;/code&gt;이 발생하게 된다.&lt;/p&gt;
&lt;hr data-ke-style=&quot;style1&quot; /&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;학습 중에 의문이든 것이 있다. HttpMessageConverter에 &lt;code&gt;FormHttpMessageConverter&lt;/code&gt;가 있다. 그런데, Form 데이터 처리를 할 때는 @RequestParam을 사용하는 것으로 알고 있었다.&lt;/p&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;h3 data-ke-size=&quot;size23&quot;&gt;@RequestBody에 FormHttpMessageConverter가 있는데, @RequestParam 왜 써?&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, &lt;code&gt;FormHttpMessageConverter&lt;/code&gt;는 HTTP 바디에 있는 'application/x-www-form-urlencoded'라는 Content-Type을 읽고 쓸 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;name=John+Doe&amp;amp;age=30&amp;amp;city=New+York&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;위 예시가 application/x-www-form-urlencoded 의 예시다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;FormHttpMessageConverter는 위와 같은 Content-Type을&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;json&quot;&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;John Doe&quot;,
  &quot;age&quot;: &quot;30&quot;,
  &quot;city&quot;: &quot;New York&quot;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이와 같은 형태로 파싱하고, &lt;b&gt;MultiValueMap 타입이나 객체로 변환해준다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;@RequestParam&lt;/code&gt;은&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;dts&quot;&gt;&lt;code&gt;name=John+Doe&amp;amp;age=30&amp;amp;city=New+York&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;위와 같은 형태의 폼 데이터나 url 쿼리 스트링가 들어왔을 때, 파싱한 이후에, MultiValueMap 타입이나 객체로 변환해주는 것이 아니라, 개별 값을 추출한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;즉, String이나 int 등 개별 타입으로 가져온다는 것이다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;폼 데이터: Post 요청의 Body에 name=John&amp;amp;age=30 형태&lt;/li&gt;
&lt;li&gt;url 쿼리 스트링: /search?name=John&amp;amp;age=30 형태&lt;/li&gt;
&lt;/ul&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;@ModelAttribute&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;HTTP 요청 파라미터를 자바 객체에 바인딩하고, 그 객체를 모델에 자동으로 추가해주는 어노테이션이다.&lt;br /&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; &amp;nbsp;참고&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;HTTP 요청 파라미터
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;쿼리 파라미터&lt;/li&gt;
&lt;li&gt;폼 데이터&lt;/li&gt;
&lt;li&gt;@RequestBody 파라미터&lt;/li&gt;
&lt;li&gt;Path Variable&lt;/li&gt;
&lt;li&gt;헤더 파라미터&lt;/li&gt;
&lt;li&gt;쿠키&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 파라미터에 사용&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;HTTP 요청 파라미터의 값들이 자바 객체의 필드에 자동으로 바인딩 된다.&lt;br /&gt;바인딩된 객체는 모델에도 자동으로 추가되어 뷰에서 사용 가능하다.&lt;br /&gt;주로 폼 데이터를 객체로 받을 때 사용한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;폼 데이터를 받을 때 사용할 경우, 사용자가 입력한 값이 자동으로 해당 모델 객체의 필드에 채워지고, 그 결과로 자바 객체가 생성된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;예시&lt;/b&gt;&lt;/p&gt;
&lt;pre class=&quot;kotlin&quot;&gt;&lt;code&gt;@PostMapping(&quot;/register&quot;)
public String register(@ModelAttribute User user) {
    //요청 파라미터가 User 객체의 필드에 자동으로 바인딩
    //user 객체는 모델에도 자동 추가
    return &quot;registerResult&quot;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파라미터 타입이 String, int 등 단순 타입이 아니면 @ModelAttribute를 생략해도 동작한다!&lt;/p&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. 메서드에 사용&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;@ModelAttribute가 붙은 메서드는 해당 메서드가 속한 컨트롤러 클래스의 메서드보다 항상 먼저 실행되어 공통적으로 모델에 데이터를 추가할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre class=&quot;dart&quot;&gt;&lt;code&gt;@ModelAttribute(&quot;roles&quot;)
public List&amp;lt;String&amp;gt; roles() {
    return List.of(&quot;USER&quot;, &quot;ADMIN&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게, 컨트롤러의 모든 요청 전에 실행되어 모델에 roles라는 이름으로 리스트를 추가할 수 있다.&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;@ModelAttribte의 동작 원리&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 파라미터 타입의 객체 인스턴스를 생성해야 한다.&lt;br /&gt;이때, 생성자를 사용하는데, 기본 생성자가 있다면, 기본 생성자를 사용한다. 여러개의 생성자가 있으면 &lt;b&gt;매개 변수가 가장 적은 것을 우선&lt;/b&gt;으로 선택한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;br /&gt;&lt;b&gt;기본 생성자를 가장 우선으로 하는 이유는, 파라미터가 적은게 객체 생성 실패 가능성을 최소화할&lt;/b&gt; &lt;b&gt;수 있기 때문이다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기본 생성자를 쓸 경우, &lt;b&gt;필드에 값을 넣기 위한 setter가 선언되어 있어야 한다.&lt;/b&gt;&lt;br /&gt;&lt;b&gt;매개 변수가 있는 생성자를 쓸 경우, 생성자에 있는 매개 변수를 위한 setter가 있을 필요는 없다.&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;@RequestBody와 @ModelAttribute의 차이&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;두 어노테이션 모두 클라이언트가 보낸 데이터를 객체로 바인딩할 때 사용할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, @RequestBody는 JSON, XML 등 구조화된 데이터를 객체로 변환하기 위해 사용하고, @ModelAttribute는 JSON 타입을 받을 수 없고, 폼 데이터, Multipart 등의 타입을 변환할 수 있다.&lt;br /&gt;또한, @RequestBody는 HttpMessageConverter가 리플렉션을 사용해 객체를 생성하지만, @ModelAttribute는 생성자와 setter를 사용하여 객체를 생성한다.&lt;/p&gt;</description>
      <category>Spring</category>
      <author>gogi masidda</author>
      <guid isPermaLink="true">https://iwbdev.tistory.com/482</guid>
      <comments>https://iwbdev.tistory.com/entry/RequestBody%EC%9D%98-%EB%8F%99%EC%9E%91-%EC%9B%90%EB%A6%AC%EC%99%80-ModelAttribute#entry482comment</comments>
      <pubDate>Wed, 16 Apr 2025 22:15:59 +0900</pubDate>
    </item>
  </channel>
</rss>