<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>키유실록</title>
    <link>https://playerdecuple.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Wed, 29 Jul 2026 06:23:08 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>데키유</managingEditor>
    <image>
      <title>키유실록</title>
      <url>https://tistory1.daumcdn.net/tistory/5395945/attach/058478ec0fcb4d6eb2f2f4a01440c523</url>
      <link>https://playerdecuple.tistory.com</link>
    </image>
    <item>
      <title>포트폴리오 페이지: DekiyU-niverse</title>
      <link>https://playerdecuple.tistory.com/15</link>
      <description>&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;432&quot; data-origin-height=&quot;433&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Qov0O/btsObH5SiFO/2Kde6BKzKtpqTZGenXzel1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Qov0O/btsObH5SiFO/2Kde6BKzKtpqTZGenXzel1/img.png&quot; data-alt=&quot;DekiyU-niverse&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Qov0O/btsObH5SiFO/2Kde6BKzKtpqTZGenXzel1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQov0O%2FbtsObH5SiFO%2F2Kde6BKzKtpqTZGenXzel1%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;432&quot; height=&quot;433&quot; data-origin-width=&quot;432&quot; data-origin-height=&quot;433&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;DekiyU-niverse&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;text-align: center;&quot; data-ke-size=&quot;size16&quot;&gt;완성된 포트폴리오 페이지는 &lt;a href=&quot;https://dekiyu.xyz&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;이 곳&lt;/b&gt;&lt;/a&gt;에서 볼 수 있다.&lt;/p&gt;
&lt;h3 style=&quot;text-align: justify;&quot; data-ke-size=&quot;size23&quot;&gt;작업물에 대해&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;제작 기간&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2025. 3. 26. ~ 2025. 3. 27.&lt;i&gt; (약 2일)&lt;/i&gt;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;사용 기술&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;icons.png&quot; data-origin-width=&quot;273&quot; data-origin-height=&quot;48&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bMkIK3/btsObmuwVb0/HUSGT1F4DoESP9dKGBt9bk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bMkIK3/btsObmuwVb0/HUSGT1F4DoESP9dKGBt9bk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bMkIK3/btsObmuwVb0/HUSGT1F4DoESP9dKGBt9bk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbMkIK3%2FbtsObmuwVb0%2FHUSGT1F4DoESP9dKGBt9bk%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;273&quot; height=&quot;48&quot; data-filename=&quot;icons.png&quot; data-origin-width=&quot;273&quot; data-origin-height=&quot;48&quot;/&gt;&lt;/span&gt;&lt;/figure&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;/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;이러한 아이디어가 가장 빛을 발할 수 있는 프로젝트 중 하나는 단연 포트폴리오라고 할 수 있다. 한 사람의 내면세계, 작업물, 마인드셋, 기술 스택 등, 즉 여러 주제들을 하나의 페이지에 담을 수 있다는 장점이 있기 때문이다. 나를 잘 나타내는 페이지는 곧 나의 평가를 더욱 정확하게 만든다. 정확한 평가는 나를 더 강하게 만들고, 그러지 못한 것들은 갈피를 잡지 못하게 만든다.&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;포트폴리오의&lt;br /&gt;&lt;span style=&quot;color: #ff6347;&quot;&gt;주제&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주제가 '주제 나와라 뚝딱' 해서 나오는 것은 아니지만, 지금 만드는 것은 '포트폴리오'이다. 즉, 나에 대해 가장 잘 나타내는 것이 주요 목적이다. 그러므로 나와 관련된 무언가에서 주제를 가져오는 것이 좋을 것이라고 생각했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본인에게는 늘 '&lt;b&gt;나는 우주다&lt;/b&gt;'라는 모토가 있었다. 너무 거창할 수도 있지만, 나름대로의 이유가 있다. 무수히 많아 셀 수 없는 양의 구성 요소를 가지고 있다는 것, 그리고 어느 형태로든 서로 상호작용할 수 있다는 것 등이 인체와 우주의 공통점이다. 나도 나 자신이 너무 넓어 스스로 잘 알 수 없지만, 오히려 너무나 넓기에 무한한 가능성이 있다고 생각한다. 다시금 생각해 보면, 여러 번아웃 또한 이러한 마인드로 이겨낸 적이 있었던 것 같다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 우주라면, 내가 나를 탐험하는 여행을 함께하는 사람이 있으면 어떨까? 하는 생각이 머릿속을 스쳤다. 포트폴리오를 보고 관심을 가진다는 것 자체가 '나의 탐험'에 관심이 있다는 것이 아닐까? 이걸 '여행'이라고 생각하고, 관심이 있는 사람이 나의 우주로 빠져들게끔 하는 컨셉은 어떨지 고민했다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;최종적으로, 포트폴리오 페이지의 컨셉을 '&lt;b&gt;내 우주를 탐험하는 여행&lt;/b&gt;'으로 잡고 개발을 시작했다.&lt;/li&gt;
&lt;/ul&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;제작 과정 중&lt;br /&gt;&lt;span style=&quot;color: #ff6347;&quot;&gt;인상적인 일&lt;/span&gt;들&lt;/h3&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;아이디어에 ChatGPT 활용하기&lt;/h4&gt;
&lt;blockquote data-ke-style=&quot;style1&quot;&gt;&lt;span style=&quot;font-family: 'Noto Serif KR';&quot;&gt;플랑크톤은 깨방정!&lt;br /&gt;&lt;/span&gt;&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;영어를 꾸준히 공부하고 있지만, 아직 완전히 유창하게 하지 못한다. 그리고, 한국어로도 느낌 좋고 누구나 감동받을 수 있는 감언 쓰기를 어려워하는데 영어라고 그게 쉽지는 않을 터다. 스스로의 영어 실력을 너무나 잘 알고 있어서, ChatGPT의 도움을 조금 받기로 했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;708&quot; data-origin-height=&quot;902&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lmYH5/btsOa8JPohB/C7yM9QlP24SFW4EgOLgfJ1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lmYH5/btsOa8JPohB/C7yM9QlP24SFW4EgOLgfJ1/img.png&quot; data-alt=&quot;ChatGPT가 추천하는 &amp;quot;이상을 향하는 개발자&amp;quot;에게 어울리는 카피들&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lmYH5/btsOa8JPohB/C7yM9QlP24SFW4EgOLgfJ1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlmYH5%2FbtsOa8JPohB%2FC7yM9QlP24SFW4EgOLgfJ1%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;407&quot; height=&quot;519&quot; data-origin-width=&quot;708&quot; data-origin-height=&quot;902&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;ChatGPT가 추천하는 &quot;이상을 향하는 개발자&quot;에게 어울리는 카피들&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;살짝씩은 오그라들기도 하지만, 개인적으로는 이 녀석. 생각보다 낭만 있는 것 같다. 하지만 이렇게 많은 카피들을 한 번에 얻으니 오히려 이것들 중에 선택하기가 더 어려웠다. 또, '나'를 최대한 간단하게 표현하는 것은 내 이름(또는 별명)일 것이라는 생각이 갑자기 들어, ChatGPT에게 다시 내 닉네임(&lt;i&gt;Dekiyu&lt;/i&gt;)을 사용해서 역두문자어로 카피를 만들어달라고 부탁해보았다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;744&quot; data-origin-height=&quot;416&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ecfnpk/btsOc82hRgw/4tkpgIWxOja1PZ2wXPjsgk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ecfnpk/btsOc82hRgw/4tkpgIWxOja1PZ2wXPjsgk/img.png&quot; data-alt=&quot;최근 게임 개발과 시나리오 라이팅 또한 하고 있다는 것을 참고해 주었다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ecfnpk/btsOc82hRgw/4tkpgIWxOja1PZ2wXPjsgk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fecfnpk%2FbtsOc82hRgw%2F4tkpgIWxOja1PZ2wXPjsgk%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;422&quot; height=&quot;236&quot; data-origin-width=&quot;744&quot; data-origin-height=&quot;416&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;정말 마음에 드는 카피가 나왔다. '네 우주 속에서 운명이 피어난다'라니. 조금 많이 오그라들지만 오히려 이런 멘트가 눈길을 끌기에는 좋지 않을까? 하는 생각에 그냥 바로 포트폴리오의 비주얼 섹션에 박아넣었다. 이외에도 스크롤 유도 버튼이나, 여행에 참가하길권하는 문구 등을 ChatGPT의 도움을 받아서 작성했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;664&quot; data-origin-height=&quot;203&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dJPOA5/btsOaQP6bhB/3ShIG5ALpUOb8Hj1jsez6k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dJPOA5/btsOaQP6bhB/3ShIG5ALpUOb8Hj1jsez6k/img.png&quot; data-alt=&quot;아부를 너무 잘 떤다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dJPOA5/btsOaQP6bhB/3ShIG5ALpUOb8Hj1jsez6k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdJPOA5%2FbtsOaQP6bhB%2F3ShIG5ALpUOb8Hj1jsez6k%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;363&quot; height=&quot;111&quot; data-origin-width=&quot;664&quot; data-origin-height=&quot;203&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;디자인 리뷰도 해 주었다! 물론, 사용자의 의도에 따라 최대한 좋은 답변을 한 것이라고 생각한다. 아직까진 ChatGPT와 같은 생성형 AI의 의견은 객관적이라고 보기에 어려우므로, 완전히 믿기보다는 참고만 하는 것이 좋다고 생각한다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;지구본&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1994&quot; data-origin-height=&quot;1212&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lqUL2/btsObt1aRhW/HyHCWFc7KmxY6X0rohru11/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lqUL2/btsObt1aRhW/HyHCWFc7KmxY6X0rohru11/img.png&quot; data-alt=&quot;지구본 섹션. 현재 시간에 따라 지구본의 밤낮이 바뀐다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lqUL2/btsObt1aRhW/HyHCWFc7KmxY6X0rohru11/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlqUL2%2FbtsObt1aRhW%2FHyHCWFc7KmxY6X0rohru11%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;651&quot; height=&quot;396&quot; data-origin-width=&quot;1994&quot; data-origin-height=&quot;1212&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;가장 애를 많이 먹었다. Three.js에 익숙하지도 않았고 Vertex Shader, Fragment Shader 등을 제대로 알지도 못했다. 이와 관련해서 찾아보느라 시간을 많이 잡아먹은 것 같다... 그럼에도 아직 완전히 이해하지 못했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;655&quot; data-origin-height=&quot;368&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/kWjtr/btsOaWXhrdR/7lBkbMR6GxN88WBfft9ur0/img.jpg&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/kWjtr/btsOaWXhrdR/7lBkbMR6GxN88WBfft9ur0/img.jpg&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/kWjtr/btsOaWXhrdR/7lBkbMR6GxN88WBfft9ur0/img.jpg&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FkWjtr%2FbtsOaWXhrdR%2F7lBkbMR6GxN88WBfft9ur0%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;293&quot; height=&quot;165&quot; data-origin-width=&quot;655&quot; data-origin-height=&quot;368&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 지구본이 마우스 드래그로 돌아가는 것을 보고 나니 칠레 아저씨 포즈가 자동으로 나왔다. 어려우면 어떠한가! 결과물이 이리도 마음에 드니 애먹던 기억도 싹 사라지는 것만 같다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마치며&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그간 (쓸 것이 없어서) 블로그에 무언가 많이 올리지를 못했다. 기회가 된다면 더 자주 글을 올려보도록 노력하고자 한다...지만 언제 올지는 본인도 알 수가 없다. 하고 있는 프로젝트는 많지만, 그 놈의 완성하기 전까지 아무것도 밝히지 않으려는 욕심 때문에 블로그에 글을 쓸 거리가 없었다.&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;</description>
      <category>프로젝트/웹</category>
      <category>포트폴리오</category>
      <author>데키유</author>
      <guid isPermaLink="true">https://playerdecuple.tistory.com/15</guid>
      <comments>https://playerdecuple.tistory.com/15#entry15comment</comments>
      <pubDate>Mon, 26 May 2025 03:04:37 +0900</pubDate>
    </item>
    <item>
      <title>Node.js에서 WinRT API로 재생 중인 미디어 정보 가져오기: NodeRT</title>
      <link>https://playerdecuple.tistory.com/13</link>
      <description>&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;i&gt;MediaDevices.getUserMedia(constraints) &lt;/i&gt;메서드 이용해 만든 거울 기능, 그리고 컴퓨터에서 지금 재생하고 있는 미디어 정보까지 표시해주게끔 만들었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1440&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ctxTRf/btsIReBZSiX/vfHEmhTICGyOqusLS0ZhnK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ctxTRf/btsIReBZSiX/vfHEmhTICGyOqusLS0ZhnK/img.png&quot; data-alt=&quot;벽에 걸고 있는 모습이 조금 조잡하지만, 충분히 마음에 든다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ctxTRf/btsIReBZSiX/vfHEmhTICGyOqusLS0ZhnK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FctxTRf%2FbtsIReBZSiX%2FvfHEmhTICGyOqusLS0ZhnK%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;1080&quot; height=&quot;1440&quot; data-origin-width=&quot;1080&quot; data-origin-height=&quot;1440&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;벽에 걸고 있는 모습이 조금 조잡하지만, 충분히 마음에 든다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 시계를 만들며 가장 노력을 들인 것이 바로 위 사진에서 보이는 &lt;b&gt;현재 음악 정보 표시 기능&lt;/b&gt;이다. 이 기능을 어떻게 구현할지 고민한 결과, 다음과 같이 요약할 수 있었다:&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Chrome Extension을 만들고 재생 중인 미디어가 있다면 이를 엔드포인트에 전송&lt;/li&gt;
&lt;li&gt;Windows에서 현재 재생 중인 미디어 불러오기&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;필자가 선택한 것은 후자였는데, 작은 규모의 프로젝트를 위해 익스텐션 프로젝트를 하나 더 만들기에는 애매하다는 이유에서였다. 'Windows에서 현재 재생 중인 미디어 불러오기'라고만 써 둔다면 잘 와닿지 않을 수 있지만, 이미 Windows 내에서 이 방식을 사용하는 UI가 존재한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;373&quot; data-origin-height=&quot;546&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cLckBR/btsITtYCVNy/9lHjUmBKGHOLUhK7JABtzK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cLckBR/btsITtYCVNy/9lHjUmBKGHOLUhK7JABtzK/img.png&quot; data-alt=&quot;상태 설정 위에 보이는 UI가 바로 설명하고자 하는 UI이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cLckBR/btsITtYCVNy/9lHjUmBKGHOLUhK7JABtzK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcLckBR%2FbtsITtYCVNy%2F9lHjUmBKGHOLUhK7JABtzK%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;373&quot; height=&quot;546&quot; data-origin-width=&quot;373&quot; data-origin-height=&quot;546&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;상태 설정 위에 보이는 UI가 바로 설명하고자 하는 UI이다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다면, 해당 UI가 어떤 방식으로 현재 재생 중인 미디어를 불러오는지, 그리고 필자가 해당 기능을 구현하는 데에 있어 어떤 실수들을 했을지 살펴보고자 한다.&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;Step 1.&amp;nbsp;&lt;br /&gt;&lt;span style=&quot;color: #ff6347;&quot;&gt;NodeRT&lt;/span&gt; 설치하기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Windows 8에서는 WinRT(Windows Runtime) API를 지원하고, Windows 10에서는 WinRT를 계승한 UWP(Universal Windows Platform) API를 지원한다. 바로 이 API들이 현재 미디어를 불러오는 역할을 한다. 이를 알게 된 계기는 WinRT를 활용하여 작성된 파이썬 코드를 찾은 것이었는데, 이 코드에서 아이디어를 얻고 Node.js에서 WinRT를 사용하는 방법을 구글링해 보았다. 그리고 찾은 것이 &lt;a href=&quot;https://github.com/NodeRT/NodeRT&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;b&gt;NodeRT&lt;/b&gt;&lt;/a&gt;였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NodeRT는 WinRT API나 UWP API를 Node.js에서 사용할 수 있게끔 만들어주는 라이브러리이다. NodeRT를 사용함으로써 현재 Windows 환경의 온갖 정보들을 가져올 수 있다. 저장소의 샘플에도 있는 내용이지만, 현재 위치를 가져와야 하는 Geolocation 서비스나 화면 캡쳐 등의 여러 기능들을 추가할 수 있다는 장점이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NodeRT를 사용하려면 먼저 Windows SDK를 설치해야 한다. Visual Studio Installer가 설치되어 있다면 SDK 체크만 하고 설치하면 되지만, 그렇지 않다면 Microsoft Developer Page의 &lt;a href=&quot;https://developer.microsoft.com/en-us/windows/downloads/sdk-archive&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Windows SDK 아카이브&lt;/a&gt;에 방문하여 직접 설치할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;312&quot; data-origin-height=&quot;256&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bTSySL/btsITkASHaZ/5Pu0fml53JDx1ARgwUUFO0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bTSySL/btsITkASHaZ/5Pu0fml53JDx1ARgwUUFO0/img.png&quot; data-alt=&quot;원하는 SDK를 체크하고 설치한다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bTSySL/btsITkASHaZ/5Pu0fml53JDx1ARgwUUFO0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbTSySL%2FbtsITkASHaZ%2F5Pu0fml53JDx1ARgwUUFO0%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;312&quot; height=&quot;256&quot; data-origin-width=&quot;312&quot; data-origin-height=&quot;256&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;원하는 SDK를 체크하고 설치한다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SDK를 설치하였다면, 본격적으로 NodeRT를 프로젝트에서 사용해 보자. GitHub의 README에서는 Windows 10의 Redstone 4 버전 SDK를 설치하게끔 설명하고 있지만, npm에서 조금만 검색해 보면 Windows 11 SDK를 사용하는 패키지들을 찾아볼 수 있다. 필자는 Windows 11 SDK를 사용하는 패키지를 설치했다.&lt;/p&gt;
&lt;pre id=&quot;code_1722521736655&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;npm i @nodert-win11/windows.media.control&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;NodeRT 내의 기능은 각각의 패키지로 구분되어 있는데, 필자가 사용할 기능은 'Windows.Media.Control' 네임스페이스 내에 있으므로 &lt;i&gt;windows.media.control&lt;/i&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;Step 2.&amp;nbsp;&lt;br /&gt;&lt;span style=&quot;color: #ff6347;&quot;&gt;미디어&lt;/span&gt; 불러오기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;WinRT에서는 미디어 세션들을 묶어 매니저를 통해 접근하도록 유도하고 있다. 여기서 사용하는 매니저는 &lt;a href=&quot;https://learn.microsoft.com/en-us/uwp/api/windows.media.control.globalsystemmediatransportcontrolssessionmanager?view=winrt-26100&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;GlobalSystemMediaTransportControlsSessionManager 클래스&lt;/a&gt;로, 해당 클래스의&lt;i&gt; &lt;a href=&quot;https://learn.microsoft.com/en-us/uwp/api/windows.media.control.globalsystemmediatransportcontrolssessionmanager.requestasync?view=winrt-26100#windows-media-control-globalsystemmediatransportcontrolssessionmanager-requestasync&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;.RequestAsync()&lt;/a&gt; &lt;/i&gt;메서드를 호출하여 세션들이 담긴 인스턴스를 가져올 수 있다. 매니저 인스턴스를 가져오는 과정까지의 코드를 작성한다면 다음과 같이 작성할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1722522343628&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const mediaControl = require('@nodert-win11/windows.media.control');
const mediaManager = mediaControl.GlobalSystemMediaTransportControlsSessionManager;

mediaManager.requestAsync((_, manager) =&amp;gt; {
    console.log(manager);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;유의해야 할 것은, NodeRT에 구현된 비동기 메서드들이 JS의 await 키워드를 지원하지 않고 오로지 Callback 함수만을 받는다는 것과 결괏값이 Callback 함수의 첫 번째 파라미터가 아닐 수 있다는 것이다. 위 코드에서도, 첫 번째 인자가 매니저 인스턴스가 아닌 undefined이기에 두 번째 인자를 manager라고 작명한 것임을 알 수 있다. 필자는 이 점들을 인지하지 못해서 몇 시간을 낭비했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;.RequestAsync()&lt;/i&gt; 메서드로 불러온 매니저 인스턴스는 &lt;a href=&quot;https://learn.microsoft.com/en-us/uwp/api/windows.media.control.globalsystemmediatransportcontrolssessionmanager.getcurrentsession?view=winrt-26100#windows-media-control-globalsystemmediatransportcontrolssessionmanager-getcurrentsession&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;i&gt;.GetCurrentSession()&lt;/i&gt;&lt;/a&gt;과 &lt;a href=&quot;https://learn.microsoft.com/en-us/uwp/api/windows.media.control.globalsystemmediatransportcontrolssessionmanager.getsessions?view=winrt-26100#windows-media-control-globalsystemmediatransportcontrolssessionmanager-getsessions&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;i&gt;.GetSession()&lt;/i&gt;&lt;/a&gt; 메서드를 가진다. 물론 여러 세션들이 있겠지만, 현재 활성화해 둔 창의 미디어가 제일 중요할 것이라고 생각한다면 필자가 사용해야 할 메서드는 &lt;i&gt;.GetCurrentSession()&lt;/i&gt; 메서드일 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1722522684871&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const mediaControl = require('@nodert-win11/windows.media.control');
const mediaManager = mediaControl.GlobalSystemMediaTransportControlsSessionManager;

mediaManager.requestAsync((_, manager) =&amp;gt; {
    const currentSession = manager.getCurrentSession();
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 메서드는 세션 클래스인 &lt;a href=&quot;https://learn.microsoft.com/en-us/uwp/api/windows.media.control.globalsystemmediatransportcontrolssession?view=winrt-26100&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;GlobalSystemMediaTransportControlsSession 클래스&lt;/a&gt;를 반환한다. 이 세션 인스턴스는 현재 미디어의 정보와 그 정보를 가져오는 메서드를 포함한다. 이 세션 클래스의 &lt;a href=&quot;https://learn.microsoft.com/en-us/uwp/api/windows.media.control.globalsystemmediatransportcontrolssession.trygetmediapropertiesasync?view=winrt-26100#windows-media-control-globalsystemmediatransportcontrolssession-trygetmediapropertiesasync&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;.TryGetMediaPropertiesAsync()&lt;/a&gt; 메서드를 통해 정보(&lt;a href=&quot;https://learn.microsoft.com/en-us/uwp/api/windows.media.control.globalsystemmediatransportcontrolssessionmediaproperties?view=winrt-26100&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;GlobalSystemMediaTransportControlsSessionMediaProperties 클래스&lt;/a&gt;)를 가져올 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1722523007993&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const mediaControl = require('@nodert-win11/windows.media.control');
const mediaManager = mediaControl.GlobalSystemMediaTransportControlsSessionManager;

mediaManager.requestAsync((_, manager) =&amp;gt; {
    const currentSession = manager.getCurrentSession();
    
    if (currentSession != null) {
    	currentSession.tryGetMediaPropertiesAsync(async (_, info) =&amp;gt; {
        	const object = {};
            const keys = [
                &quot;albumArtist&quot;, &quot;albumTitle&quot;, &quot;albumTrackCount&quot;,
                &quot;artist&quot;, &quot;genres&quot;, &quot;playbackType&quot;,
                &quot;subtitle&quot;, &quot;thumbnail&quot;, &quot;title&quot;,
                &quot;trackNumber&quot;
            ];

            for (const k of keys) {
                object[k] = info[k];
            }
            
            console.log(object);
        });
    }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 위 코드를 실행하면 다음과 같은 형식으로 미디어의 정보가 표시될 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1722523134762&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    &quot;albumArtist&quot;: &quot;&quot;,
    &quot;albumTitle&quot;: &quot;&quot;,
    &quot;albumTrackCount&quot;: 0,
    &quot;artist&quot;: &quot;Novelbright&quot;,
    &quot;genres&quot;: {
        &quot;__winRtInstance__&quot;: true
    },
    &quot;playbackType&quot;: 1,
    &quot;subtitle&quot;: &quot;&quot;,
    &quot;thumbnail&quot;: {
        &quot;__winRtInstance__&quot;: true
    },
    &quot;title&quot;: &quot;Novelbright - ツキミソウ [Official Live Video at ZeppTokyo]&quot;,
    &quot;trackNumber&quot;: 0
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Extra 1.&amp;nbsp;&lt;br /&gt;&lt;span style=&quot;color: #ff6347;&quot;&gt;썸네일&lt;/span&gt;에 대해&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 미디어의 정보 형식을 보면 알 수 있듯, 썸네일에 대한 정보를 제대로 보여주지 못한다. 필자가 원한 것은 썸네일 버퍼 데이터를 Blob이나 Base64로 변환한 것이었는데, 여러 문제가 겹쳐 의도대로 되지는 않았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래 로드되는 썸네일은 &lt;a href=&quot;https://learn.microsoft.com/en-us/uwp/api/windows.storage.streams.irandomaccessstreamreference?view=winrt-26100&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;IRandomAccessStreamReference 인터페이스&lt;/a&gt;의 구현체로, &lt;a href=&quot;https://learn.microsoft.com/en-us/uwp/api/windows.storage.streams.irandomaccessstreamreference.openreadasync?view=winrt-26100#windows-storage-streams-irandomaccessstreamreference-openreadasync&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;i&gt;.OpenReadAsync()&lt;/i&gt;&lt;/a&gt; 메서드를 호출하여 스트림 인터페이스인 &lt;a href=&quot;https://learn.microsoft.com/en-us/uwp/api/windows.storage.streams.irandomaccessstreamwithcontenttype?view=winrt-26100&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;IRandomAccessStreamWithContentType&lt;/a&gt;의 구현체를 얻을 수 있다. 이 구현체는 실제 스트림을 읽고 버퍼에 삽입하는 &lt;a href=&quot;https://learn.microsoft.com/en-us/uwp/api/windows.storage.streams.iinputstream.readasync?view=winrt-26100#windows-storage-streams-iinputstream-readasync(windows-storage-streams-ibuffer-system-uint32-windows-storage-streams-inputstreamoptions)&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;&lt;i&gt;.ReadAsync(IBuffer, UInt32, InputStreamOptions)&lt;/i&gt;&lt;/a&gt; 메서드를 가지고 있는데, NodeRT에서는 이 메서드에 접근할 수 없었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 내용에 대해 찾아보니, &lt;a href=&quot;https://github.com/NodeRT/nodert-streams&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;nodert-streams&lt;/a&gt; 패키지에서 스트림에 관한 내용을 따로 구축하고 있다고 명시하고 있었다. 하지만 유지보수가 잘 되어있지 않은지 Visual Studio 버전과 관련한 오류가 지속적으로 발생하는 것을 보고 썸네일 구현은 포기하였다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마치며&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 글에서는 NodeRT에 대해 살펴보고, 실제로 사용해보는 내용을 다루었다. Node.js로 개발하는데도 C# UWP 레퍼런스를 봐야 한다는 것은 오묘했고&amp;nbsp; &lt;span style=&quot;text-align: start;&quot;&gt;썸네일을 구현하지 못한 것은 아쉬웠지만, 충분히 원하는 기능을 만들었으니 나쁘지는 않다고 생각하고 있다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;text-align: start;&quot;&gt;이 글이 당신에게 유익했거나 참고가 되었다면 아래의 공감 버튼을 한 번 클릭해 주시길 부탁드린다. 필자는 여러분의 의견에 따라 이 글이 실제로 독자에게 도움이 되는지 판단하고, 양질의 컨텐츠를 생산하기 위해 노력하게 된다. 앞으로 여러분들에게 도움이 되는 글을 작성하기 위해서는 도움이 필요하다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;이 글에 오류가 있거나, 지나치게 편향적이거나 주관적인 서술이 존재한다고 생각되거나, 이해를 방해하는 내용, 문체 등을 포함하고 있다고 느껴진다면 언제든지 댓글로 피드백을 작성해 주시어 오류 수정에 도움을 주실 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;text-align: left;&quot;&gt;글을 읽어주시어 진심으로 감사드린다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>개발일지/웹</category>
      <category>node.js</category>
      <category>nodert</category>
      <category>UWP</category>
      <category>WinRT</category>
      <category>미디어</category>
      <author>데키유</author>
      <guid isPermaLink="true">https://playerdecuple.tistory.com/13</guid>
      <comments>https://playerdecuple.tistory.com/13#entry13comment</comments>
      <pubDate>Thu, 1 Aug 2024 23:56:30 +0900</pubDate>
    </item>
    <item>
      <title>텍스트 에디터로 메일 전송 페이지 만들기: 웹메일 클라이언트 개발하기 6</title>
      <link>https://playerdecuple.tistory.com/12</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;필자가 개발하고자 하는 웹메일 클라이언트의 프로토타입은 이 글의 내용을 구현함으로써 완성된다. 앞으로의 내용은 UI, UX와 같은 디자인과 관련된 내용이 될 것이기에 연재되지 않을 것이므로, 지금 이 글과 다음 글이 웹메일 클라이언트 개발하기의 마지막 내용이 될 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SMTP와 SMTP를 활용한 라이브러리인 nodemailer를 사용하는 메일 전송 엔드포인트 개발은 &lt;a href=&quot;https://log.dekiyu.xyz/8&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이 글&lt;/a&gt;의 Step 8에서 진행하였으므로 아직 읽지 않았다면 한 번쯤 읽어보기를 권장한다. 또한, 이 프로젝트의 진행 과정에 흥미가 있거나, 메일 클라이언트 개발에 참고하고 싶다면 '&lt;a href=&quot;https://log.dekiyu.xyz/tag/%EC%9B%B9%EB%A9%94%EC%9D%BC%20%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8%20%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;웹메일 클라이언트 개발하기&lt;/a&gt;' 태그 페이지에 방문하여 내용을 읽어보는 것도 좋다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 프로젝트 개발의 핵심 내용과 원리를 정리하고자 하는 글일 뿐, &lt;b&gt;강의를 하려는 목적의 글이 아니다&lt;/b&gt;. 따라서, 독자가 HTML, CSS, JavaScript와 같은 웹 프론트엔드의 기초 지식과 Node.js, Express.js와 같은 Node.js 기반 웹 백엔드의 기초 지식을 알고 있다는 가정 하에 작성되었다. 따라서 이러한 기초 지식 없이 글을 읽는다면 당신의 의문점이나 관심사에 큰 도움을 주지 못할 수 있다. 하지만 당신이 실제 서비스를 비슷하게 구현해보고 싶거나, 마땅히 개발에 사용할 아이디어가 없는 사람이라면 이 글이 참고가 될 수도 있다.&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;Step 14.&amp;nbsp;&lt;br /&gt;프론트엔드: &lt;span style=&quot;color: #ff6347;&quot;&gt;메일 전송&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메일 전송 페이지는 사용자에게 제목, 수신 메일 주소, 첨부 파일, 본문 등 여러 입력 창을 보여주고, 내용을 받아 엔드포인트로 내용을 전송하는 역할을 하는 페이지이다. 이 점을 고려하여 메일 전송 페이지의 ejs 파일을 작성하고, 라우트를 연결한다.&lt;/p&gt;
&lt;pre id=&quot;code_1722150982167&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;!-- view/mail_write.ejs --&amp;gt;

&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;zxx&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
    &amp;lt;title&amp;gt;Document&amp;lt;/title&amp;gt;

    &amp;lt;script src=&quot;/assets/scripts/everyday.client.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    
    &amp;lt;div class=&quot;wrap&quot; id=&quot;mail-form&quot;&amp;gt;
        &amp;lt;div&amp;gt;
            &amp;lt;label for=&quot;subject&quot;&amp;gt;Subject&amp;lt;/label&amp;gt;
            &amp;lt;input type=&quot;text&quot; id=&quot;subject&quot; name=&quot;subject&quot;&amp;gt;
        &amp;lt;/div&amp;gt;

        &amp;lt;br&amp;gt;

        &amp;lt;div&amp;gt;
            &amp;lt;label for=&quot;to&quot;&amp;gt;To&amp;lt;/label&amp;gt;
            &amp;lt;div class=&quot;to-list&quot;&amp;gt;&amp;lt;/div&amp;gt;
            &amp;lt;input type=&quot;text&quot; id=&quot;to&quot; name=&quot;to&quot;&amp;gt;
            &amp;lt;button id=&quot;to-add&quot;&amp;gt;Add&amp;lt;/button&amp;gt;
        &amp;lt;/div&amp;gt;
        
        &amp;lt;br&amp;gt;

        &amp;lt;div class=&quot;editor-wrap&quot;&amp;gt;
            &amp;lt;p&amp;gt;Content&amp;lt;/p&amp;gt;
            &amp;lt;div class=&quot;editor-menu&quot;&amp;gt;
                &amp;lt;button id=&quot;bold-btn&quot; type=&quot;button&quot;&amp;gt;
                    &amp;lt;b&amp;gt;Bold&amp;lt;/b&amp;gt;
                &amp;lt;/button&amp;gt;
                &amp;lt;button id=&quot;italic-btn&quot; type=&quot;button&quot;&amp;gt;
                    &amp;lt;i&amp;gt;Italic&amp;lt;/i&amp;gt;
                &amp;lt;/button&amp;gt;
                &amp;lt;button id=&quot;underline-btn&quot; type=&quot;button&quot;&amp;gt;
                    &amp;lt;span style=&quot;text-underline-offset: 16%; text-decoration: underline&quot;&amp;gt;Underline&amp;lt;/span&amp;gt;
                &amp;lt;/button&amp;gt;
                &amp;lt;button id=&quot;line-through-btn&quot; type=&quot;button&quot; style=&quot;margin-right: 30px;&quot;&amp;gt;
                    &amp;lt;span style=&quot;text-decoration: line-through&quot;&amp;gt;Line-Through&amp;lt;/span&amp;gt;
                &amp;lt;/button&amp;gt;
     
                &amp;lt;div&amp;gt;
                    &amp;lt;span style=&quot;display: inline-block; margin-right: 10px; font-size: .9em&quot;&amp;gt;Text Size&amp;lt;/span&amp;gt;
                    &amp;lt;button class=&quot;text-size-btn&quot; type=&quot;button&quot; data-prop=&quot;1&quot;&amp;gt;Small&amp;lt;/button&amp;gt;
                    &amp;lt;button class=&quot;text-size-btn&quot; type=&quot;button&quot; data-prop=&quot;3&quot;&amp;gt;Normal&amp;lt;/button&amp;gt;
                    &amp;lt;button class=&quot;text-size-btn&quot; type=&quot;button&quot; data-prop=&quot;5&quot;&amp;gt;Big&amp;lt;/button&amp;gt;
                    &amp;lt;button class=&quot;text-size-btn&quot; type=&quot;button&quot; data-prop=&quot;7&quot;&amp;gt;Bigger&amp;lt;/button&amp;gt;
                &amp;lt;/div&amp;gt;
            &amp;lt;/div&amp;gt;
            &amp;lt;div id=&quot;editor&quot; style=&quot;width: 400px; height: 200px&quot; contenteditable=&quot;true&quot;&amp;gt;&amp;lt;/div&amp;gt;
            &amp;lt;textarea id=&quot;editor-raw&quot; readonly style=&quot;display: none&quot;&amp;gt;&amp;lt;/textarea&amp;gt;
        &amp;lt;/div&amp;gt;
        
        &amp;lt;br&amp;gt;

        &amp;lt;div&amp;gt;
            &amp;lt;label for=&quot;attachment&quot;&amp;gt;Attachments&amp;lt;/label&amp;gt;
            &amp;lt;div id=&quot;attachment-list&quot;&amp;gt;&amp;lt;/div&amp;gt;
            &amp;lt;input type=&quot;file&quot; name=&quot;attachment&quot; id=&quot;attachment&quot;&amp;gt;
        &amp;lt;/div&amp;gt;
        
        &amp;lt;br&amp;gt;

        &amp;lt;button id=&quot;send&quot;&amp;gt;Send&amp;lt;/button&amp;gt;
    &amp;lt;/div&amp;gt;

&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1722151000631&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// route/public.route.js

// ...

router.use(&quot;/create&quot;, checkUserPermission);
router.get(&quot;/create&quot;, (req, res) =&amp;gt; {
    // Create a new email
    res.render(&quot;mail_write&quot;);
});

// ...&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;342&quot; data-origin-height=&quot;543&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dd6sP8/btsIQglhOG6/UVIVKmqsEs1wayjDxHmbIK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dd6sP8/btsIQglhOG6/UVIVKmqsEs1wayjDxHmbIK/img.png&quot; data-alt=&quot;메일 작성 페이지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dd6sP8/btsIQglhOG6/UVIVKmqsEs1wayjDxHmbIK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdd6sP8%2FbtsIQglhOG6%2FUVIVKmqsEs1wayjDxHmbIK%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;342&quot; height=&quot;543&quot; data-origin-width=&quot;342&quot; data-origin-height=&quot;543&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;ejs 파일의 HTML 구조 내에 있는 각 입력창을 $ELEMENTS에 넣음으로써 추후에 쉽게 접근할 수 있도록 만든다. 텍스트 에디터의 관련된 버튼은 $ELEMENTS에 넣지 않고 추후에 메서드에서 이벤트 리스너를 등록한다.&lt;/p&gt;
&lt;pre id=&quot;code_1722151872346&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js
// ...

const Client = {
    // ...
    initTable() {
        Object.assign($ELEMENTS, {
            // ...
            &quot;write.input.subject&quot;: {
                element: document.querySelector(&quot;#subject&quot;)
            },
            &quot;write.input.to&quot;: {
                element: document.querySelector(&quot;#to&quot;)
            },
            &quot;write.input.body&quot;: {
                element: document.querySelector(&quot;#editor-raw&quot;),
                eventListeners: [
                    {
                        event: &quot;input&quot;,
                        eventListener: Client.writePage.onBodyInput
                    }
                ]
            },
            &quot;write.input.attachment&quot;: {
                element: document.querySelector(&quot;#attachment&quot;),
                eventListeners: [
                    {
                        event: &quot;input&quot;,
                        eventListener: Client.writePage.onFileInput
                    }
                ]
            },

            &quot;write.button.toAdd&quot;: {
                element: document.querySelector(&quot;#to-add&quot;),
                eventListeners: [
                    {
                        event: &quot;click&quot;,
                        eventListener: Client.writePage.addReceiver
                    }
                ]
            },
            &quot;write.button.send&quot;: {
                element: document.querySelector(&quot;#send&quot;),
                eventListeners: [
                    {
                        event: &quot;click&quot;,
                        eventListener: Client.writePage.send
                    }
                ]
            },
            
            &quot;write.list.to&quot;: {
            	element: document.querySelector(&quot;.to-list&quot;)
            },
            &quot;write.list.attachments&quot;: {
            	element: document.querySelector(&quot;#attachment-list&quot;)
            },
            
            &quot;write.editor&quot;: {
            	element: document.querySelector(&quot;#editor&quot;),
                eventListeners: [
                    {
                        event: &quot;initialize&quot;,
                        eventListener: Client.writePage.initEditor
                    },
                    {
                        event: &quot;input&quot;,
                        eventListener: Client.writePage.onBodyInput
                    }
                ]
            }
        });
    },
    // ...
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 리스너로 사용한 ..&lt;i&gt;writePage&lt;/i&gt; 객체의 여러 메서드들을 정의한다.&lt;/p&gt;
&lt;pre id=&quot;code_1722152210868&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js
// ...

const Client = {
    // ...
    writePage: {

        onBodyInput() {

        },


        onFileInput() {

        },


        addReceiver() {

        },
        
        
        initEditor() {
        
        },


        async send() {

        }

    },
    // ...
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저 구현할 것은 ..&lt;i&gt;writePage.toAdd()&lt;/i&gt; 메서드, 즉 받는 사람을 추가하는 메서드이다. &lt;i&gt;input#to&lt;/i&gt; 개체의 값을 배열에 추가하고, 배열의 내용을 &lt;i&gt;div.to-list&lt;/i&gt; 개체 내에 표시한다.&lt;/p&gt;
&lt;pre id=&quot;code_1722153687562&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js

// ...

const Client = {
    // ...
    writePage: {
        
        to: new Set(),
        
        // ...
        
        addReceiver() {
            const address = $ELEMENTS[&quot;write.input.to&quot;].element.value;

            const isEmpty = address.trim().length &amp;lt; 1;
            const emailExp = /^[a-zA-Z0-9+-\_.]+@[a-zA-Z]+\.[a-zA-Z]+$/;

            if (isEmpty || !emailExp.test(address))
                return;

            Client.writePage.to.add(address);
            Client.writePage.showReceivers();
        },


        showReceivers() {
            $ELEMENTS[&quot;write.list.to&quot;].element.innerHTML = &quot;&quot;;

            for (const address of Client.writePage.to) {
                const element = document.createElement(&quot;p&quot;);

                element.innerHTML = address;
                element.addEventListener(&quot;click&quot;, _ =&amp;gt; Client.writePage.removeReceiver(address));

                $ELEMENTS[&quot;write.list.to&quot;].element.appendChild(element);
            }
        },


        removeReceiver(address) {
            Client.writePage.to.delete(address);
            Client.writePage.showReceivers();
        },
        
        // ...
        
    },
    
    // ...
    
};

// ...&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;251&quot; data-origin-height=&quot;192&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpLtn4/btsIRw1KuJq/8f5SG573rOHRH6q8aCBSuK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpLtn4/btsIRw1KuJq/8f5SG573rOHRH6q8aCBSuK/img.png&quot; data-alt=&quot;받는 메일 리스트 구현&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpLtn4/btsIRw1KuJq/8f5SG573rOHRH6q8aCBSuK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbpLtn4%2FbtsIRw1KuJq%2F8f5SG573rOHRH6q8aCBSuK%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;251&quot; height=&quot;192&quot; data-origin-width=&quot;251&quot; data-origin-height=&quot;192&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;i&gt;writePage.onFileInput(event)&lt;/i&gt; 메서드를 구현한다.&lt;/p&gt;
&lt;pre id=&quot;code_1722154717565&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js

// ...

const Client = {
    // ...
    writePage: {
        
        // ...
        files: [],
        
        // ...
        
        onFileInput(event) {
            for (const file of event.target.files) {
                const fr = new FileReader();

                fr.onload = () =&amp;gt; {
                    const filename = file.name;
                    const content = fr.result.split(&quot;,&quot;)[1];

                    Client.writePage.files.push({ filename, content });
                    Client.writePage.showFiles();
                }

                fr.readAsDataURL(file);
            }
        },


        showFiles() {
            $ELEMENTS[&quot;write.list.attachments&quot;].element.innerHTML = &quot;&quot;;

            for (const [i, { filename }] of Object.entries(Client.writePage.files)) {
                const element = document.createElement(&quot;p&quot;);

                element.innerHTML = filename;
                element.addEventListener(&quot;click&quot;, _ =&amp;gt; Client.writePage.removeFile(i));

                $ELEMENTS[&quot;write.list.attachments&quot;].element.appendChild(element);
            }

            console.log(Client.writePage.files);
        },


        removeFile(idx) {
            Client.writePage.files = Client.writePage.files.filter((v, i) =&amp;gt; i != idx);
            Client.writePage.showFiles();
        },
        
        // ...
        
    },
    
    // ...
    
};

// ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 에디터에 관련된 이벤트 리스너들을 구현한다. ..&lt;i&gt;writePage.initEditor()&lt;/i&gt;를 먼저 구현해 본다. contenteditable 속성이 적용되어 있는 div 개체는 &lt;i&gt;document.&lt;s&gt;execCommand&lt;/s&gt;(command)&lt;/i&gt;으로 폰트, 크기 등을 설정할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1722156001763&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js

// ...

const Client = {
    // ...
    writePage: {
        
        // ...
        
        initEditor() {
            const q = s =&amp;gt; document.querySelectorAll(s);
            const setStyle = c =&amp;gt; document.execCommand(c) || focusEditor();
            const focusEditor = _ =&amp;gt; editor.focus({ preventScroll: true });

            const editor = $ELEMENTS[&quot;write.editor&quot;];

            const boldBtn = q(&quot;#bold-btn&quot;)[0];
            const italicBtn = q(&quot;#italic-btn&quot;)[0];
            const underlineBtn = q(&quot;#underline-btn&quot;)[0];
            const strikeBtn = q(&quot;#line-through-btn&quot;)[0];
            const textSizeBtns = q(&quot;.text-size-btn&quot;);

            boldBtn.addEventListener(&quot;click&quot;, _ =&amp;gt; setStyle(&quot;bold&quot;));
            italicBtn.addEventListener(&quot;click&quot;, _ =&amp;gt; setStyle(&quot;italic&quot;));
            underlineBtn.addEventListener(&quot;click&quot;, _ =&amp;gt; setStyle(&quot;underline&quot;));
            strikeBtn.addEventListener(&quot;click&quot;, _ =&amp;gt; setStyle(&quot;strike&quot;));

            textSizeBtns.forEach(v =&amp;gt; {
                v.onclick = _ =&amp;gt;  {
                    document.execCommand(&quot;fontSize&quot;, false, v.getAttribute(&quot;data-prop&quot;));
                };
            });
        },
        
        // ...
        
    },
    
    // ...
    
};

// ...&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;301&quot; data-origin-height=&quot;310&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/nRFkC/btsIPTqqUEq/5UMfza09nEAgxhcXmtd071/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/nRFkC/btsIPTqqUEq/5UMfza09nEAgxhcXmtd071/img.png&quot; data-alt=&quot;내용을 작성하고, 스타일을 적용할 수 있다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/nRFkC/btsIPTqqUEq/5UMfza09nEAgxhcXmtd071/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FnRFkC%2FbtsIPTqqUEq%2F5UMfza09nEAgxhcXmtd071%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;301&quot; height=&quot;310&quot; data-origin-width=&quot;301&quot; data-origin-height=&quot;310&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;내용을 수정할 때마다 textarea에 수정된 내용이 반영되도록, 정확히는 textarea의 내용과 contenteditable div의 내용을 동기화하도록 ..&lt;i&gt;writePage.onBodyInput(event)&lt;/i&gt; 메서드를 작성한다.&lt;/p&gt;
&lt;pre id=&quot;code_1722156985867&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js

// ...

const Client = {
    // ...
    writePage: {
        
        // ...
        
        onBodyInput(event) {
            const isTextArea = event.target instanceof HTMLTextAreaElement;
            const html = event.target.value ?? event.target.innerHTML;

            if (isTextArea)
                $ELEMENTS[&quot;write.editor&quot;].element.innerHTML = html;
            else {
                $ELEMENTS[&quot;write.input.body&quot;].element.value = html;
            }
        },
        
        // ...
        
    },
    
    // ...
    
};

// ...&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;518&quot; data-origin-height=&quot;394&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcMsuK/btsIPZRAzto/TWMljfTkkRHWVEBAAwUmW1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcMsuK/btsIPZRAzto/TWMljfTkkRHWVEBAAwUmW1/img.png&quot; data-alt=&quot;각 내용이 잘 반영된다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcMsuK/btsIPZRAzto/TWMljfTkkRHWVEBAAwUmW1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcMsuK%2FbtsIPZRAzto%2FTWMljfTkkRHWVEBAAwUmW1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;518&quot; height=&quot;394&quot; data-origin-width=&quot;518&quot; data-origin-height=&quot;394&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;i&gt;..writePage.send()&lt;/i&gt;를 작성함으로써 메일 전송 기능을 마무리한다.&lt;/p&gt;
&lt;pre id=&quot;code_1722157620327&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js

// ...

const Client = {
    // ...
    writePage: {
        
        // ...
        
        async send() {
            const subject = $ELEMENTS[&quot;write.input.subject&quot;].element.value;
            const to = [...Client.writePage.to];
            const attachments = Client.writePage.files;
            const html = $ELEMENTS[&quot;write.input.body&quot;].element.value;

            if (subject.trim() == &quot;&quot; || to.length == 0 || html.trim() == &quot;&quot;)
                return;

            await sendRequest(POST, &quot;/endpoints/mails&quot;, {
                to, subject, html, attachments
            });
            location.reload();
        }
        
        // ...
        
    },
    
    // ...
    
};

// ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이대로 메일을 전송하려고 시도하면 오류가 발생하는데, 이는 엔드포인트가 받는 사람을 한 명으로 상정하고 작성되었기 때문이다. 따라서, 받는 사람이 여러 명일 수 있도록 엔드포인트를 수정해 준다.&lt;/p&gt;
&lt;pre id=&quot;code_1722157763765&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// route/endpoint.route.js

// ...

router.post(&quot;/mails&quot;, async (req, res) =&amp;gt; {
	try {
    	// ...
        
        if (to.length == 0 || [ subject, html ].some(e =&amp;gt; e == null || e.trim() == ''))
            return sendResponse(res, 400, &quot;Bad Request&quot;);
        
        // ...
    } catch (e) {
    	// ...
    }
}

// ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후, 메일을 전송하여 본다. 받는 사람과 첨부파일을 설정하여 잘 전송되는지 확인한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;367&quot; data-origin-height=&quot;659&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xC4Lh/btsIQ3TboP7/Yq6j3HVtE8DkT5EyIF1AaK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xC4Lh/btsIQ3TboP7/Yq6j3HVtE8DkT5EyIF1AaK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xC4Lh/btsIQ3TboP7/Yq6j3HVtE8DkT5EyIF1AaK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxC4Lh%2FbtsIQ3TboP7%2FYq6j3HVtE8DkT5EyIF1AaK%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;367&quot; height=&quot;659&quot; data-origin-width=&quot;367&quot; data-origin-height=&quot;659&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;441&quot; data-origin-height=&quot;500&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cGECDX/btsIO4lO4UZ/CG5UmrQomKLgOKK3yMOyTK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cGECDX/btsIO4lO4UZ/CG5UmrQomKLgOKK3yMOyTK/img.png&quot; data-alt=&quot;메일이 성공적으로 전송되는 모습을 볼 수 있다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cGECDX/btsIO4lO4UZ/CG5UmrQomKLgOKK3yMOyTK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcGECDX%2FbtsIO4lO4UZ%2FCG5UmrQomKLgOKK3yMOyTK%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;441&quot; height=&quot;500&quot; data-origin-width=&quot;441&quot; data-origin-height=&quot;500&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;메일이 성공적으로 전송되는 모습을 볼 수 있다.&lt;/figcaption&gt;
&lt;/figure&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;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게, 웹메일 클라이언트의 프로토타입이 완성되었다. 앞으로는 조금의 UI/UX 디자인을 적용하여 GitHub에 소스 코드를 공개하고 누구나 사용할 수 있도록 만들 생각이므로, 많은 관심 부탁드린다. 이후의 UI/UX, 또는 추가 과정을 작업하는 개발일지는 누구나 볼 수 있게 공개할 생각은 아직 없지만, 추후에 공개할 수 있다면 글로 작성해 보도록 노력해 보겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 프로젝트가 당신에게 유익하다거나, 참고가 되는 프로젝트라고 생각된다면, 아래의 공감 버튼을 한 번 클릭해 주시길 부탁드린다. 여러분의 의견을 바탕으로 이 글이 유익한지, 실제로 독자에게 도움이 되었는지 다시금 생각해 볼 수 있고, 이는 곧 양질의 컨텐츠를 생산하는 것으로 이어진다. 앞으로 여러분들에게 도움이 되는 글을 작성하기 위해 여러분들의 도움이 필요하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글에 오류가 있거나, 지나치게 편향적이거나 주관적인 서술이 존재한다고 생각되거나, 이해를 방해하는 내용, 문체 등을 포함하고 있다고 느껴진다면 언제든지 댓글로 피드백을 작성해 주시어 오류 수정에 도움을 주실 수 있다. 6부 분량에 이르는 이 긴 과정을 읽어주셔서 진심으로 감사드린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>개발일지/웹</category>
      <category>CONTENTEDITABLE</category>
      <category>EJS</category>
      <category>node.js</category>
      <category>REST API</category>
      <category>RESTful API</category>
      <category>textarea</category>
      <category>백엔드 통신</category>
      <category>웹메일</category>
      <category>웹메일 클라이언트 개발하기</category>
      <author>데키유</author>
      <guid isPermaLink="true">https://playerdecuple.tistory.com/12</guid>
      <comments>https://playerdecuple.tistory.com/12#entry12comment</comments>
      <pubDate>Sun, 28 Jul 2024 18:19:56 +0900</pubDate>
    </item>
    <item>
      <title>웹메일들은 어떻게 메일을 보여주는가: 웹메일 클라이언트 개발하기 5</title>
      <link>https://playerdecuple.tistory.com/11</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;웹메일 클라이언트 개발일지가 벌써 다섯 편이나 작성되었다. 필자로서도, 기나긴 내용의 끝이 다가오기에 더 열정적으로 개발하고 있다. 그중에서도 이번 글은 웹메일 서비스에서 메일 리스트와 메일 상세, 즉 메일 데이터를 가공하여 사용자에게 보여주는 페이지를 작성하는 내용을 다룬다. 이 글에서 요하는 '사용자의 로그인 여부 확인' 등의 내용은 &lt;a href=&quot;https://log.dekiyu.xyz/10&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이전 글&lt;/a&gt;에서, 메일 데이터를 요청받고 응답하는 백엔드에 관한 내용은 &lt;a href=&quot;https://log.dekiyu.xyz/6&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이 글&lt;/a&gt;에서 확인해 볼 수 있다. 만약 당신이 필자가 개발하는 이 프로젝트에 관해 관심이 있거나, SMTP 및 POP3를 활용한 클라이언트 개발에 참고하려는 경우 '&lt;a href=&quot;https://log.dekiyu.xyz/tag/%EC%9B%B9%EB%A9%94%EC%9D%BC%20%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8%20%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;웹메일 클라이언트 개발하기&lt;/a&gt;' 태그 페이지에 방문해 보는 것도 좋다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 글은 하나의&amp;nbsp;&lt;b&gt;개발일지&lt;/b&gt;로서, 개발을 위해 필요했던 상식, 지식, 코드 등을 정리해 놓은 글이다. 강의 목적의 글이 아니기에 HTML, CSS, JavaScript와 같은 기초적 프론트엔드 웹 지식과 Node.js, Express.js와 같은 기초적 JavaScript 기반 백엔드 웹 지식을 요한다. 따라서, 개발자가 아니거나, 프로그래밍에 관심이 없는 사람이라면 이 글이 당신의 의문점이나 관심사에 큰 도움을 주지 못할 수도 있다. 당신이 만약 기술은 있지만 아이디어가 없는 사람이거나, 웹 기술이나 이메일 관련 기술에 대해 학습이 필요한 사람이라면 이 글이 도움이 될 수도 있다.&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;Step 12.&amp;nbsp;&lt;br /&gt;프론트엔드: &lt;span style=&quot;color: #ff6347;&quot;&gt;메일 리스트&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이전 글에서 작성했던 $ELEMENTS 객체 상수를 기억하고 있는가? 이 상수는 &lt;i&gt;Node.querySelector(selector)&lt;/i&gt; 메서드의 사용 횟수를 줄이고 코드 가독성 향상을 위해 작성한 것으로, DOM 개체의 기본적인 정보와 해당 개체에 할당되어야 할 이벤트 리스너들을 전부 담고 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1721738920942&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const $ELEMENTS = {};

// ...

Object.assign($ELEMENTS, {
    &quot;login.username&quot;: {
        element: document.querySelector(&quot;#login-id&quot;)
    },
    &quot;login.password&quot;: {
        element: document.querySelector(&quot;#login-pass&quot;)
    },
    &quot;login.submit_button&quot;: {
        element: document.querySelector(&quot;#login-btn&quot;),
        eventListeners: [
            {
                event: &quot;click&quot;,
                eventListener: Client.loginPage.submit
            }
        ]
    },
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, 필자가 지금 개발하려는 페이지는 페이지가 로드되는 즉시 실행되어야 한다. 이를 위해서는 페이지 로드 시 실행되는 이벤트 리스너를 사용해야 할 것이다. 예를 들어서 DOMContentLoaded 이벤트가 있겠다. 이미 필자가 작성한 클라이언트 스크립트에서도 해당 이벤트 리스너를 사용한 모습을 볼 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1721739208909&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// everyday.client.js

// ...

document.addEventListener(&quot;DOMContentLoaded&quot;, () =&amp;gt; {
    Client.init();
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만, DOMContentLoaded 이벤트에 대해 리스너를 작성하기에는 문제가 한 개 있다. 이는 DOMContentLoaded가 완료되어 이벤트가 발생한 뒤에는 &lt;i&gt;Node.addEventListener(event, callback) &lt;/i&gt;메서드를 통해 새 이벤트 리스너를 할당하더라도 동작하지 않는다. 즉, 일회성 이벤트이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;929&quot; data-origin-height=&quot;192&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/8zHBi/btsIJsAaWQf/B2dZOgSczvNixuC7xsRLMK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/8zHBi/btsIJsAaWQf/B2dZOgSczvNixuC7xsRLMK/img.png&quot; data-alt=&quot;DOMContentLoaded 이벤트가 일회성이기에 이벤트 리스너가 호출되지 않는다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/8zHBi/btsIJsAaWQf/B2dZOgSczvNixuC7xsRLMK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F8zHBi%2FbtsIJsAaWQf%2FB2dZOgSczvNixuC7xsRLMK%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;929&quot; height=&quot;192&quot; data-origin-width=&quot;929&quot; data-origin-height=&quot;192&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;DOMContentLoaded 이벤트가 일회성이기에 이벤트 리스너가 호출되지 않는다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그렇다고, $ELEMENTS 객체에 할당하지 않고 Client를 초기화하는 이벤트 리스너에 코드를 추가하자니, $ELEMENT 상수를 정의한 의의를 해치는 것이라는 느낌이 든다. 그래서 필자가 생각한 방법은, $ELEMENTS 내 요소의 event 값이 특정 값일 때, eventListener를 노드에 할당하지 않고 그 자리에서 즉시 호출하는 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1721739715466&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js

    initTable() {
        Object.assign($ELEMENTS, {
            // ...

            &quot;mails.load&quot;: {
                element: null,
                eventListeners: [
                    {
                        event: &quot;initialize&quot;,
                        eventListener: Client.mailListPage.load
                    }
                ]
            }
        });
    },

// ...

    listen() {
        for (const v of Object.values($ELEMENTS)) {
            for (const listener of (v.eventListeners ?? [])) {
                if (listener.event == &quot;initialize&quot;)
                    listener.eventListener();
                else
                    v.element.addEventListener(listener.event, listener.eventListener);
            }
        }
    }
    
// ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다만, 이렇게 작성한다면 요청한 페이지에 대한 이벤트 리스너뿐만 아니라 event가 &quot;initialize&quot;인 다른 모든 이벤트 리스너가 호출될 것이다. 즉, 필요하지 않은 기능이 작동하여 페이지에 예기치 못한 영향을 끼칠 수 있다는 것이다. 따라서, 코드를 아래와 같이 수정한다.&lt;/p&gt;
&lt;pre id=&quot;code_1721739964855&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js

// ...

    initTable() {
        Object.assign($ELEMENTS, {
            &quot;login.username&quot;: {
                element: document.querySelector(&quot;#login-id&quot;)
            },
            &quot;login.password&quot;: {
                element: document.querySelector(&quot;#login-pass&quot;)
            },
            &quot;login.submit_button&quot;: {
                element: document.querySelector(&quot;#login-btn&quot;),
                eventListeners: [
                    {
                        event: &quot;click&quot;,
                        eventListener: Client.loginPage.submit
                    }
                ]
            },

            &quot;mails.load&quot;: {
                element: document.querySelector(&quot;#mails-page&quot;), // &amp;lt;-- 추가
                eventListeners: [
                    {
                        event: &quot;initialize&quot;,
                        eventListener: Client.mailListPage.load
                    }
                ]
            }
        });
    },


    listen() {
        for (const v of Object.values($ELEMENTS)) {
            if (v.element == null) // &amp;lt;-- 추가
                continue;

            for (const listener of (v.eventListeners ?? [])) {
                if (listener.event == &quot;initialize&quot;)
                    listener.eventListener();
                else
                    v.element.addEventListener(listener.event, listener.eventListener);
            }
        }
    }
    
// ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지에 해당하는 DOM 개체가 있는지 확인한 다음, 개체가 존재할 경우에만 이벤트 리스너 할당 및 실행 코드가 동작하게끔 설계해 둠으로써, 불필요한 경우에도 이벤트 리스너가 실행되는 것을 막았다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 리스너로서 사용하려는 &lt;i&gt;Client.mailListPage.load(startIdx?, size?)&lt;/i&gt; 메서드를 작성한다. Client 객체 내에 mailListPage 객체를 만들고, 그 안에 메서드를 정의한다.&lt;/p&gt;
&lt;pre id=&quot;code_1721740223239&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js

// ...

const Client = {
	
    // ...
    
    mailListPage: {

        async load(startIdx = 0, size = 20) {
            const mailList = await sendRequest(GET, `/endpoints/mails?start=${startIdx}&amp;amp;size=${size}`);
            console.log(mailList);
        }

    },
    
    // ...

};

// ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 메서드는 메일 리스트 엔드포인트에 시작 메일 인덱스와 응답 크기를 담은 요청을 보내고 이에 응하는 응답을 받는 역할을 한다. 따라서 엔드포인트에 요청을 전송하기 위해 이전 글에서 정의했던 &lt;i&gt;sendRequest(method, uri, body?)&lt;/i&gt; 함수를 사용할 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메서드의 내용은 나중에 구현하고, 일단은 /mails로 접근하면 메서드가 실행되는지 확인해보기 위해서 ejs 파일을 만들고, 라우트에 할당해 본다.&lt;/p&gt;
&lt;pre id=&quot;code_1721777464617&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;!-- view/mail_list.ejs --&amp;gt;

&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;zxx&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
    &amp;lt;title&amp;gt;Document&amp;lt;/title&amp;gt;
    
    &amp;lt;script src=&quot;/assets/scripts/everyday.client.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    &amp;lt;div class=&quot;wrap&quot; id=&quot;mails-page&quot;&amp;gt;
        &amp;lt;table&amp;gt;
            &amp;lt;thead&amp;gt;
                &amp;lt;tr&amp;gt;
                    &amp;lt;th&amp;gt;발신자&amp;lt;/th&amp;gt;
                    &amp;lt;th&amp;gt;제목&amp;lt;/th&amp;gt;
                    &amp;lt;th&amp;gt;첨부파일&amp;lt;/th&amp;gt;
                    &amp;lt;th&amp;gt;수신 시각&amp;lt;/th&amp;gt;
                &amp;lt;/tr&amp;gt;
            &amp;lt;/thead&amp;gt;

            &amp;lt;tbody id=&quot;mail-list&quot;&amp;gt;

            &amp;lt;/tbody&amp;gt;
        &amp;lt;/table&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1721777502790&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// route/public.route.js

// ...

router.use(&quot;/mails&quot;, checkUserPermission);
router.get(&quot;/mails&quot;, (req, res) =&amp;gt; {
    // Mailbox
    res.render(&quot;mail_list&quot;);
});

// ...&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1531&quot; data-origin-height=&quot;843&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/vydXY/btsII0jEES8/KmipCcUcNtYK0LUaTxin6K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/vydXY/btsII0jEES8/KmipCcUcNtYK0LUaTxin6K/img.png&quot; data-alt=&quot;메일 내용이 잘 표시된다(모자이크).&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/vydXY/btsII0jEES8/KmipCcUcNtYK0LUaTxin6K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FvydXY%2FbtsII0jEES8%2FKmipCcUcNtYK0LUaTxin6K%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;1531&quot; height=&quot;843&quot; data-filename=&quot;blob&quot; data-origin-width=&quot;1531&quot; data-origin-height=&quot;843&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;작성한 ejs 파일의 구조에 따라서, &lt;i&gt;load&lt;/i&gt; 메서드에서는 메일의 내용을 받아 가공하여 tbody&lt;i&gt;#mail-list&lt;/i&gt;에 적절히 삽입해 주어야 한다. 받는 응답이 JSON 배열 구조이므로, &lt;i&gt;Array.map(callback)&lt;/i&gt; 메서드를 사용하여 HTML 구조를 만들고 이를 삽입하는 방향으로 생각해 본다. 엔드포인트에서 돌려주는 응답은 다음과 같은 형식을 가진다.&lt;/p&gt;
&lt;pre id=&quot;code_1721783993116&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;[
    {
        idx: 1, // 메일 인덱스
        from: {
            value: [
                {
                    name: &quot;&quot;, // 보내는 사람 이름
                    address: &quot;&quot; // 보내는 사람 메일
                }
            ]
        },
        subject: &quot;&quot;, // 제목
        hasAttachment: true // 첨부파일 여부
    }
]&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 형식을 이용하여 메일 리스트를 보여주는 코드를 작성한다.&lt;/p&gt;
&lt;pre id=&quot;code_1721779371614&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js
// ...

const getDateString = (rawDate) =&amp;gt; {
    const date = new Date(rawDate);
    let dateStr = `${date.getFullYear()}. ${date.getMonth() + 1}. ${date.getDate()}. `;
    dateStr += `${date.getHours().toString().padStart(2, &quot;0&quot;)}:${date.getMinutes().toString().padStart(2, &quot;0&quot;)}`;

    return dateStr;
};


const Client = {

    // ...
    mailListPage: {

        async load(startIdx = 0, size = 20) {
            const raw = await sendRequest(GET, `/endpoints/mails?start=${startIdx}&amp;amp;size=${size}`);
            const mailList = JSON.parse(raw);

            const trArr = mailList.map((mail) =&amp;gt; {
                const sendersLength = mail.from.value.length;
                const firstSender = mail.from.value[0];
                let firstSenderName = firstSender.name;
                const hasMultipleSenders = sendersLength &amp;gt; 1;

                if (firstSenderName.trim() == &quot;&quot;)
                    firstSenderName = firstSender.address;

                const date = getDateString(mail.date);
                const hasAttachment = mail.hasAttachment;

                return `
                    &amp;lt;tr&amp;gt;
                        &amp;lt;td&amp;gt;${firstSenderName} ${hasMultipleSenders ? ` + ${sendersLength - 1}` : &quot;&quot;}&amp;lt;/td&amp;gt;
                        &amp;lt;td&amp;gt;&amp;lt;a href=&quot;/mails/${mail.idx}&quot;&amp;gt;${mail.subject}&amp;lt;/a&amp;gt;&amp;lt;/td&amp;gt;
                        &amp;lt;td&amp;gt;${hasAttachment ? &quot;✔️&quot; : &quot;❌&quot;}&amp;lt;/td&amp;gt;
                        &amp;lt;td&amp;gt;${date}&amp;lt;/td&amp;gt;
                    &amp;lt;/tr&amp;gt;
                `;
            });

            $ELEMENTS[&quot;mails.list&quot;].element.innerHTML = trArr.join(&quot;&quot;);
        }
       
    },
    
    // ...
    
    initTable() {
    	Object.assign($ELEMENTS, {
            // ...
            &quot;mails.list&quot;: {
            	element: document.querySelector(&quot;#mail-list&quot;); // tbody
            }
        });
    }
    
    // ...

};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 로그인 후 /mails에 접근해 보면 메일 리스트가 로드되는 모습을 볼 수 있다. 발신인의 이름이 없을 경우, 발신인의 메일 주소를 표시하는 모습, 첨부파일 여부가 잘 표시되는 모습, 받은 날짜 또한 형식에 맞추어 표시되는 모습을 전부 볼 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;526&quot; data-origin-height=&quot;91&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c6GhtZ/btsIKaeA8PI/sMxk7U5ympd24TCKcMzCr0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c6GhtZ/btsIKaeA8PI/sMxk7U5ympd24TCKcMzCr0/img.png&quot; data-alt=&quot;메일 리스트가 의도된 대로 표시된다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c6GhtZ/btsIKaeA8PI/sMxk7U5ympd24TCKcMzCr0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc6GhtZ%2FbtsIKaeA8PI%2FsMxk7U5ympd24TCKcMzCr0%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;526&quot; height=&quot;91&quot; data-origin-width=&quot;526&quot; data-origin-height=&quot;91&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;메일 리스트가 의도된 대로 표시된다.&lt;/figcaption&gt;
&lt;/figure&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;Step 13.&amp;nbsp;&lt;br /&gt;프론트엔드: &lt;span style=&quot;color: #ff6347;&quot;&gt;메일 상세&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메일 리스트에 표시되는 링크를 클릭하면 메일 상세 페이지로 이동한다. 이 페이지는 제목, 발신자, 날짜, 내용, 첨부 파일 등 메일에 포함된 여러 데이터들을 사용자에게 표시한다. 메일 리스트 페이지를 만들 때처럼, ejs 파일을 만들고 라우트에 할당한다.&lt;/p&gt;
&lt;pre id=&quot;code_1721818760860&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;!-- view/mail_detail.ejs --&amp;gt;

&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;zxx&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
    &amp;lt;title&amp;gt;Document&amp;lt;/title&amp;gt;
    
    &amp;lt;script src=&quot;/assets/scripts/everyday.client.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    &amp;lt;div class=&quot;wrap&quot; id=&quot;mail-detail-page&quot;&amp;gt;
        &amp;lt;a href=&quot;/mails&quot;&amp;gt;Back&amp;lt;/a&amp;gt;

        &amp;lt;h1 id=&quot;mail-title&quot;&amp;gt;&amp;lt;/h1&amp;gt;
        &amp;lt;p&amp;gt;&amp;lt;b&amp;gt;From:&amp;lt;/b&amp;gt; &amp;lt;span id=&quot;mail-sender&quot;&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/p&amp;gt;
        &amp;lt;p&amp;gt;&amp;lt;b&amp;gt;To:&amp;lt;/b&amp;gt; &amp;lt;span id=&quot;mail-receiver&quot;&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/p&amp;gt;
        &amp;lt;p&amp;gt;&amp;lt;b&amp;gt;Date:&amp;lt;/b&amp;gt; &amp;lt;span id=&quot;mail-date&quot;&amp;gt;&amp;lt;/span&amp;gt;&amp;lt;/p&amp;gt;
        &amp;lt;hr&amp;gt;
        &amp;lt;iframe id=&quot;mail-content&quot; frameborder=&quot;0&quot;&amp;gt;&amp;lt;/iframe&amp;gt;
        &amp;lt;div id=&quot;mail-attachments&quot;&amp;gt;&amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1721818784244&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// route/public.route.js
// ...

router.use(&quot;/mails/:mail_idx&quot;, checkUserPermission);
router.get(&quot;/mails/:mail_idx&quot;, (req, res) =&amp;gt; {
    // Mail detail
    res.render(&quot;mail_detail&quot;);
});

// ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;메일 리스트 페이지의 로드 이벤트 리스너를 작성했던 것과 같이, $ELEMENTS 상수에 DOM 개체와 그에 대한 이벤트 리스너를 명시한다.&lt;/p&gt;
&lt;pre id=&quot;code_1721818660228&quot; class=&quot;dart&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;// assets/clients/everyday.client.js
// ...

const Client = {
	// ...
    initTable() {
    	Object.assign($ELEMENTS, {
            // ...
            &quot;mails.detail.load&quot;: {
                element: document.querySelector(&quot;#mail-detail-page&quot;),
                eventListeners: [
                    {
                        event: &quot;initialize&quot;,
                        eventListener: Client.mailDetailPage.load
                    }
                ]
            }
        });
    }
	// ...
};

//...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 &lt;i&gt;Client.mailDetailPage.load&lt;/i&gt; 메서드를 구현한다. 엔드포인트(/endpoints/mails/:mail_idx)에 메일 데이터를 요청하여 돌려받은 응답을 사용자에게 표시하는 역할을 가진다. 물론, 해당 메서드에서 사용할 DOM 개체들을 $ELEMENTS 상수에 명시해 주어야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1721823505644&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js
// ...

const Client = {

    // ...
    initTable() {
    	Object.assign($ELEMENTS, {
            // ...
            &quot;mail.detail.subject&quot;: {
                element: document.querySelector(&quot;#mail-title&quot;)
            },
            &quot;mail.detail.from&quot;: {
                element: document.querySelector(&quot;#mail-sender&quot;)
            },
            &quot;mail.detail.to&quot;: {
                element: document.querySelector(&quot;#mail-receiver&quot;)
            },
            &quot;mail.detail.date&quot;: {
                element: document.querySelector(&quot;#mail-date&quot;)
            },
            &quot;mail.detail.content&quot;: {
                element: document.querySelector(&quot;#mail-content&quot;)
            },
            &quot;mail.detail.attachments&quot;: {
                element: document.querySelector(&quot;#mail-attachments&quot;)
            }
        });
    }
    // ...

};

// ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본격적으로 &lt;i&gt;Client.mailDetailPage.load&lt;/i&gt; 메서드를 작성한다. 이 메서드의 내용을 작성하기 위해 필요한 엔드포인트 구조는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1721824131226&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
    attachments: [
    	{
            checksum: &quot;&quot;,
            content: {
            	type: &quot;Buffer&quot;,
                data: [] // 버퍼 데이터 등, type에 따른 raw 데이터
            },
            contentDisposition: &quot;attachment&quot;,
            contentType: &quot;audio/mpeg&quot;, // MIME 타입
            filename: &quot;0000.mp3&quot;, // 파일 이름
            headers: {},
            size: 0 // 파일 크기
        }
    ],
    from: {
    	value: [
            {
            	name: &quot;&quot;, // 발신자 이름
                address: &quot;&quot; // 발신자 메일 주소
            }
        ],
        html: &quot;&quot;, // HTML 형식으로 표시된 발신자 리스트
        text: &quot;&quot;, // 텍스트 형식으로 표시된 발신자 리스트
    },
    to: {
    	value: [
            {
            	name: &quot;&quot;, // 수신자 이름
                address: &quot;&quot; // 수신자 메일 주소
            }
        ],
        html: &quot;&quot;, // HTML 형식으로 표시된 수신자 리스트
        text: &quot;&quot;, // 텍스트 형식으로 표시된 수신자 리스트
    },
    headerLines: [
    	// 메일 헤더 Raw 목록
    ],
    headers: [
    	// 메일 헤더 목록
    ],
    html: &quot;&quot;, // 메일 내용 HTML
    idx: 1, // 메일 인덱스
    messageId: &quot;&quot;, // 메일 메시지 ID
    subject: &quot;&quot;, // 메일 제목
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위 구조에서 필요한 부분만을 추출하여 DOM 개체의 내용에 삽입함으로써 메일 상세 표시 기능을 완성한다. 메일의 첨부 파일은 데이터를 Blob으로 읽어 들여 Object URL로 만들고, a 태그의 href 속성에 적용함으로써 다운로드할 수 있게끔 만들면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1721823588271&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js
// ...

const Client = {
    // ...
    mailDetailPage: {

        async load() {
            const idx = window.location.pathname.split(&quot;/&quot;)[2];
            const raw = await sendRequest(GET, `/endpoints/mails/${idx}`);
            const detail = JSON.parse(raw);

            const from = detail.from.html;
            const to = detail.to.html;

            const { subject, date, attachments } = detail;
            const dateStr = getDateString(date);
            const body = detail.html;

            const attachmentLinks = attachments.map(attachment =&amp;gt; {
                const blob = new Blob(attachment.content.data, {
                    type: attachment.contentType
                });
                const url = URL.createObjectURL(blob);
                const filename = attachment.filename;

                return `&amp;lt;a download=&quot;${filename}&quot; href=&quot;${url}&quot;&amp;gt;${filename}&amp;lt;/a&amp;gt;`
            });

            $ELEMENTS[&quot;mail.detail.subject&quot;].element.innerHTML = subject;
            $ELEMENTS[&quot;mail.detail.from&quot;].element.innerHTML = from;
            $ELEMENTS[&quot;mail.detail.to&quot;].element.innerHTML = to;
            $ELEMENTS[&quot;mail.detail.date&quot;].element.innerHTML = dateStr;
            $ELEMENTS[&quot;mail.detail.attachments&quot;].element.innerHTML = attachmentLinks.join(&quot;&quot;);

            const iframe = $ELEMENTS['mail.detail.content'].element;
            const iframeDoc = iframe.contentWindow.document;

            const div = iframeDoc.createElement(&quot;div&quot;);
            div.innerHTML = body;

            iframeDoc.body.appendChild(div);
        }

    },
    // ...
}
// ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;iframe의 내용에는 &lt;i&gt;document.createElement(tagSelector)&lt;/i&gt;, &lt;i&gt;document.appendChild(node)&lt;/i&gt; 메서드를 활용하여 받은 메일 데이터의 HTML 데이터를 삽입한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;631&quot; data-origin-height=&quot;424&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c31fZW/btsIMkusNwZ/wyGfSCMiXT9cDqxfoaEnl0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c31fZW/btsIMkusNwZ/wyGfSCMiXT9cDqxfoaEnl0/img.png&quot; data-alt=&quot;노래방 음악 전송 기능을 사용한 예시.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c31fZW/btsIMkusNwZ/wyGfSCMiXT9cDqxfoaEnl0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc31fZW%2FbtsIMkusNwZ%2FwyGfSCMiXT9cDqxfoaEnl0%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;631&quot; height=&quot;424&quot; data-origin-width=&quot;631&quot; data-origin-height=&quot;424&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;328&quot; data-origin-height=&quot;73&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cG1XAV/btsINvuZ0cT/Eka0fGbeu6k0w5WgZ8tfGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cG1XAV/btsINvuZ0cT/Eka0fGbeu6k0w5WgZ8tfGk/img.png&quot; data-alt=&quot;다운로드가 잘 작동한다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cG1XAV/btsINvuZ0cT/Eka0fGbeu6k0w5WgZ8tfGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcG1XAV%2FbtsINvuZ0cT%2FEka0fGbeu6k0w5WgZ8tfGk%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;328&quot; height=&quot;73&quot; data-origin-width=&quot;328&quot; data-origin-height=&quot;73&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;다운로드가 잘 작동한다.&lt;/figcaption&gt;
&lt;/figure&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;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이로써 메일 리스트와 메일 상세 기능 구현을 모두 완료했으므로 이제 메일 전송과 관련된 프론트엔드 페이지의 구현만이 남았다. 마찬가지로, ejs 생성과 이벤트 리스너 추가, 그리고 엔드포인트와의 통신만으로 구현해 낼 수 있다.&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;</description>
      <category>개발일지/웹</category>
      <category>Ajax</category>
      <category>EJS</category>
      <category>Express.js</category>
      <category>iframe</category>
      <category>node.js</category>
      <category>REST API</category>
      <category>RESTful API</category>
      <category>vanila js</category>
      <category>웹메일</category>
      <category>웹메일 클라이언트 개발하기</category>
      <author>데키유</author>
      <guid isPermaLink="true">https://playerdecuple.tistory.com/11</guid>
      <comments>https://playerdecuple.tistory.com/11#entry11comment</comments>
      <pubDate>Tue, 23 Jul 2024 22:13:05 +0900</pubDate>
    </item>
    <item>
      <title>로그인 페이지 구현해보기: 웹메일 클라이언트 개발하기 4</title>
      <link>https://playerdecuple.tistory.com/10</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이번에도, 글을 시작하기 전에 &lt;a href=&quot;https://log.dekiyu.xyz/8&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이전 글&lt;/a&gt;을 한 번쯤 읽거나 훑어볼 것을 강력히 권한다. 또한, 이 프로젝트가 어떻게 진행되었는지 궁금하거나 과정에 대해 관심이 있다면, &lt;a href=&quot;https://log.dekiyu.xyz/tag/%EC%9B%B9%EB%A9%94%EC%9D%BC%20%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8%20%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;여기&lt;/a&gt;에 글들을 정리해 두었으니 가볍게 읽어보는 것도 좋다. 이 글은 기초적 웹 기술(HTML+CSS, Node.js, Express.js)에 대한 지식을 요구하고, 그에 대한 예제 코드를 포함하고 있어 &lt;b&gt;프로그래밍에 관심이 없거나 웹 기술을 경험해보지 못한 사람에게는 적절하지 않은 내용&lt;/b&gt;일 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;저번 글에서 엔드포인트 개발을 완료했기에, 사용자에게 페이지를 표시하고 엔드포인트에 요청을 전송, 사용자가 원하는 명령을 처리하는 것이 주 목표이다. 로그인, 메일 목록, 메일 상세 정보, 메일 전송 등을 담당하는 페이지를 각각 만들 계획이다. 차례대로 천천히 개발해 본다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 9.&amp;nbsp;&lt;br /&gt;프론트엔드: &lt;span style=&quot;color: #ff6347;&quot;&gt;라우터 셋&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제, 엔드포인트 라우터가 아닌 프론트엔드를 위한 라우터를 설정해 줄 차례이다. 로그인 페이지를 제외한 모든 페이지는 전부 사용자의 ..session.username의 null 여부를 판단하는 미들웨어를 요한다는 것에 유의하며 코드를 작성한다. 일단, 현재로서 예상 가능한 모든 경우의 라우트를 추가한다.&lt;/p&gt;
&lt;pre id=&quot;code_1721431177979&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// route/public.route.js
const express = require('express');
const router = express.Router();



function checkUserPermission(req, res, next, opposite = false) {
    if (req.session.username == null) {
        if (opposite)
            next();
        else
            res.redirect(&quot;/login&quot;);
            
        return;
    }

    if (!opposite)
        next();
    else
        res.redirect(&quot;/&quot;);
}


router.use(&quot;/login&quot;, (req, res, n) =&amp;gt; checkUserPermission(req, res, n, true));
router.get(&quot;/login&quot;, (req, res) =&amp;gt; {
    // Login
});


router.use(&quot;/&quot;, checkUserPermission);
router.get(&quot;/&quot;, (req, res) =&amp;gt; {
    // Dashboard
    res.send(&quot;&quot;);
});


router.use(&quot;/mails&quot;, checkUserPermission);
router.get(&quot;/mails&quot;, (req, res) =&amp;gt; {
    // Mailbox
});


router.use(&quot;/mails/:mail_idx&quot;, checkUserPermission);
router.get(&quot;/mails/:mail_idx&quot;, (req, res) =&amp;gt; {
    // Mail detail
});


router.use(&quot;/create&quot;, checkUserPermission);
router.get(&quot;/create&quot;, (req, res) =&amp;gt; {
    // Create a new email
});


router.use(&quot;/logout&quot;, checkUserPermission);
router.get(&quot;/logout&quot;, (req, res) =&amp;gt; {
    // Logout?
});


module.exports = router;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 로그인, 대시보드, 메일함, 메일 상세, 메일 전송, 로그아웃에 대한 라우트들을 라우터에 할당한다. 추후 페이지를 만들고 라우트에서 &lt;i&gt;res.render(...)&lt;/i&gt; 등을 통해 렌더링하는 방향으로 코드를 작성하면 된다. 로그인 페이지에 대한 라우트에만 &lt;i&gt;checkUserPermission&lt;/i&gt; 함수의 &lt;i&gt;opposite&lt;/i&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;Step 10.&amp;nbsp;&lt;br /&gt;프론트엔드: &lt;span style=&quot;color: #ff6347;&quot;&gt;로그인&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;checkUserPermission(req, res, next, opposite?)&lt;/i&gt; 함수 정의 부분을 보면, 사용자의 세션 값에 &lt;i&gt;username&lt;/i&gt;이 없고 &lt;i&gt;opposite&lt;/i&gt;가 거짓이라면, res.redirect(uri) 메서드를 호출하여 강제로 로그인 페이지로 이동시키는 것을 볼 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1721431799461&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function checkUserPermission(req, res, next, opposite = false) {
    if (req.session.username == null) {
        if (opposite)
            next();
        else
            res.redirect(&quot;/login&quot;);
        
        return;
    }

    if (!opposite)
        next();
    else
        res.redirect(&quot;/&quot;);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 설정하고 상술한 것과 같이 로그인 페이지 라우트에 적용되는 미들웨어만 opposite 파라미터를 참으로 넘기는 방식을 사용하여 Guest 상태로 페이지에 접근 시 로그인 페이지를 표시하게끔 만든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ejs 파일을 HTML 페이지로서 표시하기 위해 ejs 라이브러리를 설치하고, Express 앱에 포함시킬 필요가 있다. 코드를 적용한 이후에는 &lt;i&gt;res.render(filename)&lt;/i&gt; 메서드를 통해 쉽게 ejs 파일을 보여줄 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1721432359609&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// main.js
// ...

app.set(&quot;view engine&quot;, &quot;ejs&quot;);
app.set(&quot;views&quot;, &quot;./view&quot;);

// ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 ejs를 사용할 수 있게 되었으므로 view 폴더에 login.ejs 파일을 만들고, 템플릿의 기초 내용을 채운다. ejs 템플릿에 대한 문법은 &lt;a href=&quot;https://ejs.co/#features&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;여기&lt;/a&gt;에서 확인할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1721432815038&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;!-- view/login.ejs --&amp;gt;

&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;en&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
    &amp;lt;title&amp;gt;Login&amp;lt;/title&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    &amp;lt;div class=&quot;wrap&quot;&amp;gt;
        &amp;lt;h1&amp;gt;Login&amp;lt;/h1&amp;gt;
        
        &amp;lt;form onsubmit=&quot;return false;&quot; action=&quot;&quot;&amp;gt;    
            &amp;lt;input type=&quot;text&quot; id=&quot;login-id&quot; name=&quot;login-id&quot; required&amp;gt;
            &amp;lt;input type=&quot;password&quot; id=&quot;login-pass&quot; name=&quot;login-pass&quot; required&amp;gt;
            &amp;lt;button id=&quot;login-btn&quot;&amp;gt;Login&amp;lt;/button&amp;gt;
        &amp;lt;/form&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제, &lt;i&gt;res.render&lt;/i&gt; 메서드를 활용하여 로그인 라우트에 접근하면 view/login.ejs 파일을 보여주도록 설정한다.&lt;/p&gt;
&lt;pre id=&quot;code_1721433048277&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// route/public.route.js
// ...

router.use(&quot;/login&quot;, (req, res, n) =&amp;gt; checkUserPermission(req, res, n, true));
router.get(&quot;/login&quot;, (req, res) =&amp;gt; {
    res.render(&quot;login&quot;);
});

// ...&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;481&quot; data-origin-height=&quot;200&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/4oLvU/btsIG8t6Y0G/mf6MRKNrAEGfTEypzqAvf1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/4oLvU/btsIG8t6Y0G/mf6MRKNrAEGfTEypzqAvf1/img.png&quot; data-alt=&quot;로그인 페이지가 잘 표시됨을 확인할 수 있다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/4oLvU/btsIG8t6Y0G/mf6MRKNrAEGfTEypzqAvf1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F4oLvU%2FbtsIG8t6Y0G%2Fmf6MRKNrAEGfTEypzqAvf1%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;481&quot; height=&quot;200&quot; data-origin-width=&quot;481&quot; data-origin-height=&quot;200&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;상단에서 적용한 미들웨어에 따라, 기본 라우트에 접근할 경우(예제의 경우에는 http://localhost/) 로그인 페이지로 자동으로 리다이렉트되는 것도 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제, 로그인 페이지를 엔드포인트와 연결하는 코드를 작성한다. /assets 디렉터리를 만들고, Express.js에 정적 디렉토리임을 명시함으로써 웹 페이지에서 JavaScript 파일을 불러올 수 있도록 만들면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1721433416286&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// main.js
// ...

app.use(&quot;/assets&quot;, express.static(__dirname + &quot;/assets&quot;));

// ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;정적 어셋 디렉터리에 JS 파일을 위한 디렉터리를 생성하고, 그 안에 클라이언트를 담당하는 JS 파일(예제에서는 everyday.client.js)을 생성한다. 이후, 로그인 버튼인 '#login-btn'에 대한 클릭 이벤트 리스너를 작성해줌으로써 로그인 요청을 보낼 수 있도록 만든다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 리스너를 작성하기 전에 요청을 전송하는 함수 &lt;i&gt;sendRequest(method, uri, body?)&lt;/i&gt;를 작성한다.&lt;/p&gt;
&lt;pre id=&quot;code_1721434644577&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js
const sendRequest = (method, uri, body = {}) =&amp;gt; {
    return new Promise((res, rej) =&amp;gt; {
        const xhr = new XMLHttpRequest();

        xhr.onreadystatechange = () =&amp;gt; {
            const { status } = xhr;
            if (xhr.readyState === XMLHttpRequest.DONE) {
                if (status &amp;lt; 400)
                    res(JSON.parse(xhr.responseText));
                else
                    rej(JSON.parse(xhr.responseText));
            }
        };

        xhr.open(method, uri);
        xhr.setRequestHeader(&quot;Content-Type&quot;, &quot;application/json&quot;);
        xhr.send(JSON.stringify(body));
    });
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그리고 개체와 이벤트 리스너들을 저장하는 객체를 하나 만들어, 이벤트 리스너의 등록, 개체 관리 등을 더욱 쉽게 할 수 있도록 만든다. 이렇게 하면, document.querySelector(selector) 메서드가 여러 번 동작하지 않게 되어 효율적이다.&lt;/p&gt;
&lt;pre id=&quot;code_1721434829186&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js

const $ELEMENTS = {};

// ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직은 아무 내용도 없다. Document가 완전히 로드되고 난 후 속성들을 설정해줄 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 아직 존재하지 않는 메서드인 &lt;i&gt;Client.loginPage.submit&lt;/i&gt;() 메서드를 작성해야 한다. 이 메서드가 실질적으로 로그인 요청을 보내는 메서드가 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;먼저, &lt;i&gt;Client&lt;/i&gt; 객체를 작성한다. 이 객체는 개체 테이블 업데이트, 개체에 이벤트 리스너 추가(할당), 이벤트 리스너들의 컨테이너 등을 담는 역할을 하게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1721434991792&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js
// ...

const Client = {

    init() {
        this.listen();
    },


    initTable() {
        Object.assign($ELEMENTS, {
            &quot;login.username&quot;: {
                element: document.querySelector(&quot;#login-id&quot;)
            },
            &quot;login.password&quot;: {
                element: document.querySelector(&quot;#login-pass&quot;)
            },
            &quot;login.submit_button&quot;: {
                element: document.querySelector(&quot;#login-btn&quot;),
                eventListeners: [
                    {
                        event: &quot;click&quot;,
                        eventListener: Client.loginPage.submit
                    }
                ]
            }
        });
    },


    listen() {
        for (const v of Object.values($ELEMENTS)) {
            for (const listener of (v.eventListeners ?? [])) {
                v.element.addEventListener(listener.event, listener.eventListener);
            }
        }
    }

};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;i&gt;Client.init()&lt;/i&gt; 메서드는 Client 객체의 초기화 메서드로, 외부에서 Client 내부의 메서드들을 한꺼번에 호출하는 것은 보기에 좋지 않으므로 추가하였다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 &lt;i&gt;Client.loginPage&lt;/i&gt; 객체와 &lt;i&gt;Client.loginPage.submit()&lt;/i&gt; 메서드를 작성하여 코드를 완성한다. 로그인 요청을 전송하고, 성공 시(Resolve 함수 호출 시) &lt;i&gt;location.href&lt;/i&gt; 속성 수정을 통해 대시보드 페이지로 리다이렉트한다. 그렇지 않으면 오류 메시지를 표시하면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1721435199450&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js
// ...
const Client = {

    loginPage: {

        async submit() {
            const username = $ELEMENTS[&quot;login.username&quot;].value;
            const password = $ELEMENTS[&quot;login.password&quot;].value;

            try {
                await sendRequest(GET, &quot;/endpoints/login&quot;, { username, password });
                location.href = &quot;/&quot;;
            } catch (e) {
                alert(&quot;Incorrect ID or password&quot;);
            }
        }

    },


    init() {
        this.initTable();
        this.listen();
    },


    initTable() {
        Object.assign($ELEMENTS, {
            &quot;login.username&quot;: {
                element: document.querySelector(&quot;#login-id&quot;)
            },
            &quot;login.password&quot;: {
                element: document.querySelector(&quot;#login-pass&quot;)
            },
            &quot;login.submit_button&quot;: {
                element: document.querySelector(&quot;#login-btn&quot;),
                eventListeners: [
                    {
                        event: &quot;click&quot;,
                        eventListener: Client.loginPage.submit
                    }
                ]
            }
        });
    },


    listen() {
        for (const v of Object.values($ELEMENTS)) {
            for (const listener of (v.eventListeners ?? [])) {
                v.element.addEventListener(listener.event, listener.eventListener);
            }
        }
    }

};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제, Client의 초기화 메서드를 자동으로 호출해주는 코드를 작성한다. 페이지 컨텐츠가 로드되면 실행할 것이므로 document에 'DOMContentLoaded' 이벤트 리스너를 추가하면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1721435346779&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// assets/scripts/everyday.client.js
// ...

document.addEventListener(&quot;DOMContentLoaded&quot;, () =&amp;gt; {
    Client.init();
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다시 ejs 파일로 들어와서 작성한 JS 파일을 script 태그로 불러옴으로써 페이지에 스크립트를 적용한다.&lt;/p&gt;
&lt;pre id=&quot;code_1721435412795&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;!-- view/login.ejs --&amp;gt;

&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;en&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
    &amp;lt;title&amp;gt;Login&amp;lt;/title&amp;gt;

    &amp;lt;script src=&quot;/assets/scripts/everyday.client.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    &amp;lt;div class=&quot;wrap&quot;&amp;gt;
        &amp;lt;h1&amp;gt;Login&amp;lt;/h1&amp;gt;
        
        &amp;lt;form onsubmit=&quot;return false;&quot; action=&quot;&quot;&amp;gt;    
            &amp;lt;input type=&quot;text&quot; id=&quot;login-id&quot; name=&quot;login-id&quot; required&amp;gt;
            &amp;lt;input type=&quot;password&quot; id=&quot;login-pass&quot; name=&quot;login-pass&quot; required&amp;gt;
            &amp;lt;button id=&quot;login-btn&quot;&amp;gt;Login&amp;lt;/button&amp;gt;
        &amp;lt;/form&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;847&quot; data-origin-height=&quot;223&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c58LT9/btsIGGSqIXS/YYNNhkIEfhp3eZvPXe9pD0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c58LT9/btsIGGSqIXS/YYNNhkIEfhp3eZvPXe9pD0/img.png&quot; data-alt=&quot;잘못된 ID, 비밀번호 조합을 입력하면 오류가 표시된다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c58LT9/btsIGGSqIXS/YYNNhkIEfhp3eZvPXe9pD0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc58LT9%2FbtsIGGSqIXS%2FYYNNhkIEfhp3eZvPXe9pD0%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;847&quot; height=&quot;223&quot; data-origin-width=&quot;847&quot; data-origin-height=&quot;223&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;잘못된 ID, 비밀번호 조합을 입력하면 오류가 표시된다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;661&quot; data-origin-height=&quot;97&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bM64Pc/btsIHaMfMiG/H5qix7BoB8P9eac54jP621/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bM64Pc/btsIHaMfMiG/H5qix7BoB8P9eac54jP621/img.png&quot; data-alt=&quot;올바른 ID, 비밀번호를 입력하면 / 페이지로 리다이렉트된다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bM64Pc/btsIHaMfMiG/H5qix7BoB8P9eac54jP621/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbM64Pc%2FbtsIHaMfMiG%2FH5qix7BoB8P9eac54jP621%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;661&quot; height=&quot;97&quot; data-origin-width=&quot;661&quot; data-origin-height=&quot;97&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;올바른 ID, 비밀번호를 입력하면 / 페이지로 리다이렉트된다.&lt;/figcaption&gt;
&lt;/figure&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;Step 11.&amp;nbsp;&lt;br /&gt;프론트엔드: &lt;span style=&quot;color: #ff6347;&quot;&gt;대시보드&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대시보드라는 말은 거창하지만, 사실 별 거 없이 메일함과 메일 작성 페이지로 향하는 메뉴만을 포함하는 페이지이다. 적어도 지금 만드는 프로토타입에서는 그렇다. 따라서, view 디렉터리에 dashboard.ejs 파일을 만들고 내용만 채워 둔다.&lt;/p&gt;
&lt;pre id=&quot;code_1721438479987&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;!-- view/dashboard.ejs --&amp;gt;

&amp;lt;!DOCTYPE html&amp;gt;
&amp;lt;html lang=&quot;en&quot;&amp;gt;
&amp;lt;head&amp;gt;
    &amp;lt;meta charset=&quot;UTF-8&quot;&amp;gt;
    &amp;lt;meta name=&quot;viewport&quot; content=&quot;width=device-width, initial-scale=1.0&quot;&amp;gt;
    &amp;lt;title&amp;gt;Login&amp;lt;/title&amp;gt;

    &amp;lt;script src=&quot;/assets/scripts/everyday.client.js&quot;&amp;gt;&amp;lt;/script&amp;gt;
&amp;lt;/head&amp;gt;
&amp;lt;body&amp;gt;
    &amp;lt;div class=&quot;wrap&quot;&amp;gt;
        &amp;lt;h1&amp;gt;Dashboard&amp;lt;/h1&amp;gt;

        &amp;lt;a href=&quot;/logout&quot;&amp;gt;Logout&amp;lt;/a&amp;gt;
        &amp;lt;a href=&quot;/mails&quot;&amp;gt;Mail list&amp;lt;/a&amp;gt;
        &amp;lt;a href=&quot;/create&quot;&amp;gt;New mail&amp;lt;/a&amp;gt;
    &amp;lt;/div&amp;gt;
&amp;lt;/body&amp;gt;
&amp;lt;/html&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;물론, ejs 파일을 &lt;i&gt;res.render&lt;/i&gt;를 통해 보여주는 코드도 작성해야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1721438548021&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// route/public.route.js

// ...

router.use(&quot;/&quot;, checkUserPermission);
router.get(&quot;/&quot;, (req, res) =&amp;gt; {
    // Dashboard
    res.render(&quot;dashboard&quot;);
});

// ...&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;551&quot; data-origin-height=&quot;265&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bxFPXW/btsIHccd6Ny/BM8A1bCdHYkNdfxDTEUgmk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bxFPXW/btsIHccd6Ny/BM8A1bCdHYkNdfxDTEUgmk/img.png&quot; data-alt=&quot;로그인하여 대시보드에 접근한 모습.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bxFPXW/btsIHccd6Ny/BM8A1bCdHYkNdfxDTEUgmk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbxFPXW%2FbtsIHccd6Ny%2FBM8A1bCdHYkNdfxDTEUgmk%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;551&quot; height=&quot;265&quot; data-origin-width=&quot;551&quot; data-origin-height=&quot;265&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;로그인하여 대시보드에 접근한 모습.&lt;/figcaption&gt;
&lt;/figure&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;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ejs 파일을 표시하고, 표시된 ejs 파일에 클라이언트용 JS 파일을 불러와서 엔드포인트와 통신하는 데에 성공했다. 이번에는 Client 객체, sendRequest(method, uri, body?) 함수 등을 설정하기 위해 목표까지 조금 돌아갔지만, 앞으로 구현하는 기능들은 모두 ejs 작성과 이벤트 리스너 추가로 구현되므로 더욱 명확하리라 생각한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;지금 작성하는 프로젝트 개발일지가 좋은 컨텐츠일지 필자로서는 알 수 없지만, 그래도 당신의 도움이 필요하다. 만약 이 개발일지에 관심이 있거나, 이 개발일지가 유익하거나 좋은, 양질의 컨텐츠라고 생각된다면 아래의 공감 버튼을 한 번 클릭해 주시길 바란다. 이 프로젝트의 마지막 글은 프로젝트에서 사용한 다양한 핵심 개념만을 정리한 내용으로 작성할 생각이기에, 프로젝트에는 관심이 없지만 Nodemailer, mailpop3, Express.js 등의 용례 등에 관심이 있을 경우에도 더욱 도움이 되는 자료를 만들 수 있게끔 도와주시기를 부탁드린다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;본 글에서 오류가 있거나, 지나치게 편향적이거나 주관적인 서술이 있어 이해를 방해하는 등 문제가 있다면 언제든지 댓글로 피드백을 해주어 오류 수정에 도움을 주실 수 있다. 긴 글 읽어 주시어 진심으로 감사드린다.&lt;/p&gt;</description>
      <category>개발일지/웹</category>
      <category>Ajax</category>
      <category>EJS</category>
      <category>Express.js</category>
      <category>node.js</category>
      <category>REST API</category>
      <category>RESTful API</category>
      <category>vanila js</category>
      <category>XMLHttpRequest</category>
      <category>웹메일</category>
      <category>웹메일 클라이언트 개발하기</category>
      <author>데키유</author>
      <guid isPermaLink="true">https://playerdecuple.tistory.com/10</guid>
      <comments>https://playerdecuple.tistory.com/10#entry10comment</comments>
      <pubDate>Sat, 20 Jul 2024 10:32:25 +0900</pubDate>
    </item>
    <item>
      <title>웹 우체국으로 편지 보내는 방법: 웹메일 클라이언트 개발하기 3</title>
      <link>https://playerdecuple.tistory.com/8</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;글을 읽기 전에, &lt;a href=&quot;https://log.dekiyu.xyz/6&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이전 글&lt;/a&gt;을 읽어보기를 권한다. 간략하게나마 언급하자면, 이전 글에서는 Express.js, mailpop3 등을 이용하여 POP3 서버로부터 이메일 리스트를 받아와 RESTful API 형식으로 만들어내는 과정을 거쳤다. 이번에는 POST 요청을 받아 SMTP 서버를 통해 다른 이메일 주소로 메일을 전송하는 코드를 작성할 것이다. 만약, 당신이 이 과정에 대해 관심이 있고 지금 이 글을 통해 처음 접했다면, &lt;a href=&quot;https://log.dekiyu.xyz/tag/%EC%9B%B9%EB%A9%94%EC%9D%BC%20%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8%20%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;웹메일 클라이언트 개발하기&lt;/a&gt; 태그 페이지를 방문해보는 것도 좋다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POP3 과정에서 사용한 라이브러리인 mailpop3는 필자가 사용하기 편하게 구조를 변경할 필요가 있었지만, SMTP 과정에서 사용할 라이브러리인 nodemailer는 그럴 필요 없이 편리하게 코드를 작성할 수 있도록 설계되었다. 따라서, 저번보다는 더 쉽고 빠르게 원하는 기능을 구현할 수 있다.&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;Step 7.&amp;nbsp;&lt;br /&gt;엔드포인트: &lt;span style=&quot;color: #ff6347;&quot;&gt;이메일 전송&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POST 요청을 통해 실행될 것이므로, 엔드포인트 라우터에 라우트를 한 개 더 추가한다. 로그인한 사용자만 접근할 수 있도록 세션을 체크해야 함을 주의한다.&lt;/p&gt;
&lt;pre id=&quot;code_1721339919879&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// route/endpoint.route.js

// ...

function checkUserPermission(req, res, next) {
    if (req.session.username == null) {
        sendResponse(res, 401, &quot;Unauthorized&quot;);
        return;
    }
    next();
}

// ...

router.use(&quot;/mails&quot;, checkUserPermission);
router.post(&quot;/mails&quot;, async (req, res) =&amp;gt; {
    
});

module.exports = router;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Step 3에서 테스트 용도로 미리 작성된 코드를 참고해본다. 메일을 전송할 때마다 Transport를 생성하고 삭제하기보다는 POP3 클라이언트를 clientMap에 저장한 것과 같이 로그인 시에 Transport를 생성되게 하여 Map에 저장하고, 메일을 전송할 때마다 활성화된 Transport가 메일을 전송하게끔 만들면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1721350320548&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// 이전 글에서 SMTP를 테스트하기 위해 사용한 코드
const nodemailer = require(&quot;nodemailer&quot;);

const transporter = nodemailer.createTransport({
    host: process.env.SMTP_HOST,
    port: process.env.SMTP_PORT,
    secure: process.env.SMTP_TLS_SSL == &quot;true&quot;,
    auth: {
        user: process.env.TEST_EMAIL,
        pass: process.env.TEST_PASSWORD
    }
});



// route/endpoint.route.js

// ...

const transportMap = new Map();


function createTransport(username, password) {
    return nodemailer.createTransport({
        host: process.env.SMTP_HOST,
        port: process.env.SMTP_PORT,
        secure: process.env.SMtP_TLS_SSL == &quot;true&quot;,
        auth: {
            user: username,
            pass: password
        }
    });
}


router.post(&quot;/login&quot;, async (req, res) =&amp;gt; {
    // ...
    	await client.login(username, password);
        // ... POP3 서버 로그인에 성공하면 SMTP Transport를 생성한다.
        transportMap.set(username, createTransport(username, password));
    // ...
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후, 미리 만들어둔 메일 전송 라우트에 여러 내용을 포함하여 요청하면 username, 즉 사용자의 이메일을 통해 Transport를 불러오고, 불러온 Transport로 메일을 발송하게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1721350881054&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// route/endpoint.route.js

// ...

router.post(&quot;/mails&quot;, async (req, res) =&amp;gt; {
    const username = req.session.username;

    const { to, subject, html } = req.body;
    const transport = transportMap.get(username);

    if ([ to, subject, html ].some(e =&amp;gt; e == null || e.trim() == '')) {
        return sendResponse(res, 400, &quot;Bad Request&quot;);
    }

    await transport.sendMail({
        from: username,
        to, subject, html
    });

    sendResponse(res, 200, &quot;Ok&quot;);
});&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;1081&quot; data-origin-height=&quot;842&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/n1Ux4/btsIFzx83D9/QIRKlZIvso25BA0amkrwHk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/n1Ux4/btsIFzx83D9/QIRKlZIvso25BA0amkrwHk/img.png&quot; data-alt=&quot;Postman으로 메일 전송 시도&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/n1Ux4/btsIFzx83D9/QIRKlZIvso25BA0amkrwHk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fn1Ux4%2FbtsIFzx83D9%2FQIRKlZIvso25BA0amkrwHk%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;1081&quot; height=&quot;842&quot; data-origin-width=&quot;1081&quot; data-origin-height=&quot;842&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Postman으로 메일 전송 시도&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;477&quot; data-origin-height=&quot;237&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bNY7ZH/btsIEYSUx66/x2MwQxQc0isj0PW8CaB9sK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bNY7ZH/btsIEYSUx66/x2MwQxQc0isj0PW8CaB9sK/img.png&quot; data-alt=&quot;메일이 잘 전송된 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bNY7ZH/btsIEYSUx66/x2MwQxQc0isj0PW8CaB9sK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbNY7ZH%2FbtsIEYSUx66%2Fx2MwQxQc0isj0PW8CaB9sK%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;477&quot; height=&quot;237&quot; data-origin-width=&quot;477&quot; data-origin-height=&quot;237&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;받는 메일 주소가 잘못되면 nodemailer가 오류를 Throw함과 동시에 통신 클라이언트에는 커넥션 리셋 오류가 표시된다. 잘못된 메일 주소를 입력하여 nodemailer에서 오류를 던지면 사용자에게 오류가 발생하였음을 알리기 위해 try~catch 문으로 코드를 감싼다.&lt;/p&gt;
&lt;pre id=&quot;code_1721351555414&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// route/endpoint.route.js
// ...
router.post(&quot;/mails&quot;, async (req, res) =&amp;gt; {
    try {
        const username = req.session.username;

        const { to, subject, html } = req.body;
        const transport = transportMap.get(username);

        if ([ to, subject, html ].some(e =&amp;gt; e == null || e.trim() == '')) {
            return sendResponse(res, 400, &quot;Bad Request&quot;);
        }

        await transport.sendMail({
            from: username,
            to, subject, html
        });

        sendResponse(res, 200, &quot;Ok&quot;);
    } catch (e) {
        switch (e.responseCode) {
            case 451:
                return sendResponse(res, 451, &quot;DNS Server Error&quot;);
            default:
                return sendResponse(res, 400, &quot;Bad Request&quot;);
        }
    }
});&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;Step 8.&lt;br /&gt;엔드포인트: &lt;span style=&quot;color: #ef6f53;&quot;&gt;파일 첨부&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이메일의 내용 요소에는 첨부된 파일도 있다. urlencoded를 통해 파일을 업로드받는 방법이 있겠지만, POST 요청의 Body에 Base64 형식으로 받아도 크게 문제될 것이 없다. 따라서 최대한 코드를 변형하지 않고 필요한 코드를 맞추어 넣는다.&lt;/p&gt;
&lt;pre id=&quot;code_1721352358607&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// route/endpoint.route.js
router.post(&quot;/mails&quot;, async (req, res) =&amp;gt; {
    try {
        const username = req.session.username;

        let { to, subject, html, attachments } = req.body;
        const transport = transportMap.get(username);

        attachments = attachments?.map(e =&amp;gt; {
            const { filename, content } = e;

            if ([ filename, content ].some(el =&amp;gt; el == null || el.trim() == ''))
                return null;

            return ({
                filename, content: Buffer.from(content, &quot;base64&quot;)
            });
        }) ?? [];

        if ([ to, subject, html ].some(e =&amp;gt; e == null || e.trim() == ''))
            return sendResponse(res, 400, &quot;Bad Request&quot;);

        if (attachments.some(e =&amp;gt; e == null))
            return sendResponse(res, 400, &quot;Bad Attachment&quot;);

        await transport.sendMail({
            from: username,
            to, subject, html, attachments
        });

        sendResponse(res, 200, &quot;Ok&quot;);
    } catch (e) {
        switch (e.responseCode) {
            case 451:
                return sendResponse(res, 451, &quot;DNS Server Error&quot;);
            default:
                return sendResponse(res, 400, &quot;Bad Request&quot;);
        }
    }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프론트엔드에서 파일 자체를 Base64로 인코딩하고 POST Body에 담아 전송할 것이기에, 따로 가공은 거치지 않고 오로지 유효성 검사만을 실행한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1187&quot; data-origin-height=&quot;935&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/EITUz/btsIFHix1ML/jzzIbA2BUB5uML7agw2trk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/EITUz/btsIFHix1ML/jzzIbA2BUB5uML7agw2trk/img.png&quot; data-alt=&quot;Postman으로 메일 전송 시도&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/EITUz/btsIFHix1ML/jzzIbA2BUB5uML7agw2trk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FEITUz%2FbtsIFHix1ML%2FjzzIbA2BUB5uML7agw2trk%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;1187&quot; height=&quot;935&quot; data-origin-width=&quot;1187&quot; data-origin-height=&quot;935&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Postman으로 메일 전송 시도&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;476&quot; data-origin-height=&quot;410&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/b53Iez/btsIFdbb8v1/i1zUFmJ7vwC8T3H694WFV1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/b53Iez/btsIFdbb8v1/i1zUFmJ7vwC8T3H694WFV1/img.png&quot; data-alt=&quot;첨부파일이 잘 포함된 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/b53Iez/btsIFdbb8v1/i1zUFmJ7vwC8T3H694WFV1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fb53Iez%2FbtsIFdbb8v1%2Fi1zUFmJ7vwC8T3H694WFV1%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;476&quot; height=&quot;410&quot; data-origin-width=&quot;476&quot; data-origin-height=&quot;410&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;a href=&quot;https://developer.mozilla.org/ko/docs/Web/API/FileReader/readAsDataURL&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;FileReader.readAsDataURL()&lt;/a&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;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;SMTP 라이브러리가 편하게 구성되어 있어서 그런지, 정말 쉽게 SMTP 클라이언트를 만들 수 있었다. 다음 과정은 프론트엔드 페이지를 구축하고 엔드포인트와 연결해주는 것이 되겠다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번에는 길지 않은 분량이었지만, 여기까지 읽어주신 여러분께 감사의 말씀 전한다. 글 내용에 오류가 있거나, 착오가 있으면 댓글로 피드백해 주시는 것은 언제나 환영한다.&lt;/p&gt;</description>
      <category>개발일지/웹</category>
      <category>Express.js</category>
      <category>mailparser</category>
      <category>SMTP</category>
      <category>메일 api</category>
      <category>메일 클라이언트</category>
      <category>웹메일</category>
      <category>웹메일 클라이언트 개발하기</category>
      <author>데키유</author>
      <guid isPermaLink="true">https://playerdecuple.tistory.com/8</guid>
      <comments>https://playerdecuple.tistory.com/8#entry8comment</comments>
      <pubDate>Fri, 19 Jul 2024 10:40:56 +0900</pubDate>
    </item>
    <item>
      <title>메일은 어떻게 받아오는가: 웹메일 클라이언트 개발하기 2</title>
      <link>https://playerdecuple.tistory.com/6</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;이 글을 읽기 전에, &lt;a href=&quot;https://log.dekiyu.xyz/5&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;이전 글&lt;/a&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;Step 4.&amp;nbsp;&lt;br /&gt;엔드포인트: &lt;span style=&quot;color: #ff6347;&quot;&gt;사용자 로그인&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인을 위해 'express-session'과 'memorystore' 라이브러리를 설치해 준다. 메모리에 크나큰 부담을 줄 정도로 세션을 발급할 것은 아니기에, MySQL이나 MongoDB와 같은 데이터베이스를 사용하지 않게끔 메모리에 세션 데이터를 저장해도 문제가 없을 것이라 본다.&lt;/p&gt;
&lt;pre id=&quot;code_1720949026203&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  &quot;name&quot;: &quot;everyday&quot;,
  &quot;version&quot;: &quot;1.0.0&quot;,
  &quot;description&quot;: &quot;&quot;,
  &quot;main&quot;: &quot;main.js&quot;,
  &quot;scripts&quot;: {
    &quot;test&quot;: &quot;echo \&quot;Error: no test specified\&quot; &amp;amp;&amp;amp; exit 1&quot;,
    &quot;start&quot;: &quot;node ./main.js&quot;,
    &quot;start:dev&quot;: &quot;nodemon --watch ./ --exec \&quot;npm start\&quot;&quot;
  },
  &quot;author&quot;: &quot;player_decuple&quot;,
  &quot;license&quot;: &quot;MIT&quot;,
  &quot;dependencies&quot;: {
    &quot;dotenv&quot;: &quot;^16.4.5&quot;,
    &quot;express&quot;: &quot;^4.19.2&quot;,
    &quot;express-session&quot;: &quot;^1.18.0&quot;,
    &quot;mailpop3&quot;: &quot;^0.1.0&quot;,
    &quot;memorystore&quot;: &quot;^1.6.7&quot;,
    &quot;nodemailer&quot;: &quot;^6.9.14&quot;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;적용한 라이브러리들을 main.js에서 사용해 볼 때는 다음과 같이 사용한다. 옵션 객체의 secret은 세션 암호화 시의 salt로 사용되기에 아무런 문자열이나 사용해도 괜찮고, resave 옵션은 요청이 들어올 때마다 무조건 세션을 다시 저장하는 옵션이지만 데이터의 충돌을 방지하기 위해 비활성화한다. saveUninitalized 옵션은 데이터가 없어 비어 있는 세션을 저장하는 옵션이다. 마찬가지로 필요하지 않고 효율성만 낮추어 좋을 것이 없으므로 비활성화한다.&lt;/p&gt;
&lt;pre id=&quot;code_1720949951890&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const session = require('express-session');
const MemStore = require('memorystore')(session);

const sessionOption = {
    secret: Math.random().toString().split(&quot;.&quot;).pop(),
    resave: false,
    saveUninitialized: true,
    store: new MemStore({ checkPeriod: maxAgeSec * 1000 })
};
 
// ...

app.use(session(sessionOption));&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;백엔드나 사용자 데이터를 저장하지 않고 POP3 서버에 로그인 정보를 넘겨 그 응답으로 로그인 여부를 판단하게 된다. 이전 글의 플로우 차트에서 이 부분을 제작함으로써 사용자 로그인을 처리할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1061&quot; data-origin-height=&quot;317&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bSdMHY/btsIzpWGJRS/0ii3i76xCeH21IhiieDLY1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bSdMHY/btsIzpWGJRS/0ii3i76xCeH21IhiieDLY1/img.png&quot; data-alt=&quot;로그인 플로우&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bSdMHY/btsIzpWGJRS/0ii3i76xCeH21IhiieDLY1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbSdMHY%2FbtsIzpWGJRS%2F0ii3i76xCeH21IhiieDLY1%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;1061&quot; height=&quot;317&quot; data-origin-width=&quot;1061&quot; data-origin-height=&quot;317&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;일단, POST /login으로 요청을 전송하면 받은 요청을 통해 POP3 서버에 로그인 시도해보아야 한다. mailpop3 라이브러리가 POP3Client.login(username, password) 메서드를 통해 POP3 서버 로그인을 지원하므로 받은 사용자 이름(아이디)과 비밀번호 데이터를 그대로 파라미터로 사용하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;POST 요청은 요청 내의 Body를 통해 데이터를 전송받을 수 있다. RESTful API와의 통신에서는 보통 Body를 JSON 형식으로 사용하는데, JSON 형식으로 전송된 Body의 여러 속성들에 편리하게 접근하기 위해, Express.js 내에 존재하는 파서를 사용하도록 만들어 준다.&lt;/p&gt;
&lt;pre id=&quot;code_1721073762441&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// main.js
app.use(express.json());&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이제 라우터 등지에서 Body가 자동으로 파싱 되어 req.body.username과 같이 각 속성에 객체처럼 바로 접근할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1721077263456&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const express = require('express');
const router = express.Router();

router.post(&quot;/login&quot;, async (req, res) =&amp;gt; {
    const body = req.body;
    const username = req.username;
    const password = req.password;
    
    console.log(username, password);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;mailpop3는 POP3 서버 연결과 자체 이벤트 처리를 모두 이벤트 리스너 형식으로만 지원하고 있다. 그 말인즉, 필자가 원하는 플로우를 구현하기 위해 여러 제약이 존재한다는 점이다. 예를 들어, 로그인 후에 즉시 메일을 전송하는 플로우 A가 있고, 로그인 후 즉시 메일 리스트를 불러오는 플로우 B가 있을 때, 로그인 이벤트 리스너 내부에서 각 플로우에 대한 코드를 직접 작성해 주어야 한다는 불편함이 있었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, mailpop3을 Promise로 동작하게 만들기 위해 아래와 같이 코드를 작성한다.&lt;/p&gt;
&lt;pre id=&quot;code_1721077746294&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// core/pop3.client.js

const Core = require(&quot;mailpop3&quot;);


class POP3Client {

    client;

    host; port; useSSL;


    constructor() {
        this.host = process.env.POP3_HOST;
        this.port = process.env.POP3_PORT;
        this.useSSL = process.env.POP3_TLS_SSL == &quot;true&quot;;
    }


    connect() {
        const { host, port, useSSL } = this;

        this.client = new Core(port, host, {
            tlserrs: false,
            enabletls: useSSL,
            debug: false
        });

        return new Promise((res, rej) =&amp;gt; {
            this.client.on(&quot;connect&quot;, res);
            this.client.on(&quot;error&quot;, rej);
        });
    }


    disconnect() {
        this.client.quit();
    }


    login(username, password) {
        return new Promise((res, rej) =&amp;gt; {
            this.client.on(&quot;login&quot;, (st, raw) =&amp;gt; (st ? res : rej)(raw));
            this.client.login(username, password);
        });
    }

}


module.exports = POP3Client;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;connect 메서드와 login 메서드는 일회성으로 실행되는 메서드들이기에, 이벤트 리스너를 on으로 등록하여도 큰 상관은 없다. 하지만 LIST나 RETR과 같이 여러 번 실행되는 메서드들은 이벤트 리스너를 off 처리해 줄 필요가 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Reject 처리된 Promise 객체에 await 키워드를 사용할 경우, Error가 Throw 된다는 점을 이용해서, try~catch로 로그인 성공과 실패 여부를 구별해 낼 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1721078318847&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// route/endpoint.route.js
const express = require('express');
const POP3Client = require('../core/pop3.client');
const router = express.Router();


function sendResponse(res, st, ...msg) {
    res.status(st).send({
        statusCode: st,
        message: msg
    });
}


router.post(&quot;/login&quot;, async (req, res) =&amp;gt; {
    const body = req.body;
    const username = body.username;
    const password = body.password;

    if (username == null || password == null) {
        return sendResponse(res, 400, &quot;Bad Request&quot;);
    }

    const client = new POP3Client();
    await client.connect();
    
    try {
        await client.login(username, password);
        sendResponse(res, 200, &quot;OK&quot;);
    } catch (e) {
        client.disconnect(); // 로그인에 실패한 클라이언트가 메모리에 점유되는 것을 방지한다
        sendResponse(res, 401, &quot;Invalid username or password&quot;);
    }
});


module.exports = router;&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;670&quot; data-origin-height=&quot;345&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/AcTJy/btsIBowo3W8/vZUwgzlw5oxp5lstC7CK20/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/AcTJy/btsIBowo3W8/vZUwgzlw5oxp5lstC7CK20/img.png&quot; data-alt=&quot;로그인 성공 시엔 OK 응답을 돌려준다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/AcTJy/btsIBowo3W8/vZUwgzlw5oxp5lstC7CK20/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FAcTJy%2FbtsIBowo3W8%2FvZUwgzlw5oxp5lstC7CK20%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;670&quot; height=&quot;345&quot; data-origin-width=&quot;670&quot; data-origin-height=&quot;345&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;로그인 성공 시엔 OK 응답을 돌려준다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;716&quot; data-origin-height=&quot;354&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/xXNcF/btsIASShfkx/ZedKo5rNgvQD4iGlb6kW4k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/xXNcF/btsIASShfkx/ZedKo5rNgvQD4iGlb6kW4k/img.png&quot; data-alt=&quot;사용자 이름, 비밀번호 조합이 올바르지 않으면 Reject 함수가 실행되고, 401 응답을 돌려준다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/xXNcF/btsIASShfkx/ZedKo5rNgvQD4iGlb6kW4k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FxXNcF%2FbtsIASShfkx%2FZedKo5rNgvQD4iGlb6kW4k%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;716&quot; height=&quot;354&quot; data-origin-width=&quot;716&quot; data-origin-height=&quot;354&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;사용자 이름, 비밀번호 조합이 올바르지 않으면 Reject 함수가 실행되고, 401 응답을 돌려준다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후의 요청에서 사용자가 누구인지 확인할 수 있도록 세션에 사용자 이름(이메일)을 저장한다. 또한, 사용자마다 다른 클라이언트를 사용할 수 있도록 Map을 활용하여 사용자 이름을 키로, 클라이언트를 값으로 저장한다. 따라서 앞으로의 요청에서는 세션 내의 사용자 이름을 통해 클라이언트를 가져와 원하는 명령을 실행할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1721079847784&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const clientMap = new Map();

// ...

router.post(&quot;/login&quot;, async (req, res) =&amp;gt; {
    // ...
    
    try {
        await client.login(username, password);

        if (clientMap.has(username))
            clientMap.get(username).disconnect();
        clientMap.set(username, client);

        req.session.username = username;
        sendResponse(res, 200, &quot;OK&quot;);
    } catch (e) {
        client.disconnect();
        sendResponse(res, 401, &quot;Invalid username or password&quot;);
    }
});


module.exports = router;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&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;Step 5.&amp;nbsp;&lt;br /&gt;엔드포인트: &lt;span style=&quot;color: #ff6347;&quot;&gt;이메일 리스트&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 후 이메일 목록을 확인할 수 있는 메인 페이지에 접근하면 엔드포인트 호출을 통해 메일들을 확인할 수 있어야 한다. 이전 글에서는 메일 목록을 아래와 같은 예제를 통해 불러온 것을 확인할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1721080324219&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;client.list();


client.on(&quot;list&quot;, function (st, cnt, no, d, raw) {
    console.log(raw);
});


/*
1 23860
2 30638
3 8559992
4 5656932
...
*/&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정 또한 async-await 키워드를 사용하기 위해 Promise 형식으로 변형할 것이다. 그리고 사용자 로그인 이후에도 목록을 불러올 필요가 있기에 하나의 메서드로 정의할 것이다. 이메일의 내용을 추출하기 위해 '&lt;a href=&quot;https://nodemailer.com/extras/mailparser/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;mailparser&lt;/a&gt;' 라이브러리를 npm을 통해 설치해 준다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이메일 리스트를 불러오는 .list(startIdx, size) 메서드는 시작 인덱스와 메일의 개수를 받아 제목, 첨부파일 여부 등의 간략한 정보를 반환한다. 이때, 시작 인덱스는 오름차순이다. 즉, 시작 인덱스가 0인 경우 제일 최근의 메일 정보부터 가져오게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1721085170637&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// core/pop3.client.js
const simpleParser = require('mailparser').simpleParser;


class POP3Client {

    // ...

    async list(startIdx, size) {
        const count = await new Promise((res, rej) =&amp;gt; {
            const $this = this;
            
            this.client.on(&quot;list&quot;, function listListener(st, cnt, no, data, raw) {
                (st !== false ? res : rej)(cnt);
                $this.client.off(&quot;list&quot;, listListener);
            });

            this.client.list();
        });

        const data = [];
        startIdx = count - startIdx;

        for (let i = startIdx; i &amp;gt;= Math.max(startIdx - size, 1); i--) {
            data.push(await this.retr(i));
        }

        return data;
    }


    async retr(idx) {
        return new Promise((res, rej) =&amp;gt; {
            const $this = this;

            this.client.on(&quot;retr&quot;, async function retrListener(st, no, data, raw) {
                const parsed = await simpleParser(data);
                parsed.idx = idx;

                (st !== false ? res : rej)(parsed);
                $this.client.off(&quot;retr&quot;, retrListener);
            });

            this.client.retr(idx);
        });
    }
    
//  ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;.retr(idx) 함수는 이전 글의 예제와 같이 'idx' 인덱스에 해당하는 메일의 상세 정보를 가져와 파싱한다. 이후 메일 상세 정보 페이지에 접근하기 쉽도록 idx를 파싱한 데이터에 삽입한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이후 엔드포인트에서 list 함수를 호출하고 사용자에게 표시한다. 이 엔드포인트는 로그인한 사용자 이외에는 접근할 수 없어야 하므로, 'use'를 통해 미들웨어를 추가해 주는 작업도 필요하다.&lt;/p&gt;
&lt;pre id=&quot;code_1721083974366&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// route/endpoint.route.js

// ...

function checkUserPermission(req, res, next) {
    if (req.session.username == null) {
        sendResponse(res, 401, &quot;Unauthorized&quot;);
        return;
    }
    next();
}

// ...

router.use(&quot;/mails&quot;, checkUserPermission);
router.get(&quot;/mails&quot;, async (req, res) =&amp;gt; {
    try {
        const client = clientMap.get(req.session.username);
        const startIdx = (req.start ?? 0) * 1;
        let size = (req.size ?? 20) * 1;

        if (size &amp;lt;= 0 || startIdx &amp;lt; 0 || isNaN(startIdx) || isNaN(size))
            throw new Error();
        if (size &amp;gt; 40)
            size = 40;

        const list = await client.list(startIdx, size);
        const data = list.map((mail) =&amp;gt; ({
            idx: mail.idx,
            subject: mail.subject,
            date: mail.date,
            from: mail.from,
            hasAttachment: mail.attachment.length &amp;gt; 0
        }));

        res.status(200).send(data);
    } catch (e) {
        sendResponse(res, 400, &quot;Bad Request&quot;);
    }
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 하면 로그인 이후 GET /endpoints/mail을 요청하여 메일 리스트를 가져올 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;853&quot; data-origin-height=&quot;497&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dyly0k/btsIBiwjNQf/1w5o6ekfjEiHLy8r8nBcQ0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dyly0k/btsIBiwjNQf/1w5o6ekfjEiHLy8r8nBcQ0/img.png&quot; data-alt=&quot;필요한 정보만 정확히 표시하고 있다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dyly0k/btsIBiwjNQf/1w5o6ekfjEiHLy8r8nBcQ0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fdyly0k%2FbtsIBiwjNQf%2F1w5o6ekfjEiHLy8r8nBcQ0%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;853&quot; height=&quot;497&quot; data-origin-width=&quot;853&quot; data-origin-height=&quot;497&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;필요한 정보만 정확히 표시하고 있다.&lt;/figcaption&gt;
&lt;/figure&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;Step 6.&amp;nbsp;&lt;br /&gt;엔드포인트: &lt;span style=&quot;color: #ff6347;&quot;&gt;이메일 상세&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이미 이전 단계에서 .retr(idx) 메서드를 만들어두었기에 단순히 RETR 명령을 실행하는 엔드포인트만 작성해도 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1721085843320&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// route/endpoint.route.js

// ...

router.use(&quot;/mails/:mail_id&quot;, checkUserPermission);
router.get(&quot;/mails/:mail_id&quot;, async (req, res) =&amp;gt; {
    try {
        const client = clientMap.get(req.session.username);
        const mailIdx = req.params[&quot;mail_id&quot;] * 1;

        if (isNaN(mailIdx))
            throw new Error();

        res.status(200).send(await client.retr(mailIdx));
    } catch (e) {
        console.error(e);
        sendResponse(res, 400, &quot;Bad Request&quot;);
    }
});

// ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 .retr(idx) 메서드가 올바르지 않은 idx를 받으면 메일 정보를 가져올 수 없어 simpleParser 부분에서 오류가 발생한다. .retr(idx) 메서드 하나만으로는 메일함 크기 등을 가져올 수 없으므로, 오류가 발생하면 Reject 함수를 실행하도록 만들어야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1721086049116&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// ...

    async retr(idx) {
        return new Promise((res, rej) =&amp;gt; {
            const $this = this;

            this.client.on(&quot;retr&quot;, async function retrListener(st, no, data, raw) {
                try {
                    const parsed = await simpleParser(data);
                    parsed.idx = idx;

                    (st !== false ? res : rej)(parsed);
                    $this.client.off(&quot;retr&quot;, retrListener);
                } catch (e) {
                    rej();
                }
            });

            this.client.retr(idx);
        });
    }
    
// ...&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 부분까지 완료하고 나면, 메일을 성공적으로 받아오는 것을 확인할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1086&quot; data-origin-height=&quot;961&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NfIg1/btsIzAypfhc/am5cReU5KiXFLnkNbGKgGk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NfIg1/btsIzAypfhc/am5cReU5KiXFLnkNbGKgGk/img.png&quot; data-alt=&quot;값을 정상적으로 불러오는 모습&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NfIg1/btsIzAypfhc/am5cReU5KiXFLnkNbGKgGk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNfIg1%2FbtsIzAypfhc%2Fam5cReU5KiXFLnkNbGKgGk%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;1086&quot; height=&quot;961&quot; data-origin-width=&quot;1086&quot; data-origin-height=&quot;961&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;값을 정상적으로 불러오는 모습&lt;/figcaption&gt;
&lt;/figure&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;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;mailpop3와 mailparser를 통해 메일의 내용을 불러오고 파싱하는 과정이었다. 메일 수정 기능은 거의 사용하지 않고, 메일 삭제 기능은 추후에 구현할 예정이기 때문에 메일 가져오기와 메일 보내기를 주요 기능으로 보고 개발하고자 한다. 다음 글에서는 nodemailer를 사용한 메일 발신에 대한 내용을 확인할 수 있을 것이다. 이번에도 긴 글 읽어 주시어 진심으로 감사한 말씀을 전한다.&lt;/p&gt;</description>
      <category>개발일지/웹</category>
      <category>Express.js</category>
      <category>mailparser</category>
      <category>mailpop3</category>
      <category>POP3</category>
      <category>메일 api</category>
      <category>메일 클라이언트</category>
      <category>웹메일</category>
      <category>웹메일 클라이언트 개발하기</category>
      <category>이메일</category>
      <author>데키유</author>
      <guid isPermaLink="true">https://playerdecuple.tistory.com/6</guid>
      <comments>https://playerdecuple.tistory.com/6#entry6comment</comments>
      <pubDate>Tue, 16 Jul 2024 08:32:53 +0900</pubDate>
    </item>
    <item>
      <title>내 도메인 주소에서 보내는 메일: 웹메일 클라이언트 개발하기 1</title>
      <link>https://playerdecuple.tistory.com/5</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;도메인 주소는 IP 주소에 비해 외우기 쉽다는 장점이 있어 주로 웹 페이지 등에 쉽게 연결할 수 있도록 사용된다. 도메인 주소 하나만으로도 개인이나 단체의 이름, 특성, 컨셉을 나타낼 수 있기도 하고, 도메인 주소를 부여함으로써 내 웹 페이지에 특별함을 추가해 줄 수도 있다. 시대가 가며 웹 개발을 아예 모르는 사람이더라도 'Velog'나 'Tistory' 등의 유명 블로그 플랫폼이나 'WordPress'와 같은 설치형 블로그를 쉽게 운영할 수 있게 되었기에, '나다움'이나 본인만의 개성을 강조하기 위한 다른 수단으로써 사용되는 것이 보통이 되었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 도메인 주소를 꼭 웹 페이지 운영에만 사용할 수 있는가 하면, 그렇지는 않다. 사실 이미 모두에게 친숙한 곳에서 도메인이 사용되는 곳이 있는데, 그것은 바로 이메일이다. 으레 알고 있듯이, 이메일 계정의 '@' 문자 뒤에 오는 것이 도메인 주소다. 포털 사이트에서 만든 계정이 그대로 이메일 주소가 되는 경우가 대다수이기 때문에, 개발자나 회사원이 아닌 일반적인 개인이나 단체라면, 대개 'naver.com', 'gmail.com' 등을 사용하기 마련이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 도메인 주소를 구매하여 갖고 있다면 웹 사이트 하나만을 위해 사용하는 것은 아까울 수도 있고, 특히 개발자에게는 자신의 도메인 주소로 구성된 메일 주소를 가지고 있는 것이 더 로망 있을 수 있다. &lt;b&gt;어디서든 내 도메인 주소에 접근&lt;/b&gt;하여 그 곳에서 메일을 송신하고, 수신할 수 있다면 그것은 정말 멋져 보일 것이다. 필자는 그러한 가벼운 마음에 '&lt;b&gt;내 도메인에서 작동하는 웹메일 클라이언트&lt;/b&gt;'를 개발해보고자 한다.&lt;/p&gt;
&lt;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;Step 1. &lt;br /&gt;내 도메인으로 &lt;span style=&quot;color: #ff6347;&quot;&gt;메일 주소 만들기&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;'&lt;i&gt;도메인으로 메일 주소를 사용할 수 있도록 해주는 서비스&lt;/i&gt;'는 이미 많이 존재한다. 'Mailplug'나, 'Mailgun'이 그 좋은 예시이다. 이 서비스 내에선 이메일 프로토콜을 통해 메일 데이터를 직접 전송하고 수신하는 서버인 &lt;u&gt;메일 서버&lt;/u&gt;가 존재하고, 이메일 주소의 '@' 뒤에 오는 도메인 주소가 바로 이 메일 서버의 주소를 나타낸다. 따라서, 내 도메인으로 메일 주소를 만들려면 '메일 서버'를 구축해야 한다는 결론에 다다른다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;회사나 규모가 매우 큰 단체, 재단 등이 아니라면, 메일 서버를 자체 구축하는 경우는 많지 않다. 괜히 많은 기업들이 'Mailplug'와 같은 외부 메일 서비스를 이용하는 것이 아니다. 실제로 구축에는 상당히 다양한 과정이 있고, 구축을 완료하더라도 추후 그 메일 서버를 통해 보낸 메일들이 전부 스팸 메일로 분류되는 경우가 생기기도 한다. 따라서 필자도 외부 메일 서비스를 통해 메일 주소를 만드려고 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;다음이나 카카오 메일 서비스는 한 번도 사용해 본 적이 없지만, 다음에서 제공하는 '&lt;a href=&quot;https://cs.daum.net/faq/43/13114.html&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;다음 스마트워크&lt;/a&gt;'를 통해 메일 주소를 만들어 보았다. 무료라는 점에 이 서비스를 이용했지만, 만약 다른 클라이언트에서 SMTP, POP3에 연결할 수 있는 서비스라면 다른 어떤 서비스라도 이용할 수 있다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;스팸메일이 아니라는 것을 인증하는&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;/span&gt;&lt;a style=&quot;color: #0070d1; text-align: start;&quot; href=&quot;https://cs.daum.net/faq/43/13114.html?faqId=24453&quot;&gt;SPF까지 설정&lt;/a&gt;해야만 Gmail로 메일이 전송되는 점에 유의해야 한다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;521&quot; data-origin-height=&quot;252&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/CbONs/btsIyfG9JH5/DaKjsoKHFk8KliqaWKK4MK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/CbONs/btsIyfG9JH5/DaKjsoKHFk8KliqaWKK4MK/img.png&quot; data-alt=&quot;SPF까지 설정하면 Gmail 주소로도 이메일이 잘 발송된다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/CbONs/btsIyfG9JH5/DaKjsoKHFk8KliqaWKK4MK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FCbONs%2FbtsIyfG9JH5%2FDaKjsoKHFk8KliqaWKK4MK%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;521&quot; height=&quot;252&quot; data-origin-width=&quot;521&quot; data-origin-height=&quot;252&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;SPF까지 설정하면 Gmail 주소로도 이메일이 잘 발송된다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보유한 도메인 주소의 DNS를 설정할 수 있다면, MX 레코드를 &lt;a href=&quot;https://cs.daum.net/faq/43/13114.html?faqId=24408&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;제공된 정보&lt;/a&gt;에 따라 설정해주고, SPF 설정을 해주면 이 단계가 끝난다. 필자의 경우는 가비아에서 DNS를 설정할 수 있게끔 기능을 제공하고 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;730&quot; data-origin-height=&quot;317&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OjvcU/btsIzKzoELq/uDvf53ekT5zjt6iPaJBuc0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OjvcU/btsIzKzoELq/uDvf53ekT5zjt6iPaJBuc0/img.png&quot; data-alt=&quot;가비아에서 설정된 DNS 정보&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OjvcU/btsIzKzoELq/uDvf53ekT5zjt6iPaJBuc0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOjvcU%2FbtsIzKzoELq%2FuDvf53ekT5zjt6iPaJBuc0%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;730&quot; height=&quot;317&quot; data-origin-width=&quot;730&quot; data-origin-height=&quot;317&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;가비아에서 설정된 DNS 정보&lt;/figcaption&gt;
&lt;/figure&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;Step 2.&amp;nbsp;&lt;br /&gt;&lt;span style=&quot;color: #ff6347;&quot;&gt;플로우 계획&lt;/span&gt;해보기&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;목표만 생각해 본다면 단순히 클라이언트를 만들고 메일 프로토콜을 통해 메일 서버와 통신하는 것으로 요약할 수 있다. Form에 내용을 입력하여 SMTP를 통해 메일을 전송하고, POP3를 통해 수신된 메일을 가져와 화면에 표시하는 것이 전부이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1583&quot; data-origin-height=&quot;932&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bUsiA8/btsIx98vxWj/OfLBFu5l7SZmfcVtVC2RS0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bUsiA8/btsIx98vxWj/OfLBFu5l7SZmfcVtVC2RS0/img.png&quot; data-alt=&quot;연결, 이메일 목록 받기, 이메일 전송하기만 생각한다면, 이런 모양새일 것이다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bUsiA8/btsIx98vxWj/OfLBFu5l7SZmfcVtVC2RS0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbUsiA8%2FbtsIx98vxWj%2FOfLBFu5l7SZmfcVtVC2RS0%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;1583&quot; height=&quot;932&quot; data-origin-width=&quot;1583&quot; data-origin-height=&quot;932&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;POP3 서버는 엔드포인트로부터 여러 명령어들을 받아 메일 데이터를 전송해 줄 것이고, SMTP 서버는 요청에 따라 사용자가 입력한 메일 정보를 대상 메일 주소로 전송해 주는 역할을 하게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자가 엔드포인트에 로그인을 요청하면, &lt;b&gt;사용자가 입력한&lt;/b&gt; 이메일 주소와 비밀번호를 POP3 서버에 각각 USER, PASS 명령으로 보낸다. POP3 서버가 이에 대해 'OK' 응답을 한다면 사용자가 입력한 이메일 주소와 비밀번호가 로그인 성공한 것으로 간주되어 세션을 부여하고 사용자를 로그인 처리할 것이고, 그렇지 않는다면 사용자에게 Fallback 메시지를 표시할 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;규모가 크지 않기 때문에 세션 데이터를 저장하기 위해 MySQL이나 MongoDB와 같은 데이터베이스들을 사용할 필요가 없다. 도메인 메일 주소를 많은 사람에게 사용할 수 있게끔 할 것도, 내 도메인이 아닌 다른 도메인도 이 서비스를 이용하게끔 할 것도 아니기 때문이다. 만약 추후에 생각이 바뀐다고 해도, 그저 세션 하나만을 저장하는 용도이기에 라이브러리 추가와 설정 정도만 해주면 된다.&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;Step 3.&amp;nbsp;&lt;br /&gt;&lt;span style=&quot;color: #ff6347;&quot;&gt;서버 &lt;/span&gt;&lt;span style=&quot;color: #ff6347;&quot;&gt;준비&lt;/span&gt;와 &lt;span style=&quot;color: #ff6347;&quot;&gt;테스트&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 프로젝트에서는 데이터베이스를 사용하지도 않고, 복잡할 것으로 예상되는 기능이 몇 없으므로 가볍지만 강력한 Node.js 프레임워크인 '&lt;a href=&quot;https://expressjs.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Express.js&lt;/a&gt;'를 사용하고자 한다. 적절한 장소에 빈 디렉터리를 만든 후 npm을 통해 기본적인 package.json 파일을 작성하고, 빠른 디버깅을 위해 '&lt;a href=&quot;https://www.npmjs.com/package/nodemon&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;nodemon&lt;/a&gt;'을 설정해 준다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1117&quot; data-origin-height=&quot;533&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bBW8ZL/btsIxheTZ7g/KDyezjChFRjYRLmZcPH16K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bBW8ZL/btsIxheTZ7g/KDyezjChFRjYRLmZcPH16K/img.png&quot; data-alt=&quot;npm init과 nodemon 설정을 마친 후 package.json&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bBW8ZL/btsIxheTZ7g/KDyezjChFRjYRLmZcPH16K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbBW8ZL%2FbtsIxheTZ7g%2FKDyezjChFRjYRLmZcPH16K%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;1117&quot; height=&quot;533&quot; data-origin-width=&quot;1117&quot; data-origin-height=&quot;533&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;npm init과 nodemon 설정을 마친 후 package.json&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;세션 사용을 위한 'express-session'과 디버깅을 위한 'dotenv'도 설치한다. 메일 수신, 발신이 가능하게끔 만들기 위해 SMTP를 통한 메일 전송을 지원하는 '&lt;a href=&quot;https://nodemailer.com/&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;nodemailer&lt;/a&gt;'와 POP3를 통해 서버에서 메일을 가져올 수 있는 '&lt;a href=&quot;https://www.npmjs.com/package/mailpop3&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;mailpop3&lt;/a&gt;' 또한 사용해 볼 생각이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;SMTP, POP3&lt;br /&gt;&lt;span style=&quot;color: #ff6347;&quot;&gt;서버 정보&lt;/span&gt; 알아보기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단, 제일 중요한 기능인 메일의 송수신을 테스트할 생각이다. 따라서, 자체적으로 구축한 메일 서버가 아니라면 관련 설정에서 SMTP와 POP3을 활성화하여야 한다. 그리고, 현재 사용하는 메일 서버의 SMTP 서버 정보와 POP3 정보가 필요하다. 필자가 사용하기로 한 다음 스마트메일에서는 SMTP와 POP3를 활성화할 수 있는 메뉴에서 해당 정보들을 찾을 수 있었다.&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;493&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1tL6C/btsIzyMFAWW/p9bWKKKTDbYF0ZEQk63dT0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1tL6C/btsIzyMFAWW/p9bWKKKTDbYF0ZEQk63dT0/img.png&quot; data-alt=&quot;다음 메일은 설정에서 SMTP와 POP3를 활성화할 수 있도록 되어 있다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1tL6C/btsIzyMFAWW/p9bWKKKTDbYF0ZEQk63dT0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1tL6C%2FbtsIzyMFAWW%2Fp9bWKKKTDbYF0ZEQk63dT0%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;998&quot; height=&quot;493&quot; data-origin-width=&quot;998&quot; data-origin-height=&quot;493&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;다음 메일은 설정에서 SMTP와 POP3를 활성화할 수 있도록 되어 있다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;896&quot; data-origin-height=&quot;165&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/2K9MI/btsIxfnQ8vU/aQ1dvOfjKfTciOCC0hciy1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/2K9MI/btsIxfnQ8vU/aQ1dvOfjKfTciOCC0hciy1/img.png&quot; data-alt=&quot;다음 메일의 POP3 서버와 SMTP 서버 정보&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/2K9MI/btsIxfnQ8vU/aQ1dvOfjKfTciOCC0hciy1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F2K9MI%2FbtsIxfnQ8vU%2FaQ1dvOfjKfTciOCC0hciy1%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;896&quot; height=&quot;165&quot; data-origin-width=&quot;896&quot; data-origin-height=&quot;165&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;다음 메일의 POP3 서버와 SMTP 서버 정보&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;얻은 POP3 서버와 SMTP 서버의 정보를 프로젝트 폴더에 '.env' 파일에 저장하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1720904799022&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;POP3_HOST=pop.daum.net
POP3_PORT=995
POP3_TLS_SSL=true

SMTP_HOST=smtp.daum.net
SMTP_PORT=465
SMTP_TLS_SSL=true&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #ff6347;&quot;&gt;POP3&lt;/span&gt; 연결&lt;br /&gt;테스트하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;미리 설치해 두었던 'mailpop3'를 테스트해 본다. 아래 코드는 순서대로 클라이언트를 POP3 서버에 연결하고, 로그인을 요청하고, LIST 명령어를 전송하여 메일 리스트를 불러오며, 메일 리스트의 2번째 메일을 다운로드하여 데이터를 콘솔에 표시하게끔 작동한다.&lt;/p&gt;
&lt;pre id=&quot;code_1720905682715&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// main.js
const POP3Client = require(&quot;mailpop3&quot;);

const client = new POP3Client(process.env.POP3_PORT, process.env.POP3_HOST, {
    tlserrs: false,
    enabletls: process.env.POP3_TLS_SSL == &quot;true&quot;,
    debug: false
});


client.on(&quot;connect&quot;, function () {
    client.login(process.env.TEST_EMAIL, process.env.TEST_PASSWORD);
    console.log(&quot;Mail server connect success&quot;);
});

client.on(&quot;login&quot;, function (st, raw) {
    console.log(raw);
    if (st) {
        console.log(&quot;Login Pass&quot;);
        client.list();
    } else {
        client.quit();
    }
});

client.on(&quot;list&quot;, function (st, cnt, no, d, raw) {
    console.log(raw);
    client.retr(2);
});

client.on(&quot;retr&quot;, function (st, no, d, raw) {
    console.log(d);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;로그인 요청을 받으면, client.login(username, password)를 실행하여 USER username, PASS password를 POP3 서버에 전송한다. 이후 아래와 같이 &quot;OK&quot; 응답을 받는 경우, 로그인 완료로 간주하고 세션을 발급하게끔 만들 생각이다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li style=&quot;list-style-type: none;&quot;&gt;&amp;nbsp;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;671&quot; data-origin-height=&quot;104&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cv5yn8/btsIy03Z3km/iI2G7RxNRl3kEpwmo2VWK0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cv5yn8/btsIy03Z3km/iI2G7RxNRl3kEpwmo2VWK0/img.png&quot; data-alt=&quot;로그인 요청에 대한 POP3 서버의 응답&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cv5yn8/btsIy03Z3km/iI2G7RxNRl3kEpwmo2VWK0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcv5yn8%2FbtsIy03Z3km%2FiI2G7RxNRl3kEpwmo2VWK0%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;671&quot; height=&quot;104&quot; data-origin-width=&quot;671&quot; data-origin-height=&quot;104&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;로그인 요청에 대한 POP3 서버의 응답&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;client.list()를 호출하여 LIST 명령을 서버에 전송하면, 서버는 현재 보관 중인 메일의 ID들을 돌려준다. 앞에 있는 숫자는 인덱스(순번)로, 1부터 시작한다. 그리고 뒤에 있는 숫자가 메일의 ID이다. 중요한 것은 인덱스로, 이를 통해 메일의 상세 내용을 알아볼 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;397&quot; data-origin-height=&quot;146&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bo9SiD/btsIytS8bKj/45R0xnnSENNAjTepHVotm0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bo9SiD/btsIytS8bKj/45R0xnnSENNAjTepHVotm0/img.png&quot; data-alt=&quot;LIST 명령어에 대한 POP3 서버의 응답&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bo9SiD/btsIytS8bKj/45R0xnnSENNAjTepHVotm0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbo9SiD%2FbtsIytS8bKj%2F45R0xnnSENNAjTepHVotm0%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;397&quot; height=&quot;146&quot; data-origin-width=&quot;397&quot; data-origin-height=&quot;146&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;LIST 명령어에 대한 POP3 서버의 응답&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;client.retr(n)을 호출하여 n번 메일을 받는 명령어인 'RETR n'을 서버에 전송하면, 서버는 LIST의 n번 메일의 상세 정보를 돌려준다. 보낸 이, 받은 이, 메시지 ID, 제목과 여러 헤더들이 포함되어 있고, 내용은 Content-Transfer-Encoding 헤더의 내용에 따라 인코딩 되어 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1720908059094&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;Date: Sun, 14 Jul 2024 06:55:02 +0900 (KST)
From: =?UTF-8?B?6rmA64yA7KSR?= &amp;lt;me@dekiyu.xyz&amp;gt;
To: =?UTF-8?B?6rmA64yA7KSR?= &amp;lt;me@dekiyu.xyz&amp;gt;
Message-ID: &amp;lt;20240714065502.YHQL0uuWSwe9uqY-DZqS3w@kr.decu.hanmail.net&amp;gt;
Subject: =?UTF-8?B?64K06rKM7JOw6riwIO2FjOyKpO2KuA==?=
Errors-To: &amp;lt;kr.decu@hanmail.net&amp;gt;
MIME-Version: 1.0
Content-Type: text/html; charset=UTF-8
Content-Transfer-Encoding: base64
X-Kakaomail-MID: CvAKNAAAAAgAAAGQrheiVQAM2O8=
X-Mailer: mint

PGh0bWw+CjxoZWFkPgogICAgPHN0eWxlPgogICAgICAgIHB7bWFyZ2luLXRvcDowO21hcmdpbi1i
b3R0b206MH0KICAgIDwvc3R5bGU+CjwvaGVhZD4KPGJvZHk+CiAgICA8ZGl2IHN0eWxlPSJjb2xv
cjojMTExO2ZvbnQtZmFtaWx5OkFwcGxlIFNEIEdvdGhpYyBOZW8sTWFsZ3VuIEdvdGhpYywn66eR
7J2AIOqzoOuUlScsc2Fucy1zZXJpZjtmb250LXNpemU6MTBwdDtsaW5lLWhlaWdodDoxLjU7Ij48
cD7rgrTqsozsk7DquLA8L3A+PHA+PGJyPjwvcD48L2Rpdj4KPC9ib2R5Pgo8L2h0bWw+Cg==&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;받은 이와 보낸 이, 제목의 형식이 익숙하지 않은 형식인데, 이는 Gmail 등 글로벌 이메일 서비스에서 내용을 표시할 때 인코딩 문제로 깨지는 것을 방지하기 위해서 =?UTF-8?B? 문자열과 BASE64로 인코딩 된 문자열을 포함하기 때문이다.&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #ff6347;&quot;&gt;SMTP&lt;/span&gt; 연결&lt;br /&gt;테스트하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;mailpop3는 잘 동작함을 확인했다. 리스트를 어떻게 보여주어야 하는가는 잘 생각해보아야 하겠지만, 아직은 테스트 단계이니 넘어가 보고 이번에는 SMTP 통신을 위해 nodemailer를 테스트해 본다. nodemailer는 이벤트 핸들러 기반으로 작성된 mailpop3에 비해 사용하기 상당히 쉽다. 연결하고 메일을 보내는 코드만 작성하면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1720908748813&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const nodemailer = require(&quot;nodemailer&quot;);

const transporter = nodemailer.createTransport({
    host: process.env.SMTP_HOST,
    port: process.env.SMTP_PORT,
    secure: process.env.SMTP_TLS_SSL == &quot;true&quot;,
    auth: {
        user: process.env.TEST_EMAIL,
        pass: process.env.TEST_PASSWORD
    }
});

transporter.sendMail({
    from: process.env.TEST_EMAIL,
    to: &quot;playerdecuple@dekiyu.xyz&quot;,
    subject: &quot;nodemailer 테스트&quot;,
    html: &quot;&amp;lt;b&amp;gt;Hello, world!&amp;lt;/b&amp;gt;&quot;
}).then(info =&amp;gt; {
    console.log(info);
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;nodemailer는 transport를 생성하고, 생성된 transport가 메일을 송신하는 방식으로 작동한다. mailpop3는 필자가 직접 코드를 작성하여 명령어를 송신해야 하는 데에 반해, nodemailer는 SMTP 명령어를 송신하는 과정을 압축하여 transport를 생성하여 메일을 전송하게끔 만들기만 하면 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대상 메일 주소별 전송 성공 여부, SMTP 서버에서 수신한 응답, 메시지 ID를 돌려받을 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1720909327230&quot; class=&quot;bash&quot; data-ke-language=&quot;bash&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;{
  accepted: [ 'playerdecuple@dekiyu.xyz' ],
  rejected: [],
  ehlo: [ 'PIPELINING', '8BITMIME', 'SIZE 73400320', 'AUTH LOGIN PLAIN' ],
  envelopeTime: 25,
  messageTime: 2114,
  messageSize: 333,
  response: '250 2.0.0 20240714071111.XgLwZpPFRLy30WDiyh1c1w@me.dekiyu.xyz Message accepted for delivery',
  envelope: { from: 'me@dekiyu.xyz', to: [ 'playerdecuple@dekiyu.xyz' ] },
  messageId: '&amp;lt;6cbc1ff1-a44f-d0b0-19b5-4d70ad03f76b@dekiyu.xyz&amp;gt;'
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;받는 이메일 주소를 가진 계정으로 다음 메일에 로그인하면, 다음과 같이 성공적으로 메일을 받았음을 확인해 볼 수 있다. Gmail과 같은 다른 메일 주소로도 스팸 메일로 분류되지 않고 잘 전송되는 모습을 확인할 수 있었다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1063&quot; data-origin-height=&quot;313&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cKNEMy/btsIxebkXXc/FZKtKcghUGpBmR0zKqeFS0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cKNEMy/btsIxebkXXc/FZKtKcghUGpBmR0zKqeFS0/img.png&quot; data-alt=&quot;메일이 성공적으로 전송되었다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cKNEMy/btsIxebkXXc/FZKtKcghUGpBmR0zKqeFS0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcKNEMy%2FbtsIxebkXXc%2FFZKtKcghUGpBmR0zKqeFS0%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;1063&quot; height=&quot;313&quot; data-origin-width=&quot;1063&quot; data-origin-height=&quot;313&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;413&quot; data-origin-height=&quot;157&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/p254Z/btsIAbpXL8d/OY5GOrO81C2DR4UUoK8Fn0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/p254Z/btsIAbpXL8d/OY5GOrO81C2DR4UUoK8Fn0/img.png&quot; data-alt=&quot;Gmail에서도 제목이 깨지지 않은 모습을 확인할 수 있다.&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/p254Z/btsIAbpXL8d/OY5GOrO81C2DR4UUoK8Fn0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fp254Z%2FbtsIAbpXL8d%2FOY5GOrO81C2DR4UUoK8Fn0%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;413&quot; height=&quot;157&quot; data-origin-width=&quot;413&quot; data-origin-height=&quot;157&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Gmail에서도 제목이 깨지지 않은 모습을 확인할 수 있다.&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;color: #ff6347;&quot;&gt;Express.js&lt;/span&gt;&lt;br /&gt;설정해 보기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Express.js는 간단한 코드로도 강력한 서버를 개발할 수 있는 웹 프레임워크이다. 미니멀리즘을 추구하는 것은 아니지만 빨리 개발하기 위해 적은 코드로도 큰 효율을 내기 위해 사용하기로 했다. 백엔드에서 예상되는 주요 기능이 웹 페이지를 보여주는 것과, POP3, SMTP 서버로의 데이터 전송, 클라이언트로부터 파일 업로드받기로 요약할 수 있을 정도로 규모가 크지 않아 의도에 부합한다.&lt;/p&gt;
&lt;pre id=&quot;code_1720909990727&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;require(&quot;dotenv&quot;).config();


const express = require('express');
const app = express();

const publicRouter = require(&quot;./route/public.route&quot;);
const endpointRouter = require(&quot;./route/endpoint.route&quot;);

const port = process.env.EVERYDAY_PORT;


app.listen(port, () =&amp;gt; {
    console.log(`Everyday server is listening on port ${port}.`)
});

app.use(&quot;/assets&quot;, express.static(__dirname + &quot;/assets&quot;));
app.use(&quot;/endpoints&quot;, endpointRouter);
app.use(&quot;/&quot;, publicRouter);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;퍼블리싱된 웹 페이지를 보여주기 위한 라우터와 REST API를 위한 엔드포인트 라우터를 생성하고 연결해 주면 기본적인 Express.js 설정은 끝이다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;마치며&lt;/h3&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 과정을 진행함으로써, Express.js 서버를 설정하고 SMTP, POP3 관련 라이브러리를 사용해 보았으며 플로우 설계를 완료했다. 큰 규모가 아닐 것이라 생각되기에 틈이 날 때 조금씩 개발하여도 빠르게 완료할 수 있으리라고 본다. 프로젝트의 길이가 짧더라도 한 번에 몰아 올릴 정도는 아니기에 여러 부작으로 나누어서 글이 작성될 수 있음에, 불편하시더라도 양해 부탁드린다.&lt;/p&gt;</description>
      <category>개발일지/웹</category>
      <category>Express.js</category>
      <category>mailpop3</category>
      <category>node.js</category>
      <category>nodemailer</category>
      <category>POP3</category>
      <category>SMTP</category>
      <category>메일 클라이언트</category>
      <category>웹메일</category>
      <category>웹메일 클라이언트 개발하기</category>
      <category>이메일</category>
      <author>데키유</author>
      <guid isPermaLink="true">https://playerdecuple.tistory.com/5</guid>
      <comments>https://playerdecuple.tistory.com/5#entry5comment</comments>
      <pubDate>Sun, 14 Jul 2024 09:08:32 +0900</pubDate>
    </item>
  </channel>
</rss>