<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0">
  <channel>
    <title>따꿍의 프로젝트</title>
    <link>https://capprojectfactory.tistory.com/</link>
    <description></description>
    <language>ko</language>
    <pubDate>Tue, 2 Jun 2026 15:47:46 +0900</pubDate>
    <generator>TISTORY</generator>
    <ttl>100</ttl>
    <managingEditor>공장 주인 따꿍</managingEditor>
    <image>
      <title>따꿍의 프로젝트</title>
      <url>https://tistory1.daumcdn.net/tistory/5371127/attach/97cb47d8745c4e279d90af68869cb54d</url>
      <link>https://capprojectfactory.tistory.com</link>
    </image>
    <item>
      <title>[스프린트] 사용한 기술</title>
      <link>https://capprojectfactory.tistory.com/144</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;scrollToTop&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://velog.io/@dubabbi/React-ScrollToTop%EC%9C%BC%EB%A1%9C-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%9D%B4%EB%8F%99-%EC%8B%9C-%ED%99%94%EB%A9%B4-%EC%83%81%EB%8B%A8%EC%9D%B4-%EB%82%98%ED%83%80%EB%82%98%EA%B2%8C-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://velog.io/@dubabbi/React-ScrollToTop%EC%9C%BC%EB%A1%9C-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%9D%B4%EB%8F%99-%EC%8B%9C-%ED%99%94%EB%A9%B4-%EC%83%81%EB%8B%A8%EC%9D%B4-%EB%82%98%ED%83%80%EB%82%98%EA%B2%8C-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1778807427414&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;[React] ScrollToTop으로 페이지 이동 시 화면 상단이 나타나게 구현하기&quot; data-og-description=&quot;프로젝트를 진행하던 도중&amp;hellip; 스크롤을 아래 쪽까지 내린 다음에 페이지를 이동하면 이동한 페이지의 아래쪽이 나타나서 사용자 경험에 문제가 될 것으로 생각했습니다. 실제로 배포를 진행하&quot; data-og-host=&quot;velog.io&quot; data-og-source-url=&quot;https://velog.io/@dubabbi/React-ScrollToTop%EC%9C%BC%EB%A1%9C-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%9D%B4%EB%8F%99-%EC%8B%9C-%ED%99%94%EB%A9%B4-%EC%83%81%EB%8B%A8%EC%9D%B4-%EB%82%98%ED%83%80%EB%82%98%EA%B2%8C-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&quot; data-og-url=&quot;https://velog.io/@dubabbi/React-ScrollToTop으로-페이지-이동-시-화면-상단이-나타나게-구현하기&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bK8sYU/dJMb85WYqt1/CHAyybxtbK9pMC4KCRT2wk/img.png?width=820&amp;amp;height=477&amp;amp;face=0_0_820_477,https://scrap.kakaocdn.net/dn/cfFBqE/dJMb87N1EFU/yfbv2GqxlFcyriVFDW72hk/img.png?width=820&amp;amp;height=477&amp;amp;face=0_0_820_477,https://scrap.kakaocdn.net/dn/btnjPw/dJMb9aKJ7vq/DHGLpzsvUeKEaO04Yp8PV1/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800&quot;&gt;&lt;a href=&quot;https://velog.io/@dubabbi/React-ScrollToTop%EC%9C%BC%EB%A1%9C-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%9D%B4%EB%8F%99-%EC%8B%9C-%ED%99%94%EB%A9%B4-%EC%83%81%EB%8B%A8%EC%9D%B4-%EB%82%98%ED%83%80%EB%82%98%EA%B2%8C-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://velog.io/@dubabbi/React-ScrollToTop%EC%9C%BC%EB%A1%9C-%ED%8E%98%EC%9D%B4%EC%A7%80-%EC%9D%B4%EB%8F%99-%EC%8B%9C-%ED%99%94%EB%A9%B4-%EC%83%81%EB%8B%A8%EC%9D%B4-%EB%82%98%ED%83%80%EB%82%98%EA%B2%8C-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bK8sYU/dJMb85WYqt1/CHAyybxtbK9pMC4KCRT2wk/img.png?width=820&amp;amp;height=477&amp;amp;face=0_0_820_477,https://scrap.kakaocdn.net/dn/cfFBqE/dJMb87N1EFU/yfbv2GqxlFcyriVFDW72hk/img.png?width=820&amp;amp;height=477&amp;amp;face=0_0_820_477,https://scrap.kakaocdn.net/dn/btnjPw/dJMb9aKJ7vq/DHGLpzsvUeKEaO04Yp8PV1/img.jpg?width=800&amp;amp;height=800&amp;amp;face=0_0_800_800');&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;[React] ScrollToTop으로 페이지 이동 시 화면 상단이 나타나게 구현하기&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;프로젝트를 진행하던 도중&amp;hellip; 스크롤을 아래 쪽까지 내린 다음에 페이지를 이동하면 이동한 페이지의 아래쪽이 나타나서 사용자 경험에 문제가 될 것으로 생각했습니다. 실제로 배포를 진행하&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;velog.io&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>웹프로젝트/코드잇</category>
      <author>공장 주인 따꿍</author>
      <guid isPermaLink="true">https://capprojectfactory.tistory.com/144</guid>
      <comments>https://capprojectfactory.tistory.com/144#entry144comment</comments>
      <pubDate>Fri, 15 May 2026 10:25:57 +0900</pubDate>
    </item>
    <item>
      <title>[에디터] 모바일 환경에서 게시글 작성시 AttachmentBar이 가상 키보드만큼 올라가도록 하기</title>
      <link>https://capprojectfactory.tistory.com/143</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;QA 테스트중 에디터로 게시글 작성하면 AttachmentBar이 가상키보드만큼 위로 올라와서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AttachmentBar에 있는 에디터 버블메뉴를 좀 더 쉽게 접근할 수 있게 해달라는 요청이 있었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이 기능은 CommentInput에도 있어서 (코멘트 입력란은 가상키보드만큼 잘 올라감) 참고하면서 작업하려고 했다.&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.notion.so/snorose/AttachmentBar-3537ef0aa3bf80449b2fc244cd78dc5f?source=copy_link&quot;&gt;https://www.notion.so/snorose/AttachmentBar-3537ef0aa3bf80449b2fc244cd78dc5f?source=copy_link&lt;/a&gt;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/snorose/snorose-front/issues/1622&quot;&gt;https://github.com/snorose/snorose-front/issues/1622&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777868860601&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;[MODIFY] AttachmentBar이 키보드 생성시 같이 올라가도록 수정 &amp;middot; Issue #1622 &amp;middot; snorose/snorose-front&quot; data-og-description=&quot;  기능 변경 개요 대상 기능: AttachmentBar와 에디터 변경 이유: 모바일 환경에서도 에디터 활용이 용이하도록 AttachmentBar이 키보드 생성 시 같이 올라가도록 수정할 예정입니다   변경 전후 비&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/snorose/snorose-front/issues/1622&quot; data-og-url=&quot;https://github.com/snorose/snorose-front/issues/1622&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/9JhlL/dJMb9llaPQH/AGL9zGuDGrgmQz9Tt1juLk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/Dc7WP/dJMb9efhF7P/bt2PUXkKrQ8nK7JEhp7okk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/Ciz8K/dJMb9b3VJU9/idVk8hPY32Nw7CSU5ZH0Qk/img.png?width=287&amp;amp;height=588&amp;amp;face=0_0_287_588&quot;&gt;&lt;a href=&quot;https://github.com/snorose/snorose-front/issues/1622&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/snorose/snorose-front/issues/1622&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/9JhlL/dJMb9llaPQH/AGL9zGuDGrgmQz9Tt1juLk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/Dc7WP/dJMb9efhF7P/bt2PUXkKrQ8nK7JEhp7okk/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/Ciz8K/dJMb9b3VJU9/idVk8hPY32Nw7CSU5ZH0Qk/img.png?width=287&amp;amp;height=588&amp;amp;face=0_0_287_588');&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;[MODIFY] AttachmentBar이 키보드 생성시 같이 올라가도록 수정 &amp;middot; Issue #1622 &amp;middot; snorose/snorose-front&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;  기능 변경 개요 대상 기능: AttachmentBar와 에디터 변경 이유: 모바일 환경에서도 에디터 활용이 용이하도록 AttachmentBar이 키보드 생성 시 같이 올라가도록 수정할 예정입니다   변경 전후 비&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;&amp;nbsp;&lt;/h4&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;기초 조사&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CommentInput.module.css의 스타일과 AttachmentBar.module.css의 스타일을 확인했을시 별다른 차이가 없었다.&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/bO9os2/dJMcahEgxZD/g2ZfZKcqKNviVPPcxsYPx1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bO9os2/dJMcahEgxZD/g2ZfZKcqKNviVPPcxsYPx1/img.png&quot; data-origin-width=&quot;734&quot; data-origin-height=&quot;416&quot; data-is-animation=&quot;false&quot; style=&quot;width: 57.2655%; margin-right: 10px;&quot; data-widthpercent=&quot;57.94&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bO9os2/dJMcahEgxZD/g2ZfZKcqKNviVPPcxsYPx1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbO9os2%2FdJMcahEgxZD%2Fg2ZfZKcqKNviVPPcxsYPx1%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;734&quot; height=&quot;416&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baqXMZ/dJMb990AQ2x/2FsgXfggbpSbVfpil8xpRk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baqXMZ/dJMb990AQ2x/2FsgXfggbpSbVfpil8xpRk/img.png&quot; data-origin-width=&quot;757&quot; data-origin-height=&quot;591&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;42.06&quot; style=&quot;width: 41.5718%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baqXMZ/dJMb990AQ2x/2FsgXfggbpSbVfpil8xpRk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaqXMZ%2FdJMb990AQ2x%2F2FsgXfggbpSbVfpil8xpRk%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;757&quot; height=&quot;591&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;둘다 컴포넌트를 아래에 위치시키는 방식이&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;position: fixed하고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;bottom: 0을 하고 있었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 왜 CommentInput 컴포넌트는 자동으로 가상키보드 따라 올라가고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AttachmentBar은 따라가지 않은것인가?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그건 바로&amp;nbsp;&lt;u&gt;브라우저 focus 정책&lt;/u&gt;에 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저는 포커스된 입력 요소를 자동으로 보이게 만든다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CommentInput은 글 작성시 해당 컴포넌트 자체를 focus하니까 문제가 없고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 작업할 AttachmentBar은 focus되는 글 작성 컴포넌트와 해당 컴포넌트와 서로 다르니 문제가 생기는 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 그렇다고 글작성 컴포넌트가 focus될시 AttachmentBar을 focus하면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;사용자 입장에서는 당황스러우니&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 직접 올려주는 방법밖에 없는 것 같다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 focus가 문제인지 확인해보도록 하겠다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;입증을 위한 테스트&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;테스트를 위해 바꾼 내용만 기재했다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- AttachmentBar 안의 input중에 ref 걸기 쉽게 children 넘겨주기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- EditorContainer에 onFocus prop을 추가해서 에디터에 focus할시 어떤 작업을 해야할지 넘겨주기&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; (사실상 AttachmentBar 안의 input에 focus를 넘기는 작업)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; AttachmentBar안의 input의 ref에다가 .current.focus()를 해주기&lt;/p&gt;
&lt;pre id=&quot;code_1777873644184&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function WritePostPage() {
    const attachmentBarRef = useRef();
    return (
        /*...*/
        &amp;lt;EditorContainer
            /*...*/
            onFocus={(e) =&amp;gt; {
                attachmentBarRef.current.focus()
            }}
        /&amp;gt;
        &amp;lt;AttachmentBar
          attachmentsInfo={attachmentsInfo}
          setAttachmentsInfo={setAttachmentsInfo}
          editor={editor}
        &amp;gt;
          &amp;lt;input ref={attachmentBarRef} style={{ opacity: 0 }} /&amp;gt;
        &amp;lt;/AttachmentBar&amp;gt;
    )
}

export default function EditorContainer({/*...*/, onFocus}) {
    return (
        &amp;lt;&amp;gt;
            &amp;lt;div onFocus={onFocus}&amp;gt;
                &amp;lt;EditorContent/&amp;gt;
            &amp;lt;/div&amp;gt;
        &amp;lt;/&amp;gt;
    )
}

export default function AttachmentBar({/*...*/, children}){
    return (
        &amp;lt;div&amp;gt;
            {children}
        &amp;lt;/div&amp;gt;
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c70ZuO/dJMcaaLW00o/Ls0R9SaF3oIgrqn4wZVvH1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c70ZuO/dJMcaaLW00o/Ls0R9SaF3oIgrqn4wZVvH1/img.png&quot; data-origin-width=&quot;282&quot; data-origin-height=&quot;589&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;33.12&quot; style=&quot;width: 32.3482%; margin-right: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c70ZuO/dJMcaaLW00o/Ls0R9SaF3oIgrqn4wZVvH1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc70ZuO%2FdJMcaaLW00o%2FLs0R9SaF3oIgrqn4wZVvH1%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;282&quot; height=&quot;589&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/HH4bl/dJMcaiQHvSD/4kl5AYc1Bfg3lIv7yQJ6D0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/HH4bl/dJMcaiQHvSD/4kl5AYc1Bfg3lIv7yQJ6D0/img.png&quot; data-origin-width=&quot;285&quot; data-origin-height=&quot;587&quot; data-is-animation=&quot;false&quot; style=&quot;width: 32.8038%; margin-right: 10px;&quot; data-widthpercent=&quot;33.58&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/HH4bl/dJMcaiQHvSD/4kl5AYc1Bfg3lIv7yQJ6D0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FHH4bl%2FdJMcaiQHvSD%2F4kl5AYc1Bfg3lIv7yQJ6D0%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;285&quot; height=&quot;587&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/baxSmM/dJMcabjOeCY/HtOcsK64gZWVEqL0eqGvH1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/baxSmM/dJMcabjOeCY/HtOcsK64gZWVEqL0eqGvH1/img.png&quot; data-origin-width=&quot;284&quot; data-origin-height=&quot;590&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;33.3&quot; style=&quot;width: 32.5224%;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/baxSmM/dJMcabjOeCY/HtOcsK64gZWVEqL0eqGvH1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbaxSmM%2FdJMcabjOeCY%2FHtOcsK64gZWVEqL0eqGvH1%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;284&quot; height=&quot;590&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;초기상태&lt;/u&gt; &lt;b&gt;/&lt;/b&gt; &lt;u&gt;테스트전&lt;/u&gt;의 작동모습 (화면이동X, attachmentbar 가려짐) &lt;b&gt;/&lt;/b&gt; &lt;u&gt;테스트후&lt;/u&gt;의 작동모습 (attachmentbar 위치로 화면이동)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이로서 focus의 유무차이가 두 컴포넌트의 차이임을 증명했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;허나 CommentInput처럼 작성하기에는 문제가 여러가지 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. Focus는 사실상 editor 컴포넌트에 부여해야한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 이렇게 AttachmentBar의 위치로 이동하는게 아니라, 화면은 안 움직이고 (그대로 editor이 보이는 상태로)&lt;br /&gt;&amp;nbsp; &amp;nbsp; AttachmentBar이 위로 올라와야한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;따라서, 그냥 새로운 로직을 짜야한다는 것을 깨달았다.&amp;nbsp;&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;새로운 로직 짜기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스택오버플로우 여기저기 찾아보니까&lt;span style=&quot;color: #006dd7;&quot;&gt;&lt;b&gt; VisualViewport API&lt;/b&gt;&lt;/span&gt;를 사용하는 방법을 추천했다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이는&amp;nbsp;&lt;u&gt;resize&lt;/u&gt;이벤트를 듣게해주는 API로,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이름 안의&amp;nbsp;&lt;u&gt;visualViewport&lt;/u&gt;는 on-screen keyboard / pinch-zoom 구역 밖의 구역들 / 등과 같은&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지의 크기와 같이 scale되지 않는 요소들을 뺀&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;순수 사용 화면 크기 부위를 뜻한다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;697&quot; data-origin-height=&quot;472&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bHHLEd/dJMcaf0KTWV/4AUUvjcRKuY4oZRB5rUlvK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bHHLEd/dJMcaf0KTWV/4AUUvjcRKuY4oZRB5rUlvK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bHHLEd/dJMcaf0KTWV/4AUUvjcRKuY4oZRB5rUlvK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbHHLEd%2FdJMcaf0KTWV%2F4AUUvjcRKuY4oZRB5rUlvK%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;490&quot; height=&quot;332&quot; data-origin-width=&quot;697&quot; data-origin-height=&quot;472&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://stackoverflow.com/questions/41684804/how-to-get-keyboard-height-on-a-web-app&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://stackoverflow.com/questions/41684804/how-to-get-keyboard-height-on-a-web-app&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777884147274&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;How to get keyboard height on a web app&quot; data-og-description=&quot;This is for a web app, targeting any mobile browser but mainly Chrome and Safari for iOS10. The browser opens the built-in keyboard when the user clicks on any input, which is fine, but I am trying...&quot; data-og-host=&quot;stackoverflow.com&quot; data-og-source-url=&quot;https://stackoverflow.com/questions/41684804/how-to-get-keyboard-height-on-a-web-app&quot; data-og-url=&quot;https://stackoverflow.com/questions/41684804/how-to-get-keyboard-height-on-a-web-app&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/bTOSZ7/dJMb8U8Xlhz/Ou3KeGPxkh8bwrB8XBZ4A1/img.png?width=360&amp;amp;height=360&amp;amp;face=0_0_360_360&quot;&gt;&lt;a href=&quot;https://stackoverflow.com/questions/41684804/how-to-get-keyboard-height-on-a-web-app&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://stackoverflow.com/questions/41684804/how-to-get-keyboard-height-on-a-web-app&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/bTOSZ7/dJMb8U8Xlhz/Ou3KeGPxkh8bwrB8XBZ4A1/img.png?width=360&amp;amp;height=360&amp;amp;face=0_0_360_360');&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;How to get keyboard height on a web app&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;This is for a web app, targeting any mobile browser but mainly Chrome and Safari for iOS10. The browser opens the built-in keyboard when the user clicks on any input, which is fine, but I am trying...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;stackoverflow.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AttachmentBar을 사용하면 바로 위치 조정 로직을 발동하게&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;useAttachmentUpload 훅에다가 useEffect를 생성하였다.&lt;/p&gt;
&lt;pre id=&quot;code_1777889105034&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useEffect, useRef } from 'react';
export function useAttachmentUpload({/*...*/}){
    const attachmentBarRef = useRef();
    
    useEffect(() =&amp;gt; {
    const vv = window.visualViewport;
    const handler = () =&amp;gt; {
      requestAnimationFrame(() =&amp;gt; {
        const keyboardHeight = window.innerHeight - vv.height;
        const offsetTop = vv.offsetTop;

        attachmentBarRef.current.style.transform =
          keyboardHeight &amp;gt; 0
            ? `translateY(-${keyboardHeight - offsetTop}px)`
            : `translateY(0px)`;
      });
    };
    vv.addEventListener('resize', handler);
    vv.addEventListener('scroll', handler);
    return () =&amp;gt; {
      vv.removeEventListener('resize', handler);
      vv.removeEventListener('scroll', handler);
    };
  }, [attachmentBarRef]);
  
    const changeImageUpload = (e) =&amp;gt; {}
    const changeVideoUpload = (e) =&amp;gt; {}
    
    return {attachmentBarRef, changeImageUpload, changeVideoUpload}
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이렇게 &lt;b&gt;hook 내부에 ref를 생성&lt;/b&gt;해서, 리턴을 한 후&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;hook을 사용하는 컴포넌트에서 꺼내내서 연결하는것이 좋다&lt;/p&gt;
&lt;pre id=&quot;code_1777889376943&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function AttachmentBar({/*...*/}){
    const { attachmentBarRef, changeImageUpload, changeVideoUpload } =
        useAttachmentUpload({
          attachmentsInfo,
          setAttachmentsInfo,
        });
    /*...*/
    return (
        &amp;lt;div ref={attachmentBarRef}&amp;gt;&amp;lt;/div&amp;gt;
    )
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;  &lt;u&gt;문제점&lt;/u&gt;: visualViewport resize시 키보드 높이 계산해서 translate로 직접 올려주는 방식은&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스크롤할때마다 재계산해서 컴포넌트가 키보드에 의해 끌려가는 느낌을 준다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-filename=&quot;KakaoTalk_20260504_172513925 (online-video-cutter.com) (1).gif&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/QhJjD/dJMcagZFh9v/2ksy5C0osiszbKOHNA509K/img.gif&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/QhJjD/dJMcagZFh9v/2ksy5C0osiszbKOHNA509K/img.gif&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/QhJjD/dJMcagZFh9v/2ksy5C0osiszbKOHNA509K/img.gif&quot; srcset=&quot;https://blog.kakaocdn.net/dn/QhJjD/dJMcagZFh9v/2ksy5C0osiszbKOHNA509K/img.gif&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;1280&quot; height=&quot;720&quot; data-filename=&quot;KakaoTalk_20260504_172513925 (online-video-cutter.com) (1).gif&quot; data-origin-width=&quot;1280&quot; data-origin-height=&quot;720&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;대신 태블릿에서 확인해보니 정말 부드럽게 잘 따라가는것을 확인했다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아무래도 연산력 차이인것 같다.&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;다른 사이트 참고 및 결론&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;블라인드 웹&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 원래 방식대로 되어 있었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;스크롤하면 attachmentbar이 사라지는 방식이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;결론&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 팀원들에게 상황을 공유하고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그냥 스크롤하면 사라지는 그대로 둘것인지 아니면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;좀 렉걸리고 키보드를 따라다니는것 같아도 내가 작성한 방식대로 할것인지 물어보니&lt;/p&gt;
&lt;p&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/cx3Ii7/dJMcadokG7K/VIzHTLjbpkeRLrJI7GLMsk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cx3Ii7/dJMcadokG7K/VIzHTLjbpkeRLrJI7GLMsk/img.png&quot; data-origin-width=&quot;473&quot; data-origin-height=&quot;509&quot; data-is-animation=&quot;false&quot; style=&quot;width: 51.086%; margin-right: 10px;&quot; data-widthpercent=&quot;51.69&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cx3Ii7/dJMcadokG7K/VIzHTLjbpkeRLrJI7GLMsk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcx3Ii7%2FdJMcadokG7K%2FVIzHTLjbpkeRLrJI7GLMsk%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;473&quot; height=&quot;509&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/9cDvd/dJMb990BmcF/SMQTrwsQ5OFs6N5R7ueZrk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/9cDvd/dJMb990BmcF/SMQTrwsQ5OFs6N5R7ueZrk/img.png&quot; data-origin-width=&quot;476&quot; data-origin-height=&quot;548&quot; data-is-animation=&quot;false&quot; style=&quot;width: 47.7512%;&quot; data-widthpercent=&quot;48.31&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/9cDvd/dJMb990BmcF/SMQTrwsQ5OFs6N5R7ueZrk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F9cDvd%2FdJMb990BmcF%2FSMQTrwsQ5OFs6N5R7ueZrk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;476&quot; height=&quot;548&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내가 작업한게 괜찮다고 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 이대로 PR을 올릴려고 한다.&amp;nbsp;&lt;/p&gt;</description>
      <category>웹프로젝트/스노로즈</category>
      <author>공장 주인 따꿍</author>
      <guid isPermaLink="true">https://capprojectfactory.tistory.com/143</guid>
      <comments>https://capprojectfactory.tistory.com/143#entry143comment</comments>
      <pubDate>Mon, 4 May 2026 15:16:36 +0900</pubDate>
    </item>
    <item>
      <title>[개인] status bar 투명하게 만들 수 있는가: meta theme-color</title>
      <link>https://capprojectfactory.tistory.com/142</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;발단&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;디자인팀에서 status bar을 투명하게 만들 수 있냐고 여쭤봤었는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;담당하던 팀원이 나가서 담당하게 되었다&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Status Bar 색깔 도입하는 방법&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보통은 meta태그의 theme-color로 처리한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;우리 스노로즈 역시 이 속성으로 white로 지정했다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;890&quot; data-origin-height=&quot;569&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/c9Gl8f/dJMcab47yCL/Gt7VbAfjlv61ep3UsI9oC0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/c9Gl8f/dJMcab47yCL/Gt7VbAfjlv61ep3UsI9oC0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/c9Gl8f/dJMcab47yCL/Gt7VbAfjlv61ep3UsI9oC0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fc9Gl8f%2FdJMcab47yCL%2FGt7VbAfjlv61ep3UsI9oC0%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;785&quot; height=&quot;502&quot; data-origin-width=&quot;890&quot; data-origin-height=&quot;569&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;910&quot; data-origin-height=&quot;587&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/d7naeW/dJMcajotvTK/fsVj5wXXY08DMDDubzc6HK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/d7naeW/dJMcajotvTK/fsVj5wXXY08DMDDubzc6HK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/d7naeW/dJMcajotvTK/fsVj5wXXY08DMDDubzc6HK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fd7naeW%2FdJMcajotvTK%2FfsVj5wXXY08DMDDubzc6HK%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;779&quot; height=&quot;502&quot; data-origin-width=&quot;910&quot; data-origin-height=&quot;587&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;889&quot; data-origin-height=&quot;577&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ci6jK7/dJMcabxgBbV/dlxVj6NtpUP6SCcW1sxyI0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ci6jK7/dJMcabxgBbV/dlxVj6NtpUP6SCcW1sxyI0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ci6jK7/dJMcabxgBbV/dlxVj6NtpUP6SCcW1sxyI0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fci6jK7%2FdJMcabxgBbV%2FdlxVj6NtpUP6SCcW1sxyI0%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;758&quot; height=&quot;492&quot; data-origin-width=&quot;889&quot; data-origin-height=&quot;577&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/theme-color&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Elements/meta/name/theme-color&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/theme_color&quot;&gt;https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/theme_color&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777641109273&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;theme_color - Web app manifest | MDN&quot; data-og-description=&quot;&quot; data-og-host=&quot;developer.mozilla.org&quot; data-og-source-url=&quot;https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/theme_color&quot; data-og-url=&quot;https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/theme_color&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/theme_color&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://developer.mozilla.org/en-US/docs/Web/Progressive_web_apps/Manifest/Reference/theme_color&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;theme_color - Web app manifest | MDN&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;developer.mozilla.org&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;공식문서를 찾아보면 브라우저마다 opacity는 적용될수도 안될수도 있는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;웬만해서는 적용 안된다고 한다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;877&quot; data-origin-height=&quot;269&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/ci436a/dJMcaciFtwa/Jr6roLYkDHjhGKwBnnS3l0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/ci436a/dJMcaciFtwa/Jr6roLYkDHjhGKwBnnS3l0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/ci436a/dJMcaciFtwa/Jr6roLYkDHjhGKwBnnS3l0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fci436a%2FdJMcaciFtwa%2FJr6roLYkDHjhGKwBnnS3l0%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;612&quot; height=&quot;188&quot; data-origin-width=&quot;877&quot; data-origin-height=&quot;269&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;실제 테스트&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bX4BLq/dJMcafNgbi2/QHvYtk5NMsDLHCaalxOrlK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bX4BLq/dJMcafNgbi2/QHvYtk5NMsDLHCaalxOrlK/img.png&quot; data-origin-width=&quot;311&quot; data-origin-height=&quot;658&quot; data-is-animation=&quot;false&quot; data-widthpercent=&quot;17.58&quot; style=&quot;width: 17.3777%; margin-right: 10px;&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bX4BLq/dJMcafNgbi2/QHvYtk5NMsDLHCaalxOrlK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FbX4BLq%2FdJMcafNgbi2%2FQHvYtk5NMsDLHCaalxOrlK%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;311&quot; height=&quot;658&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cv8kP0/dJMcaffqIit/soUAjVON8F7Zb6gG4sdNpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cv8kP0/dJMcaffqIit/soUAjVON8F7Zb6gG4sdNpk/img.png&quot; data-origin-width=&quot;740&quot; data-origin-height=&quot;334&quot; data-is-animation=&quot;false&quot; style=&quot;width: 81.4596%;&quot; data-widthpercent=&quot;82.42&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cv8kP0/dJMcaffqIit/soUAjVON8F7Zb6gG4sdNpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fcv8kP0%2FdJMcaffqIit%2FsoUAjVON8F7Zb6gG4sdNpk%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;740&quot; height=&quot;334&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보면 버튼과 theme-color 모두 같은 opacity로 지정했지만&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;버튼만 거의 보이지 않고 &lt;u&gt;theme-color는 뒤의 opacity는 무시하고 색만 받은게&lt;/u&gt; 보인다&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;결론&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안되고 권장되지 않는다!&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;추가문제&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;iOS에서 status bar이 페이지마다 색깔이 다르다고 한다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나는 안드로이드라서 알길이 없지만 무슨 연유로 페이지마다 색이 다르다는 팀원들의 제보가 들어왔다.&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;아이폰(미성, 희원, 선진)
&lt;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;/li&gt;
&lt;li&gt;갤럭시(민주)
&lt;ul style=&quot;list-style-type: disc;&quot; data-ke-list-type=&quot;disc&quot;&gt;
&lt;li&gt;모든 페이지에서 하얀색&lt;/li&gt;
&lt;/ul&gt;
&lt;/li&gt;
&lt;/ul&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;리액트는 SPA이기 때문에 theme-color를 지정하면 모든 페이지에 공통으로 설정됨 &lt;br /&gt;페이지 별 커스텀이 어려움 &lt;br /&gt;아이폰에서 다르게 보이는 이유는 OS 차이인 걸로 추정 &lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>웹프로젝트/스노로즈</category>
      <author>공장 주인 따꿍</author>
      <guid isPermaLink="true">https://capprojectfactory.tistory.com/142</guid>
      <comments>https://capprojectfactory.tistory.com/142#entry142comment</comments>
      <pubDate>Fri, 1 May 2026 22:19:00 +0900</pubDate>
    </item>
    <item>
      <title>MongoDB 연결 중 ECONNREFUSED 에러</title>
      <link>https://capprojectfactory.tistory.com/141</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;문제&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;서버를 MongoDB랑 연결하려니까 문제가 생겼다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;db pw랑 연결 링크도 제대로 넣었는데 계속 이 문제가 떴다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignCenter&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1332&quot; data-origin-height=&quot;521&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/mqG0K/dJMcafTZSLg/iRkt370yNVTNx8q88PMdk1/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/mqG0K/dJMcafTZSLg/iRkt370yNVTNx8q88PMdk1/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/mqG0K/dJMcafTZSLg/iRkt370yNVTNx8q88PMdk1/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FmqG0K%2FdJMcafTZSLg%2FiRkt370yNVTNx8q88PMdk1%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;521&quot; data-origin-width=&quot;1332&quot; data-origin-height=&quot;521&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;AI는 예상했지만 이런 문제에는 도움이 안되서 오랜만에 구글 서치 들어갔다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;발생 이유와 해결방법&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://www.reddit.com/r/mongodb/comments/1qojt61/querysrv_econnrefused_when_connecting_mongodb/?show=original&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://www.reddit.com/r/mongodb/comments/1qojt61/querysrv_econnrefused_when_connecting_mongodb/?show=original&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777555626183&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;website&quot; data-og-title=&quot;Reddit의 mongodb 커뮤니티&quot; data-og-description=&quot;mongodb 커뮤니티에서 이 게시물을 비롯한 다양한 콘텐츠를 살펴보세요&quot; data-og-host=&quot;www.reddit.com&quot; data-og-source-url=&quot;https://www.reddit.com/r/mongodb/comments/1qojt61/querysrv_econnrefused_when_connecting_mongodb/?show=original&quot; data-og-url=&quot;https://www.reddit.com/r/mongodb/comments/1qojt61/querysrv_econnrefused_when_connecting_mongodb/?show=original&amp;amp;seeker-session=true&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/boeL2l/dJMb87f9QPp/CXLN3QeFkjl0zSfM3AAWak/img.jpg?width=1120&amp;amp;height=584&amp;amp;face=0_0_1120_584,https://scrap.kakaocdn.net/dn/L2DhI/dJMb86n0XeM/Xwaj0QZb4Ed9TDEAv9t3W1/img.jpg?width=1120&amp;amp;height=584&amp;amp;face=0_0_1120_584&quot;&gt;&lt;a href=&quot;https://www.reddit.com/r/mongodb/comments/1qojt61/querysrv_econnrefused_when_connecting_mongodb/?show=original&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://www.reddit.com/r/mongodb/comments/1qojt61/querysrv_econnrefused_when_connecting_mongodb/?show=original&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/boeL2l/dJMb87f9QPp/CXLN3QeFkjl0zSfM3AAWak/img.jpg?width=1120&amp;amp;height=584&amp;amp;face=0_0_1120_584,https://scrap.kakaocdn.net/dn/L2DhI/dJMb86n0XeM/Xwaj0QZb4Ed9TDEAv9t3W1/img.jpg?width=1120&amp;amp;height=584&amp;amp;face=0_0_1120_584');&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;Reddit의 mongodb 커뮤니티&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;mongodb 커뮤니티에서 이 게시물을 비롯한 다양한 콘텐츠를 살펴보세요&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;www.reddit.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Reddit 포스트를 보니까 Windows Node v24가 Mongodb와 문제가 생기나보다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/nodejs/node/pull/61453&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/nodejs/node/pull/61453&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777555656761&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;dns: fix Windows SRV ECONNREFUSED regression by correcting c-ares fallback detection by NotVivek12 &amp;middot; Pull Request #61453 &amp;middot; nod&quot; data-og-description=&quot;dns: fix Windows SRV ECONNREFUSED regression by correcting c-ares fallback detection Summary This PR fixes a regression introduced in Node.js v24.13.0 on Windows where DNS SRV lookups (commonly use...&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/nodejs/node/pull/61453&quot; data-og-url=&quot;https://github.com/nodejs/node/pull/61453&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/jrwox/dJMb8YpYJGh/wWZ4u4Isdsnm5As6EQB6aK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/gTDqr/dJMb9eTSYh8/yJCGYKCtedbxdiIluiFTZ1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/nodejs/node/pull/61453&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/nodejs/node/pull/61453&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/jrwox/dJMb8YpYJGh/wWZ4u4Isdsnm5As6EQB6aK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/gTDqr/dJMb9eTSYh8/yJCGYKCtedbxdiIluiFTZ1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&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;dns: fix Windows SRV ECONNREFUSED regression by correcting c-ares fallback detection by NotVivek12 &amp;middot; Pull Request #61453 &amp;middot; nod&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;dns: fix Windows SRV ECONNREFUSED regression by correcting c-ares fallback detection Summary This PR fixes a regression introduced in Node.js v24.13.0 on Windows where DNS SRV lookups (commonly use...&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;app.js 상단에 아래 코드를 추가하면 작동한다고 한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777555686907&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { setServers } from &quot;node:dns/promises&quot;;

setServers([&quot;1.1.1.1&quot;, &quot;8.8.8.8&quot;]);&lt;/code&gt;&lt;/pre&gt;</description>
      <category>웹프로젝트/코드잇</category>
      <author>공장 주인 따꿍</author>
      <guid isPermaLink="true">https://capprojectfactory.tistory.com/141</guid>
      <comments>https://capprojectfactory.tistory.com/141#entry141comment</comments>
      <pubDate>Thu, 30 Apr 2026 22:28:10 +0900</pubDate>
    </item>
    <item>
      <title>[프론트] 브라우저가 작동하는 방식을 서술하시오</title>
      <link>https://capprojectfactory.tistory.com/140</link>
      <description>&lt;h3 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size23&quot;&gt;전반적인 구조&lt;/h3&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;751&quot; data-origin-height=&quot;412&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/cctgWf/dJMcabqsALp/ZaMYGhz5R70lrQxMYFfpkk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/cctgWf/dJMcabqsALp/ZaMYGhz5R70lrQxMYFfpkk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/cctgWf/dJMcabqsALp/ZaMYGhz5R70lrQxMYFfpkk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FcctgWf%2FdJMcabqsALp%2FZaMYGhz5R70lrQxMYFfpkk%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;518&quot; height=&quot;412&quot; data-origin-width=&quot;751&quot; data-origin-height=&quot;412&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p style=&quot;color: #666666; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;위는&amp;nbsp;&lt;b&gt;Critical Rendering Path (CRP)&lt;/b&gt;로&lt;/p&gt;
&lt;p style=&quot;color: #666666; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;브라우저가&lt;span&gt;&amp;nbsp;&lt;/span&gt;&lt;u&gt;HTML -&amp;gt; 화면 픽셀&lt;/u&gt;로 바꾸는 과정 중&lt;/p&gt;
&lt;p style=&quot;color: #666666; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;처음 화면을 그리기까지 필요한 핵심 단계를 의미한다. (최소 렌더링 과정)&lt;/p&gt;
&lt;p style=&quot;color: #666666; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;DOM (Document Object Model): HTML과 다르게 JS로 조작 가능한 객체 형태이다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #666666; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #666666; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;이 CRP가 느리면&lt;/p&gt;
&lt;p style=&quot;color: #666666; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;- First Paint 늦어짐&lt;/p&gt;
&lt;p style=&quot;color: #666666; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;- UX 나빠짐&lt;/p&gt;
&lt;p style=&quot;color: #666666; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;- SEO에도 영향이 감&lt;/p&gt;
&lt;p style=&quot;color: #666666; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;=&amp;gt; Blocking(다 받아야 다음단계로 넘어감)한 CSS&lt;/p&gt;</description>
      <category>오늘의 개발지식/기술면접 준비</category>
      <author>공장 주인 따꿍</author>
      <guid isPermaLink="true">https://capprojectfactory.tistory.com/140</guid>
      <comments>https://capprojectfactory.tistory.com/140#entry140comment</comments>
      <pubDate>Fri, 24 Apr 2026 17:27:29 +0900</pubDate>
    </item>
    <item>
      <title>[프론트] Event 버블링과 캡쳐링을 서술하고 이를 방지하기 위한 방법을 서술하세요</title>
      <link>https://capprojectfactory.tistory.com/139</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;한줄 정리&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이벤트 버블링은 이벤트가 &lt;u&gt;발생한 요소에서부터&amp;nbsp;부모로 전파&lt;/u&gt;되며 상위요소의 이벤트 핸들러가 실행되는 현상을 뜻합니다.&amp;nbsp;&lt;br /&gt;브라우저의 이벤트 흐름은 캡처링 &amp;rarr; 타겟 &amp;rarr; 버블링 순서로 이루어지며,&lt;br /&gt;버블링 단계에서 자식에서 발생한 이벤트가 부모로 전달됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 방지하기 위한 방법은 바로&amp;nbsp;&lt;b&gt;e.stopPropagation()&lt;/b&gt;입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;설명&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;안에 있는 버튼을 클릭했는데 밖의 부모의 onClick이 발동하는 경우를 보셨을겁니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이게 바로 Event 버블링을 고려하지 않아서 발생한 문제인데요,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저가 어떤 식으로 이벤트를 인식하는지 이해하면&amp;nbsp; 어렵지 않은 개념입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;브라우저의 이벤트 DOM 트리에서 &lt;u&gt;캡처링 단계에&lt;/u&gt; 자식까지 내려갔다가&lt;br /&gt;자식에서 다시 root인 html로 다시 돌아가는 &lt;u&gt;버블링&lt;/u&gt;과정에서 이벤트를 인식해내는데요,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러는 과정에서 자식에서 발동한 이벤트가 부모의 이벤트 리스너를 발동합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;&lt;u&gt;캡처링&lt;/u&gt;: 이벤트가 타겟까지 내려오는 과정&lt;br /&gt;&lt;u&gt;버블링&lt;/u&gt;: 이벤트가 타겟에서 부모 방향으로 전파되는 과&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 막기 위해서는 자식에서 e.stopPropagation를 해줘야합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면 해당 함수를 호출한 그 시점에서 전파를 중단합니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>오늘의 개발지식/기술면접 준비</category>
      <author>공장 주인 따꿍</author>
      <guid isPermaLink="true">https://capprojectfactory.tistory.com/139</guid>
      <comments>https://capprojectfactory.tistory.com/139#entry139comment</comments>
      <pubDate>Fri, 24 Apr 2026 16:58:04 +0900</pubDate>
    </item>
    <item>
      <title>[프론트] Promise의 3단계에 대해 서술하시오</title>
      <link>https://capprojectfactory.tistory.com/138</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;한줄 정의&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Promise는 비동기 작업을 완수하는데 사용하는 JS 내장 객체입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;JS 컴파일러 자체가 싱글 스레드라 자체적으로 비동기를 지원하지 않는데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이를 위해 JS는 이벤트 루프, Web API, 그리고 Task Queue를 사용합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 흐름에서 Promise는 &lt;u&gt;비동기 결과를 다루기 쉽게&lt;/u&gt; 하는 객체입니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 Promise는 3가지 단계가 있는데, 바로&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Pending&lt;/b&gt;,&lt;b&gt; Fulfilled&lt;/b&gt;와 &lt;b&gt;Rejected&lt;/b&gt; 단계입니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;executor 함수 안의 resolve와 reject를 통해서 상태를 변경하며, 한번 상태가 결정되면 이후에 변하지 않습니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;처음에는 &lt;u&gt;Pending(대기 상태)&lt;/u&gt;이고,&lt;br /&gt;작업이 성공하면 &lt;u&gt;Fulfilled&lt;/u&gt;, 실패하면 &lt;u&gt;Rejected&lt;/u&gt; 상태로 전이됩니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이러한 Promise의 상태는 Heap안에 저장된&amp;nbsp; Promise 객체 안에 기록됩니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;추가 설명&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Pending&lt;/b&gt; 단계는 아직 작업이 끝나지 않은,&amp;nbsp;&lt;u&gt;대기 상태&lt;/u&gt;를 지칭합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아직 resolve/reject가 호출되지 않 아직 then 안의 콜백함수가 실행되지 않고 기다립니다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Fulfilled&lt;/b&gt; 단계는 비동기 작업을&amp;nbsp;&lt;u&gt;성공&lt;/u&gt;적으로 완수한 상태를 지칭합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Promise 콜백함수 안에 resolve()가 fulfill 상태를 발동하고 결과값을 전달합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;resolve로 전달된 값은 then을 통해 전달받을 수 있습니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Rejected&lt;/b&gt; 단계는 비동기 작업을&amp;nbsp;&lt;u&gt;실패&lt;/u&gt;한 상태를 지칭합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Promise 콜백 함수 안에 reject가 rejected 상태를 발동하고 에러를 전달합니다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;전달된 에러는 catch로 받습니다.&lt;/p&gt;
&lt;pre id=&quot;code_1777015733298&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const fetchSomething = () =&amp;gt;
  new Promise((res, rej) =&amp;gt; {
    if (실패) {
      rej(error);
      return;
    }
    res(data);
  });

fetchSomething().then((data)=&amp;gt; {console.log(data)}
    .catch((e) =&amp;gt; {console.error(e)})&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>오늘의 개발지식/기술면접 준비</category>
      <author>공장 주인 따꿍</author>
      <guid isPermaLink="true">https://capprojectfactory.tistory.com/138</guid>
      <comments>https://capprojectfactory.tistory.com/138#entry138comment</comments>
      <pubDate>Fri, 24 Apr 2026 16:39:09 +0900</pubDate>
    </item>
    <item>
      <title>[Sprint 5] React 적용 (path alias 설정, layout 설정, element 사이사이에만 라인 설정하기, 반응형 디자인, useMediaQuery)</title>
      <link>https://capprojectfactory.tistory.com/137</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;스프린트 내용&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;862&quot; data-origin-height=&quot;739&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/be6Iy7/dJMcadaE7BV/4paNAo74N035M74PZK1rVK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/be6Iy7/dJMcadaE7BV/4paNAo74N035M74PZK1rVK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/be6Iy7/dJMcadaE7BV/4paNAo74N035M74PZK1rVK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbe6Iy7%2FdJMcadaE7BV%2F4paNAo74N035M74PZK1rVK%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;548&quot; height=&quot;470&quot; data-origin-width=&quot;862&quot; data-origin-height=&quot;739&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/quothraven1122/13-sprint-mission-fe/issues/32&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/quothraven1122/13-sprint-mission-fe/issues/32&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1777099917157&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;feat: create market page &amp;middot; Issue #32 &amp;middot; quothraven1122/13-sprint-mission-fe&quot; data-og-description=&quot;  기능 설명 Figma 디자인 중고마켓 페이지를 작성할 계획입니다.   작업 흐름 공통 컴포넌트 생성 버튼 생성 상품 컴포넌트 생성 검색창 생성 dropdown 생성 페이지 구성 생성 Header 작성하기 Body&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/quothraven1122/13-sprint-mission-fe/issues/32&quot; data-og-url=&quot;https://github.com/quothraven1122/13-sprint-mission-fe/issues/32&quot; data-og-image=&quot;&quot;&gt;&lt;a href=&quot;https://github.com/quothraven1122/13-sprint-mission-fe/issues/32&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/quothraven1122/13-sprint-mission-fe/issues/32&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url();&quot;&gt;&amp;nbsp;&lt;/div&gt;
&lt;div class=&quot;og-text&quot;&gt;
&lt;p class=&quot;og-title&quot; data-ke-size=&quot;size16&quot;&gt;feat: create market page &amp;middot; Issue #32 &amp;middot; quothraven1122/13-sprint-mission-fe&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;  기능 설명 Figma 디자인 중고마켓 페이지를 작성할 계획입니다.   작업 흐름 공통 컴포넌트 생성 버튼 생성 상품 컴포넌트 생성 검색창 생성 dropdown 생성 페이지 구성 생성 Header 작성하기 Body&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;작업 흐름&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- vite로 프로젝트 생성하기 (React + JS Compiler)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- vite starter template 지우기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;layout&lt;/b&gt; 만들고 &lt;b&gt;header&lt;/b&gt;와&amp;nbsp;&lt;b&gt;footer&lt;/b&gt;만들기&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; MainLayout이라는걸 만들어서 Header / Page / Footer 구조의 레이아웃 생성하고 App.jsx에 넣음&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 중간의 Page는 &lt;u&gt;children&lt;/u&gt;으로 받는 형식으로 작성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 전체 layout의 &lt;u&gt;min-height를 100vh, display는 flex로 지정&lt;/u&gt;하고&amp;nbsp;&lt;u&gt;page를 flex:1&lt;/u&gt;로 정해서 무조건 footer이 밑에 오도록 작성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; (밑의 &quot;Footer이 무조건 밑에 있는 Layout 구조&quot; 섹션 확인해보세요)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- useEffect에 fetch를 하는 패턴이 너무 길고 생각할게 많아서 그냥 axios를 다운받아서 사용했다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Button, Item, ProductCardList, Input, Dropdown, Pagination &lt;b&gt;공통 컴포넌트&lt;/b&gt; 생성&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp;&amp;nbsp;&lt;u&gt;상태관리는 무조건 부모인 MarketPage&lt;/u&gt;에서 관리한다는 생각으로 작성해야한다.⭐&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 그래서&amp;nbsp;&lt;b&gt;state는 무조건 page에만 존재&lt;/b&gt;하게 하고, 컴포넌트는 UI만 보이게 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; props로는&amp;nbsp;&lt;b&gt;필요한 상태&lt;/b&gt;, &lt;b&gt;데이터&lt;/b&gt;, &lt;b&gt;클래스네임&lt;/b&gt;, &lt;b&gt;주요 이벤트 헨들러&lt;/b&gt;&amp;nbsp;(onChange, onKeyDown) 만 전달하면 되는 듯 하다&lt;/p&gt;
&lt;table style=&quot;border-collapse: collapse; width: 100%;&quot; border=&quot;1&quot; data-ke-align=&quot;alignLeft&quot; data-ke-style=&quot;style13&quot;&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 28.0814%; text-align: center;&quot;&gt;전달할 Props&lt;/td&gt;
&lt;td style=&quot;width: 18.8952%; text-align: center;&quot;&gt;예시&lt;/td&gt;
&lt;td style=&quot;width: 53.0233%; text-align: center;&quot;&gt;필요한 이유&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 28.0814%; text-align: center;&quot;&gt;데이터&lt;/td&gt;
&lt;td style=&quot;width: 18.8952%; text-align: center;&quot;&gt;placeholder, title, column&lt;/td&gt;
&lt;td style=&quot;width: 53.0233%; text-align: center;&quot;&gt;재사용성 (데이터 주어진거에 따라 조금씩 다른 컴포넌트 만들어짐)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 28.0814%; text-align: center;&quot;&gt;자식&lt;/td&gt;
&lt;td style=&quot;width: 18.8952%; text-align: center;&quot;&gt;children&lt;/td&gt;
&lt;td style=&quot;width: 53.0233%; text-align: center;&quot;&gt;재사용성 (자식 주어진거에 따라 조금씩 다른 컴포넌트 만들어짐)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 28.0814%; text-align: center;&quot;&gt;부모로부터 오는 state&lt;/td&gt;
&lt;td style=&quot;width: 18.8952%; text-align: center;&quot;&gt;value, data&lt;/td&gt;
&lt;td style=&quot;width: 53.0233%; text-align: center;&quot;&gt;state에 따른 UI 변경&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 28.0814%; text-align: center;&quot;&gt;클래스&lt;/td&gt;
&lt;td style=&quot;width: 18.8952%; text-align: center;&quot;&gt;className&lt;/td&gt;
&lt;td style=&quot;width: 53.0233%; text-align: center;&quot;&gt;커스텀 스타일링 가능하게 만들기&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td style=&quot;width: 28.0814%; text-align: center;&quot;&gt;주요 이벤트 헨들러&lt;/td&gt;
&lt;td style=&quot;width: 18.8952%; text-align: center;&quot;&gt;onChange, &lt;br /&gt;onKeyDown&lt;/td&gt;
&lt;td style=&quot;width: 53.0233%; text-align: center;&quot;&gt;setState를 직접 주는 대신,&amp;nbsp;&lt;br /&gt;어떤 이벤트가 일어나면 이러한 행동을 했으면 좋겠다를 정의하는&lt;br /&gt;&lt;u&gt;여러 setState&lt;/u&gt;를&amp;nbsp;&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Button.jsx&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터인 variant와 disabled, 자식인 children, 그리고 이벤트 헨들러인 onClick을 받는다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777121766514&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function Button({
  variant,
  children,
  disabled = false,
  onClick,
}) {
  return (
    &amp;lt;button
      type=&quot;button&quot;
      disabled={disabled}
      onClick={onClick}
      className={`${styles.btn} ${styles[`btn-${variant}`]}`}
    &amp;gt;
      {children}
    &amp;lt;/button&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Item.jsx&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보다시피 컴포넌트는 오로지 UI만 담당해야해서 data만 받는다.&lt;/p&gt;
&lt;pre id=&quot;code_1777119348340&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function Item({ data }) {
  return (
    &amp;lt;div className={styles.container}&amp;gt;
      &amp;lt;img
        src={data.images[0] || defaultImg}
        loading=&quot;lazy&quot;
        onError={(e) =&amp;gt; {
          e.target.src = defaultImg;
        }}
        className={styles.img}
      /&amp;gt;
      &amp;lt;div className={styles.content}&amp;gt;
        &amp;lt;h2 className={styles.title}&amp;gt;{data.name}&amp;lt;/h2&amp;gt;
        &amp;lt;h3 className={styles.price}&amp;gt;{data.price.toLocaleString()}원&amp;lt;/h3&amp;gt;
        &amp;lt;div className={styles.meta}&amp;gt;
          &amp;lt;img src={icHeartEmpty} /&amp;gt;
          {data.favoriteCount}
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;ProductCardList.jsx&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Item 그룹의 타이틀과 아이템들을 디스플레이하는 컴포넌트다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역시 계산하는 로직은 하나도 없고 오로지 데이터인 title, column과 state인 data, 그리고 자식인 children을 받는다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777120108841&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function ProductCardList({ title, column = 5, data, children }) {
  return (
    &amp;lt;div&amp;gt;
      &amp;lt;div className={styles.top}&amp;gt;
        &amp;lt;h1 className={styles.title}&amp;gt;{title}&amp;lt;/h1&amp;gt;
        &amp;lt;div className={styles.toolbar}&amp;gt;{children}&amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
      &amp;lt;div className={styles.list} style={{ &quot;--column-count&quot;: column }}&amp;gt;
        {data.map((d) =&amp;gt; (
          &amp;lt;Item data={d} key={d.id} /&amp;gt;
        ))}
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Input.jsx&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;역시 계산하는 로직은 넣으면 안된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;setState를 바로 Input에 전달하는게 아니라,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;input 입력 값이 바뀌면 해야하는 모든 전반적인 작업을&amp;nbsp;&lt;u&gt;onChange&lt;/u&gt;라는 이벤트 헨들러 함수에 넣어서 전달한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터인 placeholder, state인 value, 이벤트 헨들러인 onChnage와 onKeyDown, 그리고 클래스인 className을 전달받는다.&lt;/p&gt;
&lt;pre id=&quot;code_1777120716039&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;Input
  placeholder=&quot;검색할 상품을 입력해주세요&quot;
  value={input}
  onChange={(e) =&amp;gt; {
    setInput(e.target.value);
    if (e.target.value.length === 0) {
      setPage(1);
      setKeyword(e.target.value);
    }
  }}
  onKeyDown={(e) =&amp;gt; {
    if (e.code === &quot;Enter&quot;) {
      setPage(1);
      setKeyword(input);
    }
  }}
  className={styles.input}
/&amp;gt;;

export default function Input({
  placeholder,
  value,
  onChange,
  onKeyDown,
  className,
}) {
  return (
    &amp;lt;div className={`${styles.container} ${className}`}&amp;gt;
      &amp;lt;img src={icSearch} /&amp;gt;
      &amp;lt;input
        placeholder={placeholder}
        value={value}
        onChange={onChange}
        onKeyDown={onKeyDown}
      /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Dropdown.jsx&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;데이터, state, 그리고 이벤트 헨들러를 받는다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777121467429&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function Dropdown({ menu, value, onChange }) {
  const [open, toggle] = useDropdown();
  return (
    &amp;lt;div className={styles.container}&amp;gt;
      &amp;lt;div className={styles.currentContainer} onClick={toggle}&amp;gt;
        &amp;lt;div className={styles.current}&amp;gt;
          &amp;lt;p&amp;gt;{value.name}&amp;lt;/p&amp;gt;
          &amp;lt;img src={icArrowDown} className={styles.icon} /&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;

      &amp;lt;div className={`${styles.dropdown} ${!open &amp;amp;&amp;amp; styles.dropdownClose}`}&amp;gt;
        {menu.map((m) =&amp;gt; (
          &amp;lt;div
            key={m.id}
            className={styles.menu}
            onClick={(e) =&amp;gt; {
              onChange(m);
              toggle();
            }}
          &amp;gt;
            {m.name}
          &amp;lt;/div&amp;gt;
        ))}
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;Pagination.jsx&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보면 계산하는 로직은 모두 utils에 따로 보관했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;해당 컴포넌트가 받는 props는 단지 state인 currentPage와 totalCount, 그리고 이벤트 헨들러인 onChange이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아래 로직을 보면 버튼중에 currentPage state와 같은 값을 담고 있는 Button만 &quot;circle-selected&quot; 스타일을 가지게 된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777121833564&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { getTotalPage, getRange } from &quot;@/utils/pagination&quot;;

export default function Pagination({ currentPage, totalCount, onChange }) {
  const totalPage = getTotalPage(totalCount);
  const numbers = getRange(currentPage, totalPage);
  return (
    &amp;lt;div className={styles.pagination}&amp;gt;
      &amp;lt;Button
        variant=&quot;circle&quot;
        onClick={() =&amp;gt; {
          if (currentPage &amp;gt; 1) onChange((prev) =&amp;gt; prev - 1);
        }}
      &amp;gt;
        &amp;lt;div className={styles.btnTxt}&amp;gt;
          &amp;lt;p&amp;gt;{&quot;&amp;lt;&quot;}&amp;lt;/p&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/Button&amp;gt;
      {numbers.map((n, i) =&amp;gt; (
        &amp;lt;Button
          variant={n === currentPage ? &quot;circle-selected&quot; : &quot;circle&quot;}
          onClick={() =&amp;gt; {
            onChange(n);
          }}
          key={i}
        &amp;gt;
          &amp;lt;div className={styles.btnTxt}&amp;gt;
            &amp;lt;p&amp;gt;{n}&amp;lt;/p&amp;gt;
          &amp;lt;/div&amp;gt;
        &amp;lt;/Button&amp;gt;
      ))}
      &amp;lt;Button
        variant=&quot;circle&quot;
        onClick={() =&amp;gt; {
          if (currentPage &amp;lt; totalPage) onChange((prev) =&amp;gt; prev + 1);
        }}
      &amp;gt;
        &amp;lt;div className={styles.btnTxt}&amp;gt;
          &amp;lt;p&amp;gt;{&quot;&amp;gt;&quot;}&amp;lt;/p&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/Button&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- currentPage와 totalCount만으로도 &lt;b&gt;현재 띄워저야하는 범위를 계산&lt;/b&gt;할수 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; - 한 페이지에 10개씩 띄우니 totalCount/10을하고 위로 올림을하면 있어야할 전체 페이지 계수 계산 가능하다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; - Pagination 버튼을 한번에 5개씬 띄우긴 하지만 막빠지쪽으로 가면 5개 없을수도 있으니 마지막 버튼 값을 계산해줘야함&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; &amp;nbsp; &amp;nbsp; end와 totalPage중에 더 작은 것이 마지막 버튼 값이 된다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; - start값과 realEnd값을 바탕으로 띄워줘야하는 버튼들을 보여준다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777122358726&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const getTotalPage = (totalCount) =&amp;gt; {
  return Math.ceil(totalCount / 10);
};
export const getRange = (currentPage, totalPage) =&amp;gt; {
  const start = Math.floor((currentPage - 1) / 5) * 5 + 1;
  const end = start + 5 - 1;

  const realEnd = Math.min(end, totalPage);
  return Array.from({ length: realEnd - start + 1 }, (_, i) =&amp;gt; i + start);
};&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;발단&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;프로젝트 구조를 설정해나가면서 path alias를 세팅해주는게 좋겠다고 판단했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;vite로 빌드한 프로젝트는 이걸 설정해준게 처음이라서 검색하면서 설정했다.&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Path Alias 설정하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;vite.config.js&lt;/b&gt;에 alias를 설정해주면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1776923407256&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { defineConfig } from &quot;vite&quot;;
import react, { reactCompilerPreset } from &quot;@vitejs/plugin-react&quot;;
import babel from &quot;@rolldown/plugin-babel&quot;;
import path from &quot;path&quot;;

const __dirname = path.resolve();

export default defineConfig({
  plugins: [react(), babel({ presets: [reactCompilerPreset()] })],
  resolve: {
    alias: {
      &quot;@&quot;: path.resolve(__dirname, &quot;src&quot;),
      &quot;@assets&quot;: path.resolve(__dirname, &quot;src/assets&quot;),
      &quot;@components&quot;: path.resolve(__dirname, &quot;src/components&quot;),
      &quot;@hooks&quot;: path.resolve(__dirname, &quot;src/hooks&quot;),
      &quot;@layouts&quot;: path.resolve(__dirname, &quot;src/layouts&quot;),
      &quot;@pages&quot;: path.resolve(__dirname, &quot;src/pages&quot;),
      &quot;@utils&quot;: path.resolve(__dirname, &quot;src/utils&quot;),
    },
  },
});&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;주요 포인트에 index.js를 세팅해준다.&lt;/p&gt;
&lt;pre id=&quot;code_1776925374646&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export { default as Footer } from &quot;./Footer/Footer&quot;;
export { default as Header } from &quot;./Header/Header&quot;;&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;코드 구조&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;페이지에 여러 library랑 effect랑 state를 사용하다 보니까&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드가 어지러워 보였다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 코드 구조를 추천받아봤다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;hook -&amp;gt; state -&amp;gt; react query -&amp;gt; derived data (가공된 데이터) -&amp;gt; effect -&amp;gt; render&lt;/p&gt;
&lt;pre id=&quot;code_1777340335270&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function MarketPage() {
  /** 1️⃣ hooks */
  const size = useResponsiveWidth();

  /** 2️⃣ state */
  const [token, setToken] = useState(null);
  const [input, setInput] = useState(&quot;&quot;);
  const [keyword, setKeyword] = useState(&quot;&quot;);
  const [selected, setSelected] = useState(constant[0]);
  const [page, setPage] = useState(1);

  /** 3️⃣ server state (react-query) */
  const { data: products = { list: [] } } = useQuery({
    queryKey: [&quot;products&quot;, token, page, selected.type, keyword],
    queryFn: () =&amp;gt; getProduct({ page, orderBy: selected.type, keyword }),
    keepPreviousData: true,
  });

  const { data: bestRaw = [] } = useQuery({
    queryKey: [&quot;best&quot;, token],
    queryFn: async () =&amp;gt; {
      const res = await getProduct({
        page,
        orderBy: &quot;favorite&quot;,
      });
      return res?.list;
    },
  });

  /** 4️⃣ derived data (가공된 데이터) */
  const bestCountMap = {
    mobile: 1,
    tablet: 2,
    desktop: 4,
  };

  const best = bestRaw.slice(0, bestCountMap[size]);

  /** 5️⃣ effects */
  useEffect(() =&amp;gt; {
    const signInPost = async () =&amp;gt; {
      const user = await signIn(&quot;example@email.com&quot;, &quot;password&quot;);
      localStorage.setItem(&quot;accessToken&quot;, user.accessToken);
      setToken(user.accessToken);
    };
    signInPost();
  }, []);

  /** 6️⃣ render */
  return (
    &amp;lt;div className={styles.content}&amp;gt;
      {/* UI */}
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;발단&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Header / Main / Footer 구조를 자주 사용할 것 같아서&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;컴포넌트로 따로 정리해두는 것이 좋을 것 같았다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 layout 폴더를 만들고 안에 MainLayout.jsx를 생성했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Footer이 무조건 밑에 있는 Layout 구조&lt;/h4&gt;
&lt;p&gt;&lt;figure class=&quot;imagegridblock&quot;&gt;
  &lt;div class=&quot;image-container&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/dP6CVw/dJMcafl3VtA/FSpsCkCi77jTFKHaRysKM0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/dP6CVw/dJMcafl3VtA/FSpsCkCi77jTFKHaRysKM0/img.png&quot; data-origin-width=&quot;961&quot; data-origin-height=&quot;1076&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.5647%; margin-right: 10px;&quot; data-widthpercent=&quot;50.15&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/dP6CVw/dJMcafl3VtA/FSpsCkCi77jTFKHaRysKM0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FdP6CVw%2FdJMcafl3VtA%2FFSpsCkCi77jTFKHaRysKM0%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;961&quot; height=&quot;1076&quot;/&gt;&lt;/span&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/omdbm/dJMcagecIdr/qbPrKAvVTU3kheQdT8OG21/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/omdbm/dJMcagecIdr/qbPrKAvVTU3kheQdT8OG21/img.png&quot; data-origin-width=&quot;958&quot; data-origin-height=&quot;1079&quot; data-is-animation=&quot;false&quot; style=&quot;width: 49.2726%;&quot; data-widthpercent=&quot;49.85&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/omdbm/dJMcagecIdr/qbPrKAvVTU3kheQdT8OG21/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fomdbm%2FdJMcagecIdr%2FqbPrKAvVTU3kheQdT8OG21%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;958&quot; height=&quot;1079&quot;/&gt;&lt;/span&gt;&lt;/div&gt;
&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- layout을 min-height를 100vh로 해서&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 최소100vh, 아니면 더 커질 수 있도록 했다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 그리고 display를 flex로 둬서 &lt;u&gt;main이 flex:1해서 나머지 공간을 꽉 채울 수 있게&lt;/u&gt;했다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776923576520&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import React from &quot;react&quot;;
import { Header, Footer } from &quot;@/components&quot;;
import styles from &quot;./MainLayout.module.css&quot;;

export default function MainLayout({ children }) {
  return (
    &amp;lt;div className={styles.layout}&amp;gt;
      &amp;lt;Header /&amp;gt;
      &amp;lt;main className={styles.main}&amp;gt;{children}&amp;lt;/main&amp;gt;
      &amp;lt;Footer /&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1776923700916&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.layout {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}
.main {
  flex: 1;
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;발단&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;dropdown의 메뉴 사이사이에 줄 넣어주려고 하니까&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;어떻게 가장 깔끔하게 하는지 궁금했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;반복되는 Element 사이사이에만 border 주기&lt;/h4&gt;
&lt;pre id=&quot;code_1777005265983&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.menu + .menu {
  border-top: 1px solid var(--cool-gray-200);
}&lt;/code&gt;&lt;/pre&gt;
&lt;blockquote data-ke-style=&quot;style2&quot;&gt;&lt;b&gt;+: Adjacent Sibling Selector&lt;/b&gt;&lt;br /&gt;.menu 바로 뒤에 오는 .menu만 선택&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;아니면&lt;/p&gt;
&lt;pre id=&quot;code_1777005308533&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.menu:not(:first-child) {
  border-top: 1px solid var(--cool-gray-200);
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;발단&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;ProductCard가 Item 그룹의 타이틀과 아이템들을 디스플레이하는 컴포넌트인데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;부모에서 주는&amp;nbsp;&lt;u&gt;column props&lt;/u&gt;를 가지고 한줄에 아이템 몇개 들어가는지 조절하고 싶었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그러면&amp;nbsp;&lt;u&gt;React에서 CSS&lt;/u&gt;로 값을 전달해야하는데&amp;nbsp; 어떻게 하는지 궁금했다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;React에서 CSS 파일로 데이터 전달하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;css variable 작성하는 방법을 알것이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;:root { --css-variablename: value}방식이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그걸 동일하게 JSX 파일에 하면 된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777120495604&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;div className={styles.list} style={{ &quot;--column-count&quot;: column }}&amp;gt;&amp;lt;/div&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;CSS파일에서 이 변수를 사용하면 된다.&lt;/p&gt;
&lt;pre id=&quot;code_1777120585163&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.list {
  display: grid;
  grid-template-columns: repeat(var(--column-count), 1fr);
  gap: 2.4rem;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;이러면 grid안의 element들이 한줄에 모두 같은 width로 column변수값 개수로 존재하게 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;화면마다 column 개수 다르게 하기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이런식으로 분기마다 column 개수를 어떻게 할지에 대한 템플릿을 객체로 넘겨주면 되는것 같다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777339472970&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//사용 방식
&amp;lt;ProductCardList
  title=&quot;베스트 상품&quot;
  column={{
    desktop: 4,
    tablet: 2,
    mobile: 1,
  }}
  data={best}
  isPending={isBestPending}
/&amp;gt;;

//해결 방식
&amp;lt;div
  className={styles.list}
  style={{
    &quot;--col-desktop&quot;: column?.desktop,
    &quot;--col-tablet&quot;: column?.tablet,
    &quot;--col-mobile&quot;: column?.mobile,
  }}
&amp;gt;
  {data.map((d) =&amp;gt;
    isPending ? &amp;lt;ItemSkeleton /&amp;gt; : &amp;lt;Item data={d} key={d.id} /&amp;gt;,
  )}
&amp;lt;/div&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;React에서 넘겨받은 데이터는 css에서 아래의 방식으로 사용한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777339702452&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.list {
  max-width: 100vw;
  display: grid;
  grid-template-columns: repeat(var(--col-desktop), 1fr);
  gap: 2.4rem;
}
/* Tablet */
@media (max-width: 1280px) {
  .list {
    grid-template-columns: repeat(var(--col-tablet), 1fr);
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;반응형 디자인&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;피그마에서 디자인을 보고 있으면 의문점이 하나 생긴다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1. 화면 크기에 따라서 보여주는 데이터 개수가 달라진다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2. 화면 크기에 따라서 툴탭에 요소들 순서가 달라진다 (파란 버튼이랑 input창이랑 순서가 달라짐)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;실무에서는 이런거 어떻게 처리하는지 알아내기 위해 강사님께 조문을 구하러 갔다&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;1450&quot; data-origin-height=&quot;827&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/F0Tzd/dJMcacpnjWW/Mzw8ApQbIlVboBKCoW1mpk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/F0Tzd/dJMcacpnjWW/Mzw8ApQbIlVboBKCoW1mpk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/F0Tzd/dJMcacpnjWW/Mzw8ApQbIlVboBKCoW1mpk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FF0Tzd%2FdJMcacpnjWW%2FMzw8ApQbIlVboBKCoW1mpk%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;714&quot; height=&quot;407&quot; data-origin-width=&quot;1450&quot; data-origin-height=&quot;827&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 화면 크기에 따라 데이터 개수 바꾸기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이거는&amp;nbsp;&lt;u&gt;비즈니스 로직&lt;/u&gt;이라서 Hook을 만들만 하다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;하지만 훅을 만들면&amp;nbsp;&lt;u&gt;오로지 API에 요청하는 데이터 개수 바꾸는데&lt;/u&gt;에 사용해야하지 -&amp;gt;&amp;nbsp;오로지&amp;nbsp;데이터를&amp;nbsp;위해서만&amp;nbsp;사용&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;스타일 바꾸는데에 사용하면 절대&lt;/span&gt; 안된다. (코드 어지러워짐)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1) 첫번 조언: 이벤트리스너 - &lt;u&gt;resize 이벤트로 커스텀 훅&lt;/u&gt; 만들기&lt;/p&gt;
&lt;pre id=&quot;code_1777339952421&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import React, { useState, useEffect } from &quot;react&quot;;

export default function useResponsiveWidth() {
  const [width, setWidth] = useState(window.innerWidth);
  useEffect(() =&amp;gt; {
    const handleResize = () =&amp;gt; {
      setWidth(window.innerWidth);
    };

    window.addEventListener(&quot;resize&quot;, handleResize);

    return () =&amp;gt; {
      window.removeEventListener(&quot;resize&quot;, handleResize);
    };
  }, []);
  if (width &amp;gt; 1280) return &quot;desktop&quot;;
  if (width &amp;gt; 720) return &quot;tablet&quot;;
  else return &quot;mobile&quot;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;나는 이거 잘 작동한다고 생각했는데, 강사님이 나중에 찾아와서 이 방법은 나중에 문제가 생긴다고 하셨다&lt;br /&gt;resize 이벤트에 대해서 고민하고 실험해보라고 이런 대답을 줬는거라고 한다.&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p style=&quot;color: #333333; text-align: start;&quot; data-ke-size=&quot;size16&quot;&gt;문제는 바로 1px씩 움직일때마다 이벤트가 발생한다는 것이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 예전에는&amp;nbsp;&lt;b&gt;debouncing&lt;/b&gt; 방식을 사용해서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&quot;setTimeout과 clearTimeout을 사용해서 이벤트가 멈춘 뒤 N ms 후에 한번만 실행&quot;을 했었다&lt;/p&gt;
&lt;pre id=&quot;code_1777340928496&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;useEffect(() =&amp;gt; {
  let timer;
  const handleResize = () =&amp;gt; {
    clearTimeout(timer);
    timer = setTimeout(() =&amp;gt; {
      setWidth(window.innerWidth);
    }, 300);
  };

  window.addEventListener(&quot;resize&quot;, handleResize);
  return () =&amp;gt; window.removeEventListener(&quot;resize&quot;, handleResize);
}, []);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-end=&quot;636&quot; data-start=&quot;630&quot; data-ke-size=&quot;size16&quot;&gt;✔ 장점&lt;/p&gt;
&lt;p data-end=&quot;636&quot; data-start=&quot;630&quot; data-ke-size=&quot;size16&quot;&gt;- 이벤트 폭주 방지&lt;/p&gt;
&lt;p data-end=&quot;656&quot; data-start=&quot;650&quot; data-ke-size=&quot;size16&quot;&gt;❌ 단점&lt;/p&gt;
&lt;p data-end=&quot;656&quot; data-start=&quot;650&quot; data-ke-size=&quot;size16&quot;&gt;- 딜레이 있음 (즉각 반응 아님)&lt;/p&gt;
&lt;p data-end=&quot;656&quot; data-start=&quot;650&quot; data-ke-size=&quot;size16&quot;&gt;- 여전히 resize 자체는 계속 발생 중&lt;/p&gt;
&lt;p data-end=&quot;656&quot; data-start=&quot;650&quot; data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;656&quot; data-start=&quot;650&quot; data-ke-size=&quot;size16&quot;&gt;그래서 요새는&amp;nbsp;&lt;b&gt;matchMedia&lt;/b&gt;방식을 사용한다고 한다.&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;656&quot; data-start=&quot;650&quot; data-ke-size=&quot;size16&quot;&gt;이거는 &quot;필셀 변화가 아니라 조건 변화&quot;만 감지하는 것이라,&amp;nbsp;&lt;/p&gt;
&lt;p data-end=&quot;656&quot; data-start=&quot;650&quot; data-ke-size=&quot;size16&quot;&gt;특정 css 조건을 만족할때만 이벤트가 발동한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777341684993&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;window.matchMedia(&quot;(max-width: 768px)&quot;)&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그걸 React스럽게 감싼것이 바로 useMediaQuery다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;내부적으로 matchMedia를 사용하고 있다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 나중에 말씀하시기로는 그냥 useMediaQuery 사용하는것도 좋은 방향이라고 한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777341730924&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useMediaQuery } from &quot;react-responsive&quot;;

const isTablet = useMediaQuery({ maxWidth: 768 });&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;결국에는 resize로 만든 훅을 useMediaQuery를 사용하도록 바꿨다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;한곳에 breakpoint를 정의할 수 있으니 꽤나 편했다.&lt;/p&gt;
&lt;pre id=&quot;code_1777360428403&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import { useMediaQuery } from &quot;react-responsive&quot;;

export default function useResponsiveWidth() {
  const isMobile = useMediaQuery({ maxWidth: 720 });
  const isTablet = useMediaQuery({ minWidth: 721, maxWidth: 1280 });
  const isDesktop = useMediaQuery({ minWidth: 1281 });

  if (isMobile) return &quot;mobile&quot;;
  if (isTablet) return &quot;tablet&quot;;
  return &quot;desktop&quot;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. 화면 크기에 따라 컴포넌트 순서 바꾸기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;위의 훅을 사용했다.&lt;/p&gt;
&lt;pre id=&quot;code_1777341970806&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const size = useResponsiveWidth();
{
  !(size===&quot;mobile&quot;) ? (
    &amp;lt;&amp;gt;
      &amp;lt;Input/&amp;gt;
      &amp;lt;Dropdown/&amp;gt;
      &amp;lt;Button&amp;gt;상품 등록하기&amp;lt;/Button&amp;gt;
    &amp;lt;/&amp;gt;
  ) : (
    &amp;lt;&amp;gt;
      &amp;lt;Button&amp;gt;상품 등록하기&amp;lt;/Button&amp;gt;
      &amp;lt;Input/&amp;gt;
      &amp;lt;Dropdown/&amp;gt;
    &amp;lt;/&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;lt;공부하며 참고해야할 코드&amp;gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/rjc1704/responsive-design-example.git&quot;&gt;https://github.com/rjc1704/responsive-design-example.git&lt;/a&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;grid와 반응형 query방식 - advanced 브랜치 보기&lt;/p&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;화면 깜빡거림&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;pagination으로 새 이미지 데이터를 가져오거나 새로고침하면 &lt;br /&gt;자꾸만 화면이 깜빡깜빡 거렸다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;1) skeleton 작성해보기&lt;/u&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;원래 Item과 동일하게 ItemSkeleton을 만들어봤다.&lt;/p&gt;
&lt;pre id=&quot;code_1777337926618&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export default function ItemSkeleton() {
  return (
    &amp;lt;div className={styles.container}&amp;gt;
      &amp;lt;Shimmer className={styles.img} /&amp;gt;
      &amp;lt;div className={styles.content}&amp;gt;
        &amp;lt;div className={styles.title}&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;div className={styles.price}&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;div className={styles.meta}&amp;gt;
          &amp;lt;img src={icHeartEmpty} /&amp;gt;
          &amp;lt;div className={styles.like}&amp;gt;&amp;lt;/div&amp;gt;
        &amp;lt;/div&amp;gt;
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;보면 안에 Shimmer이라는 컴포넌트가 있을텐데,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그건 안에 있는 children을 shimmer효과 있게 해주는 컴포넌트이다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777338167451&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;import styles from &quot;./Shimmer.module.css&quot;;

export default function Shimmer({ children, className }) {
  return &amp;lt;div className={`${styles.skeleton} ${className}`}&amp;gt;{children}&amp;lt;/div&amp;gt;;
}&lt;/code&gt;&lt;/pre&gt;
&lt;pre id=&quot;code_1777338187461&quot; class=&quot;css&quot; data-ke-language=&quot;css&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;.skeleton {
  background: linear-gradient(
    90deg,
    var(--secondary-200) 25%,
    var(--secondary-100) 50%,
    var(--secondary-200) 75%
  );
  background-size: 200% 100%;
  animation: shimmer 1.4s infinite linear;
}

@keyframes shimmer {
  0% {
    background-position: 200% 0;
  }
  100% {
    background-position: -200% 0;
  }
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;근데 문제가 일단 skeleton이 실제 item보다 더 커서 자꾸 layout이 왔다갔다 하기도 했고&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;나중에 강사님이 말씀하시기로는&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;span style=&quot;background-color: #f6e199;&quot;&gt;tanstack query에 옵션&lt;/span&gt;을 잘 사용하면 skeleton 쓸일도 없다고 했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;엥? tanstack query 사용했는데 무슨 옵션이 있었을까?&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;2) Tanstack Query의 placeholderData 옵션 사용하기&lt;/u&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;chatGPT로 조사했을때는 keepPreviousData로 나왔었는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;강사님께 여쭤보니까 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;최신 버젼에서는 placeholderData&lt;/span&gt;를 쓴다고 하셨다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또한 useQuery에 isLoading을 썼다가 안되길래&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 chatGPT로 조사하니까 isFetching쓰라해서 썼는데 그것도 안되는 기분이었다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;또 강사님께 여쭤보니까 &lt;span style=&quot;background-color: #f6e199;&quot;&gt;isPending&lt;/span&gt;이더라... (예전에는 isLoading)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;진짜 chatGPT는 어떤 방향이 좋을지만 물어보고 &lt;span style=&quot;background-color: #99cefa;&quot;&gt;사용법은 꾸역꾸역 어떻게든 공식문서를 읽어야겠다&lt;/span&gt;고 느꼈다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1777338980957&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const { data: products = { list: [] }, isPending: isProductsPending } =
  useQuery({
    queryKey: [&quot;products&quot;, token, page, selected, keyword, size],
    queryFn: () =&amp;gt;
      getProduct({
        page,
        pageSize: size === &quot;mobile&quot; ? 4 : size === &quot;tablet&quot; ? 6 : 10,
        orderBy: selected.type,
        keyword,
      }),
    placeholderData: keepPreviousData,
  });&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>웹프로젝트/코드잇</category>
      <author>공장 주인 따꿍</author>
      <guid isPermaLink="true">https://capprojectfactory.tistory.com/137</guid>
      <comments>https://capprojectfactory.tistory.com/137#entry137comment</comments>
      <pubDate>Fri, 24 Apr 2026 13:36:57 +0900</pubDate>
    </item>
    <item>
      <title>[스노로즈-에디터] 지금까지 작업 정리하기</title>
      <link>https://capprojectfactory.tistory.com/136</link>
      <description>&lt;h4 data-ke-size=&quot;size20&quot;&gt;발단&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;여행 갔다오고 부캠 때문에 지쳐하다보니&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업을 거의 팀원이 다했다. (미안해)&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 그간 쌓인 PR 리뷰를 확인했는데&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;조금 코드를 정리할 필요가 있어 보였다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;일단 이번주 스프린트 작업은 끝내야하니 중요한거 버그 외에는 어푸했다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이번 스프린트를 넘겼으니,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그간 작업을 면밀히 검토하고 이해해보며 리팩토링을 진행해볼 계획이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;작업&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. 어떤 파일에 작업했는지 확인하기&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;작업을 확인하려면 어디에 작업했는지 알아볼 필요가 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서&amp;nbsp;&lt;u&gt;git diff --name-only HEAD dev&lt;/u&gt;해서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;현재 브랜치 (feature/editor)와 dev 브랜치의 차이를 보았다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;928&quot; data-origin-height=&quot;771&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/exVykd/dJMcabKHzb5/toxyKwkYAm9cn2DuZUi0GK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/exVykd/dJMcabKHzb5/toxyKwkYAm9cn2DuZUi0GK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/exVykd/dJMcabKHzb5/toxyKwkYAm9cn2DuZUi0GK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FexVykd%2FdJMcabKHzb5%2FtoxyKwkYAm9cn2DuZUi0GK%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;552&quot; height=&quot;459&quot; data-origin-width=&quot;928&quot; data-origin-height=&quot;771&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;h4 style=&quot;color: #000000; text-align: start;&quot; data-ke-size=&quot;size20&quot;&gt;Editor로 글 작성하는 것과 관련된 코드&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. WritePostPage와 EditPostPage에서 추가된 내용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;1) AttachmentBar에 editor 아이콘을 누르면 FixedMenuEditor이 나오게&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; editor props에 editor 추가&lt;/p&gt;
&lt;pre id=&quot;code_1776580580889&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;AttachmentBar
    attachmentsInfo={attachmentsInfo}
    setAttachmentsInfo={setAttachmentsInfo}
    editor={editor}
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;2) EditorContainer 추가해서 기존의 TextareaAutosize 대체&lt;/p&gt;
&lt;pre id=&quot;code_1776580543396&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;EditorContainer
    placeholder=&quot;내용&quot;
    onEditorReady={setEditor}
    onChangeEditor={(editor) =&amp;gt; {
    const sanitized = preserveEmptyParagraphs(editor.getHTML());
        setText(sanitized);
    }}
/&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;b&gt;preservedEmptyParagraphs&lt;/b&gt; (&lt;u&gt;emptyFormat.js&lt;/u&gt;): HTML 문자열에서 비어있는 &amp;lt;p&amp;gt; 태그를 공백이 있는 &amp;lt;p&amp;gt;로 바꿔주는 처리&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; -&amp;gt; 빈 &amp;lt;p&amp;gt; 태그를 눈에 보이는 공백 줄로 바꿔주는 코드&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. AttachmentBar에 추가된 내용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- editor이 열린 상태인지 아닌지 확인하는 isEditorOpen state 추가&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 클릭으로 isEditorOpen 토글&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- editor을 props로 가져옴&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- isEditorOpen일 경우 FixedMenuEditor을 킨다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776581796772&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const [isEditorOpen, setIsEditorOpen] = useState(false);

return (
    &amp;lt;div className={styles.bar}&amp;gt;
      {isEditorOpen &amp;amp;&amp;amp; editor &amp;amp;&amp;amp; (
        &amp;lt;FixedMenuEditor editor={editor} /&amp;gt;
      )}
      &amp;lt;div className={styles.attachmentBar}&amp;gt;
        //Attachment 코드
      &amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;
);&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;3. FixedMenuEditor.jsx&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에디터 메뉴를 작성한 완전히 새로운 코드이다.&amp;nbsp;&lt;br /&gt;과거에 작성한 코드를 수정한게 아니라, 아예 새로 만든 파일이다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;읽기 조금 힘들었다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;코드 자체의 기능은 잘 작동하는데,&amp;nbsp;&lt;u&gt;책임이 한 컴포넌트에 몰려 있어&lt;/u&gt;서 읽기 어려운 케이스였다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/snorose/snorose-front/blob/feature/editor/src/feature/editor/component/FixedMenuEditor/FixedMenuEditor.jsx&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/snorose/snorose-front/blob/feature/editor/src/feature/editor/component/FixedMenuEditor/FixedMenuEditor.jsx&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776583449280&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;snorose-front/src/feature/editor/component/FixedMenuEditor/FixedMenuEditor.jsx at feature/editor &amp;middot; snorose/snorose-front&quot; data-og-description=&quot;숙명여자대학교 신입생, 재학생, 졸업생이 사용하는 커뮤니티 '스노로즈'. Contribute to snorose/snorose-front development by creating an account on GitHub.&quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/snorose/snorose-front/blob/feature/editor/src/feature/editor/component/FixedMenuEditor/FixedMenuEditor.jsx&quot; data-og-url=&quot;https://github.com/snorose/snorose-front/blob/feature/editor/src/feature/editor/component/FixedMenuEditor/FixedMenuEditor.jsx&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/buwq3Y/dJMb8Qen40c/OPfIjs7UbdXbTIGEvgahtK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/xm4zA/dJMb85WVglO/5J6xi7si6VW4zkb4DfbkJ1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600&quot;&gt;&lt;a href=&quot;https://github.com/snorose/snorose-front/blob/feature/editor/src/feature/editor/component/FixedMenuEditor/FixedMenuEditor.jsx&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/snorose/snorose-front/blob/feature/editor/src/feature/editor/component/FixedMenuEditor/FixedMenuEditor.jsx&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/buwq3Y/dJMb8Qen40c/OPfIjs7UbdXbTIGEvgahtK/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600,https://scrap.kakaocdn.net/dn/xm4zA/dJMb85WVglO/5J6xi7si6VW4zkb4DfbkJ1/img.png?width=1200&amp;amp;height=600&amp;amp;face=0_0_1200_600');&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;snorose-front/src/feature/editor/component/FixedMenuEditor/FixedMenuEditor.jsx at feature/editor &amp;middot; snorose/snorose-front&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;숙명여자대학교 신입생, 재학생, 졸업생이 사용하는 커뮤니티 '스노로즈'. Contribute to snorose/snorose-front development by creating an account on GitHub.&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;1) 본문 / 소제목 선택 버튼&lt;/u&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;770&quot; data-origin-height=&quot;268&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/1nDMe/dJMcadVYQps/36QX6NEyu6NaMeAAXO2sCk/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/1nDMe/dJMcadVYQps/36QX6NEyu6NaMeAAXO2sCk/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/1nDMe/dJMcadVYQps/36QX6NEyu6NaMeAAXO2sCk/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2F1nDMe%2FdJMcadVYQps%2F36QX6NEyu6NaMeAAXO2sCk%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;549&quot; height=&quot;191&quot; data-origin-width=&quot;770&quot; data-origin-height=&quot;268&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 버튼을 클릭하면 headingOpen이 토글되어서 위에 본문/소제목이 뜬다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- TipTap 기본 동작이&amp;nbsp;&lt;u&gt;heading에서 엔터 누르면 paragraph로 revert&lt;/u&gt;하는거라 이 동작은 따로 작성해줄 필요 없다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- onClick해서 option.value가 &lt;u&gt;paragraph&lt;/u&gt;이면 editor.chain().focus().setParagraph().run();&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 다른거면 (여기서는 1)이면 editor.chain() .focus().setHeading({ level: 1}).run();&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;현재 선택된 블록을 heading 타입으로 바꾸고 level을 1로 설정 (현재 블록을 h1으로 바꾸는 명령)&lt;/li&gt;
&lt;/ul&gt;
&lt;pre id=&quot;code_1776585504120&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;//에디터에서 선택 가능한 스타일 목록
//내부값 value vs 사용자에게 보여줄 텍스트 label
const HEADING_OPTIONS = [
    { value: 'paragraph', label: '본문' },
    { value: '1', label: '소제목' },
  ];

//커서가 heading level 1에 있으면 1, 아니면 paragraph
//커서가 어디에 있는지 상태 체크
const getCurrentHeading = () =&amp;gt; {
    if (editor.isActive('heading', { level: 1 })) return '1';
    return 'paragraph';
};
  
&amp;lt;button
    className={styles.headingButton}
    onClick={() =&amp;gt; setHeadingOpen((prev) =&amp;gt; !prev)}
&amp;gt;
    //getCurrentHeading으로 현재 상태 찾고 그에 맞는 label 찾기
    {HEADING_OPTIONS.find((o) =&amp;gt; o.value === getCurrentHeading()) ?.label ?? '본문'}
    &amp;lt;Icon
        id='arrow-down'
        width={12}
        height={6.75}
        className={styles.headingArrow}
    /&amp;gt;
&amp;lt;/button&amp;gt;
{
  headingOpen &amp;amp;&amp;amp; (
    &amp;lt;div className={styles.headingDropdown}&amp;gt;
      {HEADING_OPTIONS.map((option) =&amp;gt; (
        &amp;lt;button
          key={option.value}
          className={`${styles.headingOption} ${getCurrentHeading() === option.value ? styles.headingOptionActive : &quot;&quot;}`}
          onClick={() =&amp;gt; {
            if (option.value === &quot;paragraph&quot;) {
              editor.chain().focus().setParagraph().run();
            } else {
              editor
                .chain()
                .focus()
                .setHeading({ level: parseInt(option.value, 10) })
                .run();
            }
            setHeadingOpen(false);
          }}
        &amp;gt;
          {option.label}
        &amp;lt;/button&amp;gt;
      ))}
    &amp;lt;/div&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;2) 색상 선택 버튼&lt;/u&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 아이콘 클릭하면 showTextColor이 토글된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776588447992&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const PRESET_COLORS = [
  { label: '회색', value: 'var(--grey-4)' },
  { label: '검정', value: 'black' },
  { label: '파랑', value: 'var(--blue-4)' },
  { label: '핑크', value: 'var(--pink-3)' },
];

&amp;lt;div ref={textColorRef} className={styles.colorPickerWrapper}&amp;gt;
  {/* 폰트 색상 토글 버튼 */}
  &amp;lt;button
    onClick={() =&amp;gt; setShowTextColor((prev) =&amp;gt; !prev)}
    style={{ color: textColor || &quot;var(--grey-4)&quot; }}
  &amp;gt;
    &amp;lt;Icon id=&quot;font-color&quot; width={24} height={24} /&amp;gt;
  &amp;lt;/button&amp;gt;

  &amp;lt;div
    className={`${styles.colorPaletteInline} ${showTextColor ? styles.open : &quot;&quot;}`}
  &amp;gt;
    {/* 색상 없음 */}
    &amp;lt;button
      className={styles.colorSwatchNone}
      title=&quot;색상 없음&quot;
      onClick={() =&amp;gt; {
        setTextColor(&quot;&quot;);
        editor.chain().focus().unsetColor().run();
      }}
    &amp;gt;
      &amp;lt;Icon id=&quot;no-color&quot; width={28} height={28} /&amp;gt;
    &amp;lt;/button&amp;gt;

    {/* 고정 색상 */}
    {PRESET_COLORS.map((color) =&amp;gt; (
      &amp;lt;button
        key={color.label}
        className={`${styles.colorSwatch} ${textColor === color.value ? styles.selected : &quot;&quot;}`}
        style={{ backgroundColor: color.value }}
        title={color.label}
        onClick={() =&amp;gt; {
          setTextColor(color.value);
          editor
            .chain()
            .focus()
            .setMark(&quot;textStyle&quot;, { color: color.value })
            .run();
        }}
      /&amp;gt;
    ))}
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;3) 하이라이트 선택 버튼&lt;/u&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 아이콘 클릭하면 showBgColor이 토글된다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776588842099&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const PRESET_BG_COLORS = [
  { label: '핑크', value: 'var(--pink-1)' },
  { label: '노랑', value: '#FDFF6C' },
  { label: '연두', value: 'var(--green-2)' },
  { label: '파랑', value: 'var(--blue-2)' },
];
&amp;lt;div ref={bgColorRef} className={styles.colorPickerWrapper}&amp;gt;
  &amp;lt;button
    onClick={() =&amp;gt; setShowBgColor((prev) =&amp;gt; !prev)}
    style={{
      &quot;--bg-icon-fill&quot;: bgColor || &quot;#E6F7B1&quot;,
      &quot;--bg-icon-stroke&quot;: bgColor || &quot;#AAD916&quot;,
    }}
  &amp;gt;
    &amp;lt;Icon id=&quot;bg-color&quot; width={24} height={24} /&amp;gt;
  &amp;lt;/button&amp;gt;

  &amp;lt;div
    className={`${styles.colorPaletteInline} ${showBgColor ? styles.open : &quot;&quot;}`}
  &amp;gt;
    &amp;lt;button
      className={styles.colorSwatchNone}
      title=&quot;배경색 없음&quot;
      onClick={() =&amp;gt; {
        setBgColor(&quot;&quot;);
        editor
          .chain()
          .focus()
          .setMark(&quot;textStyle&quot;, { backgroundColor: null })
          .run();
      }}
    &amp;gt;
      &amp;lt;Icon id=&quot;no-color&quot; width={28} height={28} /&amp;gt;
    &amp;lt;/button&amp;gt;

    {/* 고정 색상 */}
    {PRESET_BG_COLORS.map((color) =&amp;gt; (
      &amp;lt;button
        key={color.label}
        className={`${styles.colorSwatch} ${bgColor === color.value ? styles.selected : &quot;&quot;}`}
        style={{ backgroundColor: color.value }}
        title={color.label}
        onClick={() =&amp;gt; {
          setBgColor(color.value);
          editor
            .chain()
            .focus()
            .setMark(&quot;textStyle&quot;, { backgroundColor: color.value })
            .run();
        }}
      /&amp;gt;
    ))}
  &amp;lt;/div&amp;gt;
&amp;lt;/div&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;4) 링크 삽입&lt;/u&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- Tiptap에는 보통 Link Extension이 있어서 url 형태로 입력하면 자동으로 링크로 바뀐다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- editor.state.selection.empty는 다음 상태이다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;822&quot; data-origin-height=&quot;497&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/FNfgv/dJMcaadWnC3/9D5qN2dUO4kjV8tHnFHl5k/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/FNfgv/dJMcaadWnC3/9D5qN2dUO4kjV8tHnFHl5k/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/FNfgv/dJMcaadWnC3/9D5qN2dUO4kjV8tHnFHl5k/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FFNfgv%2FdJMcaadWnC3%2F9D5qN2dUO4kjV8tHnFHl5k%2Fimg.png&quot; onerror=&quot;this.onerror=null; this.src='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png'; this.srcset='//t1.daumcdn.net/tistory_admin/static/images/no-image-v1.png';&quot; loading=&quot;lazy&quot; width=&quot;441&quot; height=&quot;267&quot; data-origin-width=&quot;822&quot; data-origin-height=&quot;497&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- else는 드래그로 텍스트를 선택한 상태이다.&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;815&quot; data-origin-height=&quot;557&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/bz7SDD/dJMcaiiIuqt/S6YbvGegMy5Hsp7XAHk6q0/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/bz7SDD/dJMcaiiIuqt/S6YbvGegMy5Hsp7XAHk6q0/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/bz7SDD/dJMcaiiIuqt/S6YbvGegMy5Hsp7XAHk6q0/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2Fbz7SDD%2FdJMcaiiIuqt%2FS6YbvGegMy5Hsp7XAHk6q0%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;443&quot; height=&quot;303&quot; data-origin-width=&quot;815&quot; data-origin-height=&quot;557&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;⭐드래그로 텍스트 선택하고, 버튼을 누른 후에 alert에 아무거나 넣으면&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;드래그로 선택한 텍스트가 url 형태로 바꾼다.&lt;/p&gt;
&lt;pre id=&quot;code_1776589429526&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;button
  type=&quot;button&quot;
  aria-label=&quot;링크 삽입&quot;
  onClick={() =&amp;gt; {
    const url = window.prompt(&quot;링크 주소를 입력하세요&quot;);
    if (!url) return;
    const protocolRegex = /^(https?:\/\/)/i;
    const formattedUrl = protocolRegex.test(url) ? url : `https://${url}`;

    if (editor.state.selection.empty) {
      editor
        .chain()
        .focus()
        .insertContent({
          type: &quot;text&quot;,
          text: formattedUrl,
          marks: [{ type: &quot;link&quot;, attrs: { href: formattedUrl } }],
        })
        .run();
    } else {
      editor
        .chain()
        .focus()
        .extendMarkRange(&quot;link&quot;)
        .setLink({ href: formattedUrl })
        .run();
    }
  }}
&amp;gt;
  &amp;lt;FaLink /&amp;gt;
&amp;lt;/button&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;5) iframe&lt;/u&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- 패턴 중에 어떤 패턴에 해당되는지 확인하고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; 어떤 패턴에 부합하면 &lt;u&gt;객체 안에 있는 format 함수&lt;/u&gt;를 가지고 표준 url 형태로 전환한 후&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; editor.chain().focus().insertContent({ type: &quot;iframe&quot;, attrs: { src: formattedUrl } }).run()로 iframe을 넣어준다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- iframe이 backspace로 지워지는 것은,&amp;nbsp;&lt;br /&gt;&amp;nbsp; &amp;nbsp; Tiptap이 내부적으로 ProseMirror기반이라서 노드 단위로 선택/삭제하는 규칙이 이미 잘 정의되어 있다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- ⭐&amp;nbsp;&lt;b&gt;패턴에 매칭 안 되는 값 (abc 같은 거)는 그대로 통과된다. =&amp;gt; 예외처리 안됨&lt;/b&gt;&lt;/p&gt;
&lt;pre id=&quot;code_1776589464175&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;button
  type=&quot;button&quot;
  onClick={() =&amp;gt; {
    const url = window.prompt(&quot;iframe URL 입력:&quot;);
    if (!url) return;
    let formattedUrl = url;

    const patterns = [
      {
        // YouTube 일반 + Shorts
        regex:
          /(?:youtube\.com\/watch\?v=|youtu\.be\/|youtube\.com\/shorts\/)([a-zA-Z0-9_-]+)/,
        format: (match) =&amp;gt; `https://www.youtube.com/embed/${match[1]}`,
      }, //등등 (다른 패턴은 생략)
    ];

    for (const pattern of patterns) {
      const match = url.match(pattern.regex);
      if (match) {
        formattedUrl = pattern.format(match);
        break;
      }
    }

    editor
      .chain()
      .focus()
      .insertContent({ type: &quot;iframe&quot;, attrs: { src: formattedUrl } })
      .run();
  }}
&amp;gt;
  &amp;lt;FaYoutube /&amp;gt;
&amp;lt;/button&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;u&gt;6) 나머지 간단한 기능&lt;/u&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- bold / underline / crossout / quote&lt;/p&gt;
&lt;pre id=&quot;code_1776589317008&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;     &amp;lt;button
        aria-label='굵게'
        onClick={() =&amp;gt; editor.chain().focus().toggleBold().run()}
        style={editorState.isBold ? { '--icon-stroke': 'var(--blue-4)' } : {}}
      &amp;gt;
        &amp;lt;Icon id='bold' width={24} height={24} /&amp;gt;
      &amp;lt;/button&amp;gt;

      &amp;lt;button
        aria-label='밑줄'
        onClick={() =&amp;gt; editor.chain().focus().toggleUnderline().run()}
        style={
          editorState.isUnderline ? { '--icon-stroke': 'var(--blue-4)' } : {}
        }
      &amp;gt;
        &amp;lt;Icon id='underline' width={24} height={24} /&amp;gt;
      &amp;lt;/button&amp;gt;

      &amp;lt;button
        aria-label='취소선'
        onClick={() =&amp;gt; editor.chain().focus().toggleStrike().run()}
        style={editorState.isStrike ? { '--icon-stroke': 'var(--blue-4)' } : {}}
      &amp;gt;
        &amp;lt;Icon id='strikethrough' width={24} height={24} /&amp;gt;
      &amp;lt;/button&amp;gt;

      &amp;lt;button
        type='button'
        aria-label='인용구'
        onClick={() =&amp;gt; editor.chain().focus().toggleBlockquote().run()}
      &amp;gt;
        &amp;lt;FaQuoteRight /&amp;gt;
      &amp;lt;/button&amp;gt;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;4. EditorContainer.jsx&lt;/b&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;editor에 넣어줄 수 있는 여러 editor extension들을 붙여주는 코드이다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- useEditor({extensions:[...]})은 에디터에 어떤 기능(플러그인)들을 쓸지 설정하는 곳이다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- StarterKit 안에 기본으로 들어가 있는 link와 blockquote는 비활성화하고,&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp; &amp;nbsp; Link와 Blockquote 플러그인을 따로 넣어서&amp;nbsp;&lt;u&gt;커스터미이징&lt;/u&gt;을 진행했다&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- &lt;u&gt;FontSize&lt;/u&gt;는 Tiptap에 기본제공하지 않기 때문에 직접 extension을 만들어야한다.&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776602264848&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;const FontSize = Extension.create({
  name: 'fontSize',
  addOptions() {
    return { types: ['textStyle'] }; //어떤 대상에 적용할지
  },
  addGlobalAttributes() {
    return [
      {
        types: this.options.types,
        attributes: {
          fontSize: {
            default: null,
            parseHTML: (element) =&amp;gt; //서버에서 가져온 글 파싱할때 사용 (에디터로 전환)
              element.style.fontSize.replace(/['&quot;]+/g, ''), 
            renderHTML: (attributes) =&amp;gt; { //에디터 글 저장할대 사용 (HTML로 전환)
              if (!attributes.fontSize) return {};
              return { style: `font-size: ${attributes.fontSize}` };
            },
          },
        },
      },
    ];
  },
});

export default function EditorContainer({ placeholder, text, setText, onEditorReady }) {
  const editor = useEditor({
    extensions: [
      StarterKit.configure({ 
        link: false,
        blockquote: false,
      }),
      Link.configure({ 
        openOnClick: false,
      }),
      Blockquote,
      EnterKeyHandler,
      TextAlign.configure({
        types: ['heading', 'paragraph'],
        alignments: ['left', 'center', 'right'],
      }),
      Table.configure({
        resizable: true,
      }),
      Iframe,
      TableRow,
      TableHeader,
      TableCell,
      TextStyle,
      Color,
      BackgroundColor,
      FontFamily,
      FontSize,
      Placeholder.configure({
        emptyEditorClass: 'is-editor-empty',
        placeholder,
      }),
    ],
    immediatelyRender: false,
    onUpdate: ({ editor }) =&amp;gt; {
      if (setText) setText(editor);
    },
    onCreate: ({ editor }) =&amp;gt; {
      if (onEditorReady) onEditorReady(editor);
    },
  });

  //EditPostPage처럼 initalText(text)가 존재할 시 세팅해줌
  useEffect(() =&amp;gt; {
    if (editor &amp;amp;&amp;amp; text &amp;amp;&amp;amp; editor.isEmpty) {
      editor.commands.setContent(text);
    }
  }, [editor, text]);

  return (
    &amp;lt;&amp;gt;

      &amp;lt;EditorContent editor={editor} /&amp;gt;

    &amp;lt;/&amp;gt;
  );
}&lt;/code&gt;&lt;/pre&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;Editor 작성 내용 보기&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;1. PostBar에서 추가된 내용&lt;/b&gt;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;836&quot; data-origin-height=&quot;459&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/W5p2m/dJMb99TB4AM/nbNBCskN4kL6OWnNQPcvpK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/W5p2m/dJMb99TB4AM/nbNBCskN4kL6OWnNQPcvpK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/W5p2m/dJMb99TB4AM/nbNBCskN4kL6OWnNQPcvpK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FW5p2m%2FdJMb99TB4AM%2FnbNBCskN4kL6OWnNQPcvpK%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;590&quot; height=&quot;324&quot; data-origin-width=&quot;836&quot; data-origin-height=&quot;459&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;에디터를 추가하면 텍스트 내용에 html 태그가 추가가 되어서&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;html text가 된다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그래서 html text에서 text만 추출하는 함수가 필요하다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;그게 바로 &lt;u&gt;htmlToText&lt;/u&gt;이다 (htmlToText.js에 존재)&lt;/p&gt;
&lt;pre id=&quot;code_1776599969928&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;export const htmlToText = (html) =&amp;gt; {
  const parser = new DOMParser();
  const doc = parser.parseFromString(html, 'text/html');
  return doc.body.textContent || '';
};

&amp;lt;div className={`${styles.container} ${className}`}&amp;gt;
  &amp;lt;div className={styles.body}&amp;gt;
    &amp;lt;div&amp;gt;
      &amp;lt;Meta
        userRoleId={userRoleId}
        userDisplay={userDisplay}
        createdAt={createdAt}
        isEdited={isEdited}
      &amp;gt;
        {children}
      &amp;lt;/Meta&amp;gt;

      &amp;lt;div className={styles.title}&amp;gt;{title}&amp;lt;/div&amp;gt;
      &amp;lt;div className={styles.content}&amp;gt;{htmlToText(content)}&amp;lt;/div&amp;gt;
    &amp;lt;/div&amp;gt;

    {hasMediaAttachment &amp;amp;&amp;amp; &amp;lt;Thumbnail thumbnailUrl={thumbnailUrl} /&amp;gt;}
  &amp;lt;/div&amp;gt;

  &amp;lt;ActionContainer
    likeCount={likeCount}
    commentCount={commentCount}
    scrapCount={scrapCount}
    isLiked={isLiked}
    isScrapped={isScrapped}
  /&amp;gt;
&amp;lt;/div&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;2. PostDetailView.jsx에서 추가된 내용&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;- dangerouslySetInnerHTML을 사용해서 editor로 인해 추가한 html 태그들을 실제로 적용해준다.&amp;nbsp;&amp;nbsp;&lt;/p&gt;
&lt;pre id=&quot;code_1776600647309&quot; class=&quot;javascript&quot; data-ke-language=&quot;javascript&quot; data-ke-type=&quot;codeblock&quot;&gt;&lt;code&gt;&amp;lt;div
  className={styles.contentText}
  dangerouslySetInnerHTML={{
    __html: sanitizeHtml(preserveEmptyParagraphs(data.content)),
  }}
/&amp;gt;;&lt;/code&gt;&lt;/pre&gt;
&lt;hr contenteditable=&quot;false&quot; data-ke-type=&quot;horizontalRule&quot; data-ke-style=&quot;style5&quot; /&gt;
&lt;h4 data-ke-size=&quot;size20&quot;&gt;추가 작업&lt;/h4&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;PR [SNO-378]&lt;/b&gt;&lt;/p&gt;
&lt;ul style=&quot;list-style-type: disc; background-color: #ffffff; color: #010409; text-align: start;&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;li&gt;글자 크기 선택하는 드롭다운 컴포넌트에 그림자 스타일 추가&lt;/li&gt;
&lt;li&gt;에디터 고정바 내 불필요한 기능 제거 (인용구, 링크 삽입, iframe 삽입)&lt;/li&gt;
&lt;li&gt;에디터 코드 일부 리팩토링 (EditorContainer.jsx 내 로직 분리, style 변수 적용 등)&lt;/li&gt;
&lt;/ul&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://github.com/snorose/snorose-front/pull/1546&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://github.com/snorose/snorose-front/pull/1546&lt;/a&gt;&lt;/p&gt;
&lt;figure id=&quot;og_1776866305470&quot; contenteditable=&quot;false&quot; data-ke-type=&quot;opengraph&quot; data-ke-align=&quot;alignCenter&quot; data-og-type=&quot;object&quot; data-og-title=&quot;[SNO-378] 링크 삽입 시 링크 첨부 방식을 선택할 수 있는 버블 메뉴 표시 by dawncoding &amp;middot; Pull Request #154&quot; data-og-description=&quot;  What is this PR? Close #1543   변경 사항 링크 삽입 시 링크 첨부 방식을 선택할 수 있는 버블 메뉴 표시 버블 메뉴로 링크 및 임베드 방식 선택 게시글 작성 및 수정 페이지 배경 색상 변경 글자 &quot; data-og-host=&quot;github.com&quot; data-og-source-url=&quot;https://github.com/snorose/snorose-front/pull/1546&quot; data-og-url=&quot;https://github.com/snorose/snorose-front/pull/1546&quot; data-og-image=&quot;https://scrap.kakaocdn.net/dn/v5STG/dJMb8T91Q33/IypBCCuVF9vp4YyoqshslK/img.png?width=1200&amp;amp;height=600&amp;amp;face=993_144_1049_205,https://scrap.kakaocdn.net/dn/dep3eL/dJMb8U8VV9P/KaR23gWLrlDxRoNcZ2YP90/img.png?width=1200&amp;amp;height=600&amp;amp;face=993_144_1049_205&quot;&gt;&lt;a href=&quot;https://github.com/snorose/snorose-front/pull/1546&quot; target=&quot;_blank&quot; rel=&quot;noopener&quot; data-source-url=&quot;https://github.com/snorose/snorose-front/pull/1546&quot;&gt;
&lt;div class=&quot;og-image&quot; style=&quot;background-image: url('https://scrap.kakaocdn.net/dn/v5STG/dJMb8T91Q33/IypBCCuVF9vp4YyoqshslK/img.png?width=1200&amp;amp;height=600&amp;amp;face=993_144_1049_205,https://scrap.kakaocdn.net/dn/dep3eL/dJMb8U8VV9P/KaR23gWLrlDxRoNcZ2YP90/img.png?width=1200&amp;amp;height=600&amp;amp;face=993_144_1049_205');&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;[SNO-378] 링크 삽입 시 링크 첨부 방식을 선택할 수 있는 버블 메뉴 표시 by dawncoding &amp;middot; Pull Request #154&lt;/p&gt;
&lt;p class=&quot;og-desc&quot; data-ke-size=&quot;size16&quot;&gt;  What is this PR? Close #1543   변경 사항 링크 삽입 시 링크 첨부 방식을 선택할 수 있는 버블 메뉴 표시 버블 메뉴로 링크 및 임베드 방식 선택 게시글 작성 및 수정 페이지 배경 색상 변경 글자&lt;/p&gt;
&lt;p class=&quot;og-host&quot; data-ke-size=&quot;size16&quot;&gt;github.com&lt;/p&gt;
&lt;/div&gt;
&lt;/a&gt;&lt;/figure&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;</description>
      <category>웹프로젝트/스노로즈</category>
      <author>공장 주인 따꿍</author>
      <guid isPermaLink="true">https://capprojectfactory.tistory.com/136</guid>
      <comments>https://capprojectfactory.tistory.com/136#entry136comment</comments>
      <pubDate>Sun, 19 Apr 2026 01:15:30 +0900</pubDate>
    </item>
    <item>
      <title>[프론트] Lexical Scope의 개념과 특성에 대해 설명하시오</title>
      <link>https://capprojectfactory.tistory.com/135</link>
      <description>&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;b&gt;한줄 정의&lt;/b&gt;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;Lexical Scope는 함수가&amp;nbsp;&lt;u&gt;정의된 위치&lt;/u&gt;에서 스코프가 결정되는 특성을 의미합니다.&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;동적 스코프인 Bash와 Shell Script와 달리,&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;선언된 위치와 방식에 따라 스코프가 결정됩니다.&amp;nbsp;&lt;/p&gt;
&lt;blockquote data-ke-style=&quot;style3&quot;&gt;렉시컬 스코프 (JavaScript): &lt;br /&gt;- 함수가 정의된 위치에서 스코프 결정 &lt;br /&gt;- 코드를 보면 알 수 있음 &lt;br /&gt;&lt;br /&gt;동적 스코프 (일부 언어): &lt;br /&gt;- 함수가 호출된 위치에서 스코프 결정 &lt;br /&gt;- 실행해봐야 알 수 있음&lt;/blockquote&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p&gt;&lt;figure class=&quot;imageblock alignLeft&quot; data-ke-mobileStyle=&quot;widthOrigin&quot; data-origin-width=&quot;922&quot; data-origin-height=&quot;308&quot;&gt;&lt;span data-url=&quot;https://blog.kakaocdn.net/dn/NsSWN/dJMcaiXgyqG/nSUakEu0CGIRWKKnicBJpK/img.png&quot; data-phocus=&quot;https://blog.kakaocdn.net/dn/NsSWN/dJMcaiXgyqG/nSUakEu0CGIRWKKnicBJpK/img.png&quot;&gt;&lt;img src=&quot;https://blog.kakaocdn.net/dn/NsSWN/dJMcaiXgyqG/nSUakEu0CGIRWKKnicBJpK/img.png&quot; srcset=&quot;https://img1.daumcdn.net/thumb/R1280x0/?scode=mtistory2&amp;fname=https%3A%2F%2Fblog.kakaocdn.net%2Fdn%2FNsSWN%2FdJMcaiXgyqG%2FnSUakEu0CGIRWKKnicBJpK%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;533&quot; height=&quot;178&quot; data-origin-width=&quot;922&quot; data-origin-height=&quot;308&quot;/&gt;&lt;/span&gt;&lt;/figure&gt;
&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&amp;nbsp;&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;이거와 관련되어서&amp;nbsp;&lt;u&gt;클로저&lt;/u&gt;라는 기법도 있으니 알아보면 좋을 듯 하다.&lt;/p&gt;
&lt;p data-ke-size=&quot;size16&quot;&gt;&lt;a href=&quot;https://capprojectfactory.tistory.com/115&quot; target=&quot;_blank&quot; rel=&quot;noopener&amp;nbsp;noreferrer&quot;&gt;https://capprojectfactory.tistory.com/115&lt;/a&gt;&lt;/p&gt;
&lt;h2 id=&quot;-핵심-정리&quot; style=&quot;background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-size=&quot;size26&quot;&gt;✅ 핵심 정리&lt;/h2&gt;
&lt;ol style=&quot;list-style-type: decimal; background-color: #ffffff; color: #212529; text-align: start;&quot; data-ke-list-type=&quot;decimal&quot;&gt;
&lt;li&gt;렉시컬 스코프 = 함수가 선언된 위치를 기준으로 스코프가 결정된다.&lt;/li&gt;
&lt;li&gt;중첩된 함수는 외부 함수의 변수에 접근할 수 있다.&lt;/li&gt;
&lt;li&gt;호출 위치는 스코프에 영향을 주지 않는다.&lt;/li&gt;
&lt;li&gt;내부 함수는 외부 함수의 변수를 렉시컬 스코프를 통해 기억한다.&lt;/li&gt;
&lt;/ol&gt;</description>
      <category>오늘의 개발지식/기술면접 준비</category>
      <author>공장 주인 따꿍</author>
      <guid isPermaLink="true">https://capprojectfactory.tistory.com/135</guid>
      <comments>https://capprojectfactory.tistory.com/135#entry135comment</comments>
      <pubDate>Fri, 17 Apr 2026 14:59:53 +0900</pubDate>
    </item>
  </channel>
</rss>