<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>by HuiYa</title>
    <link>https://huiya-dev.tistory.com/</link>
    <description>8년차 Backend / Frontend 개발자의 기술 블로그</description>
    <language>ko</language>
    <pubDate>Thu, 9 Jul 2026 02:18:35 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>HuiYa</managingEditor>
    <image>
      <title>by HuiYa</title>
      <url>https://tistory1.daumcdn.net/tistory/2920466/attach/119bc63bde8a4bbe8c68ad5a9f88ddc0</url>
      <link>https://huiya-dev.tistory.com</link>
    </image>
    <item>
      <title>[IoT] Home Assistant + Zigbee 스위치로 핸드폰으로 불 켜보기 1편</title>
      <link>https://huiya-dev.tistory.com/24</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt; 시작하며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;몇 년전에 저는 IoT라는 개념을 처음 접하게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 저랑 별로 관련 없는 분야라고 생각했었죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러다가 I/O에서 나온 스위처 라는 제품을 사용해보게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;너무 오래전 일이라 어떻게 그걸 접하고 구매하게 되었는지는 기억나진 않지만,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;불을 켜기 위해 일어날 필요가 없고 핸드폰으로 제어할 수 있다는 점은 신세계였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 한정적으로나마 자동화가 가능한 것도 좋았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아침에 일어나야 할 시간에 불을 켜주면 기분은 나쁘지만 바로 일어날 수 있었죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 당시 스위처 제품은 (지금도 그렇지만) 별도의 설치 과정 없이 벨크로를 이용해서 기존 전등 스위치 위에 부착하고 사용하는 방식이였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기계 내부에서 발이 튀어나와 물리적으로 스위치를 눌러주는 방식이였죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전기 관련 지식이나 네트워크에 대해 아무것도 몰라도 전등 스위치 모양만 맞춰 그 위에 부착하고 블루투스를 통해 제어하는 방식은 초심자에게는 딱 좋은 방법이였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼을 누를 때마다 작동하는 기계적 움직임에서 발생하는 소음,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스위치 위로 툭 튀어나와있는 볼품없는 디자인 (그나마 스위처가 나은 편이긴 합니다),&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블루투스라는 한계상 자주 끊어지는 연결품질과 가격 문제는 큰 걸림돌이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 3구짜리 내준다고 해놓고서 여태 안내준 I/O..... 잊지 않고 있습니다....&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 여러 단점에도 불구하고 원룸에서 경험해본 IoT는 꽤 좋은 기억으로 남아있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 돈을 벌기 시작하고 투룸으로 이사오게 되면서 스위처를 더이상 사용하지 못하게 되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;집에 전등 스위치가 너무 많아졌거든요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3구짜리도 필요한데 스위처는 3구가 없었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 2년간 IoT는 무슨 원시인처럼 살았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 일어나지 않고 불을 켜겠다는 욕망은 아직 제 안에 잘 살아있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt; ️ 다시 IoT의 세계로&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2년간 여러 고민을 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;창문이나 커튼 자동으로 쳐주는 기계는 필요 없으니 (있으면 좋겠지만) 불이라도 켜주면 좋겠다..... 하고요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이젠 스위치 위에 추가적인 기계를 다는게 아니라 스위치 자체를 교체하고 싶어졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금은 스위처 말고도 많은 장비들이 나와있지만 영 맘에 들지 않았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 하고 싶은건 명확했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 특정 어플에 종속되지 말고 애플 HomeKit으로 제어 가능할 것 (그래야 시리한테 시켜먹으니까)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 예쁜 디자인&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 싼 가격&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;몇년간 네트워크와 개발 실력을 잘 쌓아올린 (그렇다고 주장하는) 저는 근거없는 자신감과 함께 무작정 도전해보기로 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;✈️ 장비 골라보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시중에는 많은 IoT 스위치들이 나와있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 국내에서 파는건 너무 가격이 높고 디자인이 제 취향이 아니였습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러니 중국으로 시선을 돌려봤습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제 경험상 알리는 대부분의 경우에 해답이 되어줬습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  프로토콜 고르기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;IoT 장비들도 기계니까 서로 통신하기 위해서 여러 프로토콜을 사용합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적으로 예전부터 오래 사용된 Zigbee, 차세대 표준인 Matter, 그리고 각자 알아서 사용하는 WiFi 기반 프로토콜까지.&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;개인적으로는 Matter로 가고 싶었지만 집에 스위치가 몇갠데 알리 기준으로도 하나당 50달러 넘어가는 가격은 감당하기 어려웠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러니 지그비를 사용하는 장비로 하기로 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다행히 먼저 실행해보신 분도 많고, 알리에 싼 장비도 널렸습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  스위치 고르기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러나 문제가 생겼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;집에 중성선이 없었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 읽고 계신 분이라면 중성선이 뭔지는 알고 계시리라 생각됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모르는 분을 위해서 간략하게 설명하자면 그냥 전원선입니다. (저도 이렇게밖에 모릅니다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스위치는 전기가 항상 연결된 활선과 스위치를 켰을때만 전기가 연결되는, 전등에 전기를 연결하기 위한 선(이름을 모릅니다)이 기본적으로 연결됩니다 (2선)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전기라는게 +, -가 같이 연결되어야 하는거라 활선 하나만으로는 기계를 동작시킬 수가 없는 것 같았습니다 (다시 말씀드리지만 잘 모릅니다.)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 스위치라는 것도 밖에서 조작하려면 전원이 켜져있는 기계여야만 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래야 원격으로 눌렀을 때 작동하니까요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;만약 중성선 없는 집에서 중성선이 필요한 장비를 작동시키려면 전기공사를.... 해야 한다고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 제 집도 아닌데 어떻게 그렇게까지 하겠어요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;월세사는 하찮은 직장인인데.....&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다행히 중성선 없는 지그비 스위치도 시중에는 널렸습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 중에서 골라잡기만 하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전기가 연결되지 않는데 어떻게 작동하냐구요?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미세 전류가 어쩌구저쩌구 하던데 잘은 모릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;별로 중요하지 않은 것 같았습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;방식이 어떻든 작동하기만 하면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;단점으로는 미세전류를 이용하는 방식이라 불이 미세하게 깜박일 수 있고, 이걸 해결하려면 전등에 캐패시터라는 걸 달아줘야 한다고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;......일단 도전해보기로 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떻게 다는지는 문제가 생겼을때 알아보면 됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한국에서는 다들 알다시피 220볼트를 씁니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정확하게는 AC(교류) 220V 60Hz를 쓴다고 합니다.&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;교류가 뭔지 모릅니다. 60Hz는 모니터를 살때 말고는 들어본적이 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뭐 대충 비슷한거겠지 생각합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일본은 동쪽과 서쪽이 서로 다른 주파수를 사용한다고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 서로 가전제품이 호환이 안된다는 소리를 들었습니다.&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;그걸 생각해보면 60Hz 지원이 안되면 한국에서 못쓰겠구나. 하는 생각이 들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기준이 정해졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;220V 60Hz 지원되는 장비를 사야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 한가지가 더 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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;263&quot; data-origin-height=&quot;458&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cRim4W/btsLbVSq4gP/4GaODCRGy3bFAhpEXPY441/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cRim4W/btsLbVSq4gP/4GaODCRGy3bFAhpEXPY441/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cRim4W/btsLbVSq4gP/4GaODCRGy3bFAhpEXPY441/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcRim4W%2FbtsLbVSq4gP%2F4GaODCRGy3bFAhpEXPY441%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;216&quot; height=&quot;376&quot; data-origin-width=&quot;263&quot; data-origin-height=&quot;458&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 친구의 사이즈가 벽에 들어가야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다행히 집마다 다르지 않고 한국 표준에 따라 정해진 사이즈가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 중국에서 파는 물건이 한국 표준을 지킬리가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;잘 골라서 사야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저도 정확하겐 잘 모르지만, 대충 이렇다고 합니다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1549&quot; data-origin-height=&quot;729&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cDeivD/btsLbBmCMKX/BwRCCYklePL9CzbEJrPVR1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cDeivD/btsLbBmCMKX/BwRCCYklePL9CzbEJrPVR1/img.png&quot; data-alt=&quot;킴스도매 펌&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cDeivD/btsLbBmCMKX/BwRCCYklePL9CzbEJrPVR1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcDeivD%2FbtsLbBmCMKX%2FBwRCCYklePL9CzbEJrPVR1%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;1549&quot; height=&quot;729&quot; data-origin-width=&quot;1549&quot; data-origin-height=&quot;729&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사이즈를 참고해서 골라봅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 문제가 생겼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디자인이 구립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;907&quot; data-origin-height=&quot;453&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vxImB/btsLbwMjHEh/ilbRObf0lwZn3kOKIM9rxK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vxImB/btsLbwMjHEh/ilbRObf0lwZn3kOKIM9rxK/img.png&quot; data-alt=&quot;마음에 들지 않는 디자인들&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vxImB/btsLbwMjHEh/ilbRObf0lwZn3kOKIM9rxK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvxImB%2FbtsLbwMjHEh%2FilbRObf0lwZn3kOKIM9rxK%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;907&quot; height=&quot;453&quot; data-origin-width=&quot;907&quot; data-origin-height=&quot;453&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 data-ke-size=&quot;size16&quot;&gt;누가 항상 스위치를 보고 버튼을 누를까요. 대부분 손가락 감각으로 누를텐데 터치는 말도 안됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;항상 핸드폰으로만 키고 싶지 않으면 기본적인 스위치 모양은 있어야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개꽁알만하게 생긴 스위치?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저걸 또 어떻게 찾아서 누를까요. 최소한 원래 쓰던 사이즈 정도는 되어야죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;눈물을 머금고 알리를 뒤지기 시작했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;찾다 포기하고, 찾다 포기하고.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이거다 싶어서 자세히 살펴보면 중성선 필요. 라고 적혀있고....&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국 이 과정에서만 몇달을 썼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;998&quot; data-origin-height=&quot;997&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bQoSg5/btsLb0sD6EH/uLjSRcoAKdZbv8hkOkNZs1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bQoSg5/btsLb0sD6EH/uLjSRcoAKdZbv8hkOkNZs1/img.png&quot; data-alt=&quot;드디어 찾았다!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bQoSg5/btsLb0sD6EH/uLjSRcoAKdZbv8hkOkNZs1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbQoSg5%2FbtsLb0sD6EH%2FuLjSRcoAKdZbv8hkOkNZs1%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;352&quot; height=&quot;352&quot; data-origin-width=&quot;998&quot; data-origin-height=&quot;997&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 data-ke-size=&quot;size16&quot;&gt;프레임도 얇고 버튼 영역도 넓은게 딱 제 취향이었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 1~3구까지 사이즈도 다양합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;964&quot; data-origin-height=&quot;1442&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cuouVv/btsLal6qers/UCKOxHmWEk7CgEQyzIEOS1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cuouVv/btsLal6qers/UCKOxHmWEk7CgEQyzIEOS1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cuouVv/btsLal6qers/UCKOxHmWEk7CgEQyzIEOS1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcuouVv%2FbtsLal6qers%2FUCKOxHmWEk7CgEQyzIEOS1%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;603&quot; height=&quot;902&quot; data-origin-width=&quot;964&quot; data-origin-height=&quot;1442&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;제가 고른건 이 사이즈였는데 위에선 구버전이라 내부 박스 가로가 52mm라고 표기되어 있습니다. 밑에서 말하는 47mm가 맞습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이상하게 벽에 꽉 들어갔으니 참고하시길 바랍니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;표준 사이즈는 55랬는데 왜 47이 꽉 끼었는지 아직 잘 모릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 살 때까지만 해도 한국 스위치처럼 좌우로 움직이는 방식인줄 알았는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그냥 점 없는 영역만 누를 수 있는 버튼식입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 불을 키면 사진에 있는 점에서 LED가 점등됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물리적으로 스위치 켜짐/꺼짐을 구분할 수 없는 점은 아쉽지만,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가격이 싸니까 괜찮습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하나당 30달러도 안합니다.&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;30달러면 싼게 맞습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;찾아보면 더 싼것도 많은데 그냥 진행하기로 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  스위치를 원격으로 작동시킬 장비 고르기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지그비를 작동시키려면 전파를 쏴줘야 하고 그걸 쏴줄 장비도 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다행히 시중에는 그런 장비도 많이 나와있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;문제는 그걸 작동시킬 컴퓨터가 필요합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;맙소사.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일이 점점 커집니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 아직도 특정 어플(회사)에 종속되고 싶지 않다는 결심은 확고합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러던 중 Home Assistant라는걸 알게되었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지그비나 matter를 통합해서 허브처럼 동작할 수 있다고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;특정 회사에서 주도하는게 아닌 오픈소스라는게 마음에 들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;삼성 스마트싱스도 좋아보이긴 하는데 전 애플 유저라 호환성에 문제가 있을 것 같았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;애플 하는 꼬라지를 보면 다른 회사 기계에서 제대로 돌아가는 꼴이 없는데 삼성이라고 다를까... 싶은 생각이 강했습니다 (선입견입니다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 집에 이미 시놀로지 920+ 나스가 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나스에 도커를 띄워서 거기서 구동시키면 딱 좋을 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도커 관련 지식도 수박 겉핡기로는 알고 있으니 괜찮습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 도전해보기로 합니다.&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;선배들이 말하기로는 sonoff 지그비 동글이 괜찮다고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무작정 삽니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;436&quot; data-origin-height=&quot;436&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Z0qIw/btsLayxCfoc/YjF8l0uFONqlfPEwMBpWvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Z0qIw/btsLayxCfoc/YjF8l0uFONqlfPEwMBpWvK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Z0qIw/btsLayxCfoc/YjF8l0uFONqlfPEwMBpWvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FZ0qIw%2FbtsLayxCfoc%2FYjF8l0uFONqlfPEwMBpWvK%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;436&quot; height=&quot;436&quot; data-origin-width=&quot;436&quot; data-origin-height=&quot;436&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 친구를 샀습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;USB 모양에 안테나가 달려있는게 직관적이라 마음에 듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 회사의 지그비 동글은 두가지 종류가 있는데 이건 EFR32MG21 기반이라고 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다른건 CC2652P라고 부르던데 뭔지 잘 모릅니다. 그냥 다른가보다 합니다. 예로부터 뭔가 다르면 제대로 돌아가는 꼴이 없었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 아뿔싸&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무작정 사면 안됐습니다.&lt;/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;나스에 USB를 꽂을 자리가 없습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 다 쓰고 있었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;게다가 집 제일 구석에 있는 컴퓨터 방에 나스와 공유기가 같이 있는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안방에서는 와이파이도 간당간당합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(이유를 모릅니다. 그렇게 큰집도 아닌데 이상하게 안됩니다)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;암튼 지그비 통신이 원활하게 될거라는 상상이 안됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;480&quot; data-origin-height=&quot;360&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CNUY4/btsLaCGLMTC/6N1KjvmfFzpa2bGpfW6e3K/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CNUY4/btsLaCGLMTC/6N1KjvmfFzpa2bGpfW6e3K/img.jpg&quot; data-alt=&quot;맙소사&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CNUY4/btsLaCGLMTC/6N1KjvmfFzpa2bGpfW6e3K/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCNUY4%2FbtsLaCGLMTC%2F6N1KjvmfFzpa2bGpfW6e3K%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;480&quot; height=&quot;360&quot; data-origin-width=&quot;480&quot; data-origin-height=&quot;360&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;USB 허브를 달까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안테나 선을 연장해서 거실까지 쭉 달아오면 괜찮지 않을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt; 홈서버 구성하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러다가 번쩍하고 드는 생각이 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미니 PC를 사자.&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;얼마전 N100이라는 인텔 CPU가 핫하다는 소리를 들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그땐 흥미로웠지만 쓸때가 없어서 못샀습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이젠 쓸모가 생겼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 삽니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 그냥 사고 싶었던거 같기도 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&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;460&quot; data-origin-height=&quot;426&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NwAti/btsK9ZJgc3K/Xdx7VkpIP5TiVBT2dkRxG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NwAti/btsK9ZJgc3K/Xdx7VkpIP5TiVBT2dkRxG0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NwAti/btsK9ZJgc3K/Xdx7VkpIP5TiVBT2dkRxG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNwAti%2FbtsK9ZJgc3K%2FXdx7VkpIP5TiVBT2dkRxG0%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;460&quot; height=&quot;426&quot; data-origin-width=&quot;460&quot; data-origin-height=&quot;426&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 샀습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;8기가 램에 256기가 SSD입니다.&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;10만원도 안하는 돈에 컴퓨터를 사다니.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세상 참 좋아졌습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뜯어보니 램에는 라벨도 없고 SSD는 듣보잡 허접한게 꽂혀있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다행히 M.2 슬롯은 있으니 교체해도 될거 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 일단 돌려보기로 했습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;벌써 돈을 너무 많이 써서.... 지갑이 아픕니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;돌려보고 문제 생기면 바꾸면 되죠 뭐.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;USB 포트도 많고 쪼끄만게 마음에 듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 친구를 거실에 놓고 여기서 지그비 신호를 쏘면 온 집에 다 닿을 수 있을 거 같은 느낌이 들었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리눅스를 깔아서 도커를 돌릴까...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;윈도우를 깔아서 도커 데스크탑을 돌릴까....&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알 수 없는 의식의 흐름대로 윈도우를 깔았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제부터 저희집 홈서버는 윈도우입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;왠지는 잘 모르겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그냥 깔아보고 싶었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  쓴 돈 계산해보기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 하드웨어 살만한건 다 산 것 같습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한번 계산해봅시다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;973&quot; data-origin-height=&quot;578&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/uh1Kw/btsLaxlcHTW/oAbJpEUqDTY7jbZoUTXSJk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/uh1Kw/btsLaxlcHTW/oAbJpEUqDTY7jbZoUTXSJk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/uh1Kw/btsLaxlcHTW/oAbJpEUqDTY7jbZoUTXSJk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fuh1Kw%2FbtsLaxlcHTW%2FoAbJpEUqDTY7jbZoUTXSJk%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;973&quot; height=&quot;578&quot; data-origin-width=&quot;973&quot; data-origin-height=&quot;578&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 스위치 3구 하나, 2구 하나, 1구 4개를 사는데 100달러를 썼습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;6개에 100달러라니. 상당히 괜찮습니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;블랙프라이데이 세일을 받아 할인을 많이 받았습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개수 계산을 잘못해서 1구 5개를 사야하는데 하나 빼먹고 산거 빼고는 다 좋습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지그비 동글은 12달러 정도 줬네요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뭐 나쁘지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;967&quot; data-origin-height=&quot;262&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nPbeu/btsLa169lbg/NaUNBoD039VuRl0P2gnyHk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nPbeu/btsLa169lbg/NaUNBoD039VuRl0P2gnyHk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nPbeu/btsLa169lbg/NaUNBoD039VuRl0P2gnyHk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnPbeu%2FbtsLa169lbg%2FNaUNBoD039VuRl0P2gnyHk%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;967&quot; height=&quot;262&quot; data-origin-width=&quot;967&quot; data-origin-height=&quot;262&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뒤늦게 구매한 N100 미니 PC도 할인 쿠폰을 잔뜩 먹여서 74달러 줬습니다.&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;합쳐서 186달러....&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제야 정신이 번쩍 듭니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;687&quot; data-origin-height=&quot;709&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdAgte/btsLaXwTLW1/uiFP3M1vbTV7NHVKnaF1O0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdAgte/btsLaXwTLW1/uiFP3M1vbTV7NHVKnaF1O0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdAgte/btsLaXwTLW1/uiFP3M1vbTV7NHVKnaF1O0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdAgte%2FbtsLaXwTLW1%2FuiFP3M1vbTV7NHVKnaF1O0%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;444&quot; height=&quot;458&quot; data-origin-width=&quot;687&quot; data-origin-height=&quot;709&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오늘기준 27만원.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;카드 수수료까지 하면 얼마나 나갔는지 모릅니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 이렇게 돈을 쓸 일인가? 싶은데 어쩌겠습니까.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 다 제 취미인것을.....&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;취미에 30만원이라고 생각하니 나쁘지 않은거 같기도 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;몇달 아꼈으니 쓸때가 되기도 했죠.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;가격은 다시 생각하지 않도록 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 이미 샀으니 늦었습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;낙장불입입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 보시는 분은 더 저렴하게 구성하시길 바라겠습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버를 나스를 썼으면 10만원은 아꼈을텐데.....&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 친구가 문제인거 같기도 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 된 이상 더 쓸모를 찾아서 돈을 뽑아낼 수밖에 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;용도가 늘어나면 가성비도 늘어납니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;마침 윈도우를 깔았으니 원격으로 접속해서 이것저것 할 수 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;뭘 할지는 차차 생각해봐야 합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직은 쓸모가 없습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;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>ha</category>
      <category>home assistant</category>
      <category>IoT</category>
      <category>n100</category>
      <category>switch</category>
      <category>ZigBee</category>
      <category>도커</category>
      <category>스위치</category>
      <category>지그비</category>
      <category>핸드폰불</category>
      <author>HuiYa</author>
      <guid isPermaLink="true">https://huiya-dev.tistory.com/24</guid>
      <comments>https://huiya-dev.tistory.com/24#entry24comment</comments>
      <pubDate>Sat, 7 Dec 2024 16:36:42 +0900</pubDate>
    </item>
    <item>
      <title>[Vue] pinia store로 테마 관리하기</title>
      <link>https://huiya-dev.tistory.com/23</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;  시작하며&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요즘 개발하는 모던 웹 페이지에서 테마 기능을 빼놓을 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 새로운 프로젝트에서도 테마 기능을 넣어보려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테마 정보를 어디에 기록하면 좋을까 생각하다가 &lt;br /&gt;여러가지 이유로 글 제목처럼 피니아 스토어에 기록해야겠다는 생각이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 요구사항&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테마 기능을 구현할때 단순히 브라우저나 기기의 설정을 따라가기만 해도 좋지만 &lt;br /&gt;그와는 별도로 사용자 지정도 가능하게 하고 싶어서 요구사항을 비교적 복잡하게 지정했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: circle;&quot; data-ke-list-type=&quot;circle&quot;&gt;
&lt;li&gt;기본적으로 기기의 설정에 따라 설정되어야 함&lt;/li&gt;
&lt;li&gt;사용자가 테마를 변경시 기기의 설정과 별개로 동작해야 함&lt;/li&gt;
&lt;li&gt;테마 정보는 저장되어 있다가 다시 접근할때 복원되어야 함.&lt;/li&gt;
&lt;li&gt;테마를 변경할때 새로고침 등의 액션 없이 즉각적으로 반영되어야 함. &amp;larr; 피니아를 사용하게 된 이유다.&lt;/li&gt;
&lt;li&gt;라이트 / 다크 모드 뿐만 아니라 그외의 테마도 지원되어야 함 (예를 들면 true-dark 같은거)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;⌨️ 각 기능 구현&lt;/h2&gt;
&lt;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;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;❕기본적으로&amp;nbsp;기기의&amp;nbsp;설정에&amp;nbsp;따라&amp;nbsp;설정되어야&amp;nbsp;함&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 기능을 구현하는건 여러가지 방법이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대표적으로는 css의 미디어 쿼리를 활용하는 방법이 있다.&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;바로 아래 코드처럼 `prefers-color-scheme` 속성을 활용하는 방법이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기기 설정에 따라 서로 다른 테마를 지정할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만 이 방법을 사용하면 사용자가 테마를 변경할 수 없기 때문에 두번째 요구사항이 충족될 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;iframe src=&quot;https://codesandbox.io/embed/4pc7hf?view=editor&amp;amp;module=%2Fstyles.css&quot; width=&quot;100%&quot; height=&quot;300px&quot;&gt;&lt;/iframe&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 이 속성은 단순히 현재 기기의 설정값을 불러오는데만 사용하기로 했다.&lt;/p&gt;
&lt;pre id=&quot;code_1714288144478&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;/**
 * 현재 시스템 설정에 기반한 테마 값을 반환하는 함수
 */
const getSystemTheme = (): Theme =&amp;gt; {
  return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 만약 사용자가 웹사이트를 열어놓은 상태에서 기기 설정을 변경하게 된다면 어떻게 될까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 코드는 한번만 불러오는 함수기 때문에 기기 설정이 바뀌었을때 재호출을 하지 않는다면 현재 테마 값을 즉시 불러올 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서 별도의 이벤트를 추가해 사용자가 기기 설정을 변경했을때를 감지하기로 했다.&lt;/p&gt;
&lt;pre id=&quot;code_1714288605231&quot; class=&quot;javascript&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;javascript&quot;&gt;&lt;code&gt;const theme: Ref&amp;lt;Theme&amp;gt; = ref('light'); // 테마 값 저장할 변수

/**
 * 시스템 설정 변경 감지를 위한 이벤트 리스너 설정 함수
 */
const setEvent = () =&amp;gt; {
  const darkModeMediaQuery: MediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');

  const handleDarkModeChange = (): void =&amp;gt; {
    theme.value = getSystemTheme()
  };

  darkModeMediaQuery.addEventListener('change', handleDarkModeChange); // 여기가 핵심!
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드의 핵심은 미디어 쿼리에 이벤트를 등록하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 되면 미디어 쿼리가 작동할때 (여기서는 테마가 변경될때) 이벤트를 받을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;❕사용자가&amp;nbsp;테마를&amp;nbsp;변경시&amp;nbsp;기기의&amp;nbsp;설정과&amp;nbsp;별개로&amp;nbsp;동작해야&amp;nbsp;함&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 기기 설정과 별개로 동작하려면 별도의 클래스를 지정해서 테마를 작성해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러기 위해서 body에 아래처럼 테마 클래스를 지정하기로 했다.&lt;/p&gt;
&lt;pre id=&quot;code_1714290211491&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;body.theme-light
body.theme-dark&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1714290164095&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const availableThemes: Theme[] = ['light', 'dark'];

/**
 * body 태그의 클래스를 업데이트하는 함수
 */
const updateBodyClass = (newTheme: Theme) =&amp;gt; {
  document.body.classList.remove(...availableThemes.map(theme =&amp;gt; `theme-${theme}`));
  document.body.classList.add(`theme-${newTheme}`);
};

// theme 값이 변경될 때마다 실행될 watch 함수
watch(
  theme,
  newValue =&amp;gt; {
    // 테마 값이 변경 될 경우 해당 값으로 body 클래스 업데이트
    updateBodyClass(newValue);
  },
  {
    immediate: true // 스토어가 설정될 때 즉시 실행
  }
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;body에 모든 테마 클래스를 제거하고 새로 지정해주는 함수를 만들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;watch 함수를 이용해서 이 함수를 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;테마 값이 변경될때마다&lt;span&gt; 실행해주면 끝이다.&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;❕테마&amp;nbsp;정보는&amp;nbsp;저장되어&amp;nbsp;있다가&amp;nbsp;다시&amp;nbsp;접근할때&amp;nbsp;복원되어야&amp;nbsp;함.&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저장하는 기능을 별도로 만들어도 되지만 이미 잘 만들어진 피니아 플러그인이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://prazdevs.github.io/pinia-plugin-persistedstate/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;pinia-plugin-persistedstate&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1714290589856&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;Home | pinia-plugin-persistedstate&quot; data-og-description=&quot;pinia-plugin-persistedstate Configurable persistence and rehydration of Pinia stores.&quot; data-og-host=&quot;prazdevs.github.io&quot; data-og-source-url=&quot;https://prazdevs.github.io/pinia-plugin-persistedstate/&quot; data-og-url=&quot;https://prazdevs.github.io/pinia-plugin-persistedstate/&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://prazdevs.github.io/pinia-plugin-persistedstate/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://prazdevs.github.io/pinia-plugin-persistedstate/&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Home | pinia-plugin-persistedstate&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;pinia-plugin-persistedstate Configurable persistence and rehydration of Pinia stores.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;prazdevs.github.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 플러그인을 사용하면 로컬스토리지에 저장하는 과정을 우리가 신경쓸 필요 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 설정을 위한 코드는 너무 단순해서 여기에 기재하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 코드 혹은 라이브러리 예제를 참고하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;❕테마를&amp;nbsp;변경할때&amp;nbsp;새로고침&amp;nbsp;등의&amp;nbsp;액션&amp;nbsp;없이&amp;nbsp;즉각적으로&amp;nbsp;반영되어야&amp;nbsp;함.&amp;nbsp;&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;테마 변수를 ref 변수로 선언하고 watch를 통해서 값이 변경될때마다 body에 클래스를 지정하고 있으니 해당 값을 css에서 사용하면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1714291053788&quot; class=&quot;css&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;css&quot;&gt;&lt;code&gt;body.theme-light {
  color-scheme: light;
  
  background-color: #fff;
  color: #333;
}

body.theme-dark {
  color-scheme: dark;
  
  background-color: #000;
  color: #fff;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;❕라이트&amp;nbsp;/&amp;nbsp;다크&amp;nbsp;모드&amp;nbsp;뿐만&amp;nbsp;아니라&amp;nbsp;그외의&amp;nbsp;테마도&amp;nbsp;지원되어야&amp;nbsp;함&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자의 기기 설정을 유지하기 위해서는 라이트 / 다크 모드가 아니라 시스템 모드도 별개로 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 외에도 진짜 #000을 배경으로 사용하는 true-dark 모드를 지원하기로 했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;425&quot; data-origin-height=&quot;633&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bhAIOP/btsGYAmLTuz/6k4nk0ile3qvAlilFd1u7K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bhAIOP/btsGYAmLTuz/6k4nk0ile3qvAlilFd1u7K/img.png&quot; data-alt=&quot;이런 느낌?&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bhAIOP/btsGYAmLTuz/6k4nk0ile3qvAlilFd1u7K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbhAIOP%2FbtsGYAmLTuz%2F6k4nk0ile3qvAlilFd1u7K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;425&quot; height=&quot;633&quot; data-origin-width=&quot;425&quot; data-origin-height=&quot;633&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이걸 위해서는 여태 작성한 코드를 약간 수정해야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1714291469851&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// theme 상태를 Ref 객체로 생성. 
const theme: Ref&amp;lt;Theme&amp;gt; = ref('system'); // 기본 값을 system으로 설정

const availableThemes: Theme[] = ['system', 'light', 'dark', 'true-dark']; // system과 true-dark 모드 추가

/**
 * 시스템 설정 변경 감지를 위한 이벤트 리스너 설정 함수
 */
const setEvent = () =&amp;gt; {
  const darkModeMediaQuery: MediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');

  const handleDarkModeChange = (): void =&amp;gt; {
    if (theme.value === 'system') {
      // 더 이상 기기 설정이 변경될때 테마 값을 변경하지 않고 body에 클래스 명만 변경한다.
      // 이 기능은 system 일때만 작동하도록 한다.
      // system이 아닐때 body 클래스 변경은 밑에 있는 watch에서 담당한다.
      updateBodyClass(getSystemTheme());
    }
  };

  darkModeMediaQuery.addEventListener('change', handleDarkModeChange);
};

// theme 값이 변경될 때마다 실행될 watch 함수
watch(
  theme,
  newValue =&amp;gt; {
    if (newValue === 'system') {
      // 'system'으로 설정될 경우 현재 시스템 테마에 따라 body 클래스 업데이트
      updateBodyClass(getSystemTheme());
    } else {
      // 'system' 외의 값으로 설정될 경우 해당 값으로 body 클래스 업데이트
      updateBodyClass(newValue);
    }
  },
  {
    immediate: true // 스토어가 설정될 때 즉시 실행
  }
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;setEvent 함수를 변경해서 system 일때만 body 클래스를 업데이트 하도록 변경했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러면 더 이상 기기 설정 변경시 테마 값이 변경되지는 않지만 body 클래스만 변경되기 때문에 사용자는 실제로 테마가 변경되는 것처럼 보이게 된다.&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;사용자가 테마를 설정할때 body에 반영하는건 watch 함수가 담당한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  완성된 전체 코드&lt;/h2&gt;
&lt;pre id=&quot;code_1714291617734&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { defineStore } from 'pinia';
import { computed, Ref, ref, watch } from 'vue';

// Theme 타입 정의: 'light', 'dark', 'true-dark', 'system' 중 하나의 값을 가질 수 있음
export type Theme = 'light' | 'dark' | 'true-dark' | 'system';

// Pinia 스토어 정의
export const useThemeStore = defineStore(
  'theme', // 스토어 ID
  () =&amp;gt; {
    // theme 상태를 Ref 객체로 생성.
    const theme: Ref&amp;lt;Theme&amp;gt; = ref('system');

    const availableThemes: Theme[] = ['system', 'light', 'dark', 'true-dark'];

    /**
     * 현재 시스템 설정에 기반한 테마 값을 반환하는 함수
     */
    const getSystemTheme = (): Theme =&amp;gt; {
      return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
    };

    /**
     * 시스템 설정 변경 감지를 위한 이벤트 리스너 설정 함수
     */
    const setEvent = () =&amp;gt; {
      const darkModeMediaQuery: MediaQueryList = window.matchMedia('(prefers-color-scheme: dark)');

      const handleDarkModeChange = (): void =&amp;gt; {
        if (theme.value === 'system') {
          // 설정이 변경될때 body에 클래스 명만 변경한다.
          // 이 기능은 system 일때만 작동하도록 한다.
          // system이 아닐때 body 클래스 변경은 밑에 있는 watch에서 담당한다.
          updateBodyClass(getSystemTheme());
        }
      };

      darkModeMediaQuery.addEventListener('change', handleDarkModeChange);
    };

    /**
     * body 태그의 클래스를 업데이트하는 함수
     */
    const updateBodyClass = (newTheme: Theme) =&amp;gt; {
      document.body.classList.remove(...availableThemes.map(theme =&amp;gt; `theme-${theme}`));
      document.body.classList.add(`theme-${newTheme}`);
    };

    // theme 값이 변경될 때마다 실행될 watch 함수
    watch(
      theme,
      newValue =&amp;gt; {
        if (newValue === 'system') {
          // 'system'으로 설정될 경우 현재 시스템 테마에 따라 body 클래스 업데이트
          updateBodyClass(getSystemTheme());
        } else {
          // 'system' 외의 값으로 설정될 경우 해당 값으로 body 클래스 업데이트
          updateBodyClass(newValue);
        }
      },
      {
        immediate: true // 스토어가 설정될 때 즉시 실행
      }
    );

    return {
      theme,
      availableThemes,
      setEvent
    };
  },
  {
    // Pinia 스토어 지속성 설정
    persist: {
      key: 'theme'
    }
  }
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전체 프로세스는 아래와 같다.&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;기본 테마는 시스템으로 설정된다.&lt;/li&gt;
&lt;li&gt;이 테마 값은 pinia-plugin-persistedstate 라이브러리를 통해서 로컬스토리지에 저장되고, 다시 접근할때 자동으로 복원된다. 따라서 우리는 theme 값 저장/복원에 신경쓸 필요가 없다.&lt;/li&gt;
&lt;li&gt;watch를 통해서 (immediate 옵션이 있으므로) 처음에 updateBodyClass 함수가 호출된다. 이 함수는 body에 클래스를 지정해준다.&lt;/li&gt;
&lt;li&gt;실제 테마 스타일은 body 클래스를 참조해서 css로 제공된다.&lt;/li&gt;
&lt;li&gt;테마 값이 system일 경우 setEvent를 통해서 등록된 이벤트에서 &lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;updateBodyClass&lt;span&gt; 함수를 호출해 body 클래스를 변경한다.&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;&lt;span&gt;실제 적용되는 테마는 body에 클래스로, 값은 스토어의 ref 변수로 저장된다. 두 값은 system일 경우 서로 다르게 지정될 수 있다.&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;&lt;span&gt;이 코드만 가지고 자동으로 동작하면 좋겠지만 피니아는 호출하지 않는 이상 동작하지 않기 때문에...&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;&lt;span&gt;App.vue에 아래 코드를 추가했다.&lt;/span&gt;&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: left;&quot;&gt;&lt;span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1714292132608&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// App.vue

&amp;lt;template&amp;gt;
  &amp;lt;router-view /&amp;gt;
&amp;lt;/template&amp;gt;
&amp;lt;script setup lang=&quot;ts&quot;&amp;gt;
import { useThemeStore } from '@/app/stores/layoutStore.ts';
import { RouterView } from 'vue-router';

// service initialize

// 기본적으로 사용할 스토어 설치.
// 글로벌하게 사용할 목적

// 테마 이벤트 세팅
// 이걸 해야 시스템 테마 변경시 이벤트 감지 가능
const themeStore = useThemeStore();
themeStore.setEvent();
&amp;lt;/script&amp;gt;
&amp;lt;style scoped lang=&quot;scss&quot;&amp;gt;&amp;lt;/style&amp;gt;&lt;/code&gt;&lt;/pre&gt;</description>
      <category>개발/Frontend</category>
      <category>localStorage</category>
      <category>pinia</category>
      <category>store</category>
      <category>theme</category>
      <category>vue</category>
      <category>로컬스토리지</category>
      <category>뷰</category>
      <category>스토어</category>
      <category>테마</category>
      <category>피니아</category>
      <author>HuiYa</author>
      <guid isPermaLink="true">https://huiya-dev.tistory.com/23</guid>
      <comments>https://huiya-dev.tistory.com/23#entry23comment</comments>
      <pubDate>Sun, 28 Apr 2024 17:17:49 +0900</pubDate>
    </item>
    <item>
      <title>맥북 키보드 영문 키캡 교체기</title>
      <link>https://huiya-dev.tistory.com/22</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;  요즘 맥북을 사용하기 시작했다&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내 맥북은 예전 모델인 M1 맥북프로 13인치다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좀 된 모델이지만 성능상 부족한게 없고 (모니터 크기는 좀 모자라긴 하다) 개인적으로 터치바를 좋아해서 이 모델을 고수하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;무엇보다 잘 안 쓰니 새로 살 필요가 없었다  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한참 동안 안쓰던 맥북을 요즘 들어 꺼내서 사용하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러다보니 (일이 하기 싫어지면) 뭔가 이것저것 업그레이드 하고 싶다는 욕심이 자꾸 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사람들이 영문 키보드가 이쁘다고 일부러 사기도 한다는 건 예전부터 알고 있었는데 솔직히 관심이 가질 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;letter-spacing: 0px;&quot;&gt;고작 키보드가 뭐라고...&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 뭔가 업그레이드 할게 없나 하고 찾아보다가 영문 키보드를 다시 발견하게 됐고,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알리에서 만원 언저리에 키캡만 파는걸 발견했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;살까말까 하다가 덜컥 구입.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도착하자마자 밥도 안먹고 교체해봤다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;⌨️ 교체 후기&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;시작하기 전에는 어떻게 교체하는거지 하면서 여기저기 찾아봤었는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역시 인터넷에는 같은 길을 간 선배들이 많았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유튜브에 영상이 많으니 참고하면 좋다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;많이 볼 필요는 없고 한두개만 봐도 충분하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 키보드 부숴지는거 아냐?! 하면서 쫄았었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 후반쯤 가니까 손목 스냅으로 가볍게 톡톡 치니 쉽게 뽑히더라.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;생각보다 금방 익숙해진다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;끼우는건 더 쉽다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위쪽 위치만 잘 잡아주고 아래쪽을 꾹 누르면 위 아래 모두 동시에 장착된다.&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;영문 키캡은 한국어 키보드와 다르게 Enter나 Shift 같은 기능키가 다 영문으로 각인되어 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런 기능 키는 아이콘이 보이는게 더 이쁜거 같아서 새로 산 키캡은 글자 부분만 변경했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;알리에서 파는거라 좀 걱정했는데 퀄리티가 생각 이상으로 괜찮았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;눈으로 보기에는 원본 정품 키캡과 전혀 구분할 수 없었다. 손가락 감각으로도 전혀 모르겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무래도 키캡을 뽑다보면 고정부가 늘어날 수 밖에 없다. 아예 부러지진 않아서 두어번은 더 장착 / 탈착해도 괜찮겠지만 그 이상으로 재사용은 어려울 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;퀄리티가 생각이상으로 괜찮으니 예비용으로 한국어 키캡을 하나 사놓을까 싶기도 하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인적으로는 이게 무슨 차인가 싶기도 하면서도 은근히 만족스럽다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;고작 키보드인데도 보면 이쁘다는 생각이 든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;교체하기 전에 사진으로 봤을때는 이런 생각을 못했었는데...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;꼭 바꿀 필요는 없지만 난 마음에 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아마 새 맥북을 산다면 또 교체할지도 모르겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;IMG_0425.jpeg&quot; data-origin-width=&quot;5712&quot; data-origin-height=&quot;3235&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cbBQtj/btsGzDI6n2o/AV5C80RZfx0a1zfhImu1mk/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cbBQtj/btsGzDI6n2o/AV5C80RZfx0a1zfhImu1mk/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cbBQtj/btsGzDI6n2o/AV5C80RZfx0a1zfhImu1mk/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcbBQtj%2FbtsGzDI6n2o%2FAV5C80RZfx0a1zfhImu1mk%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;5712&quot; height=&quot;3235&quot; data-filename=&quot;IMG_0425.jpeg&quot; data-origin-width=&quot;5712&quot; data-origin-height=&quot;3235&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;</description>
      <category>IT 기기</category>
      <category>CTO</category>
      <category>Keycap</category>
      <category>macbook</category>
      <category>맥북</category>
      <category>알리</category>
      <category>영문</category>
      <category>자가수리</category>
      <category>키캡</category>
      <author>HuiYa</author>
      <guid isPermaLink="true">https://huiya-dev.tistory.com/22</guid>
      <comments>https://huiya-dev.tistory.com/22#entry22comment</comments>
      <pubDate>Thu, 11 Apr 2024 23:07:42 +0900</pubDate>
    </item>
    <item>
      <title>[CSS] 다크모드 완벽 지원하기</title>
      <link>https://huiya-dev.tistory.com/21</link>
      <description>&lt;h2 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt; &lt;span&gt; 다크모드에서 깜박하는 것&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최근 웹 개발의 필수품 중 하나는 다크모드다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여러가지 웹/앱을 사용하면서 다크모드가 지원되지 않는 서비스를 찾아보기 드물었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다크모드를 구현하는 방법에는 여러가지 기술이 있지만 공통점이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;모든 컬러를 수동으로 작성해야 한다는 점이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;div, Button, Input, Textarea 등등 눈에보이는 모든 부분의 색상이 재정의 되어야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러다보니 놓치기 쉬운 부분이 있는데 바로 스크롤바다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저에서 기본 css가 정의되어 있는 항목이 여럿 있지만 가장 대표적인 부분은 Input, Button, 스크롤 바 영역이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 친구들은 브라우저마다 보이는 스타일이 다르다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 대부분의 서비스에서 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;input과 button은&lt;span&gt; &lt;/span&gt;&lt;/span&gt;스타일 요소로 취급해 디자인을 변경하여 모든 브라우저에서 동일하게 보이도록 작업하는 경향이 있지만 유난히 스크롤바는 디자인하지 않고 기본 스타일을 사용하는 경우가 흔하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러다보니 다크모드에서도 간과하여 디자인(색상)이 변하지 않는다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;472&quot; data-origin-height=&quot;468&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CzVud/btsDJYDO7xm/Wn7iP7OWnSUlTgGGYI1WNk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CzVud/btsDJYDO7xm/Wn7iP7OWnSUlTgGGYI1WNk/img.png&quot; data-alt=&quot;다크모드에서 스크롤은 흰색이다. 네이버 조차도!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CzVud/btsDJYDO7xm/Wn7iP7OWnSUlTgGGYI1WNk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCzVud%2FbtsDJYDO7xm%2FWn7iP7OWnSUlTgGGYI1WNk%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;472&quot; height=&quot;468&quot; data-origin-width=&quot;472&quot; data-origin-height=&quot;468&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 data-ke-size=&quot;size16&quot;&gt;스크롤바까지도 디자인해서 적용해야 하는걸까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그치만 그렇게까지 하긴 귀찮다  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;(스크롤바를 스타일링하려면 의사선택자로 떡칠해야 한다!)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  방법이 있다.&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;바로 &lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;color-scheme&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;이 친구를 dark light로 지정하면 브라우저에게 다크모드와 라이트모드를 모두 한다고 알려주게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 브라우저가 알아서 Form 컨트롤과 스크롤바 영역의 색상을 바꿔준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;iframe src=&quot;https://codesandbox.io/embed/k3j8rp?view=preview&amp;amp;module=%2Findex.html&quot; width=&quot;100%&quot; height=&quot;500px&quot;&gt;&lt;/iframe&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;574&quot; data-origin-height=&quot;289&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/eNSopi/btsDUTAR42I/TK1Rkarl102InPeEWKb78K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/eNSopi/btsDUTAR42I/TK1Rkarl102InPeEWKb78K/img.png&quot; data-alt=&quot;이 정도 차이가 난다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/eNSopi/btsDUTAR42I/TK1Rkarl102InPeEWKb78K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FeNSopi%2FbtsDUTAR42I%2FTK1Rkarl102InPeEWKb78K%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;574&quot; height=&quot;289&quot; data-origin-width=&quot;574&quot; data-origin-height=&quot;289&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;문서 전체에 적용하려면 meta 태그를 사용해도 되고, CSS로 값을 넣어도 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1706015192797&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;meta name=&quot;color-scheme&quot; content=&quot;light dark&quot;&amp;gt;
&amp;lt;!-- &amp;uarr; light dark 두 값을 모두 넣으면 브라우저 기본 설정에 따라 자동으로 변경된다 --&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1706015212100&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;:root {
  color-scheme: dark light;
  /* &amp;uarr; light dark 두 값을 모두 넣으면 브라우저 기본 설정에 따라 자동으로 변경된다 */
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;meta 태그로 값을 지정하면 css를 로드하기 전에 브라우저에게 알려줄 수 있다는 장점이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 CSS로 넣는다면 개별적으로 컨트롤 할 수 있다는 장점이 있다.&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;많은 사이트에서 다크모드를 구현할때 기본 설정과 별개로 모드를 바꿀 수 있게 하기 위해서 body에 dark 같은 임의의 클래스를 넣고 그 클래스 하위로 색상을 지정하는 경우가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이럴때는 has 선택자를 이용해서 CSS로 컨트롤하는게 더 나은 사용자 경험을 보여줄 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1706022332011&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;:root:has(body.light) {
  color-scheme: light;
}
:root:has(body.dark) {
  color-scheme: dark;
}
:root:has(body.lightdark) {
  color-scheme: light dark;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;❗ 참고&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://bejamas.io/blog/modern-css-properties-your-website-must-have/&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://bejamas.io/blog/modern-css-properties-your-website-must-have/&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/CSS/color-scheme&lt;/a&gt;&lt;/p&gt;</description>
      <category>개발/Frontend</category>
      <category>CSS</category>
      <category>Dark</category>
      <category>Darkmode</category>
      <category>html</category>
      <category>다크모드</category>
      <category>스크롤</category>
      <category>스크롤다크모드</category>
      <category>웹개발</category>
      <category>퍼블리싱</category>
      <author>HuiYa</author>
      <guid isPermaLink="true">https://huiya-dev.tistory.com/21</guid>
      <comments>https://huiya-dev.tistory.com/21#entry21comment</comments>
      <pubDate>Wed, 24 Jan 2024 00:09:56 +0900</pubDate>
    </item>
    <item>
      <title>[Penly] 프로젝트를 시작하며</title>
      <link>https://huiya-dev.tistory.com/20</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;  왜 이런 프로젝트를 생각했을까?&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 취미로 글을 쓴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;글을 쓰다보니 영감이 시도때도 없이 찾아오게 되는데, 항상 이 영감을 어딘가에 메모해야 하는 불편함이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그냥 단순히 아이디어 수준의 영감이면 괜찮다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇지만 원고에 대해 퇴고 아이디어가 떠올랐을때가 항상 문제였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통 집필 프로그램은 컴퓨터에 설치되어 작업하는 경우가 많으니 아이디어가 번쩍하고 떠올랐을때&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 컴퓨터 앞에 앉아있지 않으면 아이디어의 신선도가 떨어져버리게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 구글독스처럼 온라인 기반의 웹 에디터가 있었으면 좋겠다는 생각이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;집필에 최적화된 UI와 모바일 지원까지 가능한 클라우드 기반의 웹 에디터.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;상상만 해도 마음에 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt; ️ 프로젝트 이름을 정해보자&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 이름을 정하는건 항상 어려운 일이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;변수명을 짓는것도 어려운데 프로젝트 이름이라니.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 이번에는 같이 하는 친구가 있어서 백지장도 맞들면 낫다고 같이 아이디어를 짜내보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결과적으로는 Weaver라는 이름을 정하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;weave는 (베를) 짜다 라는 의미인데 천을 만드는 것처럼 글도 엮어낸다는 의미로 Weaver라는 이름을 고르게 되었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;☠️ 그런데&amp;hellip;&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 data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 도메인을 찾아보니 weaver와 관련된 도메인은 올킬.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정말 weaver.com.cn 같은 생소한 도메인 빼고는 단 하나도 남아있지 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 이름을 변경할 필요성이 생겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 GPT에게 한번 물어봐야겠다는 생각이 들어 한번 시도해보았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 이름의 기준으로 생각한게 몇가지 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 한 단어일것. 두 단어 이상의 합성어는 왠지 모르게 멋이 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 발음이 쉬울 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;3. 도메인 확보를 위해 기존에 흔히 쓰지 않는 단어일 것.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;4. 프로젝트의 의미를 잘 담고 있을 것.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1776&quot; data-origin-height=&quot;1156&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/n7qKO/btsCxbkgCRk/KDJCJEW5cJjsz0MrtSM1aK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/n7qKO/btsCxbkgCRk/KDJCJEW5cJjsz0MrtSM1aK/img.png&quot; data-alt=&quot;일해라 노예야&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/n7qKO/btsCxbkgCRk/KDJCJEW5cJjsz0MrtSM1aK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fn7qKO%2FbtsCxbkgCRk%2FKDJCJEW5cJjsz0MrtSM1aK%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;1776&quot; height=&quot;1156&quot; data-origin-width=&quot;1776&quot; data-origin-height=&quot;1156&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;GPT를 열심히 굴려본 결과 마음에 드는 이름을 정할 수 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;도메인도 널널하고 집필과 관련되면서 동시에 발음도 쉬운 &lt;b&gt;Penly&lt;/b&gt;로 정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;요즘은 키보드를 쓴다지만 아무래도 집필이라는 이미지가 펜과 떨어질 수가 없는데다가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;집필 프로그램인 만큼 글쓰기에 친숙하고 쉽게 접근할 수 있다는 설명이 마음에 들었다  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;GPT가 새로운 단어를 생각보다 잘 만들어주는 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정말 AI시대가 성큼 다가왔다는 실감이 난다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;penly original - dell E.png&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bazzLw/btsCzs6mT3A/6aqsyKB6s95e4KjuMizfEK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bazzLw/btsCzs6mT3A/6aqsyKB6s95e4KjuMizfEK/img.png&quot; data-alt=&quot;DALL&amp;amp;amp;middot;E가 만들어준 Penly 로고&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bazzLw/btsCzs6mT3A/6aqsyKB6s95e4KjuMizfEK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbazzLw%2FbtsCzs6mT3A%2F6aqsyKB6s95e4KjuMizfEK%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;480&quot; height=&quot;480&quot; data-filename=&quot;penly original - dell E.png&quot; data-origin-width=&quot;1024&quot; data-origin-height=&quot;1024&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;DALL&amp;amp;middot;E가 만들어준 Penly 로고&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  기술 스택을 뭘로 쓸까?&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  언어를 골라보자&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기술 스택 고민을 시작하자마자 머릿속을 스쳐지나가는 것들이 많았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  Go언어를 한번 써볼까? 백엔드에 딱 좋다던데.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;아냐, 이번에 스프링부트가 새로 나왔다던데? 자바 최신 버전은 몇이지?&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;코틀린도&amp;nbsp;한번&amp;nbsp;배워볼까?&amp;nbsp;자바랑&amp;nbsp;100%호환이라고&amp;nbsp;들었는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;프론트엔드에 어차피 SPA를 쓸건데 이참에 백엔드도 자바스크립트 써볼까? 예전에 express 템플릿 만들어본적도 있는데.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;프론트엔드엔 Svelte를 써볼까? 한번쯤 써보고 싶었는데 이참에 한번 써봐?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;리액트도 프리액트 같은 재밌는거 많아 보이던데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt; &lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;프론트엔드 라이브러리가 너무 많아서 고민이네&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로는 &lt;b&gt;Vue3 + SpringBoot(Kotlin)&lt;/b&gt;로 결정했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기획하고 있는 기능들이 구현하기 꽤 복잡할 것 같은데 아예 모르는 기술로 넘어가서 지원되지 않는 라이브러리를 보며 이를 갈기보다 아는 기반에서 새로운 도전을 해보고 싶었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래도 예전부터 배워보고 싶었던 언어 중에 코틀린을 선정해서 새로 배워보기로 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코틀린을 배워두면 나중에는 안드로이드 쪽으로도 진출 할 수 있을 것 같아서 선택하게 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자바와의 호환성이 보장된다는 점도 엄청나게 매력적이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt; ️ 라이브러리도 빼놓을 수 없지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 목적이 집필 프로그램인 만큼 WYSIWYG 에디터도 고민을 안할 수 없었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;자칫 잘못된 라이브러리를 고른다면 개발 난이도가 수직상승하고 완성에 실패하는 꼬라지가 될 수도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 글쓰기에 QuillJS를 사용했었는데 2.0 버전이 꽤 오랫동안 베타에서 벗어나질 못하는 점, 기존에 새로운 기능을 개발해본 결과 의외로 확장하기 어렵게 구성되어 있어 생각보다 수월하지 않았다. 그래서 예전부터 새로운 라이브러리를 골라야겠다는 생각을 하고 있었다.&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;새로운 라이브러리로는 TipTap을 선정했는데 스타일이 없는 헤드리스 라이브러리라 기존 스타일과의 충돌을 생각하지 않아도 되고, 많은 플러그인들로 구성되어 확장성이 있는 점이 마음에 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에디터가 핵심적인 역할을 할거라 예상되는 만큼 확장성에 큰 기대를 걸고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;심지어 에디터 자체적으로 버전관리(유료지만), 동시편집(이것도 일부 유료다) 같은 기능도 지원하고 있어 더 마음에 든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;  웹폰트도 고민할게 많다&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옛날에는 아무생각 없이 나눔고딕을 쓰던 시절이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 경력이 쌓인 후에는 NotoSans나 스포카, 나눔스퀘어 같은 걸 골라서 사용했었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 나눔고딕처럼 무작정 고를만한 폰트는 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;노토산스는 한글과 영문의 크기 차이가 발생하는 경우가 종종 있었고, 나눔스웨어는 본문에 쓰기 적절한 모양은 아니였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 폰트와 관련된 아티클이 눈에 보일때마다 다 수집하고 있었는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;얼마전 재밌는 글을 발견했다.&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://github.com/orioncactus/pretendard&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/orioncactus/pretendard&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1703345339150&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - orioncactus/pretendard: 어느 플랫폼에서든 사용할 수 있는 system-ui 대체 글꼴 | A system-ui alternativ&quot; data-og-description=&quot;어느 플랫폼에서든 사용할 수 있는 system-ui 대체 글꼴 | A system-ui alternative font for all cross-platform - GitHub - orioncactus/pretendard: 어느 플랫폼에서든 사용할 수 있는 system-ui 대체 글꼴 | A system-ui alternative&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/orioncactus/pretendard&quot; data-og-url=&quot;https://github.com/orioncactus/pretendard&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/5YtOp/hyUPCRaU6R/bGJgUSoTb6VlObAA661zR0/img.png?width=1280&amp;amp;height=640&amp;amp;face=0_0_1280_640&quot;&gt;&lt;a href=&quot;https://github.com/orioncactus/pretendard&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/orioncactus/pretendard&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/5YtOp/hyUPCRaU6R/bGJgUSoTb6VlObAA661zR0/img.png?width=1280&amp;amp;height=640&amp;amp;face=0_0_1280_640');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - orioncactus/pretendard: 어느 플랫폼에서든 사용할 수 있는 system-ui 대체 글꼴 | A system-ui alternativ&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;어느 플랫폼에서든 사용할 수 있는 system-ui 대체 글꼴 | A system-ui alternative font for all cross-platform - GitHub - orioncactus/pretendard: 어느 플랫폼에서든 사용할 수 있는 system-ui 대체 글꼴 | A system-ui alternative&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/wanteddev/wanted-sans&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/wanteddev/wanted-sans&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1703345344464&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;GitHub - wanteddev/wanted-sans: 곧으면서도 유연한 산세리프 글꼴 | A Sans-serif font; Geometric with a heart, Human&quot; data-og-description=&quot;곧으면서도 유연한 산세리프 글꼴 | A Sans-serif font; Geometric with a heart, Humanist with a soul - GitHub - wanteddev/wanted-sans: 곧으면서도 유연한 산세리프 글꼴 | A Sans-serif font; Geometric with a heart, Humanist with a ...&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/wanteddev/wanted-sans&quot; data-og-url=&quot;https://github.com/wanteddev/wanted-sans&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/2T7Eg/hyUTC9MWcG/UHgqGpa2BAfFkc4Y3o1c20/img.png?width=1280&amp;amp;height=640&amp;amp;face=0_0_1280_640&quot;&gt;&lt;a href=&quot;https://github.com/wanteddev/wanted-sans&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/wanteddev/wanted-sans&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/2T7Eg/hyUTC9MWcG/UHgqGpa2BAfFkc4Y3o1c20/img.png?width=1280&amp;amp;height=640&amp;amp;face=0_0_1280_640');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;GitHub - wanteddev/wanted-sans: 곧으면서도 유연한 산세리프 글꼴 | A Sans-serif font; Geometric with a heart, Human&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;곧으면서도 유연한 산세리프 글꼴 | A Sans-serif font; Geometric with a heart, Humanist with a soul - GitHub - wanteddev/wanted-sans: 곧으면서도 유연한 산세리프 글꼴 | A Sans-serif font; Geometric with a heart, Humanist with a ...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프리텐다드와 원티드 산스인데 둘다 자유로운 라이센스와 한글이 지원되는 폰트라는 점에서 언젠가 사용해봐야겠다는 생각을 하고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘다 자체적으로 가변 굵기와 동적 서브셋을 지원하는 폰트라는 장점이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 개발팀이 한국인들이라 한글에 대한 지원이 빠방해서 정말 좋다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그런데 프리텐다드를 쓰기도 전에 원티드 산스를 발견해버려서  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 원티드 산스를 사용해보려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사실 이 블로그도 원티드 산스로 구성되어 있다  &lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  구현할 기능 목록&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이것저것 구상하다보니 살이 많이 붙었다.&lt;/p&gt;
&lt;pre id=&quot;code_1703342628379&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;- 로그인은 sns로
    - 자체 로그인 기능 개발 X
- 다국어
- 다크모드, 라이트모드
- 화면 스플릿
    - 무제한. 단, 최소 사이즈 지정하여 자연스럽게 제한
- 사전, 맞춤법 검사기
- 원고지 기준 장수 계산기, 글자수 계산기
- 이지윅 에디터
    - 시놉시스 메모
    - 히스토리. 파일 비교
    - 외부 공유 기능
        - export &amp;rarr; 한글, pdf, 워드 등
    - 실시간 동시 편집 기능
- 완벽한 오프라인 어플리케이션?
    - 서비스워커로 static 파일 캐시처리
    - indexedDB로 로컬 저장 기능&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여기에 작성한 기능들만 구현한다고 프로젝트가 끝나는 건 아니고 작업하다보면 떠오르는 영감에 따라서 더 추가될 수도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;최종적으로는 완벽한 웹 기반 집필 프로그램을 만들어보고 싶다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;  그 외에 이것저것&lt;/h2&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어느정도 완성도가 생기면 도메인을 구매해서 외부배포를 해볼까 하는데 이 프로젝트를 오픈소스로 오픈할지에 대한 고민도 하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;개인적으로는 오픈소스 프로젝트를 해보고 싶다는 생각을 하고 있는데 이걸 공개한다고 어떤 장점이 있을지를 잘 모르겠다. 오히려 서비스 코드가 노출되어 보안적으로 문제만 되는게 아닐까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무래도 라이브러리 개념의 프로젝트가 아니다보니 오픈소스 생태계에 기여하는 부분은 많지 않을 것 같다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;오픈소스 프로젝트를 해보고 싶다면 다른 프로젝트를 진행해봐야 할 것 같기도 하다.&lt;/p&gt;</description>
      <category>프로젝트/Penly</category>
      <category>개발</category>
      <category>개인프로젝트</category>
      <category>글쓰기</category>
      <category>소설</category>
      <category>에디터</category>
      <category>웹</category>
      <category>웹에디터</category>
      <category>집필</category>
      <category>클라우드</category>
      <author>HuiYa</author>
      <guid isPermaLink="true">https://huiya-dev.tistory.com/20</guid>
      <comments>https://huiya-dev.tistory.com/20#entry20comment</comments>
      <pubDate>Sun, 24 Dec 2023 00:06:58 +0900</pubDate>
    </item>
    <item>
      <title>[Kotlin/SpringBoot] jjwt 0.12.x으로 JWT 토큰 발행하기</title>
      <link>https://huiya-dev.tistory.com/19</link>
      <description>&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;시작하며&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이번에 새롭게 사이드프로젝트를 진행하면서 백엔드 언어로 코틀린을 선정했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;자바랑 여러가지 차이가 있고 뭐가 장점이고 하는 의사결정 과정이 중요한건 아니고,&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;중요한건 코틀린으로 언어를 바꾸면서 기존에 만들었던 코드를 다시 짜야 한다는 것이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;JWT 발행이나 인증, Intercepter 같은 기능들이 만들어놓은지 몇년 됐으니 이참에 새롭게 갈아엎자고 생각했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기존에 &lt;a href=&quot;https://blog.huiya.me/16&quot;&gt;[Spring Boot] 인터셉터 인증 처리 제외 어노테이션 만들기&lt;/a&gt; 이런 글을 올린 적이 있었는데 주석에 기재된 날짜를 보니 벌써 3년 전이다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;아까워 하지 말고 전부 버리고 새로 만들기로 했다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;기존에는 JWT 발행 라이브러리로 &lt;code&gt;com.auth0/java-jwt&lt;/code&gt;를 사용했었는데 코틀린으로 언어를 변경하면서&amp;nbsp; &lt;code&gt;io.jsonwebtoken:jjwt-api:0.12.3&lt;/code&gt;로 라이브러리를 변경했다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;다만 jjwt-api가 0.12.x로 버전을 올리면서 대부분의 메소드가 deprecated 되었다고 한다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;gpt도 그렇고 jetbrain ai도 (둘이 같은 놈이지만..) 구글에 나오는 수많은 블로그 글들도 대부분 이전 버전을 사용하고 있어 deprecated되지 않은 메소드는 어떻게 쓰는데! 하고 골머리를 앓았다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;혹시나 나처럼 고민하는 사람이 있을까 기록을 남긴다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;환경은 아래와 같다&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;- Java 17&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;- Spring Boot 3.2.0&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;- jjwt 0.12.3&lt;/span&gt;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;디펜던시 추가&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1701652375071&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;dependencies {
    ...
    // https://mvnrepository.com/artifact/io.jsonwebtoken/jjwt-api
    implementation(&quot;io.jsonwebtoken:jjwt-api:0.12.3&quot;)
    runtimeOnly(&quot;io.jsonwebtoken:jjwt-impl:0.12.3&quot;)
    runtimeOnly(&quot;io.jsonwebtoken:jjwt-jackson:0.12.3&quot;)
    ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;이 글을 쓰는 시점에는 0.12.3이 최신인데 위 mvnrepository 링크에서 최신 버전을 확인하고 넣어야 한다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR';&quot;&gt;TokenProvidor 생성&lt;/span&gt;&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1701673753946&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package me.huiya.core.Security

import org.springframework.stereotype.Service

@Service
class TokenProvider() {
    
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;Security&lt;/code&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;RS512 키를 생성하자&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JWT Signature를 만들기 위해서는 여러 방법이 있는데 그중에서 비대칭 암호화인 RSA를 사용하는 RS512를 선택했다. 요즘은 ES512나 뭐 이것저것 있다고 하는데 RSA 익숙하니까&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333; text-align: start;&quot;&gt;&amp;hellip;&lt;/span&gt; &lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1701674249304&quot; class=&quot;java&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;java&quot;&gt;&lt;code&gt;KeyPair keyPair = Jwts.SIG.RS256.keyPair().build(); //or RS384, RS512, PS256, etc...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333; text-align: start;&quot;&gt;jjwt 공식 문서에 따르면 위처럼 &lt;a href=&quot;https://github.com/jwtk/jjwt#asymmetric-keys&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;비대칭 키 쌍을 만들어주는 함수&lt;/a&gt;도 있다고 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;font-family: 'Noto Sans Demilight', 'Noto Sans KR'; color: #333333; text-align: start;&quot;&gt;다만 나는 하나의 사이트에서 동일한 키를 사용하고 서버 재실행마다 키가 바뀌길 원치 않았기 때문에 키 파일을 작성하는 쪽으로 진행했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;손으로 만들기는 귀찮기 때문에 &lt;span style=&quot;background-color: #ffffff; color: #1f2328; text-align: start;&quot;&gt;gen-rs512.sh&lt;/span&gt; 파일을 작성했다.&lt;/p&gt;
&lt;pre id=&quot;code_1701674344117&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#!/bin/bash
echo -e &quot;# Don't add passphrase&quot;
ssh-keygen -t rsa -b 4096 -m PEM -E SHA512 -f jwtRS512.key -N &quot;&quot;
# Don't add passphrase
openssl rsa -in jwtRS512.key -pubout -outform PEM -out jwtRS512.key.pub
openssl pkcs8 -topk8 -inform pem -in jwtRS512.key -outform pem -nocrypt -out jwtRS512_pkcs8.key

cat jwtRS512.key
cat jwtRS512.key.pub
cat jwtRS512_pkcs8.key&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;파일을 실행하면 3가지 결과물이 나오게 되는데 pub 파일은 공개키, 나머지 두개는 비밀키 파일이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그 중 jwtRS512.key 파일은 PKCS#1 방식으로 jjwt에서 사용할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;jjwt에서는 PKCS#8 방식을 요구하므로 jwtRS512_pkcs8.key 파일을 쓰자.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;설정 파일에 키 경로 / JWT 설정 추가&lt;/b&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;application.properties&lt;/code&gt; 파일에 아래 내용을 추가했다.&lt;/p&gt;
&lt;pre id=&quot;code_1701674725822&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;# JWT 설정
core.jwt.secret-key-path=secrets/jwtRS512_pkcs8.key # 비밀키 경로
core.jwt.public-key-path=secrets/jwtRS512.key.pub # 공개키 경로
core.jwt.expiration-hours=3 # 만료시간 3시간으로 설정
core.jwt.issuer=huiya # 토큰 발행자&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실제 키 파일의 경로는&amp;nbsp;&lt;code&gt;src/main/resources/secrets/&lt;/code&gt;이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;resources 밑에 위치해 있어야 파일을 읽어올 때 수월하게 읽을 수 있다. 그 외에는 어디에 놓든 상관 없다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;설정 값 읽어오기&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1701675182069&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;...
import org.springframework.beans.factory.annotation.Value

@Service
class TokenProvider(
    @Value(&quot;\${core.jwt.secret-key-path}&quot;)
    private val privateKeyPath: String,
    @Value(&quot;\${core.jwt.public-key-path}&quot;)
    private val publicKeyPath: String,
    @Value(&quot;\${core.jwt.expiration-hours}&quot;)
    private val expirationHours: Long,
    @Value(&quot;\${core.jwt.issuer}&quot;)
    private val issuer: String
) {
    ...&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;코틀린에서 설정 파일 읽어오는 건 어렵지 않다. 자바때보다 쉬워진 것 같다.&lt;/p&gt;
&lt;h3 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;파일에서 내용물 읽어오고 키로 변환하자&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1701675351154&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;...
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.security.KeyFactory
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec

class TokenProvider(
    ...
) {
    private lateinit var secretKey: RSAPrivateKey;
    private lateinit var publicKey: RSAPublicKey;

    init {
        val rsaPrivateKey: RSAPrivateKey? = readPrivateKeyFromResource(privateKeyPath)
        if(rsaPrivateKey != null) {
            secretKey = rsaPrivateKey
        }

        val rsaPublicKey: RSAPublicKey? = readPublicKeyFromResource(publicKeyPath)
        if(rsaPublicKey != null) {
            publicKey = rsaPublicKey
        }
    }

    private fun readPrivateKeyFromResource(path: String): RSAPrivateKey? {
        val keyFactory = KeyFactory.getInstance(&quot;RSA&quot;)
        val keyStream = javaClass.classLoader.getResourceAsStream(path)

        keyStream?.use {
            val reader = BufferedReader(InputStreamReader(it))
            val lines = reader.readLines()

            if (lines.isEmpty()) {
//                println(&quot;The file is empty or could not be read&quot;)
                return null
            }

            val keyString = lines.drop(1).dropLast(1).joinToString(&quot;&quot;)
            val privateKeyBytes = Base64.getDecoder().decode(keyString)
            val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
            return keyFactory.generatePrivate(keySpec) as RSAPrivateKey
        }

//        println(&quot;The file could not be found&quot;)
        return null
    }

    private fun readPublicKeyFromResource(path: String): RSAPublicKey? {
        val keyFactory = KeyFactory.getInstance(&quot;RSA&quot;)
        val keyStream = javaClass.classLoader.getResourceAsStream(path)

        keyStream?.use {
            val reader = BufferedReader(InputStreamReader(it))
            val lines = reader.readLines()

            if (lines.isEmpty()) {
                return null
            }

            val keyString = lines.drop(1).dropLast(1).joinToString(&quot;&quot;)
            val publicKeyBytes = Base64.getDecoder().decode(keyString)
            val keySpec = X509EncodedKeySpec(publicKeyBytes)
            return keyFactory.generatePublic(keySpec) as RSAPublicKey
        }

        return null
    }

...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각각 경로를 입력받아 RSAPrivateKey와 RSAPublicKey로 변환해주는 두 메소드를 만들었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 메소드를 init 구문에서 실행하면 클래스가 생성될때 자동으로 실행된다.&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;또 @Service 어노테이션을 통해 빈으로 등록되어 한번만 생성되는게 보장되기 때문에 서버 초기 실행시 한번만 읽어오는 코드가 되었다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;이제 진짜 토큰 생성하기&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1701675654862&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;...
import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jws
import io.jsonwebtoken.Jwt
import io.jsonwebtoken.JwtBuilder
import io.jsonwebtoken.JwtException
import io.jsonwebtoken.Jwts
import java.sql.Timestamp
import java.time.Instant
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.Base64
import java.util.Date

@Service
class TokenProvider(
    ...
    @Value(&quot;\${core.jwt.expiration-hours}&quot;)
    private val expirationHours: Long,
    @Value(&quot;\${core.jwt.issuer}&quot;)
    private val issuer: String
) {
    ...
    
    /**
     * 주어진 연산을 사용하여 지정된 클레임들로 JWT 토큰을 생성합니다.
     *
     * @param operation `JwtBuilder` 객체를 매개변수로 받고, 토큰에 추가 클레임들을 설정하는 람다 함수입니다.
     * @return 생성된 JWT 토큰을 문자열 형태로 반환합니다.
     */
    fun createToken(operation: (JwtBuilder) -&amp;gt; Unit): String {
        val jwt: JwtBuilder = Jwts.builder()
            // JWT header
            .header()
            .type(&quot;JWT&quot;) // type 선언
            .and()
            
            // pay load
            .issuer(issuer)
            .issuedAt(Timestamp.valueOf(LocalDateTime.now()))
            .expiration(Date.from(Instant.now().plus(expirationHours, ChronoUnit.HOURS)))
        
        operation(jwt)
        // 람다식 통해서 커스텀 claim 입력하도록 열어주기

        return jwt
            .signWith(secretKey)
            .compact()
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;설정파일에 미리 등록해놨던 issuer와 expiration-hours을 활용해서 토큰을 생성했다.&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;createToken에서 람다식을 통해서 커스텀 claim을 입력할 수 있게 열어놓았는데, 아래 예시처럼 사용하면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1701675891785&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;var token = tokenProvider.createToken { jwtBuilder -&amp;gt;
    jwtBuilder.claim(&quot;test&quot;, &quot;123&quot;)
}
// token: 
// eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzUxMiJ9.eyJpc3MiOiJ3ZWF2ZXIiLCJpYXQiOjE3MDE2MTY5NzgsImV4cCI6MTcwMTYyNzc3OCwidGVzdCI6IjEyMyJ9.fvBkuqrtMK5qLzo9w9235Ynvck40XLt7MiM-GBHjftkzp0M-Mzl6MS96zQjxXnVEM4VtQnyAaXVGJrP2WLKe_cu0jvFCstljAsgelgViCuiLQxh_8mqRBIIP63eOYYcrP_YfylGC9M0yjZ6Q4d4GQSWwG7I_3wP7wD3DLzvYc3TCTZHvzULHI6VYInURqgRnOfKYR_6lY2KGZZZv2vqEW5Lau52OS2SBeE61SahECGeps8hxGeQ2a0i669jbbKK1OAQpTaTpi5C-_whNpVNsd63aP_-1aRdCOrnCu_bXgSbSL5Oq-V19VW0ZYNbAe5F6AYPXSeewcv-sLSVnOpm09s7DmWglydMyS7YkwJX3BoO01ZZ3jDS_hZ_GcbCnGUZ8g8_42H8BVQYOu93ZcZ4kVinAoS7UjOHL65D99FHGyHK31RAYUmbxjmC8HLlH3vIdYemI43ELtwVzEx5g8cg1cxhtp6vg1zygSt-tngXbr2OIniaYB_5yfUHMkxSn3ApvfBx73kUTmrkyKS5hGzfYAFFiDlrHjCdudCKOixDlPdwMOQq-qBJI51D5LZFLcNtONgnKqDgE9pRLFfoZ1Gmsu8nbAHW69zDiem3PbihvZhSlR1i9kOgF0anha5I6NDXDNXWgoMAKSuXtZwz31PmQwVuBBJioTywCco2pQZGP5IE&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 TokenProvidor와 실제 필요한 값을 넣는 구현부를 분리해서 전체적인 결합도를 낮추는데 도움이 된다.&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;다음에 새로운 프로젝트를 시작한다 하더라도 issuer나 만료시간은 공통적으로 들어가는 요소기 때문에 TokenProvidor를 재활용 할 수 있다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;b&gt;토큰 읽어오기&lt;/b&gt;&lt;/h3&gt;
&lt;pre id=&quot;code_1701676012258&quot; class=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;...

@Service
class TokenProvider(
    ...
) {
    ...
    
    /**
     * JWT 토큰을 디코딩하고 그 안에 포함된 클레임을 반환합니다. 기본 클레임은 반환되지 않음에 주의!
     *
     * @param token JWT Token String.
     * @return 디코딩된 JWT claims
     * @throws io.jsonwebtoken.security.SignatureException JWT 키 오류
     * @throws io.jsonwebtoken.IncorrectClaimException iss 등 claim이 정확하지 않은 오류
     * @throws io.jsonwebtoken.ExpiredJwtException 만료된 토큰 오류
     */
    fun decodeToken(token: String): Map&amp;lt;String, Any&amp;gt; {

        val jws: Jws&amp;lt;Claims&amp;gt; = Jwts.parser()
            .verifyWith(publicKey)
            .requireIssuer(issuer)
            .clockSkewSeconds(1 * 60) // 만료시간 오차범위 1min
            .build()
            .parseSignedClaims(token)

        val claims: Claims = jws.payload

        // 기본 claim은 제거하고 custom claims만 리턴하도록 구성
        val standardClaims = listOf(&quot;iss&quot;, &quot;iat&quot;, &quot;exp&quot;, &quot;sub&quot;, &quot;aud&quot;, &quot;nbf&quot;, &quot;jti&quot;)
        val result: MutableMap&amp;lt;String, Any&amp;gt; = mutableMapOf()
        for ((key, value) in claims) {
            if (key !in standardClaims) {
                result[key] = value
            }
        }

        return result
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;tokenString을 입력하면 미리 준비된 RSAPublicKey로 복호화하고 claim을 리턴해주는데, 표준 claim은 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;일반적인&lt;span&gt; &lt;/span&gt;&lt;/span&gt;개발시 사용하지 않을 것 같아 제거했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1701676558828&quot; class=&quot;kotlin&quot; style=&quot;background-color: #f8f8f8; color: #383a42; text-align: start;&quot; data-ke-type=&quot;codeblock&quot; data-ke-language=&quot;kotlin&quot;&gt;&lt;code&gt;val _re: Map&amp;lt;String, Any&amp;gt; = tokenProvider.decodeToken(token)

println(_re)
// {test=123}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;리턴값이 Map 구조이기 때문에 사용하는 쪽에서 jjwt를 import 할 필요도 없고 jjwt에 대해 아무것도 몰라도 된다.&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;TokenProvidor의 최종 결과물은 아래에 남겨놓았다.&amp;nbsp;&lt;/p&gt;
&lt;div data-ke-type=&quot;moreLess&quot; data-text-more=&quot;더보기&quot; data-text-less=&quot;닫기&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;&lt;a class=&quot;btn-toggle-moreless&quot;&gt;더보기&lt;/a&gt;
&lt;div class=&quot;moreless-content&quot;&gt;
&lt;pre id=&quot;code_1701677062610&quot; class=&quot;kotlin&quot; data-ke-language=&quot;kotlin&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package me.huiya.weaver.Security

import io.jsonwebtoken.Claims
import io.jsonwebtoken.Jws
import io.jsonwebtoken.Jwt
import io.jsonwebtoken.JwtBuilder
import io.jsonwebtoken.JwtException
import io.jsonwebtoken.Jwts
import org.springframework.beans.factory.annotation.Value
import org.springframework.stereotype.Service
import java.io.BufferedReader
import java.io.File
import java.io.InputStreamReader
import java.security.KeyFactory
import java.security.interfaces.RSAPrivateKey
import java.security.interfaces.RSAPublicKey
import java.security.spec.PKCS8EncodedKeySpec
import java.security.spec.X509EncodedKeySpec
import java.sql.Timestamp
import java.time.Instant
import java.time.LocalDateTime
import java.time.temporal.ChronoUnit
import java.util.Base64
import java.util.Date

@Service
class TokenProvider(
    @Value(&quot;\${core.jwt.secret-key-path}&quot;)
    private val privateKeyPath: String,
    @Value(&quot;\${core.jwt.public-key-path}&quot;)
    private val publicKeyPath: String,
    @Value(&quot;\${core.jwt.expiration-hours}&quot;)
    private val expirationHours: Long,
    @Value(&quot;\${core.jwt.refresh-expiration-hours}&quot;)
    private val refreshExpirationHours: Long,
    @Value(&quot;\${core.jwt.issuer}&quot;)
    private val issuer: String
) {
    private lateinit var secretKey: RSAPrivateKey;
    private lateinit var publicKey: RSAPublicKey;

    init {
        val rsaPrivateKey: RSAPrivateKey? = readPrivateKeyFromResource(privateKeyPath)
        if(rsaPrivateKey != null) {
            secretKey = rsaPrivateKey
        }

        val rsaPublicKey: RSAPublicKey? = readPublicKeyFromResource(publicKeyPath)
        if(rsaPublicKey != null) {
            publicKey = rsaPublicKey
        }
    }

    private fun readPrivateKeyFromResource(path: String): RSAPrivateKey? {
        val keyFactory = KeyFactory.getInstance(&quot;RSA&quot;)
        val keyStream = javaClass.classLoader.getResourceAsStream(path)

        keyStream?.use {
            val reader = BufferedReader(InputStreamReader(it))
            val lines = reader.readLines()

            if (lines.isEmpty()) {
//                println(&quot;The file is empty or could not be read&quot;)
                return null
            }

            val keyString = lines.drop(1).dropLast(1).joinToString(&quot;&quot;)
            val privateKeyBytes = Base64.getDecoder().decode(keyString)
            val keySpec = PKCS8EncodedKeySpec(privateKeyBytes)
            return keyFactory.generatePrivate(keySpec) as RSAPrivateKey
        }

//        println(&quot;The file could not be found&quot;)
        return null
    }

    private fun readPublicKeyFromResource(path: String): RSAPublicKey? {
        val keyFactory = KeyFactory.getInstance(&quot;RSA&quot;)
        val keyStream = javaClass.classLoader.getResourceAsStream(path)

        keyStream?.use {
            val reader = BufferedReader(InputStreamReader(it))
            val lines = reader.readLines()

            if (lines.isEmpty()) {
                return null
            }

            val keyString = lines.drop(1).dropLast(1).joinToString(&quot;&quot;)
            val publicKeyBytes = Base64.getDecoder().decode(keyString)
            val keySpec = X509EncodedKeySpec(publicKeyBytes)
            return keyFactory.generatePublic(keySpec) as RSAPublicKey
        }

        return null
    }

    /**
     * 주어진 연산을 사용하여 지정된 클레임들로 JWT 토큰을 생성합니다.
     *
     * @param operation `JwtBuilder` 객체를 매개변수로 받고, 토큰에 추가 클레임들을 설정하는 람다 함수입니다.
     *
     * @return 생성된 JWT 토큰을 문자열 형태로 반환합니다.
     */
    fun createToken(operation: (JwtBuilder) -&amp;gt; Unit): String {
        
        val jwt: JwtBuilder = Jwts.builder()
            // JWT header
            .header()
            .type(&quot;JWT&quot;)
            .and()
            
            // pay load
            .issuer(issuer)
            .issuedAt(Timestamp.valueOf(LocalDateTime.now()))
            .expiration(Date.from(Instant.now().plus(expirationHours, ChronoUnit.HOURS)))
        
        // TODO: refresh token 구현 바람

        operation(jwt)
        // 람다식 통해서 커스텀 claim 입력하도록 열어주기

        return jwt
            .signWith(secretKey)
            .compact()
    }

    /**
     * JWT 토큰을 디코딩하고 그 안에 포함된 클레임을 반환합니다. 기본 클레임은 반환되지 않음에 주의!
     *
     * @param token JWT Token String.
     * @return 디코딩된 JWT claims
     * @throws io.jsonwebtoken.security.SignatureException JWT 키 오류
     * @throws io.jsonwebtoken.IncorrectClaimException iss 등 claim이 정확하지 않은 오류
     * @throws io.jsonwebtoken.ExpiredJwtException 만료된 토큰 오류
     */
    fun decodeToken(token: String): Map&amp;lt;String, Any&amp;gt; {

        val jws: Jws&amp;lt;Claims&amp;gt; = Jwts.parser()
            .verifyWith(publicKey)
            .requireIssuer(issuer)
            .clockSkewSeconds(1 * 60) // 만료시간 오차범위 1min
            .build()
            .parseSignedClaims(token)

        val claims: Claims = jws.payload

        // 기본 claim은 제거하고 custom claims만 리턴하도록 구성
        val standardClaims = listOf(&quot;iss&quot;, &quot;iat&quot;, &quot;exp&quot;, &quot;sub&quot;, &quot;aud&quot;, &quot;nbf&quot;, &quot;jti&quot;)
        val result: MutableMap&amp;lt;String, Any&amp;gt; = mutableMapOf()
        for ((key, value) in claims) {
            if (key !in standardClaims) {
                result[key] = value
            }
        }

        return result
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;/div&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서 소개한건 jjwt 라이브러리를 사용해서 JWT를 구현한 것 뿐이다.&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;h4 style=&quot;margin-bottom: 0px;&quot; data-ke-size=&quot;size20&quot;&gt;참고한 글&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://colabear754.tistory.com/171#JWT_의존성_라이브러리_추가&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://colabear754.tistory.com/171#JWT_의존성_라이브러리_추가&lt;/a&gt;&lt;/p&gt;</description>
      <category>개발/Backend</category>
      <category>0.12.3</category>
      <category>jjwt</category>
      <category>JWs</category>
      <category>JWT</category>
      <category>Kotlin</category>
      <category>Login</category>
      <category>spring</category>
      <category>SpringBoot</category>
      <author>HuiYa</author>
      <guid isPermaLink="true">https://huiya-dev.tistory.com/19</guid>
      <comments>https://huiya-dev.tistory.com/19#entry19comment</comments>
      <pubDate>Mon, 4 Dec 2023 18:03:21 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Boot] Log4J2 shell 취약점 대응하기</title>
      <link>https://huiya-dev.tistory.com/18</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 보는 분들이라면 다들 알겠지만 Log4J2 라이브러리에 취약점이 보고되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;참고: &lt;a href=&quot;https://nvd.nist.gov/vuln/detail/CVE-2021-44228&quot;&gt;CVE-2021-44228&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;스프링부트는 기본적으로 기본 로그 시스템을 Log4J2로 변경했을때만 해당이 된다고 한다.&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;취약점에 대한 뉴스를 접하자마자 github의 pom.xml부터 확인했었고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당사항이 없는 걸 보고 안심한채 주말내내 불구경하는 마음으로 구경했었는데...&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출근 후 확인해보니 디펜던시 걸린 다른 라이브러리에서 Log4J2를 사용하고 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1639358948039&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;./mvnw dependency:list | select-string -pattern log4j&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;925&quot; data-origin-height=&quot;161&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bf7qdb/btrnGjcfKH2/XPwQeByKQDEHWwYLjpp8ZK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bf7qdb/btrnGjcfKH2/XPwQeByKQDEHWwYLjpp8ZK/img.png&quot; data-alt=&quot;선생님 왜 거기 계세요&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bf7qdb/btrnGjcfKH2/XPwQeByKQDEHWwYLjpp8ZK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbf7qdb%2FbtrnGjcfKH2%2FXPwQeByKQDEHWwYLjpp8ZK%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;925&quot; height=&quot;161&quot; data-origin-width=&quot;925&quot; data-origin-height=&quot;161&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;선생님 왜 거기 계세요&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;797&quot; data-origin-height=&quot;898&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OUnvc/btrnL88FXeO/VIrKeIpRFTir17KJ1sCJC0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OUnvc/btrnL88FXeO/VIrKeIpRFTir17KJ1sCJC0/img.jpg&quot; data-alt=&quot;이것만큼 내 심정을 잘 표현하는 짤이 없다&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OUnvc/btrnL88FXeO/VIrKeIpRFTir17KJ1sCJC0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOUnvc%2FbtrnL88FXeO%2FVIrKeIpRFTir17KJ1sCJC0%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;400&quot; height=&quot;451&quot; data-origin-width=&quot;797&quot; data-origin-height=&quot;898&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;강제로 버전을 올리는 방법을 알아보자&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;1. Maven 사용시&lt;/h3&gt;
&lt;pre id=&quot;code_1639357060151&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;properties&amp;gt;
    &amp;lt;log4j2.version&amp;gt;2.15.0&amp;lt;/log4j2.version&amp;gt;
&amp;lt;/properties&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pom.xml의 properties를 설정하여 강제로 버전을 올려주면 된다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;2. Gradle 사용시&lt;/h3&gt;
&lt;pre id=&quot;code_1639357307549&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ext['log4j2.version'] = '2.15.0'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대부분은 log4j2.version을 설정하는 것으로 충분하다고 한다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;3. 버전 업데이트가 불가능할 경우&lt;/h3&gt;
&lt;pre id=&quot;code_1639357748273&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt; log4j2.formatMsgNoLookups=true&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;log4j2.properties에 위와 같은 옵션을 추가해도 된다고 한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1639357799278&quot; class=&quot;shell&quot; data-ke-language=&quot;shell&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;java -Dlog4j2.formatMsgNoLookups=true -jar myapp.jar&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #ffffff;&quot;&gt;혹은 프로젝트 실행시 위와 같은 옵션으로 실행하면 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;결과&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;933&quot; data-origin-height=&quot;163&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SkULs/btrnHRGw2pY/XCrCf0hi7ELv4eodPPse2K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SkULs/btrnHRGw2pY/XCrCf0hi7ELv4eodPPse2K/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SkULs/btrnHRGw2pY/XCrCf0hi7ELv4eodPPse2K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSkULs%2FbtrnHRGw2pY%2FXCrCf0hi7ELv4eodPPse2K%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;933&quot; height=&quot;163&quot; data-origin-width=&quot;933&quot; data-origin-height=&quot;163&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;빌드 후 확인해보니 Log4J2가 정상적으로 업데이트 된 모습을 볼 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출처&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://spring.io/blog/2021/12/10/log4j2-vulnerability-and-spring-boot&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://spring.io/blog/2021/12/10/log4j2-vulnerability-and-spring-boot&lt;/a&gt;&lt;/p&gt;</description>
      <category>개발/Backend</category>
      <category>CVE-2021-44228</category>
      <category>log4j</category>
      <category>spring</category>
      <category>SpringBoot</category>
      <category>대응</category>
      <category>업데이트</category>
      <category>취약점</category>
      <author>HuiYa</author>
      <guid isPermaLink="true">https://huiya-dev.tistory.com/18</guid>
      <comments>https://huiya-dev.tistory.com/18#entry18comment</comments>
      <pubDate>Mon, 13 Dec 2021 10:13:42 +0900</pubDate>
    </item>
    <item>
      <title>크롬 개발자도구 네트워크에서 확장 프로그램 요청 감추기</title>
      <link>https://huiya-dev.tistory.com/17</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;크롬을 통해 개발을 하다보면 자연스럽게 개발자 도구의 network 탭을 볼 일이 자주 생기는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용중인 여러가지 확장 프로그램들 요청이 함께 표시된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;763&quot; data-origin-height=&quot;679&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/WAikW/btrlfeDRjAm/Re4dFzJh9ftbKxrjdbeUW0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/WAikW/btrlfeDRjAm/Re4dFzJh9ftbKxrjdbeUW0/img.png&quot; data-alt=&quot;확장 프로그램에서 요청한 xhr이 섞여있는 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/WAikW/btrlfeDRjAm/Re4dFzJh9ftbKxrjdbeUW0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FWAikW%2FbtrlfeDRjAm%2FRe4dFzJh9ftbKxrjdbeUW0%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;534&quot; data-origin-width=&quot;763&quot; data-origin-height=&quot;679&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;확장 프로그램에서 요청한 xhr이 섞여있는 모습&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;한 두개면 모를까 실제 요청한 xhr 개수보다 더 많은 요청이 쌓인다. 사용하는 확장 프로그램 개수가 많아서 그런 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무튼 개발할 때 거슬리니 필터에 아래 조건을 걸어두도록 하자&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;code&gt;-scheme:chrome-extension&lt;/code&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;762&quot; data-origin-height=&quot;440&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dTu67Y/btrk6k6TbBw/7CdEauf9Sb4nmSq2JBH4kk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dTu67Y/btrk6k6TbBw/7CdEauf9Sb4nmSq2JBH4kk/img.png&quot; data-alt=&quot;확장 프로그램 xhr이 숨겨진 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dTu67Y/btrk6k6TbBw/7CdEauf9Sb4nmSq2JBH4kk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdTu67Y%2Fbtrk6k6TbBw%2F7CdEauf9Sb4nmSq2JBH4kk%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;289&quot; data-origin-width=&quot;762&quot; data-origin-height=&quot;440&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;확장 프로그램 xhr이 숨겨진 모습&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;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출처&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://stackoverflow.com/questions/16436369/is-it-possible-to-hide-extension-resources-in-the-chrome-web-inspector-network-t&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://stackoverflow.com/questions/16436369/is-it-possible-to-hide-extension-resources-in-the-chrome-web-inspector-network-t&lt;/a&gt;&lt;/p&gt;</description>
      <category>개발/Network</category>
      <category>devtool</category>
      <category>extension</category>
      <category>network</category>
      <category>개발자도구</category>
      <category>크롬</category>
      <category>확장프로그램</category>
      <author>HuiYa</author>
      <guid isPermaLink="true">https://huiya-dev.tistory.com/17</guid>
      <comments>https://huiya-dev.tistory.com/17#entry17comment</comments>
      <pubDate>Tue, 16 Nov 2021 14:58:20 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Boot] 인터셉터 인증 처리 제외 어노테이션 만들기</title>
      <link>https://huiya-dev.tistory.com/16</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;개발을 하다보면 API 호출시 사용자의 인증정보를 체크할 필요성이 생긴다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 모든 API에 인증 체크 코드를 넣어두기엔 유지보수도 힘들고 같은 코드가 모든 메소드에 반복되게 되니&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통은 요청이 넘어오기 전에 Interceptor를 둬서 인증 체크하고 실제 메소드로 넘겨주는 방법을 사용하게 된다.&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;h4 data-ke-size=&quot;size20&quot;&gt;JwtAuthInterceptor.java&lt;/h4&gt;
&lt;pre id=&quot;code_1626325768194&quot; class=&quot;java&quot; style=&quot;margin: 20px auto 0px; display: block; overflow: auto; padding: 20px; color: #383a42; background: #f8f8f8; font-size: 14px; font-family: 'SF Mono', Menlo, Consolas, Monaco, monospace; border: 1px solid #ebebeb; line-height: 1.71; cursor: default; z-index: 1;&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package me.huiya.core.Config;

import me.huiya.core.Common.JWTManager;
import me.huiya.Exception.AuthRequiredException;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class JwtAuthInterceptor implements HandlerInterceptor {

    private static final String HEADER_TOKEN_KEY = &quot;Bearer &quot;;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        if(request.getMethod().equals(&quot;OPTIONS&quot;)) {
            return true;
        }

        String token = request.getHeader(&quot;Authorization&quot;);
        if(token == null) {
            throw new AuthRequiredException(&quot;Required token&quot;);
        }
        token = token.replace(HEADER_TOKEN_KEY, &quot;&quot;);
        JWTManager.verify(token); // 실제 체크하는 메소드

        return true;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;WebConfig.java&lt;/h4&gt;
&lt;pre id=&quot;code_1626325623917&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package me.huiya.core.Config;

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping(&quot;/**&quot;)
                .allowedOrigins(&quot;*&quot;)
                .allowedMethods(&quot;*&quot;)
                .maxAge(3000);
    }

    private static final String[] INTERCEPTOR_WHITE_LIST = {
        &quot;/error&quot;,
        // /error 를 화이트리스트로 등록하지 않으면 서버 에러시 /error 호출하면서 인터셉터가 다시 실행된다
        // 2020-12-25 18:28 huiya
        &quot;/&quot;, &quot;/status&quot;,
        &quot;/images/*&quot;,
        &quot;/account/signup-check/email&quot;,
        &quot;/account/signup&quot;,
        &quot;/auth/signin&quot;,
        &quot;/auth/refresh&quot;,
    };

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(new JwtAuthInterceptor())
                .addPathPatterns(&quot;/**&quot;)
                .excludePathPatterns(INTERCEPTOR_WHITE_LIST);
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JwtAuthInterceptor에 인터셉터를 만들고, WebConfig에서 인터셉터를 모든 요청에 넣어준다.&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;하지만 인증 체크를 넣지 말아야 할 API가 있을 수도 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;예를 들어 회원가입 API나 이미지를 요청하는 API에서는 인증 체크를 해선 안된다.&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;그래서 WebConfig에서 INTERCEPTOR_WHITE_LIST 목록을 통해 인터셉터를 제외할 API를 지정해놓고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;API를 추가할때마다 저 화이트리스트 목록에 API를 추가하는 작업은 생각보다 귀찮을 수 밖에 없다.&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;만약 각 메소드를 개발할때 annotation을 통해 인터셉터를 적용할지 안할지 여부를 결정할 수 있다면 좀 더 편하게 개발을 진행할 수 있을 것이다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;1. annotation 추가 (WithOutAuth.java)&lt;/h4&gt;
&lt;pre id=&quot;code_1626327972444&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package me.huiya.core.Config;

import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface WithOutAuth {
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. Interceptor에서 annotation 체크하기 (JwtAuthInterceptor.java)&lt;/h4&gt;
&lt;pre id=&quot;code_1626328154987&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package me.huiya.core.Config;

import me.huiya.core.Common.JWTManager;
import me.huiya.core.Exception.AuthRequiredException;
import org.springframework.web.method.HandlerMethod;
import org.springframework.web.servlet.HandlerInterceptor;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

public class JwtAuthInterceptor implements HandlerInterceptor {

    private static final String HEADER_TOKEN_KEY = &quot;Bearer &quot;;

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {

        if(request.getMethod().equals(&quot;OPTIONS&quot;)) {
            return true;
        }

        // 컨트롤러에 @WithOutAuth 어노테이션이 사용되었는지 체크함
        WithOutAuth withoutAuth = ((HandlerMethod) handler).getMethodAnnotation(WithOutAuth.class);
        
        if(withoutAuth == null) {
            // @WithOutAuth 없으면 인증 체크
            String token = request.getHeader(&quot;Authorization&quot;);
            if(token == null) {
                throw new AuthRequiredException(&quot;Required token&quot;);
            }
            token = token.replace(HEADER_TOKEN_KEY, &quot;&quot;);
            JWTManager.verify(token);    
        } else {
            // @WithOutAuth 어노테이션이 있으므로 인증 체크하지 않고 넘어감
            // 2021-07-15 13:55 huiya
        }

        return true;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. Controller에 @WithOutAuth 추가&lt;/h4&gt;
&lt;pre id=&quot;code_1626328772388&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@RestController
@RequestMapping(value=&quot;/&quot;)
public class RootController {

    @RequestMapping(value={&quot;/&quot;, &quot;/status&quot;})
    @WithOutAuth
    public Result index() {
        // (생략)
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 기존에 사용하고 있던 화이트리스트를 지워버리면 끝이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존에는 화이트리스트를 통해서 인터셉터를 실행할지 말지를 결정했다면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 코드는 인터셉터는 무조건 실행되지만 인증 체크 여부를 인터셉터 내부에서 결정하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 방법은 스프링 3.0에서는 동작하지 않는다고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출처.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://krespo.net/192&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://krespo.net/192&lt;/a&gt;&lt;/p&gt;</description>
      <category>개발/Backend</category>
      <category>spring</category>
      <category>어노테이션</category>
      <category>인증</category>
      <category>인증처리</category>
      <category>인터셉터</category>
      <author>HuiYa</author>
      <guid isPermaLink="true">https://huiya-dev.tistory.com/16</guid>
      <comments>https://huiya-dev.tistory.com/16#entry16comment</comments>
      <pubDate>Thu, 15 Jul 2021 15:04:30 +0900</pubDate>
    </item>
    <item>
      <title>[Spring Boot] 작업 비동기로 실행하기</title>
      <link>https://huiya-dev.tistory.com/15</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://blog.huiya.me/14&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://blog.huiya.me/14&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1626171354924&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;SpringBoot html 템플릿 메일 보내기 - Thymeleaf&quot; data-og-description=&quot;스프링부트에서 메일을 보내야 할 필요가 생겼다. 개발하면서 필요했던 요구사항은 아래와 같다. 가입 환영 메일, 이메일 인증 메일,&amp;nbsp;기타 등등 여러 종류의 메일 발송 가능할것. 단순 텍스트 &quot; data-og-host=&quot;blog.huiya.me&quot; data-og-source-url=&quot;https://blog.huiya.me/14&quot; data-og-url=&quot;https://blog.huiya.me/14&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/nmgKh/hyKSK4pfPS/EOGnSQOLmCRCLtCY9YCXm1/img.png?width=800&amp;amp;height=286&amp;amp;face=0_0_800_286,https://scrap.kakaocdn.net/dn/lq3bF/hyKSVrjR4J/4tGjdPkidrc4fk9zhr5CbK/img.jpg?width=1920&amp;amp;height=1280&amp;amp;face=0_0_1920_1280,https://scrap.kakaocdn.net/dn/SsitC/hyKRKkEWNw/2MVL8Y68XbhWYa7Oh7UuLK/img.png?width=800&amp;amp;height=286&amp;amp;face=0_0_800_286&quot;&gt;&lt;a href=&quot;https://blog.huiya.me/14&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://blog.huiya.me/14&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/nmgKh/hyKSK4pfPS/EOGnSQOLmCRCLtCY9YCXm1/img.png?width=800&amp;amp;height=286&amp;amp;face=0_0_800_286,https://scrap.kakaocdn.net/dn/lq3bF/hyKSVrjR4J/4tGjdPkidrc4fk9zhr5CbK/img.jpg?width=1920&amp;amp;height=1280&amp;amp;face=0_0_1920_1280,https://scrap.kakaocdn.net/dn/SsitC/hyKRKkEWNw/2MVL8Y68XbhWYa7Oh7UuLK/img.png?width=800&amp;amp;height=286&amp;amp;face=0_0_800_286');&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;SpringBoot html 템플릿 메일 보내기 - Thymeleaf&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;스프링부트에서 메일을 보내야 할 필요가 생겼다. 개발하면서 필요했던 요구사항은 아래와 같다. 가입 환영 메일, 이메일 인증 메일,&amp;nbsp;기타 등등 여러 종류의 메일 발송 가능할것. 단순 텍스트&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;blog.huiya.me&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에서 메일 보내기를 구현하고 나니 메일을 비동기로 보내야겠다는 생각이 들었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 프로젝트에서 가입과 메일 보내는걸 동기적으로 처리했다가 한참동안 기다려야 했던 기억이 나서 이번에는 꼭 스레드를 써봐야겠다고 마음을 먹었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 찾아보니 SpringBoot에서는 쓰레드 생성이 어렵지 않았다.&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;h4 data-ke-size=&quot;size20&quot;&gt;1. AsyncConfig.java 추가&lt;/h4&gt;
&lt;pre id=&quot;code_1626171719540&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package me.huiya.core.Config;

import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.AsyncConfigurerSupport;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig extends AsyncConfigurerSupport {

    public Executor getAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(2); // 기본적으로 실행 대기 중인 Thread 개수
        executor.setMaxPoolSize(10); // 동시에 동작하는 최대 Thread 개수
        executor.setQueueCapacity(500); // CorePool이 초과될때 Queue에 저장했다가 꺼내서 실행된다. (500개까지 저장함)
        // 단, MaxPoolSize가 초과되면 Thread 생성에 실패할 수 있음.
        // 참고: https://medium.com/trendyol-tech/spring-boot-async-executor-management-with-threadpooltaskexecutor-f493903617d
        // jooncco님 수정사항
        executor.setThreadNamePrefix(&quot;async-&quot;); // Spring에서 생성하는 Thread 이름의 접두사
        executor.initialize();
        return executor;
    }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;@EnableAync 어노테이션이 붙은 Configuration를 하나 추가해준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;각 설정값에 대한 설명은 코드에 주석을 남겨놨다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;2. 기존 메소드에 @Async 어노테이션 추가&lt;/h4&gt;
&lt;pre id=&quot;code_1626171864895&quot; class=&quot;java&quot; data-ke-language=&quot;java&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;package me.huiya.core.Service;

import java.beans.ConstructorProperties;
import java.io.IOException;
import java.util.HashMap;

import javax.mail.MessagingException;
import javax.mail.internet.MimeMessage;
import org.springframework.mail.javamail.JavaMailSender;
import org.springframework.mail.javamail.MimeMessageHelper;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Component;
import org.thymeleaf.context.Context;
import org.thymeleaf.spring5.SpringTemplateEngine;

@Component
public class Email {

    // Autowired 대신 추천되는 의존성 주입 방식
    private static JavaMailSender javaMailSender;
    private static SpringTemplateEngine templateEngine;

    @ConstructorProperties({&quot;JavaMailSender&quot;, &quot;SpringTemplateEngine&quot;})
    public Email(JavaMailSender javaMailSender, SpringTemplateEngine templateEngine) {
        this.javaMailSender = javaMailSender;
        this.templateEngine = templateEngine;
    }

    /**
     * 이메일 발송 함수
     * @param title 이메일 제목
     * @param to 받는 사람
     * @param templateName 이메일 템플릿
     * @param values 이메일에 들어가는 값
     * @throws MessagingException
     * @throws IOException
     */
    @Async
    public void send(String title, String to, String templateName, HashMap&amp;lt;String, String&amp;gt; values) throws MessagingException, IOException {

        MimeMessage message = javaMailSender.createMimeMessage();
        MimeMessageHelper helper = new MimeMessageHelper(message, true);

        //메일 제목 설정
        helper.setSubject(title);

        //수신자 설정
        helper.setTo(to);

        //템플릿에 전달할 데이터 설정
        Context context = new Context();
        values.forEach((key, value)-&amp;gt;{
            context.setVariable(key, value);
        });

        //메일 내용 설정 : 템플릿 프로세스
        String html = templateEngine.process(templateName, context);
        helper.setText(html, true);

        //메일 보내기
        javaMailSender.send(message);
    }
}&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;559&quot; data-origin-height=&quot;465&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mc48J/btq9rddLJBv/tQdMUDESzP3DD56NZJKnG0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mc48J/btq9rddLJBv/tQdMUDESzP3DD56NZJKnG0/img.png&quot; data-alt=&quot;@Async 어노테이션만 하나 추가했다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mc48J/btq9rddLJBv/tQdMUDESzP3DD56NZJKnG0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fmc48J%2Fbtq9rddLJBv%2FtQdMUDESzP3DD56NZJKnG0%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;559&quot; height=&quot;465&quot; data-origin-width=&quot;559&quot; data-origin-height=&quot;465&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;@Async 어노테이션만 하나 추가했다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;기존 글과 비교해보면 달라진 부분은 @Async 어노테이션 하나 뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Autowired 관련해서 코드를 조금 변경한게 있는데 비동기 실행과는 전혀 상관 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컨트롤러 쪽은 건들지 않아도 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한가지 조심해야 할 점은 @Async 어노테이션은 &lt;span style=&quot;color: #ee2323;&quot;&gt;private에서는 비동기로 동작하지 않으며, public에서만 비동기로 작동&lt;/span&gt;한다고 한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;3. 비교&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1058&quot; data-origin-height=&quot;284&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dh1p1k/btq9ymNBfjK/1gmB4IPIXd4F59urHYHac1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dh1p1k/btq9ymNBfjK/1gmB4IPIXd4F59urHYHac1/img.png&quot; data-alt=&quot;기존 코드로 실행했을때. 3.26초 걸렸다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dh1p1k/btq9ymNBfjK/1gmB4IPIXd4F59urHYHac1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdh1p1k%2Fbtq9ymNBfjK%2F1gmB4IPIXd4F59urHYHac1%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;1058&quot; height=&quot;284&quot; data-origin-width=&quot;1058&quot; data-origin-height=&quot;284&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;기존 코드로 실행했을때. 3.26초 걸렸다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1054&quot; data-origin-height=&quot;301&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/GnrvT/btq9sJJR0F4/HIMnU8gO7HWsvGvpvM0Hgk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/GnrvT/btq9sJJR0F4/HIMnU8gO7HWsvGvpvM0Hgk/img.png&quot; data-alt=&quot;@Async를 적용한 코드. 77ms로 확 줄었다!&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/GnrvT/btq9sJJR0F4/HIMnU8gO7HWsvGvpvM0Hgk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FGnrvT%2Fbtq9sJJR0F4%2FHIMnU8gO7HWsvGvpvM0Hgk%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;1054&quot; height=&quot;301&quot; data-origin-width=&quot;1054&quot; data-origin-height=&quot;301&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;@Async를 적용한 코드. 77ms로 확 줄었다!&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;같은 데이터를 보냈을때 기존 코드로는 3.26초나 걸린 반면, 비동기를 적용하고 나니 77ms 밖에 걸리지 않았다. 단순 계산으로도 40배 이상 빨라졌다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론 사용자에게 응답이 리턴된 이후에도 뒤에서는 열심히 스레드가 돌고 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;옛날과 달리 스레드 적용이 어렵지도 않으니&amp;nbsp;메일 발송과 같은 시간이 오래 걸리는 작업을 실행할때는 스레드를 적극적으로 사용해야 할 것 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;출처&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.hanumoka.net/2020/07/02/springBoot-20200702-sringboot-async-service/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;https://www.hanumoka.net/2020/07/02/springBoot-20200702-sringboot-async-service/&lt;/a&gt;&lt;/p&gt;</description>
      <category>개발/Backend</category>
      <category>async</category>
      <category>spring</category>
      <category>SpringBoot</category>
      <category>메일</category>
      <category>비동기</category>
      <category>스레드</category>
      <author>HuiYa</author>
      <guid isPermaLink="true">https://huiya-dev.tistory.com/15</guid>
      <comments>https://huiya-dev.tistory.com/15#entry15comment</comments>
      <pubDate>Tue, 13 Jul 2021 19:37:53 +0900</pubDate>
    </item>
  </channel>
</rss>