<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>즐겁게, 코드</title>
    <link>https://merrily-code.tistory.com/</link>
    <description>Do what you love!</description>
    <language>ko</language>
    <pubDate>Sat, 14 Mar 2026 19:13:34 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>Chamming2</managingEditor>
    <image>
      <title>즐겁게, 코드</title>
      <url>https://tistory1.daumcdn.net/tistory/4365896/attach/54bccfea9dec4e378af0ac66ffc4d74d</url>
      <link>https://merrily-code.tistory.com</link>
    </image>
    <item>
      <title>1월에 잘한 것들</title>
      <link>https://merrily-code.tistory.com/355</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;#1. 수익화 기여하기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;입사 후 계속 &quot;돈 버는 앱을 만들자&quot; 는 생각을 갖고 있었는데, 마침 신규 서비스에 광고 연동을 담당할 기회가 생겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보상형, 인터스티셜 등 광고 관련 용어를 새로 안 것도 좋았고, 무엇보다 서비스에서 이제 실제 매출이 발생한다는 점이 뿌듯했던 것 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1852&quot; data-origin-height=&quot;676&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4M9ZC/dJMcabQAKoS/vQKbcGa7wjVtuDED1AppP1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4M9ZC/dJMcabQAKoS/vQKbcGa7wjVtuDED1AppP1/img.png&quot; data-alt=&quot;네이버 광고 SDK에 문의를 남겨야 했던 적도 있어, uglify된 소스를 열심히 훑어보기도 했다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4M9ZC/dJMcabQAKoS/vQKbcGa7wjVtuDED1AppP1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4M9ZC%2FdJMcabQAKoS%2FvQKbcGa7wjVtuDED1AppP1%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;700&quot; height=&quot;256&quot; data-origin-width=&quot;1852&quot; data-origin-height=&quot;676&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;네이버 광고 SDK에 문의를 남겨야 했던 적도 있어, uglify된 소스를 열심히 훑어보기도 했다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;711&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vqxDg/dJMcagj384R/ZYhAbstxuCjTlGKTjVPqB0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vqxDg/dJMcagj384R/ZYhAbstxuCjTlGKTjVPqB0/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vqxDg/dJMcagj384R/ZYhAbstxuCjTlGKTjVPqB0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/vqxDg/dJMcagj384R/ZYhAbstxuCjTlGKTjVPqB0/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;320&quot; height=&quot;569&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;711&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&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;최근 회사나 주식 관련 이슈가 많아 네이버 뉴스를 수시로 들락거렸었는데, 일하다 중간중간 뉴스를 찾게 되는 모습이 아쉬웠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 슬랙으로 원하는 키워드의 뉴스를 구독할 수 있도록 해뒀다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;2207&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c495cK/dJMcahwwK26/otHKzgeRja7ql9VkukRRzk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c495cK/dJMcahwwK26/otHKzgeRja7ql9VkukRRzk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c495cK/dJMcahwwK26/otHKzgeRja7ql9VkukRRzk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc495cK%2FdJMcahwwK26%2FotHKzgeRja7ql9VkukRRzk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;240&quot; height=&quot;490&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;2207&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스트레스를 겪던 나쁜 루틴 중 하나였는데, 만들고 나서 마음이 많이 홀가분해진 일 중 하나다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;#3. 서비스 만들기 (1)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만들고 싶었던 것들이 몇 가지 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2600&quot; data-origin-height=&quot;1258&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vrThd/dJMcacvd1Sa/MSYwSo5TIPehks2Zuopor1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vrThd/dJMcacvd1Sa/MSYwSo5TIPehks2Zuopor1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vrThd/dJMcacvd1Sa/MSYwSo5TIPehks2Zuopor1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvrThd%2FdJMcacvd1Sa%2FMSYwSo5TIPehks2Zuopor1%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;800&quot; height=&quot;387&quot; data-origin-width=&quot;2600&quot; data-origin-height=&quot;1258&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 회사 바깥 일은 일정 관리를 못하는 편인데, 정보처리기사를 따야지 따야지 하다가 늘 접수기간을 놓친 기억이 났다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일정을 좀 더 쉽게 추가하고 관리할 수 있는 익스텐션을 만들고 배포해 보았다.&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/m3qG7/dJMcagEoNyP/Lrnh013aB0FqKgqg1bss1k/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/m3qG7/dJMcagEoNyP/Lrnh013aB0FqKgqg1bss1k/img.jpg&quot; data-is-animation=&quot;false&quot; data-origin-height=&quot;800&quot; data-origin-width=&quot;1280&quot; style=&quot;width: 34.8493%; margin-right: 10px;&quot; data-widthpercent=&quot;35.68&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/m3qG7/dJMcagEoNyP/Lrnh013aB0FqKgqg1bss1k/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fm3qG7%2FdJMcagEoNyP%2FLrnh013aB0FqKgqg1bss1k%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;800&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dpBNa4/dJMcacPwzxi/xB3WAPIB6Qi8RCe5dxBgWK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dpBNa4/dJMcacPwzxi/xB3WAPIB6Qi8RCe5dxBgWK/img.png&quot; data-origin-width=&quot;700&quot; data-origin-height=&quot;344&quot; data-is-animation=&quot;false&quot; width=&quot;280&quot; height=&quot;138&quot; style=&quot;width: 44.3215%; margin-right: 10px;&quot; data-widthpercent=&quot;45.38&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dpBNa4/dJMcacPwzxi/xB3WAPIB6Qi8RCe5dxBgWK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdpBNa4%2FdJMcacPwzxi%2FxB3WAPIB6Qi8RCe5dxBgWK%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;700&quot; height=&quot;344&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c6DeNi/dJMcahwwLly/4qBjnGS4ZRy1cjEkq21vL1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c6DeNi/dJMcahwwLly/4qBjnGS4ZRy1cjEkq21vL1/img.png&quot; data-origin-width=&quot;734&quot; data-origin-height=&quot;864&quot; data-is-animation=&quot;false&quot; width=&quot;320&quot; height=&quot;377&quot; style=&quot;width: 18.5036%;&quot; data-widthpercent=&quot;18.94&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c6DeNi/dJMcahwwLly/4qBjnGS4ZRy1cjEkq21vL1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc6DeNi%2FdJMcahwwLly%2F4qBjnGS4ZRy1cjEkq21vL1%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;734&quot; height=&quot;864&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;만들고 나니 내 기준에서는 사용성이 나쁘지 않은 것 같아 Geeknews에도 홍보를 해봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다운로드 수가 많지는 않았지만, 시간을 내어 피드백을 남겨주고 받아준 분들께 고마웠다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;#4. 서비스 만들기 (2)&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 여자친구가 시스템 엔지니어 직무로 취업에 성공했는데, 합격한 직무에 대한 배경지식이 부족하다고 느꼈다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여자친구는 나와 달리 &quot;공부&quot; 는 잘 하는데, 문제는 인프라 직무의 특성상 방향을 잡는게 너무 어려웠던 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 최소한 리눅스 기본기를 익히는 데에 도움을 줄 수 있도록, 즐겨 하는 듀오링고와 비슷한 UI와 문제 세트를 구성해 선물해줬다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주제가 뭐든 주위 사람들을 위한 개발을 할 때는 유난히 즐거운 것 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bT84db/dJMb99SMAnR/8SlM5YrWleIspmpg6HQMg1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bT84db/dJMb99SMAnR/8SlM5YrWleIspmpg6HQMg1/img.png&quot; data-origin-width=&quot;736&quot; data-origin-height=&quot;1234&quot; data-is-animation=&quot;false&quot; style=&quot;width: 33.7684%; margin-right: 10px;&quot; data-widthpercent=&quot;34.57&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bT84db/dJMb99SMAnR/8SlM5YrWleIspmpg6HQMg1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbT84db%2FdJMb99SMAnR%2F8SlM5YrWleIspmpg6HQMg1%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;736&quot; height=&quot;1234&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cwFy3j/dJMcaac531r/Q4L53nG1zTBKkB7OtH5vL0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cwFy3j/dJMcaac531r/Q4L53nG1zTBKkB7OtH5vL0/img.png&quot; data-origin-width=&quot;760&quot; data-origin-height=&quot;1336&quot; data-is-animation=&quot;false&quot; style=&quot;width: 32.2073%; margin-right: 10px;&quot; data-widthpercent=&quot;32.97&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cwFy3j/dJMcaac531r/Q4L53nG1zTBKkB7OtH5vL0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcwFy3j%2FdJMcaac531r%2FQ4L53nG1zTBKkB7OtH5vL0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;760&quot; height=&quot;1336&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/o0YfJ/dJMcajuiuNx/M9JkziLoCqkbuFikm1mjn1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/o0YfJ/dJMcajuiuNx/M9JkziLoCqkbuFikm1mjn1/img.png&quot; data-origin-width=&quot;748&quot; data-origin-height=&quot;1336&quot; data-is-animation=&quot;false&quot; style=&quot;width: 31.6988%;&quot; data-widthpercent=&quot;32.46&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/o0YfJ/dJMcajuiuNx/M9JkziLoCqkbuFikm1mjn1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fo0YfJ%2FdJMcajuiuNx%2FM9JkziLoCqkbuFikm1mjn1%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;748&quot; height=&quot;1336&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;#5. 트렌드 배워보기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 어느 커뮤니티를 둘러봐도 oh-my-opencode(omo), ralph loop 등의 키워드가 언급되는 것 같아 써봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인적으로 omo는 클로드가 아닌 제미나이에 물려 사용해서 그런지 샤라웃되는 만큼의 인상을 받지는 못했다. 다만 벤더사마다 다른 CLI 툴을 Opencode로 통합할 수 있다는 점과 Plan Mode를 기본으로 지원한다는 점에서 순정 Opencode에서는 크게 만족할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 Ralph loop는 네이밍이 장난스럽다는 점 외에는 상당히 요긴하게 써먹을 수 있었던 것 같았던 것이, 아직 Opus든 제미나이든 체급이 높은 모델은 rate limit에 굉장히 빠르게 도달한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위처럼 특정 분야의 퀴즈를 다수 생성한다던가, 다량의 번역을 교정하는 등 물량으로 승부해야 하는 건이 있다면 로우급 모델의 iteration count를 늘리는 것으로 더 경제적이면서 Output은 비슷한 결과를 얻을 수 있을 것이라 기대했다.&lt;/p&gt;</description>
      <category> &amp;zwj;  기록</category>
      <category>1월</category>
      <author>Chamming2</author>
      <guid isPermaLink="true">https://merrily-code.tistory.com/355</guid>
      <comments>https://merrily-code.tistory.com/355#entry355comment</comments>
      <pubDate>Tue, 3 Feb 2026 17:43:14 +0900</pubDate>
    </item>
    <item>
      <title>패키지 삭제 전에는 npm ls &amp;lt;패키지명&amp;gt; 으로 의존관계 확인하기</title>
      <link>https://merrily-code.tistory.com/353</link>
      <description>&lt;blockquote data-ke-style=&quot;style2&quot;&gt;TL;DR : 라이브러리를 삭제할 때는 npm ls &amp;lt;패키지명&amp;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;회사 프로젝트에서는 주기적으로 사용하지 않는 라이브러리를 제거하고 있는데, 언젠간 잘 쓰겠지라는 마음으로 설치해두었던 &lt;a href=&quot;https://vueuse.org/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;@vueuse/core&lt;/a&gt; 가 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 기대와는 달리 오랜 기간동안 실제로 사용되지 않고 있어 이를 삭제하고 잊고 살고 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2060&quot; data-origin-height=&quot;182&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cYZkmo/dJMcabik1WA/pC8qYy50n5lOJLaIDIWjwK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cYZkmo/dJMcabik1WA/pC8qYy50n5lOJLaIDIWjwK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cYZkmo/dJMcabik1WA/pC8qYy50n5lOJLaIDIWjwK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcYZkmo%2FdJMcabik1WA%2FpC8qYy50n5lOJLaIDIWjwK%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;2060&quot; height=&quot;182&quot; data-origin-width=&quot;2060&quot; data-origin-height=&quot;182&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 몇 개월 정도가 지난 뒤 &lt;a href=&quot;https://motion.dev/docs/vue&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;motion&lt;/a&gt;에서 peerDeps로 참조하는 &lt;code&gt;@vueuse/core&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;1. (1월) &lt;code&gt;@vueuse/core&lt;/code&gt; 라이브러리를 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;설치한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. (3월) &lt;code&gt;motion-v&lt;/code&gt; 라이브러리를 설치한다. (✅ &lt;code&gt;@vueuse/core&lt;/code&gt;가 설치되어 있으므로 peerDeps 조건 만족)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. (5월) &lt;b&gt;프로젝트 내에서 사용하는 곳이 없다고 판단해&lt;/b&gt; &lt;code&gt;@vueuse/core&lt;/code&gt; 를 제거한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. (7월) 시간이 지난 뒤 &lt;code&gt;motion-v&lt;/code&gt;에서 오류가 발생한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;5. (해결) 라이브러리 참조용으로만 필요한 &lt;code&gt;@vueuse/core&lt;/code&gt; 를 devDependency에 추가해 문제를 수정한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;708&quot; data-origin-height=&quot;444&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dWapJb/dJMcahQnWfF/CvETcyWhI65Sv8He7lo7KK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dWapJb/dJMcahQnWfF/CvETcyWhI65Sv8He7lo7KK/img.png&quot; data-alt=&quot;비즈니스 코드에서 안쓰이면 OK입니다 (???)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dWapJb/dJMcahQnWfF/CvETcyWhI65Sv8He7lo7KK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdWapJb%2FdJMcahQnWfF%2FCvETcyWhI65Sv8He7lo7KK%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;360&quot; height=&quot;226&quot; data-origin-width=&quot;708&quot; data-origin-height=&quot;444&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;비즈니스 코드에서 안쓰이면 OK입니다 (???)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 peerDeps는 코드 내에서 실제로 사용되고 있는지 찾기 어렵다는 문제가 있었고, 패키지를 삭제할 일이 있다면 삭제 전 &lt;code&gt;npm ls&lt;/code&gt; 커맨드로 의존관계를 확인하면 에러를 줄일 수 있을 것 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bp4wPL/dJMcaiV0Jy5/NrhNiMQxDLyWruSAhSOyJk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bp4wPL/dJMcaiV0Jy5/NrhNiMQxDLyWruSAhSOyJk/img.png&quot; data-origin-width=&quot;350&quot; data-origin-height=&quot;60&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.3573%; margin-right: 10px;&quot; data-widthpercent=&quot;49.94&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bp4wPL/dJMcaiV0Jy5/NrhNiMQxDLyWruSAhSOyJk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbp4wPL%2FdJMcaiV0Jy5%2FNrhNiMQxDLyWruSAhSOyJk%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;350&quot; height=&quot;60&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bv56go/dJMcacuMd4N/un0yoteHkSnonxFtpeKlt1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bv56go/dJMcacuMd4N/un0yoteHkSnonxFtpeKlt1/img.png&quot; width=&quot;400&quot; height=&quot;68&quot; data-origin-width=&quot;538&quot; data-origin-height=&quot;92&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.4799%;&quot; data-widthpercent=&quot;50.06&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bv56go/dJMcacuMd4N/un0yoteHkSnonxFtpeKlt1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbv56go%2FdJMcacuMd4N%2Fun0yoteHkSnonxFtpeKlt1%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;538&quot; height=&quot;92&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;좌 : 타 라이브러리와 참조관계가 없을 때 / 우 : 타 라이브러리와 참조관계가 있을 때&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>  프론트엔드</category>
      <category>npm</category>
      <category>프론트엔드</category>
      <author>Chamming2</author>
      <guid isPermaLink="true">https://merrily-code.tistory.com/353</guid>
      <comments>https://merrily-code.tistory.com/353#entry353comment</comments>
      <pubDate>Wed, 26 Nov 2025 10:57:18 +0900</pubDate>
    </item>
    <item>
      <title>테스트용 데이터베이스 sakila 구성하기</title>
      <link>https://merrily-code.tistory.com/352</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;테스트용 데이터베이스는 어떻게 만들까?&lt;/b&gt;&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;521&quot; data-origin-height=&quot;291&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bvOLlE/dJMcaiaCwEe/dRnHQvOsLZUnvMjUwL5mK0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bvOLlE/dJMcaiaCwEe/dRnHQvOsLZUnvMjUwL5mK0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bvOLlE/dJMcaiaCwEe/dRnHQvOsLZUnvMjUwL5mK0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbvOLlE%2FdJMcaiaCwEe%2FdRnHQvOsLZUnvMjUwL5mK0%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;521&quot; height=&quot;291&quot; data-origin-width=&quot;521&quot; data-origin-height=&quot;291&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 2차전직(?)을 위해 SQL을 배우고 있는데, 기왕이면 크고 복잡한 데이터베이스를 만져봐야 실력이 늘 것 같았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 더미 데이터베이스를 만드는 방법을 검색해봤더니 프로시저를 사용하는 방법이 많이 제안되는 것 같았다.&lt;br /&gt;&lt;br /&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 내가 언제 테스트용 데이터베이스를 또 만들어야 할지 모르는데, 누군가가 만든 프로시저를 필요할 때마다 찾아 쓰는건 좋은 방법이 아닌 것 같아 공식 채널의 best practice를 찾고 싶었던 중, &lt;a href=&quot;https://dev.mysql.com/doc/index-other.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;MySQL 공식 예제 샘플 데이터베이스&lt;/a&gt;가 있음을 알게 되었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1644&quot; data-origin-height=&quot;420&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CfKAR/dJMcagYa6Zi/2OTRKMGpdXp6uAxgKrZbN0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CfKAR/dJMcagYa6Zi/2OTRKMGpdXp6uAxgKrZbN0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CfKAR/dJMcagYa6Zi/2OTRKMGpdXp6uAxgKrZbN0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCfKAR%2FdJMcagYa6Zi%2F2OTRKMGpdXp6uAxgKrZbN0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;153&quot; data-origin-width=&quot;1644&quot; data-origin-height=&quot;420&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;Sakila 설치하기&lt;/b&gt;&lt;/h3&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/lxNwZ/dJMcagYa6Zd/znXf9oZoPb8JkH75T36mlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lxNwZ/dJMcagYa6Zd/znXf9oZoPb8JkH75T36mlK/img.png&quot; data-is-animation=&quot;false&quot; data-origin-height=&quot;1272&quot; data-origin-width=&quot;1638&quot; width=&quot;600&quot; height=&quot;466&quot; style=&quot;width: 25.0898%; margin-right: 10px;&quot; data-widthpercent=&quot;25.38&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lxNwZ/dJMcagYa6Zd/znXf9oZoPb8JkH75T36mlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlxNwZ%2FdJMcagYa6Zd%2FznXf9oZoPb8JkH75T36mlK%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;1638&quot; height=&quot;1272&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tlMqB/dJMcafycpzQ/kC2vvHORyi9LCzog6UMUdK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tlMqB/dJMcafycpzQ/kC2vvHORyi9LCzog6UMUdK/img.png&quot; data-is-animation=&quot;false&quot; data-origin-height=&quot;456&quot; data-origin-width=&quot;1726&quot; style=&quot;width: 73.7474%;&quot; data-widthpercent=&quot;74.62&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tlMqB/dJMcafycpzQ/kC2vvHORyi9LCzog6UMUdK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtlMqB%2FdJMcafycpzQ%2FkC2vvHORyi9LCzog6UMUdK%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;1726&quot; height=&quot;456&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;샘플 데이터베이스가 여럿 있었지만 GPT의 조언대로 Sakila 라는 데이터베이스를 사용하기로 했다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. Sakila 데이터베이스 압축 해제하기&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;994&quot; data-origin-height=&quot;372&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c6iesS/dJMcacVMfgB/4vBIeLFWjWMwf9RRUQ3lzK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c6iesS/dJMcacVMfgB/4vBIeLFWjWMwf9RRUQ3lzK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c6iesS/dJMcacVMfgB/4vBIeLFWjWMwf9RRUQ3lzK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc6iesS%2FdJMcacVMfgB%2F4vBIeLFWjWMwf9RRUQ3lzK%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;225&quot; data-origin-width=&quot;994&quot; data-origin-height=&quot;372&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내려받은 Sakila 데이터베이스 파일의 압축을 해제하면 스키마와 데이터를 얻을 수 있다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. MySQL에서 SQL 실행하기&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 MySQL 콘솔을 실행하고 압축을 해제한 Sakila 스키마와 데이터의 SQL을 실행한다.&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/lBlQ3/dJMcagw6GrK/CncTd0ADL2aCiktXwKKn9k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lBlQ3/dJMcagw6GrK/CncTd0ADL2aCiktXwKKn9k/img.png&quot; data-origin-width=&quot;952&quot; data-origin-height=&quot;344&quot; data-is-animation=&quot;false&quot; style=&quot;width: 46.2872%; margin-right: 10px;&quot; data-widthpercent=&quot;46.83&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lBlQ3/dJMcagw6GrK/CncTd0ADL2aCiktXwKKn9k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlBlQ3%2FdJMcagw6GrK%2FCncTd0ADL2aCiktXwKKn9k%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;952&quot; height=&quot;344&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/l3x2c/dJMb995OXK2/VhbQHHK4RcGPuEzCAfNUk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/l3x2c/dJMb995OXK2/VhbQHHK4RcGPuEzCAfNUk1/img.png&quot; data-origin-width=&quot;930&quot; data-origin-height=&quot;296&quot; data-is-animation=&quot;false&quot; style=&quot;width: 52.5501%;&quot; data-widthpercent=&quot;53.17&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/l3x2c/dJMb995OXK2/VhbQHHK4RcGPuEzCAfNUk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fl3x2c%2FdJMb995OXK2%2FVhbQHHK4RcGPuEzCAfNUk1%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;930&quot; height=&quot;296&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실행을 마치면 학습에 사용할만한 적절한 크기의 데이터베이스가 준비된 모습이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2126&quot; data-origin-height=&quot;1702&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/daeAUT/dJMcaawTild/KBB2FwX43ult3ZgfZR2EXk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/daeAUT/dJMcaawTild/KBB2FwX43ult3ZgfZR2EXk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/daeAUT/dJMcaawTild/KBB2FwX43ult3ZgfZR2EXk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdaeAUT%2FdJMcaawTild%2FKBB2FwX43ult3ZgfZR2EXk%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;2126&quot; height=&quot;1702&quot; data-origin-width=&quot;2126&quot; data-origin-height=&quot;1702&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그동안은 허접한(?) todo-db 같은걸 만들어 연습을 해보곤 했었는데, 이제 좀 DB다운 DB가 생긴 것 같아 뿌듯하다.&lt;/p&gt;</description>
      <category>  DB &amp;amp; ORM/MySQL</category>
      <category>MySQL</category>
      <category>SQL</category>
      <category>더미데이터</category>
      <category>데이터베이스</category>
      <category>테스트</category>
      <author>Chamming2</author>
      <guid isPermaLink="true">https://merrily-code.tistory.com/352</guid>
      <comments>https://merrily-code.tistory.com/352#entry352comment</comments>
      <pubDate>Sun, 16 Nov 2025 01:42:38 +0900</pubDate>
    </item>
    <item>
      <title>삼성페이 티켓 페이지 인터렉션 구현하기</title>
      <link>https://merrily-code.tistory.com/351</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;연휴를 녹이러 영화도 볼겸 체인소맨 극장판을 봤다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;850&quot; data-origin-height=&quot;443&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b764aC/btsQ387iQtl/afvTjgejvMAddxoDHxWaVk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b764aC/btsQ387iQtl/afvTjgejvMAddxoDHxWaVk/img.jpg&quot; data-alt=&quot;틀린말은 아닐지도? ㅋㅎㅋㅎ&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b764aC/btsQ387iQtl/afvTjgejvMAddxoDHxWaVk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb764aC%2FbtsQ387iQtl%2FafvTjgejvMAddxoDHxWaVk%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;313&quot; data-origin-width=&quot;850&quot; data-origin-height=&quot;443&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;틀린말은 아닐지도? ㅋㅎㅋㅎ&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;br /&gt;특이했던 것이 요즘은 롯데시네마 앱에서 영화표를 예매하면 삼성페이에 자동으로 연동된다.&lt;br /&gt;뒷단 개발자들이 갈렸을 것 같은데... 간만에 프론트 개발자라 다행이라는 생각이 들었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;534&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HvsQi/btsQ5FJXFxm/XwAMISPcF03OKOumGovA5K/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HvsQi/btsQ5FJXFxm/XwAMISPcF03OKOumGovA5K/img.jpg&quot; data-alt=&quot;출처 : 네이버 모바노 님 블로그 (https://blog.naver.com/mobano/223474960698)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HvsQi/btsQ5FJXFxm/XwAMISPcF03OKOumGovA5K/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHvsQi%2FbtsQ5FJXFxm%2FXwAMISPcF03OKOumGovA5K%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;334&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;534&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;출처 : 네이버 모바노 님 블로그 (https://blog.naver.com/mobano/223474960698)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 삼성페이를 써보던 중 페이지를 아래로 스크롤하면 티켓과 영화 제목이 자연스럽게 사라지는 애니메이션이 눈에 들어왔다.&lt;br /&gt;&lt;span style=&quot;color: #333333;&quot;&gt;원래대로라면 티켓 정보를 보여주는 평범한 페이지라 느껴졌을 텐데,&amp;nbsp;&lt;/span&gt;이런 인터렉션을 넣으니 경험이 상당히 괜찮게 느껴져서 직접 만들어보고 싶었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;711&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b3iA3O/btsQ2UotTCW/WhQusz29POpR1JGqpgeEZK/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b3iA3O/btsQ2UotTCW/WhQusz29POpR1JGqpgeEZK/img.gif&quot; data-alt=&quot;코드 : https://github.com/C17AN/ticket-interaction-clone.git&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b3iA3O/btsQ2UotTCW/WhQusz29POpR1JGqpgeEZK/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/b3iA3O/btsQ2UotTCW/WhQusz29POpR1JGqpgeEZK/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;711&quot; data-origin-width=&quot;400&quot; data-origin-height=&quot;711&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;코드 : https://github.com/C17AN/ticket-interaction-clone.git&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &lt;code&gt;scrub: true&lt;/code&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;code&gt;scale: 0&lt;/code&gt;에 도달할 수 있도록 &lt;code&gt;pin: true&lt;/code&gt;를 걸어줘야 한다.&lt;/li&gt;
&lt;li&gt;스크롤을 놓쳤을 때 인터렉션의 progress를 0%로 되돌릴지, 100%로 이동할지 결정해야 한다. (snap 옵션)&lt;/li&gt;
&lt;li&gt;영화 제목은 스크롤과 다른 속도로 움직여야 한다. (패럴렉스 효과)&lt;/li&gt;
&lt;li&gt;제목의 y좌표는 화면이 넓어져도 좁은 화면과 동일한 비율대로 움직여야 한다. (font-size: *10px &amp;divide; 360px * 100vw = 2.778vw)&lt;br /&gt;(* 나는 최소 뷰포트 너비를 360px로 삼고, 가독성을 위해 1px = 0.1rem 으로 치환해 작업했다.)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre class=&quot;jsx javascript&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;jsx&quot;&gt;&lt;code&gt;// GSAP 코드
useGSAP(() =&amp;gt; {
&amp;nbsp;&amp;nbsp;gsap.registerPlugin(ScrollTrigger);

&amp;nbsp;&amp;nbsp;const tl = gsap.timeline({
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;scrollTrigger: {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;trigger: &quot;.fixed-container&quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;start: &quot;top top&quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;end: &quot;bottom top&quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;pin: true,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;scrub: true,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;snap: {
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;snapTo: (p) =&amp;gt; (p &amp;gt; 0.5 ? 1 : 0),
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;duration: 0.2,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;},
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;invalidateOnRefresh: true,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;},
&amp;nbsp;&amp;nbsp;});

&amp;nbsp;&amp;nbsp;tl
&amp;nbsp;&amp;nbsp;.to(&quot;.ticket-image&quot;, { scale: 0 }, 0)
&amp;nbsp;&amp;nbsp;.to(
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;&quot;.ticket-name&quot;,
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;{ opacity: 0, y: -60 },
&amp;nbsp;&amp;nbsp;&amp;nbsp;&amp;nbsp;0
&amp;nbsp;&amp;nbsp;);

&amp;nbsp;&amp;nbsp;ScrollTrigger.refresh();
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;아무튼, 극장판은 애니메이션 원작 스토리를 모르는 상태로 봤음에도 재밌게 볼 수 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;630&quot; data-origin-height=&quot;741&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HkVA4/btsQ3E6vcOy/kAgmJGk8AnDfH12APwdfB1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HkVA4/btsQ3E6vcOy/kAgmJGk8AnDfH12APwdfB1/img.png&quot; data-alt=&quot;다음날 또 봤다.. (ㅎㅎ;)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HkVA4/btsQ3E6vcOy/kAgmJGk8AnDfH12APwdfB1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHkVA4%2FbtsQ3E6vcOy%2FkAgmJGk8AnDfH12APwdfB1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;588&quot; data-origin-width=&quot;630&quot; data-origin-height=&quot;741&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;다음날 또 봤다.. (ㅎㅎ;)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>  일상다반사</category>
      <category>gsap</category>
      <category>UI</category>
      <category>프론트엔드</category>
      <author>Chamming2</author>
      <guid isPermaLink="true">https://merrily-code.tistory.com/351</guid>
      <comments>https://merrily-code.tistory.com/351#entry351comment</comments>
      <pubDate>Wed, 8 Oct 2025 17:24:55 +0900</pubDate>
    </item>
    <item>
      <title>Google Apps Script로 주식 주가 정보 스크래핑하기</title>
      <link>https://merrily-code.tistory.com/348</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;요즘 주식 데이터 분석에 흥미가 생겨 &lt;a href=&quot;https://kongdori.tistory.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;오렌지사과&lt;/a&gt; 님의 블로그를 잘 읽고 있는데, 구글 시트에 주가를 적재하는 부분에 호기심이 생겼다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;587&quot; data-origin-height=&quot;239&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ojqgS/btsLRkeVJwB/UBoB5ZIf8FDvLqcqXfD3IK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ojqgS/btsLRkeVJwB/UBoB5ZIf8FDvLqcqXfD3IK/img.png&quot; data-alt=&quot;아래에 소개할 코드는 위 작업을 자동화한 것이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ojqgS/btsLRkeVJwB/UBoB5ZIf8FDvLqcqXfD3IK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FojqgS%2FbtsLRkeVJwB%2FUBoB5ZIf8FDvLqcqXfD3IK%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;587&quot; height=&quot;239&quot; data-origin-width=&quot;587&quot; data-origin-height=&quot;239&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;아래에 소개할 코드는 위 작업을 자동화한 것이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 설명을 대강 요약하면 &lt;b&gt;&quot;특정 페이지에 접속해 페이지 전체를 복사한 뒤, 주가 데이터를 제외한 부분을 손으로 발라내야 한다&quot;&lt;/b&gt; 는 것인데, 이런 작업을 쉽게 만드는 것이 개발자의 의무 아니겠던가!&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 위 작업을 손으로 반복하는 것은 최선이 아닐 것 같아 스크래핑용 람다 함수를 만들어야 할지 고민하고 있었는데, 오늘 다룰 Google Apps Script 를 사용하면 너무나도 손쉽게 작업을 자동화할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;1. 주가 데이터를 수집하는 방법&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주가 데이터를 분석하기 위해서는 기본적으로 특정 기간에서의 OHLCV (시가, 고가, 저가, 종가, 거래량) 정보가 필요한데, 데이터를 수집하기 위해 &lt;a href=&quot;https://pypi.org/project/yfinance/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;yfinance&lt;/a&gt;, &lt;a href=&quot;https://github.com/FinanceData/FinanceDataReader&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;FinanceDataReader&lt;/a&gt; 등의 파이썬 라이브러리를 사용하거나 유료 API 서비스를 사용하는 방법, 웹 포탈을 직접 스크래핑하는 방법 등이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상기한 두 파이썬 라이브러리는 모두 훌륭하지만 데이터를 원하는 모습으로 가공하기 어렵다는 점과 데이터를 구글 시트에 적재하는 로직을 추가로 개발해야 한다는 점, 일일 호출 횟수 등에 대한 정보가 불확실하다는 점 등에서 스크래핑 로직을 직접 구현하고 싶었다.&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;2. Apps Script 찾기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apps Script는 구글 워크스페이스 (캘린더, 독스, 스프레드시트, 슬라이드 등) 제품군을 프로그래밍을 통해 제어하는 기능인데, 스크래핑 로직 구현을 위해 구글 시트에서 &lt;b&gt;[확장 프로그램] - [Apps Script]&lt;/b&gt; 메뉴를 찾아보자.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;530&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zYYI9/btsLTSHvOqz/zYyZ02niEjKFik0czoVFPK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zYYI9/btsLTSHvOqz/zYyZ02niEjKFik0czoVFPK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zYYI9/btsLTSHvOqz/zYyZ02niEjKFik0czoVFPK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzYYI9%2FbtsLTSHvOqz%2FzYyZ02niEjKFik0czoVFPK%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;1390&quot; height=&quot;530&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;530&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apps Script 메뉴에 진입하면 AWS Lambda의 코드 에디터같은 화면이 나타난다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2998&quot; data-origin-height=&quot;654&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dcC8u4/btsLRIfC1mB/9YDByokwj7agyNNGO71ubK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dcC8u4/btsLRIfC1mB/9YDByokwj7agyNNGO71ubK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dcC8u4/btsLRIfC1mB/9YDByokwj7agyNNGO71ubK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdcC8u4%2FbtsLRIfC1mB%2F9YDByokwj7agyNNGO71ubK%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;2998&quot; height=&quot;654&quot; data-origin-width=&quot;2998&quot; data-origin-height=&quot;654&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Apps Script는 일반적인 자바스크립트 함수의 문법을 대부분 활용할 수 있으면서 구글 시트와 상호작용할 수 있는 *&lt;code&gt;SpreadsheetApp&lt;/code&gt; 라는 강력한 인터페이스를 제공한다. (하나 특이한 점을 꼽자면 &lt;code&gt;.js&lt;/code&gt; 가 아닌 &lt;code&gt;.gs&lt;/code&gt; &lt;a href=&quot;https://developers.google.com/apps-script/guides/projects?hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;확장자를 사용한다는 특징&lt;/a&gt;이 있는데, VSCode에서도 잘 인식되는 등 개발에 큰 지장은 없다.)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;  &lt;code&gt;SpreadSheetApp&lt;/code&gt; 인스턴스에 대해서는 &lt;a href=&quot;https://developers.google.com/apps-script/reference/spreadsheet/spreadsheet-app?hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이 문서&lt;/a&gt; 에서 더 확인할 수 있다.&lt;/blockquote&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;이제 본격적으로 야후 파이낸셜의 주가 데이터를 가공한 뒤 구글 시트로 옮겨 볼텐데, 약간의 준비 작업이 필요하다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. B1, B2, B3 셀에 데이터 입력하기&lt;/b&gt;&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;498&quot; data-origin-height=&quot;468&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/D0G9l/btsLRDrPSl0/uQFjUGPeyoEnqQcB51b9hK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/D0G9l/btsLRDrPSl0/uQFjUGPeyoEnqQcB51b9hK/img.png&quot; data-alt=&quot;B1 : 티커 / B2 : 조회 시작일 / B3 : 조회 종료일&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/D0G9l/btsLRDrPSl0/uQFjUGPeyoEnqQcB51b9hK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FD0G9l%2FbtsLRDrPSl0%2FuQFjUGPeyoEnqQcB51b9hK%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;360&quot; height=&quot;338&quot; data-origin-width=&quot;498&quot; data-origin-height=&quot;468&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;B1 : 티커 / B2 : 조회 시작일 / B3 : 조회 종료일&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;B1&lt;/b&gt;&amp;nbsp;셀에는 티커의 단축번호 또는 영문 티커명 (Ex. QQQ, SPY)를 입력한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(만약 국내 티커의 단축번호를 모르겠다면 구글, &lt;a href=&quot;https://kr.investing.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;investing&lt;/a&gt; 등에서 간단히 알아낼 수 있다.)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;812&quot; data-origin-height=&quot;552&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cGVK9v/btsLRKYT2uM/RRs5rH7xk1zs3kLkZ8tqM0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cGVK9v/btsLRKYT2uM/RRs5rH7xk1zs3kLkZ8tqM0/img.png&quot; data-alt=&quot;예를 들어 삼성전자의 단축번호는 005930 이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cGVK9v/btsLRKYT2uM/RRs5rH7xk1zs3kLkZ8tqM0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcGVK9v%2FbtsLRKYT2uM%2FRRs5rH7xk1zs3kLkZ8tqM0%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;360&quot; height=&quot;245&quot; data-origin-width=&quot;812&quot; data-origin-height=&quot;552&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;예를 들어 삼성전자의 단축번호는 005930 이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;B2&lt;/b&gt; 셀에는 조회를 시작할 날짜를 YYYY-MM-DD 형식으로 입력한다.&lt;br /&gt;(만약 해당 셀을 빈 칸으로 두면 조회 시작일을 1970년 1월 1일을 기준으로 조회하게 된다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;B3&lt;/b&gt; 셀에는 조회를 종료할 날짜를 YYYY-MM-DD 형식으로 입력한다.&lt;br /&gt;(만약 해당 셀을 빈 칸으로 두면 조회 종료일을 오늘 날짜를 기준으로 조회하게 된다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. 내 시트 아이디, 시트 이름 기억하기&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떤 시트를 코드로 제어할 것인지 알려주기 위해 시트 아이디를 알려줄 필요가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러분의 구글 시트 URL 중 &lt;code&gt;/d/&lt;/code&gt; 뒤에 오는 것이 구글 시트 아이디이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1180&quot; data-origin-height=&quot;70&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/coY5W2/btsLTu7Yxb4/ohbAjSrQnXMGGJxgpRDmHK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/coY5W2/btsLTu7Yxb4/ohbAjSrQnXMGGJxgpRDmHK/img.png&quot; data-alt=&quot;/d/ 뒤에 오는 영어 + 숫자 조합이 구글 시트 아이디이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/coY5W2/btsLTu7Yxb4/ohbAjSrQnXMGGJxgpRDmHK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcoY5W2%2FbtsLTu7Yxb4%2FohbAjSrQnXMGGJxgpRDmHK%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;1180&quot; height=&quot;70&quot; data-origin-width=&quot;1180&quot; data-origin-height=&quot;70&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;/d/ 뒤에 오는 영어 + 숫자 조합이 구글 시트 아이디이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1300&quot; data-origin-height=&quot;74&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dtDo5Z/btsLRKLmAzd/oAm2w0RjxkB45OPZjullk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dtDo5Z/btsLRKLmAzd/oAm2w0RjxkB45OPZjullk1/img.png&quot; data-alt=&quot;나의 경우에는 &amp;quot;1iRDrqGSjUOvSrqP2ZTbC3JDzWLTxYCnfEw3X21udxMk&amp;quot; 이 시트 아이디가 된다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dtDo5Z/btsLRKLmAzd/oAm2w0RjxkB45OPZjullk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdtDo5Z%2FbtsLRKLmAzd%2FoAm2w0RjxkB45OPZjullk1%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;1300&quot; height=&quot;74&quot; data-origin-width=&quot;1300&quot; data-origin-height=&quot;74&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;나의 경우에는 &quot;1iRDrqGSjUOvSrqP2ZTbC3JDzWLTxYCnfEw3X21udxMk&quot; 이 시트 아이디가 된다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 Apps Script가 어떤 시트에 작업을 실행할지 알려줄 수 있도록 시트 이름을 미리 정해두자.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1506&quot; data-origin-height=&quot;78&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ZjvYy/btsLSK4wwEu/kKkgUE2qXy0U37LrkEyxhK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ZjvYy/btsLSK4wwEu/kKkgUE2qXy0U37LrkEyxhK/img.png&quot; data-alt=&quot;구글 시트 최하단을 보면 시트명 목록들이 나타날 것이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ZjvYy/btsLSK4wwEu/kKkgUE2qXy0U37LrkEyxhK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZjvYy%2FbtsLSK4wwEu%2FkKkgUE2qXy0U37LrkEyxhK%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;1506&quot; height=&quot;78&quot; data-origin-width=&quot;1506&quot; data-origin-height=&quot;78&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;구글 시트 최하단을 보면 시트명 목록들이 나타날 것이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&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;3. 코드 실행하기&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 Apps Script에 새로운 함수를 하나 만들고, 다음 코드를 붙여넣는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 맨 위의 사진의 순서에 따라 야후 파이낸스를 스크래핑하고, 분석에 유리하도록 일부 행과 열의 데이터를 가공한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(Ex. Aug 17, 1993 -&amp;gt; 1993-08-17, 배당금 지급 정보 행을 삭제)&lt;/p&gt;
&lt;pre id=&quot;code_1737288943195&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/**
 * Yahoo Finance에서 주식 데이터를 가져와 Google Sheets에 추가
 */
function importYahooFinanceData() {
  const spreadsheetId = &quot;구글 시트 아이디&quot;; // 구글 시트 ID
  const sheetName = &quot;테이블을 복사할 시트 이름&quot;; // 데이터가 들어갈 시트 이름
  const sheet = SpreadsheetApp.openById(spreadsheetId).getSheetByName(sheetName);

  if (!sheet) {
    throw new Error(`${sheetName} 시트를 찾을 수 없습니다.`);
  }

  // 조회 조건 읽기
  const ticker = sheet.getRange(&quot;B1&quot;).getValue().toString().trim();
  if (!ticker) {
    throw new Error(&quot;B1 셀에 유효한 티커를 입력해주세요.&quot;);
  }

  const startDateInput = sheet.getRange(&quot;B2&quot;).getValue().toString().trim();
  const endDateInput = sheet.getRange(&quot;B3&quot;).getValue().toString().trim();

  const todayTimestamp = Math.floor(new Date().getTime() / 1000);
  const startDate = startDateInput
    ? Math.floor(new Date(startDateInput).getTime() / 1000)
    : 0;
  const endDate = endDateInput
    ? Math.floor(new Date(endDateInput).getTime() / 1000)
    : todayTimestamp;
  


  if (isNaN(startDate) || isNaN(endDate)) {
    throw new Error(&quot;B2 또는 B3에 유효한 날짜 형식을 입력해주세요.&quot;);
  }

  const url = `https://finance.yahoo.com/quote/${ticker}/history?period1=${startDate}&amp;amp;period2=${endDate}&amp;amp;interval=1d`;
  const html = UrlFetchApp.fetch(url).getContentText();

  // 데이터를 파싱하기 위해 HTML 내용을 분석
  const rawData = parseYahooFinanceHtml(html);


  // &quot;dividend&quot; 문자열이 포함된 행 필터링 및 날짜 형식 변환
  const filteredData = rawData
    .filter(row =&amp;gt; row.length === 7 &amp;amp;&amp;amp; !row.some(cell =&amp;gt; cell.toLowerCase().includes(&quot;dividend&quot;)))
    .map(row =&amp;gt; {
      row[0] = parseDate(row[0]); // 첫 번째 열의 날짜를 변환
      return row;
    });

  if (filteredData.length === 0) {
    throw new Error(&quot;유효한 데이터가 없습니다.&quot;);
  }

  // 첫 번째 행에 라벨 추가
  const labels = [&quot;Date&quot;, &quot;Open&quot;, &quot;High&quot;, &quot;Low&quot;, &quot;Close*&quot;, &quot;Adj Close**&quot;, &quot;Volume&quot;];
  const dataWithLabels = [labels, ...filteredData];

  // 구글 시트의 A6부터 데이터 추가
  const startRow = 6; // A6부터 시작
  sheet.getRange(startRow, 1, dataWithLabels.length, dataWithLabels[0].length).setValues(dataWithLabels);

  Logger.log(&quot;데이터 가져오기 완료&quot;);
}

/**
 * Yahoo Finance HTML에서 표 데이터를 파싱
 */
function parseYahooFinanceHtml(html) {
  const tableRegex = /&amp;lt;table.*?&amp;gt;([\s\S]*?)&amp;lt;\/table&amp;gt;/; // 첫 번째 테이블 매칭
  const match = html.match(tableRegex);
  if (!match) {
    throw new Error(&quot;테이블을 찾을 수 없습니다.&quot;);
  }

  const tableHtml = match[1];
  const rowRegex = /&amp;lt;tr.*?&amp;gt;([\s\S]*?)&amp;lt;\/tr&amp;gt;/g;
  const cellRegex = /&amp;lt;t[dh].*?&amp;gt;([\s\S]*?)&amp;lt;\/t[dh]&amp;gt;/g;

  const rows = [];
  let rowMatch;

  while ((rowMatch = rowRegex.exec(tableHtml)) !== null) {
    const rowHtml = rowMatch[1];
    const row = [];
    let cellMatch;
    while ((cellMatch = cellRegex.exec(rowHtml)) !== null) {
      const cellText = cellMatch[1]
        .replace(/&amp;lt;.*?&amp;gt;/g, &quot;&quot;) // 태그 제거
        .trim();
      row.push(cellText);
    }
    rows.push(row);
  }

  return rows;
}

/**
 * 날짜 문자열을 &quot;YYYY-MM-DD&quot; 형식으로 변환
 */
function parseDate(dateString) {
  try {
    const date = new Date(dateString);
    const year = date.getFullYear();
    const month = String(date.getMonth() + 1).padStart(2, &quot;0&quot;);
    const day = String(date.getDate()).padStart(2, &quot;0&quot;);
    return `${year}-${month}-${day}`;
  } catch (e) {
    Logger.log(`날짜 변환 실패: ${dateString}`);
    return dateString; // 변환 실패 시 원래 값 반환
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 코드를 통으로 복사한 뒤 바로 실행하지 말고, 시트 아이디와 이름을 입력하는 과정을 꼭 기억하자.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2996&quot; data-origin-height=&quot;1626&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nBQzE/btsLTsoSbjc/gSyu3vs08xXC82jmkuhbt0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nBQzE/btsLTsoSbjc/gSyu3vs08xXC82jmkuhbt0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nBQzE/btsLTsoSbjc/gSyu3vs08xXC82jmkuhbt0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnBQzE%2FbtsLTsoSbjc%2FgSyu3vs08xXC82jmkuhbt0%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;2996&quot; height=&quot;1626&quot; data-origin-width=&quot;2996&quot; data-origin-height=&quot;1626&quot;/&gt;&lt;/span&gt;&lt;/figure&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;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 테스트를 위해 &lt;a href=&quot;https://www.tigeretf.com/ko/product/search/detail/index.do?ksdFund=KR7458730009&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;TIGER 미국배당다우존스&lt;/a&gt; 의 데이터를 불러와 봤는데, 국내 주식도 문제 없이 불러와지는 모습이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1512&quot; data-origin-height=&quot;1242&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dsE5G3/btsLTJDVZxu/ufXJE97KOqDbczioPkbNxK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dsE5G3/btsLTJDVZxu/ufXJE97KOqDbczioPkbNxK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dsE5G3/btsLTJDVZxu/ufXJE97KOqDbczioPkbNxK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdsE5G3%2FbtsLTJDVZxu%2FufXJE97KOqDbczioPkbNxK%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;1512&quot; height=&quot;1242&quot; data-origin-width=&quot;1512&quot; data-origin-height=&quot;1242&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 잘 가공된 주식 데이터를 획득할 수 있게 되었으니, &lt;a href=&quot;https://kongdori.tistory.com/424&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;분석&lt;/a&gt; 또는 개인 매매일지 기록용 등으로도 무궁무진하게 활용할 수 있게 되었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;기타&lt;/b&gt;&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;1. Apps Script는 스케줄링을 지원한다.&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 놀란 부분인데, Apps Script는 이처럼 시트, 캘린더의 작업을 자동화할 수 있을 뿐만 아니라 특정 시점, 주기마다 배치로 실행하는 것도 정말 쉽게 가능하다. (Ex. 장 종료 이후 주가 정보를 갱신한다던가 등의 동작을 구현할 수 있다.)&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1515&quot; data-origin-height=&quot;823&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dAWHBc/btsLTOrAV2q/2BMK0PSBN3GQkzK3cJ05E0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dAWHBc/btsLTOrAV2q/2BMK0PSBN3GQkzK3cJ05E0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dAWHBc/btsLTOrAV2q/2BMK0PSBN3GQkzK3cJ05E0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdAWHBc%2FbtsLTOrAV2q%2F2BMK0PSBN3GQkzK3cJ05E0%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;1515&quot; height=&quot;823&quot; data-origin-width=&quot;1515&quot; data-origin-height=&quot;823&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;b&gt;2. Apps Script의 사용량은 넉넉한 편이 아니다.&lt;/b&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developers.google.com/apps-script/guides/services/quotas?hl=ko&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Apps Script 서비스 할당량 문서&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1830&quot; data-origin-height=&quot;66&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cRLaZ0/btsLSi8r8ER/dNCZ9HpEKscNjkLfEBgxe0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cRLaZ0/btsLSi8r8ER/dNCZ9HpEKscNjkLfEBgxe0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cRLaZ0/btsLSi8r8ER/dNCZ9HpEKscNjkLfEBgxe0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcRLaZ0%2FbtsLSi8r8ER%2FdNCZ9HpEKscNjkLfEBgxe0%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;1830&quot; height=&quot;66&quot; data-origin-width=&quot;1830&quot; data-origin-height=&quot;66&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;비용 정책은 살짝 빡빡하게 느껴졌는데, 일일 수정 가능 용량이 50,000셀 / 일 이라면 역대 주가 데이터를 한번에 갱신하는 방법으로는 제대로 된 활용이 어려울 것 같았다. (*예를 들어 SPY의 역대 주가 데이터는 8,000행이 조금 넘는다.)&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;  그런데 실제로 테스트했을 때는 8,000(행) * 7(열) 데이터를 몇 번씩 업데이트했음에도 함수가 잘 실행된다.&lt;br /&gt;쿼터 사용량은 조금 더 테스트를 돌려 봐야 파악할 수 있을 것 같다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주가 정보 업데이트 배치를 매일 돌린다고 하면 제일 최근 데이터를 최하단 행에 추가하는 등, 쓰기 횟수를 최소화해야 여러 종목들에 대한 데이터 분석을 원활하게 진행할 수 있을 것으로 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>  프론트엔드/뚝딱뚝딱!</category>
      <category>google Sheet</category>
      <category>스크래핑</category>
      <category>자동화</category>
      <category>주식데이터</category>
      <author>Chamming2</author>
      <guid isPermaLink="true">https://merrily-code.tistory.com/348</guid>
      <comments>https://merrily-code.tistory.com/348#entry348comment</comments>
      <pubDate>Sun, 19 Jan 2025 21:45:34 +0900</pubDate>
    </item>
    <item>
      <title>Three.js로 헤일로 애니메이션 구현하기</title>
      <link>https://merrily-code.tistory.com/347</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;942&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bChbn4/btsLSSBk1Sk/YsdfqJHw3Np920Oud3GwT1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bChbn4/btsLSSBk1Sk/YsdfqJHw3Np920Oud3GwT1/img.png&quot; data-alt=&quot;ㅎㅎ;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bChbn4/btsLSSBk1Sk/YsdfqJHw3Np920Oud3GwT1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbChbn4%2FbtsLSSBk1Sk%2FYsdfqJHw3Np920Oud3GwT1%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;1600&quot; height=&quot;942&quot; data-origin-width=&quot;1600&quot; data-origin-height=&quot;942&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ㅎㅎ;&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우연히 블루 아카이브라는 게임을 깔아보고 있었는데, 설치 중 유난히 예쁜 애니메이션이 눈에 들어왔다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1544&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/y6DFy/btsLSRbosIS/QFV1JnSjzHKRPoAuuVi5ek/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/y6DFy/btsLSRbosIS/QFV1JnSjzHKRPoAuuVi5ek/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/y6DFy/btsLSRbosIS/QFV1JnSjzHKRPoAuuVi5ek/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/y6DFy/btsLSRbosIS/QFV1JnSjzHKRPoAuuVi5ek/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1544&quot; height=&quot;720&quot; data-origin-width=&quot;1544&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별의 일주 운동처럼 축을 기준으로 고리가 공전하는 애니메이션인데 이게 상당히 예쁘게 느껴져 한번 비스무리하게 만들어 보았다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;577&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bLBHfs/btsLSBzUUUI/IpwwdpbvzPBoFbrOK3Yac1/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bLBHfs/btsLSBzUUUI/IpwwdpbvzPBoFbrOK3Yac1/img.gif&quot; data-alt=&quot;급 떠오른건데 펄스건 이즈리얼 스킨에도 비슷한 효과가 있다.&amp;amp;lt;br&amp;amp;gt;이런 효과를 부르는 명칭이 따로 있으려나?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bLBHfs/btsLSBzUUUI/IpwwdpbvzPBoFbrOK3Yac1/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bLBHfs/btsLSBzUUUI/IpwwdpbvzPBoFbrOK3Yac1/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;433&quot; data-origin-width=&quot;800&quot; data-origin-height=&quot;577&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;급 떠오른건데 펄스건 이즈리얼 스킨에도 비슷한 효과가 있다.&amp;lt;br&amp;gt;이런 효과를 부르는 명칭이 따로 있으려나?&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 이런 애니메이션의 특성상 &lt;a href=&quot;https://threejs.org/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Three.js&lt;/a&gt; 를 사용하는 것이 적절해 보였고, 고리 형태를 화면에 그리기 위해 &lt;a href=&quot;https://threejs.org/docs/?q=ring#api/en/geometries/RingGeometry&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;RingGeometry&lt;/a&gt;를 사용해야 할 것 같았다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/s0VLk/btsLTISlFGa/weWSopcp6h90jkMKwYh8A0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/s0VLk/btsLTISlFGa/weWSopcp6h90jkMKwYh8A0/img.png&quot; data-origin-width=&quot;2994&quot; data-origin-height=&quot;1652&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.1587%; margin-right: 10px;&quot; data-widthpercent=&quot;49.74&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/s0VLk/btsLTISlFGa/weWSopcp6h90jkMKwYh8A0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fs0VLk%2FbtsLTISlFGa%2FweWSopcp6h90jkMKwYh8A0%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;2994&quot; height=&quot;1652&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dqQC8y/btsLSSVGgT7/OHH2TfTXDLT5Zzxz9kAhgK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dqQC8y/btsLSSVGgT7/OHH2TfTXDLT5Zzxz9kAhgK/img.png&quot; data-origin-width=&quot;3022&quot; data-origin-height=&quot;1650&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.6785%;&quot; data-widthpercent=&quot;50.26&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dqQC8y/btsLSSVGgT7/OHH2TfTXDLT5Zzxz9kAhgK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdqQC8y%2FbtsLSSVGgT7%2FOHH2TfTXDLT5Zzxz9kAhgK%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;3022&quot; height=&quot;1650&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;thetaLength 값을 조절해 'C' 형태의 고리를 만든 모습&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;RingGeometry&lt;/code&gt;는 기본적으로는 완전한 고리의 형태를 띠고 있는데, 생성자의 6번째 인자인 &lt;code&gt;thetaLength&lt;/code&gt; 값을 조절하면 우측처럼 완전하지 않은 고리를 만들 수도 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1737216046972&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/**
** RingGeometry 생성자의 인자 목록 (순서대로) **

@param innerRadius &amp;mdash; Expects a Float. Default 0.5.
@param outerRadius &amp;mdash; Expects a Float. Default 1.
@param thetaSegments &amp;mdash; Number of segments. A higher number means the ring will be more round. Minimum is 3. Expects a Integer. Default 32.
@param phiSegments &amp;mdash; Number of segments per ring segment. Minimum is 1. Expects a Integer. Default 1.
@param thetaStart &amp;mdash; Starting angle. Expects a Float. Default 0.
@param thetaLength &amp;mdash; Central angle. Expects a Float. Default Math.PI * 2.
*/
const geometry = new THREE.RingGeometry(5, 4.8, 100, 1, 2, 5);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;648&quot; data-origin-height=&quot;654&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dDKMwi/btsLSkd0NC2/b4YmXkiAF46DK7KrzZKDk0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dDKMwi/btsLSkd0NC2/b4YmXkiAF46DK7KrzZKDk0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dDKMwi/btsLSkd0NC2/b4YmXkiAF46DK7KrzZKDk0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdDKMwi%2FbtsLSkd0NC2%2Fb4YmXkiAF46DK7KrzZKDk0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;404&quot; data-origin-width=&quot;648&quot; data-origin-height=&quot;654&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고리 하나를 만든 모습이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 이 코드를 적당히 수정하고 복사해, 다음과 같이 고리의 그룹을 만들어 줬다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;1206&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kCf50/btsLSpF8Asc/2vfQki8i6zXJwnd2iagIZk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kCf50/btsLSpF8Asc/2vfQki8i6zXJwnd2iagIZk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kCf50/btsLSpF8Asc/2vfQki8i6zXJwnd2iagIZk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkCf50%2FbtsLSpF8Asc%2F2vfQki8i6zXJwnd2iagIZk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;403&quot; data-origin-width=&quot;1198&quot; data-origin-height=&quot;1206&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 메시의 해상도가 낮아 상이 자글거려 보인다면, 다음 코드를 추가해 개선할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1737221304135&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const pixelRatio = window.devicePixelRatio;
renderer.setPixelRatio(pixelRatio * 2); // 해상도 개선&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 고리를 움직이게 해보자.&lt;/p&gt;
&lt;pre id=&quot;code_1737216474669&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function animate() {
  mesh1.rotateZ(0.005);
  mesh2.rotateZ(-0.005);
  mesh3.rotateZ(-0.0075);
  mesh4.rotateZ(0.005);
  mesh5.rotateZ(-0.002);
  mesh6.rotateZ(-0.005);
  renderer.render(scene, camera);
}

// webXR을 대상으로 하지 않는다면 네이티브 RequestAnimationFrame을 사용해도 상관없다.
renderer.setAnimationLoop(animate);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;918&quot; data-origin-height=&quot;938&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rY7RO/btsLTsPGOS5/pyIejkXq8xhND78z2Fw121/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rY7RO/btsLTsPGOS5/pyIejkXq8xhND78z2Fw121/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rY7RO/btsLTsPGOS5/pyIejkXq8xhND78z2Fw121/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/rY7RO/btsLTsPGOS5/pyIejkXq8xhND78z2Fw121/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;409&quot; data-origin-width=&quot;918&quot; data-origin-height=&quot;938&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;색감은 다르지만 고리의 움직임이 어느 정도 그럴 듯 해졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도형을 마우스로 회전시킬 수 있는 기능도 추가해 보자.&lt;/p&gt;
&lt;pre id=&quot;code_1737220279555&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };

function onMouseDown() {
  isDragging = true;
}

function onMouseUp() {
  isDragging = false;
}

function onMouseMove(event) {
  if (isDragging) {
    const deltaMove = {
      x: event.movementX || event.mozMovementX || event.webkitMovementX || 0,
      y: event.movementY || event.mozMovementY || event.webkitMovementY || 0,
    };

    mesh1.rotation.y += deltaMove.x * 0.01;
    mesh1.rotation.x += deltaMove.y * 0.01;
    mesh2.rotation.y += deltaMove.x * 0.01;
    mesh2.rotation.x += deltaMove.y * 0.01;
    mesh3.rotation.y += deltaMove.x * 0.01;
    mesh3.rotation.x += deltaMove.y * 0.01;
    mesh4.rotation.y += deltaMove.x * 0.01;
    mesh4.rotation.x += deltaMove.y * 0.01;
    mesh5.rotation.y += deltaMove.x * 0.01;
    mesh5.rotation.x += deltaMove.y * 0.01;
    mesh6.rotation.y += deltaMove.x * 0.01;
    mesh6.rotation.x += deltaMove.y * 0.01;
  }

  previousMousePosition = {
    x: event.clientX,
    y: event.clientY,
  };
}

function onMouseWheel(event) {
  const delta = Math.sign(event.deltaY) * 0.01;

  mesh1.rotation.z += delta;
  mesh2.rotation.z += delta;
  mesh3.rotation.z += delta;
  mesh4.rotation.z += delta;
  mesh5.rotation.z += delta;
  mesh6.rotation.z += delta;
}

renderer.domElement.addEventListener(&quot;mousedown&quot;, onMouseDown, false);
renderer.domElement.addEventListener(&quot;mouseup&quot;, onMouseUp, false);
renderer.domElement.addEventListener(&quot;mousemove&quot;, onMouseMove, false);&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;640&quot; data-origin-height=&quot;596&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/UdDv0/btsLRiVB4Fd/I2myiQgRi3m6n8gfLxE6t0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/UdDv0/btsLRiVB4Fd/I2myiQgRi3m6n8gfLxE6t0/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/UdDv0/btsLRiVB4Fd/I2myiQgRi3m6n8gfLxE6t0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/UdDv0/btsLRiVB4Fd/I2myiQgRi3m6n8gfLxE6t0/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;373&quot; data-origin-width=&quot;640&quot; data-origin-height=&quot;596&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸로 내가 처음에 생각했던 애니메이션 효과는 구현해 보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;번외. 특정 URL의 응답이 Three.js 씬을 SVG로 만들게 할 수는 없을까?&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;욕심이 하나 있었다면 아래 링크처럼 특정 URL이 SVG를 리턴하게 해, 깃허브 프로필 영역에 이 멋진 고리 효과를 넣고 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github-readme-stats.vercel.app/api?username=c17an&amp;amp;show_icons=true&amp;amp;theme=dracula&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github-readme-stats.vercel.app/api?username=c17an&amp;amp;show_icons=true&amp;amp;theme=dracula&lt;/a&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;938&quot; data-origin-height=&quot;394&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cts3Gj/btsLRQxuLC9/RdEpk8EPk3vuTkF4fEPjak/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cts3Gj/btsLRQxuLC9/RdEpk8EPk3vuTkF4fEPjak/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cts3Gj/btsLRQxuLC9/RdEpk8EPk3vuTkF4fEPjak/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcts3Gj%2FbtsLRQxuLC9%2FRdEpk8EPk3vuTkF4fEPjak%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;168&quot; data-origin-width=&quot;938&quot; data-origin-height=&quot;394&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;pre id=&quot;code_1737220861662&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { JSDOM } from &quot;jsdom&quot;;
import * as THREE from &quot;three&quot;;
import { SVGRenderer } from &quot;three/addons/renderers/SVGRenderer.js&quot;;

// Next.js API Route를 사용할 것이라, 서버 사이드 환경을 위한 RAF 함수 모킹
function requestAnimationFrame(f) {
  setImmediate(() =&amp;gt; f(Date.now()));
}

export async function GET() {
  // Next.js API Route를 사용할 것이라, 서버 사이드 환경을 위한 DOM 모킹
  const dom = new JSDOM(&quot;&amp;lt;!DOCTYPE html&amp;gt;&amp;lt;html&amp;gt;&amp;lt;body&amp;gt;&amp;lt;/body&amp;gt;&amp;lt;/html&amp;gt;&quot;);
  global.document = dom.window.document;
  global.window = dom.window;

  // 고리 만드는 애니메이션은 생략
  // renderer만 WebGLRenderer에서 SVGRenderer로 교체
  const renderer = new SVGRenderer();

  const svgOutput = renderer.domElement.outerHTML;

  // SVG를 image/svg+xml로 반환
  return new Response(svgOutput, {
    headers: {
      &quot;Content-Type&quot;: &quot;image/svg+xml&quot;, // Response Content-type도 꼭 명시해준다
    },
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1546&quot; data-origin-height=&quot;1644&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/F9njw/btsLS5UIlU4/gSQQexDChXCaB4Nm9YEu2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/F9njw/btsLS5UIlU4/gSQQexDChXCaB4Nm9YEu2K/img.png&quot; data-alt=&quot;API Route에서 이미지가 떨어져야 하는데, &amp;amp;lt;svg&amp;amp;gt; 문자열이 떨어지는 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/F9njw/btsLS5UIlU4/gSQQexDChXCaB4Nm9YEu2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FF9njw%2FbtsLS5UIlU4%2FgSQQexDChXCaB4Nm9YEu2K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;425&quot; data-origin-width=&quot;1546&quot; data-origin-height=&quot;1644&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;API Route에서 이미지가 떨어져야 하는데, &amp;lt;svg&amp;gt; 문자열이 떨어지는 모습&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 아쉽게도 잘 되지는 않았다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;2372&quot; data-origin-height=&quot;1542&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/driu5N/btsLS4arX5O/RyZykzS432gpb2mtkpbkNk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/driu5N/btsLS4arX5O/RyZykzS432gpb2mtkpbkNk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/driu5N/btsLS4arX5O/RyZykzS432gpb2mtkpbkNk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdriu5N%2FbtsLS4arX5O%2FRyZykzS432gpb2mtkpbkNk%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;2372&quot; height=&quot;1542&quot; data-origin-width=&quot;2372&quot; data-origin-height=&quot;1542&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a style=&quot;background-color: #e6f5ff; color: #0070d1; text-align: start;&quot; href=&quot;https://github-readme-stats.vercel.app/api?username=c17an&amp;amp;show_icons=true&amp;amp;theme=dracula&quot;&gt;github-readme-stats&lt;/a&gt; 프로젝트는 URL에 진입하면 SVG가 이미지 형식으로 떨어지는데, SVGRenderer가 씬을 SVG로 치환해준 결과물은 보통의 SVG와 뭔가 다른 느낌이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 응답의 Content-Type 속성의 문제를 강하게 의심했는데, 다른 SVG 파일로 테스트했을 때는 잘 나오는 것으로 보아 렌더러의 문제가 맞는 것으로 보인다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;급하게 후다닥 만든 코드여서 탄생과 동시에 낡은 코드지만, 관심이 있는 분도 있을까봐 애니메이션 코드를 남겨둔다.&lt;/p&gt;
&lt;pre id=&quot;code_1737222253321&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import * as THREE from &quot;three&quot;;

const scene = new THREE.Scene();
// scene.background = new THREE.Color();
const camera = new THREE.PerspectiveCamera(
  50,
  window.innerWidth / window.innerHeight,
  0.1,
  100
);
// const axesHelper = new THREE.AxesHelper(5);
// scene.add(axesHelper);

const geometry1 = new THREE.RingGeometry(5, 4.8, 100, 1, 2, 5);
const material1 = new THREE.MeshBasicMaterial({
  color: &quot;#789DBC&quot;,
  side: THREE.DoubleSide,
});
const mesh1 = new THREE.Mesh(geometry1, material1);

const geometry2 = new THREE.RingGeometry(7, 6.7, 100, 1, 0, 4);
const material2 = new THREE.MeshBasicMaterial({
  color: &quot;#cdcfd1&quot;,
  side: THREE.DoubleSide,
});

const mesh2 = new THREE.Mesh(geometry2, material2);

const geometry3 = new THREE.RingGeometry(7, 6.7, 100, 1, 4, 4);
const material3 = new THREE.MeshBasicMaterial({
  color: &quot;#BCCCDC&quot;,
  side: THREE.DoubleSide,
});
const mesh3 = new THREE.Mesh(geometry3, material3);

const geometry4 = new THREE.RingGeometry(7, 6.7, 100, 1, 0, 1);
const material4 = new THREE.MeshBasicMaterial({
  color: &quot;#3483eb&quot;,
  side: THREE.DoubleSide,
});
const mesh4 = new THREE.Mesh(geometry4, material4);

const geometry5 = new THREE.RingGeometry(7, 6.7, 100, 1, 0, 5);
const material5 = new THREE.MeshBasicMaterial({
  color: &quot;#0b3975&quot;,
  side: THREE.DoubleSide,
});
const mesh5 = new THREE.Mesh(geometry5, material5);

const geometry6 = new THREE.RingGeometry(7, 6.7, 100, 1, 0, 5);
const material6 = new THREE.MeshBasicMaterial({
  color: &quot;#afcfe3&quot;,
  side: THREE.DoubleSide,
});
const mesh6 = new THREE.Mesh(geometry6, material6);

const renderer = new THREE.WebGLRenderer({ antialias: true });

// 화면 크기 및 devicePixelRatio 반영
const width = window.innerWidth;
const height = window.innerHeight;
const pixelRatio = window.devicePixelRatio;

renderer.setSize(width, height);
renderer.setPixelRatio(pixelRatio * 2); // 화면 해상도에 맞게 렌더링 크기 조정

camera.position.z = 50;

mesh1.position.z = -5;

mesh2.position.z = 0;

mesh3.position.z = 12;

mesh4.position.z = 20;

mesh5.position.z = 24;

mesh6.position.z = 16;

scene.add(mesh1);
scene.add(mesh2);
scene.add(mesh3);
scene.add(mesh4);
scene.add(mesh5);
scene.add(mesh6);

function animate() {
  mesh1.rotateZ(0.005);
  mesh2.rotateZ(-0.005);
  mesh3.rotateZ(-0.0075);
  mesh4.rotateZ(0.005);
  mesh5.rotateZ(-0.002);
  mesh6.rotateZ(-0.005);

  renderer.render(scene, camera);
}

let isDragging = false;
let previousMousePosition = { x: 0, y: 0 };

function onMouseDown() {
  isDragging = true;
}

function onMouseUp() {
  isDragging = false;
}

function onMouseMove(event) {
  if (isDragging) {
    const deltaMove = {
      x: event.movementX || event.mozMovementX || event.webkitMovementX || 0,
      y: event.movementY || event.mozMovementY || event.webkitMovementY || 0,
    };

    mesh1.rotation.y += deltaMove.x * 0.01;
    mesh1.rotation.x += deltaMove.y * 0.01;
    mesh2.rotation.y += deltaMove.x * 0.01;
    mesh2.rotation.x += deltaMove.y * 0.01;
    mesh3.rotation.y += deltaMove.x * 0.01;
    mesh3.rotation.x += deltaMove.y * 0.01;
    mesh4.rotation.y += deltaMove.x * 0.01;
    mesh4.rotation.x += deltaMove.y * 0.01;
    mesh5.rotation.y += deltaMove.x * 0.01;
    mesh5.rotation.x += deltaMove.y * 0.01;
    mesh6.rotation.y += deltaMove.x * 0.01;
    mesh6.rotation.x += deltaMove.y * 0.01;
  }

  previousMousePosition = {
    x: event.clientX,
    y: event.clientY,
  };
}

function onMouseWheel(event) {
  const delta = Math.sign(event.deltaY) * 0.01;

  mesh1.rotation.z += delta;
  mesh2.rotation.z += delta;
  mesh3.rotation.z += delta;
  mesh4.rotation.z += delta;
  mesh5.rotation.z += delta;
  mesh6.rotation.z += delta;
}

renderer.domElement.addEventListener(&quot;mousedown&quot;, onMouseDown, false);
renderer.domElement.addEventListener(&quot;mouseup&quot;, onMouseUp, false);
renderer.domElement.addEventListener(&quot;mousemove&quot;, onMouseMove, false);
renderer.domElement.addEventListener(&quot;wheel&quot;, onMouseWheel, false); // 마우스 휠 이벤트

renderer.setAnimationLoop(animate);

document.body.appendChild(renderer.domElement);

function onWindowResize() {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
}

window.addEventListener(&quot;resize&quot;, onWindowResize);&lt;/code&gt;&lt;/pre&gt;</description>
      <category>  일상다반사</category>
      <category>Three.js</category>
      <category>만들기</category>
      <author>Chamming2</author>
      <guid isPermaLink="true">https://merrily-code.tistory.com/347</guid>
      <comments>https://merrily-code.tistory.com/347#entry347comment</comments>
      <pubDate>Sun, 19 Jan 2025 02:35:05 +0900</pubDate>
    </item>
    <item>
      <title>파이썬과 함께 국장 정글에서 살아남기</title>
      <link>https://merrily-code.tistory.com/345</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;특별한 내용은 없는 일상글인데, 꽤나 신기한 경험이었어서 간단히 적어본다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;520&quot; data-origin-height=&quot;352&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bd8pCn/btsLEKDox7R/wZuTQi3pGBGMDUInMNb6g0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bd8pCn/btsLEKDox7R/wZuTQi3pGBGMDUInMNb6g0/img.png&quot; data-alt=&quot;웃긴 짤이 많은 것도 내가 주식을 좋아하는 이유 중 하나다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bd8pCn/btsLEKDox7R/wZuTQi3pGBGMDUInMNb6g0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbd8pCn%2FbtsLEKDox7R%2FwZuTQi3pGBGMDUInMNb6g0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;520&quot; height=&quot;352&quot; data-origin-width=&quot;520&quot; data-origin-height=&quot;352&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;웃긴 짤이 많은 것도 내가 주식을 좋아하는 이유 중 하나다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어렸을 때는 주식이 패가망신의 상징(?) 과도 비슷한 이미지였던 것 같은데 요즘은 어떤 모임이든 최소 두어명은 주식을 하는 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나도 국내상장 해외 ETF들에 투자하고 있는데, 갑자기 &quot;소프트웨어를 활용하면 지수 이상의 수익률을 낼 수 있지 않을까?&quot; 라는 호기심이 생겼다.&lt;/p&gt;
&lt;figure id=&quot;og_1735991066601&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;파이썬으로 구현하는 로보어드바이저 | 윤성진 - 교보문고&quot; data-og-description=&quot;파이썬으로 구현하는 로보어드바이저 | 로보어드바이저 시스템의 핵심 엔진을 개발했던 금융 AI 연구원들이 직접 쓴 책으로, 로보어드바이저를 구성하는 주요 포트폴리오 전략을 파이썬 코드와&quot; data-og-host=&quot;product.kyobobook.co.kr&quot; data-og-source-url=&quot;https://product.kyobobook.co.kr/detail/S000213848829&quot; data-og-url=&quot;https://product.kyobobook.co.kr/detail/S000213848829&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/ey3Os/hyXWyD4SVO/Wzk3Jxmydfhg6LphPZsre1/img.jpg?width=458&amp;amp;height=573&amp;amp;face=0_0_458_573,https://scrap.kakaocdn.net/dn/bdzUa2/hyXWAWddX2/cQ6axjKbB59egKAjmw4Au1/img.jpg?width=458&amp;amp;height=573&amp;amp;face=0_0_458_573,https://scrap.kakaocdn.net/dn/b4Nytq/hyXWwlXACK/5RmhU9PDl5x5EY2EU0MNP1/img.png?width=335&amp;amp;height=335&amp;amp;face=0_0_335_335&quot;&gt;&lt;a href=&quot;https://product.kyobobook.co.kr/detail/S000213848829&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://product.kyobobook.co.kr/detail/S000213848829&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/ey3Os/hyXWyD4SVO/Wzk3Jxmydfhg6LphPZsre1/img.jpg?width=458&amp;amp;height=573&amp;amp;face=0_0_458_573,https://scrap.kakaocdn.net/dn/bdzUa2/hyXWAWddX2/cQ6axjKbB59egKAjmw4Au1/img.jpg?width=458&amp;amp;height=573&amp;amp;face=0_0_458_573,https://scrap.kakaocdn.net/dn/b4Nytq/hyXWwlXACK/5RmhU9PDl5x5EY2EU0MNP1/img.png?width=335&amp;amp;height=335&amp;amp;face=0_0_335_335');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;파이썬으로 구현하는 로보어드바이저 | 윤성진 - 교보문고&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;파이썬으로 구현하는 로보어드바이저 | 로보어드바이저 시스템의 핵심 엔진을 개발했던 금융 AI 연구원들이 직접 쓴 책으로, 로보어드바이저를 구성하는 주요 포트폴리오 전략을 파이썬 코드와&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;product.kyobobook.co.kr&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 지난 주부터 &lt;a href=&quot;https://product.kyobobook.co.kr/detail/S000213848829&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;책&lt;/a&gt;을 사서 읽고 있는데, 내용이 상당히 흥미롭다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://kongdori.tistory.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;오렌지사과&lt;/a&gt; 님의 블로그를 오래 전부터 구독하면서 MDD와 CAGR 정도의 개념을 포트폴리오 배분에 참고하고 있었는데, 이 책은 포트폴리오 배분에 필요한 금융 수식과 함께 이를 소프트웨어적으로 구현하기 위한 코드를 제공한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 3장부터 효율적 포트폴리오를 구성하기 위한 편입 비중을 직접 구할 수 있다는 코드를 소개한다.&lt;br /&gt;(코드의 의미를 소개한다기보다는, 마법이나 직감이 아닌 코드로 포트폴리오를 계산할 수 있다는 것을 보여주고 싶었다.)&lt;/p&gt;
&lt;pre id=&quot;code_1735991271864&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;from typing import List, Optional, Dict
from pykrx import stock
import pandas as pd
import time
from pypfopt import EfficientFrontier

# 1. 한국거래소 데이터 스크래핑 
class PykrxDataLoader:
    def __init__(self, fromdate: str, todate: str, market: str = &quot;--&amp;gt;&quot;):
        self.fromdate = fromdate
        self.todate = todate
        self.market = market

    def load_stock_data(self, ticker_list: List, freq: str, delay: float = 1):
        ticker_data_list = []
        for ticker in ticker_list:
            ticker_data = stock.get_market_ohlcv(
                fromdate=self.fromdate,
                todate=self.todate,
                ticker=ticker,
                freq=&quot;d&quot;,
                adjusted=True,
            )
            ticker_data = ticker_data.rename(
                columns={
                    &quot;시가&quot;: &quot;open&quot;,
                    &quot;고가&quot;: &quot;high&quot;,
                    &quot;저가&quot;: &quot;low&quot;,
                    &quot;종가&quot;: &quot;close&quot;,
                    &quot;거래량&quot;: &quot;volume&quot;,
                    &quot;거래 대금&quot;: &quot;trading_value&quot;,
                    &quot;등락률&quot;: &quot;change_pct&quot;,
                }
            )
            ticker_data = ticker_data.assign(ticker=ticker)
            ticker_data.index.name = &quot;date&quot;
            ticker_data_list.append(ticker_data)
            time.sleep(delay)
        data = pd.concat(ticker_data_list)
        # 거래가 중단되어 시가가 0원이었을 경우에는 종가 데이터로 덮어씌운다.
        # loc의 첫 파라미터는 행에 대한 정보, 두 번째는 열에 대한 정보
        data.loc[data.open == 0, [&quot;open&quot;, &quot;high&quot;, &quot;low&quot;]] = data.loc[
            data.open == 0, &quot;close&quot;
        ]

        if freq != &quot;d&quot;:
            rule = {
                &quot;open&quot;: &quot;first&quot;,
                &quot;high&quot;: &quot;max&quot;,
                &quot;low&quot;: &quot;min&quot;,
                &quot;close&quot;: &quot;last&quot;,
                &quot;volume&quot;: &quot;sum&quot;,
            }
            data = (
                data.groupby(&quot;ticker&quot;).resample(freq).apply(rule).reset_index(level=0)
            )
        data.__setattr__(&quot;frequency&quot;, freq)
        return data

# 2. 주어진 기간동안의 기대수익을 리턴하는 함수
def calculate_return(ohlcv_data: pd.DataFrame):
    close_data = (
        ohlcv_data[[&quot;close&quot;, &quot;ticker&quot;]].reset_index().set_index([&quot;ticker&quot;, &quot;date&quot;])
    )

    close_data = close_data.unstack(level=0)
    close_data = close_data[&quot;close&quot;]
    print(&quot;close_data: &quot;, close_data)
    # 한 객체 내에서 행과 행의 차이를 현재값과의 백분율로 출력하는 메서드
    # pct_change(1) = 비교할 간격 = 1
    return_data = close_data.pct_change(1) * 100
    return return_data.fillna(value=0)


# 3. 포트폴리오 편입 비중을 구하는 함수
#
# @params
# return_data: 수익률 데이터
# risk_aversion: 위험 회피 계수
def get_mean_variance_weights(
    return_data: pd.DataFrame, risk_aversion: int
) -&amp;gt; Optional[Dict]:
    # 평균 수익률 계산
    expected_return = return_data.mean(skipna=False).to_list()
    print(&quot;expected_return : &quot;, expected_return)
    # 공분산 (확률변수가 2가지일 때 얼마나 퍼져 있는가) 행렬 계산
    cov = return_data.cov(min_periods=len(return_data))
    print(&quot;cov : &quot;, cov)

    if cov.isnull().values.any() or cov.empty:
        return None

    # EfficientFrontier는 다양한 목적 함수를 최적화한다.
    # 과거 평균 수익률을 기대수익률로 지정한다
    ef = EfficientFrontier(
        expected_returns=expected_return, cov_matrix=cov, solver=&quot;OSQP&quot;
    )

    ef.max_quadratic_utility(risk_aversion=risk_aversion)
    # 0에 가까운 편입 비중 처리 (clean_weights는 기본적으로 0.0001)
    weights = dict(ef.clean_weights(rounding=None))
    return weights


fromdate = &quot;2024-01-01&quot;
todate = &quot;2024-12-31&quot;

# 삼성전자, SK이노, 카카오, 현대차
ticker_list = [&quot;005930&quot;, &quot;096770&quot;, &quot;035720&quot;, &quot;005380&quot;]
data_loader = PykrxDataLoader(fromdate=fromdate, todate=todate, market=&quot;KOSPI&quot;)
ohlcv_data = data_loader.load_stock_data(ticker_list=ticker_list, freq=&quot;ME&quot;, delay=1)
return_data = calculate_return(ohlcv_data)

print(get_mean_variance_weights(return_data=return_data, risk_aversion=3.07))&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지난 1년간의 주가 정보를 기반으로 기대수익을 구하고, &lt;code&gt;EfficientFrontier&lt;/code&gt; 인스턴스가 마법처럼 포트폴리오를 배분해준 결과다.&lt;/p&gt;
&lt;pre id=&quot;code_1735991831462&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 기대수익이 가장 높은 포트폴리오를 구성하기 위해 계산된 비중
{
  '005380': 0.0586880140708824, # 현대차
  '005930': 0.1817195017162967, # 삼성전자
  '035720': 0.5192497900232249, # 카카오
  '096770': 0.2403426941895962  # SK이노
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;으잉? 그런데 카카오의 비중이 가장 높게 나왔다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 연초에 카카오에 들어갔다면 퍼포먼스가 좋지 않았을 텐데, 모델이 잘못된 것일까?&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/bl35r0/btsLEereAqK/i1IXeq4f8Z4ixjomOZMKYK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bl35r0/btsLEereAqK/i1IXeq4f8Z4ixjomOZMKYK/img.png&quot; data-origin-width=&quot;1400&quot; data-origin-height=&quot;834&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.2801%; margin-right: 10px;&quot; data-widthpercent=&quot;49.86&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bl35r0/btsLEereAqK/i1IXeq4f8Z4ixjomOZMKYK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbl35r0%2FbtsLEereAqK%2Fi1IXeq4f8Z4ixjomOZMKYK%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;1400&quot; height=&quot;834&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcmt0p/btsLE3inr7t/mELU58n651oKMeQBP6jsO1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcmt0p/btsLE3inr7t/mELU58n651oKMeQBP6jsO1/img.png&quot; data-origin-width=&quot;1418&quot; data-origin-height=&quot;840&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.5571%;&quot; data-widthpercent=&quot;50.14&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcmt0p/btsLE3inr7t/mELU58n651oKMeQBP6jsO1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbcmt0p%2FbtsLE3inr7t%2FmELU58n651oKMeQBP6jsO1%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;1418&quot; height=&quot;840&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;궁금해 TIGER 미국S&amp;amp;P500 과 나스닥100을 계산에 넣어 봤더니, 또 특이한 결과를 얻을 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 기존 국장 포트폴리오에 S&amp;amp;P 500만을 추가했을 때는 납득할 만한 결과가 도출된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1735992492954&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# 계산된 포트폴리오 비중
{
  '005380': 0.040189957090838, # 현대차
  '005930': 0.0, # 삼성전자
  '035720': 0.0, # 카카오
  '096770': 0.054764475901768, # SK이노
  '360750': 0.9050455670073938 # S&amp;amp;P 500
}

# = 국내 개별주는 최소한으로 담고, 포트폴리오 대부분을 S&amp;amp;P로 채우면 기대수익이 가장 높다&lt;/code&gt;&lt;/pre&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/bjKwI8/btsLDUT24PW/x6g4c8C3FU3x254KTjb4vK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bjKwI8/btsLDUT24PW/x6g4c8C3FU3x254KTjb4vK/img.png&quot; data-origin-width=&quot;1392&quot; data-origin-height=&quot;856&quot; data-is-animation=&quot;false&quot; style=&quot;width: 48.9042%; margin-right: 10px;&quot; data-widthpercent=&quot;49.48&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bjKwI8/btsLDUT24PW/x6g4c8C3FU3x254KTjb4vK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbjKwI8%2FbtsLDUT24PW%2Fx6g4c8C3FU3x254KTjb4vK%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;1392&quot; height=&quot;856&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/wPQdY/btsLEgifRLg/zH1fvoFSj32WW9MXdKPan0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/wPQdY/btsLEgifRLg/zH1fvoFSj32WW9MXdKPan0/img.png&quot; data-origin-width=&quot;1408&quot; data-origin-height=&quot;848&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.933%;&quot; data-widthpercent=&quot;50.52&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/wPQdY/btsLEgifRLg/zH1fvoFSj32WW9MXdKPan0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FwPQdY%2FbtsLEgifRLg%2FzH1fvoFSj32WW9MXdKPan0%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;1408&quot; height=&quot;848&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 작년 나스닥 100은 S&amp;amp;P 500보다 더 나은 성과를 보였음에도 불구하고, 위 코드의 실행 결과는 나스닥 100을 포트폴리오에 추천하지 않고 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1735993517165&quot; class=&quot;python&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;python&quot;&gt;&lt;code&gt;# 계산된 포트폴리오 비중
{
  '005380': 0.040189957090838, # 현대차
  '005930': 0.0,
  '035720': 0.0,
  '096770': 0.054764475901768, # SK이노
  '133690': 0.0,               # 나스닥 100
  '360750': 0.9050455670073942 # S&amp;amp;P 500
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 그 이유는 책을 계속 읽어가며 찾아볼 예정인데, 파이썬이 험난한 주식시장을 헤쳐나갈 정글도가 되어줄 것 같아 기대된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부디 내년엔 아래 짤을 쓸 일이 없길 바라며... 재밌는 것들을 배우면 종종 적어보려 한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;403&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cTFGZ7/btsLFcTJYJR/DJRMwwS4Yuwubj6yz1gOOK/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cTFGZ7/btsLFcTJYJR/DJRMwwS4Yuwubj6yz1gOOK/img.jpg&quot; data-alt=&quot;두번은 당하지 않는다..!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cTFGZ7/btsLFcTJYJR/DJRMwwS4Yuwubj6yz1gOOK/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcTFGZ7%2FbtsLFcTJYJR%2FDJRMwwS4Yuwubj6yz1gOOK%2Fimg.jpg&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;322&quot; data-origin-width=&quot;750&quot; data-origin-height=&quot;403&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;두번은 당하지 않는다..!&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>  일상다반사</category>
      <category>퀀트</category>
      <category>파이썬</category>
      <author>Chamming2</author>
      <guid isPermaLink="true">https://merrily-code.tistory.com/345</guid>
      <comments>https://merrily-code.tistory.com/345#entry345comment</comments>
      <pubDate>Sat, 4 Jan 2025 21:38:48 +0900</pubDate>
    </item>
    <item>
      <title>시각장애인에게 친절한 모달 컴포넌트 만들기</title>
      <link>https://merrily-code.tistory.com/342</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;일반인들은 마우스로 원하는 링크나 버튼을 눌러 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;웹 페이지를 자유롭게 탐색할 수 있지만, 시각장애인들은 주로 키보드의 Tab 또는 Shift + Tab 또는 별도의 장치를 통해 HTML 태그를 계층적으로 탐색합니다.&lt;/span&gt;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;  시각장애인의 웹 탐색 방법이 궁금하다면 &lt;a href=&quot;https://namu.wiki/w/%EC%8A%A4%ED%81%AC%EB%A6%B0%20%EB%A6%AC%EB%8D%94&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;스크린 리더&lt;/a&gt; 라는 도구에 대해 검색해 보는 것을 추천드립니다.&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 프론트엔드 개발자들은 시각장애인들이 일반인과 비슷한 수준의 탐색을 경험할 수 있도록 접근성을 신경쓸 필요가 있는데요, 오늘은 모달(팝업) 컴포넌트를 개발할 때 놓치기 쉬운 접근성을 다뤄 보려 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예시를 위해 간단한 모달을 제작한 모습입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735274035558&quot; class=&quot;react javascript&quot; data-ke-language=&quot;react&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const Modal = ({ open, handleClose }: Props) =&amp;gt; {
  // Backdrop, CloseButton 등의 마크업 코드는 생략하겠습니다.
  
  return open
    ? createPortal(
        &amp;lt;Backdrop&amp;gt;
          &amp;lt;CloseButton onClick={handleClose}&amp;gt;닫기&amp;lt;/CloseButton&amp;gt;
          &amp;lt;Body&amp;gt;
            &amp;lt;Header&amp;gt;오늘의 주요 뉴스&amp;lt;/Header&amp;gt;
            &amp;lt;p&amp;gt;환율 1470원 돌파, 이대로 괜찮은가?&amp;lt;/p&amp;gt;
            &amp;lt;Author&amp;gt;김철수 기자&amp;lt;/Author&amp;gt;
            &amp;lt;button&amp;gt;기사 후원하기&amp;lt;/button&amp;gt;
          &amp;lt;/Body&amp;gt;
        &amp;lt;/Backdrop&amp;gt;,
        document.body
      )
    : null;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;846&quot; data-origin-height=&quot;1432&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dJMns0/btsLyUttgdy/hPPHpUIuKJ3iYgjo76XWok/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dJMns0/btsLyUttgdy/hPPHpUIuKJ3iYgjo76XWok/img.gif&quot; data-alt=&quot;글을 쓰는 중에도 환율이 1480원을 돌파했네요. (2024.12.27)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dJMns0/btsLyUttgdy/hPPHpUIuKJ3iYgjo76XWok/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/dJMns0/btsLyUttgdy/hPPHpUIuKJ3iYgjo76XWok/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;677&quot; data-origin-width=&quot;846&quot; data-origin-height=&quot;1432&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;글을 쓰는 중에도 환율이 1480원을 돌파했네요. (2024.12.27)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일반인은 짙은 회색의 Backdrop 영역과 흰 배경을 통해 모달이 열린 것을 알 수 있기 때문에 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;평범한 모달처럼 보이지만,&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;시각장애인들은 스크린 리더가 읽어주는 HTML 태그의 속성만으로 이를 구분해야 하기 때문에 팝업이 열리기는 했는지, 이것이 팝업이 맞기는 한지 인식하기 어려워할 가능성이 있습니다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;개선점 1. aria- 접근성 태그 추가하기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가장 기본적인 방법은 스크린 리더가 UI의 힌트를 제공할 수 있도록 접근성 속성을 추가하는 것입니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735275235121&quot; class=&quot;react javascript&quot; data-ke-language=&quot;react&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const Modal = ({ open, handleClose }: Props) =&amp;gt; {
 
  return open
    ? createPortal(
        &amp;lt;Backdrop
            // 스크린 리더가 이 모달이 팝업 요소임을 알려줄 수 있게 됩니다.
            role=&quot;dialog&quot;
            aria-modal=&quot;true&quot;
            // &quot;오늘의 주요 뉴스 팝업&quot;
            aria-labelledby=&quot;modal-header&quot;
        &amp;gt;
          &amp;lt;CloseButton onClick={handleClose}&amp;gt;닫기&amp;lt;/CloseButton&amp;gt;
          &amp;lt;Body&amp;gt;
            &amp;lt;Header id=&quot;modal-header&quot;&amp;gt;오늘의 주요 뉴스&amp;lt;/Header&amp;gt;
            &amp;lt;p&amp;gt;환율 1470원 돌파, 이대로 괜찮은가?&amp;lt;/p&amp;gt;
            &amp;lt;Author&amp;gt;김철수 기자&amp;lt;/Author&amp;gt;
            &amp;lt;button&amp;gt;기사 후원하기&amp;lt;/button&amp;gt;
          &amp;lt;/Body&amp;gt;
        &amp;lt;/Backdrop&amp;gt;,
        document.body
      )
    : null;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모달 컴포넌트에 &lt;code&gt;role=&quot;dialog&quot;&lt;/code&gt;, &lt;code&gt;aria-modal=&quot;true&quot;&lt;/code&gt; 속성을 부여하면 스크린 리더는 이것이 평범한 &lt;code&gt;&amp;lt;div&amp;gt;&lt;/code&gt;나 &lt;code&gt;&amp;lt;main&amp;gt;&lt;/code&gt; 태그가 아닌 모달 UI라는 힌트를 시각장애인에게 전달할 수 있게 됩니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1164&quot; data-origin-height=&quot;536&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OWAPd/btsLAZUNcur/mr2MTfnE3BtNIrDKQ8X3H0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OWAPd/btsLAZUNcur/mr2MTfnE3BtNIrDKQ8X3H0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OWAPd/btsLAZUNcur/mr2MTfnE3BtNIrDKQ8X3H0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOWAPd%2FbtsLAZUNcur%2Fmr2MTfnE3BtNIrDKQ8X3H0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;600&quot; height=&quot;276&quot; data-origin-width=&quot;1164&quot; data-origin-height=&quot;536&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;크롬 개발자 도구에서 접근성 트리를 확인해 보면 마크업 구조는 수정하지 않았음에도 &quot;닫기&quot; 버튼이 본문과 함께 모달 내에 묶이게 되었고, 현재 탐색하고 있는 UI가 &quot;오늘의 주요 뉴스&quot; 모달임을 명시적으로 안내할 수 있게 되었습니다.&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/cgP2iV/btsLASg2sfX/jFKLA95zKcGwwrLCClVS50/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cgP2iV/btsLASg2sfX/jFKLA95zKcGwwrLCClVS50/img.png&quot; data-origin-width=&quot;648&quot; data-origin-height=&quot;272&quot; data-is-animation=&quot;false&quot; style=&quot;width: 50.3302%; margin-right: 10px;&quot; data-widthpercent=&quot;50.92&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cgP2iV/btsLASg2sfX/jFKLA95zKcGwwrLCClVS50/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcgP2iV%2FbtsLASg2sfX%2FjFKLA95zKcGwwrLCClVS50%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;648&quot; height=&quot;272&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GbywV/btsLBh8DnhD/VQSoZb1xzp5RfYzvZESXyK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GbywV/btsLBh8DnhD/VQSoZb1xzp5RfYzvZESXyK/img.png&quot; data-origin-width=&quot;698&quot; data-origin-height=&quot;304&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;49.08&quot; style=&quot;width: 48.507%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GbywV/btsLBh8DnhD/VQSoZb1xzp5RfYzvZESXyK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGbywV%2FbtsLBh8DnhD%2FVQSoZb1xzp5RfYzvZESXyK%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;698&quot; height=&quot;304&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;크롬 접근성 트리 : AS-IS / TO-BE&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;개선점 2. Escape 키로 모달을 닫을 수 있도록 추가하기&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;앞서 말했듯 시각장애인은 &quot;닫기&quot; 버튼을 곧바로 마우스로 클릭할 수 없고, 키보드 또는 보조 입력 도구를 통해 HTML 계층을 순차적으로 탐색하게 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 컨텐츠가 많은 팝업에서는 컨텐츠를 조회하다가 팝업을 닫기 위해 뒤로가기 키를 여러 차례 눌러야 하는 불편을 겪을 수 있는데요, 이런 경험을 개선하기 위해 ESC 키를 눌렀을 때 팝업이 닫히는 기능을 추가할 필요가 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735276389815&quot; class=&quot;react javascript&quot; data-ke-language=&quot;react&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const Modal = ({ open, handleClose }: Props) =&amp;gt; {

  // ESC 키로 모달 닫기
  useEffect(() =&amp;gt; {
    const handleKeyDown = (event: KeyboardEvent) =&amp;gt; {
      if (event.key === &quot;Escape&quot; &amp;amp;&amp;amp; handleClose) {
        handleClose();
      }
    };
    if (open) {
      document.addEventListener(&quot;keydown&quot;, handleKeyDown);
    }
    return () =&amp;gt; {
      document.removeEventListener(&quot;keydown&quot;, handleKeyDown);
    };
  }, [open, handleClose]);

  return open
    ? createPortal(
          &amp;lt;Backdrop
            role=&quot;dialog&quot;
            aria-modal=&quot;true&quot;
            aria-labelledby=&quot;modal-header&quot;
          &amp;gt;
            &amp;lt;CloseButton onClick={handleClose}&amp;gt;닫기&amp;lt;/CloseButton&amp;gt;
            &amp;lt;Body&amp;gt;
              &amp;lt;Header id=&quot;modal-header&quot;&amp;gt;오늘의 주요 뉴스&amp;lt;/Header&amp;gt;
              &amp;lt;p&amp;gt;환율 1470원 돌파, 이대로 괜찮은가?&amp;lt;/p&amp;gt;
              &amp;lt;Author&amp;gt;김철수 기자&amp;lt;/Author&amp;gt;
              &amp;lt;button&amp;gt;기사 후원하기&amp;lt;/button&amp;gt;
            &amp;lt;/Body&amp;gt;
          &amp;lt;/Backdrop&amp;gt;,
        document.body
      )
    : null;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이처럼 keydown 이벤트에 간단한 핸들러를 추가하는 것으로 시각장애인들이 컨텐츠를 읽다가 Shfit + Tab을 수 차례 눌러 닫기 버튼을 다시 찾아야 하는 불편을 해결할 수 있게 되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;846&quot; data-origin-height=&quot;1432&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bThKGV/btsLyUAjOZX/hynsVvg9Ekc6ULorLioz7K/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bThKGV/btsLyUAjOZX/hynsVvg9Ekc6ULorLioz7K/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bThKGV/btsLyUAjOZX/hynsVvg9Ekc6ULorLioz7K/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/bThKGV/btsLyUAjOZX/hynsVvg9Ekc6ULorLioz7K/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;677&quot; data-origin-width=&quot;846&quot; data-origin-height=&quot;1432&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&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;많은 불편이 개선되었지만 아직 한 가지 문제가 남아 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시각장애인이 Tab 키를 통해 페이지를 탐색하는 과정에서 모달 바깥의 요소가 focus 동작의 대상이 될 수 있다는 점인데요, 이로 인해 UI상 기존 화면 위에 오버레이된 모달과 그 밑에 깔린 요소들을 분간할 수 없게 되어 탐색에 혼란을 주게 된다는 문제가 있습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;846&quot; data-origin-height=&quot;1432&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/6SSoy/btsLz3cA5iq/OQqLMDoSnU3kQ6qMp3Gad0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/6SSoy/btsLz3cA5iq/OQqLMDoSnU3kQ6qMp3Gad0/img.gif&quot; data-alt=&quot;나는 모달의 내용을 읽고 싶은데, 모달을 넘어 헤더 영역까지 탐색하게 됩니다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/6SSoy/btsLz3cA5iq/OQqLMDoSnU3kQ6qMp3Gad0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/6SSoy/btsLz3cA5iq/OQqLMDoSnU3kQ6qMp3Gad0/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;677&quot; data-origin-width=&quot;846&quot; data-origin-height=&quot;1432&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;나는 모달의 내용을 읽고 싶은데, 모달을 넘어 헤더 영역까지 탐색하게 됩니다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 해결할 수 있는 방법이 바로 focus trap 이라는 기법입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;말 그대로 포커스를 가둔다는 의미로 모달이 열려 있는 동안에는 모달 내부의 요소들만 탭을 통해 포커스가 가능하도록 하는 것인데요, 요즘은 이를 직접 구현하지 않아도 &lt;a href=&quot;https://github.com/focus-trap/focus-trap#readme&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;focus-trap (바닐라)&lt;/a&gt; 나 &lt;a href=&quot;https://www.npmjs.com/package/focus-trap-vue&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;focus-trap-vue&lt;/a&gt;, &lt;a href=&quot;https://www.npmjs.com/package/focus-trap-react&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;focus-trap-react&lt;/a&gt; 를 통해 간단하게 구현할 수 있습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1735277623346&quot; class=&quot;react javascript&quot; data-ke-language=&quot;react&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { FocusTrap } from &quot;focus-trap-react&quot;;

const Modal = ({ open, handleClose }: Props) =&amp;gt; {

  useEffect(() =&amp;gt; {
    const handleKeyDown = (event: KeyboardEvent) =&amp;gt; {
      if (event.key === &quot;Escape&quot; &amp;amp;&amp;amp; handleClose) {
        handleClose();
      }
    };
    if (open) {
      document.addEventListener(&quot;keydown&quot;, handleKeyDown);
    }
    return () =&amp;gt; {
      document.removeEventListener(&quot;keydown&quot;, handleKeyDown);
    };
  }, [open, handleClose]);

  return open
    ? createPortal(
        {/* 모달이 열려있는 동안에는 Focus 가능한 영역을 모달 내부 요소로 한정합니다. */}
        &amp;lt;FocusTrap&amp;gt;
          &amp;lt;Backdrop
            role=&quot;dialog&quot;
            aria-modal=&quot;true&quot;
            aria-labelledby=&quot;modal-header&quot;
          &amp;gt;
            &amp;lt;CloseButton onClick={handleClose}&amp;gt;닫기&amp;lt;/CloseButton&amp;gt;
            &amp;lt;Body&amp;gt;
              &amp;lt;Header id=&quot;modal-header&quot;&amp;gt;오늘의 주요 뉴스&amp;lt;/Header&amp;gt;
              &amp;lt;p&amp;gt;환율 1470원 돌파, 이대로 괜찮은가?&amp;lt;/p&amp;gt;
              &amp;lt;Author&amp;gt;김철수 기자&amp;lt;/Author&amp;gt;
              &amp;lt;button&amp;gt;기사 후원하기&amp;lt;/button&amp;gt;
            &amp;lt;/Body&amp;gt;
          &amp;lt;/Backdrop&amp;gt;
        &amp;lt;/FocusTrap&amp;gt;,
        document.body
      )
    : null;
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 시각장애인이 Tab 키를 실수로 잘못 누르더라도 모달 외부의 영역으로 포커스가 이동하지 않아, 사용자가 실수로 탐색 중이던 영역을 벗어나거나 집중을 잃을 가능성을 줄일 수 있게 되었습니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;846&quot; data-origin-height=&quot;1432&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2DybS/btsLzboC43A/ZKonnKS164MJcr1ZKBFiE0/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2DybS/btsLzboC43A/ZKonnKS164MJcr1ZKBFiE0/img.gif&quot; data-alt=&quot;탭 키를 여러번 눌러도 모달 내부 요소에만 포커스되는 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2DybS/btsLzboC43A/ZKonnKS164MJcr1ZKBFiE0/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/2DybS/btsLzboC43A/ZKonnKS164MJcr1ZKBFiE0/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;400&quot; height=&quot;677&quot; data-origin-width=&quot;846&quot; data-origin-height=&quot;1432&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;탭 키를 여러번 눌러도 모달 내부 요소에만 포커스되는 모습&lt;/figcaption&gt;
&lt;/figure&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;/p&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;</description>
      <category>  프론트엔드</category>
      <category>Aria</category>
      <category>focus-trap</category>
      <category>접근성</category>
      <category>프론트엔드</category>
      <author>Chamming2</author>
      <guid isPermaLink="true">https://merrily-code.tistory.com/342</guid>
      <comments>https://merrily-code.tistory.com/342#entry342comment</comments>
      <pubDate>Fri, 27 Dec 2024 14:51:29 +0900</pubDate>
    </item>
    <item>
      <title>playground</title>
      <link>https://merrily-code.tistory.com/pages/playground</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;/p&gt;</description>
      <author>Chamming2</author>
      <guid isPermaLink="true">https://merrily-code.tistory.com/pages/playground</guid>
      <pubDate>Thu, 19 Dec 2024 17:22:19 +0900</pubDate>
    </item>
    <item>
      <title>vee-validate에 타입스크립트 적용하기 (@vee-validate/yup)</title>
      <link>https://merrily-code.tistory.com/338</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://vee-validate.logaretm.com/v4/guide/composition-api/getting-started/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;공식 문서&lt;/a&gt;에 있는 간단한 내용이긴 하지만 기록해두면 좋을 것 같아 정리해 보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React-Hook-Form (RHF)의 &lt;code&gt;register&lt;/code&gt; 함수와는 달리, vee-validate의 &lt;code&gt;defineField&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;/p&gt;
&lt;pre id=&quot;code_1734583598776&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const { values, errors, defineField } = useForm({
  validationSchema,
});

const validationSchema = yup.object({
  startDate: yup
    .date()
    .required('날짜를 입력해 주세요')
    .max(yup.ref('endDate'), '시작일을 종료일 이전으로 입력해 주세요'),
  endDate: yup.date().required('날짜를 입력해 주세요'),
});

// ❌ 별도의 설정이 없다면 '&amp;lt;스키마의 키 값&amp;gt;' 은 타입스크립트에 의해 제안되지 않는다.
const [startDate] = defineField('&amp;lt;스키마의 키 값&amp;gt;');&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;964&quot; data-origin-height=&quot;408&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/brjYm6/btsLpHmjrSK/ITADiEmg3AKzKK06Oh2sKk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/brjYm6/btsLpHmjrSK/ITADiEmg3AKzKK06Oh2sKk/img.png&quot; data-alt=&quot;trigger suggestion을 실행해도 스키마의 키 값이 추론되지 않는다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/brjYm6/btsLpHmjrSK/ITADiEmg3AKzKK06Oh2sKk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbrjYm6%2FbtsLpHmjrSK%2FITADiEmg3AKzKK06Oh2sKk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;500&quot; height=&quot;212&quot; data-origin-width=&quot;964&quot; data-origin-height=&quot;408&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;trigger suggestion을 실행해도 스키마의 키 값이 추론되지 않는다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;vee-validate가 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;키 값을 추론하기 위해서는&lt;/span&gt; yup이 타입스크립트 지원을 받을 수 있도록 &lt;code&gt;@vee-validate/yup&lt;/code&gt; 패키지를 추가로 설치할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1734583847354&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# @vee-validate/yup 패키지 설치
npm install @vee-validate/yup&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1734583908217&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// toTypedSchema 는 yup이 vee-validate에 타입 추론을 제공할 수 있도록 도와준다.
import { toTypedSchema } from '@vee-validate/yup';

const validationSchema = toTypedSchema(yup.object({
  startDate: yup
      .date()
      .required('날짜를 입력해 주세요')
      .max(yup.ref('endDate'), '시작일을 종료일 이전으로 입력해 주세요'),
  endDate: yup.date().required('날짜를 입력해 주세요'),
}));

const [startDate] = defineField(''); // ✅ defineField 함수 인자의 타입이 추론된다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1386&quot; data-origin-height=&quot;672&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/L4L7N/btsLpD5u7Kw/oSslpmBYyjAdOd5MTeVfjk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/L4L7N/btsLpD5u7Kw/oSslpmBYyjAdOd5MTeVfjk/img.png&quot; data-alt=&quot;defineField 함수 인자에 타입 추론이 가능해진 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/L4L7N/btsLpD5u7Kw/oSslpmBYyjAdOd5MTeVfjk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FL4L7N%2FbtsLpD5u7Kw%2FoSslpmBYyjAdOd5MTeVfjk%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;1386&quot; height=&quot;672&quot; data-origin-width=&quot;1386&quot; data-origin-height=&quot;672&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;defineField 함수 인자에 타입 추론이 가능해진 모습&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 yup이나 zod 등의 검증 라이브러리를 사용하지 않거나, 라이브러리를 추가로 설치하는 것이 마음에 들지 않는다면 타입을 직접 제네릭으로 전달할 수도 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1734588434140&quot; class=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;const validationSchema = yup.object({
  startDate: yup
      .date()
      .required('날짜를 입력해 주세요')
      .max(yup.ref('endDate'), '시작일을 종료일 이전으로 입력해 주세요'),
  endDate: yup.date().required('날짜를 입력해 주세요'),
});

// toTypedSchema를 사용하지 않고, useForm에 직접 제네릭 타입을 정의할 수도 있다.
const { values, errors, defineField } = useForm&amp;lt;{
  startDate: string;
  endDate: string;
}&amp;gt;({
  validationSchema,
});

const [startDate] = defineField(''); // ✅ defineField 함수 타입이 추론된다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;696&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b6riT8/btsLozP5sQ0/rLx5utrqPa5Yr5cXYJyxp0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b6riT8/btsLozP5sQ0/rLx5utrqPa5Yr5cXYJyxp0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b6riT8/btsLozP5sQ0/rLx5utrqPa5Yr5cXYJyxp0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb6riT8%2FbtsLozP5sQ0%2FrLx5utrqPa5Yr5cXYJyxp0%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;1390&quot; height=&quot;696&quot; data-origin-width=&quot;1390&quot; data-origin-height=&quot;696&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;의도치 않게(?) Vue 개발을 주로 하게 되면서 React의 RHF을 사용하지 못한다는 점이 크게 아쉬웠는데, 생각보다 vee-validate를 사용한 개발 경험에 만족하는 중이다.&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;code&gt;defineField(&quot;키&quot;)&lt;/code&gt; 함수는 &lt;code&gt;Path&amp;lt;TValues&amp;gt;&lt;/code&gt; 타입을 &quot;키&quot; 의 타입으로 기대한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 useForm이 제네릭으로 받는 &lt;code&gt;TValues&lt;/code&gt; 타입이 어떤 마법을 거쳐 &lt;code&gt;Path&amp;lt;TValues&amp;gt;&lt;/code&gt; 타입으로 전환되어 전달될지가 궁금했다.&lt;/p&gt;
&lt;pre id=&quot;code_1734587334654&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// useForm()
// TValues 타입을 제네릭 첫 번째 인자로 받는다.
declare function useForm&amp;lt;TValues extends GenericObject = GenericObject, TOutput extends GenericObject = TValues, TSchema extends FormSchema&amp;lt;TValues&amp;gt; | TypedSchema&amp;lt;TValues, TOutput&amp;gt; = FormSchema&amp;lt;TValues&amp;gt; | TypedSchema&amp;lt;TValues, TOutput&amp;gt;&amp;gt;(opts?: FormOptions&amp;lt;TValues, TOutput, TSchema&amp;gt;): FormContext&amp;lt;TValues, TOutput&amp;gt;;

// defineField()
// Path&amp;lt;TValues&amp;gt; 타입을 제네릭 첫 번째 인자로 받는다.
defineField&amp;lt;TPath extends Path&amp;lt;TValues&amp;gt;, TValue = PathValue&amp;lt;TValues, TPath&amp;gt;, TExtras extends GenericObject = GenericObject&amp;gt;(path: MaybeRefOrGetter&amp;lt;TPath&amp;gt;, config?: Partial&amp;lt;InputBindsConfig&amp;lt;TValue, TExtras&amp;gt;&amp;gt; | LazyInputBindsConfig&amp;lt;TValue, TExtras&amp;gt;): [Ref&amp;lt;TValue&amp;gt;, Ref&amp;lt;BaseFieldProps &amp;amp; TExtras&amp;gt;];

// 둘의 타입이 다른데, 어떻게 TValues 타입이 Path&amp;lt;TValues&amp;gt; 타입으로 전환되는 걸까?&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직 긴 코드를 분석하는 능력은 부족해 GPT에 질의했더니 &lt;code&gt;FormContext&lt;/code&gt;의 존재를 알게 되었다.&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;  질문 링크 : &lt;a href=&quot;https://chatgpt.com/share/6763b59d-dd20-8008-89ca-6783e29d4c8d&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://chatgpt.com/share/6763b59d-dd20-8008-89ca-6783e29d4c8d&lt;/a&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 &lt;code&gt;toTypedSchema&lt;/code&gt;를 살펴보면 스키마를 인자로 받아 &lt;code&gt;TypedSchema&lt;/code&gt;로 캐스팅해 리턴한다.&lt;/p&gt;
&lt;pre id=&quot;code_1734587984889&quot; class=&quot;typescript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;typescript&quot;&gt;&lt;code&gt;toTypedSchema&amp;lt;TSchema extends Schema, TOutput = InferType&amp;lt;TSchema&amp;gt;, TInput = PartialDeep&amp;lt;TOutput&amp;gt;&amp;gt;(
  yupSchema: TSchema,
  opts: ValidateOptions = { abortEarly: false },
): TypedSchema&amp;lt;TInput, TOutput&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;toTypedSchema&lt;/code&gt; 함수를 통해 &lt;code&gt;validationSchema&lt;/code&gt;에 &lt;code&gt;TypedSchema&lt;/code&gt; 타입을 전달할 수 있게 되면 &lt;code&gt;validationSchema&lt;/code&gt;는 &lt;code&gt;TypedSchema&amp;lt;TValues, TOutput&amp;gt;&lt;/code&gt;이 되고, 제네릭에 전달된 &lt;code&gt;TValues&lt;/code&gt; 타입이 &lt;code&gt;defineFields&lt;/code&gt;에 전달되는 것으로 보인다.&lt;/p&gt;
&lt;pre id=&quot;code_1734588014481&quot; class=&quot;typescript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// useForm 컴포저블의 FormOptions 중 
validationSchema?: MaybeRef&amp;lt;TSchema extends TypedSchema ? TypedSchema&amp;lt;TValues, TOutput&amp;gt; : any&amp;gt;;

// * TValues는 기본적으로 Record&amp;lt;string, any&amp;gt; 타입이다.&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1636&quot; data-origin-height=&quot;34&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/brReoy/btsLpdMzLrY/m8fXuko8CSOSiDuONFArR0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/brReoy/btsLpdMzLrY/m8fXuko8CSOSiDuONFArR0/img.png&quot; data-alt=&quot;Yup.object()로 정의한 스키마 타입 (ObjectSchema &amp;amp;sub; YupSchema)&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/brReoy/btsLpdMzLrY/m8fXuko8CSOSiDuONFArR0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbrReoy%2FbtsLpdMzLrY%2Fm8fXuko8CSOSiDuONFArR0%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;1636&quot; height=&quot;34&quot; data-origin-width=&quot;1636&quot; data-origin-height=&quot;34&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Yup.object()로 정의한 스키마 타입 (ObjectSchema &amp;sub; YupSchema)&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;884&quot; data-origin-height=&quot;42&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cHQgKj/btsLpB7vt2z/9KQj0a9GKkAVuhwh8xjezk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cHQgKj/btsLpB7vt2z/9KQj0a9GKkAVuhwh8xjezk/img.png&quot; data-alt=&quot;toTypedSchema로 래핑된 스키마 타입&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cHQgKj/btsLpB7vt2z/9KQj0a9GKkAVuhwh8xjezk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcHQgKj%2FbtsLpB7vt2z%2F9KQj0a9GKkAVuhwh8xjezk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;420&quot; height=&quot;20&quot; data-origin-width=&quot;884&quot; data-origin-height=&quot;42&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;toTypedSchema로 래핑된 스키마 타입&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오픈소스를 사용할 때 원리를 알고 쓰려 노력하는 중인데, 타입스크립트 관문을 통과해야 하는게 쉽지 않은 것 같아 더 공부해야겠다.  &amp;zwj;♂️&lt;/p&gt;</description>
      <category>  프론트엔드/Vue.js</category>
      <category>vee-validate</category>
      <category>VUE</category>
      <author>Chamming2</author>
      <guid isPermaLink="true">https://merrily-code.tistory.com/338</guid>
      <comments>https://merrily-code.tistory.com/338#entry338comment</comments>
      <pubDate>Thu, 19 Dec 2024 15:25:11 +0900</pubDate>
    </item>
  </channel>
</rss>