<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>호두 아빠는 오늘도 코딩을 하지</title>
        <link>https://velog.io/</link>
        <description>기술적 혁신과 측정 가능한 성과를 추구하는 프론트엔드 개발자 양윤성입니다.</description>
        <lastBuildDate>Wed, 12 Aug 2026 08:37:14 GMT</lastBuildDate>
        <docs>https://validator.w3.org/feed/docs/rss2.html</docs>
        <generator>https://github.com/jpmonette/feed</generator>
        <image>
            <title>호두 아빠는 오늘도 코딩을 하지</title>
            <url>https://velog.velcdn.com/images/yunsungyang-omc/profile/fee73a97-30a8-4173-bd23-54d54d850182/image.png</url>
            <link>https://velog.io/</link>
        </image>
        <copyright>Copyright (C) 2019. 호두 아빠는 오늘도 코딩을 하지. All rights reserved.</copyright>
        <atom:link href="https://v2.velog.io/rss/yunsungyang-omc" rel="self" type="application/rss+xml"/>
        <item>
            <title><![CDATA[나는 가장 늦게 핀 꽃을 좋아한다]]></title>
            <link>https://velog.io/@yunsungyang-omc/%EB%82%98%EB%8A%94-%EA%B0%80%EC%9E%A5-%EB%8A%A6%EA%B2%8C-%ED%95%80-%EA%BD%83%EC%9D%84-%EC%A2%8B%EC%95%84%ED%95%9C%EB%8B%A4</link>
            <guid>https://velog.io/@yunsungyang-omc/%EB%82%98%EB%8A%94-%EA%B0%80%EC%9E%A5-%EB%8A%A6%EA%B2%8C-%ED%95%80-%EA%BD%83%EC%9D%84-%EC%A2%8B%EC%95%84%ED%95%9C%EB%8B%A4</guid>
            <pubDate>Wed, 12 Aug 2026 08:37:14 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/fa41e7d0-b23e-4508-aa4c-47199d299bae/image.png" alt=""></p>
<p>한 초등학교 운동회에서 걸그룹 리센느가 흙바닥 위에 서서 〈LOVE ATTACK〉을 불렀다. 2024년에 발표한 이 노래는 이듬해 차트에서 한 차례 순위가 올랐다가 다시 내려갔다. 올해 리더 원이가 여러 유튜브 채널에 출연해 찍은 콘텐츠들이 서서히 입소문을 타면서, 그제야 팀을 알게 된 사람들이 지난 무대와 라이브 영상을 찾아보며 친밀감을 키웠다. 그리고 차트 밖으로 밀려났던 〈LOVE ATTACK〉은 톱 10에 다시 진입했고, 결국 1위 무대까지 찍었다.</p>
<p>내가 본 것은 순위가 아니라 그 서사였다. 사람들은 원이를 먼저 알게 된 뒤, 멤버들이 무명의 시간 동안 묵묵히 쌓아 올린 일들을 찾아보았다.</p>
<p><strong>나는 삶이라는 것이 종종 이야기의 형태로 나타난다고 생각한다.</strong> 사람은 지금 겪는 일을 그 자리에서 다 이해하지 못한다. 다음 선택을 하고 나서야 앞선 일이 자신에게 무엇이었는지 알게 된다. 오래 애써도 원하는 결과를 얻지 못하는 일 역시 그 안에 있다.</p>
<p>지금 개발자로 첫 취업을 준비하는 사람에게 이런 생각이 곧바로 위로가 되지는 않는다. 취업 문턱은 실제로 높다. 기업이 신입 개발자를 뽑는 경우는 줄었고, 처음 경력을 만들려는 사람에게도 실무 경험을 묻는다. 다른 직무에서 옮겨 왔거나 짧은 경력으로 다시 지원하는 사람도 같은 문턱 앞에 선다. 누군가 &#39;인생은 이야기니까 기다려 보라&#39;고 해도 생활비와 불안은 그대로다.</p>
<p>불합격 통보의 아픔은 대개 짧다. 그런데 개발자가 되려고 쌓아 올린 시간과 여러 번 고친 포트폴리오까지 한 번에 부정당했다고 여기기 쉽다. 회사가 실제로 내린 판단은 이번 채용에서 그 지원자를 선택하지 않았다는 데까지인데, 사람은 종종 &#39;나는 개발자가 될 수 없다&#39;는 결론까지 스스로 덧붙인다.</p>
<p><strong>내가 삶을 이야기로 생각하는 이유는 이 두 문장을 구분하고 싶어서다.</strong> 불합격은 받아들여야 할 사실이지만, &#39;나는 개발자가 될 수 없다&#39;는 아직 확인되지 않은 판단이다. 실력이 부족했을 수도 있고, 회사가 다른 경험을 원했거나 애초에 뽑는 자리가 적었을 수도 있다. 정확한 이유를 듣지 못했다면 좋은 쪽으로 지어낼 수도, 가장 나쁜 쪽으로 단정할 수도 없다.</p>
<p>두 문장을 구분하고 나면 다음에 살필 것도 달라진다. 자신 전체를 의심하는 대신, 이번 지원에서 보여 주지 못한 경험과 준비하는 동안 실제로 달라진 것을 확인한다. 어제는 화면을 따라 만들었지만 오늘은 컴포넌트를 나눈 이유와 클릭 뒤 상태를 확인한 순서를 설명할 수 있다면, 무엇이 바뀌었는지는 아는 것이다. 아무것도 달라지지 않았다면 준비 방식을 바꿀 이유가 되고, 달라진 것이 있다면 다음 지원에서 더 분명히 보여 줄 근거가 된다.</p>
<p>무엇이 달라졌는지 확인한 다음에는 같은 준비를 계속할지, 방식을 바꿀지 판단할 수 있다. 때로는 잠시 멈춰 생계를 챙기거나 다른 일을 택할 수도 있다. 어느 쪽이든 각자의 형편에서 내릴 선택이다. 나는 같은 직업을 끝까지 붙든 선택만 훌륭하다고 생각하지 않는다.</p>
<p>기성 개발자인 내가 신입 채용에 지원하는 후배들에게 할 수 있는 일은 합격을 예언하는 쪽보다, 회사가 한 번의 채용에서 내린 판단과 지원자가 앞으로 내릴 선택을 나누어 보게 하는 쪽에 가깝다. 준비하는 동안 무엇이 실제로 달라졌는지 함께 살피면, 합격을 보장할 수는 없어도 무엇을 더 할지와 언제 멈출지는 멘티가 스스로 정할 수 있다.</p>
<p>늦게 피는 일이 더 훌륭하다고 생각하지 않는다. 모든 사람에게 같은 때를 요구하고 싶지 않을 뿐이다. 그래서 나는 가장 늦게 핀 꽃을 좋아한다.</p>
<p>무관심의 시간을 묵묵히 버텨내며 이야기를 쌓아올린 그룹 리센느는 최근 상암운동장에서 5만 관중 앞에 서서 하프타임 쇼를 했다. <strong>이 글을 읽는 누군가에게도 인고의 시간 뒤에 지금을 추억으로 이야기할 결말이 함께하길 바란다. 아직 이야기는 진행 중이고, 그대는 노를 저어야 한다</strong>.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[(React Native) 같이 실행하고, 따로 바꾸고, 다시 복구하기]]></title>
            <link>https://velog.io/@yunsungyang-omc/React-Native-%EA%B0%99%EC%9D%B4-%EC%8B%A4%ED%96%89%ED%95%98%EA%B3%A0-%EB%94%B0%EB%A1%9C-%EB%B0%94%EA%BE%B8%EA%B3%A0-%EB%8B%A4%EC%8B%9C-%EB%B3%B5%EA%B5%AC%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yunsungyang-omc/React-Native-%EA%B0%99%EC%9D%B4-%EC%8B%A4%ED%96%89%ED%95%98%EA%B3%A0-%EB%94%B0%EB%A1%9C-%EB%B0%94%EA%BE%B8%EA%B3%A0-%EB%8B%A4%EC%8B%9C-%EB%B3%B5%EA%B5%AC%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 11 Aug 2026 07:45:58 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요.</p>
<p>지난 글에서는 고객용 앱과 파트너용 앱을 하나로 합치면서도 JavaScript 번들은 나눈 이유를 다뤘습니다. 두 앱을 한 번에 새로 만드는 대신 Host App이 네이티브 실행 환경을 맡고 기능별 Remote App을 필요할 때 내려받는 구조를 선택했습니다. 첫 출시까지 어떤 판단을 했는지는 <a href="https://velog.io/@yunsungyang-omc/React-Native-%EB%91%90-%EC%95%B1%EC%9D%84-%ED%95%98%EB%82%98%EB%A1%9C-%ED%95%A9%EC%B9%98%EB%A9%B4%EC%84%9C-%EB%B2%88%EB%93%A4%EC%9D%80-%EB%82%98%EB%88%88-%EC%9D%B4%EC%9C%A0">React Native, 두 앱을 하나로 합치면서 번들은 나눈 이유</a>에 정리했습니다.</p>
<p>첫 출시는 무사히 끝났습니다. 문제는 이후 Remote App을 변경하면서 생겼습니다.</p>
<blockquote>
<p><strong>Remote App 빌드와 배포는 모두 성공했습니다. 그런데 일부 설치 Host App에서는 화면이 열리지 않았습니다!</strong></p>
</blockquote>
<p>CI도 통과했고 CDN에도 파일이 있었습니다. 개발 중인 모노레포에서는 같은 Shared Package 코드를 문제없이 가져올 수 있었습니다. 하지만 새 Remote App 코드에서 호출하는 API나 native 의존성은 사용자 기기에 설치된 Host App에 아직 없을 수 있었습니다. 같은 Shared Package 모듈을 서로 다른 경로로 참조하면 Host App이 제공해야 할 코드가 Remote App 번들에 다시 들어가기도 했습니다.</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/0dd764c1-94b9-4a53-885b-d39dd56b92cb/image.png" alt=""></p>
<p>파일을 나눠 배포한 뒤에도 결정할 일이 남아 있었습니다. 어떤 Host App과 Remote App을 함께 실행할지, Shared Package 코드는 어느 번들이 소유할지, 다운로드에 실패하면 어디서 다시 시작할지 정해 두지 않으면 독립 배포는 운에 맡기게 됩니다. 운영을 시작한 뒤에는 코드와 배포 절차 모두 이 세 가지를 지키도록 구조를 다시 설계했습니다.</p>
<hr>
<h2 id="배포-조합을-운이-아니라-계약으로">배포 조합을 운이 아니라 계약으로</h2>
<p>Remote App을 빌드했다는 건 실행할 파일이 생겼다는 뜻입니다. 사용자 기기에서 실제로 열린다는 보장은 아닙니다.</p>
<p>Host App에는 Remote App이 함께 써야 하는 React 실행 환경과 native SDK가 들어 있습니다. 이런 항목은 프로세스 안에서 하나의 인스턴스로 유지해야 합니다. Remote App은 나중에 CDN에서 내려받지만 별도의 native 앱으로 실행되지는 않습니다. 이미 설치된 Host App이 제공하는 실행 환경 위에서 동작합니다. <strong>최신 소스에서 import할 수 있다는 사실과 설치된 Host App이 같은 계약을 제공한다는 사실은 서로 다릅니다.</strong></p>
<p>처음에는 배포 파일이 있고 버전 정보까지 기록돼 있으면 호환 여부를 판단할 수 있다고 봤습니다. 하지만 호환성 선언이 넓어질수록 실제로 검증하지 않은 조합까지 실행 후보가 됩니다. Remote App에 Shared Package API 하나를 추가했을 때 Host App 정식 릴리즈까지 필요한지 놓치기 쉬웠던 이유도 여기에 있었습니다.</p>
<p><strong>정상 배포와 조회 경로 모두 같은 1:1 계약을 따르게 했습니다.</strong> Remote App을 배포할 때 대상 Host App 버전을 하나 지정합니다. Host App은 실행 중인 버전과 플랫폼, 필요한 Remote App 이름을 보냅니다. 서버는 그 조건에 맞는 URL 하나를 돌려줍니다. 지원 중인 Host App 버전이 여러 개라면 같은 Remote App 변경을 각 Host App 버전에 맞게 따로 배포합니다.</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/c0b38d76-c1a1-426e-85ea-512ee35ec496/image.png" alt="Host App 버전과 플랫폼, Remote App 이름으로 검증된 번들 하나를 조회하는 1대1 계약"></p>
<p>이 구조에서는 “가장 최근 Remote App을 올렸으니 괜찮다”를 배포 판단의 근거로 삼을 수 없습니다. 배포를 시작할 때부터 어느 Host App과 함께 실행할 번들인지 정해야 합니다. 조회 결과도 같은 조합을 가리켜야 합니다. 새 Host App API를 쓰는 Remote App이라고 해서 이전 Host App용 번들까지 저절로 바뀌지는 않습니다. 이전 Host App을 계속 지원하려면 그 조합을 별도로 빌드하고 검증해야 합니다.</p>
<p>버전 계약을 정한 뒤에는 그 계약이 실제 코드와 번들에서도 지켜지는지 확인했습니다. 소스 단계에서는 Shared Package에서 금지된 import 경로를 막았습니다. 빌드 뒤에는 Host App이 제공해야 할 native 코드가 Remote App 번들에 다시 들어오지 않았는지 검사했습니다. 마지막으로 검증할 Host App과 Remote App을 production 방식으로 함께 실행했습니다.</p>
<p><strong>이 단계들은 서로를 대신하지 않습니다.</strong> 소스 검사는 잘못된 import를 찾지만 빌드 결과까지 증명하지는 못합니다. 산출물 검사도 실제 기기에 설치된 Host App과 Remote App이 함께 실행되는지까지는 알려 주지 못합니다. 배포 조합, 소스, 산출물, 실제 실행을 차례로 확인해야 앞 단계에서 놓친 실패를 다음 단계에서 찾을 수 있었습니다.</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/e3a48727-a06c-4988-8383-6fdc4e28ec4e/image.png" alt="1대1 배포 계약부터 소스 경계, 산출물 검사, 실제 조합 실행까지 이어지는 네 단계"></p>
<p>이 과정을 거치며 Remote App을 배포할 수 있는지 판단하는 기준도 바뀌었습니다. 변경 파일의 위치가 아니라 설치된 Host App 계약에 의존하는지를 봤습니다. 이 기준을 계속 적용하려면 Shared Package의 import 경로에도 같은 소유권이 보여야 했습니다.</p>
<h2 id="shared-package도-누가-제공할지-정해야-했습니다">Shared Package도 누가 제공할지 정해야 했습니다</h2>
<p>Remote App 번들이 크다고 공통 의존성을 모두 Host App에서 제공하면 번들 크기는 당장 줄어듭니다. 대신 그 코드를 바꿀 때마다 Host App 정식 릴리즈가 필요합니다. 반대로 한 프로세스에 하나만 있어야 하는 코드를 Remote App에 넣으면 React context가 둘로 갈리는 등 실행 자체가 달라질 수 있습니다. <strong>중요한 것은 공통 코드의 양이 아니라 누가 그 코드를 제공하고 배포할지였습니다.</strong></p>
<p>당시 Shared Package의 최상위 경로에는 native 연동 코드, React 실행 환경, 상태와 API, 화면 코드, 타입과 유틸이 함께 노출돼 800줄의 메가 베럴 파일로 제공되고 있었습니다.</p>
<p>성격이 다른 코드가 한 경로에서 나오니 Host App이 제공할 코드와 Remote App이 직접 가져갈 코드의 경계가 보이지 않았습니다. import 경로와 Module Federation 설정이 맞지 않으면 Host App이 제공해야 할 코드가 Remote App 번들에 복사되기도 했습니다.</p>
<p>처음에는 중복 export 몇 개를 지우는 방식으로 정리했습니다. 하지만 가져오기 편한지만 기준으로 삼자 최상위 경로는 다시 넓어졌습니다. 새 코드가 들어올 때마다 소유권을 처음부터 판단해야 하는 문제도 남았습니다.</p>
<p>그래서 Shared Package와 각 앱의 계약 관계를 명확하게 재정의하기 위해 <strong>최상위 export를 비우고 실행 인스턴스와 배포 주체에 따라 경로를 나눴습니다.</strong></p>
<p><code>Shared Package/core</code>에는 주로 Host App의 native 환경과 함께 움직여야 하는 API를 모았습니다. 그리고 <code>Shared Package/runtime</code>에는 Host App과 Remote App이 같은 React 인스턴스를 써야 하는 항목을 모았습니다. Remote App은 이 코드를 자기 번들에 복사하지 않고 Host App이 제공한 구현을 사용했습니다.</p>
<p>반대로 날짜 유틸이나 타입처럼 하나의 실행 인스턴스를 공유할 필요가 없는 코드는 <code>Shared Package/utils/date</code>, <code>Shared Package/types/app</code> 같은 실제 경로에서 가져와 Remote App 번들에 포함했습니다. </p>
<p>이제 import 경로만 봐도 Host App과 함께 바꿔야 하는 코드인지, Remote App 안에서 따로 바꿀 수 있는 코드인지 구분됐습니다.</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/a1be9bd3-693a-4f5d-b409-64f2568cdec7/image.png" alt="Shared Package root 하나에 섞여 있던 코드를 Host App이 한 인스턴스로 제공할 코드, Remote App이 직접 가져갈 코드와 데이터, Host App 내부 구현으로 나눈 구조"></p>
<p>구조를 바꾼 뒤 번들 변화도 확인했습니다.</p>
<table>
<thead>
<tr>
<th>비교 대상</th>
<th>결과</th>
<th>측정 범위</th>
</tr>
</thead>
<tbody><tr>
<td>여러 Remote App에 중복으로 들어가던 공통 코드</td>
<td>약 8.5MB 감소</td>
<td>당시 네 Remote App의 배포용 JavaScript 번들 기준. 이미지·소스맵 제외</td>
</tr>
</tbody></table>
<p><code>core</code>나 <code>runtime</code>의 계약을 바꾸면 Host App 릴리즈와 실제 조합 검증이 필요했습니다. <code>utils/*</code>나 <code>types/*</code>처럼 Remote App이 소유한 경로의 변경은 해당 Remote App 산출물 안에서 추적할 수 있었습니다. 공통 코드인지를 묻기보다 <strong>하나의 인스턴스가 필요한지, 어느 배포가 그 변경을 책임질지</strong>를 먼저 따졌습니다.</p>
<p>정상 조합에서는 코드 소유권을 설명할 수 있게 됐지만 다운로드가 실패한 뒤 어디서 다시 시작할지는 별도의 문제였습니다.</p>
<h2 id="다시-시도해도-화면이-열리지-않았습니다">다시 시도해도 화면이 열리지 않았습니다</h2>
<p>사용자가 Remote App 화면을 여는 도중 다운로드가 끊긴 적이 있었습니다. 앱이 종료되지는 않았지만 같은 화면을 다시 눌러도 다운로드는 새로 시작되지 않고 이전 오류가 반복됐습니다. <strong>사용자는 다시 시도했지만 실제로는 새 요청이 나가지 않았던 셈입니다.</strong></p>
<p>기존 재시도는 Remote App을 처음 불러오는 단계에만 적용돼 있었습니다. 화면에 들어가며 추가 코드를 받다가 실패하면 그 결과가 남아 다음 시도에서도 그대로 재사용됐습니다.</p>
<p>그래서 실패 지점에 따라 복구 방식을 나눴습니다. Remote App을 처음 불러오다가 실패하면 남아 있던 모듈과 등록 상태를 비우고 등록과 화면 로딩을 새로 시작했습니다. 화면 진입 중 추가 코드 다운로드가 실패하면 해당 경로에서 다시 시도하도록 했습니다.</p>
<p><strong>복구에서는 재시도 횟수를 늘리기보다 실패한 지점의 상태를 비우고 그 작업을 새로 시작하게 하는 일이 중요했습니다.</strong></p>
<hr>
<h2 id="결론-분리된-번들이-아니라-설명-가능한-운영-경계">결론. 분리된 번들이 아니라 설명 가능한 운영 경계</h2>
<p>React Native에서 Module Federation을 적용한 뒤 <strong>운영 안정성을 만든 것은 Remote App 수가 아니었습니다.</strong> 각 변경이 어느 Host App과 함께 실행되는지, Shared Package 코드를 누가 제공하는지, 실패한 다운로드를 어디서 다시 시작할지를 코드와 배포 절차 모두에서 같은 방식으로 설명할 수 있어야 했습니다.</p>
<p>1편에서는 서로 다른 두 앱을 하나의 경험으로 잇되 번들을 분리한 이유를 다뤘습니다. 이번 글에서는 그렇게 나눈 번들을 실제로 운영하며 함께 움직일 것과 따로 바꿀 것을 정한 과정을 다뤘습니다.</p>
<p><strong>좋은 분리는 더 많은 번들을 만드는 일이 아니라, 함께 움직여야 할 것과 독립적으로 바꿔도 될 것을 명확히 나누는 일입니다.</strong> 그래야 변경의 영향 범위와 실패 뒤의 복구 지점을 팀이 설명할 수 있습니다. </p>
<p>Host App, Remote App, Shared Package의 책임을 같은 원칙으로 나눈 이유도 하나의 앱으로 이어진 사용자 경험을 지키면서 각 팀이 안전하게 변화를 이어 가기 위해서였습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[(React Native) 두 앱을 하나로 합치면서 번들은 나눈 이유]]></title>
            <link>https://velog.io/@yunsungyang-omc/React-Native-%EB%91%90-%EC%95%B1%EC%9D%84-%ED%95%98%EB%82%98%EB%A1%9C-%ED%95%A9%EC%B9%98%EB%A9%B4%EC%84%9C-%EB%B2%88%EB%93%A4%EC%9D%80-%EB%82%98%EB%88%88-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@yunsungyang-omc/React-Native-%EB%91%90-%EC%95%B1%EC%9D%84-%ED%95%98%EB%82%98%EB%A1%9C-%ED%95%A9%EC%B9%98%EB%A9%B4%EC%84%9C-%EB%B2%88%EB%93%A4%EC%9D%80-%EB%82%98%EB%88%88-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Sat, 08 Aug 2026 06:10:50 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요.</p>
<p>미소 고객 앱과 미소 파트너 앱은 오랫동안 서로 다른 앱으로 운영되었습니다.</p>
<p>사용자도 달랐고 앱에서 하는 일도 달랐습니다. 고객은 서비스를 신청하고 진행 상황을 확인했고, 파트너는 일정을 관리하고 업무를 처리했습니다. 두 앱을 따로 개발하는 것이 자연스러운 시기도 있었습니다.</p>
<p>그런데 서비스가 이어질수록 사용자가 두 앱을 오가야 하는 상황이 생겼습니다. 하나의 서비스를 이용하는데 역할에 따라 앱이 나뉘어 있었고, 고객과 파트너 역할을 한 앱 안에서 전환할 필요도 생겼습니다. 결국 두 앱을 하나로 합치는 작업을 시작했습니다.</p>
<p>통합에 쓸 수 있는 시간과 인원은 넉넉하지 않았습니다. 모든 개발자가 원앱 작업만 맡을 수도 없었습니다. 공통 기반을 만드는 코어 작업자와 기존 앱을 잘 아는 개발자가 함께 움직이면서, 기존 기능 개발과 운영도 계속해야 했습니다. 이미 동작하는 두 앱을 처음부터 다시 만드는 방식은 선택하기 어려웠습니다.</p>
<p>이때 고민한 것은 단순히 코드를 어떻게 합칠지가 아니었습니다. 무엇을 하나로 만들고, 무엇을 기존의 경계대로 남길지가 더 중요했습니다.</p>
<p>네이티브 앱은 하나여야 했습니다. 반면 고객·파트너·인증·채팅은 변경 이유와 책임이 달랐습니다. 앱을 하나로 만든다는 이유로 이들의 변경과 배포까지 언제나 함께 움직이게 만들고 싶지는 않았습니다.</p>
<p>그래서 다음과 같이 결정했습니다.</p>
<blockquote>
<p>앱은 하나로 합치되, 번들까지 하나로 합치지는 않는다.</p>
</blockquote>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/021b80ac-2737-4ee6-9c3a-f527a795cd63/image.png" alt="두 앱을 하나의 네이티브 앱으로 통합하되, 기능별 번들은 분리한 구조_
"></p>
<p>돌이켜보면 이 작업의 중심에는 Module Federation보다 경계에 대한 판단이 있었습니다. 이 글에서는 원앱 통합이 시작된 배경부터 Host, Remote, shared 구조를 선택한 이유와 그 한계까지 정리해보려고 합니다.</p>
<hr>
<h2 id="실제로-분리된-두-앱을-하나로-합쳐야-했다">실제로 분리된 두 앱을 하나로 합쳐야 했다</h2>
<p>두 앱에는 이미 사용자에게 필요한 기능과 운영 흐름이 쌓여 있었습니다. 원앱을 만든다고 모든 기능을 다시 구현할 수는 없었습니다. 기존 기능을 한 네이티브 앱 안으로 옮기면서도 고객과 파트너의 흐름은 계속 동작해야 했습니다.</p>
<p>통합할 대상도 화면에만 그치지 않았습니다.</p>
<p>고객과 파트너는 인증을 시작하는 위치가 달랐고, 역할을 전환하는 흐름도 필요했습니다. 채팅은 양쪽에서 사용했습니다. 푸시나 딥링크로 앱을 열었을 때는 어떤 역할의 어느 화면으로 이동할지 판단해야 했습니다. 두 앱에 연결된 SDK와 공통 서비스 역시 설정을 한곳으로 복사한다고 해결되지 않았습니다.</p>
<p>결국 원앱은 두 앱의 화면을 한곳에 모으는 작업이 아니었습니다. 네이티브 생명주기와 공통 진입점은 하나로 합치되, 역할별 제품 흐름은 구분해야 했습니다.</p>
<p>그렇다면 앱이 하나가 된 뒤에도 고객과 파트너의 코드를 모두 같은 단위로 빌드하고 배포해야 할까요? 여기서 네이티브 앱과 JavaScript 번들을 따로 보기 시작했습니다.</p>
<h2 id="앱을-합치는-것과-배포-단위를-합치는-것은-달랐다">앱을 합치는 것과 배포 단위를 합치는 것은 달랐다</h2>
<p>스토어에서 설치하는 앱은 하나여야 했습니다. 두 개의 네이티브 앱을 유지하면 사용자는 계속 앱을 오가야 합니다. 앱 시작 과정과 네이티브 기능도 중복되고, 한 앱 안에서 역할을 전환하는 경험을 만들기 어렵습니다. 최상위 Navigation과 네이티브 모듈처럼 앱에 하나만 있어야 하는 것 역시 하나의 기준으로 관리하는 편이 맞았습니다.</p>
<p>하지만 JavaScript 코드까지 모두 하나의 번들에 넣어야 하는 것은 아니었습니다.</p>
<p>고객 기능을 바꿀 때 파트너 기능까지 항상 함께 배포해야 하는 것은 아닙니다. 인증과 채팅처럼 공통으로 쓰이는 기능도 고객·파트너 화면과 변경 이유가 다릅니다. 책임과 영향 범위가 다른데 배포 단위를 하나로 묶으면, 한쪽의 변경이 다른 쪽의 배포 일정과 계속 얽히게 됩니다.</p>
<p>그래서 통합 범위를 두 층으로 나눴습니다.</p>
<ul>
<li>스토어에 올라가는 네이티브 앱과 앱의 기본 진입 구조는 하나로 합친다.</li>
<li>고객·파트너·인증·채팅처럼 성격이 다른 기능은 JavaScript 번들 단위로 나눈다.</li>
</ul>
<p>여기서 OTA와 Module Federation의 역할을 구분할 필요가 있습니다. 모놀리식 React Native 앱도 OTA 배포가 가능합니다. Module Federation 때문에 OTA가 가능해진 것은 아닙니다. 기존의 OTA 배포 방식을 유지하면서, 기능별 번들 경계도 함께 가져가려 했다는 설명이 더 정확합니다.</p>
<p>앱과 번들을 따로 보기로 한 뒤에는 경계를 어디에 둘지가 다음 문제였습니다. 단순히 폴더를 나누는 것만으로는 빌드와 배포의 경계가 되지 않기 때문입니다.</p>
<h2 id="관심사를-remote-번들로-나눈-이유">관심사를 Remote 번들로 나눈 이유</h2>
<p>우리가 선택한 구조는 Host와 Remote였습니다. Host는 하나의 네이티브 앱을 구성하는 셸이고, 고객과 파트너 기능은 Remote로 불러오는 방식입니다.</p>
<p>기능의 경계는 다음처럼 정리했습니다.</p>
<table>
<thead>
<tr>
<th>구성</th>
<th>책임</th>
<th>배포 단위</th>
</tr>
</thead>
<tbody><tr>
<td>Host</td>
<td>네이티브 바이너리, 앱 셸, 기본 Navigation, 공통 런타임</td>
<td>스토어 앱</td>
</tr>
<tr>
<td><code>customer</code></td>
<td>고객 기능</td>
<td>Remote 번들</td>
</tr>
<tr>
<td><code>partner</code></td>
<td>파트너 기능</td>
<td>Remote 번들</td>
</tr>
<tr>
<td><code>auth</code></td>
<td>인증 기능</td>
<td>Remote 번들</td>
</tr>
<tr>
<td><code>chat</code></td>
<td>채팅·고객센터 기능</td>
<td>Remote 번들</td>
</tr>
</tbody></table>
<p>Remote를 나눈 이유는 팀을 기술 구조에 맞춰 억지로 분리하려는 것이 아니었습니다. 각 기능을 잘 아는 개발자가 기존 맥락을 유지한 채 통합에 참여하면서도, 앱 전체의 기반과 제품 기능이 한꺼번에 뒤섞이지 않게 할 경계가 필요했습니다.</p>
<p>코어 작업자는 Host와 공통 기반을 만들고, 기존 앱의 흐름을 잘 아는 개발자는 각 기능을 원앱으로 옮겼습니다. 이 과정에서 Remote는 단순한 폴더 구분보다 분명한 책임과 변경 단위를 제공했습니다.</p>
<p>물론 Remote를 나눴다고 모든 것이 독립되는 것은 아닙니다. Remote를 별도로 빌드하고 교체할 수 있는 범위는 Host와 shared 계약을 바꾸지 않을 때까지입니다. Host나 shared의 계약이 달라지면 네이티브 앱 릴리즈가 필요할 수 있습니다.</p>
<p>따라서 이 구조의 핵심은 각 기능이 제약 없이 따로 움직이는 데 있지 않습니다.</p>
<blockquote>
<p>하나의 앱 안에서 관심사별 변경 단위를 유지하고, 그 경계가 어디까지 유효한지 명시하는 것.</p>
</blockquote>
<p>번들을 나눴다면 다시 하나의 앱으로 조합할 주체도 필요합니다. 그 역할을 Host가 맡았습니다.</p>
<h2 id="host가-remote를-하나의-앱으로-조합한다">Host가 Remote를 하나의 앱으로 조합한다</h2>
<p>Host는 스토어에 배포되는 네이티브 앱이자 앱의 셸입니다. 네이티브 바이너리와 앱 시작 과정, 최상위 Navigation, Module Federation Runtime을 가지고 있으며 shared 의존성을 Remote에 제공합니다.</p>
<p>Remote는 기능 단위의 JavaScript 번들입니다. Host가 Remote의 위치를 확인하고 런타임에 불러오면, 사용자는 여러 번들이 조합된다는 사실을 알지 못한 채 하나의 앱을 사용합니다.</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/1821e17d-747c-4b21-bdfc-b30459120c0e/image.png" alt="_Host가 네이티브 셸과 런타임을 가지고, shared contract를 기준으로 각 Remote를 조합합니다._"></p>
<p>Host가 맡는 일은 앱 전체의 진입과 조합입니다. 푸시나 딥링크가 들어오면 어느 Remote 화면으로 이동할지 결정합니다. 반면 인증 과정이나 고객·파트너 화면의 비즈니스 로직까지 Host가 소유하지는 않습니다.</p>
<p>Host에서 예외적으로 제품 화면을 처리하기 시작하면 앱 진입과 제품 기능의 책임이 섞입니다. Remote를 바꿀 때 Host도 함께 수정해야 하는 경우가 늘고, 어느 번들을 배포해야 하는지도 판단하기 어려워집니다. 그래서 Host에는 앱에 하나만 있어야 하는 것과 Remote를 연결하는 책임만 남겼습니다. 역할별 인증과 화면 흐름은 각 Remote가 알고, Host는 앱의 생명주기와 진입 계약을 관리하는 식입니다.</p>
<p>로딩 방식도 개발 환경과 배포 환경을 구분했습니다. 개발할 때는 로컬 번들러와 빠른 새로고침을 사용하고, 배포 환경에서는 Remote 번들을 원격에서 가져옵니다. Remote 구조를 도입했다고 개발 과정까지 매번 원격 환경에 의존하게 만들 이유는 없었습니다.</p>
<p>여기까지는 Host와 Remote의 책임을 나눈 이야기입니다. 하지만 여러 번들이 하나의 앱에서 동작하려면 나눠서는 안 되는 것도 있습니다. React나 Navigation처럼 앱 전체에서 같은 인스턴스를 써야 하는 의존성입니다.</p>
<h2 id="공통-의존성은-shared-계약으로-연결했다">공통 의존성은 shared 계약으로 연결했다</h2>
<p>Remote를 나눌 때 가장 조심해야 했던 부분은 공통 의존성이었습니다.</p>
<p>React가 Host와 Remote에 각각 포함되면 서로 다른 React 인스턴스가 만들어질 수 있습니다. 그러면 Hook이 서로 다른 인스턴스에 연결되면서 <code>Invalid hook call</code> 같은 문제가 발생합니다. Navigation이나 전역 상태처럼 앱 전체에서 하나의 인스턴스를 전제로 하는 라이브러리도 마찬가지입니다.</p>
<p>그래서 React, React Native, Navigation, 상태 관리 라이브러리, 공통 모듈처럼 하나의 인스턴스만 있어야 하는 항목은 shared로 관리했습니다. Host가 먼저 제공하고 Remote가 그 인스턴스를 사용하도록 설정했습니다.</p>
<pre><code class="language-js">// Host: 앱 전체에서 사용할 인스턴스를 제공
react: { singleton: true, eager: true }
react-native: { singleton: true, eager: true }
zustand: { singleton: true, eager: true }

// Remote도 Host가 제공한 shared 인스턴스를 사용
react: { singleton: true }
react-native: { singleton: true }
zustand: { singleton: true }

// 순수 JavaScript 라이브러리는 필요에 따라 별도 판단
dayjs: { singleton: false }
zod: { singleton: false }</code></pre>
<p>여기서 <code>singleton</code>은 단순한 번들 최적화 옵션이 아닙니다. Host와 Remote가 같은 런타임 인스턴스를 사용한다는 계약입니다.</p>
<p>예를 들어 Remote가 shared에 새 메서드가 있다고 가정하고 호출하도록 바뀌었는데 사용자의 Host에는 그 메서드가 없다면, Remote 번들이 정상적으로 내려와도 런타임에서 실패할 수 있습니다. Host와 Remote의 버전만 비교해서는 충분하지 않습니다. 실제로 호출하는 API가 양쪽에 모두 존재하는지도 확인해야 합니다.</p>
<p>그래서 변경을 다음처럼 분류했습니다.</p>
<table>
<thead>
<tr>
<th>판단</th>
<th>기준</th>
</tr>
</thead>
<tbody><tr>
<td>Host 릴리즈 필요</td>
<td>shared의 메서드·시그니처·export를 추가, 변경, 삭제하는 경우</td>
</tr>
<tr>
<td>Remote만 배포 가능</td>
<td>기존 API를 유지한 내부 구현 변경이나 Remote 내부 로직 변경</td>
</tr>
<tr>
<td>Remote와 에셋만 배포 가능</td>
<td>런타임 API는 바꾸지 않고 공통 에셋만 바꾸는 경우</td>
</tr>
<tr>
<td>별도 검토 필요</td>
<td>선택적 export 추가처럼 호환성을 따로 확인해야 하는 경우</td>
</tr>
</tbody></table>
<p>이 분류는 Remote 배포를 가능하게 하는 규칙인 동시에 독립 배포의 범위를 제한하는 규칙입니다.</p>
<p>지금까지 무엇을 합치고 무엇을 나눴는지, 나뉜 번들을 Host와 shared가 어떻게 하나의 앱으로 묶는지 살펴봤습니다. 구조는 여기서 완성되지만 판단은 여기서 끝나지 않습니다. 이 경계가 배포에서도 유지되려면 어떤 조건이 필요한지, 그리고 그 조건을 감당할 만한 앱인지 확인해야 합니다.</p>
<hr>
<h2 id="결론-배포-경계와-선택-기준">결론: 배포 경계와 선택 기준</h2>
<h3 id="remote별-배포는-완전히-독립적이지-않다">Remote별 배포는 완전히 독립적이지 않다</h3>
<p>Remote를 나누면 모든 변경을 서로 독립적으로 배포할 수 있을 것처럼 보일 수 있습니다. 실제로는 Host와 shared 계약을 지키는 범위에서만 가능합니다.</p>
<p>Remote 내부 구현만 바꾸고 기존 계약을 유지한다면 Remote만 배포할 수 있습니다. 반대로 네이티브 바이너리나 Host가 제공하는 shared API를 바꾸면 Remote만 교체해서는 호환성을 보장할 수 없습니다. 이때는 Host 릴리즈가 필요합니다.</p>
<p>사용자의 기기에 서로 다른 Host 버전이 남는다는 점도 고려해야 합니다. Remote가 어느 Host와 조합될 수 있는지 기준이 필요하고, 버전이 맞는다는 사실만으로 API 호환성까지 증명되지는 않습니다.</p>
<p>Remote를 런타임에 불러오는 과정이 추가된다는 점도 비용입니다. 하나의 번들 안에서는 없던 로딩 경계가 생기기 때문에, 이 실패 가능성까지 운영할 준비가 되어 있어야 합니다. 구체적인 운영 방법과 결과는 다음 글에서 따로 다루겠습니다.</p>
<p>결국 Remote별 배포는 완전히 독립적인 배포가 아닙니다.</p>
<blockquote>
<p>Host와 shared 계약을 안정적으로 유지하는 동안, 관심사별로 배포할 수 있는 구조입니다.</p>
</blockquote>
<p>우리가 얻은 것은 무제한적인 독립성이 아니라 조건이 붙은 선택지였습니다. 그렇다면 이 선택지가 우리 앱에도 필요한지 어떻게 판단할 수 있을까요?</p>
<h3 id="이-구조를-선택하기-전에-물어야-할-것">이 구조를 선택하기 전에 물어야 할 것</h3>
<p>원앱에서 Module Federation을 선택한 이유는 기술 자체가 목적이어서가 아니었습니다. 하나의 네이티브 앱으로 통합해야 했고, 이미 동작하는 기능을 점진적으로 옮겨야 했습니다. 동시에 고객과 파트너 기능의 책임과 변경 범위까지 하나로 묶고 싶지는 않았습니다.</p>
<p>모든 React Native 앱에 이 구조가 필요한 것은 아닙니다. 도입하기 전에 다음 질문을 먼저 확인해야 합니다.</p>
<h4 id="정말-배포-단위를-나눠야-하는가">정말 배포 단위를 나눠야 하는가</h4>
<p>기능마다 변경 이유와 영향 범위가 다르고, 하나의 네이티브 앱 안에서도 그 차이를 유지해야 하는지 살펴봐야 합니다. 단순히 코드가 많다는 이유로 Remote를 늘리면 로딩 단위와 버전 조합만 복잡해질 수 있습니다.</p>
<h4 id="하나로-합칠-것과-나눌-것을-구분했는가">하나로 합칠 것과 나눌 것을 구분했는가</h4>
<p>앱 시작 과정, 네이티브 모듈, 최상위 Navigation처럼 앱에 하나만 있어야 하는 것은 Host에 남겨야 합니다. 고객·파트너처럼 제품 책임이 다른 기능은 Remote 후보가 될 수 있습니다. 인증과 채팅처럼 여러 영역에서 사용하는 기능은 공통 계약부터 정해야 합니다.</p>
<h4 id="host와-shared-계약을-운영할-수-있는가">Host와 shared 계약을 운영할 수 있는가</h4>
<p>공유 의존성을 정하는 것보다 어려운 일은 그 계약을 계속 지키는 것입니다. 어떤 변경에 Host 릴리즈가 필요한지, 어떤 Remote가 어느 Host와 호환되는지, 문제가 생겼을 때 어느 단위로 되돌릴지 합의할 수 있어야 합니다.</p>
<h4 id="추가된-로딩-경계를-감당할-수-있는가">추가된 로딩 경계를 감당할 수 있는가</h4>
<p>Remote를 원격에서 불러오면 앱 실행 과정에 새로운 실패 가능성이 생깁니다. 이를 구분하고 대응할 준비가 없다면 배포 단위를 나눈 이점보다 구조를 유지하는 비용이 더 커질 수 있습니다.</p>
<p>우리가 선택한 것은 앱을 잘게 쪼개는 방법이 아니었습니다. 하나의 앱으로 합쳐야 하는 것과 관심사별로 계속 나눠야 하는 것을 구분하는 방법에 가까웠습니다.</p>
<p>네이티브 앱은 하나로 합쳤습니다. 고객·파트너·인증·채팅의 변경과 배포는 Host와 shared 계약 안에서 나눴습니다. 당시의 일정과 인원, 이미 운영 중인 두 앱을 점진적으로 통합해야 한다는 제약을 생각하면 이 구조가 우리에게 맞는 선택이었습니다.</p>
<p>다만 구조를 나눈 순간부터 shared 계약과 버전 조합, 런타임 로딩이라는 새로운 운영 과제도 생겼습니다.</p>
<p>다음 글에서는 이 경계를 실제로 운영하면서 무엇을 다시 측정했고, Remote를 나눈 뒤 어떤 계약과 실패 경로를 추가로 관리하게 됐는지 이어서 정리하겠습니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[이력서를 부풀려서 쓸 필요가 없는 이유]]></title>
            <link>https://velog.io/@yunsungyang-omc/%EC%9D%B4%EB%A0%A5%EC%84%9C%EB%A5%BC-%EB%B6%80%ED%92%80%EB%A0%A4%EC%84%9C-%EC%93%B8-%ED%95%84%EC%9A%94%EA%B0%80-%EC%97%86%EB%8A%94-%EC%9D%B4%EC%9C%A0</link>
            <guid>https://velog.io/@yunsungyang-omc/%EC%9D%B4%EB%A0%A5%EC%84%9C%EB%A5%BC-%EB%B6%80%ED%92%80%EB%A0%A4%EC%84%9C-%EC%93%B8-%ED%95%84%EC%9A%94%EA%B0%80-%EC%97%86%EB%8A%94-%EC%9D%B4%EC%9C%A0</guid>
            <pubDate>Tue, 07 Jul 2026 10:02:00 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/3907d38a-1375-410d-ac02-def28905f3f1/image.png" alt=""></p>
<p>최근에 이력서를 볼 일이 많았습니다. 멘토링에서 주니어들의 이력서를 리뷰했고, 채용 과정에서 경력자들의 문서도 여럿 읽었습니다. 잘 쓴 이력서를 만나면 반갑죠. 그런데 가끔, 읽다 보면 어딘가 이상한 문서를 만납니다. 프로젝트의 모든 성과가 한 사람 것으로 적혀 있거나, 몸담은 기간과 성과의 규모가 도저히 맞지 않거나. 확인할 방법이 없으니 넘어가지만, 읽는 쪽은 알거든요. 그리고 한 번 이상하다 싶으면, 그 뒤로는 다른 문장도 의심하면서 읽게 됩니다.</p>
<p>이 글은 그런 이력서를 쓰는 사람을 탓하려고 쓰는 게 아닙니다. 오히려 반대입니다. 부풀리고 싶은 마음이 어디서 오는지 알 것 같아서 씁니다. &quot;내가 한 일은 너무 평범한데, 이대로 쓰면 아무도 안 뽑아줄 것 같다&quot;는 불안. 그 불안에 대한 제 답은 이렇습니다. <strong>부풀릴 필요가 없습니다. 대부분의 이력서는 사실이 부족한 게 아니라 정리가 부족합니다.</strong></p>
<h3 id="부풀린-문장은-면접에서-걸립니다">부풀린 문장은 면접에서 걸립니다</h3>
<p>이력서의 모든 문장은 면접 질문으로 돌아옵니다. &quot;결제 시스템을 설계했습니다&quot;라고 썼다면 &quot;왜 그 구조를 선택했어요?&quot;가 오고요. &quot;전환율을 30% 올렸습니다&quot;라고 썼다면 &quot;어떻게 측정했어요? 그중 당신 몫은 어디까지예요?&quot;가 옵니다.</p>
<p>직접 한 일이라면 이 질문들이 반갑습니다. 3분이고 10분이고 이야기할 수 있으니까요. 하지만 옆에서 지켜본 일을 내가 한 일로 적었다면, 두어 번만 파고들어도 답이 막힙니다. 그리고 면접관은 한 문장에서 막히는 걸 보면 나머지 문장도 다시 봅니다. 부풀린 한 줄 때문에 진짜였던 아홉 줄까지 의심받는 겁니다.</p>
<p>요즘은 리스크가 더 커졌죠. AI로 이력서를 다듬는 게 흔해지면서, 그럴듯한 표현은 넘쳐나고 검증을 통과하는 문장은 오히려 귀해졌습니다. 문서가 매끈할수록 면접은 더 집요해집니다. 부풀리기의 기대수익은 떨어지고 비용은 올라가는 중입니다.</p>
<h3 id="같은-사실도-정리하면-성과가-됩니다">같은 사실도 정리하면 성과가 됩니다</h3>
<p>그럼 평범한 사실만으로 뭘 보여줄 수 있을까요? 제 이력서로 실험해봤습니다. 최근에 이력서 작성법 강의를 준비하면서 기준을 하나 세웠는데, 그 기준으로 제 이력서를 먼저 점검했거든요. 결과는 꽤 아팠습니다. 숫자는 충분한데 구조가 없더라고요.</p>
<p>고치기 전 제 이력서의 한 항목은 이랬습니다.</p>
<blockquote>
<ul>
<li>파트너 앱 개발로 CS 효율성 극대화</li>
<li>일정 취소 변경 시스템 구축</li>
<li>취소/변경 문의 비중을 39%에서 24.5%로 감소 달성</li>
</ul>
</blockquote>
<p>거짓은 없습니다. 숫자도 있고요. 그런데 &quot;극대화&quot; 같은 말이 앞자리를 차지하고, 왜 이 일이 필요했는지가 없으니 39%가 큰 숫자인지 작은 숫자인지 전달이 안 됩니다. 이걸 이렇게 바꿨어요.</p>
<blockquote>
<p><strong>문제</strong> — 일정 취소·변경이 전체 CS 문의의 39%. 파트너는 상담 연결을 기다려야 하고, 상담사는 같은 유형을 반복 처리하는 구조</p>
<p><strong>해결</strong> — 서비스별 취소 정책을 반영한 셀프서비스 구축. 사용자 행동 분석으로 진입점을 화면 상단으로 이동</p>
<p><strong>성과</strong> — 취소·변경 문의 비중 39% → 24.5%, 평균 처리 시간 55초 → 47초</p>
</blockquote>
<p>숫자는 한 글자도 안 바꿨습니다. 문제 한 줄을 추가하고 순서를 바꿨을 뿐인데, 같은 숫자가 &quot;기능을 만들었다&quot;에서 &quot;운영 구조를 바꿨다&quot;로 읽힙니다. 부풀리기가 하는 일이 없는 성과를 지어내는 것이라면, 정리가 하는 일은 있는 성과를 보이게 만드는 것입니다. 후자는 면접관이 캐물을수록 오히려 유리해집니다. 내가 겪은 일이니까요.</p>
<p>정리의 요령은 생각보다 단순합니다. 모든 항목을 문제, 해결, 성과 세 칸으로 나눠보세요. 빈 칸이 있으면 그게 구멍입니다. &quot;개선했다&quot;로 끝나는 문장에는 숫자를 붙이고, 숫자가 없으면 규모나 전후 비교라도 붙이세요. &quot;대폭&quot;, &quot;극대화&quot;, &quot;뛰어난&quot; 같은 말은 지우세요. 그건 읽는 사람이 내릴 판정이고, 근거만 제대로 주면 리뷰어는 내가 스스로 내린 판정보다 항상 후하게 내려줍니다.</p>
<h3 id="정말-쓸-게-없다면-아무도-안-쳐다보는-곳부터">정말 쓸 게 없다면, 아무도 안 쳐다보는 곳부터</h3>
<p>여기까지 읽고 이렇게 말하는 분이 계실 텐데요. &quot;정리할 사실 자체가 없어요. 내 경력은 물경력이에요.&quot;</p>
<p>먼저 의심해볼 건 기억력입니다. 성과가 없는 게 아니라 기록이 없는 경우가 훨씬 많거든요. 작년에 고친 버그, 줄인 빌드 시간, 정리한 문서는 분명 있었는데 숫자를 안 적어뒀으니 지금 복원이 안 되는 겁니다. 그래서 저는 프로젝트가 끝날 때마다 다섯 줄짜리 기록을 남기라고 권합니다. 문제, 해결, 성과, 증거 링크, 예상 질문. 이력서는 지원할 때 쓰는 게 아니라 평소에 쌓는 겁니다.</p>
<p>그런데 진짜로 없을 수도 있습니다. 유지보수만 했고, 시키는 것만 했고, 지표를 볼 권한도 없었다면요. 그렇다면 지금부터 만들면 됩니다. 그리고 성과를 만들기 가장 좋은 곳은, 역설적으로 <strong>아무도 쳐다보지 않는 레거시</strong>입니다.</p>
<p>레거시 청산은 이력서 관점에서 거의 반칙에 가까운 일인데요. 문제가 이미 존재하고(느린 빌드, 아무도 못 건드리는 모듈, 수기로 하는 반복 업무), 전후 비교가 저절로 나오고(42초가 6초가 되고, 한 달 걸리던 게 일주일이 되고), 경쟁자가 없습니다. 아무도 하기 싫어하니까요. 새 기능은 기획과 디자인과 일정이 얽혀 내 몫을 증명하기 어렵지만, 레거시는 처음부터 끝까지 내 판단과 내 손입니다. &quot;이거 혼자 했어요?&quot;라는 질문이 두렵지 않은 몇 안 되는 영역입니다.</p>
<p>거창할 필요도 없습니다. 팀이 매일 손으로 하던 작업을 스크립트로 바꾸는 것, 에러가 나도 아무도 모르던 서비스에 모니터링을 붙이는 것, 3천 줄짜리 파일을 쪼개서 다음 사람이 건드릴 수 있게 만드는 것. 제가 좋게 본 이력서들의 한 줄을 빌리면 &quot;운영팀 하루 4시간 반복 업무 감소&quot; 같은 문장이 여기서 나옵니다. 화려한 기술이 아니라, 문제를 발견하고 끝까지 치운 기록입니다.</p>
<p>물경력이라는 말은 경력의 상태가 아니라 기록의 상태인 경우가 많습니다. 그리고 기록할 게 없다면, 기록할 일을 만드는 건 오늘부터 가능합니다.</p>
<h3 id="이력서는-나를-칭찬하는-문서가-아닙니다">이력서는 나를 칭찬하는 문서가 아닙니다</h3>
<p>부풀린 이력서가 노리는 건 감탄입니다. &quot;이 사람 대단하네.&quot; 하지만 서류가 실제로 해야 하는 일은 다릅니다. 좋은 이력서는 읽는 사람에게 좋은 질문이 생기게 만드는 문서입니다. &quot;이 문제를 어떻게 풀었는지 더 듣고 싶다&quot;는 마음이 들면 서류는 제 몫을 다 한 겁니다. 그 다음은 면접에서 내 이야기가 증명합니다.</p>
<p>감탄은 질문 두어 개면 사라지지만, 질문에 제대로 답하고 나면 그 자리에 신뢰가 남습니다. 그러니 방향은 하나입니다. 지어내지 말고, 겪은 일을 정리할 것. 정리할 게 없으면 만들 것. 그리고 만들었으면 그때그때 적어둘 것.</p>
<p>내 이력을 객관적으로 보는 건 아픕니다. 저도 제 이력서를 기준에 통과시켜보고 나서야 구멍이 보이더라고요. 하지만 그 아픔은 면접장에서 겪는 것보다 훨씬 쌉니다. 부풀리는 데 쓸 에너지가 있다면, 그 에너지로 지나온 과정을 다시 정리해보세요. 생각보다 쓸 게 많다는 걸 알게 되실 겁니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[(React) 서버 DTO, 그대로 사용할 것인가 변환할 것인가]]></title>
            <link>https://velog.io/@yunsungyang-omc/React-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8%EC%99%80-%EC%84%9C%EB%B2%84-DTO-%EC%82%AC%EC%9D%B4%EC%9D%98-%EA%B2%BD%EA%B3%84-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yunsungyang-omc/React-%ED%81%B4%EB%9D%BC%EC%9D%B4%EC%96%B8%ED%8A%B8%EC%99%80-%EC%84%9C%EB%B2%84-DTO-%EC%82%AC%EC%9D%B4%EC%9D%98-%EA%B2%BD%EA%B3%84-%EC%84%A4%EC%A0%95%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 25 Oct 2025 08:01:07 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요. 토스의 frontend-fundamentals 디스커션에서 아래와 같은 고민이 있더라구요.</p>
<blockquote>
<p>&quot;백엔드에서 준 데이터를 최대한 그대로 쓰는 게 맞아&quot; vs. &quot;프론트는 프론트 도메인으로 따로 가야지&quot;
<a href="https://github.com/toss/frontend-fundamentals/discussions/324">디스커션 링크</a></p>
</blockquote>
<p>특히 백엔드와 프론트엔드가 분리되어 개발하는 환경에서, <strong>서버에서 내려주는 데이터를 어떻게 다뤄야 할까</strong>에 대한 논쟁은 끝이 없죠.</p>
<p>저는 후자 쪽에 손을 들고 싶습니다. 하지만 <strong>무조건 변환 레이어가 정답이다</strong>라고 말하고 싶은 건 아니에요. 오늘은 <strong>어떤 상황에서 DTO에 직접 의존하는 게 위험한지</strong>, 그리고 <strong>언제 변환 레이어가 필요한지</strong> 제 경험을 공유해보려고 합니다.</p>
<h2 id="개발-단계에서-dto는-자주-변한다">개발 단계에서 DTO는 자주 변한다</h2>
<p>실제 프로젝트를 진행하다 보면 이런 경험 있으시죠?</p>
<p><strong>1주차:</strong></p>
<pre><code class="language-tsx">interface UserDTO {
  name: string;
  email: string;
}</code></pre>
<p><strong>2주차:</strong> 백엔드 개발자 :  &quot;아, 이름이 firstName, lastName으로 분리됐어요&quot;</p>
<pre><code class="language-tsx">interface UserDTO {
  firstName: string;
  lastName: string;
  email: string;
}</code></pre>
<p><strong>3주차:</strong> 백엔드 개발자 : &quot;email 필드 삭제하고 contactInfo로 통합했습니다&quot;</p>
<pre><code class="language-tsx">interface UserDTO {
  firstName: string;
  lastName: string;
  contactInfo: {
    email: string;
    phone: string;
  }
}</code></pre>
<p>이런 변경이 있을 때마다 프론트엔드는 어떻게 되나요?</p>
<pre><code class="language-tsx">// UserProfile.tsx
function UserProfile() {
  const { data: user } = useQuery([&#39;user&#39;], fetchUser);

  return (
    &lt;div&gt;
      &lt;h1&gt;{[user.name](http://user.name)}&lt;/h1&gt; {/* ❌ 타입 에러! */}
      &lt;p&gt;{[user.email](http://user.email)}&lt;/p&gt;  {/* ❌ 타입 에러! */}
    &lt;/div&gt;
  );
}

// UserCard.tsx
function UserCard() {
  const { data: user } = useQuery([&#39;user&#39;], fetchUser);

  return &lt;span&gt;{[user.name](http://user.name)}&lt;/span&gt;; {/* ❌ 또 타입 에러! */}
}

// UserMenu.tsx
function UserMenu() {
  const { data: user } = useQuery([&#39;user&#39;], fetchUser);

  return &lt;div&gt;{[user.email](http://user.email)}&lt;/div&gt;; {/* ❌ 또또 타입 에러! */}
}</code></pre>
<p>보이시나요? <strong>DTO를 직접 사용하는 모든 컴포넌트</strong>에서 에러가 터집니다.</p>
<p>개발 단계에서는 DTO가 2주에 한 번씩 변경되기도 하고, 운영 단계에서도 필요에 따라 자주 변경될 수 있어요.</p>
<h2 id="특히-앱에서는-더욱-치명적입니다">특히 앱에서는 더욱 치명적입니다</h2>
<p>웹 개발자라면 &quot;API 바뀌면 바로 배포하면 되지 않나?&quot;라고 생각할 수 있어요. 맞습니다. <strong>웹은 배포가 자유롭기 때문에</strong> DTO 변경에 상대적으로 유연하게 대응할 수 있죠.</p>
<p>하지만 <strong>모바일 앱은 완전히 다른 이야기</strong>입니다.</p>
<h3 id="앱은-영구적입니다">앱은 &quot;영구적&quot;입니다</h3>
<p>앱스토어나 플레이스토어에 배포된 순간부터, 그 앱은 개발자의 즉각적인 통제를 벗어납니다. 사용자가 언제 업데이트할지, 심지어 업데이트를 할지조차 보장할 수 없어요.</p>
<pre><code class="language-tsx">// 2024년 1월: v1.0.0 배포
interface UserDTO {
  name: string;
}

// 2024년 3월: v1.1.0 배포 - API 변경됨
interface UserDTO {
  firstName: string;
  lastName: string;
}
</code></pre>
<p>이 상황에서 문제는:</p>
<ul>
<li>v1.0.0을 쓰는 사용자가 여전히 50%</li>
<li>v1.1.0으로 업데이트한 사용자가 50%</li>
<li><strong>서버는 두 버전 모두를 지원해야 함</strong></li>
</ul>
<p>웹이었다면? 배포 한 번으로 모든 사용자가 즉시 새 코드를 사용합니다.</p>
<h3 id="ios는-리뷰-프로세스-android는-단계적-출시">iOS는 리뷰 프로세스, Android는 단계적 출시</h3>
<p>더 복잡한 것은:</p>
<ul>
<li><strong>iOS</strong>: Apple의 앱 리뷰가 2-3일 소요</li>
<li><strong>Android</strong>: 단계적 출시로 100% 배포까지 며칠 소요</li>
<li><strong>두 플랫폼 모두</strong>: 사용자의 자동 업데이트 비활성화 가능</li>
</ul>
<p>결과적으로 언제든 <strong>최소 3~4개 버전이 동시에 사용 중</strong>입니다.</p>
<h3 id="실제-사례-앱에서-dto에-직접-의존하면">실제 사례: 앱에서 DTO에 직접 의존하면</h3>
<p>앤드로이드 개발자의 경험을 공유한 글을 보면, API를 v3.5에서 v3.6으로 업그레이드했더니:</p>
<ul>
<li><code>/users</code> 엔드포인트 응답 구조 변경</li>
<li><strong>다른 2개 엔드포인트도 함께 변경됨</strong></li>
<li>앱이 예상치 못한 곳에서 크래시 발생</li>
</ul>
<p>&quot;그거 개발자 잘못 아니야?&quot;라고 할 수 있지만, 앱 개발자 입장에선 선택지가 없어요. 전역 API 버전을 올리면 예측할 수 없는 변경사항들이 한꺼번에 적용되니까요.</p>
<h3 id="해결책은-결국-변환-레이어">해결책은 결국 변환 레이어</h3>
<p>이런 상황에서 변환 레이어가 있다면:</p>
<pre><code class="language-tsx">// 서버 API가 v3.6으로 변경되어도
function serverToClient(dto: UserDTOv3_6): User {
  return {
    fullName: `${dto.firstName} ${dto.lastName}` // v1.0.0, v1.1.0 모두 대응
  };
}
</code></pre>
<p>앱의 모든 버전이 동일한 도메인 타입을 사용하고, 서버 응답만 버전별로 적절히 변환하면 됩니다.</p>
<p>웹에서는 선택사항일 수 있지만, <strong>앱에서 DTO에 직접 의존하는 것은 시한폭탄</strong>입니다. API 버전 관리와 변환 레이어는 거의 필수에 가깝습니다.</p>
<h2 id="그런데-정말-모든-프로젝트에서-문제가-될까요">그런데 정말 모든 프로젝트에서 문제가 될까요?</h2>
<p>사실 <strong>그렇지 않습니다.</strong> 제 경험상 프로젝트 상황에 따라 달라져요.</p>
<p><strong>소규모 팀에서 긴밀하게 일할 때</strong>는 DTO를 그대로 써도 괜찮아요. </p>
<p>백엔드 개발자와 한 방에서 일하면서 API 변경 전에 미리 논의할 수 있고, 변경 사항을 바로바로 공유하고 함께 대응할 수 있잖아요. API 개수도 적고, 사용하는 컴포넌트도 몇 개 안 되는 경우라면 오히려 <strong>심플하게 가는 게 좋을 수 있어요</strong>. 변환 레이어는 그냥 오버엔지니어링이 될 수 있죠.</p>
<p>하지만 제가 변환 레이어를 강력히 추천하는 케이스가 있어요.</p>
<p><strong>대규모 프로젝트에서 여러 API에 의존할 때</strong>요. </p>
<p>10개 이상의 서로 다른 API 엔드포인트를 사용한다거나, 외부 API나 파트너사 API 같은 통제 불가능한 API를 쓴다거나, 백엔드 팀이 여러 개로 나뉘어 있어서 변경 사항을 한눈에 파악하기 어려운 경우. 이런 상황에서는 <strong>각 API의 변경이 프론트엔드 전체에 파급효과</strong>를 일으킬 수 있어요.</p>
<p>특히 이런 경험 있으신가요?</p>
<pre><code class="language-tsx">// 프로필 API는 [user.name](http://user.name)
const { data: profile } = useQuery([&#39;profile&#39;], fetchProfile);

// 주문 API는 user.userName  
const { data: order } = useQuery([&#39;order&#39;], fetchOrder);

// 결제 API는 user.fullName
const { data: payment } = useQuery([&#39;payment&#39;], fetchPayment);</code></pre>
<p>같은 &quot;사용자 이름&quot;인데 <strong>API마다 필드명이 다른 경우</strong>... 정말 흔하죠. 이럴 때 컴포넌트에서 일일이 맞춰 쓰면 코드가 난잡해집니다.</p>
<pre><code class="language-tsx">// 😰 이런 코드를 여러 곳에서...
&lt;div&gt;
  {profile?.name || order?.userName || payment?.fullName}
&lt;/div&gt;</code></pre>
<h2 id="react-query-메인테이너도-데이터-변환을-이야기합니다">React Query 메인테이너도 데이터 변환을 이야기합니다</h2>
<p>TkDodo의 <a href="https://tkdodo.eu/blog/react-query-data-transformations">React Query Data Transformations</a> 블로그 포스트를 보면 이런 말이 나옵니다.</p>
<blockquote>
<p>&quot;Let&#39;s face it - most of us are not using GraphQL. If you are working with REST though, you are constrained by what the backend returns.&quot;</p>
</blockquote>
<p>REST API를 사용한다면, 백엔드가 주는 구조에 <strong>제약</strong>을 받는다는 거죠. 그래서 TkDodo는 데이터 변환을 위한 여러 방법을 제시하는데, 그중 <strong>가장 추천하는 방식</strong>이 바로 <code>select</code> 옵션입니다.</p>
<pre><code class="language-tsx">const useTodos = () =&gt; 
  useQuery({
    queryKey: [&#39;todos&#39;],
    queryFn: fetchTodos,
    select: (data) =&gt; transformData(data) // 여기서 변환!
  })</code></pre>
<p>이 방식의 장점은 변환된 데이터가 변하지 않으면 컴포넌트가 리렌더링되지 않고, 전체 데이터 중 필요한 부분만 선택적으로 구독할 수 있다는 점이에요. 그리고 <strong>타입 변환이 useQuery 레벨에서 끝난다</strong>는 게 큰 장점이죠.</p>
<h2 id="제가-실제로-사용하는-패턴">제가 실제로 사용하는 패턴</h2>
<p>그럼 실제로 어떻게 구성하면 좋을까요? 제가 쓰는 패턴을 보여드릴게요.</p>
<p>먼저 Repository 레이어를 구조화합니다.</p>
<pre><code class="language-tsx">// repositories/user/formatters.ts
import type { UserDTO } from &#39;@/types/dto&#39;;
import type { User } from &#39;@/types/domain&#39;;

export function toUser(dto: UserDTO): User {
  return {
    id: dto.id,
    fullName: `${dto.firstName} ${dto.lastName}`,
    email: dto.email,
  };
}
</code></pre>
<pre><code class="language-tsx">// repositories/user/api.ts
import type { UserDTO } from &#39;@/types/dto&#39;;

export async function fetchUser(id: string): Promise&lt;UserDTO&gt; {
  const response = await fetch(`/api/users/${id}`);
  return response.json();
}
</code></pre>
<p>그리고 Custom Hook에서 <strong>TkDodo가 추천한 select 옵션</strong>을 활용합니다.</p>
<pre><code class="language-tsx">// hooks/useUser.ts
import { useQuery } from &#39;@tanstack/react-query&#39;;
import { fetchUser } from &#39;@/repositories/user/api&#39;;
import { toUser } from &#39;@/repositories/user/formatters&#39;;

export function useUser(id: string) {
  return useQuery({
    queryKey: [&#39;user&#39;, id],
    queryFn: () =&gt; fetchUser(id),
    select: toUser, // ✅ Repository의 formatter를 select에서 사용
  });
}
</code></pre>
<p>컴포넌트는 도메인 타입만 사용합니다.</p>
<pre><code class="language-tsx">// components/UserProfile.tsx
function UserProfile({ userId }: Props) {
  const { data: user } = useUser(userId);

  return (
    &lt;div&gt;
      &lt;h1&gt;{user.fullName}&lt;/h1&gt; {/* ✅ 항상 동일한 인터페이스 */}
      &lt;p&gt;{user.email}&lt;/p&gt;
    &lt;/div&gt;
  );
}
</code></pre>
<p>이제 백엔드에서 firstName, lastName을 fullName으로 통합하더라도 어떻게 될까요?</p>
<pre><code class="language-tsx">// 수정이 필요한 곳은 Repository의 formatter뿐!
// repositories/user/formatters.ts
export function toUser(dto: UserDTO): User {
  return {
    id: dto.id,
    fullName: dto.fullName, // ✅ 여기만 수정
    email: dto.email,
  };
}
</code></pre>
<p><strong>컴포넌트는 손도 안 댔습니다.</strong> 타입 에러도 나지 않아요.</p>
<p>이 패턴의 장점은:</p>
<ul>
<li><strong>변환 로직이 <code>formatters.ts</code>에 집중</strong>되어 관리가 쉽고</li>
<li><strong>React Query의 select 최적화</strong>를 그대로 활용하며</li>
<li><strong>API 호출과 변환 로직이 명확히 분리</strong>됩니다</li>
</ul>
<h2 id="이-접근의-진짜-장점-문제-발견-지점">이 접근의 진짜 장점: 문제 발견 지점</h2>
<p>변환 레이어를 두면 <strong>어디서 문제가 생기는지 명확</strong>해집니다.</p>
<p>DTO를 직접 사용하면 15개 컴포넌트에서 각각 타입 에러가 발생하고, 어디서 뭐가 잘못됐는지 파악하기 어려워요. 하지만 변환 레이어를 사용하면 <strong>formatter에서만 타입 에러가 발생</strong>하고, 한 곳만 보면 되고 수정도 한 곳만 하면 됩니다.</p>
<pre><code class="language-tsx">// ❌ DTO를 직접 사용하면
// UserProfile.tsx에서 에러
// UserCard.tsx에서 에러
// UserList.tsx에서 에러
// ... (15곳에서 에러)

// ✅ formatter를 사용하면
// repositories/user/formatters.ts에서만 에러!
</code></pre>
<p>특히 <strong>여러 API를 사용하는 대규모 프로젝트</strong>에서 이 차이가 크게 느껴집니다. 10개 API가 있다면:</p>
<ul>
<li>DTO를 직접 쓸 때: 10개 API × 평균 8개 컴포넌트 = <strong>80곳에서 에러</strong></li>
<li>formatter를 쓸 때: <strong>10개 formatter 파일에서만 에러</strong></li>
</ul>
<p>에러가 발생하는 위치가 80곳에서 10곳으로 줄어듭니다. 수정도 10곳만 하면 되고요.</p>
<h2 id="커뮤니티의-의견도-비슷합니다">커뮤니티의 의견도 비슷합니다</h2>
<p><a href="https://profy.dev/article/react-architecture-domain-entities-and-dtos">profy.dev의 &quot;React Architecture&quot;</a> 시리즈를 보면 이런 말이 나옵니다.</p>
<blockquote>
<p>&quot;By passing the data coming from the API directly to the components we tightly couple the UI to the server.&quot;</p>
</blockquote>
<p>API 데이터를 컴포넌트에 직접 전달하면 <strong>UI가 서버에 강하게 결합</strong>된다는 거죠.</p>
<p>Stack Overflow에도 &quot;Service layer returns DTO to controller but need it to return model for other services&quot;나 &quot;Should data transformation be on the front or on the back end?&quot; 같은 질문들이 정말 많더라고요. 그리고 답은 명확합니다. <strong>경계(boundary)에서 변환하라</strong>는 거예요. 프론트엔드 관점에서 경계는 API 호출 직후, 즉 Repository 레이어입니다.</p>
<h2 id="물론-트레이드오프는-있습니다">물론 트레이드오프는 있습니다</h2>
<p>&quot;그럼 보일러플레이트 코드가 엄청 늘어나지 않아요?&quot;</p>
<p>네, 맞습니다. 변환 함수를 작성하고, 도메인 타입을 정의하고, 추가 파일을 관리해야 하죠. 이게 비용입니다.</p>
<p>하지만 서버 변경에 대한 격리, 컴포넌트 안정성, 타입 안전성, 그리고 <strong>장기적인 유지보수 비용 감소</strong>라는 이득이 있어요.</p>
<p>그래서 제 생각에는:</p>
<ul>
<li><strong>3개 이하 API + 백엔드와 긴밀한 소통</strong> → DTO 직접 사용해도 괜찮음</li>
<li><strong>API가 안정적이고 자주 안 바뀌는 환경</strong> → 필요한 것만 부분적으로 변환</li>
<li><strong>중규모 프로젝트 + 활발한 개발 단계</strong> → 전체 변환 레이어 추천</li>
<li><strong>10개 이상 API + 여러 팀 의존</strong> → 변환 레이어 강력 추천 (거의 필수)</li>
</ul>
<h2 id="도입하려면-어떻게-하면-될까요">도입하려면 어떻게 하면 될까요?</h2>
<p>만약 이 글을 읽고 공감하셨다면, 이렇게 시작해볼 수 있어요.</p>
<p>먼저 도메인 타입을 정의하는 거죠.</p>
<pre><code class="language-tsx">// types/domain/user.ts
export interface User {
  id: string;
  fullName: string;
  email: string;
  phone: string | null;
}
</code></pre>
<p>그다음 Repository에 formatter를 추가하고요.</p>
<pre><code class="language-tsx">// repositories/user/formatters.ts
import type { UserDTO } from &#39;@/types/dto&#39;;
import type { User } from &#39;@/types/domain&#39;;

export function toUser(dto: UserDTO): User {
  return {
    id: dto.id,
    fullName: `${dto.firstName} ${dto.lastName}`,
    email: dto.contactInfo?.email ?? &#39;&#39;,
    phone: dto.contactInfo?.phone ?? null,
  };
}
</code></pre>
<p>기존 코드를 점진적으로 마이그레이션하면 됩니다.</p>
<pre><code class="language-tsx">// Before
const { data: user } = useQuery({
  queryKey: [&#39;user&#39;],
  queryFn: fetchUser,
});
// user는 UserDTO 타입

// After
const { data: user } = useQuery({
  queryKey: [&#39;user&#39;],
  queryFn: fetchUser,
  select: toUser, // ✅ formatter 추가
});
// user는 User 타입
</code></pre>
<p>타입이 달라지면서 컴포넌트에서 타입 에러가 날 텐데, 그걸 하나씩 수정하면 돼요. 이게 바로 <strong>컴파일 타임에 문제를 발견</strong>하는 거죠.</p>
<h2 id="마지막으로">마지막으로</h2>
<p>&quot;당신의 프로젝트는 몇 개의 API를 사용하고 있고, 백엔드 개발자와 얼마나 긴밀하게 소통하고 있나요?&quot;</p>
<p>이 질문에 대한 답이 결국 <strong>DTO를 직접 쓸지, 변환 레이어를 둘지</strong> 결정하는 기준이 될 것 같아요.</p>
<p>제가 이 글을 쓰게 된 이유는, 아마 저뿐만 아니라 많은 프론트엔드 개발자분들이 비슷한 고민을 하고 계실 것 같아서입니다. 모든 상황에 완벽한 답은 없지만, 각자의 프로젝트 상황에 맞는 선택을 하는 데 이 글이 조금이라도 도움이 되었으면 좋겠습니다.</p>
<p><a href="https://tkdodo.eu/blog/">TkDodo의 블로그</a>도 꼭 한번 읽어보세요. 정말 좋은 인사이트가 많더라고요.</p>
<p>궁금하신 점이나 다른 의견이 있으시면 언제든 댓글로 남겨주세요!</p>
<p>감사합니다.</p>
<hr>
<h2 id="참고자료">참고자료</h2>
<p>이 글을 작성하는 데 참고한 자료들입니다.</p>
<p><strong>React Query &amp; 데이터 변환</strong></p>
<ul>
<li><a href="https://tkdodo.eu/blog/react-query-data-transformations">React Query Data Transformations</a> - TkDodo</li>
<li><a href="https://profy.dev/article/react-architecture-api-layer">React Architecture: Server-Client State Separation</a> - Profy.dev</li>
</ul>
<p><strong>모바일 앱 API 버전 관리</strong></p>
<ul>
<li><a href="https://medium.com/@emadrazo/api-changes-that-wont-break-your-mobile-app-best-practices-for-cross-team-development-94d8e5761d35">API Changes That Won&#39;t Break Your Mobile App</a> - Eva Madrazo</li>
<li><a href="https://medium.com/webcom-engineering-and-product/5-things-to-remember-before-shipping-version-1-0-of-your-mobile-app-5a1676f0fdbf">5 Things to Remember Before Shipping Version 1.0 of your Mobile App</a> - Martin Rybak</li>
</ul>
<p><strong>커뮤니티 논의</strong></p>
<ul>
<li><a href="https://github.com/toss/frontend-fundamentals/discussions">토스 frontend-fundamentals 디스커션</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[(React) React Query는 훌륭하지만, 인증 관리 매니저는 아닙니다]]></title>
            <link>https://velog.io/@yunsungyang-omc/React-React-Query%EB%8A%94-%ED%9B%8C%EB%A5%AD%ED%95%98%EC%A7%80%EB%A7%8C-%EC%9D%B8%EC%A6%9D-%EA%B4%80%EB%A6%AC%EB%8A%94-%EC%95%84%EB%8B%99%EB%8B%88%EB%8B%A4</link>
            <guid>https://velog.io/@yunsungyang-omc/React-React-Query%EB%8A%94-%ED%9B%8C%EB%A5%AD%ED%95%98%EC%A7%80%EB%A7%8C-%EC%9D%B8%EC%A6%9D-%EA%B4%80%EB%A6%AC%EB%8A%94-%EC%95%84%EB%8B%99%EB%8B%88%EB%8B%A4</guid>
            <pubDate>Sat, 18 Oct 2025 06:40:37 GMT</pubDate>
            <description><![CDATA[<p>안녕하세요. 정말 오랜만에 글을 쓰는 것 같네요. 마지막 포스팅 이후로 거의 1년 반 만입니다.</p>
<p>그동안 새로운 프로젝트에 참여하면서 다양한 경험을 쌓았는데요. 오늘은 그중에서도 React Query를 사용한 인증 처리에 대해 이야기해보려고 합니다.</p>
<p>최근 프로젝트 코드 리뷰를 진행하면서 흥미로운 패턴을 발견했는데요. React Query의 useQuery 훅을 사용해서 사용자 인증 상태를 관리하는 코드였습니다. 얼핏 보면 괜찮아 보이지만, 자세히 들여다보니 몇 가지 문제가 있더라고요.</p>
<p>이 글에서는 왜 useQuery가 인증 처리에 적합하지 않은지, 그리고 어떻게 개선할 수 있는지 제 경험을 공유해보려고 합니다.</p>
<h2 id="문제의-발단-이런-코드를-본-적-있으신가요">문제의 발단: 이런 코드를 본 적 있으신가요?</h2>
<pre><code class="language-typescript">export function useAuth() {
  const hasToken = !!tokenStorage.getAccess(); // 동기: 즉시 확인

  const { data: user, isLoading } = useGetMyInfo({
    enabled: hasToken, // 비동기: API 호출 대기
  });

  return { user, isLoading };
}</code></pre>
<p>코드 리뷰를 진행했던 프로젝트에서는 이렇게 useQuery로 사용자 정보를 가져와서 인증 여부를 확인하고 있었어요. 처음에는 &quot;React Query를 잘 활용하고 있네?&quot;라고 생각했는데, 실제 동작을 살펴보니 문제가 보이기 시작했습니다.</p>
<h2 id="인증과-사용자-정보-같은-건가요">인증과 사용자 정보, 같은 건가요?</h2>
<p>이 문제를 이해하려면 먼저 두 가지 개념을 구분해야 합니다.</p>
<p><strong>인증 확인이란?</strong></p>
<p>&quot;사용자가 로그인했는가?&quot;를 확인하는 것입니다. 이건 사실 아주 간단해요. localStorage나 Cookie에 저장된 토큰이 있는지만 확인하면 되거든요. 즉시 확인 가능하고, 서버에 물어볼 필요가 없습니다.</p>
<p><strong>사용자 정보 조회란?</strong></p>
<p>&quot;사용자의 프로필 데이터는 뭐지?&quot;를 확인하는 것입니다. 이건 서버에서 가져와야 하죠. 닉네임, 프로필 이미지, 이메일 같은 정보들이요. 당연히 API 호출이 필요하고 시간이 걸립니다.</p>
<p>프로젝트를 분석해보니 재미있는 사실을 발견했는데요.</p>
<p><strong>인증 확인이 필요한 곳</strong>은 3군데였습니다.</p>
<ul>
<li>보호된 페이지에 접근할 때 (AuthGuardProvider)</li>
<li>헤더에서 로그인/로그아웃 버튼을 보여줄 때</li>
<li>모임 참가 버튼을 활성화할 때</li>
</ul>
<p><strong>사용자 정보가 필요한 곳</strong>은 2군데였습니다.</p>
<ul>
<li>헤더에 프로필 이미지와 닉네임을 보여줄 때</li>
<li>마이페이지에서 전체 프로필을 보여줄 때</li>
</ul>
<p>보이시나요? 인증 확인은 필요한데 사용자 정보는 필요 없는 곳이 더 많다는 걸요.</p>
<h2 id="usequery로-인증을-처리하면-생기는-문제들">useQuery로 인증을 처리하면 생기는 문제들</h2>
<h3 id="1-불필요한-네트워크-요청">1. 불필요한 네트워크 요청</h3>
<p>사용자가 <code>/dashboard</code> 페이지에 접근하는 상황을 생각해볼까요?</p>
<pre><code>1. AuthGuardProvider가 마운트됩니다
2. tokenStorage.getAccess()로 토큰 확인 (0ms, 즉시!)
3. useGetMyInfo() API 요청 시작
4. 네트워크 왕복하며 대기... (200-300ms)
5. 서버에서 사용자 정보 받아옴
6. 받은 정보로 인증 여부 확인</code></pre><p>토큰이 이미 localStorage에 있는데, 굳이 서버에 물어봐야 할까요?</p>
<h3 id="2-깜빡이는-화면">2. 깜빡이는 화면</h3>
<p>실제로 AuthGuardProvider 코드를 보면 이렇게 되어 있었습니다.</p>
<pre><code class="language-typescript">export default function AuthGuardProvider({ children }) {
  const { user, isLoading } = useAuth();

  useEffect(() =&gt; {
    if (isLoading || !isProtectedRoute(pathname)) return;

    if (!user) {
      router.replace(`${PATH.SIGNIN}?redirect=${pathname}`);
    }
  }, [pathname, isLoading, user]);

  return &lt;&gt;{children}&lt;/&gt;;
}</code></pre>
<p>여기서 <code>isLoading</code>이 true인 동안에는 가드 체크를 하지 않습니다. 그 말은 즉, API 응답을 기다리는 200-300ms 동안 사용자는 빈 화면이나 로딩 스피너를 보게 된다는 거죠.</p>
<p>토큰 확인은 즉시 가능한데 말이에요.</p>
<h3 id="3-react-query의-본래-목적과-맞지-않습니다">3. React Query의 본래 목적과 맞지 않습니다</h3>
<p>React Query의 메인테이너인 TkDodo의 블로그를 보면 이런 말이 나옵니다.</p>
<blockquote>
<p>&quot;React Query is designed for asynchronous server state management.&quot;</p>
</blockquote>
<p>React Query는 서버 상태를 관리하기 위한 도구입니다. 그런데 인증 상태는 어떤가요?</p>
<ul>
<li>토큰은 이미 클라이언트에 있습니다 (localStorage/Cookie)</li>
<li>동기적으로 즉시 확인 가능합니다</li>
<li>서버에 물어볼 필요가 없습니다</li>
</ul>
<p>useQuery의 강력한 기능들(자동 리페칭, 캐싱, 리트라이 등)이 이 경우에는 오히려 복잡도만 높이는 거죠.</p>
<h3 id="4-인증-상태가-여기저기-흩어집니다">4. 인증 상태가 여기저기 흩어집니다</h3>
<p>useQuery를 사용하면 각 컴포넌트에서 독립적으로 인증을 확인하게 되는데요. 이게 문제가 됩니다.</p>
<pre><code class="language-typescript">// Header.tsx
function Header() {
  const { data: user } = useGetMyInfo({ enabled: !!token });
  // ...
}

// AuthGuard.tsx
function AuthGuard() {
  const { data: user } = useGetMyInfo({ enabled: !!token });
  // ...
}

// MyProfileButton.tsx
function MyProfileButton() {
  const { data: user } = useGetMyInfo({ enabled: !!token });
  // ...
}</code></pre>
<p>React Query가 queryKey로 캐싱을 해주긴 하지만, 각 컴포넌트가 마운트될 때마다 &quot;내가 인증 상태를 확인해야겠다&quot;고 생각하게 됩니다. 인증 로직이 컴포넌트 곳곳에 파편화되는 거죠.</p>
<p>이렇게 되면 몇 가지 문제가 생깁니다.</p>
<p><strong>로그아웃 처리가 복잡해집니다</strong></p>
<p>로그아웃을 하면 어떻게 해야 할까요? 모든 컴포넌트의 useQuery를 invalidate해야 합니다.</p>
<pre><code class="language-typescript">const logout = () =&gt; {
  tokenStorage.clear();
  queryClient.invalidateQueries([&#39;user&#39;]);
  // 혹시 빠뜨린 쿼리는 없을까?
}</code></pre>
<p><strong>동기화 이슈가 발생할 수 있습니다</strong></p>
<p>각 컴포넌트가 독립적으로 API를 호출하면, 타이밍에 따라 다른 결과를 받을 수 있습니다. 토큰이 만료되는 순간에 한 컴포넌트는 성공하고 다른 컴포넌트는 실패할 수도 있죠.</p>
<p><strong>&quot;진짜 인증 상태&quot;가 어디 있는지 불명확합니다</strong></p>
<p>인증 상태의 Single Source of Truth가 없어집니다. Header의 user가 진짜일까요? AuthGuard의 user가 진짜일까요? React Query 캐시가 진짜일까요?</p>
<pre><code class="language-typescript">// 인증 상태를 확인하려면 어디를 봐야 할까요?
const user1 = queryClient.getQueryData([&#39;user&#39;]); // React Query 캐시
const user2 = useGetMyInfo(); // 컴포넌트 A
const user3 = useGetMyInfo(); // 컴포넌트 B
// 진실은 어디에?</code></pre>
<p>이런 상황에서 디버깅을 해본 적 있으신가요? 한 컴포넌트에서는 로그인 상태인데 다른 컴포넌트에서는 로그아웃 상태로 보이는 버그요. 정말 찾기 어렵습니다.</p>
<h3 id="5-react-query-기능을-끄게-만듭니다">5. React Query 기능을 끄게 만듭니다</h3>
<p>실제로 이 문제를 겪은 개발자들은 결국 이런 코드를 작성하게 됩니다.</p>
<pre><code class="language-typescript">const { data: user } = useQuery([&#39;user&#39;], getUser, {
  staleTime: Infinity,
  cacheTime: Infinity,
  refetchOnMount: false,
  refetchOnWindowFocus: false,
  refetchOnReconnect: false,
});</code></pre>
<p>React Query의 모든 기능을 꺼버리는 거죠. 그럼 이제 질문이 생깁니다.</p>
<p>&quot;이 모든 기능을 꺼야 한다면, 왜 useQuery를 사용하나요?&quot;</p>
<h2 id="개발자들도-같은-고민을-했습니다">개발자들도 같은 고민을 했습니다</h2>
<p>제가 이 문제를 발견하고 리서치를 해보니, 많은 개발자들이 비슷한 고민을 하고 있더라고요.</p>
<p>TanStack Query의 GitHub Discussion에서 누군가 이런 질문을 했습니다.</p>
<blockquote>
<p>&quot;useQuery(&#39;users&#39;, getUser)를 여러 곳에서 사용해도 될까요? 아니면 한 번만 사용하고 다른 곳에서는 queryClient.getQueryData를 써야 할까요?&quot;</p>
</blockquote>
<p>그리고 Codemzy라는 개발자는 블로그에서 이렇게 말했습니다.</p>
<blockquote>
<p>&quot;사용자 데이터를 다시 요청할 이유가 없어요. 모든 API 호출에서 인증을 확인하니까요. 인증이 만료되면 API 응답에서 에러가 나거든요.&quot;</p>
</blockquote>
<p>결국 많은 개발자들이 <code>staleTime: Infinity</code>로 설정하고 React Query의 장점을 포기하게 되더라고요.</p>
<p>Stack Overflow에도 비슷한 질문들이 많습니다.</p>
<ul>
<li>&quot;React Query로 인증 상태를 어떻게 관리하나요?&quot;</li>
<li>&quot;isLoading 때문에 인증 가드 리다이렉트가 느려요&quot;</li>
<li>&quot;React Query가 사용자 데이터를 계속 리페칭해요&quot;</li>
</ul>
<p>이런 질문들 자체가 도구를 잘못된 용도로 사용하고 있다는 신호라고 생각합니다.</p>
<h2 id="그럼-어떻게-해야-할까요">그럼 어떻게 해야 할까요?</h2>
<p>커뮤니티에서 권장하는 방법은 관심사를 분리하는 것입니다. Zustand나 Context 같은 클라이언트 상태 관리 도구와 React Query를 함께 사용하는 거죠.</p>
<h3 id="zustand로-인증-상태-관리하기">Zustand로 인증 상태 관리하기</h3>
<pre><code class="language-typescript">import { create } from &quot;zustand&quot;;
import { tokenStorage } from &quot;@/lib/utils/token&quot;;

interface AuthStore {
  isAuthenticated: boolean;
  login: (token: string) =&gt; void;
  logout: () =&gt; void;
  validateToken: () =&gt; void;
}

export const useAuthStore = create&lt;AuthStore&gt;((set, get) =&gt; ({
  // 초기값: 토큰 존재 여부로 즉시 판단
  isAuthenticated: tokenStorage.hasValidAccess(),

  login: (token) =&gt; {
    tokenStorage.set(token);
    set({ isAuthenticated: true });
  },

  logout: () =&gt; {
    tokenStorage.clear();
    set({ isAuthenticated: false });
  },

  validateToken: () =&gt; {
    if (!tokenStorage.hasValidAccess()) {
      get().logout();
    }
  }
}));</code></pre>
<p>이렇게 하면 인증 상태를 즉시 확인할 수 있습니다.</p>
<h3 id="react-query는-사용자-정보가-필요할-때만">React Query는 사용자 정보가 필요할 때만</h3>
<pre><code class="language-typescript">export function useUserProfile() {
  const isAuthenticated = useAuthStore((s) =&gt; s.isAuthenticated);

  return useQuery({
    queryKey: [&#39;user&#39;, &#39;profile&#39;],
    queryFn: getUserProfile,
    enabled: isAuthenticated, // 인증된 경우에만
    staleTime: 5 * 60 * 1000, // 5분
    refetchOnWindowFocus: true, // 이제 이 기능들을 제대로 활용!
  });
}</code></pre>
<p>이제 React Query를 본래 목적대로 사용할 수 있습니다.</p>
<h3 id="authguard는-즉시-동작합니다">AuthGuard는 즉시 동작합니다</h3>
<pre><code class="language-typescript">export default function AuthGuardProvider({ children }) {
  const pathname = usePathname();
  const router = useRouter();
  const { isAuthenticated, validateToken } = useAuth();

  useEffect(() =&gt; {
    validateToken(); // 즉시 실행!

    if (!isProtectedRoute(pathname)) return;

    if (!isAuthenticated) { // isLoading 없음!
      router.replace(`${PATH.SIGNIN}?redirect=${pathname}`);
    }
  }, [pathname, isAuthenticated]);

  return &lt;&gt;{children}&lt;/&gt;;
}</code></pre>
<p>isLoading을 기다릴 필요가 없습니다. 인증 확인은 즉시 끝나니까요.</p>
<h2 id="성능-개선-효과">성능 개선 효과</h2>
<p>실제로 측정해보면 이 정도 차이가 납니다.</p>
<p><strong>기존 방식 (useQuery)</strong></p>
<ul>
<li>인증 확인: ~250ms (API 응답 대기)</li>
<li>초기 렌더링: isLoading 동안 대기</li>
<li>페이지 전환: 매번 잠재적 API 요청</li>
</ul>
<p><strong>개선 방식 (Zustand + React Query)</strong></p>
<ul>
<li>인증 확인: 0ms (즉시!)</li>
<li>초기 렌더링: 대기 없음</li>
<li>페이지 전환: 토큰 확인만</li>
</ul>
<p>250ms가 별거 아닌 것 같지만, 사용자 경험에서는 체감이 큽니다. 특히 페이지를 여러 번 이동할 때는 더욱 그렇죠.</p>
<h2 id="실제-프로젝트에-적용하기">실제 프로젝트에 적용하기</h2>
<p>제가 리뷰했던 프로젝트에 이 방식을 제안했을 때, 팀원들의 반응이 좋았습니다. 마이그레이션도 생각보다 간단했고요.</p>
<p><strong>1단계: Auth Store 만들기</strong></p>
<pre><code class="language-typescript">// src/store/authStore.ts
export const useAuthStore = create&lt;AuthStore&gt;((set) =&gt; ({
  isAuthenticated: tokenStorage.hasValidAccess(),
  login: (token) =&gt; {
    tokenStorage.set(token);
    set({ isAuthenticated: true });
  },
  logout: () =&gt; {
    tokenStorage.clear();
    set({ isAuthenticated: false });
  }
}));</code></pre>
<p><strong>2단계: useAuth 훅 수정하기</strong></p>
<pre><code class="language-typescript">// src/hooks/auth/useAuth.ts
export function useAuth() {
  const isAuthenticated = useAuthStore((s) =&gt; s.isAuthenticated);
  const logout = useAuthStore((s) =&gt; s.logout);

  return {
    isAuthenticated, // 즉시 사용 가능!
    logout
  };
}</code></pre>
<p><strong>3단계: 컴포넌트 업데이트하기</strong></p>
<pre><code class="language-typescript">// Before
const { user, isLoading } = useAuth();
if (isLoading) return &lt;Loading /&gt;;

// After
const { isAuthenticated } = useAuth();
// isLoading 없음! 즉시 사용 가능</code></pre>
<p>기존 코드를 크게 건드리지 않아도 되고, 점진적으로 마이그레이션할 수 있어서 좋았습니다.</p>
<h2 id="커뮤니티에서도-인정받은-패턴입니다">커뮤니티에서도 인정받은 패턴입니다</h2>
<p>Medium에서 여러 개발자들이 Zustand와 React Query를 조합한 사례를 공유하고 있습니다.</p>
<blockquote>
<p>&quot;Separation of Concerns: React Query는 서버 상태 관리에 탁월합니다. Zustand는 클라이언트 상태 관리에 적합하고요. 이 두 도구를 함께 사용하면 비즈니스 로직과 UI 상태를 서버 데이터와 명확히 분리할 수 있습니다.&quot;</p>
</blockquote>
<p>Doichev Kostia라는 개발자는 블로그에서 이렇게 말했습니다.</p>
<blockquote>
<p>&quot;제가 찾은 이상적인 해결책은 영구 저장소(쿠키)와 앱 내부 저장소를 함께 사용하는 것이었습니다. TypeScript와 React 컴포넌트 모두에서 구독할 수 있도록요. Zustand가 완벽한 선택이었습니다.&quot;</p>
</blockquote>
<p>실제로 많은 프로덕션 환경에서 이런 패턴을 사용하고 있더라고요.</p>
<ol>
<li>Zustand: 토큰 저장, 인증 상태 관리</li>
<li>React Query: 사용자 프로필, API 데이터 페칭  </li>
<li>Axios Interceptor: 토큰 자동 주입</li>
</ol>
<h2 id="마치며">마치며</h2>
<p>제가 이 글을 쓰게 된 이유는, 아마 저뿐만 아니라 많은 프론트엔드 개발자분들이 비슷한 고민을 하고 계실 것 같아서입니다.</p>
<p>React Query는 정말 훌륭한 도구입니다. 하지만 모든 상황에 완벽한 도구는 없죠. 중요한 건 각 도구의 목적을 이해하고 적재적소에 사용하는 것이라고 생각합니다.</p>
<h3 id="이-글의-핵심-요약">이 글의 핵심 요약</h3>
<p><strong>🔑 인증(로그인 여부) ≠ 사용자 정보(프로필 데이터)</strong></p>
<ul>
<li>인증 상태: 클라이언트 상태 → Zustand나 Context로 관리</li>
<li>사용자 정보: 서버 상태 → React Query로 관리</li>
<li>토큰: 이미 로컬에 있음 → 즉시 확인 가능</li>
</ul>
<p><strong>⚡ 성능과 사용자 경험 개선</strong></p>
<ul>
<li>인증 확인: API 응답 대기(250ms) → 즉시 확인(0ms)</li>
<li>불필요한 네트워크 요청 제거</li>
<li>페이지 전환 시 화면 깜빡임 없음</li>
</ul>
<p><strong>🛠️ 권장하는 접근 방식</strong></p>
<ol>
<li>Zustand로 인증 상태 관리 (isAuthenticated)</li>
<li>React Query는 사용자 정보가 필요할 때만 사용</li>
<li>각 도구를 본래 목적에 맞게 활용</li>
</ol>
<h3 id="제-의견이-정답은-아닙니다">제 의견이 정답은 아닙니다</h3>
<p>이 글에서 제시한 방법은 제가 프로젝트를 경험하면서 찾은 하나의 접근 방식입니다. 절대적인 정답이라고 할 수는 없어요. </p>
<p>프로젝트의 규모, 팀의 상황, 기술 스택에 따라 더 나은 방법이 있을 수 있습니다. 예를 들어:</p>
<ul>
<li>작은 프로젝트에서는 Context API만으로도 충분할 수 있습니다</li>
<li>이미 Redux를 사용 중이라면 Redux로 인증을 관리하는 게 나을 수도 있죠</li>
<li>SSR이 중요한 프로젝트라면 또 다른 고려사항이 있을 겁니다</li>
</ul>
<p>중요한 건 &quot;왜 이렇게 하는가?&quot;를 이해하는 것이라고 생각합니다. 제 글이 여러분의 프로젝트에서 더 나은 결정을 내리는 데 하나의 참고 자료가 되었으면 좋겠습니다.</p>
<p>마지막으로 한 가지 질문을 던지고 싶습니다.</p>
<p>&quot;토큰이 이미 로컬에 있는데, 서버에 사용자 정보를 요청해야만 인증 여부를 알 수 있나요?&quot;</p>
<p>제 답은 아니오입니다. 인증 상태는 즉시 확인할 수 있고, 사용자 정보는 필요할 때만 가져오면 됩니다. 하지만 여러분의 답은 다를 수 있고, 그것도 괜찮습니다.</p>
<p>이 글이 비슷한 고민을 하고 계신 분들께 도움이 되었으면 좋겠습니다. 궁금하신 점이나 다른 의견이 있으시면 언제든 댓글로 남겨주세요. 여러분의 경험과 생각도 듣고 싶습니다.</p>
<p>감사합니다.</p>
<h2 id="참고-자료">참고 자료</h2>
<ul>
<li><a href="https://tkdodo.eu/blog/react-query-as-a-state-manager">TkDodo&#39;s Blog - React Query as a State Manager</a></li>
<li><a href="https://github.com/TanStack/query/discussions/1547">TanStack Query Discussion - Best practice handling auth state</a></li>
<li><a href="https://www.codemzy.com/blog/react-auth-context-vs-react-query">Codemzy - React auth: context vs React Query</a></li>
<li><a href="https://medium.com/@freeyeon96/zustand-react-query-new-state-management-7aad6090af56">Medium - Zustand + React Query: A New Approach to State Management</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[(React) 그로스 해킹 프론트엔드 : GTM을 사용해 마케팅 지표 로깅하기]]></title>
            <link>https://velog.io/@yunsungyang-omc/React-%EA%B7%B8%EB%A1%9C%EC%8A%A4-%ED%95%B4%ED%82%B9-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C</link>
            <guid>https://velog.io/@yunsungyang-omc/React-%EA%B7%B8%EB%A1%9C%EC%8A%A4-%ED%95%B4%ED%82%B9-%ED%94%84%EB%A1%A0%ED%8A%B8%EC%97%94%EB%93%9C</guid>
            <pubDate>Sat, 31 Aug 2024 09:02:38 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/7fad406c-1baa-4bf6-ae8d-0105f9d266bf/image.webp" alt=""></p>
<p>안녕하세요. 오늘은 그로스 해킹을 만난 프론트엔드라는 주제에 대해서 포스팅을 다뤄볼까 합니다. </p>
<p>올해 3월부터는 메인 스크럼 별도로 서브 스크럼으로 <strong>퍼널 스크럼</strong>에 참여하고 있는데요.
ios, android, 웹 프론트엔드 개발자들이 모여 각 클라이언트에 존재하는 커스텀 로깅 이벤트를 정리하고, 구글 애널리틱스에서 정의하는 추천 이벤트를 제대로 적용하는 업무를 하고 있어요. </p>
<hr>
<h2 id="퍼널-스크럼과-마구잡이로-추가된-커스텀-로깅-이벤트">퍼널 스크럼과 마구잡이로 추가된 커스텀 로깅 이벤트</h2>
<p>스크럼이 시작된 3월에는 웹 프론트엔드에서 관리하는 코드 베이스에는 정말 다양하고 많은 로깅 이벤트 함수들이 존재했는데요. </p>
<p>프론트엔드 개발환경이 모노레포로 전환되면서, 미쳐 공통 패키지로 모듈화되지 못한 코드들과 각 스크럼 별로 단순 클릭 수치를 기록하기 위해 스프린트 때마다 추가된 무수히 많은 커스텀 로깅 이벤트들로 코드 베이스가 아주 지저분해진 상황이었어요. </p>
<p>그래서 퍼널 스크럼 작업을 진행하면서, 문서가 안내하는 방식을 따라 유입, 탐색, 결정, 구매 등 이커머스에서 보이는 유저의 행동을 정확히 측정하고자 업무가 시작되었습니다.</p>
<hr>
<h2 id="google-tag-manager를-이용해서-로깅-이벤트-관리하기">Google Tag Manager를 이용해서 로깅 이벤트 관리하기</h2>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/ba7c33b5-7b85-4cbc-9ea0-0f5244b8cc5f/image.png" alt=""></p>
<p>프론트엔드 개발자로 팀에 합류하게 되면 어떤 조직이든 마케팅 툴을 연동하는 작업을 요청받은 적이  있을 거에요. 현재 아몬즈에서는 amplitude, GA4, Braze 등 다양한 마케팅 툴을 연동해 고객의 인터렉션을 수치화해 살펴보고 있습니다. </p>
<p>그런데, 각 마케팅 툴 별로 로깅 이벤트를 추가하고 코드를 별도로 관리하면 어떻게 될까요?</p>
<p>분명 코드는 굉장히 지저분해질 것이고 반 년 정도만 지나도 관심도가 떨어진 로깅 이벤트들은 코드 베이스 이 곳 저곳에 산개해서 방치될 것입니다. </p>
<p>이를 방지하기 위해서 아몬즈 프론트엔드 팀은 Google Tag Manager를 다양한 로깅 툴들의 중간 허브로 사용하고 있어요. </p>
<p>아주 간단합니다. </p>
<h3 id="구글-태그-매니저를-사용해-ga4-써드파티-연동하기">구글 태그 매니저를 사용해 GA4, 써드파티 연동하기</h3>
<p>1) 구글 태그 매니저에 이벤트 명과 이벤트 프로퍼티를 전송할 함수를 정의해 핸들러에 연동하고</p>
<pre><code class="language-ts">// react를 사용한다면 아주 간단하고 손쉽게 아래 패키지로 태그매니저를 연동할 수 있습니다.
import TagManager from &quot;react-gtm-module&quot;;

export const addToCart = (data: GTMAddToCartEventType): void =&gt; {
  const { price, products} = data;

  TagManager.dataLayer({
    dataLayer: {
      event: GTM_EVENT.ADD_TO_CART,
      currency: &quot;KRW&quot;,
      value: price,
      items: products,
    },
  });
};
</code></pre>
<pre><code class="language-ts">Button onClick={() =&gt; addToCart(data)}</code></pre>
<p>2) 구글 태그 매니저에서는 해당 이벤트로 인해 발동될 트리거를 정의합니다. 
<img src="https://velog.velcdn.com/images/yunsungyang-omc/post/5693d69c-84f7-42b7-aa4e-493d37f749b8/image.png" alt=""></p>
<p>3) GA4는 태그 유형에 이미 존재하지만, 그외 써드 파티를 사용해 로깅해야한다면 각 마케팅 툴에 맞게 템플릿을 정의하고, 태그 유형으로 맞춤 설정을 정의합니다.
<a href="https://blog.ab180.co/posts/amplitude-gtm-guide">AB180 Blog|구글 태그 매니저로 엠플리튜드 연동하기</a></p>
<p>4) 이벤트 매개변수에 트리거 발동 시 데이터 레이어로 전송된 프로퍼티를 연동해 해당 툴로 데이터를 전송할 수 있도록 연동해주면 모든 작업이 끝났습니다.
<img src="https://velog.velcdn.com/images/yunsungyang-omc/post/2fb7e5fe-0e8a-4f9d-ab15-45b8d009db86/image.png" alt=""></p>
<p>위와 같이 모든 마케팅 툴들은 로깅을 위한 흐름을 갖습니다. </p>
<p>따라서 각 마케팅 툴의 로깅함수들이 코드베이스에 돌아다니도록 방치하지 말고
트리거를 발동시키기 위한 함수와 트리거가 각각 1개씩만 존재해도 여러가지 이벤트를 연동할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/26c19817-2cee-4dee-aae1-dfe7746587cb/image.png" alt=""></p>
<hr>
<h3 id="이커머스에서-유저들의-행동-지표를-트래킹하기">이커머스에서 유저들의 행동 지표를 트래킹하기</h3>
<p>제가 참여하고 있는 퍼널 스크럼에서는 다양한 툴 중 글로벌 스탠다드인 GA4를 기준으로 데이터를 쌓고 있는데요. 이 중 GA4에서 추천 정의한 여러 이벤트 중 <strong>전자상거래 이벤트</strong>를 중심으로 유저들의 데이터를 기록하고 있습니다. </p>
<p>[GA4 공식문서 보기]
(<a href="https://developers.google.com/analytics/devguides/collection/ga4/ecommerce?hl=en&amp;client_type=gtag">https://developers.google.com/analytics/devguides/collection/ga4/ecommerce?hl=en&amp;client_type=gtag</a>)
<a href="https://developers.google.com/analytics/devguides/collection/ga4/reference/events?client_type=gtag#view_item_list">GA4 전자상거래 이벤트 레퍼런스 소개</a></p>
<p>수 많은 이벤트 중 몇가지 이벤트를 소개드려보겠습니다.</p>
<h3 id="view_item_list">view_item_list</h3>
<p>view_item_llist 이벤트는 문맥상과 같이 조회하고 있는 목록을 기록하기 위해 사용합니다. 
이커머스 사이트에는 여러가지 목록이 존재할 수 있는데요.
상품 목록, 리뷰 목록, 구매 목록, 홈에서 전시하고 있는 베너 목록 등 목록화해 상품 정보를 전달하고 있는 거의 모든 정보들을 view_item_list 이벤트들을 통해 트래킹할 수 있습니다. </p>
<h3 id="view_item">view_item</h3>
<p>view_item은 상세 정보를 담는 페이지에서 사용합니다. view_item_list를 통해 목록화된 정보 중 특정 아이템이 조회되었다는 것을 기록하기 위해 사용하는 이벤트 입니다. 배열의 아이템 중 한 한가지를 선택(select_item)해서 상세 페이지로 이동한다면 이 인터렉션을 기록하기 위해 view_item을 사용할 수 있습니다. </p>
<h3 id="select_item">select_item</h3>
<p>select_item은 view_item_list 이벤트를 통해 전달된 목록 정보 중 특정 아이템이 선택되었다는 것을 기록하기 위해 사용되는 이벤트입니다. 여기까지 읽어보셨다면 느낌이 오지 않으시나요?</p>
<p><code>탐색(view_item_list)</code> -&gt; <code>선택(select_item)</code> -&gt; <code>조화(view_item)</code>의 순으로 이벤트가 실행이 되면서 유저의 행동 지표가 기록되고 있는거죠. </p>
<p>이 행동 단계를 좀 더 전문적으로 표현하면 점점 많은 정보에서 좁혀지며 상세한 정보로 유입되었다는 의미로 &quot;깔때기(Funnel) 플로우를 타고 있다.&quot; 라고 표현할 수 있습니다.</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/19e8746c-aa51-4a94-adfe-ab17ed24b673/image.png" alt=""></p>
<hr>
<h2 id="이커머스-funnel-플로우를-통해-약한-고리-찾아내기">이커머스 Funnel 플로우를 통해 약한 고리 찾아내기</h2>
<p><code>탐색(view_item_list)</code> -&gt; <code>선택(select_item)</code> -&gt; <code>조회(view_item)</code> 이후로도 퍼널 플로우를 탐색하기 위해 아래와 같은 다양한 이벤트를 연동하면 퍼널의 종착지인 구매 단계까지 유저의 행동 지표를 살펴볼 수 있습니다. </p>
<p><code>장바구니 넣기(add_to_cart)</code>
<code>장바구니 조회(view_cart)</code>
<code>결제 단계 진입(begin_checkout)</code>
<code>결제 완료(purchase)</code></p>
<p>그리고 GA4 전자상거래 탭과 탐색 보고서를 통해 특정 상품이 얼마나 클릭되었는지 혹은 구매되었는지, 장바구니에서 추가된 이후 이탈은 얼마나 발생하고 있는지를 측정할 수 있죠 </p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/8f6c1d4b-d5b8-4522-961d-e1f8835f6ba5/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/4c3e147d-6760-42f2-87f4-0663edae8e54/image.png" alt=""></p>
<p>위 지표는 블로깅을 위해 샘플링된 데이터지만,
좀 더 세분화해 보고서를 작성해본다면 </p>
<ul>
<li>의도된 상품이 유저들에게 잘 노출되고 클릭되고 있는지</li>
<li>장바구니에서 추가된 상품 숫자와 구매 숫자를 비교해 유저들이 왜 구매를 하지 않는지 </li>
<li>주로 어떤 목록에서 구매가 많이 일어나는지 </li>
</ul>
<p>등의 이커머스 서비스의 강한고리와 약한 고리를 데이터를 통해 추론해 개선 포인트를 마련할 수 있습니다. </p>
<hr>
<h2 id="마치며">마치며</h2>
<p>아마 저 뿐만 아니라, 많은 프론트엔드 개발자께서 생소한 개념의 로깅 이벤트를 연동하기 위해 노력하고 계실 것 같아 이 글을 작성해보았는데요. </p>
<p>다음에는 각 전자상거래의 주요 이벤트를 관찰하기 위해 로깅 함수를 추가하며 경험한 트러블 슈팅에 대해 몇 가지 팁을 작성해보려고 합니다. 이 글이 꼭 도움이 되길 바라며, 궁금하신 부분이 생기신다면 언제든지 댓글로 남겨주세요! 제 경험을 뜸뿍 공유드리겠습니다.</p>
<p>감사합니다.</p>
]]></description>
        </item>
        <item>
            <title><![CDATA[(React) 트러블 슈팅 : antd v4 Form 마이그레이션]]></title>
            <link>https://velog.io/@yunsungyang-omc/React-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-antd-v-4-Form-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98</link>
            <guid>https://velog.io/@yunsungyang-omc/React-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-antd-v-4-Form-%EB%A7%88%EC%9D%B4%EA%B7%B8%EB%A0%88%EC%9D%B4%EC%85%98</guid>
            <pubDate>Sun, 04 Aug 2024 05:05:59 GMT</pubDate>
            <description><![CDATA[<p>아주 오랜만에 작성하는 벨로그 글입니다. 
미디엄으로 옮겨서 글을 작성하기도 하고, 주로 사내에서 발생한 트러블 슈팅은 노션에 작성하고 있어서 그간 글 작성이 뜸했는데요. 다시 심기 일전해서 제가 겪은 트러블 슈팅을 공유해서 다른 분께 도움을 드리려고 합니다. </p>
<p>이번 글은 <strong>사내 개발 환경 모던화</strong>의 일환으로 진행하고 있는 antd 라이브러리 버전업에거 겪었던 트러블 슈팅에 대한 글입니다. </p>
<h2 id="antd-v3에서-v4로-전환하기">ANTD V3에서 V4로 전환하기</h2>
<hr>
<p>아몬즈를 운영하는 비주얼의 프론트엔드 개발팀은 백 오피스 어드민 제품에 <strong>UI 디자인 라이브러러리로 antd를 사용</strong>하고 있습니다. 어드민 기능 개선은 꾸준히 이루어지고 있었지만, 적절한 시점을 놓쳐 상용 안정화 버전보다 무려 2단계나 낮은 V3버전을 사용하고 있는데요. </p>
<p>&quot;빠르고 쉽고 안정적으로&quot; 기능을 제공받기 위해 사용하는 <strong>antd가 현재에 와서는 오히려 최신 버전의 React 패키지로 나아가는데 큰 장애물</strong>이 되고 있었습니다. </p>
<p>올해부터 팀에서는 최신 버전의 리액트로 전환하고 더이상 유지보수되지 않거나, 결함이 있는 패키지를 차차 제거하고 있는데요. 기타 작업이 모두 진행된 지금 마지막 장애물로 antd 마이그레이션만 남겨두고 있습니다. </p>
<p>antd v3에서 v4로 전환하는건 몇 가지 컴포넌트에서 큰 변경점을 갖습니다. </p>
<p>아주 간단하게 요약하자면, </p>
<ul>
<li>테이블 컴포넌트는 헤드리스 컴포넌트 라이브러리인 React Table을 사용하도록 바뀌었구요,</li>
<li>Form 컴포넌트도 HOC(Higher Order Component) 패턴에서 useForm이라는 훅을 사용하도록 바뀌었지요.</li>
<li>그리고 className 선택자도 많이 변경되어, 혹시 reset.css나 글로벌 스타일 시트에서 선택자를 통해 커스텀 스타일을 적용하고 있다면.. 축하합니다. 변경점을 잘 찾아내서 모두 바꾸어주어야 해요.</li>
</ul>
<p>고로, 마이그레이션을 진행하게 된다면 위 3가지 컴포넌트를 변경점이 아주 큰 난관이 됩니다.</p>
<p><a href="https://4x.ant.design/docs/react/migration-v4">antd 마이그레이션 문서보기</a></p>
<h2 id="form-컴포넌트-라이브러리-전환하기">Form 컴포넌트 라이브러리 전환하기</h2>
<p><a href="https://antd-pixel.vercel.app/components/form/v3/">Form 컴포넌트 마이그레이션 가이드</a></p>
<h3 id="1-반드시-최상위-컴포넌트에서-form-컴포넌트로-래핑하기">1) 반드시 최상위 컴포넌트에서 Form 컴포넌트로 래핑하기</h3>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/d5d3f707-471d-4952-92b7-9515314ca1a8/image.png" alt=""></p>
<p>우선 HOC 패턴을 사용해 하위 컴포넌트까지 전달하는 form props를 전달하는 방식이 바뀌었는데요,</p>
<pre><code class="language-js">import { Form } form &#39;antd-v4&#39;;

const { form } = Form.useForm();</code></pre>
<p>위 처럼 훅을 불러내 하위 컴포넌트로 전달할 form 객체를 생성하고 <code>&lt;Form /&gt;</code> 컴포넌트로 래핑된 하위 컴포넌트들은 <code>&lt;Form.Item/&gt;</code> 컴포넌트로 래핑을 해주어야 합니다. </p>
<blockquote>
<p>이전 버전에서는 단지 HOC Form.create로 하위 컴포넌트를 묶기만해도 form 객체에 접근하고, 내부 상태를 이용가능했는데요. 변경된 버전에서는 반드시 최상위 컴포넌트를 Form으로 묶어주어야 form.getFieldsValue 등 인스턴스의 메소드에서 올바른 값을 얻을 수 있습니다. 만일 Form이 제대로 묶여있지 않으면 인스턴스에서 아무것도 조회되지 않기 때문에 동작이 제대로 되지 않습니다.</p>
</blockquote>
<h3 id="2-form-내부-상태를-조회할-때-유의할-점">2) Form 내부 상태를 조회할 때 유의할 점</h3>
<p>특정 폼 페이지의 유효성 검증과 버튼 등에 사용할 변수를 관리하는 커스텀 훅이 작성되어있다고 가정해봅시다. </p>
<pre><code class="language-js">const useCusotmForm = (form) =&gt; {
  const name = form.getFieldsValue({&#39;id&#39;, form});
  const password = form.getFieldValue({&#39;password&#39;,, form});

  const isValidName = !!name.length &amp;&amp; !form.getFieldError(&#39;name&#39;);
  const isValidPassword = !!password.length &amp;&amp; !form.getFieldError(&#39;password&#39;);


     return {
           isValidName,
           isValidPassword,
     }
 }</code></pre>
<p> V3를 사용하는 form이 위 같은 커스텀 훅을 사용해서 특정 필드에 대한 값이 있는지를 체크하고, 필드의 에러가 있는지 검증하는 로직을 갖추고 있었는데요, </p>
<p> V4로 전환한 이후에는 해당 값들을 <code>getFieldsValue</code>나 <code>getFieldValue</code>로 조회하면 <strong>리랜더링을 일으키지 않는 한 조회가 되지 않는 문제가 있었습니다</strong>. 하위 컴포넌트는 <code>&lt;Form.Item/&gt;</code>으로 묶여있고, <code>rules</code>와 <code>name</code> 필드가 모두 제대로 전달되고 있음을 확인해도, 값이 조회되지 않는 문제였는데요. </p>
<p> 만일 onChange나 onBlur 이벤트 발생할때마다 필드의 유효성 검증을 해야한다면 form.getFieldValue 대신 Form.useWatch를 사용해야 합니다. </p>
<p> 더불어 Form.Item으로 래핑한 컴포넌트에 rules를 전달할때도, </p>
<pre><code class="language-js">&lt;Form.Item
  label={label}
  name={name}
  rules={rules}
  validateTrigger={[&#39;onBlur&#39;, &#39;onChange&#39;]} // &lt;- 트리거 조건을 전달해야합니다.
&gt;</code></pre>
<p><code>validateTrigger</code> 옵션을 사용해 트리거 조건을 설정해야 최상단에서 <code>Form</code>의 값의 변화를 관측할 수 있습니다. </p>
<p><code>form.getFieldError</code>의 경우, 비동기적으로 발생하는 값의 변화를 아쉽게도 알아낼 수 있는 방법이 없는데요, 훅에서 해당 값에 의존하는 로직이 있다면, 이를 제거하고 <code>form.submit</code>시 발생하는 함수 내부에서 <code>form.valiadateFileds</code> 메서드를 통해 폼의 유효성 검증이 모두 완료되었음을 검증해야 합니다.</p>
<blockquote>
<p>더 이상 커스텀 훅 내부에서 특정 필드를 관측하거나 해당 필드의 에러를 알아내려고 시도하지 마세요! 에러는 Input이나 Select를 래핑하고 있는 Form.Items 컴포넌트에 전달한 rules로 유효성 검증을 하고, 최종적인 폼의 검증은 onFinish 함수에서 이뤄지게 해야 합니다.</p>
</blockquote>
<h3 id="3-onfinish-함수와-onfinishfailed-함수의-분리">3) onFinish 함수와 onFinishFailed 함수의 분리</h3>
<p>v3 버전에서는 <code>onFinish</code> 함수 내부에서 <code>try ... catch</code> 블록을 구성하거나 <code>then</code> 체이닝을 통해 에러를 핸들링 했는데요</p>
<pre><code class="language-js">// antd v3
const Demo = ({ form: { getFieldDecorator, validateFields } }) =&gt; {
  const onSubmit = e =&gt; {
    e.preventDefault();
    validateFields((err, values) =&gt; {
      if (!err) {
        console.log(&#39;Received values of form: &#39;, values);
      }
    });
  };

  return (
    &lt;Form onSubmit={onSubmit}&gt;
      &lt;Form.Item&gt;
        {getFieldDecorator(&#39;username&#39;, {
          rules: [{ required: true }],
        })(&lt;Input /&gt;)}
      &lt;/Form.Item&gt;
    &lt;/Form&gt;
  );
};

const WrappedDemo = Form.create()(Demo);</code></pre>
<p>V4 버전에서는 <code>onFinish</code> 함수와 <code>onFinishFailed</code> 함수가 분리되었습니다. </p>
<pre><code class="language-js">const Demo = () =&gt; {
  const onFinish = values =&gt; {
    console.log(&#39;Received values of form: &#39;, values);
  };

  const onFinishFailed = ({ errorFields }) =&gt; {
      form.scrollToField(errorFields[0].name);
  };

  return (
    &lt;Form onFinish={onFinish} onFinishFailed={onFinishFailed}&gt;
      &lt;Form.Item name=&quot;username&quot; rules={[{ required: true }]}&gt;
        &lt;Input /&gt;
      &lt;/Form.Item&gt;
    &lt;/Form&gt;
  );
};</code></pre>
<pre><code class="language-js">// antd v3
validateFields((err, value) =&gt; {
  if (!err) {
    // Do something with value
  }
});
To

// antd v4
validateFields().then(values =&gt; {
  // Do something with value
});</code></pre>
<p>v3에서는 <code>onSumit</code> 함수 내부에서 각 폼의 유효성을 검증하고, 콜백함수를 통해 에러핸들링을 했다면, 지금은 성공처리와 실패처리 역할을 하는 각 함수를 통해 보다 유연하게 코드를 작성할 수 있습니다. (가독성도 챙기구요)  </p>
<h3 id="4-rules에-validator를-전달한다면-이-함수는-반드시-promise를-리턴해야-합니다">4) rules에 validator를 전달한다면, 이 함수는 반드시 Promise를 리턴해야 합니다.</h3>
<p>더불어, 만약 <code>&lt;Form.Item /&gt;</code>에 전달하는 <code>rules</code>에 <code>validator</code>함수를 포함해 전달하고 있다면, 이 <code>validator</code>는 반드시 <code>Promise</code>를 반환하는 함수로 작성이 되어 있어야 합니다.</p>
<pre><code class="language-js">rules={
  [formRequiredRules(true, INVALID_REQUIRED_PASSWORD_MESSAGE, false),
  { validator: validateToConfirmNewPassword },
  { pattern: new RegExp(REX_PASSWORD), message: INVALID_MINIMUM_EIGHT_WORD_ENG_NUM_MESSAGE }]
}</code></pre>
<p>이 부분이 아주 절 골탕먹인 부분이었는데요, 
v3를 기준으로 작성된 코드는 프로미스를 리턴하도록 작성하지 않았어도 큰 문제가 없었겠지만,
v4에서는 전달된 <code>validator</code>가 프로미스를 리턴하지 않는다면 폼의 유효성 검증이 어디선가 블락이 되버리고,
<code>onFinish</code> 함수가 콜 되지 않습니다. 대신 <code>onFinishFailed</code>에서는 아무런 에러가 발생하지 않아 errorFields가 빈 배열로 관측됨에도 계속 이 함수가 콜되죠. </p>
<blockquote>
<p>개인적으로 아주 불편한 점이라고 생각합니다. 디버그도 어렵고요. 다시 강조해서 만일 validator로 전달하는 함수가 프로미스를 리턴하지 않도록 작성되어 있다면 아래의 예시처럼 바꾸어주세요</p>
</blockquote>
<pre><code class="language-js">const validateToConfirmNewPassword = (_, value) =&gt; {
    if (value) {
      return Promise.resolve();
    } else {
      return Promise.reject(new Error(&#39;비밀번호가 일치하지 않습니다.&#39;));
    }
  };</code></pre>
<hr>
<h2 id="마이그레이션-후기">마이그레이션 후기</h2>
<p>아직 현재 진행 중이지만, <code>antd Form</code>을 마이그레이션 한다면, 위 4가지 사항 정도를 필히 숙지하시는게 좋습니다. <code>antd</code>를 버전업하는 작업을 진행하고는 있지만, 전 개인적으로 <code>react-hooks-form</code>과 <code>formik</code>을 조합해 폼 컴포넌트를 구성하는게 훨씬 DX가 좋다고 느껴지네요. </p>
<p><code>antd</code>패키지의 4 버전도 현재는 레거시로 취급받고 있고, 향후 개발환경을 고려한다면 V5로 빌드업을 또 진행해야겠지만, MVP 레벨이 아닌 프로덕션을 운영 중인 회사의 제품에서 굳이 <code>antd</code> 패키지 사용을 고집할 필요가 있을까? 라는 생각이 드는 것도 사실입니다.</p>
<p>동료를 통해 알게 된 사실이지만, v5에서는 공식문서에서 컴포넌트를 래핑해 커스텀한 디자인을 입히지 말라고 명시되어 있다고도 하구요.</p>
<p>이 패키지에 대한 인기도 전과 같지 않고 shadn ui같은 훨씬 좋은 ui 라이브러리들이 존재하기도 하구요.</p>
<p>누군가에게 이 글이 필히 도움이 되었으면 좋겠습니다. 
추가적으로 질문 생기시면 댓글로 남겨주시거나 이메일을 주셔도 좋습니다. 
감사합니다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[2023년, 성장하는 한 해를 보냈다.]]></title>
            <link>https://velog.io/@yunsungyang-omc/23%EB%85%84-%ED%9A%8C%EA%B3%A0%EB%A1%9D</link>
            <guid>https://velog.io/@yunsungyang-omc/23%EB%85%84-%ED%9A%8C%EA%B3%A0%EB%A1%9D</guid>
            <pubDate>Mon, 01 Jan 2024 10:01:23 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/31781e37-c290-42c9-aefb-06e4b7714eb7/image.png" alt=""></p>
<p>근 1년이 넘는 시간 동안 벨로그에 글을 쓰지 못했네요.
많은 이벤트가 있었고, 다양한 느낀 점이 있었던 2023년이었습니다. 
그래서, 다시 벨로그에 장작도 땔 겸 23년의 회고록을 남겨보려고해요.</p>
<h2 id="이직을-했습니다-12월---2월">이직을 했습니다. (12월 - 2월)</h2>
<p>첫 회사가 시리즈 A 투자 유치 성공 이후 구성원을 많이 불렸지만,
새로운 비즈니스 모델을 만드는데 실패하고 투자 유치도 요원해지면서 
급격히 경영 사정이 어려워졌습니다.</p>
<p>4대보험 납부 지연을 시작으로ㅡ 화기애애했던 회사 분위기가 냉랭해지는게 느껴져서
과감히 이직을 준비했었는데요. </p>
<p>22년 10월부터 이력서를 업데이트 하고 이직을 준비한지 2개월차에
지금의 회사 채용 과제를 진행하게 되었습니다.</p>
<p>22년 12월은 여러 회사의 과제와 면접을 동시에 진행해야해서 너무 힘들었던 기억이 아직도 나요.
정말 추운 겨울이었지만 과제를 제출하고, 몇 번의 면접을 진행한 결과
지금의 회사를 만나게 되었습니다.</p>
<p>참! 전 지금 반지, 목걸이 등 보석 류를 취급하는 커머스 플랫폼 <strong>아몬즈</strong>를 
운영하는 비주얼이라는 회사에서 일하고 있습니다 :-)</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/790ad1a9-aa5b-4182-9bb4-ee812aa9444e/image.png" alt=""></p>
<hr>
<h2 id="팀-비주얼에서-안정적으로-성장-3월---현재까지">팀 비주얼에서 안정적으로 성장 (3월 - 현재까지)</h2>
<p>잡플래닛에서의 평가와는 달리 합류한 회사는 굉장히 좋은 문화를 가지고 있는 회사입니다. 
열정적이고 의욕적인 개발팀, 그 개발팀을 리드하는 리더들도 굉장히 좋은 분들입니다. 
코드 리뷰 뿐만 아니라, 안정적인 배포 과정, 수준 높은 코드 리뷰 등 
주니어 개발자인 제게 안정적인 성장환경을 제공하고 있어 의욕적으로 개발 업무를 이어나가고 있습니다. </p>
<h3 id="어드민-서비스에서-abacattribute-based-access-control-권한을-도입">어드민 서비스에서 ABAC(Attribute-Based Access Control) 권한을 도입</h3>
<p>새로운 회사에 합류 후 약 한 달 동안의 온보딩 과정을 거쳐, 내부 고객(비주얼 팀원들)을 위한 어드민 서비스를 고도화하는 업무를 진행하는 백오피스 스크럼에 합류하게 되었어요. 
3번 정도의 스크럼 기간 동안, 가장 기억 나는 것은 ABAC 기반 권한 체계를 도입했다는거에요.</p>
<p>기존에는 권한 체계가 1, 2, 3등급으로 나눠진 롤 베이스 권한 체계(RBAC)였지만,
새롭게 개선된 권한 체계는 상품, 주문, 정산 등 다양한 주제에서 조회 수정 생성 삭제 등 액션을 선택적으로 지정해서 권한을 부여하는 체계로 개편되었어요 :-) </p>
<p>이 과정에서 컨텍스트 기반 로그인 컨트롤도 함께 도입을 했습니다. 이 부분은 별도 포스팅으로 상세하게 글을 남길게요 :-)</p>
<h3 id="상품-스크럼에-합류해서-고객의-편의성을-개선하고-있습니다">상품 스크럼에 합류해서 고객의 편의성을 개선하고 있습니다.</h3>
<p>백 오피스 스크럼은 스크럼 백로그에 정도 이상의 업무가 쌓이거나, 시급한 개선을 요하는 경우에 진행되는 임시 스크럼이었습니다. 시급한 개선 포인트를 마무리한 이후에는 현재 후기, 상품 상세 페이지 등을 개선하는 상품 스크럼에 소속해서 외부 유저(아몬즈 웹, 모바일 서비스 이용 고객)의 편의성을 개선하는 업무를 진행하고 있어요!
<img src="https://velog.velcdn.com/images/yunsungyang-omc/post/d99c5c00-c66c-4ac5-9a16-1f13c99f2b61/image.png" alt="">
상품 상세 페이지에서 유저가 구매할 때 받게되는 구매혜택(최저가, 쿠폰, 포인트)을 자세하게 노출하고
<img src="https://velog.velcdn.com/images/yunsungyang-omc/post/e79b2ae7-733f-45d8-b593-f8c63f910ff5/image.png" alt="">
쿠폰을 상세페이지에서 다운로드할 수 있게하는 작업 등을 작년 하반기 동안 진행했습니다. 
<img src="https://velog.velcdn.com/images/yunsungyang-omc/post/c1cc2605-b0de-4be7-b760-77c4750c1776/image.png" alt="">
후기를 좀 더 고도화해서, 사이즈, 색상 등에 객관적인 평가지표를 라디오 버튼으로 선택하게 하는 리뷰 고도화 작업 또한 함께 진행했네요!</p>
<p>현재 아몬즈 서비스는 웹 / 앱 모두에서 제공하고 있어요!
그중 저를 포함해서 웹 프론트엔드 개발자는 모노레포로 구성된 웹 서비스와 모바일 서비스를 유지 보수 관리하고 있습니다! </p>
<p>웹 서비스는 좀 더 활성화되어있는 앱에 비해 수치가 보다 낮지만, 
24년도에는 웹 서비스에서의 구매 전환율을 전년보다 훨씬 높이는 것을 목표로 삼고 있어요!</p>
<hr>
<h2 id="개발자-커뮤니티에서-활동">개발자 커뮤니티에서 활동</h2>
<p>벨로그에 글을 쓰거나, 가끔 노마드 코더 슬랙방에서 의견을 주고 받다가
우연한 기회에 들어가게 된 개발바닥 오픈채팅방에서 다양한 개발자 분들을 만나 의견을 주고 받고 있습니다. </p>
<p>작년 1월부터 진행하게 된 <strong>JS 스터디 디스코드 방</strong>은 현재까지도 활동을 이어가고 있어요!
처음에는 각자 발표하고 싶었던 주제를 발표했지만,</p>
<p>작년 8월부터는 <strong>자바스크립트 모던 딥다이브</strong> 완독을 목표로 12월까지 함께 스터디를 이어 갔어요 :-)
지금은 타입스크립트를 함께 공부하고 있답니다. 짧게 끝날 줄 알았던 스터디가 이렇게 장수할 수 있는게 신기하네요</p>
<p>JS 스터디를 진행하면서도, 향로님과 호돌맨님께 실시간으로 받은 면접 첨삭의 은혜도 보은할 겸 
선한 영향력을 다른 분들에게 나누고 싶다는 생각이 들었어요.
그래서 다른 개발자 분들과 힘을 합쳐 면접 스터디 방의 멘토로 참여하기도 했습니다. </p>
<p>4개의 프론트엔드 면접 스터디 팀에는 저보다도 경력이 길었던 분들도 계셨는데요,
다년 간의 썰풀기(정훈장교 6년, 마케팅 3년)로 다져진 제게는 경력과 상관없이 멘토할 수 있는 포인트들이 있더라구요. 그래서 즐겁게 멘토링을 했던 것 같습니다. </p>
<p>모든 분들이 이직에 성공하시지는 않았지만, 
그래도 5분이 넘게 이직에 성공하셔서 꽤 뿌듯했던 기억으로 남아있네요!</p>
<p>지금은 2사로의 부방장으로 활동하고 있어요! 일이 바빠서, 채팅방을 자주 보지는 못하지만 좋은 글이 생기면 링크를 남기고 있으니 언제든지 찾아주시면 환영입니다! </p>
<hr>
<h2 id="2024년의-목표">2024년의 목표</h2>
<p>2024년에는 주니어 딱지를 떼기 위한 다양한 활동을 진행해보려고 해요! 
멘토링과 스터디 리드를 진행했지만, 
올해에는 좀 더 다양한 분들에게 도움을 줄 수 있는 방법을 찾아보려고 해요.</p>
<h3 id="사이드-프로젝트-진행하기"><strong>사이드 프로젝트 진행하기</strong></h3>
<p>작년 한 해 동안 좋은 개발팀에 소속되어,  1인분을 충실하게 담당하기에 주력했습니다. 
과연, 1인분을 충실하게 해냈느냐?에 대해 스스로 묻는다면 <strong>기본은 했다</strong>라고 말할 수 있을 것 같네요!</p>
<p>스타트업이지만, 스크럼 / 팀 / 실에서 제가 할 수 있는 모든 것을 결정할 수는 없다는 걸 깨달았어요!
그래서 기술적으로 도전해보고 싶은 포인트 혹은 경험을 좀 더 비동기적으로 쌓기 위해 사이드 프로젝트를 진행해보려고 합니다. 지금 생각하고 있는 건 기부 플랫폼인데요! 이건 자세한 아이디어를 별도 글로 풀도록 할게요!</p>
<h3 id="사이드-잡을-찾아보려고-해요">사이드 잡을 찾아보려고 해요!</h3>
<p>멘토링과 스터디 리드를  진행하면서, 취준생 혹은 동일한 개발에 흥미를 가진 사회인을 대상으로 수업을 진행해보고 싶어졌어요! 그래서 사이드 잡으로 <strong>멘토링 서비스</strong>에 참여하고 싶다는 생각과 목표를 가지고 준비를 하고 있어요</p>
<h3 id="팀-비주얼로써-좀-더-높은-목표에-도달할-수-있도록-기여하기">팀 비주얼로써 좀 더 높은 목표에 도달할 수 있도록 기여하기</h3>
<p>시리즈 B를 유치에 성공했고, 실제로 매출을 내고 있는 서비스를 운영하는 회사의 개발팀에 속해있지만, 전 아직도 목마르다는 생각을 하고 있어요! 무신사나 쿠팡, 컬리 같은 모두가 알고 선망하는 회사로 옮겨가는 것을 목표하기 보다는 지금의 회사를 위의 회사같이 선망받는 회사로 가꾸는 게 아직까지의 목표에요! </p>
<p>그래서 팀에서 한 명의 개발자로 책임을 다할 뿐만 아니라, 좋은 동료를 계속 소개하고 함께 서비스를 가꿔나가고 싶어요! 아마 이게 24년의 제 가장 큰 목표가 될 것 같아요!  팀 비주얼에서는 계속 좋은 동료를 찾고 있어요! 혹 관심 생기시면 지원해보시길 바랄게요!</p>
<p><a href="https://shue.notion.site/02cd717fc5af4ef0bf57967bb0c86166">비주얼 채용 페이지 확인하기</a></p>
<h2 id="모두-새해-복-많이-받으시고-더-힘찬-2024년-되길-바랄게요">모두 새해 복 많이 받으시고, 더 힘찬 2024년 되길 바랄게요!</h2>
]]></description>
        </item>
        <item>
            <title><![CDATA[(React) 트러블 슈팅 : 카드리더기 프린터를 이용하는 환경에서 오토커팅 구현하기]]></title>
            <link>https://velog.io/@yunsungyang-omc/React-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-Nicepay-%ED%8F%AC%EC%8A%A4%EA%B8%B0%EC%97%90%EC%84%9C-%ED%94%84%EB%A6%B0%ED%8A%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yunsungyang-omc/React-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-Nicepay-%ED%8F%AC%EC%8A%A4%EA%B8%B0%EC%97%90%EC%84%9C-%ED%94%84%EB%A6%B0%ED%8A%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 06 Feb 2023 07:24:31 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/6906ffbb-ef2e-44e6-b9c0-08dc1a1725d6/image.png" alt=""></p>
<hr>
<p>최근 포스팅(<a href="https://velog.io/@yunsungyang-omc/React-%EB%A6%AC%EC%95%A1%ED%8A%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%84%9C-POS-%ED%94%84%EB%A6%B0%ED%8C%85-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0">리액트 프로덕트에서 POS 구현하기</a>)을 통해 웹 어플리케이션에서 POS 프린터 기능 구현에 대한 경험을 풀어냈습니다.</p>
<p>회사에 구비해 놓은 앱솔론 POS 프린터기에서 꽤 잘 작동했고, QA 팀의 검수도 통과해 큰 문제가 없을거라고 생각했었는데요? 문제가 일어나고 말았습니다. </p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/7fe15bc1-ca01-4b67-b1f8-33c8b6488cf5/image.png" alt=""></p>
<p>기능을 구현하기에 앞서, 해당 프로덕트를 사용할 환경에서 사용하고 있는 동일한 기기를 구비해달라고 그렇게 요청드렸건만...</p>
<p>막상 현장에 실사 테스트에 나가보니, 매장에는 기대하던 POS 프린터기와 평범한 PC가 아닌 KCP-C3100이라고 부르는 카드 리더기와 OKPOS 단말기가 세팅되어 있었습니다. </p>
<p>(okpos 단말기는 식당에서 아주 많이 사용하는..네 그 기기가 맞습니다.) </p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/4397b453-c9e5-4bf7-8670-0bbe151a1593/image.jpg" alt=""></p>
<p>OKPOS 단말기야 앞뒤로 디스플레이가 세팅된 일종의 PC라고 생각하면 문제될 것이 없는데, 카드 단말기가 말썽이었습니다. 분명, 앱솔론 POS 프린터에서 잘만 출력되는데, 이 카드리더기가 물고 있는 COM1 포트로 프린트 요청을 보내면 정상적인 텍스트 대신 <strong>****</strong>라는 별표가 대신 프린트됩니다. </p>
<h3 id="문제-1--okpos-단말기-내-nhn-포스-프로그램은-출력속도-38400을-지원한다">문제 1 : OKPOS 단말기 내 nhn 포스 프로그램은 출력속도 38400을 지원한다.</h3>
<p>이 문제를 해결하기 위해서는 kcp-c3100 가맹 안내 블로그에서 제공하고 있는 POS 기능 활성화 안내를 참고해야했습니다. <a href="https://blog.naver.com/teraonmaster/222394284389">링크</a></p>
<pre><code>특수키 입력 후 1200 
포스사용: 1번 선택 
단말 연동 POS 통신 방식: 1번 선택
통신포트선택: 2번 선택 
통신속도선택: 3번(38400) 선택 
한줄 인쇄 모드: 1번 선택
단말 연동 화면 표시시간 설정: 3초</code></pre><p>라이브러리에서 포트를 열때, 넘겨주고 있는 통신속도는 9600(<code>baudRate: 9600</code>)입니다. pos 프린터기에서는 9600속도를 일반적으로 가져가지만, 카드리더기인만큼 4배나 빠른 속도를 사용하는 것 같네요.</p>
<p>그래서 코드를 아래와 같이 수정해주었습니다. </p>
<pre><code class="language-js">  const onClickPrintHandler = async () =&gt; {
    const data = await render(UserReceipt({ orderinfo }));
    const port = await window.navigator?.serial?.requestPort();
    if (port.writable === null) {
      await port.open({ baudRate: 38400 }); // ← 9600을 38400으로
    }
    const writer = port.writable?.getWriter();
    if (writer !== null) {
      await writer!.write(data)
      writer!.releaseLock();
    }
    await port.close();
  };</code></pre>
<p>해당 방식대로 카드 리더기의 세팅을 손 봐주니, 프린트 기능은 작동합니다. 
하지만 문제 하나 건너 문제라더니, 또 다른 문제가 발생하더군요. </p>
<h3 id="문제-2--오토커팅이-작동되지-않는다">문제 2 : 오토커팅이 작동되지 않는다</h3>
<h4 id="1️⃣-cut함수를-function-b타입으로-바꾸기">1️⃣ cut함수를 Function B타입으로 바꾸기</h4>
<p>해당 이슈는 꽤 곤혹스러웠습니다. 인쇄물이 출력된다는 것은 print 관련 함수가 제대로 동작한다는 뜻일텐데, 유독 커팅 명령만 제대로 작동하지 않고 있었거든요. </p>
<p>주문이 몰리거나, 바쁠 시 매장에서 일일히 영수증을 가위로 자를 수는 없습니다. 그래서  반드시 해결해야 할 이슈라고 생각하고 매장 구석에 앉아 라이브러리를 뜯어보기 시작했습니다. </p>
<pre><code class="language-ts">// react-thermal-printer/packages/printer/src/BasePrinter.ts
  cut(): this {
    this.cmds.push({
      name: &#39;cut&#39;,
      data: cut(48),
    });
    return this;
  }</code></pre>
<pre><code class="language-ts">// react-thermal-printer/packages/printer/src/commands/cut.ts
import { GS } from &#39;./common&#39;;

/**
 * Select cut mode and cut paper
 * &lt;Function A&gt;
 * | Format   | Value    |
 * |---------|----------|
 * | ASCII   | GS V m  |
 * | Hex     | 1D 56 m |
 * | Decimal | 29 86 m |
 *
 * &lt;Function B, C, D&gt;
 * | Format   | Value    |
 * |---------|-----------|
 * | ASCII   | GS V m n  |
 * | Hex     | 1D 56 m n |
 * | Decimal | 29 86 m n |
 *
 * @see https://www.epson-biz.com/modules/ref_escpos/index.php?content_id=87
 */
export function cut(m: number, n?: number) {
  const cmd = [GS, 0x56, m];
  if (n != null) {
    cmd.push(n);
  }
  return cmd;
}</code></pre>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/e2ee2b02-746e-4cc2-af8b-2849a352fbd7/image.png" alt=""></p>
<p>위 코드와 epson 사의 프린트 기술 문서를 확인해 보니, 프린터 대부분이 커팅관련해서는 <code>Function A</code> 와 <code>Function B</code>를 지원하고 있었습니다. 그리고 라이브러리에는 <code>A</code>타입을 선택하는지 <code>cut</code>함수의 파라미터로 숫자<code>48</code>을 넘겨주고 있구요.</p>
<p>그래서 커팅함수의 타입을 B로 바꾸면 커팅기능이 제대로 작동할까 싶어,<code>BasePrinter.ts</code>의 <code>cut</code> 함수의 파라미터를 48에서 65와 0에서 250 사이의 정수를 선택해 넣어주었습니다. </p>
<pre><code class="language-ts">// react-thermal-printer/packages/printer/src/BasePrinter.ts
  cut(): this {
    this.cmds.push({
      name: &#39;cut&#39;,
      data: cut(65, 65),
    });
    return this;
  }</code></pre>
<p>이렇게 전달하니 분명 해결될 것 같았는데, 역시나 오토커팅은 해결되지 않습니다. 
그 순간 불현듯 머리를 스쳐가는게 하나 있었습니다. </p>
<h4 id="2️⃣-문제는-portclose-😤">2️⃣ 문제는 port.close() 😤</h4>
<pre><code class="language-js">  const onClickPrintHandler = async () =&gt; {
    const data = await render(UserReceipt({ orderinfo }));
    const port = await window.navigator?.serial?.requestPort();
    if (port.writable === null) {
      await port.open({ baudRate: 38400 }); // 9600에서 38400으로 수치 조절
    }
    const writer = port.writable?.getWriter(); // 1)
    if (writer !== null) {
      await writer!.write(data) // 2)
      writer!.releaseLock(); // 3)
    }
    await port.close(); // 4)
  };</code></pre>
<p>짧막하게 프린트 핸들러 함수를 설명하면, </p>
<pre><code>1) port를 열어 열거 가능한 객체를 나타내는 writer를 조회한다.

2) writer 객체의 프로퍼티 중에서 write의 파라미터로 
영수증 컴포넌트를 전달함으로서 프린트 함수가 작동한다. 

3) releaseLock()은 포트 요청에 외부 변인이 침범이 발생하지 않도록, 
포트 요청을 잠그는 역할을 한다.

4) 포트가 이미 열려있다면, 이후 프린트 요청 간 포트를 열려고 할 때 
에러가 나기 때문에, 포트를 닫아주어야 한다.</code></pre><p>라이브러리 샘플 예제에는 4번 항목이 존재하지 않습니다. </p>
<p>하지만, 포트를 열고난 후 닫아주지 않는다면 거듭 영수증 출력을 할 수 없을 뿐더러, POS기기에서도 카드 결제로 영수증을 출력하려 할때 제대로 작동하지 않게 됩니다. (모두 COM1 포트를 사용하고 있기 때문입니다.)</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/4f11d3f7-d10e-49cb-9d9b-75379a20fbfd/image.png" alt=""></p>
<p>위 코드의 함수는 사내에서 테스트 할 경우 아무런 문제가 없었지만, 실제 사용환경에서는 <code>port.close()</code> 명령이 영수증 출력에 채 끝나기도 전에 port를 닫도록 요청을 보내는듯 했습니다.</p>
<blockquote>
<p>await는 함수의 동기적 실행 순서를 보장할 뿐, 그 타이밍까지는 보장하지 않으니깐요</p>
</blockquote>
<p>하여 다음과 같이 코드를 수정했습니다. </p>
<pre><code class="language-ts">  const onClickPrintHandler = async () =&gt; {
    const data = await render(UserReceipt({ orderinfo }));
    const port = await window.navigator?.serial?.requestPort();
    if (port.writable === null) { // ← 최초 실행에만 포트를 연다.
      await port.open({ baudRate: 38400 });
    }
    const writer = port.writable?.getWriter();
    if (writer !== null) {
      await writer!.write(data).then(() =&gt; setTimeout(() =&gt; port.close(), 500)); // ←
      writer!.releaseLock();
    }
  };</code></pre>
<p>포트의 열거 가능한 객체 writer에 출력 요청을 보내고 난 후, 
<code>setTimeout</code>함수를 이용해 <code>port.close()</code>를 실행하도록 조치했습니다.
더불어 열거가능한 객체가 없을 때를 나타내는, 포트를 맨 처음 열때만 <code>port.open()</code>이 실행되도록 if 문으로 감싸주었습니다</p>
<p>이 조치를 통해 포트를 닫는 요청은 콜스택이 모두 비워질때까지 테스크 큐에서 기다렸다가 실행될 것입니다. 동기적으로 함수가 작동할 수 있도록 트릭을 이용해 프로그래밍해준 것이죠. </p>
<blockquote>
<p>이 결과 영수증 출력이 아주 잘 되었습니다.</p>
</blockquote>
<ul>
<li>500밀리 세컨즈를 인자로 넣어주었지만, 사실 0을 넣어도 결과는 마찬가지일겁니다.</li>
<li>if 문을 제거하더라도 함수는 잘 동작할 것 같네요. 포트는 언제나 닫히지깐요!</li>
</ul>
<p>결국, 영수증 출력이 되지 않는 범인은 <code>port.close()</code>이 한 줄이 원인이었네요. 😪</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/6fec468f-3743-4eb8-9e7b-8d4d7b9a1ea6/image.jpg" alt=""></p>
<p>이렇게 트러블 슈팅 성공!!</p>
<hr>
<h3 id="주니어는-트러블-슈팅에서-배운다">주니어는 트러블 슈팅에서 배운다.</h3>
<p>마블러스에서 마지막 테스크가 될 POS 프린터를 깔끔하게 마무리 지을 수 있어서, 굉장히 기분 좋은 트러블 슈팅 경험을 쌓게 되었습니다. 끝끝내 마무리 하지 못했다면 두고 두고 생각났을 것 같거든요.</p>
<p>라이브러리를 이용해 간단하게 구현될 수 있을 것 같은 문제도 때때로는 실행 환경에 따라서, 제대로 실행되지 않거나, 라이브러리 자체의 세팅 값을 바꿔주어야 할 경우도 많이 발생합니다. </p>
<p>그때는 좌절하지 말고, 적극적으로 트러블 슈팅에 몰입한다면 온전히 나만의 경험으로 만들 수 있을 뿐만 아니라, 라이브러리를 보다 정교화할 수 있는 경험을 쌓을 수 있게 될 것 같습니다. </p>
<p>아마, 윈도우 네이티브 앱 혹은 모바일 네이티브 앱으로 POS를 구현해본 분들은 많겠지만, 웹 클라이언트, okpos 그리고 카드리더기 환경을 이용하는 pos 영수증 출력 기능을 구현해본 사람은 많지 않을 것 같습니다. </p>
<p>위 조합은 자영업자들에게는 국룰 처럼 여겨지기에 누군가 이 블로그를 보고 동일한 과업을 받는다면 쉽게 해결할 수 있기를 기대하겠습니다. </p>
<p>긴 글 읽어주셔서 감사합니다 :-) </p>
<hr>
<h3 id="함께-읽어보면-좋을-자료">함께 읽어보면 좋을 자료</h3>
<p><a href="https://wicg.github.io/serial/#dom-serialport-writable">Web Serial API</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[(React) 리엑트 프로덕트에서 소리 알람 기능 구현하기]]></title>
            <link>https://velog.io/@yunsungyang-omc/React-%EB%A6%AC%EC%97%91%ED%8A%B8-%ED%94%84%EB%A1%9C%EB%8D%95%ED%8A%B8%EC%97%90%EC%84%9C-%EC%86%8C%EB%A6%AC-%EC%95%8C%EB%9E%8C-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yunsungyang-omc/React-%EB%A6%AC%EC%97%91%ED%8A%B8-%ED%94%84%EB%A1%9C%EB%8D%95%ED%8A%B8%EC%97%90%EC%84%9C-%EC%86%8C%EB%A6%AC-%EC%95%8C%EB%9E%8C-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Sat, 14 Jan 2023 01:25:34 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/2e6fb90b-34dd-43ba-b633-4d2c8dc66e4a/image.png" alt=""></p>
<hr>
<p>직전 포스팅으로 <strong>&quot;리액트 프로덕트에서 프린트 기능 구현하기&quot;</strong>에 대한 글을 작성했었는데, 이번에도 추가 기능을 요청받아 구현한 이야기를 작성하게 되었습니다. </p>
<p>11월에 커머스 프로젝트가 고도몰로 넘어간 이후 <code>Jquery</code>길을 걷게될 줄만 알았는데, 다행히 React 라이브러리를 계속 갈고 닦고 있네요. </p>
<hr>
<h2 id="🧲-web-환경에서-소리-효과-구현하기">🧲 Web 환경에서 소리 효과 구현하기</h2>
<p>웹에서 오디오 기능을 활용하기 위해서 가장 쉬운 방법으로는 HTML태그 중 <code>&lt;audio /&gt;</code>태그를 사용하는 것입니다. </p>
<p><a href="https://developer.mozilla.org/en-US/docs/Web/HTML/Element/audio">MDN 문서</a></p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/764bbb9b-8521-4c08-900c-a1739a9f0410/image.png" alt=""></p>
<p>오디오 태그의 소스로 public 폴더 혹은 assets 폴더에 위치한 소리 파일의 경로를 넣어주면 아주 간단하게 구현가능합니다. </p>
<p>HTML 태그를 이용해서 오디오 기능을 구현할 수도 있지만, 
널리 쓰이는 라이브러리가 존재합니다. </p>
<h2 id="🔍-usesound-라이브러리">🔍 useSound 라이브러리</h2>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/e0e94781-5a0f-4ed8-a73e-cd11918955e2/image.png" alt=""></p>
<p><a href="https://www.npmjs.com/package/use-sound">npm</a> <a href="https://www.npmjs.com/package/use-sound">github</a></p>
<p>README 에서 소개하듯 리엑트에서 훅의 형태로 소리기능을 구현할 수 있는 아주 유용한 라이브러리입니다.  이 라이브러리를 사용하면 <strong>굳이 HTML audio 태그를 사용하지 않고도 특정 상황이나 이벤트에 오디오를 재생할 수 있는 기능을 추가</strong>해줄 수 있습니다. </p>
<h4 id="usesound-라이브러리-사용법">useSound 라이브러리 사용법</h4>
<pre><code class="language-js">import useSound from &#39;use-sound&#39;;

import boopSfx from &#39;../../sounds/boop.mp3&#39;;

const BoopButton = () =&gt; {
  const [play] = useSound(boopSfx);

  return &lt;button onClick={play}&gt;Boop!&lt;/button&gt;;
};</code></pre>
<p>사용방법은 엄청나게 심플합니다. 라이브러리와 소리파일을 임포트한 후 훅의 형태로 사용해주기만 하면 끝!인거죠. 이제 이 라이브러리를 이용해서 요구받은 &quot;소리알람&quot;기능을 구현해보려합니다.</p>
<hr>
<h3 id="🫥-구현할-soundalarm-요구사항">🫥 구현할 SoundAlarm 요구사항</h3>
<pre><code>- 주문이 접수되면 소리 알람으로 사용자에게 알려준다.
- 주문 탭(주문 대기 / 접수 / 픽업 요청 / 픽업) 어디에서라도 새로운 주문이 접수되면 소리 알람 기능이 제공되야 한다. </code></pre><p>위 요구사항을 충족할 수 있도록 구현된 벤더 어드민 프로덕트의 파일구조를 살펴보았습니다.</p>
<pre><code class="language-js">// index.js

const Dashboard = () =&gt; {
  const searchProps = {
    sort,
    setSort,
    searchType,
    setSearchType,
    searchString,
    setSearchString,
    searchDate,
    setSearchDate,
    searchStatus,
    setSearchStatus,
  };

  const { vendorState, getVendorOrderList} = useVendor();

  const {
    data: orderListData,
    refetch: refetchOrderList,
    dataUpdatedAt: updatedAtOrderListData,
  } = getVendorOrderList(
    {
      limit,
      page,
      sort,
      searchStatus,
      searchDate,
      searchType,
      searchString,
      selectedCategory,
    },
    {
      // Refetch the data every 10 second
      refetchInterval: 10000,
      onSuccess: () =&gt; {
        refetchOrderCount();
      },
    }

  return (
    &lt;Container&gt;
      &lt;VendorHeader auth={auth} /&gt;
      &lt;FixedStyledTabs /&gt;
      {CategoryList.map(
         (item, index) =&gt;
           (
             &lt;VendorOrderList /&gt;
            )
      )}
    &lt;/Container&gt;
  );
};

export default Dashboard;</code></pre>
<p>searchProps는 tab을 클릭할때마다 새롭게 설정이 되고, 이 새롭게 설정된 searchProps가 useQuery로 데이터를 패칭하는 함수 <code>getVendorOrderList</code>의 쿼리 키로 전달되는 구조로 데이터를 패칭하고 있군요. </p>
<p>패칭 결과로 전달된 <code>orderListData</code>가 옵션에 맞는 주문 리스트를 렌더링하도록 map 함수를 사용하고 있습니다. </p>
<p>여기서 한 가지 고민이 생깁니다. </p>
<blockquote>
<p>🧐 탭을 클릭할때마다 조건에 맞는 주문의 길이가 달라지는 거면, 요구사항 2번(탭이 어디에 위치하든, 새로운 주문이 접수되면 벨을 울린다.)을 어떻게 충족할 수 있을까? </p>
</blockquote>
<p>정답은 생각보다 간단했습니다. </p>
<p>api 요청결과 넘어오는 값 중 각 단계별로 주문의 수를 전달하는 배열이 존재했는데, 이 배열의 값 중 &quot;주문대기&quot;의 숫자를 비교해서 증감했을때만 벨을 울리면 되는 것입니다. </p>
<p>따라서, 이 배열의 값과 비교해야할 &quot;절대값&quot;을 다음과 같은 코드로 구현했습니다. </p>
<h3 id="😇-useref를-이용해서-절대값과-상대값-비교하기">😇 useRef를 이용해서 절대값과 상대값 비교하기</h3>
<p>React 함수 컴포넌트에서 ref 를 사용 할 때에는 useRef 라는 Hook 함수를 사용합니다. 이 ref의 current 값은 리랜더링되더라도 변하지 않기 때문에 절대값으로 사용하기 아주 유용합니다. </p>
<pre><code class="language-js">import React, { useRef, useEffect } from &quot;react&quot;;
import useSound from &quot;use-sound&quot;;
import bell from &quot;public/assets/sounds/bell.mp3&quot;;

const soundRef = useRef(undefined);
const [soundPlay] = useSound(bell);

// res의 data 결과를 담고있는 vendorState의 값중 orderTags는 각 상태별 주문의 수를 담고 있는 배열입니다. 
const getCountOfWaitOrder = () =&gt; {
  if (vendorState?.orderTags !== null &amp;&amp; vendorState?.orderTags) {
      const count = vendorState?.orderTags.find(
      (tag) =&gt; tag.slug === &quot;smartorder-wait&quot;
  )?.total;
  return count;
  }
};


useEffect(() =&gt; {
 const countOfWait = getCountOfWaitOrder();

 // 레프의 초기값(0)보다 최근 주문시간이 크고, 대기중인 주문의  // 카운트가 1이상일때 : 주문
 if (soundRef.current &lt; countOfWait) {
     // 사운드를 출력시키고, 주문 카운트를 ref로 등록시킨다.
     soundPlay();
     soundRef.current = countOfWait;
 } else {
    // 주문이 취소되거나, 상태가 변경되었을댄, 대기 중인 주문의 수가 더 적으므로, 줄어든 값을 레프로 등록시킨다.
    soundRef.current = countOfWait;
  }
}, [vendorState]);</code></pre>
<p>주문이 접수되면 해당 페이지에서는 다음과 같은 흐름이 일어납니다. </p>
<pre><code>1) 10초 마다 한 번씩 주문 상태의 값을 받아오는 api가 새로운 주문을 감지합니다. 

2) 업데이트 된 상태 &quot;vendorState&quot;의 프로퍼티 OrderTags의 값을 갱신시킵니다. 그리고 이 값은 함수 getCountofWaitOrder로 얻고 있는 &quot;대기중인 주문 건 수&quot;를 최신화합니다. 

3) useEffect 함수에서는 주시 중인 의존성 배열의 값이 변하였기 때문에 최산화된 &quot;대기 중인 주문&quot;의 수와 비교 절대값 ref을 비교하려고 합니다. </code></pre><p>근데, ref의 초기 값이 0이 아니라 undefined를 설정한 이유가 무엇일까요? </p>
<p>soundRef의 current의 값이 0으로 설정하면 다음과 같은 현상이 생깁니다.</p>
<pre><code>1) ref의 current 값은 0이다. 
2) 갱신된 대기 중인 주문의 count는 0보다 크다. 
3) 따라서, 페이지를 현재 경로로 다시돌아오게 되면 무조건 벨이 울린다. </code></pre><p>값이 0이기 때문에, 로고를 클릭해서 현재 위치를 다시 불러오면 10초에 한 번씩 카운트를 가져오는 현재의 로직 상 무조건 useEffect내의 로직이 발동되게 됩니다. </p>
<p>따라서, 주문이 추가되지 않아도 페이지에 첫 진입하거나, 경로로 재진입시 무조건 벨이 울리는 조건이 되어버리는 것이죠.</p>
<p>따라서, useRef의 값을 undefined로 설정해 soundRef의 값을 카운트가 업데이트되고 난 후에 할당되도록 로직을 구성했습니다. </p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/542f1079-1ade-485e-a470-be8aa16420e9/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/22752140-6991-49c9-ba88-85199e7ad3ac/image.png" alt=""></p>
<p>첫 렌더링 시 ref의 current 값을 의도적으로 undefined로 설정한 결과, 무조건 벨이 울리게 되는 현상을 방지하게 된 것이죠.</p>
<hr>
<h3 id="👹-구현결과">👹 구현결과</h3>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/7fc9b6b1-4fa1-47f8-9a71-aa78f1c94f71/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/bc838882-75fd-458b-a67b-b85efe4ef060/image.gif" alt=""></p>
<p>네 주문이 접수 된 후 아주 잘 소리 알람이 작동하고 있습니다. </p>
<hr>
<h2 id="📝-후기">📝 후기</h2>
<p>개인적으로는 이번 기능을 구현하면서, 함수의 실행 컨텍스트에 관해 몸으로 깨달을 수 있었던 것 같습니다. 
위 글에서는 아주 간단하고 명료하게 기능을 구현한 것 같지만, 
주시하고 있던 상태(주문 카운트)의 업데이트 시점을 제대로 적용하기 위한 많은 삽질이 있었거든요. </p>
<p>React로 프로그래밍한 코드는 반드시 함수의 실행 시점의 환경, 실행 컨텍스트와 Lexical Enviroment 등 실행을 위한 요소들을 수집해서 실행에 참고합니다. 따라서, 우리 머리속에서는 동기적으로 착착 잘 진행될 것 같은 코드도 경우에 따라서는 제대로 실행이 되지 않을 수도 있습니다. </p>
<p>다시 한 번 기초의 중요성을 깨닫게 되는 사례였습니다. </p>
<p>이 글이 누군가에게 반드시 도움이 되길 바라며, 이만 글을 맺습니다. 긴 글 읽어주셔서 감사합니다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[(React) 리액트 프로덕트에서 POS 프린트 기능 구현하기]]></title>
            <link>https://velog.io/@yunsungyang-omc/React-%EB%A6%AC%EC%95%A1%ED%8A%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%84%9C-POS-%ED%94%84%EB%A6%B0%ED%8C%85-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yunsungyang-omc/React-%EB%A6%AC%EC%95%A1%ED%8A%B8-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8%EC%97%90%EC%84%9C-POS-%ED%94%84%EB%A6%B0%ED%8C%85-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 27 Dec 2022 13:51:44 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/be91445a-6cf4-476f-88c6-aa4577e54484/image.png" alt=""></p>
<hr>
<p>회사에서 진행하는 커머스 프로젝트는 &quot;고도몰&quot;로 갈음처리되었지만, 
운 좋게도(?) 벤더용 어드민 기능은 어찌저찌 살아남아 벤더어드민 기능을 덧붙이는 작업을 12월 동안 진행하고 있습니다. </p>
<p>이번 콘텐츠는 가장 최근 올린 새로운 기능 중 <strong>&quot;POS 프린터 기능&quot;</strong>에 대한 글입니다. </p>
<p>이번 기능을 위해 오픈 소스 라이브러리 생태계에 기여해주신 &quot;나석주&quot;개발자님께 먼저 감사의 인사를 전합니다. (<a href="https://github.com/seokju-na/react-thermal-printer">깃헙 라이브러리 링크</a>]</p>
<hr>
<h2 id="🧲-들어가기에-앞서">🧲 들어가기에 앞서</h2>
<p>일반적인 프린터와는 다르게, 카드 영수증 출력이나 POS기의 주문을 출력하는 프린터는 &quot;써멀 프린터&quot;라고 부릅니다. </p>
<p>용지는 얇고 폭이 좁아서 일반적인 프린터의 그것과는 많이 다른데다, 윈도우7 혹은 심지어 윈도우 XP 환경에서 구동해야 하기 때문에 블루투스로 프린팅 기능을 지원하는 요즘과는 다르게 대부분 시리얼 포트 규격을 채택하고 있습니다. </p>
<p><em>(물론 USB 2.0을 지원하긴 합니다만,가상 포트 드라이버를 설치해서 사용합니다.)</em></p>
<p>때문에, &quot;프린팅 기능&quot;을 주문받았을때 가장 먼저 확인한 것은 POS 구동환경과 프린터 기기의 종류였습니다.</p>
<p>아니나 다를까, 제품을 사용할 협력사는 윈도우 7과 구형 써멀 프린터를 사용하고 있었습니다 😇</p>
<h2 id="🖨-리액트에서-프린트-기능을-구현하려면">🖨 리액트에서 프린트 기능을 구현하려면</h2>
<p>리액트에서 프린트 기능을 구현할 수 있는 대표적인 라이브러리가 있습니다 </p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/9b406318-ba68-4dcc-afd5-3a802090442c/image.png" alt=""></p>
<p>바로 React to Print 라이브러리입니다. 
<a href="https://www.npmjs.com/package/react-to-print">링크</a></p>
<pre><code class="language-js">import React, { useRef } from &#39;react&#39;;
import { useReactToPrint } from &#39;react-to-print&#39;;

import { ComponentToPrint } from &#39;./ComponentToPrint&#39;;

const Example = () =&gt; {
  const componentRef = useRef();
  const handlePrint = useReactToPrint({
    content: () =&gt; componentRef.current,
  });

  return (
    &lt;div&gt;
      &lt;ComponentToPrint ref={componentRef} /&gt;
      &lt;button onClick={handlePrint}&gt;Print this out!&lt;/button&gt;
    &lt;/div&gt;
  );
};</code></pre>
<p><code>react to print</code>라이브러리에서 소개하는 예시를 살펴보니
<code>useRef</code>를 이용해서 ref의 current 객체에 프린트하고자 하는 HTML 돔요소를 업데이트 시켜, 출력함수의 <code>content</code>키의 벨류로 전달하고 있습니다.</p>
<p>직관적이고 사용이 편하다는 생각이 듭니다. 하지만, 위 라이브러리를 사용해서  프린터 기능을 구현하더라도 시리얼 포트를 사용하는 써멀 프린터의 작동방식과는 사못 달라 적용하기 어렵다는 생각이 들었습니다. </p>
<p>바쁜 주방에서 하나하나 프린터 세팅을 다뤄가면서 출력을 할 수는 없을테니깐요 😪</p>
<h2 id="🧐-프린트-기능-팝업-호출없이-바로-출력을-할-수-없을까">🧐 프린트 기능 팝업 호출없이 바로 출력을 할 수 없을까?</h2>
<p>위 질문을 해결하기 위해 
다시 열심히 구글에 자료를 서칭한 결과, 엄청난 자료를 찾고야 말았습니다. </p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/f3c1ccf4-ad3d-4dff-a311-d3c9adfd5bdf/image.png" alt=""></p>
<p>JSConf2022 행사에서 요기요 개발자 팀의 나석주 님께서 발표하신 세션을 찾은 것입니다. </p>
<p>위 콘텐츠 덕분에 훨씬 더 자세하게 써멀프린터의 구동환경을 알게 되었습니다. </p>
<p>더불어, 위 세션에서 발표하신 결과물을 npm에 배포해주신 덕분에 제가 필요로한 라이브러리를 바로 적용해볼 수 있었습니다. </p>
<h2 id="react-themal-print-라이브러리-적용하기">React themal Print 라이브러리 적용하기</h2>
<p><a href="https://www.npmjs.com/package/react-thermal-printer">링크</a></p>
<p>해당 라이브러리에 나와있는 깃헙 주소로 들어가면 README.md에 상세하게 라이브러리 사용법이 나와있습니다. 하여, 하단의 코드에서는 제가 라이브러리를 어떻게 적용했는지 소개하려합니다 </p>
<h3 id="1️⃣-영수증-출력을-담당할-컴포넌트-생성">1️⃣. 영수증 출력을 담당할 컴포넌트 생성</h3>
<p>먼저 주방에 전달될 주문지에 담을 정보를 나타내는 UserReceipt.jsx  파일을 먼저 생성했습니다.</p>
<pre><code class="language-js">// src/components/UserReceipt

import { Br, Cut, Line, Printer, Text, Row } from &quot;react-thermal-printer&quot;;
import moment from &quot;moment&quot;;
import { FlexBox } from &quot;src/components/flex-box&quot;;
import { Typography } from &quot;@mui/material&quot;;

const UserReceipt = (props) =&gt; {
  const { orderinfo } = props;

  if (!orderinfo) return &lt;&gt;&lt;/&gt;;

  return (
    &lt;Printer type=&quot;epson&quot; width={42} characterSet=&quot;korea&quot;&gt;
      &lt;FlexBox sx={{ alignItems: &quot;center&quot;, justifyContent: &quot;center&quot; }}&gt;
        &lt;Text&gt;주방주문서&lt;/Text&gt;
      &lt;/FlexBox&gt;
      &lt;Text size={{ width: 2, height: 2 }}&gt;
        [주문시간] {moment(orderinfo?.paymentDt).format(&quot;YYYY-MM-DD HH:mm&quot;)}
      &lt;/Text&gt;
      &lt;Text&gt;[오더번호] {String(orderinfo[0]).slice(-3)}&lt;/Text&gt;
      &lt;Line /&gt;
      &lt;Row left=&quot;메뉴명&quot; right=&quot;수량(구분)&quot; /&gt;
      &lt;Line /&gt;
      &lt;Br /&gt;
      &lt;FlexBox sx={{ flexDirection: &quot;column&quot; }}&gt;
        {orderItem &amp;&amp;
          orderItem?.map((item, index) =&gt; {
              return (
                &lt;&gt;
                  &lt;Row left={item.goodsNm} right={`${item.goodsCnt} (신규)`} /&gt;
                  {item.optionInfo !== null &amp;&amp; (
                    &lt;Row
                      left={`ㄴ ${item.optionInfo}`}
                      right={`${item.goodsCnt} (속성)`}
                    /&gt;
                  )}
                &lt;/&gt;
              );
          })}
        &lt;Br /&gt;
      &lt;/FlexBox&gt;
      &lt;Line /&gt;
      &lt;Text&gt;매장컵&lt;/Text&gt;
      &lt;Line /&gt;
      &lt;Br /&gt;
      &lt;Line /&gt;
      &lt;Cut /&gt;
    &lt;/Printer&gt;
  );
};

export default UserReceipt;</code></pre>
<p>코드 상단에서 임포트하고 있는 라이브러리의 <code>Line</code> <code>Br</code> <code>Row</code> <code>Printer</code> <code>Text</code> <code>Cut</code>등 기본 컴포넌트만 사용해서 구현해도 훌륭하게 영수증을 출력할 수 있습니다. </p>
<p>각 컴포넌트들은 시멘틱하게 네이밍 되어 있기 때문에 뜻 그대로의 기능을 제공합니다. </p>
<p>저 중 가장 중요한 것은 <code>Printer</code> 컴포넌트인데, 일반적인 Container 컴포넌트의 기능을 제공하는 것같지만, 프린터 기능에 관련된 props를 전달하는 아주 중요한 기능을 합니다. </p>
<h3 id="2️⃣-프린터-기능을-담당하는-함수에-jsx-엘리먼트-전달하기">2️⃣. 프린터 기능을 담당하는 함수에 JSX 엘리먼트 전달하기</h3>
<pre><code class="language-js">// src/components/VendorOrderList
import { render } from &quot;react-thermal-printer&quot;;
import UserReceipt from &quot;./UserReciept&quot;;

const VendorOrderListItem = (props) =&gt; {

      const orderInfo = props.orderInfo;

    const onClickPrintHandler = async () =&gt; {
      const data = await render(UserReceipt({ orderinfo }));
      const port = await window.navigator.serial.requestPort();
      await port.open({ baudRate: 9600 });
      const writer = port.writable?.getWriter();
      if (writer !== null) {
        await writer.write(data);
        await writer.releaseLock();
      }
      await port.close({ baudRate: 9600 });
  };

  return (
   &lt;Container&gt;
    ...
    &lt;Button
      onClick={async () =&gt; {
        await onClickPrintHandler();
        await toggleAlertDialog(&quot;smartorder-preparing&quot;);
      }}
     &gt; 접수하기 &lt;/Button&gt;
    ...
   &lt;/Container&gt;
 );
};</code></pre>
<p>버튼을 클릭하면 <strong>주문접수</strong>와 함께 주방에 있는 써멀프린터로 영수증이 전달될 수 있도록 onClick 함수를 구현해보았습니다. </p>
<p>라이브러리의 README.md의 예제에는 영수증 양식을 변수에 직접 할당해 <code>render</code>함수의 인자로 전달하고 있었지만, 관심사 분리를 위해 컴포넌트로 분리해 작업했습니다. </p>
<p>JSX 컴포넌트 또한 일종의 함수로 리턴 값으로 HTML Element를 리턴하기 때문에 함수의 형식으로 render 함수의 인자로 전달해주었습니다. </p>
<p><code>const data = await render(UserReceipt({orderInfo})</code></p>
<h3 id="3️⃣-시리얼-포트에-연결된-프린트로-작업-요청하기">3️⃣. 시리얼 포트에 연결된 프린트로 작업 요청하기</h3>
<p><code>window.navigator.serial.requestPort()</code>문을 통해 실제로는 usb에 연결되어 있지만, 드라이버를 통해 가상 포트에 할당되어 있는 프린터에 접근할 수 있습니다. (전 COM3 포트로 연결을 했습니다.)</p>
<p>함수의 흐름은 라이브러리가 제공한 가이드 대로 진행했지만,
한편으로는 주방 환경에서 영수증을 재 출력하거나 포트가 계속 열려있어 다른 출력요청과 충돌할 수 있다고 생각했습니다. </p>
<pre><code class="language-js">const onClickPrintHandler = async () =&gt; {
      const data = await render(UserReceipt({ orderinfo }));
      const port = await window.navigator.serial.requestPort();
      await port.open({ baudRate: 9600 });
      const writer = port.writable?.getWriter();
      if (writer !== null) {
        await writer.write(data);
        await writer.releaseLock();
      }
  // 작업을 마쳤다면 다시 포트를 닫습니다. 이 한줄로 충돌을 방지
      await port.close({ baudRate: 9600 });
  };</code></pre>
<p>하여, 하단에 한줄을 더 추가해 출력이 마치면 기존에 열어놓았던 포트 요청을 다시 닫도록 했습니다. </p>
<p><code>await port.close({ baudRate: 9600 })</code></p>
<hr>
<h2 id="🧾-출력-결과물">🧾 출력 결과물</h2>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/8a43c378-612a-4083-a90c-d2334045422a/image.jpg" alt=""></p>
<p>라이브러리를 발견한 순간부터 해당 기능을 구현하기까지 2시간 남짓 걸려서 테스트까지 모두 마칠 수 있었습니다. </p>
<p>웹에서 프린트 기능을 구현할 수 있냐는 리드 개발자 님의 우려를 빠르게 해치울 수 있어서 굉장히 통쾌한 기억으로 남을 것 같네요.</p>
<hr>
<h2 id="👹-오픈소스-라이브러리에-기여하자">👹 오픈소스 라이브러리에 기여하자</h2>
<p>이 블로그에 매번 콘텐츠를 작성하는 이유는 오픈 소스 라이브러리에 기여하기 위함입니다. 이번에는 현명하신 개발자 &quot;나석주&quot;님의 도움을 받아 업무를 일사천리로 해결할 수 있었습니다. </p>
<p>제가 도움을 받았듯이, 제가 발행하는 콘텐츠들이 다른 개발자 분들께 유용하게 다가갔으면 좋겠다는 마음을 다시 한 번 갖게 되었습니다. </p>
<hr>
<h2 id="이어지는-다음-포스팅">이어지는 다음 포스팅</h2>
<p><a href="https://velog.io/@yunsungyang-omc/React-%ED%8A%B8%EB%9F%AC%EB%B8%94-%EC%8A%88%ED%8C%85-Nicepay-%ED%8F%AC%EC%8A%A4%EA%B8%B0%EC%97%90%EC%84%9C-%ED%94%84%EB%A6%B0%ED%8A%B8-%EA%B8%B0%EB%8A%A5-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0">(React) 트러블 슈팅 : 카드리더기 프린터를 이용하는 환경에서 오토커팅 구현하기</a></p>
<h2 id="라이브러리-링크-모음">라이브러리 링크 모음</h2>
<ul>
<li>React to Print : <a href="https://www.npmjs.com/package/react-to-print">링크</a></li>
<li>React Thermal Printer : <a href="https://www.npmjs.com/package/react-thermal-printer">링크</a></li>
<li>나석주님이 발표하신 JSConf 2022 세션 영상 보기 : <a href="https://www.youtube.com/watch?v=FFolxFrUgoQ">링크</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[(React) 리액트 프로덕트에서 NICEPAY 결제 모듈 연동하기]]></title>
            <link>https://velog.io/@yunsungyang-omc/React-%EB%A6%AC%EC%95%A1%ED%8A%B8-%ED%94%84%EB%A1%9C%EB%8D%95%ED%8A%B8%EC%97%90%EC%84%9C-Nicepay-%EA%B2%B0%EC%A0%9C-%EB%AA%A8%EB%93%88-%EC%97%B0%EB%8F%99%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yunsungyang-omc/React-%EB%A6%AC%EC%95%A1%ED%8A%B8-%ED%94%84%EB%A1%9C%EB%8D%95%ED%8A%B8%EC%97%90%EC%84%9C-Nicepay-%EA%B2%B0%EC%A0%9C-%EB%AA%A8%EB%93%88-%EC%97%B0%EB%8F%99%ED%95%98%EA%B8%B0</guid>
            <pubDate>Tue, 20 Dec 2022 07:24:29 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/7e4c6034-dc71-474e-a465-2c6f01594763/image.png" alt=""></p>
<hr>
<p>3번째 커머스 프로젝트 &quot;푸드버스 스마트오더 어플리케이션&quot;을 개발했을때 겪은 우여곡절에 대한 글이다.</p>
<p>프로젝트 내내 결제 모듈 연동은 가장 중요한 기능이었기 때문에, 시니어 개발자께서 담당하셨다. 
하지만, 시니어께서 다른 프로젝트로 끌려간 기간동안, 
QA 단계에서 발생한 여러 에러와 성능이슈 때문에 코드 파악 및 기능 개선이 시급했졌다.
결국, 시니어를 기다리기 보다는 스스로 기능 개선을 하며 전체적인 리팩토링을 진행하게 되었다. </p>
<h2 id="nicepay-결제모둘-연동하기">NICEPAY 결제모둘 연동하기</h2>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/0411bf62-a16c-4608-9b3c-77cf300ce2cc/image.png" alt=""></p>
<p><a href="https://developers.nicepay.co.kr/manual-auth.php">공식메뉴얼</a></p>
<p>요즘 커머스 프로덕트를 개발할때는 아엠포트나 토스 페이먼츠, NHN 결제 시스템 등 조금 더 개발하기 수월한 환경을 제공하는 결제 모듈을 사용하려는 추세지만, 전통의 강자 &quot;나이스페이 결제 모듈&quot;은 저렴한 수수료와 레거시 개발 환경에 익숙한 인터페이스를 제공하고 있어 현재에도 폭넓게 사용되고 있다.</p>
<p>다만, 진행하고 있는 프로젝트는 <code>React</code> <code>Next.js</code> 기반으로 이루어져 있다보니, <code>JSP</code>예시 결제 모듈을 그대로 따르기엔 무리가 있었다. 하여, 하단부터는 초심자가 결제모듈을 연동하는데 참조할 수 있도록 차근 차근 단계를 밟아가려 한다.</p>
<h3 id="1️⃣-nicepay-스크립트-코드-import-하기">1️⃣ NICEPAY 스크립트 코드 import 하기</h3>
<p>결제창을 호출하기 위해서는 부모 컴포넌트(Next.js에서는 주로 이 작업을 Page 단에서 처리한다.)의 <code>&lt;Script /&gt;</code> 태그에 자바스크립트 코드를 임포트 해주어야 한다. </p>
<pre><code class="language-js">// src/pages/checkout/index.tsx

const Checkout = () =&gt; {
  ...
  return (
    &lt;CheckoutLayout&gt;
        &lt;Script
            src=&quot;https://web.nicepay.co.kr/v3/webstd/js/nicepay-3.0.js&quot;
            type=&quot;text/javascript&quot;
           /&gt;
        &lt;CheckoutForm /&gt;
    &lt;/CheckoutLayout&gt; 
  )
};</code></pre>
<p>이렇게 스크립트 코드를 임포트하게 되면 이제 Nicepay에서 제공하는 각종 함수(<code>goPay</code>, <code>niceSubmit</code>)를 사용할 수 있게 된다. </p>
<h3 id="2️⃣-gopay함수에-전달할-파라미터를-저장할-폼-엘리먼트-작성하기">2️⃣ <code>goPay()</code>함수에 전달할 파라미터를 저장할 폼 엘리먼트 작성하기</h3>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/d2471af3-7c18-40ac-9d7f-a4cd9fb9a6b8/image.png" alt=""></p>
<p>클라이언트에서 <code>goPay()</code>함수를 호출해 결제창 팝업을 호출하게 될때, <code>goPay()</code>함수의 파라미터로 결제에 필요한 정보를 form 데이터 형식에 담아 전달하게 된다. 하여 다음 단계에서는 <code>&lt;CheckoutForm /&gt;</code>컴포넌트 최하단에 정보를 담고 전달할 form 엘리먼트를 작성해줄 것이다.</p>
<pre><code class="language-js">// src/components/checkout/CheckoutForm.tsx
import React, { useState, useRef } from &#39;react&#39;;

const CheckoutForm = () =&gt; {
  const { form, setForm } = useState({}); 
  const formRef = useRef();
  ... 
  return (
    &lt;Container&gt;
      ...
      &lt;form
        name=&quot;payForm&quot;
        method=&quot;post&quot;
        action={ReturnURL}
        ref={formRef}
        acceptCharset=&quot;euc-kr&quot;
      &gt;
        &lt;input type=&quot;hidden&quot; name=&quot;GoodsName&quot; value={form.GoodsName} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;Amt&quot; value={form.Amt} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;MID&quot; value={form.MID} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;EdiDate&quot; value={form.EdiDate} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;Moid&quot; value={form.Moid} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;PayMethod&quot; value={form.PayMethod} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;MerchantKey&quot; value={form.MerchantKey} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;SignData&quot; value={form.SignData} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;BuyerName&quot; value={form.BuyerName} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;BuyerTel&quot; value={form.BuyerTel} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;BuyerEmail&quot; value=&quot;&quot; /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;ReturnURL&quot; value={form.ReturnURL} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;FailURL&quot; value={form.failURL} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;GoodsCl&quot; value={form.GoodsCl} /&gt;
      &lt;/form&gt;
    &lt;/Container&gt;
  )
}</code></pre>
<p>이제 우리는 useState를 통해 결제 api의 response로 받아온 값을 정형해 그 값을 useRef의 current값에 전달함으로써, 폼 데이터를 저장할 것이다. useRef를 이용해서 htmlElement를 조작하면, 업데이트 할 객체의 값을 자동으로 찾아 업데이트 해주기 때문에 상당히 유용하다. </p>
<p>[[React] ref로 HTML 엘리먼트에 접근/제어하기] (<a href="https://www.daleseo.com/react-refs/">https://www.daleseo.com/react-refs/</a>)</p>
<p><code>&lt;input /&gt;</code>엘리먼트의 각 값에 대한 정의는 아래와 같다. 이중 하나도 누락되지 않도록 전달되도록 주의해야 한다. 
<img src="https://velog.velcdn.com/images/yunsungyang-omc/post/80c33386-7887-4621-bfdc-5ddd12666dde/image.png" alt=""></p>
<h3 id="3️⃣-결제-성공-시에-실행할-콜백-함수-정의하기">3️⃣ 결제 성공 시에 실행할 콜백 함수 정의하기</h3>
<p>나이스 페이 결제 모듈을 불러오는데 성공하고, 결제 실패 유무에 따라 페이지를 라우팅하거나 Alert 메시지를 띄워줘야 할 경우를 고려해서 각각 상태에 대응하는 콜백함수를 정의 후 이를 전달할 수 있다.</p>
<pre><code class="language-js">//src/util/index.ts
export const convertFormToObj = (form) =&gt; {
    const obj = {};
    Object.keys(form).map((k) =&gt; {
      if (!parseInt(k)) return;
      const { name, value } = form[k];
      obj[name] = value;
    });
    return obj;
  };</code></pre>
<pre><code class="language-js">// src/components/checkout/CheckoutForm.tsx
import React, { useState, useEffect, useRef } from &#39;react&#39;;
import { useRouter } from &quot;next/router&quot;;
import { convertFormToObj } from &quot;src/util&quot;;

const CheckoutForm = () =&gt; {
    const { form, setForm } = useState({}); 
    const formRef = useRef();
    const router = useRouter();

    // 결제 후 성공 callback
    const nicepaySubmit = async () =&gt; {
      setAlertType(&quot;success&quot;);
      setAlertMsg(&quot;결제에 성공했습니다.&quot;);
      sendPaymentResult(true);
    };
    // 결제 후 실패 callback
    const nicepayClose = async () =&gt; {
      // TODO: 결제 실패시 처리
      setAlertType(&quot;error&quot;);
      setAlertMsg(&quot;결제를 다시 시도해주세요&quot;);
      sendPaymentResult(false);
    };
    const sendPaymentResult = async () =&gt; {
      const body = convertFormToObj(formRef.current);
      body.success = success;
      ...
      if (success) {
        window.deleteLayer();
        router.push(&quot;/payment/complete&quot;)
      }
    };

    useEffect(() =&gt; {
      if (Object.keys(form).length === 0) return;
      if (isMobile) {
        // 모바일 결제창 진입
        formRef.current.action = &quot;https://web.nicepay.co.kr/v3/v3Payment.jsp&quot;;
        formRef.current.acceptCharset = &quot;euc-kr&quot;;
        formRef.current.submit();
      } else {
          // PC 결제창 진입
        if (typeof window !== &quot;undefined&quot;) {
          window.nicepaySubmit = nicepaySubmit;
          window.nicepayClose = nicepayClose;
          window.goPay(formRef.current);
          }
      }
    }, [form]);

    return (
      &lt;Container&gt;
        ...
        &lt;form
          name=&quot;payForm&quot;
          method=&quot;post&quot;
          action={ReturnURL}
          ref={formRef}
          acceptCharset=&quot;euc-kr&quot;
        &gt;
          &lt;input type=&quot;hidden&quot; name=&quot;GoodsName&quot; value={form.GoodsName} /&gt;
          &lt;input type=&quot;hidden&quot; name=&quot;Amt&quot; value={form.Amt} /&gt;
          &lt;input type=&quot;hidden&quot; name=&quot;MID&quot; value={form.MID} /&gt;
          &lt;input type=&quot;hidden&quot; name=&quot;EdiDate&quot; value={form.EdiDate} /&gt;
          &lt;input type=&quot;hidden&quot; name=&quot;Moid&quot; value={form.Moid} /&gt;
          &lt;input type=&quot;hidden&quot; name=&quot;PayMethod&quot; value={form.PayMethod} /&gt;
          &lt;input type=&quot;hidden&quot; name=&quot;MerchantKey&quot; value={form.MerchantKey} /&gt;
          &lt;input type=&quot;hidden&quot; name=&quot;SignData&quot; value={form.SignData} /&gt;
          &lt;input type=&quot;hidden&quot; name=&quot;BuyerName&quot; value={form.BuyerName} /&gt;
          &lt;input type=&quot;hidden&quot; name=&quot;BuyerTel&quot; value={form.BuyerTel} /&gt;
          &lt;input type=&quot;hidden&quot; name=&quot;BuyerEmail&quot; value=&quot;&quot; /&gt;
          &lt;input type=&quot;hidden&quot; name=&quot;ReturnURL&quot; value={form.ReturnURL} /&gt;
          &lt;input type=&quot;hidden&quot; name=&quot;FailURL&quot; value={form.failURL} /&gt;
          &lt;input type=&quot;hidden&quot; name=&quot;GoodsCl&quot; value={form.GoodsCl} /&gt;
        &lt;/form&gt;
      &lt;/Container&gt;
    );
};</code></pre>
<p>NICEPAY 결제모듈은 모바일과 PC 결제창 두 가지 타입으로 호출하는 방식을 구분되어 있다. 때문에 상태관리되고 있는 form 객체의 정보가 업데이트 될때 <code>useEffect</code>로 사이드 이펙트 제어를 분기처리해서 진행할 수 있다. </p>
<p>모바일 결제창 모듈은 <code>formRef</code>의 <code>current</code>객체를 참조하는 반면, PC 결제창 모듈은 window의 document 객체에 성공 콜백과 실패 콜백을 체이닝하고, <code>goPay()</code>함수를 호출하는 방식으로 구동한다.  </p>
<p>유틸 함수를 통해서 formRef 객체의 키 값중 success 값에 따라 transaction을 업데이트 하는 등의 추가 api 요청과 라우팅 등은 <code>sendPaymentResult()</code>함수에서 실행하도록 로직을 분리했다. </p>
<h3 id="4️⃣-결제-api의-response를-form-데이터로-정형해서-전달하기setform">4️⃣ 결제 api의 response를 form 데이터로 정형해서 전달하기(setForm)</h3>
<pre><code class="language-js">// src/hooks/useCheckout.ts
import React, { useState, useEffect, useRef } from &#39;react&#39;;
import { useMutation } from &quot;react-query&quot;;
import { useAtom } from &quot;jotai&quot;;
import { CartStateAtom, CartAllDataAtom } from &quot;src/atoms/Atoms&quot;;

const useCheckout = () =&gt; {
  const [state, setState] = useAtom(CartStateAtom);
  ...
  const checkoutForMe = () =&gt; {
    const mutation = useMutation(() =&gt; {
      // 결제 요청 후 200 status 를 반환받으면 state의 cart 값을 업데이트 시킨다.
      const res = await cartApi.checkoutForMe();
      return res.data;
    });
    return mutation
  };
  return {
    state,
    setState,
    checkoutForMe
  };
};</code></pre>
<pre><code class="language-js">// src/components/checkout/CheckoutForm.tsx
import useCheckout from &quot;src/hooks/useCheckout&quot;;

const CheckoutForm = () =&gt; {
    const { state, setstate, checkoutForMe } = useCheckout();
    const { mutate, isLoading, isSuccess, success } = checkoutForMe();

      const handleCheckout = () =&gt; {
      ...
      // 결제 관련 valiation 로직을 수행한 후 주문을 생성하는 api로 요청한다. 
      createOrder();
    }

    const createOrder = () =&gt; {
      try {
        mutate();
        // 결제 요청에 성공하면, 결제 유무를 판단하는 atom State의 불리언 값을 변경시킨다.
        setState((state) =&gt; ({
         ...state,
         isCheckout, !state.isCheckout;
        }));
      } catch (err) {
        console.log(err);
      }
    };

   const createForm = (order) =&gt; {
        const EdiDate = moment().format(&quot;YYYYMMDDHHMMSS&quot;);
        const MID = process.env.NEXT_PUBLIC_MID;
        const Moid = order._id;
        const TotalAmount = order.total.amount;
        const TotalTaxFree = order.totalTaxFree;
        const TotalTaxedAmount = TotalAmount - TotalTaxFree;
        const TotalSupplyAmt = Math.ceil(TotalTaxedAmount / 1.1);
        const TotalVAT = TotalTaxedAmount - TotalSupplyAmt;
        const Amt = TotalAmount + ShippingFee;
        const MerchantKey = process.env.NEXT_PUBLIC_MERCHANT_KEY;
        const TestData = EdiDate + MID + Amt + MerchantKey;
        const SignData = cryptoJs.SHA256(TestData).toString();
        const successUrl = encodeURIComponent(
          `${window.location.origin}/payment/complete?isMobile=${isMobile}`
        );
        const failUrl = encodeURIComponent(
          `${window.location.origin}/payment/complete?isMobile=${isMobile}`
        );
        const returnUrl = isMobile ? process.env.NEXT_PUBLIC_RETUN_Mobile_URL : process.env.NEXT_PUBLIC_RETURN_URL
        setReturnURL(returnUrl);
          // 파라미터로 전달된 값에 따라 전달할 정보를 담아 form 상태를 업데이트 시킨다.
        setForm({
        // 상품이름
        GoodsName,
        // 결제 금액
        Amt,
        // 상점 아이디
        MID,
        // 전문생성일시
        EdiDate,
        // 상점 주문번호 상점 고유 식별값
        Moid,
        MerchantKey,
        SignData,
        BuyerName: order.address.billing.name.full,
        BuyerTel: order.address.billing.name.phone,
        BuyerEmail: &quot;&quot;,
        // 아래 값들중 하나 [CARD, VBANK, SSG_BANK, GIFT_CULT]
        PayMethod: &quot;CARD&quot;,
        ReturnURL: returnUrl,
        failURL: failUrl,
        // 휴대폰 소액결제 추가 요청 파라미터 (0 = 컨텐츠, 1 = 현물)
        GoodsCl: 1,
        SupplyAmt: TotalSupplyAmt,
        GoodsVat: TotalVAT,
        TaxFreeAmt: TotalTaxFree,
      });
   }

  useEffect(() =&gt; {
      // 결제 성공과 mutation의 상태를 판단하는 지표를 사용해서 폼 데이터를 업데이트 하는 로직을 실행한다.
    if (state.order &amp;&amp; !isLoading &amp;&amp; state.isCheckout) {
      createForm(state.order)
    }
  }, [state.order]);

  return (
    &lt;Container&gt;
        &lt;ButtonWrap&gt;
            &lt;Button onClick={() =&gt; handleCheckout()} /&gt; 결제하기 &lt;/Button&gt;
        &lt;/ButtonWrap&gt;
        &lt;form&gt;
          ...
          &lt;/form&gt;
      &lt;/Container&gt;  
  );
};</code></pre>
<p>결제하기 버튼을 클릭해서 온클릭 함수<code>handleCheckout()</code> 를 실행하면 다음과 같은 로직이 실행될 것이다.</p>
<pre><code>1. 결제를 위한 validate 로직이 실행된다.
2. validate 완료 후 mutate 함수와 setState 함수를 실행시킨다. 
3. mutate 함수 결과에 따라 useEffect로 사이드를 제어해, createForm 함수를 실행시킨다.
4. createForm 함수의 결과로 상태 관리되는 form 객체의 값이 채워진다. 
5. useEffect의 디펜던시로 form을 넣었두었기 때문에 아래 로직이 실행되며, 결제 모듈을 불러온다.</code></pre><pre><code class="language-js">// src/components/checkout/CheckoutForm.tsx 

...
    useEffect(() =&gt; {
      if (Object.keys(form).length === 0) return;
      if (isMobile) {
        // 모바일 결제창 진입
        formRef.current.action = &quot;https://web.nicepay.co.kr/v3/v3Payment.jsp&quot;;
        formRef.current.acceptCharset = &quot;euc-kr&quot;;
        formRef.current.submit();
      } else {
          // PC 결제창 진입
        if (typeof window !== &quot;undefined&quot;) {
          window.nicepaySubmit = nicepaySubmit;
          window.nicepayClose = nicepayClose;
          window.goPay(formRef.current);
          }
      }
    }, [form]);
...</code></pre>
<hr>
<h2 id="🧐-정리하며">🧐 정리하며</h2>
<p>이해를 쉽게 하기 위해, 함수를 분리하지 않고 최대한 절차 중심으로 코드를 구성하려고 노력해보았다. 만약 여기서 더 리팩토링할 기회가 있다면, form 상태 또한 jotai의 useAtom을 이용해서 관리할 것이고, 따라서 <code>createForm</code> 함수 등 컴포넌트 뷰 단에 함께 존재하지 않아도 될 함수들은 관심사 분리를 해서 파일을 분리할 것 같다. </p>
<p>이 글을 통해 nicePay 결제 모듈을 연동하는 누군가는 반드시 그렇게 해보길 바란다. 
아쉽게도 이 프로젝트는 여기서 끝을 맺어, 수정이 어렵게 되었지만 조금 더 나은 방안이 떠오른다면 언제든지 댓글로 의견을 주면 정말 감사할 것 같다. </p>
<p>더불어, 누군가에게 이 글이 꼭 도움이 되길 바란다.</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/6fa5838a-a206-4af3-b7c0-082806630065/image.png" alt=""></p>
<hr>
<h3 id="전체-코드">전체 코드</h3>
<pre><code class="language-js">// src/hooks/useCheckout.ts
import React, { useState, useEffect, useRef } from &#39;react&#39;;
import { useMutation } from &quot;react-query&quot;;
import { useAtom } from &quot;jotai&quot;;
import { CartStateAtom, CartAllDataAtom } from &quot;src/atoms/Atoms&quot;;

const useCheckout = () =&gt; {
  const [state, setState] = useAtom(CartStateAtom);
  ...
  const checkoutForMe = () =&gt; {
    const mutation = useMutation(() =&gt; {
      // 결제 요청 후 200 status 를 반환받으면 state의 cart 값을 업데이트 시킨다.
      const res = await cartApi.checkoutForMe();
      return res.data;
    });
    return mutation
  };
  return {
    state,
    setState,
    checkoutForMe
  };
};</code></pre>
<pre><code class="language-js">//src/util/index.ts
export const convertFormToObj = (form) =&gt; {
    const obj = {};
    Object.keys(form).map((k) =&gt; {
      if (!parseInt(k)) return;
      const { name, value } = form[k];
      obj[name] = value;
    });
    return obj;
  };</code></pre>
<pre><code class="language-js">// src/pages/checkout/index.tsx
const Checkout = () =&gt; {
  ...
  return (
    &lt;CheckoutLayout&gt;
        &lt;Script
            src=&quot;https://web.nicepay.co.kr/v3/webstd/js/nicepay-3.0.js&quot;
            type=&quot;text/javascript&quot;
           /&gt;
        &lt;CheckoutForm /&gt;
    &lt;/CheckoutLayout&gt; 
  );
};</code></pre>
<pre><code class="language-js">// src/components/checkout/CheckoutForm.tsx
import React, { useState, useRef } from &#39;react&#39;;
import { useRouter } from &quot;next/router&quot;;
import useCheckout from &quot;src/hooks/useCheckout&quot;;
import { convertFormToObj } from &quot;src/util&quot;;

const CheckoutForm = () =&gt; {
    const { form, setForm } = useState({}); 
    const formRef = useRef();
    const router = useRouter();
    const { state, setstate, checkoutForMe } = useCheckout();

    // 결제 후 성공 callback
    const nicepaySubmit = async () =&gt; {
      setAlertType(&quot;success&quot;);
      setAlertMsg(&quot;결제에 성공했습니다.&quot;);
      sendPaymentResult(true);
    };

    // 결제 후 실패 callback
    const nicepayClose = async () =&gt; {
      // TODO: 결제 실패시 처리
      setAlertType(&quot;error&quot;);
      setAlertMsg(&quot;결제를 다시 시도해주세요&quot;);
      sendPaymentResult(false);
    };

    const sendPaymentResult = async () =&gt; {
      const body = convertFormToObj(formRef.current);
      body.success = success;
      ...
      if (success) {
        window.deleteLayer();
        router.push(&quot;/payment/complete&quot;)
      }
    };

      const handleCheckout = () =&gt; {
      ...
      // 결제 관련 valiation 로직을 수행한 후 주문을 생성하는 api로 요청한다. 
      createOrder();
    }

    const createOrder = () =&gt; {
      try {
        mutate();
        // 결제 요청에 성공하면, 결제 유무를 판단하는 atom State의 불리언 값을 변경시킨다.
        setState((state) =&gt; ({
         ...state,
         isCheckout, !state.isCheckout;
        }));
      } catch (err) {
        console.log(err);
      }
    };

    const createForm = (order) =&gt; {
      const EdiDate = moment().format(&quot;YYYYMMDDHHMMSS&quot;);
      const MID = process.env.NEXT_PUBLIC_MID;
      const Moid = order._id;
      const TotalAmount = order.total.amount;
      const TotalTaxFree = order.totalTaxFree;
      const TotalTaxedAmount = TotalAmount - TotalTaxFree;
      const TotalSupplyAmt = Math.ceil(TotalTaxedAmount / 1.1);
      const TotalVAT = TotalTaxedAmount - TotalSupplyAmt;
      const Amt = TotalAmount + ShippingFee;
      const MerchantKey = process.env.NEXT_PUBLIC_MERCHANT_KEY;
      const TestData = EdiDate + MID + Amt + MerchantKey;
      const SignData = cryptoJs.SHA256(TestData).toString();
      const successUrl = encodeURIComponent(
        `${window.location.origin}/payment/complete?isMobile=${isMobile}`
      );
      const failUrl = encodeURIComponent(
        `${window.location.origin}/payment/complete?isMobile=${isMobile}`
      );
      const returnUrl = isMobile ? process.env.NEXT_PUBLIC_RETUN_Mobile_URL : process.env.NEXT_PUBLIC_RETURN_URL
      setReturnURL(returnUrl);
      // 파라미터로 전달된 값에 따라 전달할 정보를 담아 form 상태를 업데이트 시킨다.
      setForm({
        // 상품이름
        GoodsName,
        // 결제 금액
        Amt,
        // 상점 아이디
        MID,
        // 전문생성일시
        EdiDate,
        // 상점 주문번호 상점 고유 식별값
        Moid,
        MerchantKey,
        SignData,
        BuyerName: order.address.billing.name.full,
        BuyerTel: order.address.billing.name.phone,
        BuyerEmail: &quot;&quot;,
        // 아래 값들중 하나 [CARD, VBANK, SSG_BANK, GIFT_CULT]
        PayMethod: &quot;CARD&quot;,
        ReturnURL: returnUrl,
        failURL: failUrl,
        // 휴대폰 소액결제 추가 요청 파라미터 (0 = 컨텐츠, 1 = 현물)
        GoodsCl: 1,
        SupplyAmt: TotalSupplyAmt,
        GoodsVat: TotalVAT,
        TaxFreeAmt: TotalTaxFree,
      });
   }

    useEffect(() =&gt; {
      // 결제 성공과 mutation의 상태를 판단하는 지표를 사용해서 폼 데이터를 업데이트 하는 로직을 실행한다.
      if (state.order &amp;&amp; !isLoading &amp;&amp; state.isCheckout) {
        createForm(state.order)
      }
    }, [state.order]);

    useEffect(() =&gt; {
      // 폼이 빈 객체일때는 실행하지 않는다.
      if (Object.keys(form).length === 0) return;
      if (isMobile) {
        // 모바일 결제창 진입
        formRef.current.action = &quot;https://web.nicepay.co.kr/v3/v3Payment.jsp&quot;;
        formRef.current.acceptCharset = &quot;euc-kr&quot;;
        formRef.current.submit();
      } else {
          // PC 결제창 진입
        if (typeof window !== &quot;undefined&quot;) {
          window.nicepaySubmit = nicepaySubmit;
          window.nicepayClose = nicepayClose;
          window.goPay(formRef.current);
          }
      }
    }, [form]);

  ... 
  return (
    &lt;Container&gt;
      ...
      &lt;ButtonWrap&gt;
         &lt;Button onClick={() =&gt; handleCheckout()} /&gt; 결제하기 &lt;/Button&gt;
      &lt;/ButtonWrap&gt;
      ...
      &lt;form
        name=&quot;payForm&quot;
        method=&quot;post&quot;
        action={ReturnURL}
        ref={formRef}
        acceptCharset=&quot;euc-kr&quot;
      &gt;
        &lt;input type=&quot;hidden&quot; name=&quot;GoodsName&quot; value={form.GoodsName} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;Amt&quot; value={form.Amt} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;MID&quot; value={form.MID} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;EdiDate&quot; value={form.EdiDate} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;Moid&quot; value={form.Moid} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;PayMethod&quot; value={form.PayMethod} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;MerchantKey&quot; value={form.MerchantKey} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;SignData&quot; value={form.SignData} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;BuyerName&quot; value={form.BuyerName} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;BuyerTel&quot; value={form.BuyerTel} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;BuyerEmail&quot; value=&quot;&quot; /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;ReturnURL&quot; value={form.ReturnURL} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;FailURL&quot; value={form.failURL} /&gt;
        &lt;input type=&quot;hidden&quot; name=&quot;GoodsCl&quot; value={form.GoodsCl} /&gt;
      &lt;/form&gt;
    &lt;/Container&gt;
  )
}</code></pre>
]]></description>
        </item>
        <item>
            <title><![CDATA[(Zustand) 간단하게 Zustand 적용해보기]]></title>
            <link>https://velog.io/@yunsungyang-omc/Zustand-%EA%B0%84%EB%8B%A8%ED%95%98%EA%B2%8C-Zustand-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0</link>
            <guid>https://velog.io/@yunsungyang-omc/Zustand-%EA%B0%84%EB%8B%A8%ED%95%98%EA%B2%8C-Zustand-%EC%A0%81%EC%9A%A9%ED%95%B4%EB%B3%B4%EA%B8%B0</guid>
            <pubDate>Mon, 19 Dec 2022 07:20:20 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/19ded547-cbae-4e84-9357-10ec3dc07633/image.png" alt=""></p>
<hr>
<p>이직을 준비하는 과정에서 새로운 상태관리를 적용해볼 필요가 있어, zustand를 사용했다. 
이전 진행했던 프로젝트에서 상태관리 라이브러리 Jotai를 사용해본 경험이 있어서 그리 어렵지 않게 사용할 수 있었다. 
(놀랍게도, 두 라이브러리 이름 모두 &quot;상태&quot; 이다. Zustand 제작에 Jotai 제작팀도 참여했다고 한다.)</p>
<hr>
<h2 id="간단하게-zustand-적용해보기">간단하게 Zustand 적용해보기</h2>
<p>Zustand 스토어를 만들때는<code>create</code>함수를 이용해서 상태와 상태를 변경하는 액션 함수를 정의한다. 이렇게 정의된 상태와 액션은 훅으로 리턴해서 사용할 수 있다. </p>
<pre><code class="language-ts">// src/store/useStore.ts
import create &quot;zustand&quot;
import { CartType } from &quot;src/types&quot;

export const useStore = create&lt;CartType&gt;(
    (set) =&gt; {
      // initial한 상태를 정의한다.
      cart: [],
    },
    // 상태를 변경하는 액션을 정의한다.
    addItem : (item) =&gt; ({
      set((state) =&gt; ({
        ....state.cart,
        cart: item,
      }));
    }),
      removeItem: (id) =&gt; {
        set((state) =&gt; ({
        cart: state.cart.filter((item) =&gt; item.id !== id),
      }));
    },  
  })
);</code></pre>
<p>먼저 create 함수를 통해서, initail한 상태를 정의 후에 이어 현재 상태를 인자로 넘겨주는 형식으로 다음 상태를 정의하는 액션을 정의할 수 있다.</p>
<p>스토어의 상태와 액션은  <code>Subscribe</code>함수를 사용해서 상태의 모든 변화 혹은 일부를 구독할 수 있지만, 나는 React 컴포넌트에서 직접 사용할 수 있도록 훅으로 만들었다.</p>
<p>위 상태와 액션은 컴포넌트 단에서 아래와 같이 사용할 수 있다. </p>
<pre><code class="language-ts">// src/components/cart.tsx

import React from &#39;react&#39;
import {useStore} from &#39;src/store/useStore&#39;

const CartItems = () =&gt; {
  const { cart, addItem, removeItem } = useStore();

  ...

  return(
    &lt;div&gt;
        {
          cart &amp;&amp; cart.map((item, index) =&gt; {
             return (
              &lt;&gt;
                &lt;ItemCard item={item} id={item.id}/&gt;
                &lt;div&gt;
                 &lt;button onClick={() =&gt; addItem()}&gt;담기 &lt;/button&gt;
                 &lt;button onClick={() =&gt; removeItem()}&gt;빼기&lt;/button&gt;
               &lt;/div&gt; 
             &lt;/&gt;
          )});
         }
    &lt;/div&gt;
  )    
}</code></pre>
<p>아주 간단하게 상태를 참조해서 카트의 아이템과 카트 아이템을 추가하거나 제거할 수 있는 컴포넌트를 렌더링해보았다. 
위처럼 Zustand는 상태와 액션을 훅의 형태로 이용할 수 있기 때문에 Redux의 그것과는 달리 보일러플레이트 없이 간결하게 상태를 이용하거나 변경할 수 있다.</p>
<hr>
<h3 id="zustand-persist">Zustand Persist</h3>
<p>전역상태는 페이지가 refresh 될때 초기화된다. 하여, 상태를 기억하게 하기 위해서 갖가지 방법이 동원되는데 Zustand에서는 <code>persist</code> 미들웨어를 지원해주고 있어 상태를 쉽게 보존할 수 있다. </p>
<pre><code class="language-ts">// src/store/useStore.ts
import create &quot;zustand&quot;
import { CartType } from &quot;src/types&quot;
import { persist } from &#39;zustand/middleware&#39;;
import { StateCreator } from &#39;zustand&#39;;
import { PersistOptions } from &#39;zustand/middleware&#39;;

type PersisType = (
  config: StateCreator&lt;CartType&gt;,
  options: PersistOptions&lt;CartType&gt;,
) =&gt; StateCreator&lt;CartType&gt;;


export const useStore = create&lt;CartType&gt;(
  //상태와 액션을 persist 미들웨어로 감싸줍시다.
 (persis as PersisType)(
    (set) =&gt; {
      // initial한 상태를 정의한다.
      cart: [],
    },
    // 상태를 변경하는 액션을 정의한다.
    addItem : (item) =&gt; ({
      set((state) =&gt; ({
        ....state.cart,
        cart: item,
      }));
    }),
      removeItem: (id) =&gt; {
        set((state) =&gt; ({
        cart: state.cart.filter((item) =&gt; item.id !== id),
      }));
    },  
  }),
  // persist 미들웨어로 Storage에 저장할 상태 명을 정의한다.
  {name :&#39;user_CartStore&#39;},
  ),
);</code></pre>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/67fbd9b8-4926-452c-a306-5b79b59e4959/image.png" alt=""></p>
<p>페이지를 리프레쉬했을때 이렇게 로컬스토리지에 정의한 키 네임으로 상태들이 값으로 잘 저장되고 있는 것을 확인할  수 있다. </p>
<h3 id="🧲--one-more-thing--nextjs에서-persist-값-사용하기">🧲  one more thing : Next.js에서 persist 값 사용하기</h3>
<p>Next.js에서 해당 값이 저장된 것을 확인할 수 있겠지만, 상태 그대로를 컴포넌트에 참고해서 사용할 수 없다.
아마 페이지를 리프레쉬하면,</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/b260e1f8-2148-49d6-ab0a-e63eeab06d19/image.png" alt=""></p>
<p>다음과 같은 에러메시지를 만나게 될 것이다.
Next.js는 서버사이드에서 페이지를 프리랜더링하게 되는데, 로컬스토리지에 있는 값은 클라이언트 사이드에서만 접근가능하다. 하여 undefined한 값들을 참조하게 되기 때문이다. </p>
<p>이를 해결하기 위해서 방법들을 찾던 중에 마침 비슷한 문제로 골머리를 앓던 분의 <a href="https://kbwplace.tistory.com/152">포스트</a>를 찾게 되어, 문제를 해결할 수 있었다. </p>
<pre><code class="language-ts">// src/hooks/useCart.ts
import { useEffect, useState } from &#39;react&#39;;
import { useStore } from &#39;src/store/useStore&#39;;
import { ProductInfoType } from &#39;src/type/index&#39;;

const useCart = () =&gt; {
  const [userCart, setUserCart] = useState&lt;ProductInfoType[]&gt;([]);
  const { cart } = useCartStore();

  useEffect(() =&gt; {
    const loacalCartData = localStorage.getItem(&#39;user_CartStore&#39;);
    if (loacalCartData !== null) {
      const restoreCartData = JSON.parse(loacalCartData);
      if (restoreCartData.state.cart.length !== 0) {
        setUserCart(restoreCartData.state.cart);
      }
    }
  }, []);
  // 해당 훅을 통해 카트 상태에 변경이 있었을 때 반영할 수 있따.
  useEffect(() =&gt; {
    setUserCart(cart);
  }, [cart]);

  return { userCart };
};

export default useCart;</code></pre>
<p>원 글의 저자도 밝혔듯이 명확하게 문제를 해결한 것은 아니지만, 로컬스토리지에 저장된 값을 반영해서 업데이트한 상태를 참고할 수 있어 최선의 방법인 것 같다. </p>
<hr>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/226ee986-500c-4dfd-9503-07525b60c551/image.png" alt=""></p>
<h3 id="🧐-과연-redux의-미래는">🧐 과연 Redux의 미래는..?</h3>
<p>여전히 많은 프로젝트에서 리덕스와 리덕스 툴킷이 사용되고 있고, 그에 따라 비동기를 제거하기 위한 <code>Redux thunk</code>나 <code>Redux saga</code>패턴을 적용해서 액션 함수를 정의하고 있다. </p>
<p>물론, 위의 방법대로 프로젝트를 구성한다고 해서 문제가 있는 것은 아니다. 다만, jotai, recoil, Zustand같은 차세대 상태관리 라이브러리의 출현과 React-Query라는 강력한 서버 상태 관리 라이브러리가 자리잡은 지금 굳이 Redux를 고집할 필요는 없다고 생각한다. </p>
<p>코드란 결국 수많은 라인을 한 데 뭉쳐 하나의 프로덕트를 구성하는 요소이고, 이 요소는 코드를 작성한 작성자 뿐만 아니라 공동작업자들도 직관적으로 확인할 수 있어야 한다. 그런 점에서 코드의 흐름이 간결할 뿐만 아니라, 보일러 플레이트를 최소화 할 수 있는 Zustand 등 차세대 라이브러리 삼총사를 필히 경험해보라 추천하고 싶다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[(회고) 입사부터 현재까지]]></title>
            <link>https://velog.io/@yunsungyang-omc/%ED%9A%8C%EA%B3%A0-%EC%9E%85%EC%82%AC%EB%B6%80%ED%84%B0-%ED%98%84%EC%9E%AC%EA%B9%8C%EC%A7%80</link>
            <guid>https://velog.io/@yunsungyang-omc/%ED%9A%8C%EA%B3%A0-%EC%9E%85%EC%82%AC%EB%B6%80%ED%84%B0-%ED%98%84%EC%9E%AC%EA%B9%8C%EC%A7%80</guid>
            <pubDate>Tue, 06 Dec 2022 17:21:22 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/f6ce88a1-bae2-424b-9d01-7284faee9b41/image.png" alt=""></p>
<hr>
<h2 id="입사한-이후로-벌써-8개월이-지났습니다-😇">입사한 이후로 벌써 8개월이 지났습니다. 😇</h2>
<p>블로그 회고 마지막 글이 취업을 알리는 글이었는데, 벌써 8개월이 지났다. 입사하고 나서 나름 한 두 달 사이에는 블로그에 글도 작성했는데.. 본격적으로 제품 개발에 들어가고 나서는 <strong>정신적인 여유</strong>가 나지 않아 블로그 콘텐츠 작성에 소홀해질 수 밖에 없었다. </p>
<p>연봉 재협상 시즌인 만큼 현재까지 진행했던 성과를 바탕으로 회사와 이야기를 나눌 기회가 생겨 그간 진행했던 프로젝트를 모두 정리(<a href="https://hodoopapa.oopy.io/72ff1df6-4819-4c1b-a1fd-eb74bacf39ab">포트폴리오 링크</a>)하고, 기억이 잊혀지기 전에 회고를 남기게 되었다.</p>
<h3 id="4월--온보딩-및-nextjs-탐구하기-💻">4월 : 온보딩 및 Next.js 탐구하기 💻</h3>
<p>첫 입사 후 팀에서 진행되는 다양한 프로젝트에 대해서 현황 및 적용 스택 등에 대해서 안내를 받을 수 있었다.
작년 하반기에 시리즈 A를 막 유치한 회사답게 굉장히 다양한 프로젝트가 진행 중에 있었는데, 이중 <strong>커머스 프로젝트</strong>에 투입되었다. </p>
<p>단일 React 기반으로 제작이 거의 완료된 커머스 프로덕트가 어떤 이유로 엎어지고 처음부터 다시 시작되게 되었다. 기획이 준비되는 동안 손만 빨고 있을 수 없었기 때문에, react 앱을 <code>Next.js</code>로 마이그레이션을 해보라는 첫 업무를 받게 되었다. </p>
<p>(CSR 앱을 단순히 서버사이드 랜더링 가능하게 바꾸는 라이브러리 정도로 생각했었는데, 마이그레이션이 그리 호락호락 하지 않았던 것 같다. <a href="https://velog.io/@yunsungyang-omc/Next.js-%EC%97%90%EB%9F%AC%EB%A1%9C%EA%B7%B8-window-is-not-defined">관련 포스트 링크</a>)</p>
<h3 id="5월---8월--본격적으로-커머스-프로젝트-밈즈샵-개발-👨🏼💻">5월 - 8월 : 본격적으로 커머스 프로젝트, 밈즈샵 개발 👨🏼‍💻</h3>
<p>5월에 들어서자마자 본격적으로 커머스 프로젝트가 가동되었는데, 기존 앱의 한계로 지적되었던 SEO 최적화 부분을 개선해달라는 사업팀의 요구를 받아 서버사이드 랜더링을 구현가능하도록 도와주는 프레임워크 <code>Next.js</code>를 적용하기로 했다. </p>
<p>더불어, 패드 기반 서비스를 제공하는 회사이기 때문에, 패드와 모바일 기기에서 모두 반응형 UI가 원활하게 있게 해달라는 요청도 있어 Css 라이브러리로 <code>Material UI</code>를 사용하게 되었다.</p>
<p>상태관리로는 시니어 개발자 분들이 관심있게 학습하셨던 <code>Jotai</code>를 도입하기로 했고, 꽤 뿌듯하게도 서버 상태 관리를 위해 기존에 주로 사용하던 RTK가 아닌 <code>React Query</code>를 적용하자는 내 의견이 받아들여졌다. 🙇🏽‍♂️</p>
<p>엎고 새로 시작한 프로젝트이니 만큼 <strong>빠르게 개발이 완료</strong>되도록 일정이 굉장히 타이트했다. 
레이아웃을 잡을 시간도 없어서 MUI에서 유료로 제공하는 커머스 템플릿을 사용하기로 했고, 서버 팀의 Api도  막연하게 기다릴 수 있는 상황이 안돼 <code>Clayful의 헤드리스 커머스 api</code>를 사용해 기능을 구현하기로 했다.</p>
<p><em>*<em>(임시 방편이었지만, 결국 서버팀은 커머스 프로젝트에서 제외되었고 헤드리스 커머스 형태로 최종 진행된다.)
*</em></em></p>
<p>내가 맡은 부분은 처음에는 아주 간단한 <strong>&quot;마이페이지&quot;를 구현</strong>하는 것으로 시작해 <strong>취소 / 환불 기능을 전담</strong>하게 되었다.  UI가 당초 약속했던 것과는 다르게 기획팀에서 아주 다양하게 변주를 주었기 때문에 <strong>컴포넌트 재사용</strong>을 고려할 틈새도 없이 페이지를 찍어냈어야 했다는 점을 제외하고는 꽤 수월하게 개발을 진행할 수 있었다. </p>
<p><strong>Clayful</strong> 사에게는 유감이지만, 개발자 문서에서 제공하는 범위가 제한적이라 이메일 문의를 해가면서 많은 부분을 해결해야 했다. 8월 통째로 매달렸던 <strong>부분 환불 / 부분 취소</strong> 기능은 구현에 성공했지만, PO께서 결국 스펙아웃 처리를 해버려서 전부 들어내야 했다. 😤 많은 아쉬움이 남았다.</p>
<p>그래도 3개월 남짓한 기간동안, 헤드리스 커머스 형태의 프로덕트가 만들어질 수 있었다. </p>
<p><a href="https://meemzshop.com/">밈즈샵 링크</a></p>
<h3 id="9월---fb-협력사의-샵-제작하기-🎂">9월  : F&amp;B 협력사의 샵 제작하기 🎂</h3>
<p>밈즈샵의 런칭 이후 회사에서는 커머스 프로젝트를 조금 더 확장시키길 원했는데 메타버스 플랫폼 안에 <strong>푸드버스</strong>라는 환경을 만들고, 이 안에 많은 F&amp;B 브랜드를 입점시켜 진정 커머스 플랫폼으로 성장해나가길 원하는 것 같았다. </p>
<p>첫 번째 외주 프로젝트로 협력사 &quot;폴콘&quot;의 쇼핑몰 사이트를 구축해주기로 했다. 기존 밈즈샵 코드를 재활용하기로 했고, UI 변경도 거의 이루어지지 않아 큰 공수 없이 사이트를 런칭할 수 있었다.</p>
<h3 id="10월---11월----푸드버스-스마트오더-앱-제작하기-📲">10월 - 11월   : 푸드버스 스마트오더 앱 제작하기 📲</h3>
<p>브랜드 폴콘의 쇼핑몰 서비스 &quot;폴콘샵&quot;의 런칭 이후 
전국에 위치한 폴콘 카페에서 사용할 수 있는 스마트오더 애플리케이션을 제작해달라는 요청을 받게 되었다. 
(이만하면 SI 아닐까..)</p>
<p>애플리케이션을 제작해야 하는 부담스러운 업무가 웹팀에게 주어졌지만, 
밈즈샵의 코드를 이용해 제작을 진행하기로 해, 첫 삽은 큰 부담없이 수월하게 진행할 수 있었다.</p>
<p>밈즈샵 제작에는 총 6명의 개발자가 함께 협업을 진행했는데 이 서비스를 제작하는데는 겨우 3명, 
나중에는 벤더 측** 어드민 페이지를 담당하는 시니어 한 분과 유저 페이지를 담당하는 나 둘로만 진행**하게 되어 QA 이슈 처리 및 컴포넌트 고도화에 꽤 큰 고생을 하게 되었다. </p>
<p>밈즈샵 제작에는 주로 &quot;마이페이지&quot;, &quot;취소 / 환불&quot; 기능에 집중했지만, 이 시기에는** &quot;본인 인증&quot;, &quot;SNS 회원 로그인 연동&quot;, &quot;결제&quot; 등 커머스 프로젝트의 핵심 비즈니스 로직 대부분에 관여**를 하게 되서 개발 경험을 꽤 많이 축적할 수 있는 시간이 되었다. </p>
<p>다만, 정말 아쉽게도 회사에서 <strong>모종의 이유</strong>를 이유로 더 이상 <strong>clayful</strong>기반의 커머스 프로덕트를 신뢰할 수 없다고 해서, <em><strong>이 프로덕트는 완성은 했으나 런칭까지는 하지 못한 비운의 프로젝트가 되고 말았다.</strong></em> 🥲</p>
<p><a href="https://smartorder.vollkorn.land/vendor/6V63R7436SDY">스마트오더 웹 링크</a></p>
<hr>
<h2 id="✍🏼-어쩌다-보니-커머스-도메인의-한-사이클을-경험">✍🏼 어쩌다 보니 커머스 도메인의 한 사이클을 경험</h2>
<p>정리하고 보니 8개월이 넘는 기간동안 커머스 도메인의 비즈니스 로직 한 사이클을 모두 경험하게 되었다. 
짧은 기간동안 제품을 런칭하는 짜릿한 경험을 맛보기도 했지만,
그것보다 더 짧은 시간에 피땀눈물을 쏟아 제작한 애플리케이션을 엎어야 했던 불운을 경험하기도 했다. </p>
<p>그래도 실력있는 QA, PM, 함께 페어를 맞춘 시니어 개발자 분들 덕분에 
커머스 프로덕트에 대해서 참 많은 고민을 함께 나누고 적용해본 것 같다. </p>
<p>비록 경력적으로는 1년 미만의 주니어 프론트엔드 개발자지만, 
최신 스택 적용에 앞장 서서 가이드를 제시해 시니어 분들에게 작게나마 도움이 된 것도 꽤 인상적인 경험이었다. </p>
<h3 id="그렇다면-이제-어디로-next-step">그렇다면 이제 어디로? Next Step</h3>
<p>아쉽게 엎어진 스마트오더 애플리케이션은 스스로도 틈틈히 시간을 내서 기능을 더 붙여보려고 한다.
웹 팀에게 주어진 시간이 너무 제한적이었고, 앱 자체 기능은 클라이언트 팀의 힘을 빌어 Unity로 구현을 했다. 
하지만, 생각보다 구동이 매끄럽지 못하다는 생각이 들어서 앱 기능 모두 React Native로 전환해서 구현하고 싶다.
퀵 뷰 및 빠른 구매 기능도 적용해보지 못해 아쉬웠는데, 이 부분도 적용해봐야겠다.</p>
<h3 id="강력한-이직동기-형성">강력한 이직동기 형성</h3>
<p>풀스택 서비스 개발 → 헤드리스 커머스 개발 → <code>🧲고도몰 웹 빌더 템플릿 사용</code></p>
<p>결국, 커머스 프로젝트는 위의 단계를 밟아 기존 개발 인력의 리소스를 사용하는 대신 <strong>고도몰</strong> 이라는 쇼핑몰 호스팅 도메인의 템플릿을 구매해 수정 제작하는 것으로 결론이 났다. </p>
<p>고도몰 코드에 모던 스택이 적용되면 좋았을텐데, 서버는 php기반이고 프론트는 <code>jQuery</code>가 사용되고 있어서  경력적으로 웹팀 모두에게 굉장히 우려스러운 상황이다. </p>
<p>(템플릿을 사용하기 때문에, github을 이용할 수도 없고..고도몰 어드민 내 ftp 편집기를 통해 수정을 해야한다. 😇 환장하겠네...)</p>
<p>고모돌 전환 결정은 개발자인 내게 큰 좌절로 다가오고 있고, 현재에도 멘탈을 회복하기 위해 부정적인 생각을 긍정적으로 전환하기 위해 노력하고 있다. 🧘🏿‍♂️</p>
<p><strong>낮은 성장 가능성</strong>이라는 <strong>큰 이직동기</strong>가 생겼기 때문에 채용 플랫폼의 내 상태를 &quot;구직 중&quot;으로 돌려놓고 이력서를 업데이트하고 있다. </p>
<h2 id="아마도-다음-회고를-작성할때는-새로-이직한-직장에-관해-작성을-하게-되지-않을까-👹">아마도, 다음 회고를 작성할때는 새로 이직한 직장에 관해 작성을 하게 되지 않을까..? 👹</h2>
<hr>
]]></description>
        </item>
        <item>
            <title><![CDATA[(React) 커머스 프로젝트 : 결제 버튼 중복 클릭 방지하기]]></title>
            <link>https://velog.io/@yunsungyang-omc/React-%EC%BB%A4%EB%A8%B8%EC%8A%A4-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B2%B0%EC%A0%9C-%EB%B2%84%ED%8A%BC-%EC%A4%91%EB%B3%B5-%ED%81%B4%EB%A6%AD-%EB%B0%A9%EC%A7%80%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yunsungyang-omc/React-%EC%BB%A4%EB%A8%B8%EC%8A%A4-%ED%94%84%EB%A1%9C%EC%A0%9D%ED%8A%B8-%EA%B2%B0%EC%A0%9C-%EB%B2%84%ED%8A%BC-%EC%A4%91%EB%B3%B5-%ED%81%B4%EB%A6%AD-%EB%B0%A9%EC%A7%80%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 21 Nov 2022 15:05:27 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/685da20c-645b-4fc6-8114-9510e15eb7ae/image.png" alt=""></p>
<hr>
<h2 id="유저의-이상-행동을-예측하기란">유저의 이상 행동을 예측하기란</h2>
<p>버그를 유발하는 유저의 &quot;이상행동&quot;을 예측하면서 코드를 작성하기란 여간 쉬운 일이 아니다. 
프로덕트를 제작하기 위한 일정도 빠듯하거니와 &quot;설마 이런 행동을 하겠어?&quot;라는 의심과 확신으로 
예외 처리를 고려하지 않게되는 것이 일반적이기 때문이다.  </p>
<p>1) &quot;결제하기&quot;버튼을 연타한다거나, 
2) PG 창을 띄워놓은 다음 뒤로 가기 버튼을 누른다거나 등의 이상 행동을 하는 유저는 그리 많지 않을 것이다. </p>
<p>하지만, 우리의 프로덕트는 작은 에러조차 허용해서는 안될 것이며, 특히나 &quot;결제&quot; 관련 이슈는 커머스 프로젝트에서 중대한 이슈로 받아들여지기 때문에 반드시 처리해야할 문제였다. </p>
<h2 id="🖱-결제하기-버튼-중복클릭-막기">🖱 결제하기 버튼 중복클릭 막기</h2>
<p>작성되어 있는 코드의 로직은 다음과 같았다. </p>
<pre><code>1) 유저가 &quot;결제하기&quot; 버튼을 누른다.
2) 버튼이 눌리면 onClick 이벤트로 상태 값에 저장된 정보를 바탕으로 checkout api로 요청을 보낸다. 
3) api의 res가 전달되면 data 값을 바탕으로 PG에 전달할 데이터 form을 만드는 함수가 실행된다.
4) form이 만들어지면, 이어서 PG로 form데이터를 전달하면서 PG창을 호출한다.</code></pre><p>흐름으로 보아 문제가 없는 로직같지만, &quot;결제하기&quot;부터 &quot;PG창 호출까지&quot;의 1-2초 간격동안 유저가 버튼을 적게는 두 번부터 많게는 5-6번 정도 클릭할 수 있는 것이 문제였다. (당연히 클릭한 만큼, api 요청이 이루어진 것도 코미디 😪)</p>
<p>이 문제를 해결하기 위해 자료를 서칭해보니, 몇 가지 해결책이 존재했다.</p>
<h3 id="첫-번째-시도--buttonref의-current를-이용해서-버튼을-disabled-처리하기">첫 번째 시도 : ButtonRef의 current를 이용해서 버튼을 disabled 처리하기</h3>
<p>ref의 current 객체는 리렌더링이 되더라도 상태가 초기화되지 않는다는 점을 이용해 버튼 상태를 조작할 수 있다.
버튼을 한 번 클릭한 이후로는 더 이상 클릭되지 않도록 current 객체에 diabled 값을 true로 지정해 처리를 해보았다.</p>
<pre><code class="language-js">  const buttonRef = useRef();
  // onClick 이벤트로 실행될 함수
  const handleCheckout = async () =&gt; {
    buttonRef.current.disabled = true;
    ...
  }

  return (
    &lt;&gt;
       &lt;Button
         ref={buttonRef}
            onClick={handleCheckout}     
        &gt;
   &lt;/&gt;
    ...
  )   
}</code></pre>
<p>👺 하지만, 이렇게 처리를 했을 경우에는 문제가 몇 가지 발생했는데..</p>
<p>만일 유저가 &quot;결제하기&quot;를 의도치 않고 <strong>실수로 버튼을 클릭</strong>했다거나, 결제에 필요한 <strong>데이터를 모두 입력하지 않고 &quot;결제하기&quot;버튼을 클릭했을 때 경고 메시지를 띄우는 등의 로직이 존재할 경우</strong>, 경고 메시지가 한 번 렌더링 된 이후에는 버튼이 정상적으로 작동하지 않는다.  </p>
<p>결국, 유저가 버튼을 &quot;1번만 클릭하게 만드는 것&quot;에는 성공했지만, 
유저의 편의성과 예측성 모두를 잃었기 때문에 결코 훌륭한 선택이었다고 할 수 없다. 다시 문제는 원점으로.. </p>
<h3 id="두-번째-시도--데이터-패칭-라이브러리의-상태값-이용하기">두 번째 시도 : 데이터 패칭 라이브러리의 상태값 이용하기</h3>
<p>올 해 들어 서버 상태 관리 라이브러리인 <strong>React Query</strong>를 아주 잘 이용하고 있다. GET 요청에는 useQuery나 useQueries를, POST나 UPDATE 등의 요청에는 useMutation를 이용해 코드를 직관적으로 작성할 수 있게 되었는데, 제공하는 옵션이 아주 유용해서 다양한 상황에서 요령껏 잘 이용하고 있다. </p>
<p>나는 이번 이슈에 <strong>데이터를 패칭하는 중임을 나타내는 상태값 isLoading</strong>을 이용해 버튼 대신 프로그레스 바를 렌더링한 다음, PG창이 요청되는 찰나의 시간동안에도 유저가 버튼을 더이상 클릭할 수 없도록 <strong>패칭 성공을 나타내는 isSuccess</strong> 또한 이용할 것이다.</p>
<pre><code class="language-js">import { useMutation } from &quot;react-query&quot;;

// 결제관련 hook에 사용될 함수
const checkoutForMe = () =&gt; {
    const mutation = useMutation(async () =&gt; {
      try {
        const res = await cartApi.checkoutForMe({
          type: &quot;order&quot;,
          data: payload,
        });
        setState((state) =&gt; ({
          ...state,
          order: res.data.order,
        }));
        return res.data;
      } catch (err) {
        console.log(&quot;checkout err :&gt;&gt; &quot;, err)
      }
    });
  return mutation;
};

...

  // 결제 관련 페이지

  const { data, mutate, isLoading, error, isSuccess } = checkoutForMe();

  // api로 요청을 보낼 함수
  const createOrder = async () =&gt; {
    try {
      mutate();
    } catch (err) {
      console.log(&quot;checkout err :&gt;&gt; &quot;, err);
    }
  };

  // onClick 이벤트 함수
  const handleCheckout = () =&gt; {
    if (여러가지 예외처리 상황) {
      ...
    }
    createOrder();    
  }

  // api 조회 결과 200 코드를 받으면, 로딩 스피너를 보여주고 다음 폼으로 이동한다.
  if (isLoading) {
    return (
      &lt;FlexBox&gt;
          &lt;CircularProgress /&gt;
      &lt;/FlexBox&gt;
      );
  }

  return (
    ...
    &lt;Button
      onClick={handleCheckout}
      disabled={isSuccess}
    &gt;
    결제 하기
   &lt;/Button&gt;
  )
}</code></pre>
<p>위 코드처럼 처리한 결과 결제하기 버튼을 클릭하면 다음과 같이 작동한다.</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/c864b902-be99-4b93-bfa6-11804aa1c12d/image.gif" alt=""></p>
<p>클릭과 동시에 데이터 패칭 요청이 이뤄져서, loading 상태를 나타내는 프로그레스바가 렌더링되었다가, 패칭이 이뤄지면 결제하기 버튼을 disabled처리하기 때문에 유저는 더 이상 더블 클릭할 수 없게 되는 것이다!</p>
<p>이 외에도 이벤트 자체에 flag 변수를 두어 api 요청을 막는 등의 방법도 존재하지만, 시도 결과 loading 상태를 이용하는 것이 유저의 이상 행동을 방지하면서도 ui도 깔끔하게 처리해줘 가장 성공적인 결과물을 보여주었다.</p>
<hr>
<h2 id="개발은-qa-이후부터">개발은 QA 이후부터</h2>
<p>최근 팀의 프로젝트로 한창동안 진행해온 프로덕트의 릴리즈를 코 앞에 두고 있다. 꽤 오랜 기간동안 개발했지만, 모종의 이유로 헐거운 기획서를 바탕으로 프로젝트를 진행해야 했고, 당연하게도 QA 단계에서 발견되는 갖가지 에러로 버그 처리에 혼신을 다하고 있다. </p>
<p>유저의 이상 행동을 예측하고 테스트를 진행해주시는 QA 여러분들 덕분에 경험치가 팍팍 쌓여간다는 점에서 운이 좋다고 해야할지 😂</p>
<p>내일 혹은 모레 쯤 <strong>&quot;PG 창을 호출하고 뒤로 가기 버튼을 누르는 케이스&quot;</strong>를 처리했던 경험을 작성해봐야겠다. 
이 글이 나와 동일한 문제를 겪고 있는 누군가에게 큰 힘이 되었으면 하는 바라며 이만 글을 마친다. </p>
]]></description>
        </item>
        <item>
            <title><![CDATA[(Next.js) Google Analytics 이식하기]]></title>
            <link>https://velog.io/@yunsungyang-omc/Next.js-Google-Analystics-%EC%9D%B4%EC%8B%9D%ED%95%98%EA%B8%B0</link>
            <guid>https://velog.io/@yunsungyang-omc/Next.js-Google-Analystics-%EC%9D%B4%EC%8B%9D%ED%95%98%EA%B8%B0</guid>
            <pubDate>Mon, 23 May 2022 08:50:21 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/941b4a90-dae2-44ca-ba0b-7c3d902afa91/image.png" alt=""></p>
<hr>
<h2 id="들어서며">들어서며</h2>
<p>Google Analystic 등 웹(앱) 서비스를 이용하는 유저의 행동을 분석하는 마케팅 툴을 팀 프로젝트에 이식하게 되었다. 이중 내가 당담했던 툴들을 Google Analytics와 Google Adsence, 네이버 프리미엄 로그 분석이었다. </p>
<p>이 중 Google의 마케팅 툴을 이식하는 방법에 대해 포스팅을 진행하려 한다. </p>
<hr>
<blockquote>
<h3 id="google-analytics">Google Analytics</h3>
<p>구글 에널리스틱스는 웹(앱)에 대한 방문자의 데이터를 수집해서 분석함으로써 온라인 비즈니스의 성과를 측정하고 개선하는데 사용하는 로그분석 툴이다. </p>
</blockquote>
<h2 id="nextjs-프로젝트에-google-analytics-이식하기">Next.js 프로젝트에 Google Analytics 이식하기</h2>
<p>React 프로젝트에서 GA를 이식할때 많이들 <code>react-ga</code>라이브러리를 사용하고는 한다. 그러나, GA가 업그레이드 되면서 이전 GA의 베이스가 되었던 유니버셜 에널리스틱스 속성을 사용하려면 GA 계정에서 별도 세팅을 해주어야 한다. 이에 따라 GA 추적 코드 형식도 <code>UA-XXXXXXXX-X</code>에서 <code>G-XXXXXXXXXX</code>로 변경되었다.</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/2aa33231-3073-4979-b99b-362214553bd0/image.png" alt=""></p>
<p>유럽권에서 개인정보를 활용한 마케팅에 대해 규제를 진행하고 있어 향후 구글은 기존의 방식을 버리고 새로운 방식으로 마케팅 지원을 할 수 있도록 방향을 전환하고 있다. 따라서, 변화에 대응할 수 있도록 보수적으로 이식을 진행하고자 라이브러리 없이 GA를 이식하는 방향으로 작업을 진행했다. </p>
<h3 id="step-1-env-파일에-ga-추적-코드-복사하기">Step 1. <code>.env</code> 파일에 GA 추적 코드 복사하기</h3>
<p>.env.local</p>
<pre><code>NEXT_PUBLIC_GOOGLE_ANALYTICS=&quot;code&quot;</code></pre><h3 id="step-2-_appjs-파일에-ga-추적-코드-추가하기">Step 2. <code>_app.js</code> 파일에 GA 추적 코드 추가하기</h3>
<p>Next.js에서 <head> 태그나 <body> 태그 안에 들어갈 내용들을 커스텀할 때는 주로 <code>_document.js</code>파일을 사용한다. 아마 대부분 포스팅에서도 <head> 엘리먼트에 GA 관련 코드를 이식하기 위해  <code>_document.js</code>를 커스텀 하고 있을 것이다. </p>
<p>하지만, <a href="https://github.com/vercel/next.js/tree/canary/examples/with-google-analytics">vercel/Next.js</a>의 샘플 코드를 확인하면 <code>_app.js</code>에서 커스텀을 진행하고 있다.</p>
<p>직접 프로젝트에서 <code>_document.js</code>파일에 이식을 진행해보면 린트에서 다음과 같이 경고하는 것을 확인할 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/8b27e2e4-18d2-46a3-aeb8-029f4bca6727/image.png" alt=""></p>
<p>아마 이것은 Next 11이 릴리즈 되고나서  추가된 next/Script 때문인것으로 보이는데, Next.js 공식 홈페이지는 이에 관해서 이렇게 설명하고 있다. </p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/8dec75e3-43db-4f35-8727-6d103db1d764/image.png" alt=""></p>
<p>Next.js는 SSR(ServerSideRendering) 구현하고 있는 프레임웍이고 서버단에서 실행되는 코드에서 window객체를 참고하는 코드를 실행하면 <code>window is not defined</code>란 에러를 띄운다. </p>
<p><a href="https://velog.io/@yunsungyang-omc/Next.js-%EC%97%90%EB%9F%AC%EB%A1%9C%EA%B7%B8-window-is-not-defined">관련 포스팅 보기</a></p>
<pre><code class="language-js"> // ga 관련 태그  
window.dataLayer = window.dataLayer || [];
  function gtag(){dataLayer.push(arguments);}
  gtag(&#39;js&#39;, new Date());
  gtag(&#39;config&#39;, &#39;${gtag.GA_TRACKING_ID}&#39;, {
    page_path: window.location.pathname,
  });</code></pre>
<p>따라서, 윈도우를 참조하고 있는 코드가 클라이언트 단에서 안정적으로 실행될 수 있도록 개선된 것으로 보인다. </p>
<h3 id="_appjs와-nextscript"><code>_app.js</code>와 next/Script</h3>
<p>vercel에서 제안하는 샘플 코드는 아래와 같다. </p>
<pre><code class="language-js">import { useEffect } from &#39;react&#39;
import Script from &#39;next/script&#39;
import { useRouter } from &#39;next/router&#39;
import * as gtag from &#39;../lib/gtag&#39;

const App = ({ Component, pageProps }) =&gt; {
  const router = useRouter()

  return (
    &lt;&gt;
      {/* Global Site Tag (gtag.js) - Google Analytics */}
      &lt;Script
        strategy=&quot;afterInteractive&quot;
        src={`https://www.googletagmanager.com/gtag/js?id=${gtag.GA_TRACKING_ID}`}
      /&gt;
      &lt;Script
        id=&quot;gtag-init&quot;
        strategy=&quot;afterInteractive&quot;
        dangerouslySetInnerHTML={{
          __html: `
            window.dataLayer = window.dataLayer || [];
            function gtag(){dataLayer.push(arguments);}
            gtag(&#39;js&#39;, new Date());
            gtag(&#39;config&#39;, &#39;${gtag.GA_TRACKING_ID}&#39;, {
              page_path: window.location.pathname,
            });
          `,
        }}
      /&gt;
      &lt;Component {...pageProps} /&gt;
    &lt;/&gt;
  )
}</code></pre>
<h3 id="nextscript">next/Script</h3>
<p><code>next/Script</code>는 개발자들이 third-party 스크립트들의 로딩 우선순위를 설정할 수 있어 로딩 성능을 향상시킬 수 있다. <code>next/Head</code>를 <code>_document.js</code>에서만 사용할 수 있다면, <code>next/Script</code>는 <code>_app.js</code>에서만 위치시킬 수 있다. </p>
<p>next/Script를 어떻게 적용시킬 것인지(Strategy)에 대한 옵션 값들은 다음과 같다. </p>
<ul>
<li><p><strong>BeforeInteractive</strong></p>
</li>
<li><p><em>페이지가 상호작용하기 전에 가져오거나 실행되어야 할 필요*</em>가 있는 중요한 스크립트에 사용하는 옵션이다. 이 옵션이 적용된 스크립트들은 초기 HTML에 삽입되어 자체 번들 JavaScript가 실행되기 전에 실행된다. </p>
</li>
<li><p>*<em>AfterInteractive (default) *</em> </p>
</li>
<li><p><em>태그 매니저나 분석툴처럼 해당 페이지의 상호작용 이후에 가져오고 실행되는 스크립트의 경우 사용*</em>한다. 이 스크립트들은 클라이언트 사이드에 삽입되어 hydration(html이 그려지고 난 후 이벤트 핸들러가 추가된 시점) 이후에 실행된다. </p>
</li>
<li><p><strong>lazyOnload</strong>
SNS 챗 같이 유휴 시간동안 기다릴 수 있는 스크립트의 경우 적용한다. </p>
</li>
</ul>
<p>이외에도 다양하게 적용할 수 있도록 지원하고 있다.  <a href="https://nextjs.org/docs/basic-features/script">공식문서 살펴보기</a></p>
<h3 id="step-3-조회수-측정하기">Step 3. 조회수 측정하기</h3>
<pre><code class="language-js">  useEffect(() =&gt; {
    const handleRouteChange = (url) =&gt; {
      gtag.pageview(url)
    }
    router.events.on(&#39;routeChangeComplete&#39;, handleRouteChange)
    router.events.on(&#39;hashChangeComplete&#39;, handleRouteChange)
    return () =&gt; {
      router.events.off(&#39;routeChangeComplete&#39;, handleRouteChange)
      router.events.off(&#39;hashChangeComplete&#39;, handleRouteChange)
    }
  }, [router.events])</code></pre>
<p>위 코드를 <code>_app.js</code>컴포넌트에 위치 시킴으로써 이벤트(유입, 이탈) 유형에 따라 조회수를 측정할 수 있다. </p>
<hr>
<h2 id="bonus--google의-다른-툴-추가-적용하기">Bonus : Google의 다른 툴 추가 적용하기</h2>
<p>만일 GA 뿐만 아니라, 구글 애드센스 등의 다른 툴을 프로젝트에 적용시켜야 한다면 마찬가지로 <code>_app.js</code>에
next/Script 컴포넌트를 사용해 적용할 수 있다. </p>
<p>만일 GoogleAds를 붙여야 한다면, GA가 적용된 코드 최하단에 gtag(&#39;confing&#39;, &#39;Google ADS코드&#39;)를 삽입하면 적용 가능하다. 각 트래킹 코드를 분석해 관련 툴에서 로그를 파싱해 기록한다. </p>
<pre><code class="language-js">&lt;Script
    id=&quot;gtag-init&quot;
    strategy=&quot;afterInteractive&quot;
    dangerouslySetInnerHTML={{
    __html: `
      window.dataLayer = window.dataLayer || [];
      function gtag(){dataLayer.push(arguments);}
      gtag(&#39;js&#39;, new Date());
      gtag(&#39;config&#39;, &#39;${ga.GA_TRACKING_ID}&#39;, {
      page_path: window.location.pathname,
      });
      gtag(&#39;config&#39;, &#39;GOOGLE_ADS_TRACKINGCODE&#39;); &lt;-
      `,
    }}
  /&gt;  </code></pre>
<hr>
<h2 id="마치며">마치며</h2>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/ad30d71d-ea60-456e-a33c-c79b15cdf893/image.jpeg" alt=""></p>
<p>GA는 마케터였던 과거에도 날 애먹이더니, 이번에도 쉽지 않았다. 여러가지 포스팅에서 이전의 방식으로 GA를 적용하도록 안내하고 있어 적잖히 혼동을 주었기 때문이다. </p>
<p>공식 문서에 안내하는 방식 외에도 GA 등 마케팅 툴을 적용하는 방식은 다양하다. 하지만, 향후 보수성과 퍼포먼스를 고려한다면 공식문서가 제안하는 방향으로 이식을 진행하는 것이 합리적인 결정이라는 생각이 들었다. </p>
<p>이번에도 다시 한 번 더 <strong>공식문서의 중요성</strong>을 확인했다. </p>
<hr>
<h2 id="참조--읽어보면-좋은-글들">참조 / 읽어보면 좋은 글들</h2>
<ul>
<li>[Next.js 스크립트 컴포넌트] (<a href="https://themarketer.tistory.com/82">https://themarketer.tistory.com/82</a>)</li>
<li>[Next.js의 _document와 _app에 대하여] (<a href="https://merrily-code.tistory.com/154">https://merrily-code.tistory.com/154</a>)</li>
<li><a href="https://mnxmnz.github.io/nextjs/google-analytics/">[NextJS] Next 프로젝트에서 Google Analytics 적용하기</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[(Next.js) Pre-rendering : getServerSideProps]]></title>
            <link>https://velog.io/@yunsungyang-omc/Next.js-Pre-rendering-getServerSideProps</link>
            <guid>https://velog.io/@yunsungyang-omc/Next.js-Pre-rendering-getServerSideProps</guid>
            <pubDate>Thu, 19 May 2022 09:57:30 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/95e53de6-a0fb-407d-a81c-ea04f4f31cb7/image.png" alt=""></p>
<p>이 포스팅은 <a href="https://velog.io/@yunsungyang-omc/next.js-Pre-rendering-Static-Generation">(Next.js) Pre-rendering : Static Generation</a>와 이어진 글입니다. </p>
<hr>
<h2 id="다시-들어서며">다시 들어서며</h2>
<p>앞서 살펴보았듯이, 정적인 요소를 담은 페이지를 렌더링하기 위해서 <code>getStaticProps</code>를 사용한다는 것을 알게 되었다. 여기서 정적인 요소를 담은 페이지로 마케팅 페이지, 블로그 포스트, 커머스 페이지 중 상품 상세 페이지 등을 말한다. </p>
<p>이어 이번 포스팅에서 알아볼 내용은 <code>getServerSideProps</code>이다. </p>
<h3 id="언제-사용하는-것이-좋을까">언제 사용하는 것이 좋을까?</h3>
<p>이전 포스팅을 통해 알아본 <code>getStaticProps</code>는 빌드 시에 데이터를 가져온다. 때문에 빌드되고 나면 그 데이터는 조작되지 않는다. 따라서, 최신화된 정보를 업데이트해 보여줘야 한다면 <code>getStaticProps</code>보다는 요청할때마다 데이터를 최신화할 수 있는 <code>getServerSideProps</code>를 사용해야 한다. </p>
<p>팀 프로젝트에서 상품 상세페이지를 Next.js로 컨버팅해야할 일이 있었는데 일반적으로 상품 상세 페이지는 <code>getStaticProps</code>를 이용할 것을 권장하고 있었다. 하지만, 해당 페이지에서 고객 리뷰, 세일 적용 가격 등 동적으로 구성해야할 요소들이 존재하고 있어서 <code>getServerSideProps</code>를 이용하기로 결정했다. </p>
<h2 id="getserversideprops와-react-query">getServerSideProps와 React Query</h2>
<p>팀 프로젝트는 상태관리를 위해 <code>redux-toolkit</code>을 사용하고 있다. <code>getServerSideProps</code>에 다른 상태관리 라이브러리를 시범적으로 적용해볼 수 있을 것 같아, 서버 스테이트를 다루는데 탁월한 기능을 제공하는 React Query를 사용해보기로 했다. (경우에 따라서는, vercel에서 제공하는 useSwr도 적용해볼만 할 것 같다.)</p>
<h3 id="nextjs에-react-query-적용하기">Next.js에 React Query 적용하기</h3>
<p>리액트 앱과 마찬가지고 리액트 쿼리도 앱 자체를 Provider로 감싸준 후 context api처럼 context 객체를 앱 전체에서 접근할 수 있도록 사전 작업을 해주어야 한다. </p>
<h4 id="_appjs">_app.js</h4>
<pre><code class="language-js">import { Hydrate, QueryClient, QueryClientProvider } from &#39;react-query&#39;;
import { ReactQueryDevtools } from &#39;react-query/devtools&#39;;

const App = (props) =&gt; {
  const { Component, pageProps } = props;

  const [queryClient] = useState(() =&gt; new QueryClient());

  return (
    &lt;QueryClientProvider client={queryClient}&gt;
      &lt;Hydrate state={pageProps.dehydratedState}&gt;
        &lt;Provider store={store}&gt;
            &lt;CssBaseline /&gt;
            &lt;Component {...pageProps} /&gt;
        &lt;/Provider&gt;
      &lt;/Hydrate&gt;
      &lt;ReactQueryDevtools /&gt;
    &lt;/QueryClientProvider&gt;
  );
};

export default App;</code></pre>
<p>context Provider처럼 <code>QueryClientProvider</code>로 감싸고, 다시 <code>Hydrate</code>로 감싸주었다.
이를 통해 Hydrate(업데이트되거나 패칭된)된 server state를 앱 전반에서 접근할 수 있게 되었다.</p>
<p>여기서 컴포넌트들이 접급하게 될 상태들은 pageProps 객체의 dehyderatedState에 내장된 값들이다. </p>
<h4 id="srcutilhooksusefeeddispatchjs">src/util/hooks/useFeedDispatch.js</h4>
<pre><code class="language-js">import { useDispatch, useSelector } from &#39;react-redux&#39;;
import { useQuery } from &#39;react-query&#39;;

export const useFeedDispatch = () =&gt; {
    const dispatch = useDispatch();

    const fetchFeedList = async (feedOrderType, page) =&gt; {
        try {
            ...
            const { data } = await axios({
                url: `${BASE_URL}/feeds?${queryString}`,
                method: &#39;GET&#39;,
            });
            return data;
        } catch (err) {
            consoloe.log(&#39;err&#39;, err);
        }
    };

    const useFeedList = () =&gt; {
        return useQuery([&#39;feedList&#39;], () =&gt; fetchFeedList());
    };
export { useFeedList, fetchFeedList };</code></pre>
<p>해당 파일에서는 서버 통신으로 데이터를 받아 <code>useQuery</code>를 이용해 &#39;feedList&#39;라는 키를 갖는 쿼리 객체 속에 리스폰스로 넘어온 데이터를 담아줄 것이다. </p>
<h4 id="srcpagesitemidjs">src/pages/item/[id].js</h4>
<pre><code class="language-js">import { dehydrate, QueryClient, useQuery } from &#39;react-query&#39;;
import { fetchFeedList } from &#39;src/util/hooks/useFeedDispatch&#39;;


const FeedDetail = () =&gt; {

      const { isLoading, error, isData } = useQuery(&#39;feedList&#39;, () =&gt;
        fetchFeedList(),
    );

      if (isLoading) return &lt;div&gt;Loading&lt;/div&gt;;
    if (error) return &#39;error has occured&#39;;

    return (
          ...
    )

export default FeedDetail;

export async function getServerSideProps() {
    const queryClient = new QueryClient();

    await queryClient.prefetchQuery(&#39;feedList&#39;, () =&gt;
        fetchFeedList(&#39;SELLS_MOST&#39;, 1),
    );

    return {
        props: {
            dehydrateState: dehydrate(queryClient),
         },
    };
}</code></pre>
<p><code>getStaticProps</code>함수에서는 props에 데이터 객체를 할당해 jsx 컴포넌트의 매개변수로 전달해 사용하고 있지만, <code>getServerSideProps</code>에서 쿼리 객체를 사용할때는 굳이 props를 매개변수로 전달할 필요가 없다. <strong>왜냐하면 이미 <code>_app.js</code>에서 Hydrate 프로바이더로 컴포넌트를 감싸주고 있어 접근이 가능하기 때문이다.</strong></p>
<p>따라서 위처럼 <code>getServerSideProps</code> 함수 내에서 데이터를 받아오는 <code>fetchFeedList</code>함수를 실행 후 <code>props: {dehydrateState: dehydrate(queryClient) }</code>라고 작성만 해주면 jsx 컴포넌트 내부에서 useQuery로 받아온 값들을 사용할 수 있다. </p>
<p><a href="https://prateeksurana.me/blog/mastering-data-fetching-with-react-query-and-next-js/">구동 프로세스 자세히 알아보기 : Mastering data fetching with React Query and Next.js</a></p>
<hr>
<h2 id="마치며">마치며</h2>
<p>만일, 리액트 쿼리를 이용하지 않고 상태관리를  redux로 관리할 경우에는  <strong><em>_app.js</em></strong>에 redux Wrapper로 감싸주는 등의 추가 작업이 역시 필요하다. (redux는 역시 보일러 플레이트를 좋아해..)</p>
<p><a href="https://simsimjae.medium.com/next-redux-wrapper%EA%B0%80-%ED%95%84%EC%9A%94%ED%95%9C-%EC%9D%B4%EC%9C%A0-5d0176209d14">next-redux-wapper에 관해 알아보기</a></p>
<p>리액트 쿼리를 이용할 경우, server state와 client state로 state 관리를 위한 관심사를 분리할 수 있고 패칭된 데이터를 알아서 로컬 캐싱해주는 등의 장점이 있기 때문에 프로젝트에 적용하면 장점이 많을 것이라는 생각이 들었다. </p>
<p>프로젝트에 적용하기 위해 참고한 많은 글들이 타입스크립트를 기준으로 작성되어 있어, 내 경우처럼 리액트와 next.js를 결합한 프로젝트를 진행하는 누군가가 있다면 도움이 되길 바라는 마음을 담아 이 글을 맺는다. </p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/c1646a09-d53e-4864-ac8a-7f1c74552e3d/image.png" alt=""></p>
<hr>
<h3 id="읽어보면-좋은-글들">읽어보면 좋은 글들</h3>
<p><a href="https://react-query.tanstack.com/guides/ssr">리액트 쿼리 공식 가이드</a>
<a href="https://gingerkang.tistory.com/123">[React Query] Next.js + React Query로 무한 스크롤과 SSR 구현하기</a></p>
]]></description>
        </item>
        <item>
            <title><![CDATA[(Next.js) Pre-rendering : Static Generation]]></title>
            <link>https://velog.io/@yunsungyang-omc/next.js-Pre-rendering-Static-Generation</link>
            <guid>https://velog.io/@yunsungyang-omc/next.js-Pre-rendering-Static-Generation</guid>
            <pubDate>Thu, 19 May 2022 08:39:06 GMT</pubDate>
            <description><![CDATA[<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/13568d9e-50e6-489d-aa45-a308dcda6b81/image.png" alt=""></p>
<hr>
<h2 id="들어서며">들어서며</h2>
<p><code>Next.js</code>의 Dynamic Routing은 <code>react-router-dom</code>을 사용하지 않고도 아주 아주 손쉽게 라우팅을 프로젝트에 적용할 수 있다. src 디렉토리에서 page 폴더에 js 파일만 넣어놓으면 알아서 라우팅되기 때문에 next.js를 입문하는 뉴비 개발자들에게 가장 널리 알려진 기능이기도 하다. </p>
<p>하지만 Dynamic Routing만 이용하기 위해 <code>Next.js</code>를 이용한다는 것은 <code>Next.js</code>의 본질을 제대로 이해하지 못한 것이다. </p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/9869bd98-e678-4b6d-aaf8-9960aec114c0/image.png" alt=""></p>
<p><code>Next.js</code>는 서버 사이드 랜더링을 구현하기 위함이지, 손쉽게 라우팅을 구현하기 위한 것이 아니다!
하여, 본 포스팅에서는 <code>Next.js</code>의 주요한 기능인 <strong>getStaticProps</strong>와 <strong>getServerSideProps</strong>에 대해 다뤄보려고 한다.</p>
<h3 id="getstaticsideprops와-getserversideprops">getStaticSideProps와 getServerSideProps</h3>
<p>아주 짧게 요약하면, 이 두 가지 기능을 사용하는 이유는 PreRendering을 적용하기 위함이다. </p>
<h4 id="prerendering">PreRendering</h4>
<p>프리랜더링이란 <code>SSR(ServerSideProps)</code>를 구현하는 Next.js의 가장 큰 특징이다. React로 구성된 웹/앱은 <code>CSR(Client Side Rendering)</code>방식을 사용한다. CSR은 <strong>초기 랜더링 시 자바스크립트 파일을 모두 로드</strong>해오기 때문에, 초기 렌더링의 사용자 경험이 SSR에 비해 좋지 못하다. </p>
<p>때문에 사용자 반응이 중요하고, 검색엔진 최적화(search engine optimization, SEO)가 중요한 도메인에서는 서버 사이드 랜더링이 줄 수 있는 이점이 중요하다. </p>
<p>SSR은 <strong>미리 서버에서 HTML파일을 랜더링해 클라이언트로 전송</strong>해주기 때문에 초기랜더링 속도가 빠르다. 하지만 매번 서버에 요청을 보내서 화면을 받아오기 때문에, 화면 깜빡임이 많아지는 단점 또한 존재한다. </p>
<p>여기서, 프리 랜더링이 빛을 발휘하게 된다. <strong>Next.js는 필요한 화면만큼을 렌더링하고 나머지는 차차 자바스크립트 파일을 받아와 클라이언트 측에 랜더링을 맡기는 절충의 형태</strong>를 가지고 있다. </p>
<blockquote>
<p>보다 쉽게 표현하자면, 처음에는 SSR을 나중에는 CSR 방식을 사용한다는 것이다. 문자 그대로 이해하자! 미리 랜더링하는 것!</p>
</blockquote>
<h4 id="prerendering-적용하기">PreRendering 적용하기</h4>
<p>Next.js는 프리랜더링을 위해 정적 생성 방식과 서버사이드 랜더링 방식을 제공하고, 각각의 경우에 따라 사용할 것을 권장하고 있다. </p>
<h4 id="정적-생성방식-static-generation---getstaticprops">정적 생성방식 (Static Generation - getStaticProps)</h4>
<p>정적 생성 방식은 페이지의 콘텐츠가 외부 데이터에 연동될 때 사용할 수 있는데, 대략 마케팅 페이지, 블로그 포스트, E-커머스의 상품 페이지, term 같은 정적 텍스트가 주가 되는 페이지를 랜더링할때 유용하게 사용할 수 있다.  </p>
<p>내 경우에는 프로젝트에서 &quot;규정&quot;과 관련된 텍스트들을 랜더링할때 사용할 수 있었다.</p>
<pre><code class="language-js">import Term from &quot;src/constants/terms/Term&quot;;
import { Grid } from &quot;@mui/material&quot;;
import { serialize } from &quot;next-mdx-remote/serialize&quot;;
import { MDXRemote } from &quot;next-mdx-remote&quot;;

const ServiceTerms = ({ source }) =&gt; ( // &lt;--- 이 부분을 주목하시라
  &lt;Grid container direction=&quot;column&quot; flexWrap=&quot;nowrap&quot;&gt;
    &lt;Grid container direction=&quot;row&quot;&gt;
      &lt;MDXRemote {...source} /&gt;
    &lt;/Grid&gt;
  &lt;/Grid&gt;
);

export async function getStaticProps() {
  // MDX text - can be from a local file, database, anywhere
  const source = serviceTerm;
  const mdxSource = await serialize(source);
  return { props: { source: mdxSource } };
}

export default ServiceTerms;</code></pre>
<p>자주 바뀌지 않는 규정 텍스트 뭉치를 상수 폴더 안에서 보관하고 있었는데, 이에 대해 마크다운 문법까지 적용해 프리랜더링 하기 위해 Next.js의 공식문서에서 사용하기를 권장하고 있는 <code>next-mdx-remote</code>라이브러리를 사용했다.</p>
<p><code>mdxSource</code>로 정의한 변수를 props의 벨류로 적용 후, 상단의 JSX 함수의 매개변수로 전달해주면 프리랜더링이 아주 간단하게 적용된다. </p>
<p><code>getStaticProps</code>함수는 params를 받아 그에 해당하는 경로(path)도 받아오는데, 캐싱까지 지원되기 때문에 페이지를 이동할 때 굉장히 빠른 렌더링 속도를 보여준다. 만일 데이터를 적용할 필요없이 단순히, path만 얻고 싶다면, <code>getStaticPath</code>를 사용할 수 있다. 하지만 대게는, 경로 뿐만 아니라 이에 연동된 데이터까지 적용하기 때문에 주로 getStaticProps를 사용하게 될 것이다. </p>
<h4 id="sample-code">sample code</h4>
<pre><code class="language-js">const SamplerPost ({ post}) =&gt; {
  ...

}

export async function getStaticPaths() {
  const res = await fetch(&#39;https://.../posts&#39;);
  const posts = await res.json();
  // 원하는 post로 구성된 paths를 pre rendering할 수 있다.  
  const paths = posts.map((post) =&gt; ({
    params: {id: post.id}
  }));
  // { fallback : false }는 이외 다른 경로를 404에러 처리한다는 것을 의미한다.
  return { paths, fallback: false }
}

export async function getStaticProps({params}) {
  const res = await fetch(&#39;https://.../popst/${params.id}&#39;);
  const post = await res.json();

  return { props: {post}}
}

export default SamplePost;</code></pre>
<hr>
<h2 id="더-읽어보면-좋은-글">더 읽어보면 좋은 글</h2>
<ul>
<li><a href="https://nextjs.org/blog/markdown">Next.js에서 마크다운 문법 적용하기</a></li>
</ul>
]]></description>
        </item>
        <item>
            <title><![CDATA[(라이브러리) Strapi : for Headless Commerce]]></title>
            <link>https://velog.io/@yunsungyang-omc/%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-Strapi-for-Headless-Commerce</link>
            <guid>https://velog.io/@yunsungyang-omc/%EB%9D%BC%EC%9D%B4%EB%B8%8C%EB%9F%AC%EB%A6%AC-Strapi-for-Headless-Commerce</guid>
            <pubDate>Wed, 18 May 2022 02:34:10 GMT</pubDate>
            <description><![CDATA[<p><strong>Strapi Node.js 기반 오픈 소스 헤드리스 CMS다.</strong></p>
<blockquote>
<p><strong>CMS(Contents Management System)란?</strong>
DB에 데이터를 전달할 수 있도록 웹 관리자 화면을 제공하고,
웹 관리자에서 내용을 입력하면 CMS가 가진  프론트엔드 UI를 통해 코딩없이 웹사이트를
만들 수 있도록 솔루션을 제공하는 것을 말한다. </p>
</blockquote>
<ul>
<li>Strapi는 Front UI 대신 API 제공해준다. 따라서 Strapi에 Headless가 붙는다. 이렇듯 strapi는 DB, 웹관리자, API까지 쉽게 구축을 도와주는 BFF(Backedn for Frontend)로서 역할을 제공한다. </li>
</ul>
<p>strapi를 설치하면 웹관리자 화면에서 DB 생성 및 REST API를 생성할 수 있다. 
언제든지 API 파일에 액세스할 수 있고 커스텀할 수 있다. 더불어, 고급 필터링, 정렬 및 페이지 매김, 인증 관리 등을 지원한다.  </p>
<hr>
<h3 id="strapi-실행하기">strapi 실행하기</h3>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/f522eccd-e729-454a-b69a-a529c975aa1f/image.png" alt=""></p>
<pre><code>strapi develop</code></pre><h3 id="strapi-가능-간략-요약">strapi 가능 간략 요약</h3>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/7dcb592d-b6d4-4a26-92ed-abb272e485e9/image.png" alt=""></p>
<p>스트라피 로그인</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/db3b652d-7bc7-44a1-9d3b-b4666dbb235f/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/5afb1e44-1f6b-4433-9adc-a3bb1cca24bc/image.png" alt=""></p>
<p>스트라피는 모델링을 쉽게 해주는 워크스페이스를 제공한다. 여기서 컬랙션은 모델을 말한다. </p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/df5b3b63-6cf0-4e00-9891-5b6416ac1792/image.png" alt=""></p>
<p>필요한 필드를 선택해서 만들 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/9e060f88-0150-4459-8f39-22f3c1e7ecb9/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/8ecfffe4-7f7e-42f9-a5c7-9d86a96d0ab6/image.png" alt=""></p>
<p>모델만 생성해도 REST API가 기본적으로 만들어진다.</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/71bd7b17-473b-4265-adcd-caedca82ae08/image.png" alt=""></p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/9b727ae9-1c39-4d13-a7c6-4a59ce5b4a57/image.png" alt=""></p>
<p>원하는 롤도 생성할 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/49fa68cc-7923-4adf-8e18-1b0dc5cba375/image.png" alt=""></p>
<p>어드민 계정으로 데이터도 쉽게 접근이 가능하다.</p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/46b84162-2d72-4d7b-a8b7-77e3d73eaa7e/image.png" alt=""></p>
<p>소셜 로그인도 쉽게 구현할 수 있다. </p>
<p><img src="https://velog.velcdn.com/images/yunsungyang-omc/post/72496153-9ec8-43f8-a202-21eaea1b2646/image.png" alt=""></p>
<p>기본적으로 제공하는 api들이 풍부해서 개발시간을 획기적으로 줄여줄 수 있다. </p>
]]></description>
        </item>
    </channel>
</rss>