<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Dev_won</title>
        <link>https://velog.io/</link>
        <description>Frontend Developer</description>
        <lastBuildDate>Mon, 16 Mar 2026 15:16:51 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>Dev_won</title>
            <url>https://velog.velcdn.com/images/seongwon__105/profile/b82c4572-e7bf-408e-9008-f797478cfdc4/image.jpg</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. Dev_won. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/seongwon__105" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[나의 첫 장기 프로젝트, 그 1년 간의 기록]]></title>
            <link>https://velog.io/@seongwon__105/projectyear</link>
            <guid>https://velog.io/@seongwon__105/projectyear</guid>
            <pubDate>Mon, 16 Mar 2026 15:16:51 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>작년 1월부터 나는 <a href="https://www.moadong.com/">모아동</a>이라는 대학교 동아리를 모아 보는 사이트를 운영하고 있다. </p>
<p>2025년은 나에겐 <strong>모아동의 해</strong>였다고 해도 과언이 아닐 정도로 많은 노력을 투자했다. </p>
<p>그래서 2025 회고를 적을까하다가, 정말 많은 시간을 이 프로젝트에 투자했기에  오로지 이 프로젝트를 위한 회고를 써 주겠다 마음을 먹은 것이다.</p>
<h2 id="2025년-1월">2025년 1월</h2>
<p>이 프로젝트를 하기 전, 나는 대학교 학생증 QR을 인식해 출석체크 및 인원관리를 하는 프로젝트를 했었다. </p>
<p>어찌저찌해서 GDG Busan DevFest에서 사이드부스를 운영하게 되었고 충분히 가능성있고 확장가능한 서비스라는 피드백을 받았다. 하지만 정말 큰 문제가 있었다. 그건 바로 <strong>개인정보문제</strong>였다. </p>
<p>학생들의 개인정보를 DB에 저장해야 인식이 가능했는데, 학교 전담 변호사분과도 얘기해야 했고, 당장 내가 속해있던 동아리 내부에서도 사용을 꺼려하는 사람들이 일부 있었다.</p>
<p>기존 팀원들끼리 다시 의견을 모았다. <strong>결국 개인정보가 문제가 될 것이고, 우리는 다른 방법으로 학교 내에 가치를 전달해야 한다.</strong> </p>
<p>그래서 나온 아이디어가 바로 동아리 서비스였다. 다른 학교에서도 성공한 서비스가 있었다. 심지어 우리 학교에서도 몇 년 전 앱으로 출시했다가 금방 망한 사례가 있었다. 하지만 우리는 믿음이 있었다.</p>
<h3 id="신입생들의-이야기">신입생들의 이야기</h3>
<p>아무 이유없이 프로젝트를 시작할 수는 없었다. 10명이라도 좋으니 수요를 관찰하고 만들어보자는게 우리의 생각이었다. </p>
<p>다행히도 동아리 내에 신입생들이 많이 있어서 그들의 의견을 쉽게 들을 수 있었다. 그들의 의견은 이랬다. </p>
<p><strong>&quot;에브리타임에서만 동아리를 찾는게 불편하다. 항상 올라오는 글을 직접 찾아봐야한다.</strong></p>
<p><strong>&quot;학교에 어떤 동아리가 있는지 모르겠다. 한 번에 볼 수 있는 곳이 있으면 좋겠다.&quot;</strong></p>
<p>우리 학교는 68개가 넘는 다양한 동아리가 있는데 인스타나 학교 홈페이지를 찾아봐도 정보가 없으니 알 수가 없다. 사실 신입생뿐만 아니라 재학생들도 자신이 한 동아리 외에는 잘 모르는 분위기였다.</p>
<p> 무엇보다 처음 온 신입생들은 에브리타임 새내기게시판만을 사용할 수 있는데, 일부 동아리들은 새내기가 아니라 동아리게시판에 올리니 신입생들이 얻을 수 있는 정보가 제한적이었다.</p>
<h3 id="바로-시작하자">바로 시작하자</h3>
<p>주변 신입생들의 얘기를 듣고 수요가 있다고 판단했다. 무엇보다 다른 대학교의 잘 된 선례가 있으니, 우리가 못 할 게 뭐가 있냐는 생각도 있었다.</p>
<p>물론 잘 되기 위해서는 디자인적 완성도가 필수라고 생각했다. 무엇보다 개발자들 중 디자인을 좋아하거나 잘 하는 사람도 없었다. 그래서 같은 학교 시각디자인전공 디자이너 한 분은 섭외했다.</p>
<p>그렇게 프론트 2명, 백엔드 4명, 디자이너1명으로 팀이 꾸려졌다. 첫 회의는 새마음 새뜻으로 1월 2일에 시작했다. 2월말부터 동아리 모집이 시작되기에 디자인과 개발을 합쳐 1개월 반 정도 남아있었다.</p>
<h3 id="문제는-개발이-아니다">문제는 개발이 아니다</h3>
<p>우리가 개발해야 할 건 동아리 목록과 배너가 있는 메인페이지, 그리고 각 동아리 상세정보가 있는 상세페이지였다. 개발은 정말 간단했다. 어 근데 이걸 어떻게 동아리 관리자들이 쓰게 하지?라는 생각이 몰려왔다.</p>
<p>68개의 동아리 관리자들은 이미 에브리타임으로 동아리를 홍보하고 있었다. 1차목표는 에브리타임에 홍보를 올릴 때 우리 서비스링크를 첨부하게 하기 였다. </p>
<p>그들과 컨택하기 위해서는 모든 동아리를 관리하는 총동아리연합회의 힘이 필요했다. 하지만 총동연측에선 완성될지 모르는 단계에서 관리자들에게 사용하라고 부추기기엔 리스크가 있다는 입장이었다.</p>
<p>그리고 1개월 반만에 관리자들을 위한 기능도 추가하는 건 무리라고 판단했다. 그렇게 야심찬 3월의 부흥은 물거품으로 돌아갔다.</p>
<h2 id="약간의-희망">약간의 희망</h2>
<p>팀의 목표는 <strong>&quot;대학교 학생들 모두가 우리 서비스로 동아리를 찾고 지원한다.&quot;</strong>였다. 그러기 위해서는 온전히 우리 힘으로만 할 수는 없었다.</p>
<p>홍보를 하려면 홍보 플랫폼이 있어야 하는데, 자체적인 홍보효과가 없으니 그것마저 에브리타임의 힘을 빌려야 했다. </p>
<p>총동연의 도움이라면 충분히 홍보효과를 볼 수 있었지만, 그들은 새로운 시도보다는 기존의 안정성을 택했다.</p>
<p>장기적으로 우리 서비스가 안정적으로 유지되려면 모든 동아리를 관리하고 있는 총동아리연합회의 힘이 무조건 필요하다고 생각했다. 그들을 설득하기 위해서는 <strong>일단 신뢰성을 쌓아야 했다.</strong> </p>
<h3 id="총동연과의-첫-미팅">총동연과의 첫 미팅</h3>
<p>개강 후 총동아리연합회 회장과 첫 대면 미팅을 가졌다. 우리가 얘기한 신입생들의 고충을 동감해주었고, 6월말까지 모든 동아리를 섭외하겠다는 공동의 목표를 세웠다.</p>
<p>그때까지 서비스 완성도를 높이는 일에 집중해야 했다.</p>
<h3 id="관리자-기능">관리자 기능?</h3>
<p>그 당시에는 우리 서비스를 많은 사람들이 사용하려면 먼저 동아리 관리자를 섭외해야한다고 생각했다. 관리자들이 사용한다면 학생들도 신뢰하고 이용할 것이라는 믿음 때문이었다. 훗날 이 믿음은 서비스를 더 늪으로 가져갔다.</p>
<p>돌아와서 관리자들이 겪는 어려움이 무엇인지부터 파악해야 했다. 총동연의 힘을 빌려 관리자들만 모아 둔 단톡을 만드는 것까진 성공했기에, 편하게 구글폼으로 피드백을 받을 수 있었다. </p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/778bfe33-4a75-4ea8-91de-0ccc010903e1/image.png" alt=""></p>
<p>그 많은 관리자들 중 비록 20퍼센트가 안 되는 인원들이 피드백에 응해줬고, 그 중 다수가 동아리 지원과 SNS 공유 기능을 원했다. </p>
<p>SNS 공유 기능은 매우 간단했지만, 지원하기는 신경 쓸 부분이 많았다.</p>
<ul>
<li>모집기간 설정 기능</li>
<li>외부지원폼 설정 기능 (구글, 네이버 등)</li>
<li>서비스 자체 지원서 기능</li>
</ul>
<p>이번에도 개발 자체의 문제는 없었다. 모집시기까지는 아직 3개월이라는 시간이 있었다. 그 사이에 우리는 빠르게 관리자 기능을 개발하기 시작했다.</p>
<h3 id="첫-홍보">첫 홍보</h3>
<p>5월에는 학생들에게 서비스를 처음 홍보했다. 모집기간은 방학이 시작되는 7월, 그 전에 미리 서비스를 각인시켜줘야겠다는 생각이 들었다.</p>
<p>학생들이 홍보하기 가장 좋은 곳은 아무래도 에브리타임이다. 간단한 사진과 글을 적어 올렸고 다행히도 반응이 좋았다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/22160b8c-2f07-4d08-a3ac-fddf08ccc6f8/image.png' width=400/>

<p>덕분에 당일 사용자가 300명에 가깝게 들어왔고, 우리는 가능성을 맛봤다. </p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/9a4758e3-95dc-4ef1-a8da-3306e52cf015/image.png' width=200/>



<h3 id="내부지원서라는-위기">내부지원서라는 위기</h3>
<p>관리자들이 사용할 수 있는 지원서를 만들기 위한 계획을 세웠다. 맹점은 관리자들은 이미 구글폼, 네이버폼 등의 외부 사이트를 사용하고 있었고, 굳이 우리 서비스 내부의 지원서 기능을 사용할 이유가 없었다.</p>
<p>하지만 우리는 그 부분을 크게 받아들이지 않고 내부지원서 기능을 감행했다. 지원서뿐만 아니라 정보 수정을 위한 관리자 기능이 우선순위였다. 3개월, 즉 12주에서 시험기간을 제외하고 6주만에 그 기능들을 모두 완성해야 했다. </p>
<p>간과한 것은 동시에 <strong>동아리 관리자들이 우리 서비스의 지원서 기능을 사용하도록 설득해야 한다는 것</strong>이었다.</p>
<p>관리자 기능에 생각보다 많은 리소스가 들어갔고, 결국 6월말 지원서 기능을 완성하지 못했다. 우리가 보여줄 수 있는 건 메인 페이지, 상세 페이지 둘 뿐이었다. 지금 생각해보면 외부지원서폼만 넣도록 설정해뒀다면 서비스상 큰 문제가 없었을 것이다. </p>
<p>결과적으로 동아리 관리자가 사용할 수 있는 건 정보 수정밖에 없었다. 중간중간에 몇 번 총동연에 부탁하여 전체 동아리에 우리 서비스를 홍보했었지만, <strong>동아리를 하지 않는 학생들은 서비스에 대해 잘 모르고 있었다.</strong> 우리의 타겟은 동아리를 하지 않는 인원들인데, 정작 홍보는 동아리를 하고 있는 사람들한테만 한 것이다. </p>
<p>동아리 관리자들도 에브리타임에 올리면 되는데, 굳이 학생들이 잘 안 쓰는 서비스를 사용할 이유는 없었다.</p>
<h3 id="2025년-7월">2025년 7월</h3>
<p>모집기간이 시작되었을 때 에브리타임에 모집공고가 올라올 때마다 직접 서비스에 내용을 채워넣었다. 그리고 1주마다 에브리타임에 올리며 서비스를 홍보했다.</p>
<img src=https://velog.velcdn.com/images/seongwon__105/post/b00c1d68-335c-4a43-9f89-bda402d15029/image.png width=300/>



<p>그렇게 2개월동안 홍보했지만 누적사용자는 1400명에 불과했다. </p>
<p>신입생들은 이미 에브리타임에 익숙해져 있었고, 웹이라는 특성때문에 리텐션을 유지하는 것은 더 어려웠다.</p>
<h2 id="새로운-마음으로">새로운 마음으로</h2>
<p>지원서 기능에서의 병목, 그리고 동아리 관리자 포섭 실패가 있었지만 서비스에 대한 믿음은 여전했다.</p>
<h3 id="새로운-팀원">새로운 팀원</h3>
<p>8월이 되었을 무렵, 나를 포함한 2명이 부스트캠프를 하게 되었고 개발 리소스를 다 쳐내기 힘들다고 판단했다.</p>
<p>그리하여 3명을 더 뽑았고 이는 패착이었다. </p>
<p>첫 번째는 문서 부족이었다. 새로운 팀원들은 기존 프로젝트 구조를 모르기에 문서가 필요하다. 그 당시 문서라고는 테스스택, 회의록, 미팅 자료가 전부였다. </p>
<p>두 번째는 기존 개발자들이 온보딩에 사용할 시간이 없었다. 진행하던 개발과 부트캠프를 병행하면서도 새로운 팀원들을 기존 팀에 적응시키기엔 역부족이었다. </p>
<p>마지막은 7개월 간 달려 온 기존 팀원들과 새로운 팀원들의 싱크를 맞추는 일이었다. <strong>그 간극은 풀 수 있는 과제라기보단 열정의 온도를 맞추는 일에 더 가까웠다.</strong> </p>
<h3 id="빠른-이별">빠른 이별</h3>
<p>팀 생산성은 저하되었고 쳐내지 못 한 일들이 다음 스프린트로 계속 미뤄지는 현상이 발생했다. 이렇게 가면 저번과 같은 결과를 불러올 것이라는 두려움이 닥쳤다. 그럴수록 객관적인 시선이 필요했다. </p>
<p>모아동이 시작되기 초기부터 협업, 애자일, 홍보 등 많은 면에서 도움을 주었던 현업자 선배에게 나는 조언을 구했다. 활발하지 않은 소통과 팀 생산성이 저하된 상황에서 팀장이 해야 할 일은 빠르게 핵심 인원만 남기는 것이라는 조언을 들었다. 무엇보다 <strong>열정적인 팀원의 생산력을 낮추는 일을 방지하기 위해서라도</strong> 결정해야 한다고 했다.</p>
<p>지금 생각해보면 이 순간이 개인적으로 가장 힘들었던 상황인 동시에 성장할 수 있었던 큰 발판이었던 것 같다.</p>
<p>그렇게 회의에서 내가 느낀 점들을 설명하며 <strong>개발보다는 홍보에 집중할 미래를 그려갈 사람만 남아달라고 했다.</strong>
3명이 나갔고 남은 사람은 총 8명이었다. 목표가 같은 사람끼리 남았기에 더 큰 활력을 불어넣어 주었다.</p>
<h2 id="2026년">2026년</h2>
<p>2025년 우리는 두 번의 실패를 겪었다. 다음해에도 성공하지 못 한다면 어떻게 해야 할까라는 불안감이 있었다. 그래도 투자한 시간이 있어 쉽게 포기할 생각은 없었다.</p>
<p>방학동안 10명 정도 동아리 관리자들을 만나면서 들은 공통된 의견은 <strong>학생들이 잘 몰라서 사용하기 어렵다</strong>였다. 정리하자면</p>
<ul>
<li>동아리 관리자들은 신뢰성있는 서비스여야 사용한다.</li>
<li>총동아리연합회는 신뢰성이 보장된 단체다.</li>
</ul>
<p>그와 별개로 관리자들이 사용유무와 사용자들의 사용유무는 비례하지 않는다고 생각했다. 공통된 것은 <strong>신뢰성있는 서비스여야 모두가 사용한다</strong>였다.</p>
<h3 id="미팅에서의-성과">미팅에서의 성과</h3>
<p>총동아리연합회도 1년이 지나 새로운 인원들로 교체되었다. </p>
<p>새로운 회장과 첫 미팅을 했다. 회장은 작년 홍보쪽을 담당하던 분이었다. 
그때부터 모아동을 눈여겨봤다고 했다. 적극적으로 사용하고 싶었는데 아쉬웠다고 했다. </p>
<p>우리는 다양한 홍보방식을 제안했다. 2월부터 신입생이 들어올텐데 그때 만들어지는 모든 과 단톡에 홍보하기, 총동연 인스타에 서비스 홍보 게시물 올리기 등이 있었다. 다행히도 모든 제안을 흔쾌히 받아주었고, 그때부터 팀에는 확실한 동기부여가 생기기 시작했다.</p>
<p>이것 말고도 여러 가지 방안들이 오갔고 기대가 현실이 되는 순간이었다.</p>
<p>아래는 올해 진행했던 미팅 기록인데 <strong>대작전</strong>의 이름만큼 절실했다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/165eea4a-45bc-4434-859f-7e4d2ad76a6e/image.png' width=300 />

<h3 id="앱-도입">앱 도입</h3>
<p>웹은 최초 접근성이 좋지만 유지력은 떨어진다. 앱은 다운받고 어디 들어갈 필요없이 클릭 한 번으로 서비스에 접근할 수 있다.</p>
<p>무엇보다 <strong>알림기능</strong>은 장기적으로 유리할 것이라 생각했다. 모집정보, 모집기간 등 계속해서 사용자를 끌어오기 위해서 필수적이라고 판단했다.</p>
<p>팀에는 모바일 엔지니어로 일하고 계신 현업자 선배가 있었고, 덕분에 빠르게 React Native 웹뷰기반으로 앱을 만들 수 있었다. </p>
<h2 id="안정화">안정화</h2>
<p>신입생이 들어오기 전, 첫 홍보를 했다. 이전처럼 에브리타임으로 진행했고 역시나 좋은 반응이었다. 그러나 그게 끝이 아니었다.</p>
<p>모아동에 대한 글이 에브리타임에 자주 올라오기 시작했다. 동시에 바이럴인지 몰라도 앱다운로드수가 기하급수적으로 늘었다. 1개월간 무려 1000명이 넘게 앱을 다운받았다. 오로지 글 하나로 유입된 사용자였기에 입소문의 힘은 강력하다는 것을 체감했다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/23aa9573-9931-42d2-a1bc-5a4edb5d9f40/image.png' width=200/>
<img src='https://velog.velcdn.com/images/seongwon__105/post/293c0912-61d8-4a68-abc7-921b34f784a8/image.png' width=200/>



<h3 id="동아리소개한마당">동아리소개한마당</h3>
<p>3월에 열리는 동아리 소개 한마당은 모집 시즌에 지원하지 못한 학생들을 위해 하루 동안 오프라인 부스를 운영하며 추가 모집을 진행하는 행사이다.</p>
<p>아직 지원하지 못 한 학생들을 위해 모아동이 할 수 있는 일은 아직 있었다. 행사 일주일을 남기고 행사에 참여하는 동아리 부스 지도와 공연 시간표의 디자인+개발을 완성했다. <a href="https://velog.io/@seongwon__105/Figma-MCP%EB%A1%9C-%EB%B9%A0%EB%A5%B4%EA%B2%8C-%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0-feat-codex">당시 개발기록</a></p>
<p>운좋게도 우리 모아동팀 또한 행사에서 부스를 하게 되어 겸사겸사 홍보를 했다. 개인적으로는 현장실습때문에 못 갔지만 열심히 부스를 이끌어 준 팀원들한테 정말 고마웠다. </p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/37b1374d-9dd4-4f9c-84b3-d15516d5acca/image.jpeg' width=200/>



<h3 id="행사-후기">행사 후기</h3>
<p>행사 전 팝업과 배너로 미리 홍보를 했어서 그런지 사용자가 많이 들어왔다. 당일을 포함하여 무려 5000명 가까이 우리 사이트에 방문했고, 앱다운로드수는 2500이 넘었다.
<img src='https://velog.velcdn.com/images/seongwon__105/post/a088d9ff-66e8-45f1-9e6f-5c7b33b8ed20/image.png' width=200/></p>
<p>불과 2개월 전 10명도 들어오지 않던 서비스였는데 이렇게나 성장했다는 게 신기했다. 무엇보다 <strong>많은 사람들이 우리 서비스로 동아리를 찾고 지원한다</strong>는 우리의 첫 번째 목표를 달성한 것 같아서 뿌듯했다. </p>
<h2 id="다음-목표">다음 목표</h2>
<p>동아리 지원이라는 1차 목표를 달성한 후, 우리는 새로운 목표를 다시 세웠다.</p>
<p><strong>지원 시기에만 사용되는 앱은 지속적인 가치를 만들기 어렵다고 판단했기 때문이다.</strong></p>
<p>하지만 동아리 활동은 학기 내내 이어진다. 그래서 동아리원뿐만 아니라 모든 학생들이 각 동아리의 행사와 활동을 확인할 수 있도록 홍보 게시판 기능을 기획했다. 현재 대부분의 기능 개발을 마쳤고, 다음 주 중으로 배포할 예정이다.</p>
<h2 id="끝이-아닌-시작">끝이 아닌 시작</h2>
<p>앞서 이야기한 기능 외에도 다른 학교로의 확장, 서비스를 통한 수익 모델 구축 등 여러 계획을 가지고 있다. 때로는 현실적인 목표만큼이나, 야심찬 포부도 필요하다.</p>
<p>돌이켜보면 이렇게 오랜 기간 팀 프로젝트를 이어온 것은 처음이었다. 기술이 특별히 뛰어났던 것도, 아이디어가 압도적으로 독창적이었던 것도 아니었다. 다만 정말로 <strong>사람들이 사용하는 서비스를 만들고 운영해 보고 싶다는 마음이 컸다.</strong></p>
<p>1년이 넘는 시간 동안 함께해 준 팀원들에게 다시 한 번 감사의 마음을 전하며, 이 프로젝트가 하나의 마무리가 아니라 더 큰 도전을 향한 새로운 시작이 되기를 기대한다.</p>
<p>2026년도 화이팅!!! </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React.FC에 대해]]></title>
            <link>https://velog.io/@seongwon__105/React.FC%EC%97%90-%EB%8C%80%ED%95%B4</link>
            <guid>https://velog.io/@seongwon__105/React.FC%EC%97%90-%EB%8C%80%ED%95%B4</guid>
            <pubDate>Mon, 09 Mar 2026 12:51:26 GMT</pubDate>
            <description><![CDATA[<h3 id="reactfc를-지양해야-할-때">React.FC를 지양해야 할 때</h3>
<p>지금은 사용하지 않는 CRA에서는 React.FC를 템플릿에서 제거한 <a href="https://github.com/facebook/create-react-app/pull/8177">PR</a>을 확인할 수 있다. 그렇다면 왜 React.FC 사용을 지양해야 할까? 무조건 쓰지 말아야 하는 걸까?</p>
<pre><code class="language-tsx">// ❌ 지양                                                                                                                                      
  const Button: React.FC&lt;ButtonProps&gt; = ({ children }) =&gt; {                                                                                       
    return &lt;button&gt;{children}&lt;/button&gt;;                                                                                                           
  };

// ✅ 권장
  const Button = ({ children }: ButtonProps) =&gt; {
    return &lt;button&gt;{children}&lt;/button&gt;;
  };</code></pre>
<h3 id="1-children-이-암묵적으로-포함된다">1. <code>children</code> 이 암묵적으로 포함된다.</h3>
<p>React.FC는 자동으로 children을 props에 추가하므로, children을 받지 않는 컴포넌트에서도 children을 넘길 수 있어서 버그 가능성이 생긴다. (React 18에서는 제거됐지만 레거시에서 고려할 필요)</p>
<h3 id="2-제네릭-컴포넌트를-못-만든다">2. 제네릭 컴포넌트를 못 만든다.</h3>
<pre><code class="language-tsx">// ❌ React.FC로는 불가능
const List: React.FC&lt;ListProps&lt;T&gt;&gt; = ... // T를 어디에?

// ✅ 직접 선언하면 가능
const List = &lt;T,&gt;(props: ListProps&lt;T&gt;) =&gt; { ... };</code></pre>
<h3 id="3-compound-component가-번거롭다">3. compound component가 번거롭다.</h3>
<p>예시: Dropdown </p>
<pre><code class="language-tsx">// ❌ React.FC — sub-component마다 타입 선언에 다 적어야 함                                                                                     
  const Dropdown: React.FC&lt;DropdownProps&gt; &amp; {                                                                                                     
    Trigger: React.FC&lt;TriggerProps&gt;;                                                                                                              
    Menu: React.FC&lt;MenuProps&gt;;
    Item: React.FC&lt;ItemProps&gt;;
  } = ({ children }) =&gt; {
    return &lt;div className=&quot;dropdown&quot;&gt;{children}&lt;/div&gt;;
  };

  Dropdown.Trigger = ({ children }) =&gt; &lt;button&gt;{children}&lt;/button&gt;;
  Dropdown.Menu = ({ children }) =&gt; &lt;ul&gt;{children}&lt;/ul&gt;;
  Dropdown.Item = ({ label, onClick }) =&gt; &lt;li onClick={onClick}&gt;{label}&lt;/li&gt;;</code></pre>
<p><code>React.FC&lt;DropdownProps&gt;</code>로 타입을 선언하면, TypeScript는 Dropdown이 정확히 그 타입만 가진다고 본다. </p>
<pre><code class="language-tsx">  // TypeScript가 보는 관점:
  const Dropdown: React.FC&lt;DropdownProps&gt; = ...                                                                                                        
  // → Dropdown의 타입 = (props: DropdownProps) =&gt; ReactElement | null
  // → 그 외 프로퍼티? 없음</code></pre>
<p>Dropdown.Trigger는 React.FC<DropdownProps>에 없는 프로퍼티기에, 미리 “이 함수에는 Trigger, Menu, Item이 있어.”라고 <code>intersection(&amp;)</code> 으로 알려줘야 한다.</p>
<pre><code class="language-tsx">const Dropdown: React.FC&lt;DropdownProps&gt; &amp; {
    Trigger: React.FC&lt;TriggerProps&gt;;   // ← &quot;Trigger도 있을 거야&quot;
    Menu: React.FC&lt;MenuProps&gt;;         // ← &quot;Menu도 있을 거야&quot;
    Item: React.FC&lt;ItemProps&gt;;         // ← &quot;Item도 있을 거야&quot;
  } = ({ children }) =&gt; { ... };</code></pre>
<p>하지만 서브 컴포넌트가 많아질수록 React.FC로 타입 선언을 일일이 해 줘야 하기에 매우 번거롭고, 코드도 복잡해진다.</p>
<p>반면 FC 없이는 TS가 타입 추론만 하고 고정하지는 않는다. 그래서 프로퍼티를 붙이면 자동으로 타입을 추론한다.</p>
<pre><code class="language-tsx">const Dropdown = ({ children }: DropdownProps) =&gt; { ... };

Dropdown.Trigger = ... // 자동 추론해서 반영</code></pre>
<h3 id="정리">정리</h3>
<p>FC를 쓰면 타입이 잠겨서 미리 다 열거해야 하고, 안 쓰면 타입이 열려있어서 그냥 붙이기만 하면 되는 것으로 이해할 수 있겠다.</p>
<h3 id="reactfc를-사용하는-예시">React.FC를 사용하는 예시</h3>
<pre><code class="language-tsx">const TRIGGER_TYPE_ICON_MAP: Record&lt;
  TriggerType,
  React.FC&lt;{ color?: string; size?: string }&gt;
&gt; </code></pre>
<p>여기서 React.FC를 쓴 이유는 compound component가 아니라 Record의 value 타입을 지정하는 용도이기 때문이다.</p>
<p>⇒ “<strong>이 Record의 value는 color와 size props를 받는 React 컴포넌트다.</strong>”라는 타입 제약을 뜻한다.</p>
<p>만약 React.FC없이 같은 걸 표현하려면</p>
<pre><code class="language-tsx">  const TRIGGER_TYPE_ICON_MAP: Record&lt;
    TriggerType,
    (props: { color?: string; size?: string }) =&gt; React.ReactElement | null
  &gt;</code></pre>
<p>더 길고 번거롭다. 그래서 컴포넌트 타입을 참조용으로 쓸 때는 React.FC가 간결하다.</p>
<p>예시는 타입 참조기 때문에 children을 암묵적으로 포함하지 않으며, 타입 파라미터로 넘기면 되니까 제네릭과 상관이 없고, compound와도 무관하기 때문에 <strong>가독성과 간결함을 위해 사용한 것이다.</strong></p>
<p>ex. 타입 참조 시 - FC로 제네릭 문제는 없음</p>
<pre><code class="language-tsx">  type ListProps&lt;T&gt; = {
    items: T[];
    renderItem: (item: T) =&gt; React.ReactNode;
  };

  // 타입 참조에서는 T를 구체적으로 지정하니까 문제없음
  type Props = {
    userList: React.FC&lt;ListProps&lt;User&gt;&gt;;
    productList: React.FC&lt;ListProps&lt;Product&gt;&gt;;
  };</code></pre>
<p>선언 시에는 <T>를 함수 앞에 붙여야 하는데 FC 문법 구조상 넣을 곳이 없고, 타입 참조 시에는 이미 <code>&lt;User&gt;</code> 같이 구체적인 타입을 넣기 때문에 문제가 생기지 않는다.</p>
<h3 id="참고">참고</h3>
<blockquote>
<p><a href="https://www.reddit.com/r/reactjs/comments/ys70t9/is_is_still_problematic_to_use_reactfc_if_our/?tl=ko">https://www.reddit.com/r/reactjs/comments/ys70t9/is_is_still_problematic_to_use_reactfc_if_our/?tl=ko</a></p>
</blockquote>
<blockquote>
<p><a href="https://github.com/facebook/create-react-app/pull/8177">https://github.com/facebook/create-react-app/pull/8177</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[Figma MCP로 빠르게 개발하기 (feat. codex)]]></title>
            <link>https://velog.io/@seongwon__105/Figma-MCP%EB%A1%9C-%EB%B9%A0%EB%A5%B4%EA%B2%8C-%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0-feat-codex</link>
            <guid>https://velog.io/@seongwon__105/Figma-MCP%EB%A1%9C-%EB%B9%A0%EB%A5%B4%EA%B2%8C-%EA%B0%9C%EB%B0%9C%ED%95%98%EA%B8%B0-feat-codex</guid>
            <pubDate>Mon, 02 Mar 2026 04:15:48 GMT</pubDate>
            <description><![CDATA[<h3 id="배경">배경</h3>
<p>작년부터 저는 <strong>모아동</strong>이라는 대학교 동아리 서비스를 개발하여 지금까지 운영중이에요. 3월 5일 열리는 <strong>동아리 소개 한마당</strong>은 동아리에 마지막으로 지원할 수 있는 행사에요.</p>
<h3 id="요구사항">요구사항</h3>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/3ad83e23-823f-4aec-9d9f-2971b34c85c8/image.png" alt=""></p>
<p>3월 5일에 열리는 동아리 소개 한마당에서 사용되는 <strong>동아리 부스 지도</strong>를 만들어야 했어요. 테스트까지 생각하면 촉박한 시간이었기에 figma MCP를 사용해 빠르게 개발하는 것을 선택했어요.</p>
<h3 id="codex에-figma-mcp-연결">codex에 figma mcp 연결</h3>
<p>저는 터미널에서 codex를 쓰고 있었어요. 그래서 <code>.codex/config.toml</code> 에서 직접 수정해야 합니다. </p>
<h4 id="권한-변경">권한 변경</h4>
<pre><code class="language-toml">[projects.&quot;/Users/..../Desktop/moadong&quot;]
trust_level = &quot;untrusted&quot;</code></pre>
<p>제가 작업하고 있는 디렉토리의 권한을 볼 수 있어요. 디폴트는 untrusted기 때문에 trusted로 바꿔야 합니다.</p>
<h4 id="figma-mcp-붙이기">figma mcp 붙이기</h4>
<p>ide는 Cursor를 사용하고 있어요. Cursor에서 MCP를 사용하기 위해 Remote MCP Cient 기능을 활성화해야 해요. 이는 외부 MCP 서버 (여기선 Figma MCP)에 연결이 가능하도록 만드는 스위치라고 보면 됩니다.</p>
<pre><code class="language-toml">[features]
rmcp_client = true</code></pre>
<p>이제 Figma MCP 서버 연결 정보를 넣어줍니다.</p>
<pre><code class="language-toml">[mcp_servers.figma]
url = &quot;https://mcp.figma.com/mcp&quot;
bearer_token_env_var = &quot;FIGMA_OAUTH_TOKEN&quot;
http_headers = { &quot;X-Figma-Region&quot; = &quot;us-east-1&quot; }</code></pre>
<ul>
<li>url: Figma 공식 MCP 서버주소에요. Cursor는 여기로 API 요청을 보내요.</li>
<li><code>bearer_token_env_var</code>: 인증 토큰을 직접 쓰는 것이 아니라 환경변수에 저장된 값을 사용하도록 하는 설정이에요. </li>
</ul>
<pre><code class="language-bash">export FIGMA_OAUTH_TOKEN=피그마_토큰</code></pre>
<p>이렇게 세팅되어야 해요. Cursor는 실행할 때 </p>
<pre><code>Authorization: Bearer &lt;환경변수값&gt;</code></pre><p> 이렇게 헤더를 붙여 Figma MCP에 요청을 보내요. </p>
<ul>
<li><code>http_headers</code>: Figma MCP를 리전 기반으로 동작해요. 저는 us-east-1을 썼어요.</li>
</ul>
<h4 id="figma-token-발급">Figma token 발급</h4>
<p>Figma Token은 홈에서 프로필 클릭하면 setting이 나오는데요. 거기서 Security를 보면 액세스 토큰을 발급할 수 있어요.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/45fabca8-6c53-4bb6-bce4-3193585b20b1/image.png" alt=""></p>
<h4 id="token-적용">token 적용</h4>
<p>token을 적용하는 것은 두 개의 방법이 있어요. mac인 경우 <code>~/.zshrc</code>에 </p>
<p><code>&#39;export FIGMA_OAUTH_TOKEN=발급받은토큰&#39;</code></p>
<p>이걸 저장하고 <code>source ~./zshrc</code>로 저장하면 됩니다.</p>
<p>두 번째는 mcp를 쓸 터미널에서 미리 <code>&#39;export FIGMA_OAUTH_TOKEN=발급받은토큰&#39;</code>를 입력하면 터미널에서 token을 사용할 수 있어요.</p>
<h3 id="figma-node-id">Figma node-id</h3>
<p>저는 cursor ide에서 터미널로 codex를 켜서 작업했고, codex 실행 전에 터미널에 피그마 토큰을 주입하는 것으로 했습니다.</p>
<p>Figma에서는 node-id로 컴포넌트를 읽을 수 있습니다. 
<code>https://www.figma.com/design/...?node-id=8847-8004&amp;m=draw</code> 컴포넌트를 클릭하면 이런 식으로 된 URL을 볼 수 있는데, node-id를 codex에게 던져주면 해당 컴포넌트를 읽을 수 있습니다.</p>
<h3 id="cursor---codex---figma-mcp-통신-순서">Cursor - Codex - Figma MCP 통신 순서</h3>
<p>실제로는 <strong>Cursor(또는 Codex 실행 환경)</strong>가 MCP 클라이언트 역할을 하고, Figma 공식 MCP 서버와 통신합니다.
  요청은 내가 링크(노드 id 포함)를 입력하는 순간부터 아래 순서로 진행돼요.</p>
<ol>
<li>사용자 입력<ul>
<li>Figma URL(예: ...?node-id=8851-6378)을 Codex에 전달</li>
</ul>
</li>
<li>Codex가 node-id 추출<ul>
<li>링크에서 fileKey, node-id를 파싱해서 어떤 프레임을 읽을지 결정</li>
</ul>
</li>
<li>MCP 서버 설정 확인<ul>
<li>~/.codex/config.toml의 [mcp_servers.figma] 설정 사용</li>
<li>bearer_token_env_var = &quot;FIGMA_OAUTH_TOKEN&quot;로 토큰을 환경변수에서 읽음</li>
</ul>
</li>
<li>Figma MCP 서버로 요청 전송<ul>
<li>Codex → <a href="https://mcp.figma.com/mcp">https://mcp.figma.com/mcp</a></li>
<li>헤더:<ul>
<li>Authorization: Bearer <FIGMA_OAUTH_TOKEN></li>
<li>X-Figma-Region: us-east-1</li>
</ul>
</li>
</ul>
</li>
<li>Figma MCP가 Figma 데이터 조회<ul>
<li>해당 노드의 구조(텍스트, 레이아웃, 스타일), 메타데이터, 스크린샷/에셋 URL 반환</li>
</ul>
</li>
<li>Codex가 결과를 코드로 변환<ul>
<li>반환된 디자인 컨텍스트를 기준으로 코드 생성/수정</li>
<li>필요하면 여러 노드를 순차 조회해 화면 전체 구성 완성</li>
</ul>
</li>
</ol>
<h3 id="요구사항-전달하기">요구사항 전달하기</h3>
<pre><code>내가 준 node-id 4개를 읽고 특정 파일 내에 동아리 부스지도 컴포넌트를 만들어줘.
아래 1/4 ~ 4/4 보여주도록 추가해주고 점선 누르면 슬라이드 이동도 가능해야해</code></pre><img src='https://velog.velcdn.com/images/seongwon__105/post/18ede646-8348-4a0d-a694-d53e2478444e/image.gif' width=300/>


<p>결과적으로 기존 컴포넌트를 깨뜨리지 않으면서 요구사항과 동일하게 구현하는 데 성공했습니다.
프로젝트에서는 이미 Swiper 라이브러리를 사용하고 있었고, Codex가 프로젝트 구조를 파악한 뒤 기존 Swiper 기반 구조에 맞춰 슬라이드를 구현해주는 것을 확인할 수 있었습니다.</p>
<h3 id="후기">후기</h3>
<p>작년 10월에도 Figma MCP를 이용해 컴포넌트를 구현한 적이 있었는데 그때는 픽셀도 제각각에다가 결과물도 좋지 않았어요. 제가 직접 구현하는게 더 빠를 정도였으니까요. 5개월이 지난 지금 Figma MCP의 성능은 훨씬 좋아졌고 직접 개발하는 것의 몇 배는 생산성이 높아졌어요.</p>
<p>조금 아쉬웠던 것은 카드 컴포넌트 내에 들어가는 여러 요소들의 위치를 하드코딩으로 구현한 것이었어요. 정말 껍데기만 잘 만들고 확장성이나 유지보수를 전혀 생각하지 않은 느낌이었어요. </p>
<p>이번 기능 개발은 딱 하루를 위한 기능이었기 때문에 확장성과 유지보수를 크게 고려하지 않아도 되기에 다시 수정하지는 않았습니다. 하지만 이후에도 간단한 컴포넌트는 Figma MCP로 개발해 볼 생각이에요. 확장성과 유지보수성을 프롬프트에 추가한다면 결과물이 또 어떻게 달라질지 궁금해지네요. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[swiper 라이브러리 내부 톺아보기 2]]></title>
            <link>https://velog.io/@seongwon__105/swiper-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EB%82%B4%EB%B6%80-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0-2</link>
            <guid>https://velog.io/@seongwon__105/swiper-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EB%82%B4%EB%B6%80-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0-2</guid>
            <pubDate>Thu, 15 Jan 2026 15:26:43 GMT</pubDate>
            <description><![CDATA[<p><a href="https://velog.io/@seongwon__105/swipe-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EB%82%B4%EB%B6%80-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0-1">이전글</a>에서는 swiper 라이브러리에 구현되어 있는 슬라이드 종류와 일반 슬라이드 구현의 특징에 대해 알아보았다. </p>
<p>이번에는 Virtual 슬라이드의 개념과 쓰임에 대해 알아보려 한다.</p>
<h2 id="virtural-슬라이드의-내부동작">Virtural 슬라이드의 내부동작</h2>
<p>Virtual 슬라이드의 메소드 인터페이스는 <a href="https://github.com/nolimits4web/swiper/blob/975277111b73f389043cb0ed19feee0244a80f57/src/types/modules/virtual.d.ts#L1">virtual.d.ts</a>에서 볼 수 있다.</p>
<p>Virtual 슬라이드에서는 <a href="https://github.com/nolimits4web/swiper/blob/975277111b73f389043cb0ed19feee0244a80f57/src/types/modules/manipulation.d.ts#L1">일반 슬라이드 메소드</a>인 <code>appendSlide, prependSlide, removeSlide, removeAllSlides</code>외에도 <code>from, to, cache, slides, update</code>를 가진다. </p>
<p>이 메소드들은 왜 더 필요할까? 그것은 DOM을 다루는 방식에 있다.</p>
<h3 id="1-일반-vs-virtual의-상태를-가지는-주인">1. 일반 vs Virtual의 상태를 가지는 주인</h3>
<h4 id="일반-슬라이드">일반 슬라이드</h4>
<ul>
<li>상태의 근원 = DOM</li>
<li>슬라이드 추가/삭제 = DOM에 append/prepend/remove</li>
<li>Swiper는 DOM을 읽어 (recalcSlides, update) 내부 상태를 재조정</li>
</ul>
<h4 id="virtual-슬라이드">Virtual 슬라이드</h4>
<ul>
<li>상태의 근원 = <code>virtual.slides</code>배열</li>
<li>DOM은 &quot;화면에 필요한 만큼&quot; 부분적으로 존재</li>
<li>Swiper는 아래의 값들을 계산한다</li>
</ul>
<pre><code>    - 지금 화면에 어떤 인덱스를 보여줘야 할지 (from/to)
    - 지금 렌더링된 DOM을 어디에 놓아야 하는지 (offset)
    - 이미 만들어 둔 DOM을 재사용해야 하는지 (cache)</code></pre><p>Virtual은 렌더링 엔진이 하나 더 붙은 구조라서, 인터페이스가 &quot;상태+렌더 제어&quot;까지 포함한다.</p>
<h3 id="2-virtual에-cachefromtoupdate가-있는-이유">2. Virtual에 cache/from/to/update가 있는 이유</h3>
<h4 id="from-to">from, to</h4>
<p>Virtual은 전체 슬라이드가 DOM에 없다. 그래서 &quot;현재 렌더링된 구간&quot;을 반드시 기억해야 한다.</p>
<ul>
<li>from = 현재 DOM에 존재하는 첫 슬라이드 인덱스</li>
<li>to = 현재 DOM에 존재하는 마지막 슬라이드 인덱스</li>
</ul>
<h4 id="offset">offset</h4>
<p>Virtual은 일부만 DOM에 렌더링하기 때문에, 실제로는 &quot;100번째 슬라이드부터 렌더링&quot;해도 DOM상으로는 첫 번째처럼 보일 수 있다.</p>
<p>그래서 원래 위치처럼 보이도록 밀어주는 값이 필요하다.</p>
<p>가로면 left/right, 세로면 top을 사용한다. 실제로 React호환성 코드에서도 이 값을 style로 사용한다. <a href="https://github.com/nolimits4web/swiper/blob/ec4977b629c0823173e7b8bde7feb040a9fc4ff3/src/react/virtual.mjs">react/virual.mjs</a></p>
<h4 id="cache">cache</h4>
<p>Virtual은 계속 DOM을 만들었다가 지웠다를 반복한다. 매번 새로 만드는 비용을 줄이기 위해 한 번 만든 SlideEl을 저장해두고 재사용하는 방식으로 비용을 줄인다.</p>
<p>일반 슬라이드는 DOM에 계속 존재하기에 cache가 필요없다.</p>
<h4 id="update">update</h4>
<p>일반 슬라이드는 DOM을 추가하면 되지만, Virtual은 데이터만 바뀌면 DOM을 다시 맞춰 렌더링해야 한다.</p>
<p>그래서 <code>virtual.update()</code>는 아래와 같은 역할을 수행한다.</p>
<ul>
<li>activeIndex 기준 <code>from, to</code> 재계산</li>
<li>DOM에 있어야 할 슬라이드만 남기고 교체</li>
<li>offset 재적용</li>
</ul>
<h3 id="3-addslide의-부재">3. addSlide의 부재</h3>
<ul>
<li>일반 슬라이드: append / prepend / add / remove / removeAll</li>
<li>Virtual: append / prepend / remove / removeAll / update</li>
</ul>
<p>Virtual에 addSlide가 없는 이유는 중간 삽입은 배열 수정으로 하고 update만 호출하면 되기 때문이다.</p>
<h3 id="4-reactvue에서-virtual을-지양한다">4. React/Vue에서 Virtual을 지양한다</h3>
<blockquote>
<p>Only for Core version (in React &amp; Vue it should be done by modifying slides array/data/source) </p>
</blockquote>
<p>React와 Vue는 렌더링 주도권이 자신에게 있다.</p>
<p>만약 Swiper가 DOM을 직접 append, prepend하면 React의 Virtual DOM과 충돌한다.</p>
<h3 id="5-virtual-최적화">5. Virtual 최적화</h3>
<p><a href="https://github.com/nolimits4web/swiper/blob/975277111b73f389043cb0ed19feee0244a80f57/src/modules/virtual/virtual.css#L14">virtual.css</a>에서는 Virtual 슬라이드의 성능이나 스크롤이 깨지지 않도록 한다.</p>
<p>핵심부분을 살펴보자면</p>
<p><strong>translateZ(0)</strong></p>
<ul>
<li>해당 요소를 CPU 레이어로 올리는 역할이다. 스와이프 중 깜빡임이나 떨림을 방지한다.</li>
</ul>
<p><strong>backface-visibility: hidden</strong></p>
<ul>
<li>3D 변환 중 뒷면 렌더링으로 발생할 수 있는 텍스트 깨짐을 방지한다.</li>
</ul>
<p>Virtual에서 이 부분들이 중요한 이유는 DOM을 계속 갈아끼우는 데에 있다. 그렇게 갈아끼운 DOM의 위치를 transform으로 재조정한다.</p>
<p>만약 GPU 레이어로 올리지 않으면 페인트 비용과 시각적으로 깨짐이 발생한다. 이것은 브라우저 렌더링 순서와 관련이 있다.</p>
<h4 id="브라우저-렌더링-순서">브라우저 렌더링 순서</h4>
<ol>
<li>style 계산</li>
<li>Layout (reflow) - 위치/크기 계산</li>
<li>Paint - 픽셀 비트맵으로 그림</li>
<li>Composite - 레이어 합성해서 화면에 출력</li>
</ol>
<p>여기서 깨짐(flicker, jitter, tearing)은 대부분 <strong>Paint 단계에서 발생</strong>한다.</p>
<h4 id="virtual에서-일어나는-일">Virtual에서 일어나는 일</h4>
<p>Virtual은 스와이프할 때마다 <code>기존 슬라이드 DOM 제거 -&gt; 새로운 슬라이드 DOM 삽입 -&gt; 위치 재조정</code>이 일어난다. 즉, 한 프레임 안에서 DOM 트리 변경, 위치 변경, 스타일 변경이 한꺼번에 일어나는 것이다.</p>
<h4 id="cpu-레이어만-쓴다면">CPU 레이어만 쓴다면</h4>
<p>DOM 제거/추가는 reflow, 위치 변경은 repaint라 하면 브라우더 렌더링 순서 중 Layout -&gt; Paint가 자주 발생하게 된다. 프레임 안에 다 못 끝낸다면 중간 상태가 화면에 노출된다.</p>
<p>Paint는 이전 비트맵 위에 변경된 영역만 다시 그리는 <strong>누적 비트맵</strong>이라 Virtual에서 Layout -&gt; Paint 가 원자적으로 처리되지 않으면 앞에서 말한 &quot;깨짐&quot;이 발생한다.</p>
<h4 id="gpu-레이어를-쓰면-달라지는-것">GPU 레이어를 쓰면 달라지는 것</h4>
<p>GPU 레이어의 특징은 이렇다.</p>
<ul>
<li>Layouy / Paint를 다시 안 함</li>
<li>이미 그려진 비트맵을 CPU에서 위치만 이동</li>
<li>Composite 단계에서만 처리</li>
</ul>
<p>그렇기에 요소를 별도의 합성 레이어로 분리함으로써 repaint가 아니라 composite단계에서 처리하는 것이다.</p>
<p>깨짐이 사라지는 이유도 프레임 단위로 레이어가 교체되기 때문에 중간 상태가 화면에 노출될 일이 없기 때문이다.</p>
<h4 id="virtual에서-특히-중요한-이유">virtual에서 특히 중요한 이유</h4>
<p>일반 슬라이드에선 DOM이 거의 고정되어 있고 위치 변화가 적다. 그렇기에 repaint 빈도가 낮은 반면, Virtual에서는 DOM을 자주 교체하여 offset이 계속 변경되므로 repaint 빈도가 높다.</p>
<p>CPU 레이어는 Virtual 슬라이드에서 일어나는 Paint를 감당할 수 없기에 GPU 레이어로 올린 것이다.</p>
<h3 id="다음-챕터">다음 챕터</h3>
<p>Virtual 슬라이드를 알아보니 브라우저 내부 동작과 관련있다는 것이 신기했다. 다음은 React에서 Swiper가 어떻게 돌아가는지 알아볼 예정이다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[swiper 라이브러리 내부 톺아보기 1]]></title>
            <link>https://velog.io/@seongwon__105/swipe-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EB%82%B4%EB%B6%80-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0-1</link>
            <guid>https://velog.io/@seongwon__105/swipe-%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-%EB%82%B4%EB%B6%80-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0-1</guid>
            <pubDate>Tue, 13 Jan 2026 17:21:07 GMT</pubDate>
            <description><![CDATA[<p>최근 캐러셀 구현을 위해 swipe 라이브러리를 사용했다.</p>
<p>내부 동작이 궁금해 <a href="https://github.com/nolimits4web/swiper/tree/ec4977b629c0823173e7b8bde7feb040a9fc4ff3">swipe 깃허브</a>에서 코드를 보고 간단하게 정리해보았다.</p>
<h2 id="swipe-슬라이드-구분">Swipe 슬라이드 구분</h2>
<p>Swipe 라이브러리에는 슬라이드 관리 방식이 크게 2가지가 있다.</p>
<h3 id="1-일반-슬라이드-dom-기반-슬라이드">1. 일반 슬라이드 (DOM 기반 슬라이드)</h3>
<blockquote>
<p>모든 슬라이드를 실제 DOM으로 다 만들어서 관리</p>
</blockquote>
<ul>
<li>슬라이드가 전부 <code>.swipe-slide</code> DOM으로 존재한다.</li>
<li><code>appendSlide</code>, <code>removeSlide</code>는 DOM을 직접 추가/삭제</li>
</ul>
<h4 id="언제-쓸까">언제 쓸까?</h4>
<ul>
<li>슬라이드 수가 적을 때</li>
<li>단순한 캐러셀, 배너</li>
<li>구현이나 디버깅이 쉬운 것을 원할 때</li>
</ul>
<h3 id="2-virtual-슬라이드">2. Virtual 슬라이드</h3>
<blockquote>
<p>슬라이드 데이터만 들고 있고
화면에 필요한 일부만 DOM으로 렌더링</p>
</blockquote>
<ul>
<li>전체 슬라이드는 JS배열로 관리한다.</li>
<li>DOM에는 항상 현재 화면 주변 슬라이드만 존재한다.</li>
<li>스크롤/스와이프 시 DOM을 교체한다.</li>
</ul>
<h4 id="언제-쓸까-1">언제 쓸까?</h4>
<ul>
<li>슬라이드가 매우 많을 때</li>
<li>무한 스크롤</li>
<li>React/Vue와 같이 Virtual DOM을 쓰는 환경</li>
</ul>
<h3 id="일반-슬라이드-내부-동작">일반 슬라이드 내부 동작</h3>
<p>일반 슬라이드의 메소드 인터페이스는 <a href="https://github.com/nolimits4web/swiper/blob/975277111b73f389043cb0ed19feee0244a80f57/src/types/modules/manipulation.d.ts#L1">manipulation.ts</a>에서 볼 수 있다.</p>
<p>이제 <a href="https://github.com/nolimits4web/swiper/tree/ec4977b629c0823173e7b8bde7feb040a9fc4ff3/src/modules/manipulation/methods">코드</a>를 살펴보자.</p>
<h4 id="1-addslide">1. <code>addSlide</code></h4>
<h4 id="동작흐름">동작흐름</h4>
<pre><code>1. loop면 구조 해체
2. index 뒤 슬라이드 전부 잠시 제거
3. 새 슬라이드 삽입
4. 제거했던 슬라이드 다시 붙임
5. 전체 재계산 + loop 복구 + slideTo로 화면유지</code></pre><h4 id="핵심로직">핵심로직</h4>
<pre><code class="language-ts">// 1. index 이후 슬라이드들을 DOM에서 제거해서 임시 저장
const slidesBuffer = [];
for (let i = baseLength - 1; i &gt;= index; i -= 1) {
  const currentSlide = swiper.slides[i];
  currentSlide.remove();
  slidesBuffer.unshift(currentSlide);
}

// 2. 새 슬라이드를 원하는 위치에 append
slidesEl.append(slides);

// 3. 제거했던 슬라이드들을 다시 뒤에 붙임
for (let i = 0; i &lt; slidesBuffer.length; i += 1) {
  slidesEl.append(slidesBuffer[i]);
}</code></pre>
<p>DOM에는 insertBefore 같은 고수준 API를 쓰지 않고 있다.</p>
<p><code>&quot;뒤쪽 슬라이드 전부 빼고 -&gt; 새 슬라이드 붙이고 -&gt; 다시 붙인다&quot;</code> 전략을 쓰기 때문이다.</p>
<h4 id="why">why</h4>
<p>Swipe는 내부적으로 슬라이드 순서를 단순하게 <code>slidesEl.children</code> 순서로 관리하기 때문에 중간 삽입을 직접 지원하지 않는다. 그래서 물리적으로 DOM을 재배치하는 방식을 사용하고 있다.</p>
<h3 id="2-removeslide">2. removeSlide</h3>
<h4 id="동작흐름-1">동작흐름</h4>
<pre><code>1. loop 모드면 복제 슬라이드 때문에 loop 제거
2. 지정한 인덱스의 슬라이드 DOM 제거
3. activeIndex 보정
4. 슬라이드 목록 재계산
5. loop 복구
6. 화면 유지 (보던 슬라이드로 이동)</code></pre><p>loop 모드에서는 앞/뒤에 <strong>복제 슬라이드</strong>가 붙어서 인덱스가 어긋나기에 loop를 제거한다.</p>
<h4 id="핵심로직-1">핵심로직</h4>
<pre><code class="language-ts">// 1. 슬라이드 DOM 제거
swiper.slides[indexToRemove].remove();

// 2. 삭제로 인해 activeIndex 보정
if (indexToRemove &lt; activeIndex) {
  activeIndex -= 1;
}

// 3. 슬라이드 목록 재수집
swiper.recalcSlides();</code></pre>
<p>슬라이드 DOM을 제거하고, 그로 인해 밀린 activeIndex를 보정한 뒤 Swiper가 다시 계산하게 하는 것이다.</p>
<p>이외에도 모든 슬라이드를 제거하는 <code>removeAllSlides</code>가 있다.</p>
<h3 id="3-appendslide">3. appendSlide</h3>
<h4 id="동작흐름-2">동작흐름</h4>
<pre><code>1. loop 모드면 기존 loop 구조 제거
2. 슬라이드 DOM 추가 
    2-1. 문자열 -&gt; 임시 DOM 생성 후 append
    2-2. HTMLElement -&gt; 그대로 append
3. 슬라이드 목록 재계산
4. loop 복구 
5. 레이아웃과 상태 업데이트</code></pre><h4 id="핵심로직-2">핵심로직</h4>
<pre><code class="language-ts">// 1. 슬라이드 DOM에 추가
slidesEl.append(slideEl);

// 2. Swiper 내부 슬라이드 목록 재수집
swiper.recalcSlides();</code></pre>
<p>복잡한 로직은 없었고 슬라이드 DOM 추가 -&gt; Swipe가 변화를 인식하는 것이 다였다.</p>
<h3 id="4-prependslide">4. prependSlide</h3>
<h4 id="동작흐름-3">동작흐름</h4>
<pre><code>1. loop 모드면 기존 loop 구조 제거
2. 슬라이드 DOM을 맨 앞에 추가
    2-1. 문자열 → 임시 DOM → prepend
    2-2. HTMLElement → 그대로 prepend
3. activeIndex 보정 (앞의 슬라이드가 추가되었으므로)
4. 슬라이드 목록 재계산
5. loop 복구
6. 레이아웃과 상태 업데이트
7. 보던 슬라이드로 유지 </code></pre><h4 id="핵심로직-3">핵심로직</h4>
<pre><code class="language-ts">// 1. 슬라이드를 맨 앞에 DOM으로 추가
slidesEl.prepend(slideEl);

// 2. 앞에 추가됐으므로 activeIndex 보정
newActiveIndex = activeIndex + addedSlidesCount;

// 3. Swiper 내부 상태 재계산
swiper.recalcSlides();</code></pre>
<p>슬라이드를 DOM 앞에 붙이고, activeIndex를 밀어준 뒤 Swipe가 다시 계산하게 한다.</p>
<h3 id="간단정리">간단정리</h3>
<p><code>appendSlide</code> → DOM 뒤에 추가</p>
<p><code>prependSlide</code> → DOM 앞에 추가 + index 밀기</p>
<p><code>addSlide</code> → DOM 재배치</p>
<p><code>removeSlide</code> → DOM 제거 + index 당기기</p>
<h3 id="다음-챕터">다음 챕터</h3>
<p>다음글에서는 Virtual 슬라이드 동작과 특징, 그리고 일반 슬라이드와 Virtural 슬라이드의 차이점과 쓰임에 대해 작성해보려 한다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[프론트엔드 주도 개발 ]]></title>
            <link>https://velog.io/@seongwon__105/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%A3%BC%EB%8F%84-%EA%B0%9C%EB%B0%9C</link>
            <guid>https://velog.io/@seongwon__105/%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C-%EC%A3%BC%EB%8F%84-%EA%B0%9C%EB%B0%9C</guid>
            <pubDate>Fri, 07 Nov 2025 09:18:56 GMT</pubDate>
            <description><![CDATA[<h2 id="주도-개발">주도 개발</h2>
<p>TDD, BDD, DDD 뒤에 붙은 DD(Driven Development) 에서 따온 단어인데요.</p>
<p>개발뿐만 아니라 협업에서도 주도하는 역할이 중요하다고 생각합니다. 저는 프론트엔드 개발자로서 여러 프로젝트에서 다양한 분들과 협업을 해 왔는데요. 프론트엔드 개발자가 할 수 있는 주도 개발에는 어떤 것이 있을 지 고민해 봤습니다.</p>
<h2 id="프론트엔드가-하는-일">프론트엔드가 하는 일</h2>
<p>프로젝트에서 프론트엔드가 하는 작업은 UI/UX를 고려한 컴포넌트 설계 및 제작, api 요청응답 관리, 라우팅 설계, 최적화 작업 등 여러 가지가 있습니다.</p>
<p>이 중에서 저는 오늘 UI/UX와 api에 관련하여 프론트엔드가 주도할 수 있는 작업을 얘기하려고 합니다. </p>
<h2 id="늦어지는-작업-속도">늦어지는 작업 속도</h2>
<p>&quot;디자인이 아직 없어서 컴포넌트를 제작할 수 없어.&quot;</p>
<p>&quot;백엔드 api 구현이 안 되어서 api 연동을 못 하네. 일단 mock data로 대신해서 보여줘야 하나..?&quot;</p>
<p>다들 프로젝트를 하면서 이런 경험을 한 번쯤 해 보셨을 것 같아요. 개발을 처음 시작했을 때는 이 간극을 어떻게 메워야 할 지, 팀원에게 어떤 식으로 얘기할 지 고민이 많았던 것 같습니다.</p>
<p>여러 프로젝트를 진행해 오면서 이 고민은 점점 커졌습니다. 어떻게 하면 디자이너-프론트, 프론트-백엔드 간의 작업 병목을 해소할 수 있을까 하고요. </p>
<h2 id="storybook으로-디자이너와-협업하기">Storybook으로 디자이너와 협업하기</h2>
<p>Storybook은 React, Vue 등에서 사용되는 UI 컴포넌트를 효율적으로 관리할 수 있는 개발 도구입니다. 
그 외에 Storybook에 대해 조금 더 알고 싶으시다면 <a href="https://velog.io/@seongwon__105/React-Storybook-%EC%A0%81%EC%9A%A9">React + Storybook 적용</a>을 참고해주세요!</p>
<p>개발을 하다 보면 UI 컴포넌트가 많아져 폴더를 일일이 찾아봐야 하는 번거로움이 생길 때가 있습니다.</p>
<p>이때 Storybook을 사용하면, 현재 추가한 UI 컴포넌트들만 모아 볼 수 있어요. 버튼을 예로 들면 <code>Button.stories.ts</code>로 버튼 스토리를 추가할 수 있어요. </p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/ba069af0-584a-439d-9275-374b3bfe8d21/image.png' width=200 />

<p>UI 컴포넌트가 많아질수록 추가해야 할 스토리 파일도 많아질 거에요. 저는 <code>stories</code> 라는 폴더에 스토리 파일들을 배치했어요. 컴포넌트 파일과 같이 두는 경우도 있지만 확장성을 고려한다면 저처럼 다른 폴더로 분리하는 것을 추천합니다.</p>
<h3 id="storybook-본-컴포넌트">Storybook 본 컴포넌트</h3>
<p>스토리북을 설치하셨다면 <code>npm run storybook</code>으로 스토리북을 실행할 수 있어요. </p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/9beb7d73-f454-414b-ac61-74f76ad7f478/image.png" alt=""></p>
<p>왼쪽 파일들이 모두 <code>stories.ts</code> 파일입니다. 오른쪽엔 이름, 설명, 타입, 제어 등 다양한 속성이 존재해요.</p>
<pre><code class="language-typescript">import { Button } from &#39;@/components/Button/Button&#39;;
import type { Meta, StoryObj } from &#39;@storybook/nextjs&#39;;

const meta: Meta&lt;typeof Button&gt; = {
  title: &#39;Components/Button&#39;,
  component: Button,
  tags: [&#39;autodocs&#39;],
};

export default meta;
type Story = StoryObj&lt;typeof Button&gt;;</code></pre>
<p>해당 코드는 Button 스토리 파일에서 기본으로 설정해야 하는 코드입니다. <code>tags: [&#39;autodocs&#39;]</code> 부분이 문서 페이지를 자동으로 생성해주는 부분이고요. </p>
<p>meta 객체는 스토리북 사이드바에 표시될 경로, 그리고 문서를 만들 대상 컴포넌트를 지정하는 핵심적인 설정이에요. 
그 외 StoryObj 타입이나 export default meta 구문은 타입스크립트의 타입 안정성을 등록하기 위한 표준 보일러플레이트입니다. </p>
<h3 id="chromatic으로-배포">Chromatic으로 배포</h3>
<p>아직은 로컬에서만 보이는 스토리북을 배포할 수 있는 방법이 따로 있습니다. 바로 Chromatic이라는 도구입니다. netlify,vercel 같은 배포 도구가 있지만 chromatic은 배포 전에 컴포넌트를 미리 볼 수 있고, 무엇보다 스토리북에 최적화되어 있다는 것이 장점이기에 사용했습니다.</p>
<pre><code class="language-bash">npm install --save-dev chromatic // 의존성 설치

npx chromatic --project-token &lt;your-project-token&gt; // 스토리북을 chromatic에 배포</code></pre>
<p>명령어를 수행하면 추가한 스토리북 파일들이 chromatic에 배포됩니다. 실제로 제가 배포한 <a href="https://687f952cbf0b6b7d2e31932f-ysfatlexad.chromatic.com/">스토리북 배포링크</a> 입니다.</p>
<h3 id="디자이너에게-공유하기">디자이너에게 공유하기</h3>
<p>위 링크는 서비스가 배포되기 전에 미리 디자이너에게 공유할 수 있습니다. 이것의 가장 큰 장점은 디자이너가 보는 UI와 개발자가 제작한 UI 간극을 최대한 줄일 수 있다는 것입니다.</p>
<p>이미 머지가 된 PR인데 갑자기 수정 요청이 오면 당황스럽겠죠. 또 자주 그런 일이 발생한다면 개발자나 디자이너 모두에게 부담이 될 것입니다.</p>
<h3 id="💁🏻-개발-주도하기">💁🏻 개발 주도하기</h3>
<p>일반적으로는 디자이너가 먼저 Figma 같은 디자인툴을 이용해 컴포넌트를 디자인하고, 개발자가 이를 코드로 구현하는 &quot;디자인 주도 개발&quot; 방식을 따릅니다.</p>
<p>하지만 개발자가 먼저 공통 컴포넌트를 만들고 디자이너에게 공유하는 개발을 주도할 수 있지 않을까요?</p>
<h3 id="장점">장점</h3>
<p>심미적인 부분이나 UX적인 흐름보다 기능 구현에 초점을 맞춰 공유한다면, 디자이너는 기능적인 측면의 고민들보다 UX와 그 외 스타일 부분들에 집중할 수 있어요. 또한 빠른 프로토타입을 만들어야 할 때 유용합니다.</p>
<h3 id="단점">단점</h3>
<p>물론 단점도 존재합니다. 디자이너가 나중에 컴포넌트를 보고 마음에 들지 않는다고 하면, 개발자는 다시 코드를 대폭 수정해야 하겠죠. </p>
<h3 id="그럼에도-추천해요">그럼에도 추천해요</h3>
<p>프로젝트의 성격에 따라 개발을 주도할 수 있는 방법이 달라질 것 같은데요, 이런 방법도 가능할 것 같다~ 라는 하나의 예시로 봐 주시면 될 것 같아요. 그럼에도 불구하고 Storybook과 Chromatic을 사용하는 것은 프론트엔드와 디자인 간의 의사소통을 더 적극적으로 가능하게 하고, 작업 병목을 해소할 수 있는 아주 좋은 방법이라고 생각합니다.</p>
<h2 id="백엔드와-협업하기">백엔드와 협업하기</h2>
<p>그 다음은 프론트엔드-백엔드 사이의 작업 병목을 해결하기 위해서 어떤 방법을 사용할 수 있을까에 대한 고민을 해 봤어요.</p>
<h3 id="api-연동-프로세스">API 연동 프로세스</h3>
<p>개발자는 API 개발을 하기 위해 해야 하는 여러 가지 단계가 있습니다.</p>
<pre><code>➡️ API 명세서 작성 -&gt; 프론트 UI 개발 -&gt; 백엔드 API 개발 (기다려야 함) -&gt; API 연동</code></pre><p>여기서 백엔드 개발자가 API를 개발하기 전까지 프론트엔드는 기다려야 합니다. </p>
<p>추가적으로 생기는 귀찮음도 존재해요.</p>
<ul>
<li>백엔드는 매번 mock api를 생성해주고 삭제해야 함</li>
<li>mock api를 적용할 때와 아닐 때 api 주소를 변경해야 함 </li>
</ul>
<p>이때 MSW를 활용하여 프론트에서 미리 API 요청/응답을 모의 테스트할 수 있습니다.</p>
<h3 id="msw">msw</h3>
<p>Mock Server Worker(msw)는 프론트엔드 개발에서 백엔드 개발이 완료되기 전에 API 요청을 가로채서 모의(mock)응답을 보내주는 라이브러리입니다.</p>
<h3 id="msw-사용-방법">msw 사용 방법</h3>
<p>이제 msw 기본 설정에 필요한 핵심 코드들을 설명해보겠습니다.</p>
<h3 id="api-핸들러-정의">API 핸들러 정의</h3>
<p><strong>📂 src/mocks/handlers.js</strong></p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/5a114970-c162-4dec-81b5-9350d52cc405/image.png" alt=""></p>
<p><code>http</code>는 어떤 HTTP 요청을 가로챌지 정의하는 메서드들의 모음입니다. 흔히 아는 HTTP 메서드명과 이름이 같아요. 실제 api 코드처럼 작성하면 됩니다.</p>
<p>단지 실제 api 요청을 보내는 것이 아니라, 요청을 백엔드 서버에 도착하기전에 가로챈다는 것이 차이점이에요.</p>
<p><code>HttpResponse</code>는 http가 가로챈 요청에 대해 실제 브라우저에게 돌려줄 mock 응답을 쉽게 만들어줍니다. 가장 많이 사용되는 게 바로 <code>HttpResponse.json()</code> 메서드에요.</p>
<h3 id="브라우저-환경-설정">브라우저 환경 설정</h3>
<p><strong>📂 src/mocks/browser.js</strong></p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/247e1624-b504-437f-92be-5c56f64a9480/image.png" alt=""></p>
<p>이제 서비스 워커를 등록하여 mock 폴더에서 발생하는 모든 네트워크 요청을 서비스 워커가 감시하고 가로채도록 합니다.</p>
<p>과거 모킹 라이브러리에서는 <code>fetch</code>나 <code>axios</code> 함수 자체를 덮어쓰는 방식을 사용했다고 해요. MSW는 그보다 더 낮은 네트워크 레벨에서 서비스 워커를 통해 요청을 가로채기 때문에, 코드를 더 깔끔하게 유지할 수 있어요.</p>
<h3 id="어플리케이션에-적용">어플리케이션에 적용</h3>
<p>MSW는 개발 환경에서만 실행되어야 하기 때문에 앱 진입점에 설정해야 할 부분이 있어요.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/7326ad89-7c8e-4076-affd-d38d0c23a733/image.png" alt=""></p>
<p>이제 development 환경에서만 msw가 실행되도록 변경했습니다.
bypass 옵션은 핸들러애 정의되지 않은 요청을 실제 네트워크로 전달하도록 합니다. </p>
<h3 id="💁🏻-개발-주도하기-1">💁🏻 개발 주도하기</h3>
<pre><code>➡️ API 명세서 작성 -&gt; 프론트 UI 개발 -&gt; 백엔드 API 개발 (기다려야 함) -&gt; API 연동</code></pre><p>다시 API 개발 프로세스를 가져오면, 이제 프론트엔드는 백엔드 API 개발이 완료되기 전에 기다림없이 API 요청과 응답을 테스트할 수 있어요. </p>
<p>실제 api 로직이 들어갈 부분에 mock api 코드를 추가하여 개발할 수 있고,
추후 백엔드에서 API 개발이 완료되면 mock api 부분을 실제 api 요청 코드로 변경만 하면 바로 연동이 가능합니다.</p>
<h2 id="마무리">마무리</h2>
<p>프론트엔드 개발자 입장에서 작업을 주도하는 방법에 대해 알아봤습니다. </p>
<p>글을 쓰면서 프론트 뿐만 아니라 백엔드, 디자이너 등 각 분야의 팀원이 주도할 수 있는 방법은 무엇일지 고민해봐도 좋을 것 같다는 생각이 들었습니다. </p>
<p>제 포스트가 팀프로젝트에 도움이 되었길 바라며 이것으로 마칩니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[네이버 부스트캠프 웹모바일 10기 합격 후기]]></title>
            <link>https://velog.io/@seongwon__105/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B6%80%EC%8A%A4%ED%8A%B8%EC%BA%A0%ED%94%84-%EC%9B%B9%EB%AA%A8%EB%B0%94%EC%9D%BC-10%EA%B8%B0-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</link>
            <guid>https://velog.io/@seongwon__105/%EB%84%A4%EC%9D%B4%EB%B2%84-%EB%B6%80%EC%8A%A4%ED%8A%B8%EC%BA%A0%ED%94%84-%EC%9B%B9%EB%AA%A8%EB%B0%94%EC%9D%BC-10%EA%B8%B0-%ED%95%A9%EA%B2%A9-%ED%9B%84%EA%B8%B0</guid>
            <pubDate>Thu, 14 Aug 2025 07:01:41 GMT</pubDate>
            <description><![CDATA[<h2 id="네이버-부스트캠프란">네이버 부스트캠프란?</h2>
<p> AI시대에 맞는 개발자를 교육하는 네이버 커넥트재단에서 운영하는 프로그램이다. 
 과정은 <strong>베이직, 챌린지, 멤버쉽</strong>로 총 3개로 구성되어 있다. <a href="https://boostcamp.connect.or.kr/program_wm.html">참고</a></p>
<p>모집 분야는 웹 풀스택, 모바일(ios, android)로 나누어져 있다. 프론트엔드 개발만 해 오다가 최근 백엔드에도 관심이 생겼었고, 마침 부스트캠프에 웹 풀스택 과정이 있는 것을 보고 지원하게 되었다. </p>
<h2 id="베이직">베이직</h2>
<p>2주동안 진행되는 챌린지 맛보기(?) 과정이다. 금요일을 제외하고 월<del>목요일 동안 매일 나오는 미션을 풀고, 다른 사람의 코드를 리뷰한다. 리뷰가 필수는 아니지만, 다양한 방식의 풀이가 궁금해서 미션마다 2</del>3개 정도 리뷰를 했다.</p>
<p>챌린지에 입과하려면 베이직 과정과 문제 해결력 테스트까지 해야 한다. 문제 해결력 테스트를 보려면 베이직에 있는 모든 문제를 풀어야 해서 정말 열심히 했다.</p>
<h2 id="문제-해결력-테스트">문제 해결력 테스트</h2>
<p>베이직이 목요일에 끝나고, 준비할 시간이 금요일밖에 없었다. 풀었던 미션을 보면서 부족했던 JS메소드를 숙지하고 갔다. 다행히 시험 환경에서 MDN 을 제공해줘서 생각 안 나는 메소드를 바로바로 찾아볼 수 있었다. </p>
<p>제한시간은 3시간이었고 코테 3개, 나머지는 CS 문제였다. (CS 문제가 좀 많았던 걸로 기억한다.) 1,2번을 풀고 나머지 시간은 CS 문제에 투자했다. 베이직에서 학습했던 내용이 생각보다 많았다. 제대로 학습하지 않았다면 힘들 수도 있기 때문에 베이직 과정을 착실히 해 낸 사람이 유리해보였다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/6eaf9663-8791-4ba2-87cc-70754c41fb61/image.png" alt=""></p>
<p>엄청 잘 본 것도, 그렇다고 못 본 것도 아닌 애매한 느낌이었다. 다행히 챌린지가 되어서 더더 열심히 해야겠다는 생각이 들었다.</p>
<h2 id="챌린지">챌린지</h2>
<p>주말을 제외하고 한 달 동안 매일매일 미션을 풀어야 한다.  </p>
<p>이 과정이 가장 힘들었다. 매일 10<del>19는 코어 타임, 10</del>12는 동료들과 실시간 피드백 및 피드백 작성으로 구성되어 있다. 지금와서 보면 코어 타임은 의미가 없었던 것 같다. 하루종일 해야 하기 때문이다. 매일 새벽에 잤는데, 매일매일 문제를 풀어야 하고 멤버쉽에 떨어지면 어떡하지 하는 불안감 때문에 더 열심히 했다.</p>
<h3 id="🏃챌린지-전의-나">🏃챌린지 전의 나</h3>
<p> 나는 프로젝트를 위한 공부만 해 왔기에, 필요할 때마다 찾아보고 적용하는 방식으로 학습했다. 단점은 빠르게 휘발되어서 시간이 지나면 잘 기억이 안 났다.</p>
<p>전공에서 배운 CS지식은 하나도 기억이 안 나고, 진정 내가 전공생인가 의구심이 들 정도였다.</p>
<h3 id="🥕얻은-것">🥕얻은 것</h3>
<p>챌린지에서는 학습 내용과 리드미를 추가로 작성해야 한다. 하루에 하나의 미션을 풀어야 하기에 학습과 구현의 밸런스를 잘 찾아야 한다. </p>
<p>주차가 지나면서 시간 분배가 정말 중요하다는 것을 느꼈다. 또한 너무 깊은 학습은 오히려 <strong>야크털을 깎는 행위</strong>라는 것이다. (멘토분께서 이렇게 표현하셨다.)</p>
<p>처음에는 야크털을 많이 깎으면서 시행착오를 많이 겪었고, 점점 학습과 구현의 밸런스를 찾아갔다. 학습을 많이 한다고 미션이 잘 해결되는 것이 아니고, 구현에만 집중해도 기억에 잘 남지 않았다. 그래서 각 미션마다 <strong>내가 얻어가고 싶은 것</strong>에 집중했다. </p>
<h3 id="챌린지의-끝">챌린지의 끝</h3>
<p>미션이 어려워 스스로 타협하는 순간도 있었고, 잠을 줄이면서 열심히 했던 순간도 있었다. 그러다보니 모르는 것 자체에 면역이 생겼다. 모르면 또 학습하면 되지 하는 마인드가 자연스럽게 생겼고 나에게 맞는 구현-학습 사이클이 생긴 것을 체감했다.</p>
<p>가장 큰 수확은 CS 지식이 모든 문제 해결의 근간이라는 것을 깨닫게 된 것이다. 학교에서 배울 때는 정말 먼 얘기처럼 들렸는데, 부스트캠프에서는 실제로 CS지식이 어떻게 활용되는지 이해할 수 있었다. </p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/7fe979c2-b380-4a3f-9539-c0250a205fbb/image.png" alt=""></p>
<p>결과는 합격이었다.. 마음속으로는 안 되면 어떡하지 하는 생각이 정말 많이 들었다. 챌린지를 잘 끝낸 나에게 정말 고생했다고 말해주고 싶고, 부족한 만큼 더 열심히, 잘 하고 싶다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[[JIRA] Github - JIRA담당자 자동화]]></title>
            <link>https://velog.io/@seongwon__105/JIRA-Github-JIRA%EB%8B%B4%EB%8B%B9%EC%9E%90-%EC%9E%90%EB%8F%99%ED%99%94</link>
            <guid>https://velog.io/@seongwon__105/JIRA-Github-JIRA%EB%8B%B4%EB%8B%B9%EC%9E%90-%EC%9E%90%EB%8F%99%ED%99%94</guid>
            <pubDate>Mon, 02 Jun 2025 16:11:58 GMT</pubDate>
            <description><![CDATA[<h2 id="jira-이슈-담당자-자동화">Jira 이슈 담당자 자동화</h2>
<p><a href="https://velog.io/@seongwon__105/JIRA-Github-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0-%EC%83%9D%EC%84%B1">이전 포스팅</a>에서는 깃허브 이슈와 지라 이슈 자동화에 대한 워크플로우를 만들어봤습니다. 하지만 지라 이슈의 담당자를 직접 설정해줘야 하는 문제가 있었습니다. 이 부분을 자동화하지 않으면 워크플로우의 의미가 없다고 생각했어요. 그래서 이번엔 이슈 담당자 자동화를 해 보았습니다.</p>
<h3 id="이슈-템플릿에-assignee추가">이슈 템플릿에 Assignee추가</h3>
<p>먼저 이슈 템플릿에서 Assignee를 드롭박스에서 선택할 수 있도록 만들었어요. <a href="https://github.com/Moadong/moadong/blob/main/.github/ISSUE_TEMPLATE/jira-issue-form.yml">이슈 템플릿 링크</a></p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/c6d5362c-a1b8-4690-85d7-c2e042f4657f/image.png' width=450/>


<h3 id="터미널에서-accountid-찾기">터미널에서 accountId 찾기</h3>
<p>깃허브 Assignee에 나오는 이름은 깃허브 닉네임이에요. 이것은 Jira에서 호환이 안 되겠죠. Jira에서 담당자를 설정하려면 각 담당자의 <code>accountId</code>를 알아야 합니다. 터미널에서 아래 명령어를 입력하면 기본적으로 50명의 사용자의 정보를 반환합니다.</p>
<pre><code class="language-bash">curl -u &lt;email&gt;:&lt;api_token&gt; &quot;https://&lt;your-domain&gt;.atlassian.net/rest/api/3/user/search&quot;</code></pre>
<p>원래는 제일 뒤에 <code>/query=사용자이름</code>으로 찾으려 했지만 잘 안 되었어요. 한글 문제인가 싶어서 영어를 입력했는데도 404가 뜨더군요. 그래서 위 명령어를 입력해서 손수 노가다로 <code>accountId</code>를 찾았습니다.</p>
<h3 id="깃허브-지라-간의-매핑-테이블-만들기">깃허브 지라 간의 매핑 테이블 만들기</h3>
<p>위에서 찾은 <code>accountId</code>와 깃허브 Assignee를 매핑한 테이블을 만들 차례입니다. 이것은 지라 워크플로우에 들어갈 필수적인 요소에요.</p>
<pre><code class="language-json">{
  &quot;seongwon030&quot;: &quot;accountId1&quot;,
  &quot;oesnuj&quot;: &quot;accountId2&quot;,
  &quot;Zepelown&quot;: &quot;accountId3&quot;,
  &quot;Due-IT&quot;: &quot;accountId4&quot;,
  &quot;PororoAndFriends&quot;: &quot;accountId5&quot;,
  &quot;lepitaaar&quot;: &quot;accountId6&quot;,
}</code></pre>
<p>이렇게 json형식으로 만들어줍니다. accountId부분은 임의로 넣었어요.</p>
<h3 id="accountid-보안-위험">accountId 보안 위험</h3>
<p><code>accountId</code>를 깃허브에 노출하면 해당 accountId로 api요청 시 displayName이 노출될 우려가 있습니다. 사용자 실명 유추가 가능한 것이죠. 또한, 조직 내 특정 유저가 존재한다는 사실이 노출되어 피싱 공격이 가능합니다. </p>
<h3 id="repository-secret으로-설정">repository secret으로 설정</h3>
<p>repository -&gt; setting -&gt; Secrets -&gt; Secrets and variables -&gt; Actions 으로 이동한 다음, New repository secret 를 클릭합니다.
<img src='https://velog.velcdn.com/images/seongwon__105/post/994ddc73-16c7-4e8c-8748-20bbb0737a7f/image.png' width=450/></p>
<h3 id="기존-워크플로우-수정">기존 워크플로우 수정</h3>
<p><a href="https://github.com/Moadong/moadong/blob/main/.github/workflows/common-jira-create.yml#L75">common-jira-create.yml</a></p>
<pre><code class="language-yml">      - name: Map GitHub username to Jira accountId
        id: assignee
        run: |
          echo &#39;${{ secrets.JIRA_USER_MAP }}&#39; &gt; user_map.json
          FORM_ASSIGNEE=&quot;${{ steps.issue-parser.outputs.issueparser_assignee }}&quot;
          ACCOUNT_ID=$(jq -r --arg user &quot;$FORM_ASSIGNEE&quot; &#39;.[$user]&#39; user_map.json)

          echo &quot;Resolved accountId for $FORM_ASSIGNEE → $ACCOUNT_ID&quot;
          echo &quot;accountId=$ACCOUNT_ID&quot; &gt;&gt; $GITHUB_OUTPUT</code></pre>
<ol>
<li><code>secrets.JIRA_USER_MAP</code>이라는 JSON 문자열을 user_map.json으로 저장합니다.</li>
<li>GitHub 이슈 템플릿에서 추출한 assignee 값을 $FORM_ASSIGNEE에 저장합니다.</li>
<li>jq를 사용해서 해당 사용자명의 <code>Jira accountId</code>를 추출합니다.</li>
<li>GITHUB_OUTPUT에 accountId를 저장해서 다음 step에서 사용 가능하게 만듭니다.</li>
</ol>
<p>중요한 것은 Login 워크플로우 후에 해당 작업이 수행되어야 한다는 것입니다. 로그인을 하지 않는다면 accountId를 식별하지 못 할 것입니다.</p>
<h3 id="워크플로우-테스트">워크플로우 테스트</h3>
<p>먼저 이슈를 생성합니다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/1af5e0b0-40fb-4144-8152-7f94c2c08dd0/image.png' width=450/>


<p>이제 지라에 이슈가 생성되었는지 확인합니다. 아래처럼 이슈가 잘 생성된 것을 확인할 수 있습니다.
<img src="https://velog.velcdn.com/images/seongwon__105/post/58e2a7d5-396d-4353-9e60-a0443b16a250/image.png" alt=""></p>
<p>마지막으로 담당자도 잘 할당되는 것을 볼 수 있습니다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/a7f2212f-0c60-418d-ad64-1e80814ceaa5/image.png' width=300/>

]]></description>
        </item>
        <item>
            <title><![CDATA[[JIRA] Github 이슈 워크플로우]]></title>
            <link>https://velog.io/@seongwon__105/JIRA-Github-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0-%EC%83%9D%EC%84%B1</link>
            <guid>https://velog.io/@seongwon__105/JIRA-Github-%EC%9B%8C%ED%81%AC%ED%94%8C%EB%A1%9C%EC%9A%B0-%EC%83%9D%EC%84%B1</guid>
            <pubDate>Sun, 01 Jun 2025 12:27:43 GMT</pubDate>
            <description><![CDATA[<h2 id="jira란">Jira란?</h2>
<p>Atlassian에서 개발한 프로젝트 관리 및 이슈 추적 도구로, 주로 소프트웨어 개발팀에서 사용됩니다. 애자일 방식에 유용하며, 칸반, 스크럼 등 다양한 방법론을 지원합니다. </p>
<h2 id="처음엔-그냥-">처음엔 그냥 ..</h2>
<p>프로젝트를 도중에 &#39;깃허브 프로젝트 기능보다 더 나은 도구가 없을까?&#39;하는 생각이 들었습니다. 깃허브 프로젝트는 TODO, INPROGRESS, DONE 세 가지로 프로젝트 진행 상황을 관리하며, 추가로 Milestone으로 스프린트 또한 관리할 수 있습니다.</p>
<p>깃허브 프로젝트를 도입하려던 찰나에 Jira라는 협업 도구가 제 눈에 띄었습니다. 애자일 프로세스에 유용하다는 것을 알게 되었고, 먼저 프론트엔드 이슈에 도입하였습니다. </p>
<p>사실 큰 이유는 없었어요. 저희 팀이 프론트엔드, 백엔드, 디자이너로 구성된 팀이었고 회의 때마다 각자의 진행상황을 공유했습니다. 하지만 실시간으로 각자의 진행상황을 파악하기 힘들었어요. 카카오톡으로 일일이 묻기 번거롭고, 계속 묻는 것도 서로에게 부담일 거니까요. 
개발자들은 디스코드 웹훅으로 어떤 이슈가 올라갔는지 실시간으로 알 수 있었지만, 작업들을 모아보기도 힘들었어요.</p>
<h2 id="fe만-워크플로우-만들기">FE만 워크플로우 만들기</h2>
<p>저는 여러 자료를 찾아보면서 깃허브 이슈와 지라 이슈를 연동하는 방법을 알게 되었습니다. 해당 워크플로우는 다음과 같습니다.</p>
<ol>
<li>깃허브에서 이슈를 생성한다.</li>
<li>Title과 Description을 작성한다.</li>
<li>FE-번호 형식에 맞게 티켓 넘버를 작성한다.</li>
<li>브랜치명을 적는다.</li>
<li>create를 한다.</li>
</ol>
<p>이렇게 하면 자동으로 브랜치명에 이슈 넘버와 티켓넘버가 붙도록 만들었어요. 예를 들면, FE-11 티켓넘버에 브랜치명은 <code>feature/add-login-ui</code>라고 가정했을 때 최종 브랜치명은 <code>feature/#깃허브이슈번호-add-login-ui</code>가 됩니다. 깃허브 이슈번호는 생성한 이슈에서 바로 가져오는 방식이에요.
해당 코드는 여기서 보실 수 있어요. <a href="https://github.com/Moadong/moadong/blob/main/.github/workflows/create-jira-issue.yml">create-jira-issue.yml</a> </p>
<p>초반에는 백로그 없이 보드로만 작업을 했어요. <img src="https://velog.velcdn.com/images/seongwon__105/post/81936a39-b0ce-4d32-a558-27632a0376c9/image.png" alt=""></p>
<h3 id="장점">장점</h3>
<p>브랜치를 생성할 때 이슈넘버를 확인하지 않아도 되어 정말 편해졌습니다. 보드로 작업 진행도를 파악하기도 쉬워졌어요. </p>
<h3 id="단점">단점</h3>
<p>하지만 깃허브 프로젝트와 다를 게 없다는 생각이 들었습니다. 보드 그 이상의 기능을 활용할 수 없었어요. 모든 팀원이 사용해야 스프린트, 백로그의 의미가 생기고 모든 작업의 진행도를 파악할 수 있으니까요. </p>
<h2 id="드디어-이유가-생겼다">드디어 이유가 생겼다</h2>
<p>저번주에 갑자기 팀장님이 Jira를 사용하는 게 어떠냐고 모든 팀원들에게 물어보셨습니다. 부트캠프에서 여러 현직자분들을 만나셨는데, 거의 다 Jira를 사용한다는 얘기를 들었다고 하셨어요. 그래서 팀원 모두의 찬성으로 저희 팀 모두가 Jira를 사용하기로 했어요. </p>
<p>저희가 쓰던 FE프로젝트는 놔두고, 새로운 프로젝트를 만들었습니다. 새로운 프로젝트에도 이슈가 생성되도록 워크플로우를 추가했습니다.</p>
<h2 id="깃허브-jira-연동하기">깃허브 jira 연동하기</h2>
<p>기존의 워크플로우는 무조건 <code>develop-fe</code> 브랜치에서 분기되었었는데, 이제는 모든 브랜치에서 분기가 가능하도록 해야 합니다. 프론트엔드, 백엔드 모두가 써야하기 때문입니다. </p>
<h3 id="이슈템플릿-변경하기">이슈템플릿 변경하기</h3>
<p><a href="https://github.com/Moadong/moadong/blob/main/.github/ISSUE_TEMPLATE/jira-issue-form.yml">이슈 템플릿 링크</a>
<img src="https://velog.velcdn.com/images/seongwon__105/post/e421dfb1-01ab-4728-b3d7-623230abc5f9/image.png" alt=""></p>
<p>분기할 브랜치 선택 항목을 추가하여, 어떤 브랜치든 분기할 수 있도록 만들었어요.</p>
<h3 id="워크플로우-변경하기">워크플로우 변경하기</h3>
<p><a href="https://github.com/Moadong/moadong/blob/main/.github/workflows/common-jira-create.yml">common-jira-create.yml</a>
입력한 브랜치에 대해 브랜치를 분기하도록 변경하였습니다. 추가로 기존에 없는 브랜치라면 워크플로우를 미리 중단하도록 했어요. </p>
<h2 id="결과-확인하기">결과 확인하기</h2>
<p>깃허브 이슈를 생성하니 지라에도 이슈가 잘 생성되네요!
<img src="https://velog.velcdn.com/images/seongwon__105/post/d23e5f21-d0c8-48a5-b862-8b95b69e3038/image.png" alt=""></p>
<p><a href="https://github.com/Moadong/moadong/blob/main/.github/workflows/close-jira-issue.yml">close-jira-issue.yml</a> 이것은 깃허브 이슈 닫기 시 지라 이슈를 완료상태로 변경해주는 코드입니다.</p>
<h2 id="마치며">마치며</h2>
<p>팀원 모두가 Jira를 사용하게 되어 기쁩니다. 스프린트, 백로그, 회고까지 애자일 프로세스를 적극 경험해보고 싶네요. 사용자를 만나면서 백로그를 작업하는 경험이 제일 기다려지네요. 피드백 환영합니다~!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Sentry with React]]></title>
            <link>https://velog.io/@seongwon__105/Sentry-with-React</link>
            <guid>https://velog.io/@seongwon__105/Sentry-with-React</guid>
            <pubDate>Fri, 16 May 2025 11:47:03 GMT</pubDate>
            <description><![CDATA[<h2 id="sentry란">Sentry란?</h2>
<p>Sentry는 어플리케이션에서 발생하는 에러를 자동으로 감지하고 추적합니다. 에러는 매일 감지되며 ip, 브라우저 정보, os정보 등 세부적인 정보를 제공합니다. </p>
<p><a href="https://sentry.io/welcome/">Sentry공식문서</a>에서 볼 수 있듯이 여러 언어, 프레임워크를 지원합니다. 
<img src="https://velog.velcdn.com/images/seongwon__105/post/6a7818dd-1961-467e-afb6-ae7e8ea64197/image.png" alt=""></p>
<h2 id="왜-사용할까">왜 사용할까?</h2>
<p>지금 진행 중인 <a href="https://github.com/Moadong/moadong">moadong</a> 프로젝트가 얼마 전 사용자에게 배포되었습니다. 배포하고 나서 문득 그런 생각이 들었습니다. &quot;사용자에게 보이는 에러는 어떻게 방지하지?&quot; </p>
<p>로컬에서 발생한 에러는 개발자 눈에 보이기 때문에 바로 고칠 수 있습니다. 반면 사용자에게 에러가 났을 때 언제 에러가 발생했는지, 무슨 에러인지 파악할 수  없습니다. 
Sentry는 로컬 환경이 아니어도 배포 주소만 설정하면 사용자에게 뜨는 모든 에러를 추적할 수 있습니다. 더 세세한 에러에 대처하고, 사용자에게 최대한 편한 UI/UX를 제공하기 위해 저희 프론트엔드 팀은 Sentry를 적용하기로 하였습니다. </p>
<h2 id="적용하기">적용하기</h2>
<h3 id="시작하기">시작하기</h3>
<p><a href="https://github.com/Moadong/moadong">Sentry</a>에 먼저 접속해줍니다. 
<img src="https://velog.velcdn.com/images/seongwon__105/post/f7ff0921-1264-4972-9777-83592070a173/image.png" alt=""></p>
<p>여기서 <code>GET STARTED</code> 를 눌러줍니다. </p>
<h3 id="로그인">로그인</h3>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/3973a508-b894-4961-82c1-b98480ab4248/image.png" alt=""></p>
<p>여기서 저는 깃허브 계정으로 로그인하였습니다.</p>
<h3 id="organization-만들기">Organization 만들기</h3>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/fdc51916-834f-40ba-8d20-29c0cf491063/image.png" alt=""></p>
<p>Sentry내에서 쓸 Organization을 만듭니다. 이름은 자유롭게 지어주시면 됩니다.
Data Storage Location은 US로 설정해주었습니다. </p>
<h3 id="플랫폼-설정">플랫폼 설정</h3>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/83ff182b-3b62-4d32-93bf-07cc94ffbb54/image.png" alt=""></p>
<p>React를 클릭해줍니다. 
<img src="https://velog.velcdn.com/images/seongwon__105/post/ca96fb88-1896-4ea1-8e8b-f3c4ce15a538/image.png" alt=""></p>
<p>아래 설정은 자유롭게 해주세요. 그 다음 Create Project를 합니다.</p>
<h3 id="라이브러리-설치">라이브러리 설치</h3>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/0158ff06-e1aa-49a8-a17d-882b3f660866/image.png" alt=""></p>
<p>위 사진처럼 자신의 프로젝트 폴더에서 <code>npm install --save @sentry/react</code>를 합니다.</p>
<h3 id="sdk-설정">SDK 설정</h3>
<p><strong>주의</strong> : dsn에 나와있는 key는 자신의 sentry 프로젝트에 부여된 고유한 key이기 때문에 노출하면 안 됩니다. 
해당 키를 .env파일에 설정해 주세요. </p>
<p>추가로 제 프로젝트에서는 여러 sdk를 사용하고 있었기에 <code>initSDK.ts</code> 파일을 따로 생성한 다음 각각 함수로 묶어주었습니다.</p>
<pre><code class="language-typescript">// initSDK.ts
export function initializeSentry() {
  if (process.env.NODE_ENV === &#39;development&#39;) {
    return;
  }

  Sentry.init({
    dsn: process.env.SENTRY_DSN,
    sendDefaultPii: false,
    release: process.env.SENTRY_RELEASE,
    tracesSampleRate: 0.1,
  });
}

// index.tsx
import {
  initializeSentry,
} from &#39;./utils/initSDK&#39;;

initializeSentry();

const root = ReactDOM.createRoot(
  document.getElementById(&#39;root&#39;) as HTMLElement,
);
root.render(&lt;App /&gt;);</code></pre>
<h3 id="error-테스트">error 테스트</h3>
<p>로컬에서 에러를 테스트하기 위해 먼저 <code>if (process.env.NODE_ENV === &#39;development&#39;)</code> 를 주석처리 해 둡니다. </p>
<p>그리고 나서 임시로 error 코드를 작성합니다.</p>
<pre><code class="language-typescript">import React from &#39;react&#39;;
import * as Styled from &#39;./Footer.styles&#39;;

const Footer = () =&gt; {
  return (
    &lt;&gt;
      &lt;Styled.FooterContainer&gt;
        &lt;Styled.Divider /&gt;
        &lt;Styled.FooterContent&gt;
          &lt;Styled.PolicyText&gt;개인정보 처리방침&lt;/Styled.PolicyText&gt;
          &lt;Styled.CopyRightText&gt;
            Copyright © moodong. All Rights Reserved
          &lt;/Styled.CopyRightText&gt;
          &lt;Styled.EmailText&gt;
            e-mail:{&#39; &#39;}
            &lt;a href=&#39;mailto:pknu.moadong@gmail.com&#39;&gt;pknu.moadong@gmail.com&lt;/a&gt;
            &lt;button
              onClick={() =&gt; {
                throw new Error(&#39;test&#39;);
              }}
            &gt;
              test
            &lt;/button&gt;
          &lt;/Styled.EmailText&gt;
        &lt;/Styled.FooterContent&gt;
      &lt;/Styled.FooterContainer&gt;
    &lt;/&gt;
  );
};

export default Footer;</code></pre>
<p>당연하게도 로컬에서는 에러가 발생할 것입니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/e6efbe81-98f9-4b49-b4bb-0739bf816279/image.png" alt=""></p>
<p>예상대로 에러가 잘 나옵니다. 그럼 이제 Sentry에 해당 에러가 추적되었는지 확인해봅시다.</p>
<h3 id="sentry-이벤트-화면">Sentry 이벤트 화면</h3>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/99ba8f4c-2e40-426c-b4f0-6b3e1264f0f1/image.png" alt=""></p>
<p>이벤트 목록에서 에러가 잘 뜨는 것을 확인할 수 있습니다. <img src="https://velog.velcdn.com/images/seongwon__105/post/403d5cf0-c574-4a01-bb1f-67b9fbe7a8e1/image.png" alt=""></p>
<p>이벤트 수, 유저 수를 보여줍니다. 브라우저 정보, 릴리즈(개발 및 배포 환경), URL, environment(어떤 환경에서 발생했는지) 등을 볼 수 있습니다. </p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/39b7e2e4-33d0-4e95-85de-c2314ee59ec4/image.png" alt="">
여기선 어떤 동작이 에러를 발생시켰는지 볼 수 있습니다. 문자가 이상하게 나오는 것은 소스맵 문제입니다. 해당 내용도 추후에 추가할 예정입니다.</p>
<h3 id="배포-서버-설정">배포 서버 설정</h3>
<p>저는 netlify에서 배포하고 있었기 때문에 deploy settings의 environment에 새로운 variables로 <code>dsn</code>을 설정해주었습니다. 환경변수를 추가하지 않으면 배포 사이트 에러를 추적할 수 없기에 꼭 해주셔야 합니다.</p>
<h2 id="디스코드-웹-훅-연동">디스코드 웹 훅 연동</h2>
<p>저희 팀은 디스코드와 깃허브를 연동하여 작업 시 활발하게 사용 중이었습니다. sentry에도 웹 훅 연동 기능이 있어서 겸사겸사 연동을 해 보았습니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/3aae4e4a-9316-410e-beeb-a7acc03dbe77/image.png" alt=""></p>
<p> <code>organization settings -&gt; Integrations -&gt; Discord</code> 순입니다.</p>
<p> <img src="https://velog.velcdn.com/images/seongwon__105/post/0d134138-ed37-45b2-915c-c3b630e58d52/image.png" alt=""></p>
<p>Add Installation을 누르면 아래와 같은 창이 나옵니다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/d7264a7b-ffca-4be9-a776-5b56ad9c2bba/image.png' width=400 />

<p>여기서 프로젝트 Discord를 선택하고 승인을 눌러줍니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/57e18d64-f043-4626-8048-5f3fb90b5c88/image.png" alt=""></p>
<p>승인이 완료되면 알람 세팅을 할 수 있습니다. 어차피 개발 서버에서 실행되지 않기에 All Environments로 설정해 줍니다.
저는 간단하게  이슈 생성 시(에러 발생)에 알람을 주도록 하였습니다. </p>
<p>두번째 설정은 자유롭게 해 주시면 됩니다. 중요한 것은 세 번째 설정인데, 여기서 알림을 받을 디스코드 채널 Id를 입력해야 합니다. <img src="https://velog.velcdn.com/images/seongwon__105/post/51cd7397-e83b-4814-bee6-f1bedaa2b7f1/image.png" alt=""></p>
<p>나머지 설정도 완료한 다음 Save Rule을 합니다. 이제 디스코드 알림이 오는지 확인하겠습니다. </p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/71d61193-28fa-4cc0-a0ca-a9a40752570b/image.png' width=400 />

<p>잘 오는 것을 볼 수 있습니다. Assign은 해당 에러를 팀원에게 할당하는 기능입니다.
에러를 해결했다면 Resolve를 합니다.</p>
<h2 id="마치며">마치며</h2>
<p>이상으로 Sentry 설정과 디스코드 연동까지 해 보았습니다. 지금까지 에러는 개발자에게만 일어나는 것이라 생각했습니다. 사용자에게 배포하고 나서 에러에 대한 시각이 더 넓어진 느낌이 들었습니다. 이제부터 사용자에게 불편함 없이 서비스를 제공하기 위해 한 층 더 나아가 보려고 합니다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[타입스크립트 keyof typeof ]]></title>
            <link>https://velog.io/@seongwon__105/%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-keyof-typeof</link>
            <guid>https://velog.io/@seongwon__105/%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-keyof-typeof</guid>
            <pubDate>Sun, 27 Apr 2025 07:29:52 GMT</pubDate>
            <description><![CDATA[<h2 id="사용법">사용법</h2>
<blockquote>
<p>객체를 선언하고 그 key를 타입으로 쓰고 싶다면 무조건 <code>as const</code>를 붙이고 <code>keyof typeof</code>를 쓴다.</p>
</blockquote>
<h2 id="1-버튼-타입-제한">1. 버튼 타입 제한</h2>
<pre><code class="language-typescript">const BUTTON_TYPES = {
  primary: &#39;Primary Button&#39;,
  secondary: &#39;Secondary Button&#39;,
  danger: &#39;Danger Button&#39;,
} as const;

type ButtonType = keyof typeof BUTTON_TYPES;
// ButtonType = &#39;primary&#39; | &#39;secondary&#39; | &#39;danger&#39;

interface ButtonProps {
  type: ButtonType;
}

const Button = ({ type }: ButtonProps) =&gt; {
  return &lt;button&gt;{BUTTON_TYPES[type]}&lt;/button&gt;;</code></pre>
<ul>
<li>버튼 타입 prop은 &#39;primary&#39; | &#39;secondary&#39; | &#39;danger&#39;만 가능하다.</li>
<li>실수로 <code>&#39;Third&#39;</code> 를 넣으면 컴파일 에러가 난다.</li>
</ul>
<h2 id="2-api-요청-타입-강제">2. API 요청 타입 강제</h2>
<pre><code class="language-typescript">const API_ENDPOINTS = {
  getUser: &#39;/api/user&#39;,
  getPosts: &#39;/api/posts&#39;,
  getComments: &#39;/api/comments&#39;,
} as const;

type ApiType = keyof typeof API_ENDPOINTS;
// &#39;getUser&#39; | &#39;getPosts&#39; | &#39;getComments&#39;

function fetchApi(api: ApiType) {
  return fetch(API_ENDPOINTS[api]);
}</code></pre>
<ul>
<li><code>fetchApi(&#39;getPosts&#39;)</code> 처럼 사용한다.</li>
<li>없는 API 이름 쓰면 에러 발생.</li>
</ul>
<h2 id="3-테마-설정">3. 테마 설정</h2>
<pre><code class="language-typescript">const THEMES = {
  light: &#39;Light Mode&#39;,
  dark: &#39;Dark Mode&#39;,
  system: &#39;System Default&#39;,
} as const;

type ThemeType = keyof typeof THEMES;
// &#39;light&#39; | &#39;dark&#39; | &#39;system&#39;

function setTheme(theme: ThemeType) {
  console.log(`Setting theme to ${THEMES[theme]}`);
}</code></pre>
<ul>
<li><code>setTheme(&#39;light&#39;)</code> or <code>setTheme(&#39;dark&#39;)</code> </li>
</ul>
<h2 id="4-key-매핑해서-타입-변환하기">4. key 매핑해서 타입 변환하기</h2>
<blockquote>
<p>키를 이용해 새로운 타입 만들기</p>
</blockquote>
<pre><code class="language-typescript">const THEMES = {
  light: &#39;Light Mode&#39;,
  dark: &#39;Dark Mode&#39;,
  system: &#39;System Default&#39;,
} as const;

type ThemeValue = {
  [K in keyof typeof THEMES]: string;</code></pre>
<pre><code class="language-typescript">{
  light: string;
  dark: string;
  system: string;
}</code></pre>
<p>➡️ <code>ThemeValues</code> 타입의 모습입니다. 
객체의 키들을 순회하며 새로운 타입을 만들 수 있으며, 이것을 <strong>Mapped Type</strong> 이라 합니다.</p>
<h2 id="5-키-값value에-따라-타입-달리하기">5. 키 값(value)에 따라 타입 달리하기</h2>
<pre><code class="language-typescript">const API_ENDPOINTS = {
  getUser: &#39;/api/user&#39;,
  getPosts: &#39;/api/posts&#39;,
  deletePost: null,
} as const;

// value가 null이면 optional, 아니면 required로
type ApiFunctions = {
  [K in keyof typeof API_ENDPOINTS]: type of API_ENDPOINTS[K] extends string
      ? () =&gt; Promise&lt;void&gt;
    : never;
};</code></pre>
<ul>
<li><code>extends</code>는 타입 상속과 타입 검사에 둘 다 사용됩니다. 여기서는 <code>string</code>인지 검사합니다.</li>
<li>&#39;getUser&#39;, &#39;getPosts&#39;는 () =&gt; Promise<void> 타입으로 매핑</li>
<li>&#39;deletePost&#39;는 never로 매핑</li>
</ul>
<h2 id="언제-사용할까">언제 사용할까?</h2>
<ul>
<li>정해진 키들만 쓰도록 할 때 </li>
<li>props 안전하게 하고 싶을 때</li>
<li>객체의 키들을 타입으로 안전하게 뽑아내고 싶을 때</li>
</ul>
<h2 id="string-리터럴-사용하면-안-되나">string 리터럴 사용하면 안 되나?</h2>
<pre><code class="language-typescript">type ColorKey = &#39;red&#39; | &#39;blue&#39; | &#39;green&#39;;</code></pre>
<p>-&gt; 리터럴로 직접 쓰기.</p>
<pre><code class="language-typescript">  const COLORS = {
  red: &#39;#ff0000&#39;,
  blue: &#39;#0000ff&#39;,
  green: &#39;#00ff00&#39;,
  yellow: &#39;#ffff00&#39;,  // 새로운 색 추가
} as const;</code></pre>
<p>여기서 만약 새로운 프로퍼티를 추가해야 하는 상황이라면?</p>
<pre><code class="language-typescript">// 기존
type ColorKey = &#39;red&#39; | &#39;blue&#39; | &#39;green&#39;;

// yellow 추가했으니까 여기도 수정해야 함
type ColorKey = &#39;red&#39; | &#39;blue&#39; | &#39;green&#39; | &#39;yellow&#39;;</code></pre>
<p>객체를 바꿀 때마다 타입도 수동으로 수정해야 합니다. 
  이것은 매우 비효율적입니다.</p>
<h3 id="keyof-typeof-쓰기">keyof typeof 쓰기</h3>
<pre><code class="language-typescript">type ColorKey = keyof typeof COLORS;  </code></pre>
<p>객체가 바뀌면 타입도 자동으로 따라갑니다.</p>
<h2 id="프로젝트에-적용해보기">프로젝트에 적용해보기</h2>
<ol>
<li>믹스패널 track 이벤트를 클릭함수 내에 적용해야 한다.</li>
<li>데스크탑 home 버튼과 모바일 home버튼에 각각 다른 이벤트를 적용해야 한다.</li>
</ol>
<h3 id="trackevent-타입">trackEvent 타입</h3>
<pre><code class="language-typescript"> const trackEventNames = {
  desktop: &#39;Home Button Clicked&#39;,
  mobile: &#39;Mobile Home Button Clicked&#39;,
} as const; </code></pre>
<h3 id="click-함수에-매개변수로-전달하기">Click 함수에 매개변수로 전달하기</h3>
<pre><code class="language-typescript">const handleHomeClick = (device: keyof typeof trackEventNames) =&gt; {
    navigate(&#39;/&#39;);
    setKeyword(&#39;&#39;);
    setInputValue(&#39;&#39;);
    trackEvent(trackEventNames[device]);
};</code></pre>
<h2 id="마치며">마치며</h2>
<p>프로젝트를 하면서 타입스크립트를 그때그때 배우고 있지만 항상 새롭습니다. 그래도 그때그때 배우는게 기억에 제일 잘 남는 것 같아요. 피드백은 언제나 환영입니다! </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React의  intersection observer]]></title>
            <link>https://velog.io/@seongwon__105/React%EC%9D%98-intersection-observer</link>
            <guid>https://velog.io/@seongwon__105/React%EC%9D%98-intersection-observer</guid>
            <pubDate>Fri, 25 Apr 2025 16:40:56 GMT</pubDate>
            <description><![CDATA[<p>프로젝트에서 옆으로 넘어가는 카드 슬라이드를 구현하였습니다. 
슬라이드는 보통 옆으로 쭉 늘어뜨려 카드를 배열해 놓고 슬라이드 할 때마다 현재 카드만 보이도록 옆쪽에 위치한 카드를 의도적으로 가립니다.</p>
<p>즉, <strong>현재 보이는 카드 부분</strong>만 사용자에게 보여주면 된다는 것입니다. 
만약 모든 카드를 렌더링마다 불러오게 된다면, 사용자에게 보이지 않는 카드들 모두 가져오게 됩니다. 이는 불필요한 이미지 렌더링이 일어나게 되고 성능에도 문제가 발생합니다.</p>
<p>그래서 <strong>현재 카드 부분이 보일 때</strong>만 카드를 렌더링 하도록 하고 싶었습니다. 자바스크립트에서는<code>IntersectionObserver</code>라는 API를 제공합니다. <a href="https://developer.mozilla.org/ko/docs/Web/API/IntersectionObserver">MDN</a></p>
<p>해당 API로 이미지가 뷰포트에 들어왔을 시에만 이미지를 지연로딩하는 컴포넌트를 제작하였습니다. 참고로 리액트 + 타입스크립트 조합입니다.</p>
<h3 id="이미지-props">이미지 Props</h3>
<pre><code class="language-typescript">interface LazyImageProps {
  src: string;        // 로드할 이미지 주소
  alt: string;        // 이미지 설명 (접근성용)
  onError?: () =&gt; void; // 이미지 로드 실패 시 콜백
  index?: number;     // 리스트일 경우 지연 순서 지정
  delayMs?: number;   // 각 이미지 로딩 간의 지연 시간
}</code></pre>
<h3 id="상태-정의">상태 정의</h3>
<pre><code class="language-typescript">const [shouldLoad, setShouldLoad] = useState(false);
const [isVisible, setIsVisible] = useState(false);</code></pre>
<ul>
<li>shouldLoad : 이미지 로드를 시작할지 여부</li>
<li>isVisible : 이미지 렌더링 여부</li>
</ul>
<h3 id="ref">ref</h3>
<pre><code class="language-typescript">const imgRef = useRef&lt;HTMLImageElement | null&gt;(null);</code></pre>
<ul>
<li>dom 요소 추적으로 <code>IntersectionObserver</code>가 관찰할 수 있도록 함</li>
</ul>
<h3 id="뷰포트-감지">뷰포트 감지</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  const observer = new IntersectionObserver(([entry]) =&gt; {
    if (entry.isIntersecting) {
      const delay = index * delayMs;
      const timeout = setTimeout(() =&gt; {
        setShouldLoad(true);
      }, delay);
      observer.disconnect();

      return () =&gt; clearTimeout(timeout);
    }
  }, { threshold: 0.1 });

  if (imgRef.current) {
    observer.observe(imgRef.current);
  }

  return () =&gt; observer.disconnect();
}, [index, delayMs]);</code></pre>
<ul>
<li>이미지가 뷰포트에 10% 이상 들어오면 -&gt; <code>threshold: 0.1</code></li>
<li><code>index * delayMs</code>만큼 기다렸다가 <code>setShouldLoad(true)</code> </li>
<li>이는 로딩이 시작되었다는 뜻입니다. 그렇다면 요소를 <strong>더 이상 관찰할 필요</strong>가 없기에 <code>observer.disconnect()</code>로 중지합니다.
(disconnect는 모든 요소, unobserve()는 특정요소입니다.)</li>
</ul>
<h3 id="이미지-로딩">이미지 로딩</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  if (shouldLoad) {
    setIsVisible(true);
  }
}, [shouldLoad]);</code></pre>
<h3 id="렌더링">렌더링</h3>
<pre><code class="language-typescript">return isVisible ? (
  &lt;img ref={imgRef} src={src} alt={alt} onError={onError} /&gt;
) : (
  &lt;div ref={imgRef} ... /&gt; // placeholder
);</code></pre>
<p>이미지가 아직 안 보이면 placeholder를 렌더링합니다. 
로드가 시작되면 진짜 이미지로 교체합니다.</p>
<h2 id="마치며">마치며</h2>
<p>제가 만든 컴포넌트는 뷰포트에 하나의 카드 + 두 번째 카드의 10분의 1의 정도 보이는 구조였습니다. 만약 뷰포트에 거의 하나의 요소만 들어간다면 <code>index * delayMs</code>를 추가하는 것은 필수는 아니라는 생각이 드네요. </p>
<p>결과적으로 카드 인덱스가 낮은 순으로 순차적 렌더링이 됨을 확인할 수 있었습니다. 원한다면 onError 함수에 fallback 이미지로 대체하여 에러 방지도 할 수 있습니다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[4월 회고]]></title>
            <link>https://velog.io/@seongwon__105/4%EC%9B%94-%ED%9A%8C%EA%B3%A0</link>
            <guid>https://velog.io/@seongwon__105/4%EC%9B%94-%ED%9A%8C%EA%B3%A0</guid>
            <pubDate>Sat, 05 Apr 2025 18:04:20 GMT</pubDate>
            <description><![CDATA[<p>올해 1월부터 3월까지 저는 총 3개의 프로젝트를 진행해왔는데요. 함께 자라기에서 본 회고를 실천하기 위해 이제부터 자주 회고글을 작성할 생각입니다.</p>
<h2 id="keep">Keep</h2>
<ul>
<li>깃허브와 디스코드, jira 연동으로 변경사항 추적이 쉽고 간편해졌다.</li>
<li>사용한 기술에 대해 기록하는 습관을 들였다.</li>
<li>더 좋은 리뷰에 대해 고민하는 시간이 많다.</li>
<li>디자인 시스템인 스토리북을 도입하고 사용했다.</li>
<li>jest를 도입하고 단위테스트를 학습했다.</li>
<li>요구사항 개발에 대한 날짜를 정한 뒤 마감일을 지켰다.</li>
<li>연락을 자주 봄으로써 매끄럽게 개발을 진행했다.</li>
</ul>
<h2 id="problem">Problem</h2>
<ul>
<li>명확한 이유없이 jira를 도입했다.</li>
<li>디자인시스템 도입 후 제대로 활용하지 못했다.</li>
<li>pr에 리뷰요청을 한 뒤 개인적으로 연락을 한 적이 많았다. (컨벤션에 있던 1일을 기다리지 않고)</li>
<li>세 개의 프로젝트를 진행하면서 한 가지 일에 제대로 집중하지 못 했다.</li>
<li>회고없이 기능 개발과 회의만 진행했다.</li>
<li>새벽에 개발을 하면서 아침 시간을 잘 활용하지 못 했다. </li>
<li>급하게 merge해야 하는 상황에서 양질의 리뷰를 하지 못했다.</li>
<li>여러 가지 일을 하면서 스트레스 관리가 어려웠다.</li>
</ul>
<h2 id="try">Try</h2>
<ul>
<li>jira를 도입한 이유를 명확히 하고, 우리 팀에 필요한 것인지 조금 더 고민해본다.</li>
<li>리뷰 시간은 컨벤션대로 기다린다.</li>
<li>디자인 시스템을 활용해 디자이너와 협업한다.</li>
<li>늦어도 새벽 2시에는 잔다.</li>
<li>아침 9시 데일리 미팅에 늦지 않기.</li>
<li>사용한 기술에 대해 조금 더 깊은 공부를 해 본다. ex) tanstack-query </li>
<li>jest로 단위 테스트를 한다.</li>
<li>팀 역량을 끌어올리는 팀원이 되도록 노력한다.</li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[코드래빗 깃허브 연동]]></title>
            <link>https://velog.io/@seongwon__105/%EC%BD%94%EB%93%9C%EB%9E%98%EB%B9%97-%EA%B9%83%ED%97%88%EB%B8%8C-%EC%97%B0%EB%8F%99</link>
            <guid>https://velog.io/@seongwon__105/%EC%BD%94%EB%93%9C%EB%9E%98%EB%B9%97-%EA%B9%83%ED%97%88%EB%B8%8C-%EC%97%B0%EB%8F%99</guid>
            <pubDate>Wed, 19 Mar 2025 06:19:15 GMT</pubDate>
            <description><![CDATA[<p>코드 리뷰를 자동으로 해 주는 툴이 있다고 해서 적용해 보았는데요
<a href="https://www.coderabbit.ai/">CodeRabbit</a> 에 먼저 들어갑니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/bde9e7cf-e188-41a2-a0e9-6d6f5f8efbd2/image.png" alt=""></p>
<p>여기서 <code>Get a free trial</code>을 클릭합니다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/74bda0f8-44d4-4a89-bde3-cddf0b83905a/image.png' width=400/>

<p>저는 github와 바로 연동하기 위해 <code>Sign up with Github</code>를 클릭해 주었습니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/7e9a40dc-9530-4fae-95b6-325ebf797ccb/image.png" alt=""></p>
<p>깃허브 내에서 코드래빗을 적용하고 싶은 레포지토리를 연결해줍니다.</p>
<img src='https://velog.velcdn.com/images/seongwon__105/post/0c33f593-379e-47e9-bf47-6dce36acc492/image.png' width=400/>


<p>연결이 완료되면 왼쪽 메뉴에 대시보드와 여러 세팅이 보입니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/a6469445-ad45-472a-af4e-039866a064f1/image.png" alt=""></p>
<p>여기서 Organization Settings -&gt; Configuration 으로 갑니다. 
여기서 Review Language를 Korean으로 설정한 다음 오른쪽 위 <code>Apply Changes</code>를 눌러주면 설정 완료입니다.</p>
<p>이제 PR에 코멘트를 달아주는지 확인해 봅시다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/ee8acfde-7065-4e02-a40a-d96430659a5b/image.png" alt=""></p>
<p>리뷰가 스킵되었네요. Review -&gt; Auto Review 설정을 해 준다면  자동을 리뷰를 해 줄 겁니다. </p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/ca563fae-10b1-4ac1-b594-266e924464d5/image.png" alt=""></p>
<p>하지만 제 리뷰는 이미 스킵되었기 때문에 강제로 리뷰를 시켜야겠습니다. 위에 코멘트를 보시면 <code>@coderabbitai review</code>로 강제 리뷰를 할 수 있다고 합니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/e56263ec-776d-4ce8-9918-06c6402a0a4a/image.png" alt=""></p>
<p>Quote reply로 해당 명령을 치고 comment를 눌러줍니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/ee840dda-e39b-4f24-af80-17fb8127c514/image.png" alt=""></p>
<p>그러면 코드래빗이 pr에 올라간 커밋들을 모두 검사하여 리뷰를 달아줍니다. </p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/385efb36-7393-4fdf-93cb-0875a5c60b72/image.png" alt=""></p>
<p>코멘트에 대한 답도 해 주는군요. </p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/21f33164-2ea4-4070-bc16-0b44b9f93aac/image.png" alt=""></p>
<p>시퀸스 다이어그램까지 만들어주네요. </p>
<h3 id="변경사항">변경사항</h3>
<p>pr 자동 리뷰가 되지 않은 건 설정을 빼 먹었기 때문입니다..  먼저 organization settings로 다시 갑니다.
<img src="https://velog.velcdn.com/images/seongwon__105/post/a3440bdb-73ca-471b-9b00-fa2be42d1962/image.png" alt=""></p>
<p>가장 아래 <strong>Base Branches</strong> 부분에 <code>.*</code> 를 입력하고 엔터를 누른 뒤 <code>apply  changes</code> 하면 전체 브랜치에 대한 pr에 자동으로 리뷰하겠다는 뜻입니다.
<img src="https://velog.velcdn.com/images/seongwon__105/post/9fc2625a-841e-44d4-b5b4-7ec1d6b16613/image.png" alt=""></p>
<p>만약 특정 브랜치에만 리뷰하도록 하려면 <code>feature/*</code> 같이 설정해주시면 됩니다.</p>
<p>또한, <code>coderabbit.yaml</code>로 설정하는 방법도 있습니다. 자신의 프로젝트 루트 폴더에 해당 파일을 추가하면 가능합니다.</p>
<pre><code class="language-yaml">language: &#39;ko-KR&#39;
early_access: false
reviews:
  profile: &#39;chill&#39;
  request_changes_workflow: false
  high_level_summary: true
  poem: false
  review_status: true
  collapse_walkthrough: false
  auto_review:
    enabled: true
    drafts: false
    base_branches:
      - &quot;/*&quot;
chat:
  auto_reply: true</code></pre>
<p>여기서 원하는대로 수정하여 사용하시면 됩니다!</p>
<h2 id="마치며">마치며</h2>
<p>자동 코드리뷰를 해 주는 것은 너무 편리한 기능인 것 같습니다. 하지만 ai가 언제나 정답일 수는 없기에 저희 팀에서는 팀원 간 리뷰 후 명령어를 사용하여 코드래빗으로 추가적인 리뷰를 받는 방식을 선택했습니다!</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[React 무한 캐러셀 구현하기]]></title>
            <link>https://velog.io/@seongwon__105/React-%EB%AC%B4%ED%95%9C-%EC%BA%90%EB%9F%AC%EC%85%80-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@seongwon__105/React-%EB%AC%B4%ED%95%9C-%EC%BA%90%EB%9F%AC%EC%85%80-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Fri, 28 Feb 2025 16:21:07 GMT</pubDate>
            <description><![CDATA[<p>저는 현재 학교에서 동아리 지원 관리 플랫폼을 제작 중입니다. 아래는 해당 프로젝트 깃허브 링크입니다.</p>
<blockquote>
<p><a href="https://github.com/Moadong/moadong">moadong</a></p>
</blockquote>
<p>이번에 메인페이지에 들어갈 캐러셀을 제작하게 되었습니다.<br>Typescript와 styled-component를 사용하였고 반응형과 무한 캐러셀 동작에 집중하였습니다. </p>
<h2 id="기능-요구사항">기능 요구사항</h2>
<ul>
<li>좌우 버튼을 눌러 배너를 슬라이드할 수 있어야 한다.</li>
<li>자동으로 3초마다 배너가 이동한다.</li>
<li>첫 번째 배너에서 이전 버튼을 누르면 마지막 배너로 이동해야 한다.</li>
<li>마지막 배너에서 다음 버튼을 누르면 첫 번째 배너로 이동해야 한다.</li>
<li>창 크기가 변경되면 슬라이드 크기가 자동 조정되어야 한다.</li>
</ul>
<h2 id="구현-과정">구현 과정</h2>
<h3 id="1-무한-루프를-위한-슬라이드-배열-확장">1. 무한 루프를 위한 슬라이드 배열 확장</h3>
<pre><code class="language-typescript">const extendedBanners = [banners[banners.length - 1], ...banners, banners[0]];</code></pre>
<h3 id="2-슬라이드-크기-동적-업데이트">2. 슬라이드 크기 동적 업데이트</h3>
<pre><code class="language-typescript">  const [currentSlideIndex, setCurrentSlideIndex] = useState(1); 

const updateSlideWidth = useCallback(() =&gt; {
  if (slideRef.current) {
    setSlideWidth(slideRef.current.offsetWidth);
    slideRef.current.style.transform = `translateX(-${currentSlideIndex * slideRef.current.offsetWidth}px)`;
  }
}, [currentSlideIndex]);</code></pre>
<p><code>currentSlideIndex</code>는 현재 슬라이드 인덱스를 저장하고, <code>updateSlideWidth</code> 함수는 currentSlideIndex가 변경될 때마다 실행됩니다.</p>
<p>현재 슬라이드가 있다면 현재 슬라이드 width 값을 가져와 슬라이드 너비를 업데이트합니다. </p>
<h3 id="3-슬라이드-이동-로직">3. 슬라이드 이동 로직</h3>
<pre><code class="language-typescript">const [isAnimating, setIsAnimating] = useState(true);
const [isTransitioning, setIsTransitioning] = useState(false);

const moveToNextSlide = useCallback(() =&gt; {
  if (isTransitioning) return;
  setIsTransitioning(true);
  setIsAnimating(true);
  setCurrentSlideIndex((prev) =&gt; prev + 1);
}, [isTransitioning]);

const moveToPrevSlide = useCallback(() =&gt; {
  if (isTransitioning) return;
  setIsTransitioning(true);
  setIsAnimating(true);
  setCurrentSlideIndex((prev) =&gt; prev - 1);
}, [isTransitioning]);</code></pre>
<p><code>isAnimating</code>는 애니메이션 상태를 관리합니다.
<code>isTransitioning</code>는 슬라이드가 애니메이션 중인지 여부를 나타내는 상태입니다. </p>
<p><code>isTransitioning</code> 상태가 true라면 애니메이션 중이므로 중복 호출을 방지합니다. 그렇지 않다면 슬라이드 전환이 시작되었기 때문에 상태를 true로 바꿉니다. 슬라이드 인덱스 또한 업데이트 해 줍니다.</p>
<h3 id="4-무한-슬라이드-구현">4. 무한 슬라이드 구현</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  if (!slideRef.current) return;

  if (isAnimating) {
    slideRef.current.style.transform = `translateX(-${currentSlideIndex * slideWidth}px)`;
  } else {
    if (currentSlideIndex === 1) {
      slideRef.current.style.transform = `translateX(-${slideWidth}px)`;
    } else if (currentSlideIndex === banners.length) {
      slideRef.current.style.transform = `translateX(-${banners.length * slideWidth}px)`;
    }
  }

  const transitionEndHandler = () =&gt; {
    if (currentSlideIndex === banners.length + 1) {
      setIsAnimating(false);
      setCurrentSlideIndex(1);
    } else if (currentSlideIndex === 0) {
      setIsAnimating(false);
      setCurrentSlideIndex(banners.length);
    }
    setIsTransitioning(false);
  };

  slideRef.current.addEventListener(&#39;transitionend&#39;, transitionEndHandler);
  return () =&gt; slideRef.current?.removeEventListener(&#39;transitionend&#39;, transitionEndHandler);
}, [currentSlideIndex, slideWidth, banners.length, isAnimating]);</code></pre>
<p><strong>📌 1. 기본적인 슬라이드 이동</strong></p>
<p>currentSlideIndex가 변경되면 useEffect가 실행됩니다.
<code>isAnimating이 true</code>이면 translateX(-currentSlideIndex * slideWidth)를 적용하여 슬라이드를 이동시킵니다.
transitionend 이벤트가 발생하면 transitionEndHandler를  실행합니다.</p>
<p><strong>📌 2. 무한 루프 효과</strong></p>
<p>마지막 배너(banners.length)에서 다음 슬라이드로 이동하면?
currentSlideIndex === banners.length + 1 → 첫 번째 배너(1번)로 이동합니다.</p>
<p>첫 번째 배너(1번)에서 이전 슬라이드로 이동하면?
currentSlideIndex === 0 → 마지막 배너(banners.length)로 이동하여 이를 통해 슬라이드가 처음과 끝을 무한히 반복하는 것처럼 보이게 만듭니다.</p>
<h3 id="5-자동-슬라이드-기능">5. 자동 슬라이드 기능</h3>
<pre><code class="language-typescript">useEffect(() =&gt; {
  const interval = setInterval(() =&gt; {
    moveToNextSlide();
  }, 3000);

  return () =&gt; clearInterval(interval);
}, [moveToNextSlide]);</code></pre>
<p>3초마다 자동으로 배너가 이동하도록 설정합니다. 컴포넌트가 언마운트될 때 clearInterval을 호출하여 메모리 누수를 방지합니다.</p>
<h2 id="전체-코드">전체 코드</h2>
<pre><code class="language-typescript">import React, { useRef, useState, useEffect, useCallback } from &#39;react&#39;;
import * as Styled from &#39;./Banner.styles&#39;;
import { SlideButton } from &#39;@/utils/banners&#39;;

export interface BannerProps {
  backgroundImage?: string;
}

interface BannerComponentProps {
  banners: BannerProps[];
}

const Banner = ({ banners }: BannerComponentProps) =&gt; {
  const slideRef = useRef&lt;HTMLDivElement&gt;(null);
  const [currentSlideIndex, setCurrentSlideIndex] = useState(1);
  const [slideWidth, setSlideWidth] = useState(0);
  const [isAnimating, setIsAnimating] = useState(true);
  const [isTransitioning, setIsTransitioning] = useState(false);

  const extendedBanners = [banners[banners.length - 1], ...banners, banners[0]];

  const updateSlideWidth = useCallback(() =&gt; {
    if (slideRef.current) {
      setSlideWidth(slideRef.current.offsetWidth);
      slideRef.current.style.transform = `translateX(-${currentSlideIndex * slideRef.current.offsetWidth}px)`;
    }
  }, [currentSlideIndex]);

  useEffect(() =&gt; {
    updateSlideWidth();
    window.addEventListener(&#39;resize&#39;, updateSlideWidth);

    return () =&gt; {
      window.removeEventListener(&#39;resize&#39;, updateSlideWidth);
    };
  }, [updateSlideWidth]);

  useEffect(() =&gt; {
    if (!slideRef.current) return;

    if (isAnimating) {
      slideRef.current.style.transform = `translateX(-${currentSlideIndex * slideWidth}px)`;
    } else {
      // 애니메이션 없이 즉시 이동
      if (currentSlideIndex === 1) {
        slideRef.current.style.transform = `translateX(-${slideWidth}px)`;
      } else if (currentSlideIndex === banners.length) {
        slideRef.current.style.transform = `translateX(-${banners.length * slideWidth}px)`;
      }
    }

    const transitionEndHandler = () =&gt; {
      if (currentSlideIndex === banners.length + 1) {
        setIsAnimating(false);
        setCurrentSlideIndex(1);
      } else if (currentSlideIndex === 0) {
        setIsAnimating(false);
        setCurrentSlideIndex(banners.length);
      }
      setIsTransitioning(false);
    };

    slideRef.current.addEventListener(&#39;transitionend&#39;, transitionEndHandler);
    return () =&gt; {
      slideRef.current?.removeEventListener(
        &#39;transitionend&#39;,
        transitionEndHandler,
      );
    };
  }, [currentSlideIndex, slideWidth, banners.length, isAnimating]);

  const moveToNextSlide = useCallback(() =&gt; {
    if (isTransitioning) return;
    setIsTransitioning(true);
    setIsAnimating(true);
    setCurrentSlideIndex((prev) =&gt; prev + 1);
  }, [isTransitioning]);

  const moveToPrevSlide = useCallback(() =&gt; {
    if (isTransitioning) return;
    setIsTransitioning(true);
    setIsAnimating(true);
    setCurrentSlideIndex((prev) =&gt; prev - 1);
  }, [isTransitioning]);

  useEffect(() =&gt; {
    const interval = setInterval(() =&gt; {
      moveToNextSlide();
    }, 3000);

    return () =&gt; clearInterval(interval);
  }, [moveToNextSlide]);

  return (
    &lt;Styled.BannerContainer&gt;
      &lt;Styled.BannerWrapper&gt;
        &lt;Styled.ButtonContainer&gt;
          &lt;Styled.SlideButton onClick={moveToPrevSlide}&gt;
            &lt;img src={SlideButton[0]} alt=&#39;Previous Slide&#39; /&gt;
          &lt;/Styled.SlideButton&gt;
          &lt;Styled.SlideButton onClick={moveToNextSlide}&gt;
            &lt;img src={SlideButton[1]} alt=&#39;Next Slide&#39; /&gt;
          &lt;/Styled.SlideButton&gt;
        &lt;/Styled.ButtonContainer&gt;
        &lt;Styled.SlideWrapper ref={slideRef} isAnimating={isAnimating}&gt;
          {extendedBanners.map((banner, index) =&gt; (
            &lt;Styled.BannerItem key={index}&gt;
              &lt;img
                src={banner.backgroundImage}
                alt={`banner-${index}`}
                style={{
                  width: &#39;100%&#39;,
                  height: &#39;100%&#39;,
                  objectFit: &#39;cover&#39;,
                }}
              /&gt;
            &lt;/Styled.BannerItem&gt;
          ))}
        &lt;/Styled.SlideWrapper&gt;
      &lt;/Styled.BannerWrapper&gt;
    &lt;/Styled.BannerContainer&gt;
  );
};

export default Banner;
</code></pre>
<h2 id="스타일링">스타일링</h2>
<pre><code class="language-typescript">import styled from &#39;styled-components&#39;;
import { BannerProps } from &#39;./Banner&#39;;

export const BannerContainer = styled.div`
  padding: 0 40px;
  max-width: 1180px;
  margin: 0 auto;
  width: 100%;

  display: flex;
  justify-content: center;
  align-items: center;
  margin-top: 90px;
  position: relative;

  @media (max-width: 500px) {
    margin-top: 42px;
    padding: 0;
  }
`;

export const BannerWrapper = styled.div&lt;BannerProps&gt;`
  position: relative;
  width: 100%;
  max-width: 1180px;
  height: auto;
  aspect-ratio: 1180 / 316;
  border-radius: 26px;
  overflow: hidden;
  background-color: transparent;
  ${({ backgroundImage }) =&gt;
    backgroundImage &amp;&amp;
    `
    background-image: url(${backgroundImage});
    background-size: cover;
    background-position: center;
    `}

  @media (max-width: 500px) {
    width: 100vw;
    border-radius: 0;
  }
`;

export const SlideWrapper = styled.div&lt;{ isAnimating: boolean }&gt;`
  display: flex;
  width: 100%;
  height: 100%;
  ${({ isAnimating }) =&gt;
    isAnimating
      ? &#39;transition: transform 0.5s ease-in-out;&#39;
      : &#39;transition: none;&#39;}
`;

export const BannerItem = styled.div`
  flex: none;
  width: 100%;
  height: 100%;
  img {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
`;
export const ButtonContainer = styled.div`
  position: absolute;
  width: 100%;
  top: 50%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  transform: translateY(-50%);
  z-index: 1;
`;

export const SlideButton = styled.button`
  width: 60px;
  height: auto;
  padding: 10px 20px;
  border: none;
  background-color: transparent;
  cursor: pointer;

  img {
    width: 100%;
    height: auto;
    object-fit: cover;
  }

  @media (max-width: 698px) {
    width: 35px;
    padding: 6px 12px;
  }


  @media (max-width: 375px) {
    width: 30px;
    padding: 4px 8px;
  }
`;
</code></pre>
<h2 id="구현-영상">구현 영상</h2>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/8680c5a2-7040-4ed8-a838-87782321d65b/image.gif" alt=""></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[Next js 시작하기 ]]></title>
            <link>https://velog.io/@seongwon__105/Next-js-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@seongwon__105/Next-js-%EC%8B%9C%EC%9E%91%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 24 Feb 2025 09:16:34 GMT</pubDate>
            <description><![CDATA[<h2 id="왜-많이-사용하는가">왜 많이 사용하는가?</h2>
<p>Library가 아닌 Framework 이기 때문입니다. </p>
<p>참고로 next는 React.js 전용 웹 개발 프레임워크이고, React는 라이브러리입니다.</p>
<h3 id="프레임워크-vs-라이브러리">프레임워크 vs 라이브러리</h3>
<blockquote>
<p>기능 구현의 주도권이 누구에게 있는가?</p>
</blockquote>
<p>주도권이 개발자에게 있다 -&gt; Library
주도권이 개발자에게 없다 → Framework</p>
<ul>
<li>Library<ul>
<li>어떤 기능을 구현할 때 제약없이 사용합니다.</li>
<li>Ex) React page routing에서 React router또는 tanstack을 사용합니다.</li>
</ul>
</li>
<li>Framework<ul>
<li>기능 구현 시 프레임워크가 자체적으로 제공하는 범위 내에서 사용 가능합니다.
  <img src="https://velog.velcdn.com/images/seongwon__105/post/292379c9-b1ba-44ec-8d28-fe5dd5617773/image.png" alt=""><h3 id="자유도">자유도</h3>
</li>
</ul>
</li>
</ul>
<blockquote>
<p>Next &lt; React</p>
</blockquote>
<p>자유도가 낮다고 하는 Next를 사용하는게 괜찮을까요? 너무 높아도 좋지 않습니다. 왜냐하면 주요 기능을 제외한 그 외의 모든 기능을 만들거나, 비슷한 기능을 하는 라이브러리를 직접 찾아야하기 때문입니다.
<img src='https://velog.velcdn.com/images/seongwon__105/post/13f61f98-2e4b-4382-8245-eac21a575ade/image.png' width=300 />
Next는 웹개발에 필요한 모든 기능이 제공됩니다. 추가로 React 확장판이라서 빠르게 배울 수 있습니다.</p>
<h2 id="사전렌더링">사전렌더링</h2>
<p>브라우저 요청에 사전에 렌더링이 완료된 HTML을 응답하는 렌더링 방식입니다. 이것은 CSR의 단점을 효율적으로 해결합니다.</p>
<h3 id="csr">CSR</h3>
<p>Client Side Rendering의 약자는 CSR은 React에 기본적인 렌더링 방식입니다. 클라이언트에서 직접 화면을 렌더링합니다.<img src="https://velog.velcdn.com/images/seongwon__105/post/fdaaa751-9cc8-4a6b-88fd-dd1c04fc1319/image.png" alt=""></p>
<p><strong>장점</strong></p>
<ul>
<li><p>페이지 이동이 매우 빠르고 쾌적합니다.    </p>
<ul>
<li>서버에서 브라우저로 JS Bundle 과정이 일어납니다. 여기엔 서비스에서 접근 가능한 모든 컴포넌트 코드가 존재합니다.</li>
<li>초기 접속 이후 페이지 이동하게 되더라도 서버에 새로운 페이지를 요청할 필요가 없어집니다.
<img src="https://velog.velcdn.com/images/seongwon__105/post/239c2d13-7580-409d-b412-627c405afc24/image.png" alt=""></li>
</ul>
</li>
</ul>
<p><strong>단점</strong></p>
<ul>
<li>초기 접속 속도가 느립니다.<ul>
<li>초기 접속에서 실제로 화면 렌더링까지 오래 걸립니다.</li>
<li>컨텐츠 렌더링 전까지 index.html도 받아오고 js 번들 파일도 받아온 다음 js 실행까지 해야 합니다.</li>
</ul>
</li>
</ul>
<h3 id="fcpfirst-contentful-paint">FCP(First Contentful Paint)</h3>
<blockquote>
<p>&quot;요청 시작&quot; 시점으로부터 컨텐츠가 화면에 처음 나타나는데 걸리는 시간</p>
</blockquote>
<img src='https://velog.velcdn.com/images/seongwon__105/post/d8c8e08a-0f68-46e9-bb1c-91b995fd26b5/image.png' width=300/>

<h2 id="next의-렌더링">Next의 렌더링</h2>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/0640eb25-4cc9-48af-ab8a-61b24436fbbb/image.png" alt="">
여기서 렌더링은 두 가지 종류가 있습니다.</p>
<ul>
<li>JS실행(렌더링): JS코드를 HTML로 변환합니다.</li>
<li>화면에 렌더링: HTML코드를 브라우저가 화면에 그립니다.</li>
</ul>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/3fb61efb-d8c1-4dea-99ff-f97ff0ea4813/image.png" alt=""></p>
<p>상호작용이란 html이 아닌 js가 처리하는 영역이기 때문에 FCP 시점에서는 상호작용이 불가능합니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/cfa72921-73ed-4a99-b2bc-69cd6c9a3fc7/image.png" alt=""></p>
<p>JS 번들링이 끝나고 브라우저에서 html을 연결하면, 상호작용이 가능한 페이지가 됩니다.</p>
<h2 id="수화hydration">수화(Hydration)</h2>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/b92dbeb2-07ec-4ce0-992c-8f3bc56ac257/image.png" alt=""></p>
<p>브라우저가 js번들링 후 html과 연결할 때, html에 js를 뿌리는 형태같다고 하여 수화(Hydration)이라 불립니다. 참고로 TTI는 Time To Interactive의 약자로 초기 요청에서 hydration까지의 시간을 말합니다.</p>
<h2 id="페이지-이동-요청">페이지 이동 요청</h2>
<blockquote>
<p>CSR과 같은 방식</p>
</blockquote>
<p>앞선 초기접속 요청과정에서 hydration을 위해 서버가 브라우저에 js 번들파일(React app)을 전달했기 때문에 사실상 리액트 컴포넌트를 미리 받아온 것입니다.</p>
<p><strong>사전렌더링</strong></p>
<p>서버측에서 js코드를 html로 미리 렌더링하는 사전렌더링을 통해 기존 CSR의 단점인 FCP를 개선하고, 페이지 이동은 CSR과 똑같이 동작합니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/29f0122c-e35f-4a34-b0e1-778c7f87d5c9/image.png" alt=""></p>
<h2 id="참고">참고</h2>
<blockquote>
<p><a href="https://www.inflearn.com/course/%ED%95%9C%EC%9E%85-%ED%81%AC%EA%B8%B0-nextjs">한 입 크기로 잘라먹는 Next.js</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[리액트 자동스크롤 구현]]></title>
            <link>https://velog.io/@seongwon__105/%EB%A6%AC%EC%95%A1%ED%8A%B8-%EC%9E%90%EB%8F%99%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B5%AC%ED%98%84</link>
            <guid>https://velog.io/@seongwon__105/%EB%A6%AC%EC%95%A1%ED%8A%B8-%EC%9E%90%EB%8F%99%EC%8A%A4%ED%81%AC%EB%A1%A4-%EA%B5%AC%ED%98%84</guid>
            <pubDate>Wed, 12 Feb 2025 13:14:50 GMT</pubDate>
            <description><![CDATA[<h2 id="figma-화면">Figma 화면</h2>
<img src='https://velog.velcdn.com/images/seongwon__105/post/75c57b38-9c10-460b-a221-30d5d956f1e0/image.gif' width=300/>


<p>지금 만들고 있는 동아리 지원 플랫폼에서 디자이너분이 주신 피그마 영상입니다. 이 페이지는 동아리 상세보기 페이지입니다. 개발하려고 하는 것은 모집정보, 동아리정보, 소개글, 활동사진이 있는 <strong>탭</strong>과 자동 스크롤입니다.</p>
<ol>
<li><p>탭 클릭 시 클릭된 메뉴의 아래 테두리가 검게 칠해진다.</p>
</li>
<li><p>탭을 클릭할 시 해당 컨텐츠로 자동 스크롤된다.</p>
</li>
</ol>
<p>이것이  구현할 때 필요한 기능입니다.</p>
<h2 id="메뉴tab">메뉴Tab</h2>
<p>모바일 화면이기 때문에 500px보다 클 때는 탭이 보이지 않도록 설정합니다. 총 4개의 버튼이 필요하기 때문에 width는 25%로 해 두었습니다.</p>
<h3 id="infotabstylests">InfoTab.styles.ts</h3>
<pre><code class="language-typescript">import styled from &#39;styled-components&#39;;

export const InfoTabWrapper = styled.div`
  display: none;
  position: fixed;
  margin-top: -40px;

  @media (max-width: 500px) {
    display: flex;
    flex-direction: row;
    width: 100%;
    height: 45px;
    background-color: white;
  }
`;

export const InfoTabButton = styled.button`
  width: 25%;
  border: none;
  border-bottom: 2px solid #cdcdcd;
  background-color: transparent;
  cursor: pointer;
  font-size: 14px;
  transition: border-bottom 0.3s ease;

  &amp;.active {
    border-bottom: 2px solid black;
  }
`;</code></pre>
<p>active로 클릭할 시에만 border-bottom을 black으로 설정할 겁니다. </p>
<p>active 클래스를 사용하려면 현재 선택된 탭이 무엇인지 알아야 합니다. 그러기 위해선 Tab의 상태를 관리하는 로직이 필요합니다.</p>
<h3 id="infotabtsx">InfoTab.tsx</h3>
<pre><code class="language-typescript">import React, { useState } from &#39;react&#39;;
import * as Styled from &#39;./InfoTabs.styles&#39;;

const tabLabels = [&#39;모집정보&#39;, &#39;동아리정보&#39;, &#39;소개글&#39;, &#39;활동사진&#39;];

const InfoTabs = ({ onTabClick }: { onTabClick: (index: number) =&gt; void }) =&gt; {
  const [activeTab, setActiveTab] = useState(0);

  const handleTabClick = (index: number) =&gt; {
    setActiveTab(index);
    onTabClick(index);
  };

  return (
    &lt;Styled.InfoTabWrapper&gt;
      {tabLabels.map((label, index) =&gt; (
        &lt;Styled.InfoTabButton
          key={label}
          className={activeTab === index ? &#39;active&#39; : &#39;&#39;}
          onClick={() =&gt; handleTabClick(index)}&gt;
          {label}
        &lt;/Styled.InfoTabButton&gt;
      ))}
    &lt;/Styled.InfoTabWrapper&gt;
  );
};

export default InfoTabs;</code></pre>
<p><code>useState</code>로 클릭한 버튼만 active상태가 되고, 다른 버튼은 비활성화되도록 하였습니다.</p>
<h2 id="자동-스크롤">자동 스크롤</h2>
<h3 id="useautoscrollts">useAutoScroll.ts</h3>
<pre><code class="language-typescript">import { useRef } from &#39;react&#39;;

const useAutoScroll = () =&gt; {
  const sectionRefs = useRef&lt;(HTMLDivElement | null)[]&gt;(
    new Array(4).fill(null),
  );

  const scrollToSection = (index: number) =&gt; {
    if (sectionRefs.current[index]) {
      const element = sectionRefs.current[index];
      const yOffset = -100;

      window.scrollTo({
        top: element.getBoundingClientRect().top + window.scrollY + yOffset,
        behavior: &#39;smooth&#39;,
      });
    }
  };

  return { sectionRefs, scrollToSection };
};

export default useAutoScroll;</code></pre>
<p>📌 useAutoScroll 훅 설명</p>
<ul>
<li><code>useRef</code>로 스크롤할 섹션들을 참조할 배열을 만듭니다. 배열 요소는 위에 만들어 두었던 탭의 메뉴들에 해당합니다.</li>
<li><code>scrollToSection</code>는 index를 받아서 해당 인덱스 섹션으로 스크롤되도록 합니다.</li>
<li>yOffset은 스크롤 위치를 조정하기 위해 추가했습니다.</li>
<li><code>element.getBoundingClientRect().top</code>는 현재 뷰포트 내에서 해당 요소의 상대적인 위치를 가져오는데,여기에 <code>window.scrollY</code>와 yOffset을 더하면 절대적인 화면 위치로 스크롤됩니다.</li>
</ul>
<h3 id="infoboxtsx">InfoBox.tsx</h3>
<pre><code class="language-typescript">import React from &#39;react&#39;;
import * as Styled from &#39;./InfoBox.styles&#39;;
import { InfoList } from &#39;@/types/Info&#39;;

const infoData: InfoList[] = [
  {
    title: &#39;모집정보&#39;,
    descriptions: [
      { label: &#39;모집기간&#39;, value: &#39;2025.02.28&#39; },
      { label: &#39;모집대상&#39;, value: &#39;재학생&#39; },
    ],
  },
  {
    title: &#39;동아리정보&#39;,
    descriptions: [
      { label: &#39;회장이름&#39;, value: &#39;xxx&#39; },
      { label: &#39;전화번호&#39;, value: &#39;010-1234-5678&#39; },
    ],
  },
];

const InfoBox = ({
  sectionRefs,
}: {
  sectionRefs: React.RefObject&lt;(HTMLDivElement | null)[]&gt;;
}) =&gt; {
  return (
    &lt;Styled.InfoBoxWrapper&gt;
      {infoData.map((info, index) =&gt; (
        &lt;Styled.InfoBox
          key={index}
          ref={(el) =&gt; {
            sectionRefs.current[index] = el;
          }}&gt;
          &lt;Styled.Title&gt;{info.title}&lt;/Styled.Title&gt;
          &lt;Styled.DescriptionContainer&gt;
            {info.descriptions.map((desc, idx) =&gt; (
              &lt;Styled.DescriptionWrapper key={idx}&gt;
                &lt;Styled.LeftText&gt;{desc.label}&lt;/Styled.LeftText&gt;
                &lt;Styled.RightText&gt;{desc.value}&lt;/Styled.RightText&gt;
              &lt;/Styled.DescriptionWrapper&gt;
            ))}
          &lt;/Styled.DescriptionContainer&gt;
        &lt;/Styled.InfoBox&gt;
      ))}
    &lt;/Styled.InfoBoxWrapper&gt;
  );
};

export default InfoBox;</code></pre>
<ul>
<li>반환값 타입을 <code>React.RefObject<T></code>로 하면 DOM요소에 접근할 수 있습니다. 특징은 current가 <code>readonly</code>라 직접 변경할 수 없다는 점입니다.<ul>
<li>배열에 <code>ref</code>를 동적으로 할당하면 클릭 시 해당 div로 이동합니다.</li>
</ul>
</li>
</ul>
<h3 id="clubdetailpagetsx">ClubDetailPage.tsx</h3>
<pre><code class="language-typescript">const ClubDetailPage = () =&gt; {
  const { sectionRefs, scrollToSection } = useAutoScroll();

  return (
    &lt;&gt;
      &lt;Header /&gt;
      &lt;InfoTabs onTabClick={scrollToSection} /&gt;
      &lt;Styled.PageContainer&gt;
        &lt;InfoBox sectionRefs={sectionRefs} /&gt;
        &lt;IntroduceBox sectionRefs={sectionRefs} /&gt;
      &lt;/Styled.PageContainer&gt;
      &lt;Footer /&gt;
    &lt;/&gt;
  );
};
export default ClubDetailPage;</code></pre>
<p> 이제 상세페이지로 돌아가서 <code>useAutoScroll</code>훅에서 sectionRefs를 가져옵니다. 스크롤되어야 하는 컴포넌트에 sectionRefs를 전달하면 완성입니다.</p>
<h2 id="최종화면">최종화면</h2>
<img src='https://velog.velcdn.com/images/seongwon__105/post/66d197e9-c523-4b20-baa8-32e83de57315/image.gif' width=300/>


]]></description>
        </item>
        <item>
            <title><![CDATA[타입스크립트 사용자 정의 타입 가드]]></title>
            <link>https://velog.io/@seongwon__105/%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%A0%95%EC%9D%98-%ED%83%80%EC%9E%85-%EA%B0%80%EB%93%9C</link>
            <guid>https://velog.io/@seongwon__105/%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8-%EC%82%AC%EC%9A%A9%EC%9E%90-%EC%A0%95%EC%9D%98-%ED%83%80%EC%9E%85-%EA%B0%80%EB%93%9C</guid>
            <pubDate>Fri, 07 Feb 2025 07:07:45 GMT</pubDate>
            <description><![CDATA[<h2 id="예제">예제</h2>
<pre><code class="language-typescript">type Dog = {
  name: string;
  isBark: boolean;
};

type Cat = {
  name: string;
  isScratch: boolean;
};

type Animal = Dog | Cat;

function warning(animal: Animal) {
  if (&quot;isBark&quot; in animal) {
    console.log(animal.isBark ? &quot;짖습니다&quot; : &quot;안짖어요&quot;);
  } else if (&quot;isScratch&quot; in animal) {
    console.log(animal.isScratch ? &quot;할큅니다&quot; : &quot;안할퀴어요&quot;);
  }
}</code></pre>
<p>2개의 타입 Dog와 Cat의 유니온 타입인 Animal 타입까지 정의했습니다.</p>
<p>매개변수로 Animal 타입의 값을 받아 동물에 따라 각각 다른 경고를 콘솔에 출력하는 함수를 만들었습니다. 이때, Dog또는 Cat타입인지 알기 위해 <code>in 연산자</code>를 이용해 타입을 좁힙니다.</p>
<p>하지만 Dog타입이나 Cat타입의 프로퍼티가 중간에 수정되거나 추가 또는 삭제될 때 타입가드는 제대로 동작하지 않습니다.</p>
<h2 id="커스텀-타입-가드">커스텀 타입 가드</h2>
<pre><code class="language-typescript">function isDog(animal: Animal): animal is Dog {
  return (animal as Dog).isBark !== undefined;
}

function isCat(animal: Animal): animal is Cat {
  return (animal as Cat).isScratch !== undefined;
}

function warning(animal: Animal) {
  if (isDog(animal)) {
    console.log(animal.isBark ? &quot;짖습니다&quot; : &quot;안짖어요&quot;);
  } else {
    console.log(animal.isScratch ? &quot;할큅니다&quot; : &quot;안할퀴어요&quot;);
  }</code></pre>
<p><code>isDog</code>함수의 매개변수로 받은 값이 Dog 타입이라면 true 아니라면 false를 반환합니다. 이때 반환값의 타입으로 <code>animal is Dog</code> 를 정의해 줍니다. </p>
<p>이렇게 하면  함수가 true를 반환하면 조건문 내부에서는 이 값이 Dog타입임을 보장한다는 의미가 됩니다.</p>
<h2 id="타입-가드를-사용하는-경우">타입 가드를 사용하는 경우</h2>
<h3 id="1-api-응답-데이터-검증">1. API 응답 데이터 검증</h3>
<pre><code class="language-typescript">type User = {
  id: number;
  name: string;
  email: string;
};

function isUser(data: any): data is User {
  return (
    typeof data.id === &#39;number&#39; &amp;&amp;
    typeof data.name === &#39;string&#39; &amp;&amp;
    typeof data.email === &#39;string&#39;
  );
}</code></pre>
<p>여기서 isUser는 매개변수로 전달된 data가 User타입인지 검사합니다. 조건이 모두 참이어야 true를 반환하고, 아니면 false를 반환합니다.</p>
<p>반환 타입에서 data is User는 타입스크립트에게 <strong>이 값이 User타입이다</strong>라고 알리는 역할을 합니다.</p>
<pre><code class="language-typescript">const response = { id: 1, name: &#39;John Doe&#39;, email: &#39;john@example.com&#39; };

if (isUser(response)) {
  console.log(response.name);
} else {
  console.error(&#39;Invalid user data&#39;); // 타입이 User가 아님
}</code></pre>
<p>조건문 안에서 typescript는 response를 자동으로 User타입으로 간주합니다. 타입 가드가 없을 때보다 코드의 안전성이랑 가독성이 올라갑니다.</p>
<h3 id="2-선택적-프로퍼티를-가진-객체-판별">2. 선택적 프로퍼티를 가진 객체 판별</h3>
<pre><code class="language-typescript">type Admin = {
  id: number;
  role: &#39;admin&#39;;
  permissions?: string[];
};

function isAdmin(data: any): data is Admin {
  return (
    typeof data.id === &#39;number&#39; &amp;&amp;
    data.role === &#39;admin&#39; &amp;&amp;
    (data.permissions === undefined || Array.isArray(data.permissions))
  );
}

const person = { id: 1, role: &#39;admin&#39;, permissions: [&#39;read&#39;, &#39;write&#39;] };

if (isAdmin(person)) {
  console.log(person.permissions); 
}</code></pre>
<p><code>isAdmin</code>함수에서 permissions는 선택적 속성이므로 두 가지를 검사해야 합니다. </p>
<ol>
<li>permissions가 undefined인 경우</li>
<li>permission가 존재한다면 배열이어야 함</li>
</ol>
<p>자바스크립트에서 배열은 객체로 취급되기 때문에 <code>typeof</code>로 배열을 정확히 판별할 수 없습니다. <code>Array.isArray()</code>는 값이 배열인지 정확하게 검사합니다.</p>
<h3 id="3-폼-데이터-검증">3. 폼 데이터 검증</h3>
<p><strong>1️⃣ 동적 데이터 타입</strong></p>
<p>타입 스크립트는 정적 타입검사(컴파일 타임)를 통해 오류를 방지하려고 하지만, 동적 데이터에서는 타입 보장이 어렵습니다.</p>
<pre><code class="language-typescript">function processFormData(data: FormData) {
  console.log(data.username.toUpperCase());
}

const formData = { username: &#39;JohnDoe&#39;, age: &#39;30&#39; }; // 잘못된 타입 (age가 문자열)

processFormData(formData);  // 컴파일 시 오류 없음 -&gt; 런타임에 문제 발생 가능</code></pre>
<p><strong>2️⃣ 명시적으로 타입을 검사</strong></p>
<p>타입스크립트는 타입을 명시적으로 검사하는 코드가 없으면 자동으로 타입을 추론할 수 없습니다.</p>
<pre><code class="language-typescript">const formData = { username: &#39;JohnDoe&#39;, age: &#39;30&#39; };

if (typeof formData.age === &#39;number&#39;) {
  console.log(formData.age.toFixed(2));  // 타입이 맞는 경우에만 안전하게 동작
} else {
  console.log(&#39;Invalid data&#39;);
}</code></pre>
<p><strong>3️⃣ 불필요하게 타입 검사 반복</strong></p>
<p>타입 가드를 사용하지 않으면 타입 검사를 반복적으로 수행해야 합니다.</p>
<pre><code class="language-typescript">const formData = { username: &#39;JohnDoe&#39;, age: &#39;30&#39; };

// 여러 곳에서 타입을 수동으로 검사
if (typeof formData.username === &#39;string&#39; &amp;&amp; typeof formData.age === &#39;number&#39;) {
  console.log(formData.username.toUpperCase());
}

if (typeof formData.age === &#39;number&#39;) {
  console.log(formData.age.toFixed(2));
}</code></pre>
<h3 id="타입-가드-함수로-동적-데이터-검증">타입 가드 함수로 동적 데이터 검증</h3>
<p>타입 가드를 사용하면 한 번의 타입 검사를 통해 타입을 안전하게 사용할 수 있습니다. 또한, 여러 곳에서 타입 검사를 하지 않고 타입 가드 함수를 재사용할 수 있습니다.</p>
<pre><code class="language-typescript">type FormData = {
  username: string;
  age: number;
};

function isValidFormData(data: any): data is FormData {
  return typeof data.username === &#39;string&#39; &amp;&amp; typeof data.age === &#39;number&#39;;
}

const formData = { username: &#39;JohnDoe&#39;, age: 30 }; 

if (isValidFormData(formData)) {
  console.log(&#39;Valid form data:&#39;, formData);
} else {
  console.error(&#39;Invalid form data&#39;);
}</code></pre>
<h2 id="참고">참고</h2>
<blockquote>
<p><a href="https://www.inflearn.com/course/%ED%95%9C%EC%9E%85-%ED%81%AC%EA%B8%B0-%ED%83%80%EC%9E%85%EC%8A%A4%ED%81%AC%EB%A6%BD%ED%8A%B8">한 입 크기로 잘라먹는 타입스크립트</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[React와 React Native ]]></title>
            <link>https://velog.io/@seongwon__105/React%EC%99%80-React-Native</link>
            <guid>https://velog.io/@seongwon__105/React%EC%99%80-React-Native</guid>
            <pubDate>Thu, 06 Feb 2025 10:01:53 GMT</pubDate>
            <description><![CDATA[<h2 id="들어가며">들어가며</h2>
<p>저는 React로 웹을 개발해오던 사람입니다. 최근 간단하게 앱을 만들 일이 있어서 알아보다가, 제가 쓰던 React와 가장 비슷한 React Native를 알게 되었습니다. 저는 이 두 개가 어떻게 다르며, 왜 나누었을까 하는 궁금증이 생겼습니다. </p>
<h2 id="react">React</h2>
<p>자바스크립트 컴포넌트 라이브러리인 <strong>React.js</strong>는 웹 사이트의 프론트엔드 제작을 위해 사용됩니다. 다른 프레임워크보다 더 각광받는 이유는 재사용 가능한 컴포넌트를 활용할 수 있기 때문이라고 생각합니다. </p>
<p>React는 <a href="https://github.com/facebook/react">React github</a>를 보시면 알 수 있듯이 232000개의 star를 가지고 있고 현재도 계속 pr이 올라오는 것을 보실 수 있습니다.</p>
<h2 id="react는-이때-사용한다">React는 이때 사용한다</h2>
<p>1️⃣SPA 를 개발할 때 </p>
<p>SPA는 웹 서버에서 전체 HTML 화면을 받아오는 방식이 아니라, 로컬 PC에서 화면을 렌더링하는 방식입니다. 화면 전환 속도가 빠르고, 네이티브 앱처럼 보일 수 있는 장점이 있습니다. React의 가장 큰 특징인 컴포넌트와 Virtual Dom 구조는 SPA 동작에 적합하다고 볼 수 있습니다.</p>
<p>2️⃣소셜 미디어 사이트를 만들 때</p>
<p>소셜 미디어 사이트는 사용자의 동작이 매우 빠르게 처리됩니다. 리액트의 상태 관리를 통해 특정 컴포넌트를 업데이트 할 수 있어 리액트를 쓰기 좋습니다.</p>
<p>리액트의 장점에 대해서는 제가 이전에 썼던 <a href="https://velog.io/@seongwon__105/%EB%82%B4%EA%B0%80-%EB%A6%AC%EC%95%A1%ED%8A%B8%EB%A5%BC-%EC%82%AC%EC%9A%A9%ED%95%98%EB%8A%94-%EC%9D%B4%EC%9C%A0">내가 리액트를 사용하는 이유</a>를 참고해주세요☺️</p>
<h2 id="react-native">React Native</h2>
<p>React Native는 크로스 플랫폼 앱으로 하나의 소스코드로 android, ios에서 똑같이 동작하는 앱을 말합니다. React를 기반으로 구축되었지만, 별도의 라이브러리를 사용합니다. 가장 큰 차이점은 React는 DOM을 조작하는 방식으로 UI을 업데이트하고, React Native는 js와 네이티브 모듈을 사용해 UI를 조작한다는 것입니다.</p>
<p><a href="https://github.com/facebook/react-native">React Native github</a>또한 12만개 star가 가지고 있으며 24년 10월 드디어 새로운 버전을 내며 지속적인 업데이트를 하고 있습니다.</p>
<h2 id="react-native의-장점">React Native의 장점</h2>
<p>1️⃣JS를 사용한다 </p>
<p>JS를 사용하던 사람들에게 익숙하다는 장점이 있습니다. React 또한 js를 사용하기 때문에 React를 사용하던 개발자들은 비교적 수월할 수 있습니다.</p>
<p>2️⃣크로스 플랫폼이다</p>
<p>Window와 macOS 둘 다 호환되어 개발에 부담이 줄어듭니다. 이것은 언어가 아닌 서비스 환경을 얘기하는 것입니다. </p>
<h2 id="어떻게-다른가">어떻게 다른가</h2>
<p>React와 React Native는 같은 라이브러리를 사용하지만 동작 과정은 다릅니다. React는 Virtual Dom을 사용하는 반면, RN은 Native 모듈로 동작합니다. </p>
<h3 id="react의-virtual-dom">React의 Virtual DOM</h3>
<p>React 컴포넌트는 Virtual DOM이라는 가상돔을 사용해 UI을 업데이트 합니다. 이전의 가상돔과 리렌더링된 후의 가상돔을 비교하여 바뀐 부분만 업데이트하는 방식인데요. 변경된 모든 Element를 집단화시켜 이를 한 번에 실제 DOM에 적용하는 <strong>Batch Update</strong>는 기존에 브라우저에 바로 화면을 그려주는 방법에 비해 매우 효율적으로 DOM을 조작하게 된 것입니다.</p>
<h3 id="react-native의-bridge">React Native의 Bridge</h3>
<p>React Native에서는 Bridge로 Native 모듈이 호출됩니다. 다른 네이티브 앱과 다르게 자바스크립트 엔진에서 작동하기 때문에, Bridge를 통해 네이티브와의 통신에서 발생하는 성능 저하를 줄이기 위해 JS 스레드와 네이티브 스레드 간의 통신을 비동기 처리하는 최적화 방법을 사용합니다. </p>
<h2 id="마치며">마치며</h2>
<p>React와 React Native이 무엇인지와 언제 사용되는지에 대해 간단하게 정리해봤습니다. RN의 자세한 동작과정은 추후 강의를 들은 후에 한 번 더 정리해보고자 합니다. </p>
<h2 id="참고">참고</h2>
<blockquote>
<p><a href="https://www.etatvasoft.com/blog/react-js-vs-react-native/">React.js 대 React Native: 주요 차이점</a>
<a href="https://callmedevmomo.medium.com/virtual-dom-react-%ED%95%B5%EC%8B%AC%EC%A0%95%EB%A6%AC-bfbfcecc4fbb">Virtual DOM (React) 핵심정리</a></p>
</blockquote>
]]></description>
        </item>
        <item>
            <title><![CDATA[React + Storybook 적용]]></title>
            <link>https://velog.io/@seongwon__105/React-Storybook-%EC%A0%81%EC%9A%A9</link>
            <guid>https://velog.io/@seongwon__105/React-Storybook-%EC%A0%81%EC%9A%A9</guid>
            <pubDate>Wed, 22 Jan 2025 07:23:02 GMT</pubDate>
            <description><![CDATA[<h2 id="컴포넌트-관리의-필요성">컴포넌트 관리의 필요성</h2>
<p>프론트엔드 개발자라면 공통으로 사용되는 컴포넌트 관리를 위해 많은 신경을 쓴 경험이 있을 것입니다. 저는 이번에 동아리 지원 플랫폼 개발을 시작했는데요, 여기서 Storybook을 도입하고자 합니다. </p>
<p>처음에는 Storybook을 왜 사용하는지 설명하고, 그 후에 적용 방법을 작성하고자 합니다. 추가로 chromatic에 대해서도 다뤄볼 에정입니다.</p>
<h2 id="storybook">Storybook</h2>
<p>Storybook은 UI 구성 요소와 페이지를 분리하여 빌드하기 위한 프론트엔드 워크숍입니다. <a href="https://storybook.js.org/docs">Storybook 공식문서</a> 수천 개의 팀이 UI 개발, 테스트 및 문서화에 사용하고 있다고 하네요. 오픈 소스이고 무료입니다.</p>
<h3 id="storybook의-장점">Storybook의 장점</h3>
<p><strong>1️⃣컴포넌트 중심</strong></p>
<p>다양한 stories 파일로 컴포넌트의 props 을 직접 조작할 수 있습니다. 어떤 경우에 컴포넌트를 나누어야 하는지도 테스트할 수 있다는 얘기입니다.</p>
<p><strong>2️⃣독립적인 개발 환경</strong></p>
<p>어플리케이션 전체와 분리된 상태에서 개별 컴포넌트를 개발하고 테스트할 수 있도록 설계되었습니다. 어플리케이션의 상태나 의존성에서 벗어날 수 있다는 게 장점이에요.</p>
<p><strong>3️⃣가상 개발 환경</strong></p>
<p>Storybook은 실제 애플리케이션이 아닌 가상의 개발 환경에서 실행됩니다. 브라우저 기반 인터페이스로 UI를 미리 볼 수 있습니다.</p>
<p><strong>4️⃣빠른 피드백 루프</strong></p>
<p>독립적인 환경 덕분에 컴포넌트 수정 시 실시간으로 변경 사항을 확인할 수 있어요. </p>
<h2 id="storybook-설치">Storybook 설치</h2>
<pre><code class="language-bash">npx storybook@latest init</code></pre>
<p>프로젝트 루트 폴더에서 해당 명령어를 실행합니다. 번들러는 webpack과 vite 중 프로젝트와 맞는 번들러를 선택해주시면 됩니다.</p>
<img src=https://velog.velcdn.com/images/seongwon__105/post/bee63af8-8da7-4471-9366-f98ef4f640da/image.png width=200/>

<p><code>.storybook</code>폴더와 <code>stories</code>폴더가 보이면 정상적으로 완료된 것입니다.</p>
<img src=https://velog.velcdn.com/images/seongwon__105/post/0962820d-a0fd-4ddf-b832-268880f27565/image.png width=500/>

<p>package.json에는 스토리북을 실행하고 빌드하는 명령어가 생깁니다.</p>
<h2 id="storybook-폴더">.storybook 폴더</h2>
<h3 id="maints">main.ts</h3>
<pre><code class="language-typescript">stories: [&#39;../src/**/*.mdx&#39;, &#39;../src/**/*.stories.@(js|jsx|mjs|ts|tsx)&#39;],</code></pre>
<p>src 디렉토리 아래의 모든 .mdx 파일을 스토리로 사용한다는 뜻입니다. stories로 끝나는 파일은 스토리북에서 쓸 수 있게 한다는 얘기입니다.</p>
<pre><code class="language-typescript">  addons: [
    &#39;@storybook/addon-webpack5-compiler-swc&#39;,
    &#39;@storybook/addon-onboarding&#39;,
    &#39;@storybook/addon-essentials&#39;,
    &#39;@chromatic-com/storybook&#39;,
    &#39;@storybook/addon-interactions&#39;,
  ],</code></pre>
<p>Storybook Addons(플러그인) 목록입니다. 프로젝트에서 Storybook 기능을 확장 및 강화하는데 사용됩니다.</p>
<pre><code class="language-typescript">framework: {
    name: &#39;@storybook/react-webpack5&#39;,
    options: {},
  },</code></pre>
<p>name은 React 기반의 storybook을 Webpack5와 함께 사용할 것을 정의합니다. options 추가적인 프레임워크 옵션을 정의하는데 사용됩니다.</p>
<p>이제 <code>preview.ts</code>입니다.</p>
<h3 id="previewts">preview.ts</h3>
<p><strong>✔️controls</strong></p>
<ul>
<li>props 이름 패턴 기반으로 색상 및 날짜 props를 자동 감지하고 적절한 UI를 컨트롤합니다.</li>
</ul>
<p><strong>✔️전역으로 적용</strong></p>
<ul>
<li>모든 스토리에 같은 parameters 설정을 제공합니다.</li>
</ul>
<h2 id="story-작성하기">Story 작성하기</h2>
<p>src 폴더 내에 <code>components/Button</code>에 버튼 컴포넌트를 작성합니다.</p>
<pre><code class="language-typescript">import React from &#39;react&#39;;

export const MyButton = (props: {
  children: React.ReactNode;
  backgroundColor?: string;
}) =&gt; {
  return (
    &lt;button style={{ backgroundColor: props.backgroundColor }}&gt;
      {props.children}
    &lt;/button&gt;
  );
};</code></pre>
<p>버튼 컴포넌트의 스토리 파일 또한 작성합니다.</p>
<pre><code class="language-typescript">import { MyButton } from &#39;./MyButton&#39;;

const meta = {
  title: &#39;MyComponent/MyButton&#39;,
  component: MyButton,
  argTypes: {
    backgroundColor: { control: &#39;color&#39; },
  },
};

export default meta;

export const Primary = {
  args: {
    children: &#39;Button&#39;,
    backgroundColor: &#39;#fff&#39;,
  },
};</code></pre>
<p>이제 <code>npm run storybook</code>을 하면 브라우저로 스토리북이 실행됩니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/63a5abc0-47d1-44ec-b4dd-aeb94e035843/image.png" alt=""></p>
<p>보시면 만들어 둔 Button 컴포넌트가 보입니다. <code>Controls</code>에는 정의해 둔 <code>args</code>를 확인할 수 있습니다. </p>
<p>버튼을 추가로 생성해보겠습니다.</p>
<pre><code class="language-typescript">export const Secondary = {
  args: {
    children: &#39;Button&#39;,
    backgroundColor: &#39;white&#39;,
  },
  argTypes: {
    backgroundColor: { control: &#39;color&#39; },
  },
};</code></pre>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/7fb5ab13-603e-433c-884e-383b28da4ddd/image.png" alt=""></p>
<p><code>argTypes</code>은 <code>backgroundColor</code> 속성을 제어하기 위해 명시하였습니다. 이제 스토리북에서 <code>backgroundColor</code>를 패널에서 조절할 수 있습니다.</p>
<h2 id="chromatic으로-배포하기">Chromatic으로 배포하기</h2>
<p>로컬에서만 컴포넌트를 만들면 팀원 간 공유가 어려울 것입니다. chromatic은 스토리북을 무료로 배포할 수 있는 도구입니다.</p>
<p>먼저 <a href="https://www.chromatic.com/">chromatic</a>으로 가서 get started for free로 시작해봅시다. 저는 깃허브 계정을 연동하여 하나의 레포지토리를 선택하였습니다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/70c84ca7-acc8-4246-92bf-3e34e3b8919f/image.png" alt=""></p>
<p>터미널에서 두 가지 명령어를 실행하면 됩니다. 특히, 두 번째 명령어를 실행하면 package.json에 token이 추가되기 때문에 git에 올릴 때 env에 꼭 추가해주세요.</p>
<p>scripts 명령어에도 chromatic 이 추가됩니다. 
<img src="https://velog.velcdn.com/images/seongwon__105/post/c6e1f28e-f646-4242-9a62-f397d930250a/image.png" alt=""></p>
<p>이제 <code>npm run chromatic</code>으로 실행해봅시다.</p>
<p><img src="https://velog.velcdn.com/images/seongwon__105/post/0c8e8fe6-79a2-4b8b-9e28-acce1cdf2e8b/image.png" alt="">
터미널에 스토리북이 배포되었다는 메세지가 나옵니다. 추가로 사이트를 들어가보면 아래 사진처럼 Build 내용도 볼 수 있습니다. </p>
<p>여기엔 몇 개의 스토리 파일을 테스트했는지, 빌드된 시간, 브라우저를 보여주고 있습니다.
<img src="https://velog.velcdn.com/images/seongwon__105/post/fcbe69d5-d9eb-4b94-9183-7debd5284627/image.png" alt=""></p>
<h2 id="마치며">마치며</h2>
<p>이번 프로젝트에 스토리북을 도입하게 된 것은 디자이너와의 협업을 좀 매끄럽게 진행해보고 싶었고, 개발할 때도 공통 컴포넌트를 관리하고 싶어서였습니다. 실제로 사용해보니 막 간단하지는 않았습니다. 코드를 추가로 작성해야 하는 점이 지금 당장에는 부담으로 다가오네요. 어쩌면 프로젝트 첫 배포 날짜가 임박해서 그런 것일 수도... 시작만 하지 말고 꾸준히 써 봐야겠습니다.</p>
<h2 id="참고">참고</h2>
<blockquote>
<p><a href="https://velog.io/@93minki/Storybook-Chromatic-%EC%9C%BC%EB%A1%9C-%EB%B0%B0%ED%8F%AC%ED%95%98%EA%B8%B0">Storybook Chromatic 으로 배포하기</a>
<a href="https://lasbe.tistory.com/196#google_vignette">Storybook + React, 장점부터 설치와 문서 작성법 가이드</a></p>
</blockquote>
]]></description>
        </item>
    </channel>
</rss>